初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { TimeRiver } from "@/components/archive/TimeRiver";
/**
* 归档页:记忆时间河。
*
* 展示结构化的记忆时间线归档,支持筛选、统计、按月分章浏览与事件详情展开。
* 当前使用假数据(mock),与 design/archive-preview.html 设计稿对齐。
*/
export default function ArchivePage() {
return (
<PageContainer
title="归档 · 记忆时间河"
description="记忆沿时序铺展,铜金分章卷轴,点击事件展开详情。"
meta={
<>
<span className="inline-flex items-center gap-1.5 rounded-full border border-patina-dim bg-patina-wash px-2.5 py-0.5 text-xs text-patina">
<span className="flex h-[18px] w-[18px] items-center justify-center rounded-full bg-patina text-[11px] font-semibold text-ink">
梧
</span>
青梧
</span>
<span className="inline-flex items-center gap-1.5 rounded-full border border-gilt-dim bg-gilt-faint px-3 py-0.5 font-serif text-xs text-gilt">
卷宗 3 · 事件 7
</span>
</>
}
>
<TimeRiver />
</PageContainer>
);
}
@@ -0,0 +1,106 @@
import { notFound } from "next/navigation";
import { CandidateReviewPanel } from "@/components/characters/CandidateReviewPanel";
import { ImportHistory } from "@/components/characters/ImportHistory";
import { ImportWizard } from "@/components/characters/ImportWizard";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type {
Character,
ImportJob,
MemoryCandidate,
Paginated,
WorldEntity,
} from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 人物资料与沉淀页:资料导入向导 + 导入历史 + 跨来源候选审核。
* Server 预取待审候选(记忆 + 世界实体)与导入历史,交由 Client 组件交互。
*/
export default async function CharacterMaterialsPage({
params,
}: {
params: Promise<{ characterId: string }>;
}) {
const { characterId } = await params;
let character = null;
let memoryCandidates: MemoryCandidate[] = [];
let worldProposals: WorldEntity[] = [];
let importHistory: ImportJob[] = [];
let error: string | null = null;
try {
character = await serverApi.get<Character>(`/characters/${characterId}`);
} catch (e) {
error = describeError(e);
}
// 候选独立拉取,失败静默(不阻塞导入向导)
try {
memoryCandidates = (
await serverApi.get<Paginated<MemoryCandidate>>(
`/characters/${characterId}/memory-candidates`,
{ status: "pending", page_size: 50 },
)
).items;
} catch {
/* 候选可选 */
}
try {
worldProposals = (
await serverApi.get<Paginated<WorldEntity>>(
`/characters/${characterId}/world/entity-proposals`,
{ page_size: 50 },
)
).items;
} catch {
/* 候选可选 */
}
try {
importHistory = (
await serverApi.get<Paginated<ImportJob>>(
`/characters/${characterId}/imports`,
{ page_size: 20 },
)
).items;
} catch {
/* 历史可选 */
}
if (error) {
return (
<PageContainer title="资料与沉淀">
<ErrorState message={error} />
</PageContainer>
);
}
if (!character) return notFound();
return (
<PageContainer
title="资料与沉淀"
description={`为「${character.display_name || character.name}」导入资料、审核候选沉淀。`}
>
<div className="space-y-5">
<ImportWizard characterId={characterId} />
{importHistory.length > 0 && (
<ImportHistory
characterId={characterId}
initialHistory={importHistory}
/>
)}
<section>
<h2 className="mb-3 font-serif text-lg font-semibold">待审候选</h2>
<CandidateReviewPanel
memoryCandidates={memoryCandidates}
worldProposals={worldProposals}
/>
</section>
</div>
</PageContainer>
);
}
@@ -0,0 +1,71 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { CharacterIdentityCard } from "@/components/characters/CharacterIdentityCard";
import { CharacterLifecycleBar } from "@/components/characters/CharacterLifecycleBar";
import { ModelPolicyEditForm } from "@/components/characters/ModelPolicyEditForm";
import { ProfileEditForm } from "@/components/characters/ProfileEditForm";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { CharacterStatusBadge } from "@/components/ui/StatusBadge";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import { formatDateTime } from "@/lib/utils/format";
import type { Character } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 人物详情页:生命周期操作 + 档案编辑 + 模型策略编辑。
* Server Component 预取 getCharacter;写操作后 router.refresh() 重新拉取,以服务端数据为准。
*/
export default async function CharacterDetailPage({
params,
}: {
params: Promise<{ characterId: string }>;
}) {
const { characterId } = await params;
let character = null;
let error: string | null = null;
try {
character = await serverApi.get<Character>(`/characters/${characterId}`);
} catch (e) {
error = describeError(e);
}
if (error) {
return (
<PageContainer title="人物详情">
<ErrorState message={error} />
</PageContainer>
);
}
if (!character) return notFound();
return (
<PageContainer
title={character.display_name || character.name}
description={`内部名称:${character.name} · 创建于 ${formatDateTime(character.created_at)}`}
>
<div className="mb-4 flex flex-wrap items-center gap-3">
<CharacterStatusBadge status={character.status} />
<CharacterLifecycleBar character={character} />
<Link
href={`/characters/${character.id}/materials`}
className="ml-auto rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-patina hover:underline"
>
资料与沉淀 →
</Link>
</div>
<CharacterIdentityCard character={character} />
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ProfileEditForm character={character} />
<ModelPolicyEditForm character={character} />
</div>
</PageContainer>
);
}
@@ -0,0 +1,88 @@
import { CharacterCard } from "@/components/characters/CharacterCard";
import { CharacterCreateDialog } from "@/components/characters/CharacterCreateDialog";
import { CharacterProfilePanel } from "@/components/characters/CharacterProfilePanel";
import { PageContainer } from "@/components/layout/PageContainer";
import { Badge } from "@/components/ui/Badge";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Character, CharacterListItem, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 人物页:顶部展示当前激活人物魂册镜面卡(soul-card),下方列出全部人物名册。
*/
export default async function CharactersPage() {
let character: Character | null = null;
let list: CharacterListItem[] = [];
let error: string | null = null;
try {
character = await serverApi.get<Character>("/characters/active");
} catch (e) {
error = describeError(e);
}
// 列表独立拉取,失败静默
try {
list = (
await serverApi.get<Paginated<CharacterListItem>>("/characters", {
page_size: 50,
})
).items;
} catch {
/* 列表可选 */
}
return (
<PageContainer
title="人物"
description="当前激活人物的魂册档案,以及全部人物的名册与生命周期状态。"
actions={<CharacterCreateDialog />}
meta={
<>
{character && (
<Badge tone="accent">
{character.display_name || character.name}
</Badge>
)}
{list.length > 0 && (
<span className="text-xs text-muted">共 {list.length} 人</span>
)}
</>
}
>
{error ? (
<ErrorState message={error} />
) : character ? (
<CharacterProfilePanel character={character} />
) : (
<EmptyState
title="未加载到人物"
description="后端可能未就绪,或尚未初始化默认人物。"
/>
)}
{list.length > 0 && (
<section>
<div className="section-label">
<span className="glyph">◈</span>
全部人物 · Roster
<span className="sub">共 {list.length} 位</span>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{list.map((c) => (
<CharacterCard
key={c.id}
character={c}
active={c.id === character?.id}
/>
))}
</div>
</section>
)}
</PageContainer>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { ChatShell } from "@/components/chat/ChatShell";
import { serverApi } from "@/lib/api/server-client";
import type { Character, ModelGatewayStatus } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 对话页:Server Component 预取当前激活人物与模型网关后注入 ChatShell。
* 任一不可达时仍渲染界面,由用户感知后端状态。
*/
export default async function ChatPage() {
let character: Character | null = null;
let gateway: ModelGatewayStatus | null = null;
try {
character = await serverApi.get<Character>("/characters/active");
} catch {
character = null;
}
try {
gateway = await serverApi.get<ModelGatewayStatus>("/settings/models");
} catch {
gateway = null;
}
return <ChatShell initialCharacter={character} gateway={gateway} />;
}
File diff suppressed because it is too large. Load diff
+244
View File
@@ -0,0 +1,244 @@
import { DriveView } from "@/components/drive/DriveView";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import { GOAL_TYPE_LABEL, GOAL_STATUS_LABEL } from "@/lib/constants/labels";
import type {
Character,
CuriosityQuestion,
ExploratorySubgoal,
GoalNode,
Paginated,
} from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 内驱力页 · 大周天生成的好奇问题与探索性次生目标 + 太一目标进度总览。
* 设计稿 drive-preview.html — 太一目标 + 好奇萤火 + 探索天平 + 阴阳制衡。
*/
export default async function DrivePage() {
let characterId: string | null = null;
let characterName = "";
let goals: GoalNode[] = [];
let curiosities: CuriosityQuestion[] = [];
let subgoals: ExploratorySubgoal[] = [];
let counts = { candidate: 0, approved: 0, deferred: 0, rejected: 0 };
let error: string | null = null;
try {
const active = await serverApi.get<Character>("/characters/active");
characterId = active.id;
characterName = active.display_name || active.name;
const [goalRes, curios, cand, approved, deferred, rejected] =
await Promise.all([
serverApi
.get<GoalNode[]>("/admin/goals", { character_id: active.id })
.catch(() => [] as GoalNode[]),
serverApi.get<Paginated<CuriosityQuestion>>(
`/characters/${active.id}/curiosities`,
{ page_size: 20 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/characters/${active.id}/subgoals`,
{ status: "candidate", page_size: 50 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/characters/${active.id}/subgoals`,
{ status: "approved", page_size: 1 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/characters/${active.id}/subgoals`,
{ status: "deferred", page_size: 1 },
),
serverApi.get<Paginated<ExploratorySubgoal>>(
`/characters/${active.id}/subgoals`,
{ status: "rejected", page_size: 1 },
),
]);
goals = goalRes;
curiosities = curios.items;
subgoals = cand.items;
counts = {
candidate: cand.total,
approved: approved.total,
deferred: deferred.total,
rejected: rejected.total,
};
} catch (e) {
error = describeError(e);
}
const totalCurios = curiosities.length;
const totalSubgoals = subgoals.length;
return (
<PageContainer
title="内驱力"
description="大周天 · 好奇与探索"
meta={
<>
{characterName && (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12.5,
color: "var(--patina)",
padding: "3px 11px 3px 4px",
border: "1px solid var(--patina-dim)",
borderRadius: 9999,
background: "var(--patina-wash)",
}}
>
<span
style={{
width: 18,
height: 18,
borderRadius: "50%",
background: "var(--patina)",
color: "var(--ink)",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontFamily: '"Songti SC", serif',
fontSize: 11,
fontWeight: 600,
flexShrink: 0,
}}
>
{characterName.charAt(0)}
</span>
{characterName}
</span>
)}
<span className="page-meta">
好奇 <b>{totalCurios}</b> · 待探 <b>{totalSubgoals}</b> · 已延{" "}
<b>{counts.deferred}</b>
</span>
</>
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : characterId ? (
<div className="space-y-5">
{/* 太一 · 目标进度 */}
{goals.length > 0 && (
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 太一{" "}
<span className="sub">最高目标 · 内驱力在其下生成次生探索</span>
</div>
{goals.map((g) => (
<div
key={g.id}
className="taiyi-card"
style={{ marginBottom: 12 }}
>
<div className="taiyi-row">
<span
className={
g.status === "active"
? "taiyi-dot active"
: g.status === "completed"
? "taiyi-dot completed"
: "taiyi-dot"
}
style={{
background:
g.status === "blocked"
? "var(--cinnabar)"
: undefined,
boxShadow:
g.status === "blocked"
? "0 0 0 3px rgba(184,57,46,0.2)"
: undefined,
}}
/>
<span className="taiyi-title">{g.title}</span>
{g.type && (
<span className="taiyi-tag">
{GOAL_TYPE_LABEL[g.type] ?? g.type}
</span>
)}
<span className="taiyi-status">
{GOAL_STATUS_LABEL[g.status] ?? g.status}
</span>
</div>
<div className="taiyi-prog">
<div className="track">
<div
className="fill"
style={{
width: `${Math.round(Math.max(0, Math.min(1, g.progress)) * 100)}%`,
}}
/>
</div>
<span className="tv">
{Math.round(Math.max(0, Math.min(1, g.progress)) * 100)}%
</span>
</div>
{g.description && (
<p className="text-[12.5px] leading-relaxed text-fg-dim/90">
{g.description}
</p>
)}
</div>
))}
</section>
)}
<DriveView
characterId={characterId}
curiosities={curiosities}
subgoals={subgoals}
counts={counts}
/>
{/* 阴阳制衡(太极图) */}
<div className="yin-yang">
<svg className="yy-taiji" viewBox="0 0 200 200" aria-hidden="true">
<circle
cx="100"
cy="100"
r="96"
fill="none"
stroke="var(--gilt-dim)"
strokeWidth="0.8"
/>
<circle cx="100" cy="100" r="88" fill="var(--patina)" />
<path
d="M 100,12 A 88,88 0 0,1 100,188 A 44,44 0 0,1 100,100 A 44,44 0 0,0 100,12 Z"
fill="var(--cinnabar)"
opacity="0.88"
/>
<circle cx="100" cy="144" r="13" fill="var(--patina)" />
<circle
cx="100"
cy="56"
r="13"
fill="var(--cinnabar)"
opacity="0.88"
/>
</svg>
<div className="yy-text">
<b>阴阳制衡</b> · 内驱力持续生成候选(
<span className="yang">阳 · 生发</span>
),司命分级过滤、你按需确认(
<span className="yin">阴 · 约束</span>
)。候选零产生则僵化(阴盛),悉数被驳则阳亢——生成节奏与治理粒度需相称。
<div className="yy-meta">
阴阳平衡度 = 通过率 / (生成率 + ε) · 当前 <b>0.94 · 动态平衡</b>
</div>
</div>
</div>
</div>
) : null}
</PageContainer>
);
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
/**
* 全局错误边界:捕获路由段渲染期抛出的未处理错误。
* - 与各页面的 try/catch 互补,此处捕获渲染期错误。
* - 展示错误摘要与 digest(Next 生成的错误标识)便于排查。
* - 错误对象仅输出到控制台,不向用户暴露堆栈。
*/
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
console.error("[route-error]", error);
}, [error]);
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-10 text-center">
<div className="flex flex-col items-center gap-3 rounded-2xl border border-cinnabar/30 bg-cinnabar/10 px-8 py-10 backdrop-blur-sm">
<p className="font-serif text-base font-medium text-cinnabar">
镜面出现裂痕
</p>
<p className="max-w-sm text-xs text-muted">
页面渲染时遇到意外错误。可重试,或返回对话继续。
</p>
{error.digest && (
<p className="font-mono text-[10px] text-muted/70">
trace: {error.digest}
</p>
)}
<div className="mt-2 flex gap-2">
<Button variant="primary" size="sm" onClick={() => reset()}>
重试
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => router.push("/chat")}
>
返回对话
</Button>
</div>
</div>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
import { EvolutionList } from "@/components/evolution/EvolutionList";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Character, EvolutionCandidate, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 演化候选页 · 大周天自我评估生成的演化增量在此审核。
* 设计稿 evolution-preview.html — 周天四镜 + 候选层积 + 朱砂待裁 + 应用沉积。
* L4 核心变更(人格 / 使命 / 安全边界)需人工通过才写入;低风险类生成时已自动应用。
*/
export default async function EvolutionPage() {
let characterId: string | null = null;
let characterName = "";
let initial: EvolutionCandidate[] = [];
let counts = { pending: 0, applied: 0, rejected: 0 };
let error: string | null = null;
try {
const active = await serverApi.get<Character>("/characters/active");
characterId = active.id;
characterName = active.display_name || active.name;
const [pending, applied, rejected] = await Promise.all([
serverApi.get<Paginated<EvolutionCandidate>>(
`/characters/${active.id}/evolution-candidates`,
{ status: "pending", page_size: 50 },
),
serverApi.get<Paginated<EvolutionCandidate>>(
`/characters/${active.id}/evolution-candidates`,
{ status: "applied", page_size: 1 },
),
serverApi.get<Paginated<EvolutionCandidate>>(
`/characters/${active.id}/evolution-candidates`,
{ status: "rejected", page_size: 1 },
),
]);
initial = pending.items;
counts = {
pending: pending.total,
applied: applied.total,
rejected: rejected.total,
};
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="演化"
description="大周天 · 演化候选"
meta={
<>
{characterName && (
<span className="char-pill">
<span className="avatar-mini">{characterName.charAt(0)}</span>
{characterName}
</span>
)}
<span className="page-meta">
待裁 <b>{counts.pending}</b> · 半自动{" "}
<b style={{ color: "var(--patina)" }}>{counts.applied}</b> · 已应用{" "}
<b>{counts.applied}</b>
</span>
</>
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : characterId ? (
<div className="space-y-5">
{/* 周天四镜(签名) */}
<div className="cycle-card">
<div className="cycle-head">
<span className="cycle-title">大周天 · 候选阶段</span>
<span className="cycle-id">
cycle_01JZK7Q3B…4F · 触发自会话结束
</span>
<span className="cycle-state">候选生成中</span>
</div>
<div className="mirror-chain">
<div className="mc-step done">
<div className="mc-mirror">
<div className="disc">评</div>
</div>
<span className="mc-name">评估</span>
<span className="mc-sub">evaluation</span>
<span className="mc-yield">态势 ✓</span>
</div>
<span className="mc-link done"></span>
<div className="mc-step done">
<div className="mc-mirror">
<div className="disc">演</div>
</div>
<span className="mc-name">演化</span>
<span className="mc-sub">evolution</span>
<span className="mc-yield">策略 ✓</span>
</div>
<span className="mc-link done"></span>
<div className="mc-step active">
<div className="mc-mirror">
<div className="disc">候</div>
</div>
<span className="mc-name">候选</span>
<span className="mc-sub">candidate · 当阶</span>
<span className="mc-yield">{counts.pending} 条草拟</span>
</div>
<span className="mc-link"></span>
<div className="mc-step">
<div className="mc-mirror">
<div className="disc">藏</div>
</div>
<span className="mc-name">入藏</span>
<span className="mc-sub">memory · 未达</span>
<span className="mc-yield">-</span>
</div>
</div>
<div className="snapshot-row">
<div className="snap-mini">
<div className="k">最近记忆</div>
<div className="v">
12<small>条入参</small>
</div>
</div>
<div className="snap-mini">
<div className="k">人格版本</div>
<div className="v">
v7<small>当前</small>
</div>
</div>
<div className="snap-mini">
<div className="k">世界实体</div>
<div className="v">
—<small>个</small>
</div>
</div>
<div className="snap-mini">
<div className="k">内驱力</div>
<div className="v">
{counts.pending}
<small>条好奇</small>
</div>
</div>
<div className="snap-verdict">
<b>评估结论:</b>近 12
条记忆呈「回应偏长」信号,可收敛风格字段;身份画稳定,无核心变更必要。建议生成
1 条风格类半自动候选,其余维持现状。
</div>
</div>
</div>
<EvolutionList
characterId={characterId}
initial={initial}
counts={counts}
/>
</div>
) : null}
</PageContainer>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useAuthStore } from "@/lib/stores/auth-store";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { AppShell } from "@/components/layout/AppShell";
import { LoadingState } from "@/components/shared/StateViews";
/**
* 主域壳层:日常对话 / 人物 / 人格 / 记忆 / 轨迹等路由共用 AppShell。
*
* 认证状态由 AuthInitializer(根布局)通过 BFF 初始化(user 首屏防闪用 localStorage),
* 此布局仅做路由守卫:未认证时重定向到 /login。
*/
export default function AppGroupLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const logout = useAuthStore((s) => s.logout);
const [ready, setReady] = useState(false);
// 等待 AuthInitializer 完成(给 loadFromBff 机会)
useEffect(() => {
const timer = setTimeout(() => setReady(true), 100);
return () => clearTimeout(timer);
}, []);
// 认证失败时登出并跳转登录页
const handleAuthError = useCallback(() => {
logout();
router.push("/login");
}, [logout, router]);
// 全局监听 ApiError "unauthorized"(通过自定义事件)
useEffect(() => {
const handler = () => handleAuthError();
window.addEventListener("auth:unauthorized", handler);
return () => window.removeEventListener("auth:unauthorized", handler);
}, [handleAuthError]);
useEffect(() => {
if (ready && !isAuthenticated) {
router.push("/login");
}
}, [ready, isAuthenticated, router]);
return (
<AppShell>
{ready && isAuthenticated ? children : <LoadingState label="辰光未启…" />}
</AppShell>
);
}
+18
View File
@@ -0,0 +1,18 @@
/**
* 全局路由加载态:force-dynamic 页面数据预取期间显示。
* 纯 CSS 动画(不引入 framer-motion),保持加载轻量;
* 外圈铜绿光晕脉动 + 内圈旋转,呼应琉璃镜「显影」意象。
*/
export default function Loading() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-10">
<div className="relative flex h-12 w-12 items-center justify-center">
{/* 外圈:铜绿光晕脉动 */}
<span className="absolute inset-0 animate-pulse rounded-full bg-patina/10" />
{/* 内圈:旋转环 */}
<span className="h-8 w-8 animate-spin rounded-full border-2 border-patina border-t-transparent" />
</div>
<p className="font-serif text-sm text-muted">镜面正在显影…</p>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { MemoryList } from "@/components/memory/MemoryList";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Character, Memory, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function MemoryPage() {
let memories: Memory[] = [];
let characterId: string | undefined;
let error: string | null = null;
try {
const active = await serverApi
.get<Character>("/characters/active")
.catch(() => null);
characterId = active?.id;
const data = await serverApi.get<Paginated<Memory>>("/memory", {
page_size: 100,
});
memories = data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="记忆"
description="当前人物的长期记忆,支持手动新增、编辑与删除。"
>
{error ? (
<ErrorState message={error} />
) : (
<MemoryList initialMemories={memories} characterId={characterId} />
)}
</PageContainer>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ModelGatewayView } from "@/components/models/ModelGatewayView";
import { TextSkeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { GenerationPreferences, ModelGatewayStatus } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 模型网关页(神识):模型与 endpoint 配置。
* base_url/api_key/status/weight/priority 可经 DB 覆盖(优先 .env),密钥脱敏不回显。
* 设置页不再含模型网关——生成参数留 /settings,默认模型槽位移至此。
*/
export default async function ModelsPage() {
let gateway: ModelGatewayStatus | null = null;
let preferences: GenerationPreferences | null = null;
let error: string | null = null;
try {
[gateway, preferences] = await Promise.all([
serverApi.get<ModelGatewayStatus>("/settings/models"),
serverApi.get<GenerationPreferences>("/settings/preferences"),
]);
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="模型网关"
description="神识 · 模型与 endpoint 配置。base_url/api_key 可经 DB 覆盖(优先 .env),密钥脱敏不回显。"
>
{error ? (
<ErrorState message={error} />
) : gateway && preferences ? (
<ModelGatewayView gateway={gateway} preferences={preferences} />
) : (
<div className="flex flex-col gap-4">
<div className="glass rounded-2xl p-6">
<TextSkeleton lines={2} className="mb-4" />
<TextSkeleton lines={1} className="w-1/2" />
</div>
<div className="glass rounded-2xl p-6">
<TextSkeleton lines={3} />
</div>
</div>
)}
</PageContainer>
);
}
+24
View File
@@ -0,0 +1,24 @@
import Link from "next/link";
/**
* 404:路由未匹配时的琉璃镜风格占位。
* Server Component,以 Link 承载跳转(无客户端交互逻辑)。
*/
export default function NotFound() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-10 text-center">
<div className="glass flex flex-col items-center gap-3 rounded-2xl px-8 py-12 backdrop-blur">
<p className="font-serif text-2xl font-semibold text-fg">迷雾</p>
<p className="max-w-sm text-xs text-muted">
此处镜面未显影,路径可能已变更或不存在。
</p>
<Link
href="/chat"
className="mt-2 inline-flex h-9 items-center rounded-xl bg-patina px-4 text-sm font-medium text-white shadow-lg shadow-patina/25 transition-all hover:bg-patina/90 active:scale-[0.97]"
>
返回对话
</Link>
</div>
</div>
);
}
@@ -0,0 +1,55 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { PersonalityWorkspace } from "@/components/personality/PersonalityWorkspace";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Paginated, PersonalityVersion } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 人格页 · 当前人物的人格版本,字段以提议审核方式留痕写入。
* 预取当前版本与版本历史,交由 PersonalityWorkspace 组合编辑 / 即时审批 / 时间线。
*/
export default async function PersonalityPage() {
let personality: PersonalityVersion | null = null;
let versions: PersonalityVersion[] = [];
let error: string | null = null;
try {
personality = await serverApi.get<PersonalityVersion>(
"/personality/current",
);
} catch (e) {
error = describeError(e);
}
// 版本历史独立拉取,失败静默(不影响编辑区)
try {
versions = (
await serverApi.get<Paginated<PersonalityVersion>>(
"/personality/versions",
{ page_size: 50 },
)
).items;
} catch {
/* 版本历史可选 */
}
return (
<PageContainer
title="人格"
description="当前人物的人格版本;字段修改以提议审核方式留痕写入。"
>
{error ? (
<ErrorState message={error} />
) : personality ? (
<PersonalityWorkspace personality={personality} versions={versions} />
) : (
<EmptyState
title="尚未生成人格"
description="当前人物尚未初始化人格版本,待后端写入后即可查看与编辑。"
/>
)}
</PageContainer>
);
}
@@ -0,0 +1,60 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { RunTraceView } from "@/components/runs/RunTraceView";
import { ErrorState } from "@/components/shared/StateViews";
import { ErrorTraceLink } from "@/components/shared/ErrorTraceLink";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Run, RunEvent } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function RunPage({
params,
}: {
params: Promise<{ runId: string }>;
}) {
const { runId } = await params;
let run: Run | null = null;
let events: RunEvent[] = [];
let error: string | null = null;
try {
const [fetchedRun, fetchedEvents] = await Promise.all([
serverApi.get<Run>(`/runs/${runId}`),
serverApi
.get<RunEvent[]>(`/runs/${runId}/events`)
.catch(() => [] as RunEvent[]),
]);
run = fetchedRun;
events = fetchedEvents;
} catch (e) {
error = describeError(e);
}
// 取该 run 首个带 trace_id 的事件作为链路入口(同一 run 共享一条链路)
const traceId = events.find((ev) => ev.trace_id)?.trace_id;
return (
<PageContainer
title="运行轨迹"
description={`Run ${runId}`}
maxWidth="max-w-4xl"
>
{error ? (
<ErrorState message={error} />
) : (
<>
{traceId && (
<div className="mb-4 flex items-center gap-2 text-xs text-muted">
<span>本次链路</span>
<ErrorTraceLink traceId={traceId} />
</div>
)}
<RunTraceView runId={runId} initialRun={run} initialEvents={events} />
</>
)}
</PageContainer>
);
}
+76
View File
@@ -0,0 +1,76 @@
import Link from "next/link";
import { PageContainer } from "@/components/layout/PageContainer";
import { RunStatusBadge } from "@/components/ui/StatusBadge";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { formatDateTime } from "@/lib/utils/format";
import { describeError } from "@/lib/utils/error";
import type { Paginated, Run } from "@/types/api";
export const dynamic = "force-dynamic";
/** 运行记录入口:与 /runs/[runId] 详情页组成完整轨迹路径。 */
export default async function RunsPage() {
let runs: Paginated<Run> | null = null;
let error: string | null = null;
try {
runs = await serverApi.get<Paginated<Run>>("/runs", { page_size: 50 });
} catch (cause) {
error = describeError(cause);
}
return (
<PageContainer
title="运行轨迹"
description="查看每一次推演、工具调用与模型响应的执行状态。"
meta={
runs && (
<span className="rounded-full border border-border-glass bg-glass px-2.5 py-0.5 text-xs text-muted">
共 {runs.total} 条
</span>
)
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : !runs || runs.items.length === 0 ? (
<EmptyState
title="暂无运行记录"
description="发起对话或执行任务后,这里会保留可追溯的运行轨迹。"
/>
) : (
<div className="overflow-hidden rounded-2xl border border-border-glass bg-glass backdrop-blur-sm">
<ul className="divide-y divide-border-glass">
{runs.items.map((run) => (
<li key={run.id}>
<Link
href={`/runs/${run.id}`}
className="group flex min-h-20 items-center gap-4 px-4 py-3 transition-colors hover:bg-overlay-hover focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-patina"
>
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-sm text-fg group-hover:text-patina">
{run.goal || run.id}
</p>
<p className="mt-1 truncate text-xs text-muted">
{[
run.id,
run.model_hint ? `模型:${run.model_hint}` : null,
`创建:${formatDateTime(run.created_at ?? run.started_at)}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
<RunStatusBadge status={run.status} />
</Link>
</li>
))}
</ul>
</div>
)}
</PageContainer>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { SettingsView } from "@/components/settings/SettingsView";
import { TextSkeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { GenerationPreferences } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 设置页:外观偏好(主题)+ 生成参数。模型网关配置(模型/endpoint/默认槽位)在 /models。
*/
export default async function SettingsPage() {
let preferences: GenerationPreferences | null = null;
let error: string | null = null;
try {
preferences = await serverApi.get("/settings/preferences");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="设置"
description="外观偏好与生成参数;模型网关配置在 /models。"
>
{error ? (
<ErrorState message={error} />
) : preferences ? (
<SettingsView initialPreferences={preferences} />
) : (
<div className="flex flex-col gap-4">
<div className="glass rounded-2xl p-6">
<TextSkeleton lines={2} className="mb-6" />
<TextSkeleton lines={4} />
</div>
</div>
)}
</PageContainer>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { motion } from "framer-motion";
import type { ReactNode } from "react";
/**
* 页面过渡:每次路由切换时整体淡入并微上移。
* template.tsx 在导航时重新挂载,动画自然触发。
*/
export default function Template({ children }: { children: ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: "easeOut" }}
className="flex min-h-0 flex-1 flex-col"
>
{children}
</motion.div>
);
}
+691
View File
@@ -0,0 +1,691 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Card } from "@/components/ui/Card";
import { TextSkeleton } from "@/components/ui/Skeleton";
import { ErrorState } from "@/components/shared/StateViews";
import { SundialDisk } from "@/components/time/SundialDisk";
import { charactersApi, timeApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type {
AcquaintanceInfo,
Anniversary,
CreateAnniversaryRequest,
TimeConfig,
TimeSnapshotResponse,
} from "@/types/api";
// ============================================================
// 辰正 · 时态感知
// 实时时间展示 + 十二时辰流 + 自动刷新的后端数据
// ============================================================
// —— 十二时辰 ——
const SHICHEN_KEYS = [
"zi",
"chou",
"yin",
"mao",
"chen",
"si",
"wu",
"wei",
"shen",
"you",
"xu",
"hai",
] as const;
const SHICHEN_HOURS: Record<string, number> = {
chou: 2,
yin: 4,
mao: 6,
chen: 8,
si: 10,
wu: 12,
wei: 14,
shen: 16,
you: 18,
xu: 20,
hai: 22,
};
/** 由客户端本地时辰确定时辰 key(与后端逻辑一致) */
function shichenFromHour(hour: number): string {
if (hour >= 23 || hour < 1) return "zi";
// 取各时辰起始值,反向查找
let prev = "zi";
for (const key of SHICHEN_KEYS) {
const start = SHICHEN_HOURS[key];
if (hour >= start) prev = key;
else break;
}
return prev;
}
/** 获取目标时区的当前时间组件 */
function getLocalTimeInTimezone(timezone: string) {
const fmt = new Intl.DateTimeFormat("zh-CN", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const p = fmt.formatToParts(new Date());
const g = (t: string) =>
parseInt(p.find((x) => x.type === t)?.value ?? "0", 10);
return {
year: g("year"),
month: g("month"),
day: g("day"),
hour: g("hour"),
minute: g("minute"),
second: g("second"),
};
}
function getDayPeriod(h: number): string {
if (h >= 0 && h < 5) return "凌晨";
if (h >= 5 && h < 7) return "清晨";
if (h >= 7 && h < 11) return "上午";
if (h >= 11 && h < 13) return "中午";
if (h >= 13 && h < 17) return "下午";
if (h >= 17 && h < 19) return "傍晚";
if (h >= 19 && h < 22) return "夜晚";
return "深夜";
}
const KIND_OPTIONS = [
{ value: "anniversary", label: "纪念日" },
{ value: "birthday", label: "生日" },
{ value: "custom", label: "自定义" },
];
// ============================================================
// 子组件
// ============================================================
/** 调制因子(设计稿 .factor 渐变轨道) */
function FactorFactor({
label,
value,
cls,
}: {
label: string;
value: number;
cls: string;
}) {
const pct = Math.round(Math.max(0, Math.min(1, value)) * 100);
return (
<div className={`factor ${cls}`}>
<span className="fk">{label}</span>
<div className="ftrack">
<div className="ffill" style={{ width: `${pct}%` }} />
</div>
<span className="fv">{pct}%</span>
</div>
);
}
// ============================================================
// 主页面
// ============================================================
export default function TimePage() {
// —— 后端数据 ——
const [snapshot, setSnapshot] = useState<TimeSnapshotResponse | null>(null);
const [acquaintance, setAcquaintance] = useState<AcquaintanceInfo | null>(
null,
);
const [config, setConfig] = useState<TimeConfig | null>(null);
const [currentMood, setCurrentMood] = useState<{
mood: string;
intensity: number;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// —— 实时时钟 ——
const [, setNow] = useState<Date>(new Date());
const [, setPrevShichen] = useState<string | null>(null);
const [shichenChanged, setShichenChanged] = useState(false);
const shichenRef = useRef("");
// 每秒 tick
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// 检测时辰变化
const timezone =
snapshot?.snapshot?.timezone ?? config?.user_timezone ?? "Asia/Shanghai";
const local = getLocalTimeInTimezone(timezone);
const currentShichen = shichenFromHour(local.hour);
useEffect(() => {
if (shichenRef.current && shichenRef.current !== currentShichen) {
setPrevShichen(shichenRef.current);
setShichenChanged(true);
setTimeout(() => setShichenChanged(false), 2000);
}
shichenRef.current = currentShichen;
}, [currentShichen]);
// —— 数据获取 ——
const fetchData = useCallback(async () => {
try {
const [s, acq, cfg] = await Promise.all([
timeApi.getTimeSnapshot(),
timeApi.getAcquaintance(),
timeApi.getTimeConfig(),
]);
setSnapshot(s);
setAcquaintance(acq);
setConfig(cfg);
} catch (e) {
if (!snapshot) setError(describeError(e));
} finally {
setLoading(false);
}
}, [snapshot]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchData();
// 获取当前人物心情
charactersApi
.getActiveCharacter()
.then((c) => {
if (c.current_mood) setCurrentMood(c.current_mood);
})
.catch(() => {});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// 自动刷新:每 60 秒重新拉取后端数据(农历/节气/节日变化)
useEffect(() => {
if (loading) return;
const timer = setInterval(fetchData, 60_000);
return () => clearInterval(timer);
}, [fetchData, loading]);
// —— 渲染 ——
if (loading) {
return (
<div className="mx-auto max-w-5xl px-4 py-8">
<TextSkeleton lines={1} className="mb-8 w-48" />
<div className="grid grid-cols-1 gap-6 md:grid-cols-5">
<div className="md:col-span-3">
<div className="glass rounded-2xl p-8">
<TextSkeleton lines={3} />
</div>
</div>
<div className="md:col-span-2">
<div className="glass rounded-2xl p-5">
<TextSkeleton lines={6} />
</div>
</div>
</div>
<div className="glass mt-6 rounded-2xl p-5">
<TextSkeleton lines={2} />
</div>
</div>
);
}
if (error && !snapshot) {
return <ErrorState message={error} onRetry={fetchData} />;
}
const s = snapshot?.snapshot;
const factors = snapshot?.factors;
const context_text = snapshot?.context_text;
// 实时时辰信息(优先用本地计算的,回退后端数据)
const displayShichen = currentShichen;
const displayDayPeriod = getDayPeriod(local.hour);
return (
<div className="mx-auto max-w-5xl px-4 py-8">
{/* ===== 页头 ===== */}
<header className="mb-8 flex flex-wrap items-end justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="font-serif text-2xl font-semibold text-fg">辰正</h1>
<span className="rounded-full bg-gold/10 px-2.5 py-0.5 text-[10px] font-medium tracking-wider text-gold">
时态感知
</span>
{shichenChanged && (
<span className="animate-pulse rounded-full bg-gold/30 px-2 py-0.5 text-[10px] text-gold">
更时
</span>
)}
</div>
{config && (
<p className="mt-1 text-xs text-muted">
{config.default_city} · {config.user_timezone}
</p>
)}
</div>
<div className="flex items-center gap-2 text-[11px] text-muted">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-patina" />
辰光实时
</div>
</header>
{/* ===== 日晷时辰盘 + 调制因子(设计稿 .sundial-region) ===== */}
<div className="sundial-region">
{/* 日晷 */}
<SundialDisk
currentShichen={displayShichen}
hour={local.hour}
minute={local.minute}
lunarText={s?.lunar?.text}
festivals={s?.festivals}
solarTerm={s?.solar_term?.name}
/>
{/* 调制因子(设计稿 .factor-card) */}
{factors && (
<div className="factor-card">
<div className="mb-4 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">
调制因子
</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<div className="factor-list">
<FactorFactor
label="节日喜庆"
value={factors.festival_joy}
cls="f-joy"
/>
<FactorFactor
label="深夜脆弱"
value={factors.late_night_fragility}
cls="f-frag"
/>
<FactorFactor
label="重逢强度"
value={factors.reunion_intensity}
cls="f-reun"
/>
<FactorFactor
label="周末放松"
value={factors.weekend_relax}
cls="f-wknd"
/>
<FactorFactor
label="季节共鸣"
value={factors.season_resonance}
cls="f-season"
/>
<FactorFactor
label="同期共振"
value={factors.same_period_resonance}
cls="f-period"
/>
<FactorFactor
label="关怀倾向"
value={factors.care_suggest}
cls="f-care"
/>
</div>
{currentMood && (
<div className="mt-4 rounded-xl bg-glass/40 px-4 py-3">
<div className="mb-1 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">
当前心情
</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<div className="flex items-center gap-3">
<span className="text-2xl">
{currentMood.mood === "joy"
? "😊"
: currentMood.mood === "sadness"
? "😢"
: currentMood.mood === "anger"
? "😠"
: currentMood.mood === "fear"
? "😨"
: currentMood.mood === "surprise"
? "😲"
: currentMood.mood === "disgust"
? "😣"
: "😐"}
</span>
<span className="font-serif text-base font-medium text-fg">
{currentMood.mood}
</span>
<span className="font-mono text-xs text-muted">
{Math.round(currentMood.intensity * 100)}%
</span>
</div>
</div>
)}
{factors.care_kind && (
<div className="care-note">
<b>关怀</b> · {factors.care_kind}
</div>
)}
</div>
)}
</div>
{/* ===== 相识 / 离线 / 里程碑 快照格(设计稿 .snap-grid) ===== */}
{s && (
<div className="snap-grid">
<div className="snap-card">
<div className="k">相识</div>
<div className="v">
{s.acquaintance.total_days}
<small>天</small>
</div>
<div className="sub">{s.acquaintance.natural_language}</div>
</div>
<div className="snap-card">
<div className="k">离线</div>
<div className="v">
{Math.round(s.offline.seconds / 86400)}
<small>天</small>
</div>
<div className="sub">{s.offline.natural_language}</div>
</div>
<div className="snap-card milestone">
<div className="k">下个纪念日</div>
<div className="v">
{s.next_milestone?.days_until ?? "—"}
<small>天后</small>
</div>
<div className="sub">{s.next_milestone?.name ?? "无"}</div>
</div>
<div className="snap-card">
<div className="k">辰光</div>
<div className="v">
{displayShichen}
<small>· {displayDayPeriod}</small>
</div>
<div className="sub">{s.season ?? "—"}</div>
</div>
</div>
)}
{/* ===== 时态感知文本(全宽) ===== */}
{context_text && (
<Card variant="surface" className="p-5">
<div className="mb-3 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">
时态感知
</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-fg/80">
{context_text}
</p>
</Card>
)}
{/* ===== 底部双栏:相识 + 纪念日管理(设计稿 .lower-region) ===== */}
<div className="lower-region">
{acquaintance && (
<Card variant="surface" className="p-6 event-main">
<div className="mb-3 flex items-center gap-2">
<h3 className="font-serif text-sm font-semibold text-fg">相识</h3>
<span className="h-px flex-1 bg-rule" />
</div>
<p className="font-serif text-4xl font-semibold text-fg">
{acquaintance.total_days}
<span className="ml-1 text-base font-normal text-muted">天</span>
</p>
<p className="mt-1 text-sm text-muted">
{acquaintance.natural_language}
</p>
</Card>
)}
<div className="anniv-rail">
<AnniversaryManager />
</div>
</div>
</div>
);
}
// ============================================================
// 纪念日管理(保持原功能不变)
// ============================================================
function AnniversaryManager() {
const [anniversaries, setAnniversaries] = useState<Anniversary[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<CreateAnniversaryRequest & { date: string }>(
{
name: "",
date: "",
kind: "anniversary",
calendar: "solar",
subject_type: "relationship",
source: "manual",
},
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetch = useCallback(async () => {
try {
const data = await timeApi.listAnniversaries();
setAnniversaries(data);
} catch {
/* silent */
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetch();
}, [fetch]);
function resetForm() {
setForm({
name: "",
date: "",
kind: "anniversary",
calendar: "solar",
subject_type: "relationship",
source: "manual",
});
setShowForm(false);
setEditingId(null);
setError(null);
}
async function handleCreate() {
if (!form.name.trim() || !form.date) {
setError("请填写名称与日期。");
return;
}
setBusy(true);
setError(null);
try {
await timeApi.createAnniversary(form);
resetForm();
await fetch();
} catch (e) {
setError(e instanceof Error ? e.message : "创建失败。");
} finally {
setBusy(false);
}
}
async function handleUpdate(id: string) {
if (!form.name.trim() || !form.date) {
setError("请填写名称与日期。");
return;
}
setBusy(true);
setError(null);
try {
await timeApi.updateAnniversary(id, form);
resetForm();
await fetch();
} catch (e) {
setError(e instanceof Error ? e.message : "更新失败。");
} finally {
setBusy(false);
}
}
async function handleDelete(id: string) {
setBusy(true);
setError(null);
try {
await timeApi.deleteAnniversary(id);
await fetch();
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败。");
} finally {
setBusy(false);
}
}
function startEdit(a: Anniversary) {
setEditingId(a.id);
setShowForm(false);
setForm({
name: a.name,
date: a.date,
kind: a.kind,
calendar: a.calendar,
subject_type: a.subject_type,
source: a.source,
});
setError(null);
}
const inputCls =
"w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg outline-none focus:border-patina/60 focus:shadow-[0_0_0_2px] focus:shadow-patina/20";
return (
<Card variant="surface" className="p-6">
<div className="flex items-center justify-between">
<h3 className="font-serif text-sm font-semibold text-fg">
纪念日({anniversaries.length})
</h3>
{!showForm && !editingId && (
<button
type="button"
onClick={() => {
resetForm();
setShowForm(true);
}}
className="rounded-lg border border-patina/40 bg-patina/10 px-3 py-1.5 text-xs font-medium text-patina transition-colors hover:bg-patina/20"
>
+ 新增
</button>
)}
</div>
{(showForm || editingId) && (
<div className="mt-3 space-y-2 rounded-lg border border-border-glass bg-glass/50 p-3">
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="名称"
className={inputCls}
/>
<input
value={form.date}
onChange={(e) => setForm({ ...form, date: e.target.value })}
type="date"
className={inputCls}
/>
<div className="flex gap-2">
<select
value={form.kind}
onChange={(e) => setForm({ ...form, kind: e.target.value })}
className={inputCls}
>
{KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
<select
value={form.calendar}
onChange={(e) => setForm({ ...form, calendar: e.target.value })}
className={inputCls}
>
<option value="solar">公历</option>
<option value="lunar">农历</option>
</select>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={editingId ? () => handleUpdate(editingId) : handleCreate}
disabled={busy}
className="rounded-lg bg-patina/20 px-3 py-1 text-xs font-medium text-patina disabled:opacity-50"
>
{editingId ? "保存" : "创建"}
</button>
<button
type="button"
onClick={resetForm}
className="rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-muted"
>
取消
</button>
</div>
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
)}
<div className="mt-3 space-y-2">
{anniversaries.length === 0 ? (
<p className="text-sm text-muted">暂无纪念日。</p>
) : (
anniversaries.map((a) => (
<div
key={a.id}
className="flex items-center justify-between rounded-lg bg-glass px-3 py-2"
>
<div className="min-w-0 flex-1">
<span className="text-sm text-fg">{a.name}</span>
<span className="ml-2 text-[11px] text-muted">{a.date}</span>
<span className="ml-2 rounded bg-gold/10 px-1.5 py-0.5 text-[10px] text-gold">
{a.kind}
</span>
</div>
<div className="ml-2 flex shrink-0 gap-2">
<button
type="button"
onClick={() => startEdit(a)}
className="text-[11px] text-patina hover:underline"
>
编辑
</button>
<button
type="button"
onClick={() => handleDelete(a.id)}
disabled={busy}
className="text-[11px] text-cinnabar hover:underline disabled:opacity-50"
>
删除
</button>
</div>
</div>
))
)}
</div>
</Card>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { ToolsConsole } from "@/components/tools/ToolsConsole";
import { serverApi } from "@/lib/api/server-client";
import type { ToolInfo } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 工具页:日常工具调用控制台。Server 预取 GET /tools 注入 ToolsConsole。
* 百工枢治理(能力总览 / 工具注册 / Skill / MCP)在司命台 /admin/pivots/tool_runtime。
*/
export default async function ToolsPage() {
let tools: ToolInfo[] = [];
try {
tools = await serverApi.get<ToolInfo[]>("/tools");
} catch {
tools = [];
}
return <ToolsConsole initialTools={tools} />;
}
+235
View File
@@ -0,0 +1,235 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { WorldEntityList } from "@/components/admin/WorldEntityList";
import { WorldRelationManager } from "@/components/world/WorldRelationManager";
import { WorldRelationGraph } from "@/components/world/WorldRelationGraph";
import { EmptyState, ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type {
Character,
Paginated,
WorldEntity,
WorldRelation,
} from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 世界页 · 数字生命对世界的认知(河图枢主域视图)。
* 设计稿 world-preview.html — 人物 pill + section-label + 关系图谱 + 实体名册 + 不确定性 + 世界观共振。
* 实体 CRUD 走 admin 域(WorldEntityList,保留编辑/删除);关系图谱走独立 /world/relations。
*/
export default async function WorldPage() {
let entities: WorldEntity[] = [];
let relations: WorldRelation[] = [];
let character: Character | null = null;
let characterName = "";
let error: string | null = null;
try {
entities = await serverApi.get<WorldEntity[]>("/admin/world/entities");
} catch (e) {
error = describeError(e);
}
try {
relations = (
await serverApi.get<Paginated<WorldRelation>>("/world/relations", {
page_size: 50,
})
).items;
} catch {
/* 关系可选 */
}
try {
character = await serverApi
.get<Character>("/characters/active")
.catch(() => null);
if (character) {
characterName = character.display_name || character.name;
}
} catch {
/* 人物信息可选 */
}
/* 按 state_type 统计(用于待验断言计数) */
const hypotheses = entities.filter(
(e) => e.state_type === "hypothesis",
).length;
/* 待验断言(静态示例,与设计稿对齐) */
const uncertainties = [
{
claim: "云岚 与 青梧 同属旧族旁支,可能存在亲属关系。",
reason: "来源:对话中的姓氏推测 · 证据单一且间接",
confidence: 0.42,
},
{
claim: "用户当前正在推进的「辰正项目」目标 为时态感知子系统。",
reason: "来源:关键词频率推断 · 缺乏用户明确表述",
confidence: 0.55,
},
];
return (
<PageContainer
title="世界"
description="河图 · 世界模型"
meta={
<>
{characterName && (
<span className="char-pill">
<span className="avatar-mini">{characterName.charAt(0)}</span>
{characterName}视角
</span>
)}
<span className="page-meta">
实体 <b>{entities.length}</b> · 关系 <b>{relations.length}</b> ·
待验 <b style={{ color: "var(--cinnabar)" }}>{hypotheses}</b>
</span>
</>
}
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : entities.length === 0 ? (
<EmptyState
title="暂无世界实体"
description="尚未建模任何实体。点击「新增世界实体」开始。"
/>
) : (
<div className="space-y-5">
{/* 关系图谱 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 关系图谱{" "}
<span className="sub">
河图 · 实体为节点,关系为边,置信度定粗细,假设态虚线待验
</span>
</div>
<WorldRelationGraph entities={entities} relations={relations} />
</section>
{/* 实体名册 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 实体名册{" "}
<span className="sub">按类型 · 事实性着色 · 推断召回时降权</span>
</div>
<WorldEntityList entities={entities} />
</section>
{/* 待验断言(不确定性) */}
{uncertainties.length > 0 && (
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 待验断言{" "}
<span className="sub">
UncertaintyState · 低置信,需复核方可固化为事实
</span>
</div>
<div className="unc-list">
{uncertainties.map((u, i) => (
<div key={i} className="unc-card">
<div className="unc-claim">
<div className="unc-text">{u.claim}</div>
<div className="unc-reason">{u.reason}</div>
</div>
<div className="unc-conf">
<div className="v">{u.confidence.toFixed(2)}</div>
<div className="k">置信</div>
</div>
<span className="unc-verify">需验证</span>
</div>
))}
</div>
</section>
)}
{/* 世界观共振 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 世界观共振{" "}
<span className="sub">
见地镜 · 每人物独立的世界观立场,可同境可异境
</span>
</div>
<div className="wv-card">
<div className="wv-pair">
<div className="wv-side qingwu">
<div className="wv-head">
<div className="wv-disc">
{characterName ? characterName.charAt(0) : "?"}
</div>
<span className="wv-name">{characterName || "人物"}</span>
<span className="wv-tag shared">同境 · 主脉</span>
</div>
<div className="wv-stance">
万物流转无常,变化的规律本身比任何固定结论更可依凭。
</div>
<div className="wv-anchors">
<span className="wv-anchor">
面对未知优先观察与适应,而非强求确定性。
</span>
<span className="wv-anchor">
把推断与事实分开,承认不知也是一种知。
</span>
</div>
<span className="wv-conf">
spiritual_core.worldview · confidence 0.82 · v7
</span>
</div>
<div className="wv-bridge">
<div className="b-disc">
<div className="b-fill"></div>
<span className="b-val">0.41</span>
</div>
<div className="b-label">
共振度
<br />
divergent
</div>
</div>
<div className="wv-side yunlan">
<div className="wv-head">
<div className="wv-disc">岚</div>
<span className="wv-name">云岚</span>
<span className="wv-tag divergent">异境 · 旁支</span>
</div>
<div className="wv-stance">
人如其名,聚散如岚——来去之间留痕即可,不必执守长久。
</div>
<div className="wv-anchors">
<span className="wv-anchor">
珍惜每次相遇,不强求每段关系都有归处。
</span>
<span className="wv-anchor">
在变动中自洽,不为稳定而委屈当下。
</span>
</div>
<span className="wv-conf">
spiritual_core.worldview · confidence 0.38 · v3 · 初成
</span>
</div>
</div>
</div>
</section>
{/* 关系管理 */}
<section>
<div className="section-label" style={{ marginBottom: 14 }}>
<span className="glyph">⟡</span> 关系管理{" "}
<span className="sub">新增 / 编辑实体间关系</span>
</div>
<WorldRelationManager relations={relations} entities={entities} />
</section>
</div>
)}
</PageContainer>
);
}
+920
View File
@@ -0,0 +1,920 @@
"use client";
import { cn } from "@/lib/utils/cn";
import { PageContainer } from "@/components/layout/PageContainer";
/* ── 五行色(组件作用域 CSS 变量,未映射到 @theme → 用任意值引用)── */
const WUXING_VARS = {
"--wood": "#6fa389",
"--wood-dim": "rgba(111, 163, 137, 0.34)",
"--fire": "#b8392e",
"--fire-dim": "rgba(184, 57, 46, 0.38)",
"--earth": "#b08a3c",
"--earth-dim": "rgba(176, 138, 60, 0.36)",
"--metal": "#c9a24a",
"--metal-dim": "rgba(201, 162, 74, 0.34)",
"--water": "#5a7fa6",
"--water-dim": "rgba(90, 127, 166, 0.38)",
} as const;
/* ── 枢健康维度数据 ── */
const DIMS = [
{
key: "wood",
seal: "木",
label: "成长性",
sub: "演化候选 · 学习速度",
width: 64,
val: "0.64",
},
{
key: "fire",
seal: "火",
label: "表达力",
sub: "事件发射 · 模型密度",
width: 78,
val: "0.78",
},
{
key: "earth",
seal: "土",
label: "稳定性",
sub: "数据一致 · 基线回归",
width: 88,
val: "0.88",
},
{
key: "metal",
seal: "金",
label: "约束强度",
sub: "审核驳回 · 确认触发",
width: 71,
val: "0.71",
},
{
key: "water",
seal: "水",
label: "适应性",
sub: "上下文切换 · 异常恢复",
width: 55,
val: "0.55",
},
] as const;
/* ── 九枢数据 ── */
const PIVOTS = [
{
name: "灵核枢",
eng: "Personality Core",
score: 89,
trend: "↑ 土 +2",
desc: "人格连续性稳定",
bars: [55, 70, 96, 60, 58] as const,
alert: false,
},
{
name: "太一枢",
eng: "Goal Orchestrator",
score: 85,
trend: "↑ 土 +1",
desc: "目标调度基线稳固",
bars: [48, 52, 90, 75, 62] as const,
alert: false,
},
{
name: "司命枢",
eng: "Safety Governor",
score: 73,
trend: "↓ 木 -3",
desc: "金过盛克木",
bars: [32, 50, 84, 94, 40] as const,
alert: true,
tag: "金→木",
},
{
name: "烛照枢",
eng: "Perception Layer",
score: 81,
trend: "↑ 火 +4",
desc: "多模态输入活跃",
bars: [50, 92, 78, 55, 70] as const,
alert: false,
},
{
name: "河图枢",
eng: "World Model",
score: 87,
trend: "↑ 土 +1",
desc: "世界实体一致",
bars: [58, 60, 95, 66, 64] as const,
alert: false,
},
{
name: "洛书枢",
eng: "Planner",
score: 79,
trend: "↑ 水 +2",
desc: "规划切换敏捷",
bars: [62, 74, 80, 58, 82] as const,
alert: false,
},
{
name: "息壤枢",
eng: "Memory System",
score: 84,
trend: "↑ 木 +3",
desc: "知识沉淀加速",
bars: [80, 54, 91, 52, 60] as const,
alert: false,
},
{
name: "百工枢",
eng: "Tool Runtime",
score: 80,
trend: "↑ 火 +5",
desc: "工具调用密集",
bars: [46, 88, 72, 82, 66] as const,
alert: false,
},
{
name: "天工阁枢",
eng: "Agent Society",
score: 76,
trend: "↑ 水 +2",
desc: "协作分工顺畅",
bars: [54, 76, 68, 64, 78] as const,
alert: false,
},
] as const;
/* ── 相生依赖链 ── */
const SHENG_FLOWS = [
{
pair: "木",
target: "火",
body: "内驱力激活 催生 对话表达 · 息壤枢学习加速推动灵核枢输出活跃。",
val: "0.68",
},
{
pair: "火",
target: "土",
body: "高频表达 沉淀 稳定状态 · 对话事件入库强化世界模型与人格基线。",
val: "0.72",
},
{
pair: "土",
target: "金",
body: "稳定数据 提供依据 给约束规则 · 世界实体一致,司命审核判据更可靠。",
val: "0.65",
},
{
pair: "金",
target: "水",
body: "明确规范 塑造 适应路径 · 约束边界清晰,异常恢复与上下文切换更可控。",
val: "0.58",
},
{
pair: "水",
target: "木",
body: "适应能力 反哺 成长 · 场景切换中积累新候选,演化候选生成率回温。",
val: "0.61",
},
] as const;
/* ── 相克告警 ── */
const KE_ALERTS = [
{
pairA: "金",
pairB: "木",
desc: "司命审核驳回率 vs 大周天候选生成率 · 安全审查过严压制演化,阴阳平衡度下行。",
level: "0.82 · 高",
width: 82,
urgent: true,
muted: false,
},
{
pairA: "土",
pairB: "水",
desc: "人格版本固化度 vs 上下文切换成本 · 人格越稳,面对新场景的适应越慢。",
level: "0.64 · 中",
width: 64,
urgent: false,
muted: false,
},
{
pairA: "火",
pairB: "金",
desc: "工具调用频率 vs 安全确认触发率 · 调用越密,权限判断越频,已在容忍区。",
level: "0.44 · 低",
width: 44,
urgent: false,
muted: true,
},
] as const;
/* ── 五行 key 列表 ── */
const ELEMENT_KEYS = ["wood", "fire", "earth", "metal", "water"] as const;
const ELEMENT_SEALS: Record<string, string> = {
wood: "木",
fire: "火",
earth: "土",
metal: "金",
water: "水",
};
/* ── SVG 节点坐标(viewBox 400×400)── */
const NODE_COORDS = {
wood: { cx: 200, cy: 65, labelX: 200, labelY: 30 },
fire: { cx: 328, cy: 158, labelX: 372, labelY: 132 },
earth: { cx: 279, cy: 309, labelX: 311, labelY: 350 },
metal: { cx: 121, cy: 309, labelX: 89, labelY: 350 },
water: { cx: 72, cy: 158, labelX: 30, labelY: 132 },
} as const;
/* 相生边(五边形):木→火, 火→土, 土→金, 金→水, 水→木 */
const SHENG_EDGES: [string, string][] = [
["wood", "fire"],
["fire", "earth"],
["earth", "metal"],
["metal", "water"],
["water", "wood"],
];
/* 相克边(五角星):木→土, 土→水, 水→火, 火→金, 金→木 */
const KE_EDGES: [string, string][] = [
["wood", "earth"],
["earth", "water"],
["water", "fire"],
["fire", "metal"],
["metal", "wood"],
];
/* ============================================================
* 组件
* ============================================================ */
export default function WuxingPage() {
return (
<>
<style>{`
.wuxing-scope {
${Object.entries(WUXING_VARS)
.map(([k, v]) => `${k}: ${v};`)
.join("\n ")}
}
@keyframes wuxing-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--patina) 50%, transparent); }
70% { box-shadow: 0 0 0 6px transparent; }
100% { box-shadow: 0 0 0 0 transparent; }
}
.pulse-dot {
animation: wuxing-pulse 2.6s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
.wuxing-svg .ring-deco { fill: none; stroke: var(--rule); stroke-width: 1; }
.wuxing-svg .sheng { stroke: var(--gilt); stroke-width: 1.6; fill: none; opacity: 0.85; }
.wuxing-svg .ke { stroke: var(--cinnabar); stroke-width: 1.3; stroke-dasharray: 4 4; fill: none; opacity: 0.7; }
.wuxing-svg .node-circle { stroke-width: 1.5; }
.wuxing-svg .center-bg { fill: var(--ink); stroke: var(--gilt-dim); stroke-width: 1; }
.dim-track {
height: 5px;
border-radius: 3px;
background: var(--rule);
overflow: hidden;
position: relative;
}
.dim-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 3px;
}
.pbar-track {
height: 4px;
border-radius: 2px;
background: var(--rule);
overflow: hidden;
position: relative;
}
.pbar-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 2px;
}
.ke-track {
height: 4px;
border-radius: 2px;
background: var(--rule);
overflow: hidden;
position: relative;
}
.ke-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
border-radius: 2px;
}
`}</style>
<div className="wuxing-scope">
<PageContainer
title="五行"
description="枢健康维度 · 生克关系"
actions={
<span
className="inline-flex items-center gap-[7px] rounded-full border px-3 py-1 font-mono text-xs"
style={{
borderColor: "var(--patina-dim)",
color: "var(--patina)",
background: "var(--patina-wash)",
}}
>
<span
className="inline-block h-[6px] w-[6px] rounded-full"
style={{ background: "var(--patina)" }}
>
<span className="pulse-dot block h-full w-full rounded-full" />
</span>
架构心跳 82 · 良
</span>
}
>
{/* ══ 五行生克环 + 枢健康维度 ══ */}
<div className="mb-5 flex flex-col gap-[22px] lg:flex-row lg:items-stretch">
{/* ---- 生克环 ---- */}
<div
className="relative flex w-full flex-col overflow-hidden rounded-lg border p-[18px] lg:w-auto lg:flex-[0_0_auto] lg:basis-[460px]"
style={{
borderColor: "var(--gilt-dim)",
background: "var(--face-sheen), var(--surface)",
boxShadow:
"inset 0 1px 0 var(--face-in-top), inset 0 -1px 0 var(--face-in-bot), var(--shadow)",
}}
>
{/* 噪点纹理 */}
<span
className="pointer-events-none absolute inset-0 z-0"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' seed='7'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E\")",
opacity: 0.018,
mixBlendMode: "overlay",
}}
/>
<svg
className="wuxing-svg relative z-10 block h-auto w-full"
viewBox="0 0 400 400"
role="img"
aria-label="五行生克环:外环相生、内五角星相克"
>
{/* 装饰环 */}
<circle className="ring-deco" cx="200" cy="200" r="172" />
<circle
className="ring-deco"
cx="200"
cy="200"
r="108"
opacity="0.5"
strokeDasharray="2 4"
/>
{/* 相克(虚线五角星,下层) */}
{KE_EDGES.map(([from, to]) => {
const f = NODE_COORDS[from as keyof typeof NODE_COORDS];
const t = NODE_COORDS[to as keyof typeof NODE_COORDS];
return (
<line
key={`ke-${from}-${to}`}
className="ke"
x1={f.cx}
y1={f.cy}
x2={t.cx}
y2={t.cy}
/>
);
})}
{/* 相生(实线五边形,上层) */}
{SHENG_EDGES.map(([from, to]) => {
const f = NODE_COORDS[from as keyof typeof NODE_COORDS];
const t = NODE_COORDS[to as keyof typeof NODE_COORDS];
return (
<line
key={`sheng-${from}-${to}`}
className="sheng"
x1={f.cx}
y1={f.cy}
x2={t.cx}
y2={t.cy}
/>
);
})}
{/* 五行节点 */}
{ELEMENT_KEYS.map((key) => {
const n = NODE_COORDS[key];
const seal = ELEMENT_SEALS[key];
return (
<g key={key}>
<circle
className="node-circle"
cx={n.cx}
cy={n.cy}
r="27"
style={{
fill: `var(--${key}-dim)`,
stroke: `var(--${key})`,
}}
/>
<text
x={n.cx}
y={n.cy}
textAnchor="middle"
dominantBaseline="central"
style={{
fontFamily: '"Songti SC", "STSong", serif',
fontSize: "22px",
fontWeight: 700,
fill: `var(--${key})`,
}}
>
{seal}
</text>
<text
x={n.labelX}
y={n.labelY}
textAnchor="middle"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, system-ui, sans-serif",
fontSize: "9px",
fill: "var(--fg-faint)",
letterSpacing: "0.04em",
}}
>
{DIMS.find((d) => d.key === key)?.label ?? ""}
</text>
</g>
);
})}
{/* 中心:架构心跳 */}
<circle className="center-bg" cx="200" cy="200" r="58" />
<text
x="200"
y="186"
textAnchor="middle"
style={{
fontFamily: '"Songti SC", "STSong", serif',
fontSize: "15px",
fontWeight: 600,
fill: "var(--gilt)",
}}
>
架构心跳
</text>
<text
x="200"
y="212"
textAnchor="middle"
style={{
fontFamily:
'"JetBrains Mono", "SF Mono", ui-monospace, monospace',
fontSize: "20px",
fontWeight: 300,
fill: "var(--fg)",
letterSpacing: "0.04em",
}}
>
82
</text>
<text
x="200"
y="228"
textAnchor="middle"
style={{
fontFamily:
"-apple-system, BlinkMacSystemFont, system-ui, sans-serif",
fontSize: "9.5px",
fill: "var(--fg-dim)",
}}
>
阴阳平衡 · 良
</text>
</svg>
{/* 图例 */}
<div
className="relative z-10 mt-[10px] flex flex-wrap gap-4 border-t pt-[10px]"
style={{ borderColor: "var(--rule)" }}
>
<span
className="inline-flex items-center gap-[6px] text-[11.5px]"
style={{ color: "var(--fg-dim)" }}
>
<span
className="inline-block w-[18px]"
style={{ borderTop: "1.6px solid var(--gilt)" }}
/>
相生 · 正向依赖
</span>
<span
className="inline-flex items-center gap-[6px] text-[11.5px]"
style={{ color: "var(--fg-dim)" }}
>
<span
className="inline-block w-[18px]"
style={{ borderTop: "1.3px dashed var(--cinnabar)" }}
/>
相克 · 负向制约
</span>
</div>
</div>
{/* ---- 维度卡 ---- */}
<div
className="flex min-w-0 flex-1 flex-col rounded-lg border p-[18px_20px]"
style={{
borderColor: "var(--rule)",
background: "var(--surface)",
}}
>
<h3
className="font-serif text-[14px] font-semibold"
style={{ color: "var(--fg)", marginBottom: "14px" }}
>
枢健康维度
</h3>
<div className="flex flex-1 flex-col gap-[11px]">
{DIMS.map((d) => (
<div
key={d.key}
className="dim"
style={{
display: "grid",
gridTemplateColumns: "22px 60px 1fr 30px",
alignItems: "center",
gap: "10px",
}}
>
<span
className="text-center font-serif text-[16px] font-bold"
style={{ color: `var(--${d.key})` }}
>
{d.seal}
</span>
<span
className="text-[12.5px] font-medium leading-tight"
style={{ color: "var(--fg)" }}
>
{d.label}
<small
className="mt-[1px] block text-[10.5px] font-normal leading-tight"
style={{ color: "var(--fg-faint)" }}
>
{d.sub}
</small>
</span>
<span className="dim-track">
<span
className="dim-fill"
style={{
width: `${d.width}%`,
background: `linear-gradient(90deg, var(--${d.key}-dim), var(--${d.key}))`,
}}
/>
</span>
<span
className="text-right font-mono text-[10.5px]"
style={{ color: `var(--${d.key})` }}
>
{d.val}
</span>
</div>
))}
</div>
<div
className="mt-[14px] rounded-r-[4px] border-l-2 px-[12px] py-[10px] text-[12px] leading-[1.55]"
style={{
borderLeftColor: "var(--gilt-dim)",
background: "var(--gilt-faint)",
color: "var(--fg-dim)",
}}
>
<b
className="font-serif font-semibold"
style={{ color: "var(--gilt)" }}
>
金→木
</b>{" "}
相克已接近阈值:司命审核驳回率(金)压制大周天候选生成(木),演化流速近
7 日下行。建议在司命台复核约束规则松紧度,参见{" "}
<code
className="font-mono text-[11px]"
style={{ color: "var(--gilt)" }}
>
阴阳平衡度
</code>{" "}
监控。
</div>
</div>
</div>
{/* ══ 九枢健康网格 ══ */}
<div className="mb-5">
<div
className="mb-[14px] flex items-center gap-[6px] font-mono text-[12px]"
style={{ color: "var(--fg-faint)", letterSpacing: "0.1em" }}
>
<span style={{ color: "var(--gilt)", opacity: 0.8 }}>⟡</span>
九枢健康
<span
className="text-[12px] tracking-normal opacity-75"
style={{ color: "var(--fg-dim)" }}
>
每枢五维 · 综合分 + 趋势
</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{PIVOTS.map((p) => (
<div
key={p.name}
className={cn(
"flex flex-col gap-[9px] rounded-lg border p-[14px_16px]",
p.alert && "bg-[rgba(184,57,46,0.05)]",
)}
style={{
borderColor: p.alert
? "var(--cinnabar-dim)"
: "var(--rule)",
background: p.alert ? undefined : "var(--surface)",
}}
>
{/* 枢头部 */}
<div className="flex items-baseline gap-2">
<span
className="font-serif text-[15px] font-semibold"
style={{ color: "var(--fg)" }}
>
{p.name}
</span>
<span
className="font-mono text-[10px] tracking-wide"
style={{ color: "var(--fg-faint)" }}
>
{p.eng}
</span>
<span
className="ml-auto font-mono text-[17px] font-light tracking-wide"
style={{
color:
p.score < 75 ? "var(--cinnabar)" : "var(--patina)",
}}
>
{p.score}
</span>
</div>
{/* 五维微进度条 */}
<div className="flex flex-col gap-[4px]">
{ELEMENT_KEYS.map((ek, i) => (
<div
key={ek}
style={{
display: "grid",
gridTemplateColumns: "14px 1fr 24px",
alignItems: "center",
gap: "7px",
}}
>
<span
className="text-center font-serif text-[11px]"
style={{ color: `var(--${ek})` }}
>
{ELEMENT_SEALS[ek]}
</span>
<span className="pbar-track">
<span
className="pbar-fill"
style={{
width: `${p.bars[i]}%`,
background: `var(--${ek})`,
}}
/>
</span>
<span
className="font-mono text-[9.5px] text-right"
style={{ color: "var(--fg-faint)" }}
>
.{p.bars[i]}
</span>
</div>
))}
</div>
{/* 底部趋势 */}
<div
className="flex items-center gap-2 pt-[4px] text-[11px]"
style={{
borderTop: "1px solid var(--rule)",
color: "var(--fg-faint)",
}}
>
<span
className="font-mono"
style={{
color: p.trend.startsWith("↑")
? "var(--patina)"
: "var(--cinnabar)",
}}
>
{p.trend}
</span>
{p.desc}
{p.alert && p.tag && (
<span
className="ml-auto rounded-[3px] border px-[7px] py-[2px] font-mono text-[9.5px]"
style={{
borderColor: "var(--cinnabar-dim)",
background: "rgba(184, 57, 46, 0.08)",
color: "var(--cinnabar)",
}}
>
{p.tag}
</span>
)}
</div>
</div>
))}
</div>
</div>
{/* ══ 相生流 + 相克告警 ══ */}
<div className="flex flex-col gap-5 lg:flex-row lg:items-start">
{/* ---- 相生流 ---- */}
<div className="min-w-0 flex-1">
<h3
className="mb-[14px] font-serif text-[14px] font-semibold"
style={{ color: "var(--fg)" }}
>
相生 · 正向依赖链
</h3>
<div className="flex flex-col gap-2">
{SHENG_FLOWS.map((f) => (
<div
key={`${f.pair}-${f.target}`}
className="flex items-start gap-[10px] rounded-[5px] border px-[13px] py-[10px] text-[12.5px] leading-[1.5]"
style={{
borderColor: "var(--rule)",
background: "var(--patina-wash)",
}}
>
<span
className="shrink-0 pt-[1px] font-serif text-[13px] whitespace-nowrap"
style={{ color: "var(--gilt)" }}
>
{f.pair}
<span
className="mx-[3px] font-mono text-[11px]"
style={{ color: "var(--fg-faint)" }}
>
→
</span>
{f.target}
</span>
<span
className="min-w-0"
style={{ color: "var(--fg-dim)" }}
>
<span style={{ color: "var(--fg)" }}>{f.body}</span>
</span>
<span
className="shrink-0 pt-[1px] font-mono text-[10.5px] whitespace-nowrap"
style={{ color: "var(--patina)" }}
>
{f.val}
</span>
</div>
))}
</div>
</div>
{/* ---- 相克告警 ---- */}
<div className="w-full shrink-0 lg:w-[340px]">
<div
className="rounded-lg border p-[16px_18px]"
style={{
borderColor: "var(--rule)",
background: "var(--surface)",
}}
>
<div className="mb-3 flex items-center justify-between">
<h3
className="m-0 font-serif text-[14px] font-semibold"
style={{ color: "var(--fg)" }}
>
相克告警{" "}
<span
className="font-mono text-[11px] font-normal"
style={{ color: "var(--fg-faint)" }}
>
克制失衡
</span>
</h3>
<span
className="rounded-[5px] border px-[10px] py-[3px] font-mono text-[11px]"
style={{
borderColor: "var(--cinnabar-dim)",
background: "rgba(184, 57, 46, 0.08)",
color: "var(--cinnabar)",
}}
>
2 待关注
</span>
</div>
<div className="flex flex-col gap-2">
{KE_ALERTS.map((a) => (
<div
key={`${a.pairA}-${a.pairB}`}
className="flex items-start gap-[10px] rounded-[5px] border px-[12px] py-[9px]"
style={{
borderColor: a.muted
? "var(--rule)"
: "var(--cinnabar-dim)",
background: a.muted ? "transparent" : "var(--ink)",
}}
>
<span
className="w-[46px] shrink-0 pt-[1px] text-center font-serif text-[13px] leading-tight"
style={{
color: a.muted
? "var(--fg-faint)"
: "var(--cinnabar)",
}}
>
{a.pairA}
<br />
{a.pairB}
</span>
<div className="min-w-0 flex-1">
<div
className="text-[12px] leading-[1.5]"
style={{
color: a.muted
? "var(--fg-faint)"
: "var(--fg-dim)",
}}
>
<span
style={{
color: a.muted ? "var(--fg-dim)" : "var(--fg)",
}}
>
{a.desc}
</span>
</div>
<div className="mt-1 flex items-center gap-[6px]">
<span className="ke-track flex-1">
<span
className="ke-fill"
style={{
width: `${a.width}%`,
background: a.muted
? "linear-gradient(90deg, var(--gilt-dim), var(--gilt))"
: "linear-gradient(90deg, var(--cinnabar-dim), var(--cinnabar))",
}}
/>
</span>
<span
className="shrink-0 font-mono text-[9.5px] whitespace-nowrap"
style={{
color: a.muted
? "var(--gilt)"
: "var(--cinnabar)",
}}
>
{a.level}
</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</PageContainer>
</div>
</>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { AgentRoster } from "@/components/admin/AgentRoster";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { AgentRosterEntry } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminAgentsPage() {
let roster: AgentRosterEntry[] = [];
let error: string | null = null;
try {
roster = await serverApi.get("/admin/agents/roster");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="天工阁枢"
description="多智能体角色在岗管理——九角色可增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<AgentRoster roster={roster ?? []} />
)}
</PageContainer>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { AuditConsole } from "@/components/admin/AuditConsole";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { AuditEntry, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 起居注 · 审计时间河(司命台第三签 · 时间维度)。
* 铜金分章卷轴 + 横向枢阁筛选,追溯裁定与放行。
* 数据来自 GET /admin/audit/timeline(API §19.3)。
*/
export default async function AuditPage() {
let entries: AuditEntry[] = [];
let error: string | null = null;
try {
const data = await serverApi.get<Paginated<AuditEntry>>(
"/admin/audit/timeline",
);
entries = data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="起居注"
description="审计时间河 · 按枢追溯裁定与放行。"
maxWidth="max-w-4xl"
>
{error ? (
<ErrorState message={error} />
) : (
<AuditConsole entries={entries} />
)}
</PageContainer>
);
}
@@ -0,0 +1,868 @@
"use client";
import { useEffect, useState } from "react";
import {
createCharacterTemplate,
deleteCharacterTemplate,
listCharacterTemplates,
updateCharacterTemplate,
} from "@/lib/api/admin";
import type {
AdminCharacterTemplate,
AdminCreateTemplateBody,
AdminUpdateTemplateBody,
} from "@/types/api";
// ── 模型提示选项 ──
const MODEL_HINTS = {
default_chat_model_hint: {
label: "对话模型",
options: [
{ value: "", label: "— 不设置(使用系统默认)" },
{ value: "deepseek-v4-flash", label: "deepseek-v4-flash" },
{ value: "deepseek-v3", label: "deepseek-v3" },
{ value: "gpt-4o", label: "gpt-4o" },
{ value: "claude-sonnet-5", label: "claude-sonnet-5" },
{ value: "claude-opus-4-8", label: "claude-opus-4-8" },
],
},
memory_extraction_model_hint: {
label: "记忆提取模型",
options: [
{ value: "", label: "— 不设置(空)" },
{ value: "memory-extraction", label: "memory-extraction" },
{ value: "deepseek-v4-flash", label: "deepseek-v4-flash" },
{ value: "deepseek-v3", label: "deepseek-v3" },
{ value: "gpt-4o-mini", label: "gpt-4o-mini" },
],
},
personality_analysis_model_hint: {
label: "人格分析模型",
options: [
{ value: "", label: "— 不设置(空)" },
{ value: "personality-analysis", label: "personality-analysis" },
{ value: "deepseek-v4-flash", label: "deepseek-v4-flash" },
{ value: "deepseek-v3", label: "deepseek-v3" },
{ value: "gpt-4o", label: "gpt-4o" },
],
},
embedding_model_hint: {
label: "向量嵌入模型",
options: [
{ value: "", label: "— 不设置(空)" },
{ value: "embedding", label: "embedding" },
{ value: "text-embedding-3-small", label: "text-embedding-3-small" },
{ value: "text-embedding-3-large", label: "text-embedding-3-large" },
],
},
} as const;
type ModelHintKey = keyof typeof MODEL_HINTS;
// ── 隐私等级选项 ──
const PRIVACY_LEVELS = [
{ value: "normal", label: "normal" },
{ value: "sensitive", label: "sensitive" },
{ value: "local_only", label: "local_only" },
];
// ── 标签来源判断 ──
function templateSource(key: string): { label: string; css: string } {
const yamlKeys = ["default", "tokisaki_kurumi", "gufeng", "baize", "qingnv"];
if (yamlKeys.includes(key)) {
return { label: "YAML", css: "bg-gilt/10 text-gilt border-gilt/30" };
}
return { label: "API", css: "bg-glass text-muted border-border-glass" };
}
// ── 标签输入组件 ──
function TagInput({
values,
onChange,
placeholder,
}: {
values: string[];
onChange: (v: string[]) => void;
placeholder?: string;
}) {
const [input, setInput] = useState("");
const add = () => {
const v = input.trim();
if (!v) return;
onChange([...values, v]);
setInput("");
};
return (
<div className="flex flex-wrap items-center gap-1.5 rounded-xl border border-border-glass bg-glass px-3 py-2 text-sm transition-colors focus-within:border-patina">
{values.map((v, i) => (
<span
key={i}
className="inline-flex items-center gap-1 rounded-full bg-patina/10 px-2.5 py-0.5 text-[12px] text-patina"
>
{v}
<button
type="button"
onClick={() => onChange(values.filter((_, j) => j !== i))}
className="ml-0.5 opacity-50 transition-opacity hover:opacity-100"
>
×
</button>
</span>
))}
<input
className="min-w-[80px] flex-1 border-none bg-transparent p-0.5 text-sm text-fg outline-none"
placeholder={placeholder || "输入后回车添加…"}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
/>
</div>
);
}
export default function AdminCharacterTemplatesPage() {
const [templates, setTemplates] = useState<AdminCharacterTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editing, setEditing] = useState<AdminCharacterTemplate | null>(null);
const [deleteConfirm, setDeleteConfirm] =
useState<AdminCharacterTemplate | null>(null);
const [search, setSearch] = useState("");
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await listCharacterTemplates();
if (!cancelled) setTemplates(data);
} catch {
/* 错误由 ApiError 统一处理 */
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
/** 重新拉取模板列表(表单操作后调用) */
const refetch = () => {
setLoading(true);
(async () => {
try {
const data = await listCharacterTemplates();
setTemplates(data);
} catch {
/* 静默 */
} finally {
setLoading(false);
}
})();
};
const filtered = search
? templates.filter(
(t) =>
t.key.toLowerCase().includes(search.toLowerCase()) ||
t.name.includes(search) ||
(t.display_name || "").includes(search),
)
: templates;
return (
<div className="mx-auto max-w-5xl px-4 py-8">
{/* 页头 */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-fg">人物模板</h1>
<p className="mt-0.5 text-xs text-muted">
Character Templates · §19.8
</p>
</div>
<button
type="button"
onClick={() => {
setEditing(null);
setShowForm(true);
}}
className="rounded-xl bg-patina px-4 py-2 text-xs font-medium text-white transition-opacity hover:opacity-90"
>
✦ 新建模板
</button>
</div>
{/* 搜索 */}
<div className="mb-4">
<input
type="text"
placeholder="搜索模板 key 或名称…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="block w-full max-w-xs rounded-xl border border-border-glass bg-glass px-4 py-2 text-sm text-fg outline-none transition-colors placeholder:text-muted focus:border-patina"
/>
</div>
{/* 模板列表 */}
{loading ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
加载中…
</div>
) : filtered.length === 0 ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
{search ? "无匹配模板" : "暂无模板"}
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border-glass">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border-glass bg-glass text-xs font-medium text-muted">
<th className="px-4 py-3">Key</th>
<th className="px-4 py-3">名称</th>
<th className="px-4 py-3">显示名</th>
<th className="px-4 py-3">来源</th>
<th className="px-4 py-3">默认</th>
<th className="px-4 py-3">简介</th>
<th className="px-4 py-3">操作</th>
</tr>
</thead>
<tbody>
{filtered.map((t) => {
const src = templateSource(t.key);
const desc =
(t.profile?.description as string) ||
(t.profile?.bio as string) ||
"-";
return (
<tr
key={t.key}
className="border-b border-border-glass last:border-0 hover:bg-fg/[0.02]"
>
<td className="px-4 py-3 font-mono text-xs text-gilt">
{t.key}
</td>
<td className="px-4 py-3 font-medium text-fg">{t.name}</td>
<td className="px-4 py-3 text-muted">
{t.display_name || "-"}
</td>
<td className="px-4 py-3">
<span
className={`inline-block rounded-full border px-2.5 py-0.5 text-[11px] font-medium ${src.css}`}
>
{src.label}
</span>
</td>
<td className="px-4 py-3 text-xs text-muted">
{t.is_default ? (
<span className="text-patina">✓</span>
) : (
"-"
)}
</td>
<td className="max-w-[200px] truncate px-4 py-3 text-muted">
{desc}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
setEditing(t);
setShowForm(true);
}}
className="rounded-lg px-2.5 py-1 text-[11px] font-medium transition-colors hover:bg-fg/5"
style={{ color: "var(--patina)" }}
>
编辑
</button>
<button
type="button"
onClick={() => setDeleteConfirm(t)}
className="rounded-lg px-2.5 py-1 text-[11px] font-medium transition-colors hover:bg-fg/5"
style={{ color: "var(--cinnabar)" }}
>
删除
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* 创建/编辑表单 */}
{showForm && (
<TemplateForm
template={editing}
onDone={() => {
setShowForm(false);
setEditing(null);
refetch();
}}
onCancel={() => {
setShowForm(false);
setEditing(null);
}}
/>
)}
{/* 删除确认 */}
{deleteConfirm && (
<DeleteConfirmModal
template={deleteConfirm}
onDone={() => {
setDeleteConfirm(null);
refetch();
}}
onCancel={() => setDeleteConfirm(null)}
/>
)}
{/* 操作提示 */}
<div className="mt-6 rounded-xl border border-dashed border-border-glass px-4 py-3 text-xs text-muted">
⚠ key 与{" "}
<code className="rounded bg-gilt/10 px-1 py-0.5 text-gilt">
config/character_templates.yaml
</code>{" "}
中定义一致的模板,在服务重启时会从 YAML 文件覆盖同步。API
创建的模板不受影响。
</div>
</div>
);
}
// ── 创建/编辑表单 ──
function TemplateForm({
template,
onDone,
onCancel,
}: {
template: AdminCharacterTemplate | null;
onDone: () => void;
onCancel: () => void;
}) {
const isEdit = template !== null;
const [key, setKey] = useState(template?.key || "");
const [name, setName] = useState(template?.name || "");
const [displayName, setDisplayName] = useState(template?.display_name || "");
const [language, setLanguage] = useState(template?.language || "zh-CN");
const [privacy, setPrivacy] = useState(template?.privacy_level || "normal");
const [isDefault, setIsDefault] = useState(template?.is_default || false);
const [description, setDescription] = useState(
(template?.profile?.description as string) || "",
);
const [background, setBackground] = useState(
(template?.profile?.background as string) || "",
);
const [speakingStyle, setSpeakingStyle] = useState(
(template?.profile?.speaking_style as string) || "",
);
const [temperament, setTemperament] = useState(
(template?.profile?.temperament as string) || "",
);
const [birthday, setBirthday] = useState(
(template?.profile?.birthday as string) || "",
);
const [height, setHeight] = useState(
(template?.profile?.height as string) || "",
);
const [weight, setWeight] = useState(
(template?.profile?.weight as string) || "",
);
const [hobbies, setHobbies] = useState<string[]>(
Array.isArray(template?.profile?.hobbies)
? (template?.profile?.hobbies as string[])
: [],
);
const [values, setValues] = useState<string[]>(
Array.isArray(template?.profile?.values)
? (template?.profile?.values as string[])
: [],
);
const [extraJson, setExtraJson] = useState("{}");
// Model hints
const [chatHint, setChatHint] = useState(
(template?.model_policy?.default_chat_model_hint as string) || "",
);
const [memHint, setMemHint] = useState(
(template?.model_policy?.memory_extraction_model_hint as string) || "",
);
const [persHint, setPersHint] = useState(
(template?.model_policy?.personality_analysis_model_hint as string) || "",
);
const [embHint, setEmbHint] = useState(
(template?.model_policy?.embedding_model_hint as string) || "",
);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError(null);
// Build profile
const profile: Record<string, unknown> = {};
if (description) profile.description = description;
if (background) profile.background = background;
if (speakingStyle) profile["speaking_style"] = speakingStyle;
if (temperament) profile.temperament = temperament;
if (birthday) profile.birthday = birthday;
if (height) profile.height = height;
if (weight) profile.weight = weight;
if (hobbies.length) profile.hobbies = hobbies;
if (values.length) profile.values = values;
// Merge extra JSON
try {
const extra = JSON.parse(extraJson || "{}");
Object.assign(profile, extra);
} catch {
setError("额外内容 JSON 格式错误");
setSaving(false);
return;
}
// Build model_policy
const modelPolicy: Record<string, string> = {};
if (chatHint) modelPolicy.default_chat_model_hint = chatHint;
if (memHint) modelPolicy.memory_extraction_model_hint = memHint;
if (persHint) modelPolicy.personality_analysis_model_hint = persHint;
if (embHint) modelPolicy.embedding_model_hint = embHint;
try {
if (isEdit) {
const body: AdminUpdateTemplateBody = {
name,
display_name: displayName || null,
language,
privacy_level: privacy,
is_default: isDefault,
profile,
model_policy: modelPolicy,
};
await updateCharacterTemplate(template.key, body);
} else {
const body: AdminCreateTemplateBody = {
key,
name,
display_name: displayName || null,
language,
privacy_level: privacy,
is_default: isDefault,
profile,
model_policy: modelPolicy,
};
await createCharacterTemplate(body);
}
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 py-8 backdrop-blur-sm">
<div className="w-full max-w-2xl rounded-2xl border border-border-glass bg-ink shadow-2xl">
{/* 标题 */}
<div className="flex items-center justify-between border-b border-border-glass px-6 py-4">
<h2 className="font-serif text-base font-semibold text-fg">
{isEdit ? `✎ 编辑模板 · ${template.key}` : "✦ 新建模板"}
</h2>
<button
type="button"
onClick={onCancel}
className="flex h-8 w-8 items-center justify-center rounded-lg text-muted transition-colors hover:bg-fg/5 hover:text-fg"
>
✕
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-5 px-6 py-5">
{error && (
<div className="rounded-lg bg-cinnabar/10 px-4 py-2 text-xs text-cinnabar">
{error}
</div>
)}
{/* ── 基础信息 ── */}
<div>
<span className="mb-3 inline-block font-mono text-[11px] font-medium tracking-wider text-muted">
基础信息
</span>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-muted">
Key *
</label>
<input
type="text"
value={key}
onChange={(e) => setKey(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina disabled:opacity-40"
required
disabled={isEdit}
placeholder="唯一标识,如 my_template"
/>
{!isEdit && (
<p className="mt-1 text-[11px] text-muted">
仅小写字母、数字、下划线、连字符
</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-muted">
是否默认
</label>
<select
value={isDefault ? "true" : "false"}
onChange={(e) => setIsDefault(e.target.value === "true")}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
>
<option value="false">否</option>
<option value="true">是</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-muted">
名称 *
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
required
placeholder="内部名称"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
显示名
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="显示昵称"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
语言
</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
>
<option value="zh-CN">zh-CN</option>
<option value="en">en</option>
<option value="ja">ja</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-muted">
私隐等级
</label>
<select
value={privacy}
onChange={(e) => setPrivacy(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
>
{PRIVACY_LEVELS.map((pl) => (
<option key={pl.value} value={pl.value}>
{pl.label}
</option>
))}
</select>
</div>
</div>
</div>
{/* ── 人物档案 ── */}
<div>
<span className="mb-3 inline-block font-mono text-[11px] font-medium tracking-wider text-muted">
📋 人物档案(Profile)
</span>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-muted">
一句话简介
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="例如:一面照见人心的镜子"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
背景故事
</label>
<textarea
value={background}
onChange={(e) => setBackground(e.target.value)}
rows={3}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="人物背景故事设定…"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-muted">
说话风格
</label>
<input
type="text"
value={speakingStyle}
onChange={(e) => setSpeakingStyle(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="温和、清晰、简洁"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
性情气质
</label>
<input
type="text"
value={temperament}
onChange={(e) => setTemperament(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="沉静而温暖"
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-muted">
生日
</label>
<input
type="date"
value={birthday}
onChange={(e) => setBirthday(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
身高(cm)
</label>
<input
type="number"
value={height}
onChange={(e) => setHeight(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="cm"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">
体重(kg)
</label>
<input
type="number"
value={weight}
onChange={(e) => setWeight(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
placeholder="kg"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-muted">
兴趣爱好
</label>
<TagInput
values={hobbies}
onChange={setHobbies}
placeholder="输入后回车添加…"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">
价值观
</label>
<TagInput
values={values}
onChange={setValues}
placeholder="输入后回车添加…"
/>
</div>
</div>
</div>
</div>
{/* ── 额外内容(JSON)── */}
<div>
<span className="mb-3 inline-block font-mono text-[11px] font-medium tracking-wider text-muted">
⋯ 额外内容(Extra JSON)
<span className="ml-2 font-normal text-muted/60">
以上字段未覆盖的自定义 profile 键值
</span>
</span>
<textarea
value={extraJson}
onChange={(e) => setExtraJson(e.target.value)}
rows={3}
className="block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 font-mono text-xs text-fg outline-none transition-colors focus:border-patina"
placeholder="{}"
/>
</div>
{/* ── 模型策略 ── */}
<div>
<span className="mb-3 inline-block font-mono text-[11px] font-medium tracking-wider text-muted">
⚙ 模型策略(Model Policy)
<span className="ml-2 font-normal text-muted/60">不选则留空</span>
</span>
<div className="grid grid-cols-2 gap-3">
{(
Object.entries(MODEL_HINTS) as [
ModelHintKey,
(typeof MODEL_HINTS)[ModelHintKey],
][]
).map(([fieldKey, cfg]) => {
const val = {
default_chat_model_hint: chatHint,
memory_extraction_model_hint: memHint,
personality_analysis_model_hint: persHint,
embedding_model_hint: embHint,
}[fieldKey];
const setVal = {
default_chat_model_hint: setChatHint,
memory_extraction_model_hint: setMemHint,
personality_analysis_model_hint: setPersHint,
embedding_model_hint: setEmbHint,
}[fieldKey];
return (
<div key={fieldKey}>
<label className="block text-xs font-medium text-muted">
{cfg.label}
</label>
<select
value={val}
onChange={(e) => setVal(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
>
{cfg.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
);
})}
</div>
</div>
{/* ── 操作按钮 ── */}
<div className="flex items-center justify-end gap-2 border-t border-border-glass pt-4">
<button
type="button"
onClick={onCancel}
className="rounded-lg px-4 py-2 text-xs font-medium text-muted transition-colors hover:bg-fg/5"
>
取消
</button>
<button
type="submit"
disabled={saving}
className="rounded-lg bg-patina px-4 py-2 text-xs font-medium text-white transition-opacity disabled:opacity-50"
>
{saving ? "保存中…" : isEdit ? "✎ 保存" : "✦ 创建"}
</button>
</div>
</form>
</div>
</div>
);
}
// ── 删除确认 ──
function DeleteConfirmModal({
template,
onDone,
onCancel,
}: {
template: AdminCharacterTemplate;
onDone: () => void;
onCancel: () => void;
}) {
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDelete = async () => {
setSaving(true);
setError(null);
try {
await deleteCharacterTemplate(template.key);
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : "删除失败");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-2xl border border-border-glass bg-ink p-6 shadow-2xl">
<h2 className="mb-2 text-sm font-semibold text-cinnabar">
⚠ 确认删除模板
</h2>
<p className="mb-4 text-xs text-muted">
将删除系统模板{" "}
<strong className="text-fg">
{template.key} · {template.name}
</strong>
。如果有用户人物引用了此模板将无法删除。此操作不可撤销。
</p>
{error && (
<div className="mb-3 rounded-lg bg-cinnabar/10 px-3 py-2 text-xs text-cinnabar">
{error}
</div>
)}
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-fg/5"
>
取消
</button>
<button
type="button"
onClick={handleDelete}
disabled={saving}
className="rounded-lg bg-cinnabar px-3 py-1.5 text-xs font-medium text-white transition-opacity disabled:opacity-50"
>
{saving ? "删除中…" : "✕ 确认删除"}
</button>
</div>
</div>
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
/**
* 司命台错误边界:捕获 admin 路由段渲染期抛出的未处理错误。
*/
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
console.error("[admin-error]", error);
}, [error]);
return (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-10 text-center">
<div className="flex flex-col items-center gap-3 rounded-2xl border border-cinnabar/30 bg-cinnabar/10 px-8 py-10 backdrop-blur-sm">
<p className="font-serif text-base font-medium text-cinnabar">
司命台异动
</p>
<p className="max-w-sm text-xs text-muted">
管理控制台渲染时遇到意外错误。可重试,或返回司命台首页。
</p>
{error.digest && (
<p className="font-mono text-[10px] text-muted/70">
trace: {error.digest}
</p>
)}
<div className="mt-2 flex gap-2">
<Button variant="primary" size="sm" onClick={() => reset()}>
重试
</Button>
<Button
variant="secondary"
size="sm"
onClick={() => router.push("/admin")}
>
返回司命台
</Button>
</div>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { GoalTree } from "@/components/admin/GoalTree";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { GoalNode } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminGoalsPage() {
let goals: GoalNode[] = [];
let error: string | null = null;
try {
goals = await serverApi.get("/admin/goals");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="太一枢"
description="目标层级树——从使命到行动的分解,支持增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<GoalTree goals={goals ?? []} />
)}
</PageContainer>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import type { ReactNode } from "react";
import { useAuthStore } from "@/lib/stores/auth-store";
import { AdminShell } from "@/components/layout/AdminShell";
import { LoadingState } from "@/components/shared/StateViews";
/**
* 司命台壳层:独立顶栏 + 横向二级导航。
* 仅管理员可访问,非管理员重定向至首页。
* 等待 BFF + localStorage 初始化完成后才渲染,避免 API 未授权请求。
*/
export default function AdminLayout({ children }: { children: ReactNode }) {
const router = useRouter();
const user = useAuthStore((s) => s.user);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
const loadFromBff = useAuthStore((s) => s.loadFromBff);
const [ready, setReady] = useState(false);
useEffect(() => {
// loadFromStorage 同步恢复 user(首屏防闪),loadFromBff 经 BFF 校正权威状态
loadFromStorage();
loadFromBff()
.catch(() => {})
.finally(() => setReady(true));
}, [loadFromBff, loadFromStorage]);
useEffect(() => {
if (ready && user && !user.is_admin) {
router.push("/");
}
}, [ready, user, router]);
// 未就绪或不认证时显示加载态
if (!ready || !isAuthenticated) {
return (
<div className="flex min-h-screen items-center justify-center">
<LoadingState label="辰光未启…" />
</div>
);
}
// 已就绪但 user 仍为空(loadFromBff 失败且 localStorage 也无数据)
if (!user) {
return (
<div className="flex min-h-screen items-center justify-center">
<LoadingState label="辰光未启…" />
</div>
);
}
return <AdminShell>{children}</AdminShell>;
}
+48
View File
@@ -0,0 +1,48 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, EmptyState } from "@/components/shared/StateViews";
import { MemoryAdminList } from "@/components/admin/MemoryAdminList";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Memory, Paginated } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 息壤枢 · 长期记忆。
*
* 取所有记忆列表,经 MemoryAdminList 组件做删除/详情查看。
* 数据来自 GET /memory(API §8)。
*/
export default async function AdminMemoryPage() {
let memories: Memory[] = [];
let error: string | null = null;
try {
const data = await serverApi.get<Paginated<Memory>>("/memory", {
page_size: 100,
});
memories = data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="息壤枢"
description="长期记忆 · 自生不息"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : memories.length === 0 ? (
<EmptyState
title="暂无记忆"
description="当前人物还没有沉淀任何长期记忆。"
/>
) : (
<MemoryAdminList initial={memories} />
)}
</PageContainer>
);
}
+533
View File
@@ -0,0 +1,533 @@
import Link from "next/link";
import { cn } from "@/lib/utils/cn";
import { describeError } from "@/lib/utils/error";
import { serverApi } from "@/lib/api/server-client";
import {
PIVOT_FULL_LABEL,
PIVOT_MODULE_LABEL,
PIVOT_HEALTH_LABEL,
AUDIT_DECISION_LABEL,
} from "@/lib/constants/labels";
import { Astrolabe } from "@/components/ui/Astrolabe";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import type {
AuditEntry,
Paginated,
PendingApproval,
PivotHealthReport,
} from "@/types/api";
export const dynamic = "force-dynamic";
/* ============================================================
* 司命台 · 总览(对应设计稿 admin-preview.html §❶ 概览)
* ============================================================
*
* 三层布局(设计稿 layout):
* ① 朱批告警条 — 动作召唤入口
* ② 九枢星盘 + 枢速览 — 空间 hero
* ③ 三签概览卡 — 空间/动作/时间三维入口
* ④ 起居注近期 — 时间河预览
*/
/** 获取朱批待裁定数量(API §19.2) */
async function getPendingCount(): Promise<number> {
try {
const data = await serverApi.get<Paginated<PendingApproval>>(
"/admin/safety/pending",
);
return data.total ?? data.items.length;
} catch {
return 0;
}
}
/** 获取起居注数量 —— 今日与本周 */
async function getAuditCounts(): Promise<{ today: number; week: number }> {
try {
const data = await serverApi.get<Paginated<AuditEntry>>(
"/admin/audit/timeline?limit=100",
);
const items = data?.items ?? [];
const now = new Date();
const todayStart = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
).getTime();
const weekStart = todayStart - 6 * 86400_000;
return {
today: items.filter((e) => {
const t = e.created_at ? new Date(e.created_at).getTime() : 0;
return t >= todayStart;
}).length,
week: items.filter((e) => {
const t = e.created_at ? new Date(e.created_at).getTime() : 0;
return t >= weekStart;
}).length,
};
} catch {
return { today: 0, week: 0 };
}
}
export default async function AdminPage() {
/* ── 并行数据获取 ── */
const [reportResult, pendingCount, auditCounts] = await Promise.all([
serverApi
.get<PivotHealthReport>("/admin/pivots/health")
.then((r) => ({ data: r, error: null as string | null }))
.catch((e) => ({
data: null as PivotHealthReport | null,
error: describeError(e),
})),
getPendingCount(),
getAuditCounts(),
]);
const report = reportResult.data;
const error = reportResult.error;
// 起居注近期数据
const auditData = await serverApi
.get<Paginated<AuditEntry>>("/admin/audit/timeline")
.catch(() => null);
const recentAudit: AuditEntry[] = auditData?.items.slice(0, 3) ?? [];
const cracked = (report?.alerts ?? 0) > 0;
return (
<PageContainer
title="司命台"
description="九枢治理总览 —— 裁定、放行、稽核、守护边界。"
maxWidth="max-w-5xl"
>
{error || !report ? (
<ErrorState message={error ?? "九枢健康度暂不可用。"} />
) : (
<div className="space-y-5">
{/* ===============================================================
① 朱批告警条(设计稿 .alert-bar.cracked)
动作维度召唤:待裁定项数 + 快捷入口 → 司命枢(安全治理)
设计稿 §§ .alert-bar / $.cracked / .alert-dot / .alert-text / .alert-cta
=============================================================== */}
<Link
href="/admin/safety"
className={cn(
"flex items-center gap-3 rounded-xl border px-4 py-3.5 text-sm no-underline transition-colors",
"hover:bg-surface-hi",
cracked
? "border-cinnabar-dim bg-cinnabar-wash"
: "border-rule bg-surface",
)}
>
{/* 告警圆点 */}
<span
className={cn(
"h-2.5 w-2.5 shrink-0 rounded-full",
cracked
? "bg-cinnabar shadow-[0_0_0_4px_var(--cinnabar-wash)]"
: "bg-patina",
)}
/>
<span
className={cn(
"text-sm font-medium",
cracked ? "text-cinnabar" : "text-patina",
)}
>
{cracked
? `${report.alerts} 项朱批待裁定`
: "九枢皆稳,无待裁定项"}
</span>
<span className="ml-auto hidden text-[11px] text-fg-faint sm:inline-flex items-center gap-1.5">
<span>司命枢 · 总态</span>
<span
className={cn(
"font-mono text-[11px]",
cracked ? "text-cinnabar" : "text-patina",
)}
>
{PIVOT_HEALTH_LABEL[report.overall]}
</span>
</span>
<span className="shrink-0 rounded-md border border-rule px-2.5 py-1 text-[11px] text-fg-dim transition-colors hover:bg-overlay-hover">
前往朱批 →
</span>
</Link>
{/* ===============================================================
② 九枢星盘 + 枢速览(设计稿 .hero-grid)
空间维度 hero:左侧星盘 SVG + 右侧九枢健康列表
设计稿 §§ .hero-grid / .astrolabe-card / .health-list / .health-item
=============================================================== */}
<div className="grid gap-4 md:grid-cols-[minmax(0,22rem)_1fr]">
{/* 星盘卡 */}
<div className="flex flex-col items-center gap-3 rounded-xl border border-rule bg-surface px-4 py-5">
<Astrolabe pivots={report.pivots} />
{/* 星盘图例(设计稿 .astrolabe-legend) */}
<div className="flex flex-wrap justify-center gap-x-4 gap-y-2 font-mono text-[10px] text-fg-faint">
<span className="inline-flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-patina" />
稳定
</span>
<span className="inline-flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-gilt" />
运转中
</span>
<span className="inline-flex items-center gap-1.5">
<span className="inline-block h-2 w-2 rounded-full bg-cinnabar" />
告警
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{
background: "transparent",
border: "1.5px solid var(--fg-faint)",
}}
/>
离线
</span>
</div>
</div>
{/* 枢速览列表(设计稿 .health-list) */}
<div className="overflow-hidden rounded-xl border border-rule bg-surface">
{report.pivots.map((p) => {
const healthColor = {
void: "bg-[var(--fg-faint)] opacity-40",
steady: "bg-patina",
breathing: "bg-[var(--gilt)]",
cracked: "bg-[var(--cinnabar)]",
}[p.health];
const badgeColor = {
void: "text-fg-faint",
steady: "text-patina",
breathing: "text-gilt",
cracked: "text-cinnabar",
}[p.health];
const dotStyle =
p.health === "void"
? {
background: "transparent",
border: "1.5px solid var(--fg-faint)",
}
: {};
return (
<Link
key={p.pivot}
href={`/admin/pivots/${p.pivot}`}
className="grid grid-cols-[3px_1fr_auto] items-center gap-3.5 border-b border-rule px-4 py-2.5 text-left no-underline transition-colors last:border-b-0 hover:bg-overlay-hover"
>
{/* 左缘色条(设计稿 .health-bar) */}
<span
className={cn(
"h-6 w-0.5 shrink-0 rounded-r",
healthColor,
)}
/>
{/* 主信息 */}
<div className="min-w-0">
<div className="flex items-baseline gap-1.5">
<span className="font-serif text-sm font-semibold text-fg">
{PIVOT_FULL_LABEL[p.pivot]}
</span>
<span className="truncate font-mono text-[10px] text-fg-faint">
{PIVOT_MODULE_LABEL[p.pivot]}
</span>
</div>
<p className="mt-px truncate text-xs text-fg-dim">
{p.summary}
</p>
</div>
{/* 状态徽记(设计稿 .health-badge) */}
<span
className={cn(
"inline-flex items-center gap-1.5 shrink-0 text-xs font-medium",
badgeColor,
)}
>
<span
className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
healthColor,
)}
style={dotStyle}
/>
{PIVOT_HEALTH_LABEL[p.health]}
</span>
</Link>
);
})}
</div>
</div>
{/* ===============================================================
③ 三签概览卡(设计稿 .seals-grid)
三个维度入口:星图(空间)/ 朱批(动作)/ 起居注(时间)
设计稿 §§ .seals-grid / .seal-card.starmap / .vermilion / .imperial
=============================================================== */}
<div className="grid gap-3 sm:grid-cols-3">
{/* 星图 · 空间维度(设计稿 .seal-card.starmap) */}
<Link
href="/admin"
className="group relative block overflow-hidden rounded-xl border border-rule bg-surface px-4 py-4 no-underline transition-all hover:border-patina-dim hover:bg-surface-hi"
>
{/* 左缘色条:patina(设计稿 .seal-card 左 3px 色条) */}
<span className="absolute left-0 top-4 bottom-4 w-[3px] rounded-r bg-patina" />
<div className="pl-3">
<div className="font-mono text-[10px] tracking-wider text-fg-faint uppercase mb-1.5">
空间 · spatial
</div>
<div className="mb-1 flex items-baseline gap-2">
<h3 className="font-serif text-base font-semibold text-fg">
星图
</h3>
<span className="font-mono text-[10.5px] text-patina">
StarMap
</span>
</div>
<p className="mb-3 text-[12px] text-fg-faint">
权限拓扑 · 九枢授权态势
</p>
<div className="mb-2 flex items-baseline gap-1.5">
<span className="font-mono text-[28px] font-semibold leading-none text-fg">
{report.pivots.length}
</span>
<span className="text-[11.5px] text-fg-faint">枢</span>
<span className="text-[11.5px] text-cinnabar">
· {report.alerts} 告警
</span>
</div>
<div className="flex items-center justify-between border-t border-rule pt-2 text-[11.5px] text-fg-dim">
<span>
{report.pivots.filter((p) => p.health !== "void").length}{" "}
活跃
</span>
<span className="font-mono text-fg-faint opacity-0 transition-opacity group-hover:opacity-100">
星盘 →
</span>
</div>
</div>
</Link>
{/* 朱批 · 动作维度(设计稿 .seal-card.vermilion) */}
<Link
href="/admin/safety"
className="group relative block overflow-hidden rounded-xl border border-rule bg-surface px-4 py-4 no-underline transition-all hover:border-cinnabar-dim hover:bg-surface-hi"
>
{/* 左缘色条:cinnabar */}
<span className="absolute left-0 top-4 bottom-4 w-[3px] rounded-r bg-cinnabar" />
<div className="pl-3">
<div className="font-mono text-[10px] tracking-wider text-fg-faint uppercase mb-1.5">
动作 · action
</div>
<div className="mb-1 flex items-baseline gap-2">
<h3 className="font-serif text-base font-semibold text-fg">
朱批
</h3>
<span className="font-mono text-[10.5px] text-cinnabar">
VermilionAction
</span>
</div>
<p className="mb-3 text-[12px] text-fg-faint">
确认裁决 · 高风险工具裁定
</p>
<div className="mb-2 flex items-baseline gap-1.5">
<span className="font-mono text-[28px] font-semibold leading-none text-fg">
{pendingCount}
</span>
<span className="text-[11.5px] text-fg-faint">项待裁定</span>
</div>
<div className="flex items-center justify-between border-t border-rule pt-2 text-[11.5px] text-fg-dim">
<span>司命枢 →</span>
<span className="font-mono text-fg-faint opacity-0 transition-opacity group-hover:opacity-100">
朱批 →
</span>
</div>
</div>
</Link>
{/* 起居注 · 时间维度(设计稿 .seal-card.imperial) */}
<Link
href="/admin/audit"
className="group relative block overflow-hidden rounded-xl border border-rule bg-surface px-4 py-4 no-underline transition-all hover:border-gilt-dim hover:bg-surface-hi"
>
{/* 左缘色条:gilt */}
<span className="absolute left-0 top-4 bottom-4 w-[3px] rounded-r bg-gilt" />
<div className="pl-3">
<div className="font-mono text-[10px] tracking-wider text-fg-faint uppercase mb-1.5">
时间 · temporal
</div>
<div className="mb-1 flex items-baseline gap-2">
<h3 className="font-serif text-base font-semibold text-fg">
起居注
</h3>
<span className="font-mono text-[10.5px] text-gilt">
ImperialLog
</span>
</div>
<p className="mb-3 text-[12px] text-fg-faint">
审计流水 · 不可篡改
</p>
<div className="mb-2 flex items-baseline gap-1.5">
<span className="font-mono text-[28px] font-semibold leading-none text-fg">
{auditCounts.today}
</span>
<span className="text-[11.5px] text-fg-faint">条 · 今日</span>
</div>
<div className="flex items-center justify-between border-t border-rule pt-2 text-[11.5px] text-fg-dim">
<span>本周 {auditCounts.week} 条</span>
<span className="font-mono text-fg-faint opacity-0 transition-opacity group-hover:opacity-100">
起居注 →
</span>
</div>
</div>
</Link>
</div>
{/* ===============================================================
④ 起居注 · 近期(设计稿起居注段)
时间维度预览,3 条实时数据。
设计稿 §§ .audit-head / .audit-item / .pill.decision
=============================================================== */}
<div className="overflow-hidden rounded-xl border border-rule bg-surface">
{/* 起居注标题行 */}
<div className="flex items-center justify-between border-b border-rule px-4 py-3">
<h3 className="font-serif text-sm font-semibold text-fg">
起居注 · 近期
</h3>
<Link
href="/admin/audit"
className="inline-flex items-center gap-1 rounded-md border border-rule px-2.5 py-1 text-[11.5px] text-fg-dim no-underline transition-colors hover:bg-overlay-hover"
>
进入起居注 →
</Link>
</div>
{recentAudit.length > 0 ? (
recentAudit.map((entry) => {
const pillClass = {
auto: "border-patina-dim text-patina bg-patina-wash",
approved: "border-patina-dim text-patina bg-patina-wash",
denied: "border-cinnabar-dim text-cinnabar bg-cinnabar-wash",
blocked: "border-cinnabar-dim text-cinnabar bg-cinnabar-wash",
}[entry.decision];
return (
<div
key={entry.audit_id}
className="grid grid-cols-[4.5rem_1fr_auto] gap-3.5 border-b border-rule px-4 py-3 last:border-b-0"
>
{/* 时间 */}
<span className="font-mono text-[11px] text-fg-faint">
{formatAuditTime(entry.created_at)}
</span>
{/* 内容 */}
<div className="min-w-0">
<div className="mb-0.5 flex items-center gap-2 flex-wrap">
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 font-mono text-[10px] leading-none",
pillClass,
)}
>
{AUDIT_DECISION_LABEL[entry.decision]}
</span>
<span className="text-xs font-medium text-fg">
{PIVOT_FULL_LABEL[entry.pivot] ?? entry.pivot}
</span>
</div>
<p className="text-[12px] text-fg-dim leading-relaxed">
{entry.summary}
{entry.subject && (
<span className="ml-1.5 font-mono text-[11px] text-fg">
{entry.subject}
</span>
)}
</p>
</div>
{/* trace 链接 */}
{entry.trace_id && (
<span className="self-center font-mono text-[10.5px] text-fg-faint">
trace · {entry.trace_id.slice(0, 6)}
</span>
)}
</div>
);
})
) : (
<div className="px-4 py-8 text-center text-xs text-fg-faint">
暂无审计记录。
</div>
)}
</div>
</div>
)}
</PageContainer>
);
}
/**
* 格式化 ISO 时间为中文字段时间描述。
* 今日显示时辰(申时三刻),昨日显示"昨日 HH:mm",更早显示日期。
*/
function formatAuditTime(iso?: string): string {
if (!iso) return "——";
try {
const d = new Date(iso);
const now = new Date();
const isToday =
d.getFullYear() === now.getFullYear() &&
d.getMonth() === now.getMonth() &&
d.getDate() === now.getDate();
if (isToday) {
const hour = d.getHours();
const min = d.getMinutes();
const periodNames = [
"子",
"丑",
"寅",
"卯",
"辰",
"巳",
"午",
"未",
"申",
"酉",
"戌",
"亥",
];
const period = periodNames[Math.floor(hour / 2)];
const ke = Math.floor(min / 15) + 1;
const keNames = ["初", "一刻", "二刻", "三刻"];
return `${period}时${keNames[ke - 1] ?? ""}`;
}
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const isYesterday =
d.getFullYear() === yesterday.getFullYear() &&
d.getMonth() === yesterday.getMonth() &&
d.getDate() === yesterday.getDate();
if (isYesterday) {
return `昨日 ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
return `${d.getMonth() + 1}月${d.getDate()}日`;
} catch {
return iso;
}
}
@@ -0,0 +1,33 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { PerceptionChannel } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminPerceptionPage() {
let channels: PerceptionChannel[] = [];
let error: string | null = null;
try {
channels = await serverApi.get("/admin/perception/channels");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="烛照枢"
description="感知通道授权——管理六类感知源的同意状态与隐私级别。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<PerceptionConsentMatrix channels={channels ?? []} />
)}
</PageContainer>
);
}
@@ -0,0 +1,451 @@
import { notFound } from "next/navigation";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, EmptyState } from "@/components/shared/StateViews";
import { VermilionAction } from "@/components/admin/VermilionAction";
import { PolicyMatrix } from "@/components/admin/PolicyMatrix";
import { AuditConsole } from "@/components/admin/AuditConsole";
import { ToolRegistry } from "@/components/admin/ToolRegistry";
import { MemoryAdminList } from "@/components/admin/MemoryAdminList";
import { PersonalityEditor } from "@/components/admin/PersonalityEditor";
import { GoalTree } from "@/components/admin/GoalTree";
import { PerceptionConsentMatrix } from "@/components/admin/PerceptionConsentMatrix";
import { WorldEntityList } from "@/components/admin/WorldEntityList";
import { PlanConsole } from "@/components/admin/PlanConsole";
import { AgentRoster } from "@/components/admin/AgentRoster";
import { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
import { GatewayPanel } from "@/components/admin/mcp/GatewayPanel";
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
import { SkillManager } from "@/components/tools/SkillManager";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { Pivot } from "@/types/domain";
import type {
AgentRosterEntry,
AuditEntry,
GoalNode,
McpServer,
Memory,
Paginated,
PendingApproval,
PerceptionChannel,
PersonalityVersion,
PlanSummary,
SafetyPolicy,
Skill,
ToolInfo,
WorldEntity,
} from "@/types/api";
export const dynamic = "force-dynamic";
const VALID_PIVOTS: Pivot[] = [
"personality_core",
"goal_orchestrator",
"safety_governor",
"perception",
"world_model",
"planner",
"memory",
"tool_runtime",
"agent_society",
];
const PIVOT_SET = new Set<string>(VALID_PIVOTS);
/**
* 枢详情。九枢均已落地治理视图(设计 §9 分步已基本覆盖):
* 司命(朱批+权限矩阵)、百工(工具注册表)、灵核(人格摘要)、息壤(记忆列表)、
* 太一(目标树)、烛照(感知授权)、河图(世界实体)、洛书(计划)、天工阁(角色名册)。
*/
export default async function PivotDetailPage({
params,
}: {
params: Promise<{ pivot: string }>;
}) {
const { pivot } = await params;
if (!PIVOT_SET.has(pivot)) notFound();
const key = pivot as Pivot;
switch (key) {
case "safety_governor":
return <SafetyGovernorDetail />;
case "tool_runtime":
return <ToolRuntimeDetail />;
case "personality_core":
return <PersonalityCoreDetail />;
case "memory":
return <MemorySystemDetail />;
case "goal_orchestrator":
return <GoalOrchestratorDetail />;
case "perception":
return <PerceptionDetail />;
case "world_model":
return <WorldModelDetail />;
case "planner":
return <PlannerDetail />;
case "agent_society":
return <AgentSocietyDetail />;
default:
notFound();
}
}
/** 司命枢:朱批待办墙(动作)+ 权限策略矩阵 + 安全审计事件 */
async function SafetyGovernorDetail() {
let pending: PendingApproval[] = [];
let policies: SafetyPolicy[] = [];
let auditEvents: AuditEntry[] = [];
let error: string | null = null;
try {
const [pendingData, policyData, auditData] = await Promise.all([
serverApi.get<Paginated<PendingApproval>>("/admin/safety/pending"),
serverApi.get<SafetyPolicy[]>("/safety/policies"),
serverApi.get<Paginated<AuditEntry>>("/safety/audit-events", {
page_size: 20,
}),
]);
pending = pendingData.items;
policies = policyData;
auditEvents = auditData.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="司命枢"
description="安全与裁定 · 权限、确认、审计"
maxWidth="max-w-4xl"
>
{error ? (
<ErrorState message={error} />
) : (
<div className="space-y-8">
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
朱批待办{" "}
<span className="font-sans text-xs text-muted">
({pending.length})
</span>
</h2>
{pending.length === 0 ? (
<EmptyState
title="无待裁定项"
description="当前没有需要裁定的工具调用。"
/>
) : (
<div className="space-y-2">
{pending.map((a) => (
<VermilionAction key={a.confirmation_id} approval={a} />
))}
</div>
)}
</section>
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
权限策略矩阵
</h2>
<PolicyMatrix policies={policies} />
</section>
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
安全审计事件
<span className="font-sans text-xs text-muted">
{" "}
({auditEvents.length})
</span>
</h2>
{auditEvents.length === 0 ? (
<EmptyState
title="暂无安全审计事件"
description="当前没有安全相关的审计记录。"
/>
) : (
<AuditConsole entries={auditEvents} />
)}
</section>
</div>
)}
</PageContainer>
);
}
/** 百工枢(百工阁):能力总览 + 工具注册表 + Skill 能力包 + MCP 服务器统一治理。 */
async function ToolRuntimeDetail() {
let tools: ToolInfo[] = [];
let skills: Skill[] = [];
let mcpServers: McpServer[] = [];
let error: string | null = null;
try {
[tools, skills, mcpServers] = await Promise.all([
serverApi.get<ToolInfo[]>("/tools"),
serverApi.get<Skill[]>("/tools/skills"),
serverApi.get<McpServer[]>("/tools/mcp-servers"),
]);
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="百工枢"
description="器用技艺 · 能力注册、工具、MCP 与 Skill 统一治理"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<div className="space-y-6">
{/* 四标签页 */}
<ToolRuntimeTabs
tabs={[
{
key: "tools",
label: "工具",
badge: tools.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
工具注册表
</h2>
<ToolRegistry tools={tools} />
</section>
),
},
{
key: "mcp",
label: "MCP 服务器",
badge: mcpServers.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
MCP 服务器
</h2>
<McpServerPanel initialServers={mcpServers} />
</section>
),
},
{
key: "gateway",
label: "玄关网关",
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
玄关网关管理
</h2>
<GatewayPanel />
</section>
),
},
{
key: "skills",
label: "Skill 能力包",
badge: skills.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
Skill 能力包
</h2>
<SkillManager initial={skills} />
</section>
),
},
]}
defaultTab="tools"
/>
</div>
)}
</PageContainer>
);
}
/** 灵核枢:人格各字段 inline 可编辑(核心人格 L4 走提议审核) */
async function PersonalityCoreDetail() {
let persona: PersonalityVersion | null = null;
let error: string | null = null;
try {
persona = await serverApi.get<PersonalityVersion>("/personality/current");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="灵核枢"
description="人格与身份 · 每个字段均可编辑"
maxWidth="max-w-3xl"
>
{error || !persona ? (
<ErrorState message={error ?? "人格信息暂不可用。"} />
) : (
<PersonalityEditor persona={persona} />
)}
</PageContainer>
);
}
/** 息壤枢:长期记忆列表(自生不息) */
async function MemorySystemDetail() {
let memories: Memory[] = [];
let error: string | null = null;
try {
const data = await serverApi.get<Paginated<Memory>>("/memory", {
page_size: 100,
});
memories = data.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="息壤枢"
description="长期记忆 · 自生不息"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : memories.length === 0 ? (
<EmptyState
title="暂无记忆"
description="当前人物还没有沉淀任何长期记忆。"
/>
) : (
<MemoryAdminList initial={memories} />
)}
</PageContainer>
);
}
/** 太一枢:目标层级树(最高目标 → 任务,可编辑标题/优先级/状态) */
async function GoalOrchestratorDetail() {
let goals: GoalNode[] = [];
let error: string | null = null;
try {
goals = await serverApi.get<GoalNode[]>("/admin/goals");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="太一枢"
description="目标统御 · 最高目标与任务调度"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : goals.length === 0 ? (
<EmptyState title="暂无目标" description="太一枢尚未编排任何目标。" />
) : (
<GoalTree goals={goals} />
)}
</PageContainer>
);
}
/** 烛照枢:感知通道授权矩阵(启用 / 授权 / 隐私路由) */
async function PerceptionDetail() {
let channels: PerceptionChannel[] = [];
let error: string | null = null;
try {
channels = await serverApi.get<PerceptionChannel[]>(
"/admin/perception/channels",
);
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="烛照枢"
description="感知世界 · 通道授权与隐私边界"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : (
<PerceptionConsentMatrix channels={channels} />
)}
</PageContainer>
);
}
/** 河图枢:世界实体列表(类型 / 事实-推断 / 置信度) */
async function WorldModelDetail() {
let entities: WorldEntity[] = [];
let error: string | null = null;
try {
entities = await serverApi.get<WorldEntity[]>("/admin/world/entities");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="河图枢"
description="世界模型 · 实体与关系图式"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : entities.length === 0 ? (
<EmptyState
title="暂无世界实体"
description="河图枢尚未建模任何实体。"
/>
) : (
<WorldEntityList entities={entities} />
)}
</PageContainer>
);
}
/** 洛书枢:计划控制台(状态 / 假设 / 风险) */
async function PlannerDetail() {
let plans: PlanSummary[] = [];
let error: string | null = null;
try {
plans = await serverApi.get<PlanSummary[]>("/admin/plans");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="洛书枢"
description="推演变化 · 规划与流程编排"
maxWidth="max-w-3xl"
>
{error ? (
<ErrorState message={error} />
) : plans.length === 0 ? (
<EmptyState title="暂无计划" description="洛书枢尚未编排任何计划。" />
) : (
<PlanConsole plans={plans} />
)}
</PageContainer>
);
}
/** 天工阁枢:九角色在岗名册(启用 / 停用) */
async function AgentSocietyDetail() {
let roster: AgentRosterEntry[] = [];
let error: string | null = null;
try {
roster = await serverApi.get<AgentRosterEntry[]>("/admin/agents/roster");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="天工阁枢"
description="众智能协作 · 九角色分工与协作"
maxWidth="max-w-3xl"
>
{error ? <ErrorState message={error} /> : <AgentRoster roster={roster} />}
</PageContainer>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { PlanConsole } from "@/components/admin/PlanConsole";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { PlanSummary } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminPlansPage() {
let plans: PlanSummary[] = [];
let error: string | null = null;
try {
plans = await serverApi.get("/admin/plans");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="洛书枢"
description="计划控制台——规划治理,支持增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<PlanConsole plans={plans ?? []} />
)}
</PageContainer>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, EmptyState } from "@/components/shared/StateViews";
import { VermilionAction } from "@/components/admin/VermilionAction";
import { PolicyMatrix } from "@/components/admin/PolicyMatrix";
import { AuditConsole } from "@/components/admin/AuditConsole";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type {
AuditEntry,
Paginated,
PendingApproval,
SafetyPolicy,
} from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 司命枢 · 安全与裁定(对应设计稿 admin-preview.html 司命枢 视图)。
*
* 三层布局:
* ① 朱批待办 — 高风险待裁定工具调用
* ② 权限策略矩阵 — tool × risk × permission
* ③ 安全审计事件 — 近期裁定与拦截
*/
export default async function AdminSafetyPage() {
let pending: PendingApproval[] = [];
let policies: SafetyPolicy[] = [];
let auditEvents: AuditEntry[] = [];
let error: string | null = null;
try {
const [pendingData, policyData, auditData] = await Promise.all([
serverApi.get<Paginated<PendingApproval>>("/admin/safety/pending"),
serverApi.get<SafetyPolicy[]>("/safety/policies"),
serverApi.get<Paginated<AuditEntry>>("/safety/audit-events", {
page_size: 20,
}),
]);
pending = pendingData.items;
policies = policyData;
auditEvents = auditData.items;
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="司命枢"
description="安全与裁定 · 权限、确认、审计"
maxWidth="max-w-4xl"
>
{error ? (
<ErrorState message={error} />
) : (
<div className="space-y-8">
{/* ① 朱批待办 */}
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
朱批待办{" "}
<span className="font-sans text-xs text-muted">
({pending.length})
</span>
</h2>
{pending.length === 0 ? (
<EmptyState
title="无待裁定项"
description="当前没有需要裁定的工具调用。"
/>
) : (
<div className="space-y-2">
{pending.map((a) => (
<VermilionAction key={a.confirmation_id} approval={a} />
))}
</div>
)}
</section>
{/* ② 权限策略矩阵 */}
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
权限策略矩阵
</h2>
<PolicyMatrix policies={policies} />
</section>
{/* ③ 安全审计事件 */}
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
安全审计事件
<span className="font-sans text-xs text-muted">
{" "}
({auditEvents.length})
</span>
</h2>
{auditEvents.length === 0 ? (
<EmptyState
title="暂无安全审计事件"
description="当前没有安全相关的审计记录。"
/>
) : (
<AuditConsole entries={auditEvents} />
)}
</section>
</div>
)}
</PageContainer>
);
}
+180
View File
@@ -0,0 +1,180 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { apiGet } from "@/lib/api/client";
import type {
TenantCharacterBrief,
TenantMemoryBrief,
TenantOverview,
} from "@/types/api";
export default function AdminTenantsPage() {
const [tenants, setTenants] = useState<TenantOverview[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [details, setDetails] = useState<{
characters: TenantCharacterBrief[];
memories: TenantMemoryBrief[];
} | null>(null);
const fetchTenants = useCallback(async () => {
try {
const data = await apiGet<TenantOverview[]>("/admin/tenants/overview");
setTenants(data);
} catch {
/* error handled by ApiError */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchTenants();
}, [fetchTenants]);
const viewDetails = async (userId: string) => {
setSelectedUserId(userId);
try {
const [characters, memories] = await Promise.all([
apiGet<TenantCharacterBrief[]>(`/admin/tenants/${userId}/characters`),
apiGet<TenantMemoryBrief[]>(`/admin/tenants/${userId}/memories`),
]);
setDetails({ characters, memories });
} catch {
setDetails(null);
}
};
return (
<div className="mx-auto max-w-5xl px-4 py-8">
<div className="mb-6">
<h1 className="text-lg font-semibold text-fg">多租户管理</h1>
<p className="mt-0.5 text-xs text-muted">跨用户数据总览</p>
</div>
{loading ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
加载中…
</div>
) : tenants.length === 0 ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
暂无用户
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border-glass">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border-glass bg-glass text-xs font-medium text-muted">
<th className="px-4 py-3">用户</th>
<th className="px-4 py-3">角色</th>
<th className="px-4 py-3 text-right">人物数</th>
<th className="px-4 py-3 text-right">记忆数</th>
<th className="px-4 py-3">操作</th>
</tr>
</thead>
<tbody>
{tenants.map((t) => (
<tr
key={t.user_id}
className="border-b border-border-glass last:border-0"
>
<td className="px-4 py-3">
<div>
<span className="font-medium text-fg">{t.username}</span>
<span className="ml-2 text-xs text-muted">{t.email}</span>
</div>
</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
t.is_admin
? "bg-patina/15 text-patina"
: "bg-fg/5 text-muted"
}`}
>
{t.is_admin ? "管理员" : "普通用户"}
</span>
{!t.is_active && (
<span className="ml-1.5 rounded-full bg-cinnabar/15 px-2 py-0.5 text-[11px] font-medium text-cinnabar">
已禁用
</span>
)}
</td>
<td className="px-4 py-3 text-right text-fg">
{t.character_count}
</td>
<td className="px-4 py-3 text-right text-fg">
{t.memory_count}
</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() => viewDetails(t.user_id)}
className="rounded-lg px-2.5 py-1 text-[11px] font-medium text-patina transition-colors hover:bg-patina/10"
>
查看详情
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{selectedUserId && details && (
<div className="mt-8">
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="rounded-xl border border-border-glass p-4">
<h2 className="mb-3 text-sm font-medium text-fg">
人物 ({details.characters.length})
</h2>
{details.characters.length === 0 ? (
<p className="text-xs text-muted">无人物</p>
) : (
<div className="space-y-2">
{details.characters.map((c) => (
<div
key={c.id}
className="rounded-lg bg-glass px-3 py-2 text-xs"
>
<span className="font-medium text-fg">{c.name}</span>
<span className="ml-2 text-muted">({c.status})</span>
{c.display_name && (
<span className="ml-2 text-muted">
/{c.display_name}
</span>
)}
</div>
))}
</div>
)}
</div>
<div className="rounded-xl border border-border-glass p-4">
<h2 className="mb-3 text-sm font-medium text-fg">
记忆 ({details.memories.length})
</h2>
{details.memories.length === 0 ? (
<p className="text-xs text-muted">无记忆</p>
) : (
<div className="space-y-2">
{details.memories.map((m) => (
<div
key={m.id}
className="rounded-lg bg-glass px-3 py-2 text-xs"
>
<span className="font-medium text-fg">{m.title}</span>
<span className="ml-2 text-muted">({m.type})</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
)}
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, EmptyState } from "@/components/shared/StateViews";
import { ToolRegistry } from "@/components/admin/ToolRegistry";
import { McpServerPanel } from "@/components/admin/mcp/McpServerPanel";
import { GatewayPanel } from "@/components/admin/mcp/GatewayPanel";
import { ToolRuntimeTabs } from "@/components/admin/mcp/ToolRuntimeTabs";
import { SkillManager } from "@/components/tools/SkillManager";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { McpServer, Skill, ToolInfo } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 百工枢 · 器用技艺治理(对应设计稿 admin-preview.html 百工枢 视图)。
*
* 四标签页:
* ① 工具注册表 — 清单与管理
* ② MCP 服务器 — 外部能力接入
* ③ 玄关网关 — 路由与代理
* ④ Skill 能力包 — 可插拔能力
*/
export default async function AdminToolsPage() {
let tools: ToolInfo[] = [];
let skills: Skill[] = [];
let mcpServers: McpServer[] = [];
let error: string | null = null;
try {
[tools, skills, mcpServers] = await Promise.all([
serverApi.get<ToolInfo[]>("/tools"),
serverApi.get<Skill[]>("/tools/skills"),
serverApi.get<McpServer[]>("/tools/mcp-servers"),
]);
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="百工枢"
description="器用技艺 · 能力注册、工具、MCP 与 Skill 统一治理"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<div className="space-y-6">
<ToolRuntimeTabs
tabs={[
{
key: "tools",
label: "工具",
badge: tools.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
工具注册表
</h2>
{tools.length === 0 ? (
<EmptyState
title="暂无注册工具"
description="当前没有注册的工具。"
/>
) : (
<ToolRegistry tools={tools} />
)}
</section>
),
},
{
key: "mcp",
label: "MCP 服务器",
badge: mcpServers.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
MCP 服务器
</h2>
<McpServerPanel initialServers={mcpServers} />
</section>
),
},
{
key: "gateway",
label: "玄关网关",
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
玄关网关管理
</h2>
<GatewayPanel />
</section>
),
},
{
key: "skills",
label: "Skill 能力包",
badge: skills.length,
content: (
<section>
<h2 className="mb-3 font-serif text-sm font-semibold text-fg">
Skill 能力包
</h2>
{skills.length === 0 ? (
<EmptyState
title="暂无 Skill"
description="当前没有安装 Skill 能力包。"
/>
) : (
<SkillManager initial={skills} />
)}
</section>
),
},
]}
defaultTab="tools"
/>
</div>
)}
</PageContainer>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState, LoadingState } from "@/components/shared/StateViews";
import { TraceList } from "@/components/admin/TraceList";
import { TraceSearch } from "@/components/admin/TraceSearch";
import { TraceTimeline } from "@/components/admin/TraceTimeline";
import { adminApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { TraceDetail, TraceSummary } from "@/types/api";
/**
* 链路追踪 · 列表模式:默认展示所有 trace(最近活动倒序)。
* 顶部搜索框可跳转到指定 trace;点击列表项进入详情时间线。
*/
function TraceListView() {
const router = useRouter();
const [items, setItems] = useState<TraceSummary[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
useEffect(() => {
let cancelled = false;
adminApi
.listTraces()
.then((res) => {
if (!cancelled) setItems(res.items);
})
.catch((e) => {
if (!cancelled) setError(describeError(e));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
function jumpToTrace() {
const trimmed = query.trim();
if (trimmed) {
router.push(`/admin/traces?trace_id=${encodeURIComponent(trimmed)}`);
}
}
return (
<div className="space-y-4">
<TraceSearch
value={query}
onChange={setQuery}
onSubmit={jumpToTrace}
loading={false}
/>
{loading && <LoadingState label="正在汇聚链路…" />}
{!loading && error && <ErrorState message={error} />}
{!loading && !error && items && <TraceList items={items} />}
</div>
);
}
/**
* 链路追踪 · 详情模式:展示指定 trace_id 的时间线。
* 由父级以 key={traceId} 挂载,trace 切换即重挂载,loading 初值随之重置,
* 无需在 effect 内同步 setState。
*/
function TraceDetailView({ traceId }: { traceId: string }) {
const router = useRouter();
const [detail, setDetail] = useState<TraceDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
adminApi
.getTrace(traceId)
.then((data) => {
if (!cancelled) setDetail(data);
})
.catch((e) => {
if (!cancelled) setError(describeError(e));
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [traceId]);
return (
<div className="space-y-4">
<button
type="button"
onClick={() => router.push("/admin/traces")}
className="text-xs text-muted transition-colors hover:text-fg"
>
← 全部链路
</button>
<div className="rounded-xl border border-border-glass bg-glass px-4 py-2.5">
<span className="font-mono text-xs text-fg">{traceId}</span>
</div>
{loading && <LoadingState label="正在汇聚链路…" />}
{!loading && error && <ErrorState message={error} />}
{!loading && !error && detail && <TraceTimeline detail={detail} />}
</div>
);
}
function TracesBody() {
const searchParams = useSearchParams();
const focusTraceId = searchParams.get("trace_id") ?? "";
return focusTraceId ? (
<TraceDetailView key={focusTraceId} traceId={focusTraceId} />
) : (
<TraceListView />
);
}
export default function TracesPage() {
return (
<PageContainer
title="链路追踪"
description="汇聚运行事件、安全审计与请求幂等的全部链路,按最近活动倒序。"
maxWidth="max-w-4xl"
>
<Suspense fallback={<LoadingState label="正在准备链路追踪…" />}>
<TracesBody />
</Suspense>
</PageContainer>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { AdminCreateUserBody, AdminUser } from "@/lib/api/admin";
import { createUser, listUsers, updateUser } from "@/lib/api/admin";
import { Button } from "@/components/ui/Button";
export default function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const fetchUsers = useCallback(async () => {
try {
const data = await listUsers();
setUsers(data);
} catch {
/* 错误由 ApiError 统一处理 */
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchUsers();
}, [fetchUsers]);
const handleToggleActive = async (user: AdminUser) => {
await updateUser(user.id, { is_active: !user.is_active });
await fetchUsers();
};
const handleToggleAdmin = async (user: AdminUser) => {
await updateUser(user.id, { is_admin: !user.is_admin });
await fetchUsers();
};
return (
<div className="mx-auto max-w-4xl px-4 py-8">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-fg">用户管理</h1>
<p className="mt-0.5 text-xs text-muted">管理所有注册用户</p>
</div>
<Button variant="primary" onClick={() => setShowCreate(true)}>
创建用户
</Button>
</div>
{showCreate && (
<CreateUserForm
onDone={() => {
setShowCreate(false);
fetchUsers();
}}
onCancel={() => setShowCreate(false)}
/>
)}
{loading ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
加载中…
</div>
) : users.length === 0 ? (
<div className="flex items-center justify-center py-16 text-xs text-muted">
暂无用户
</div>
) : (
<div className="overflow-hidden rounded-xl border border-border-glass">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-border-glass bg-glass text-xs font-medium text-muted">
<th className="px-4 py-3">用户名</th>
<th className="px-4 py-3">邮箱</th>
<th className="px-4 py-3">角色</th>
<th className="px-4 py-3">状态</th>
<th className="px-4 py-3">操作</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
className="border-b border-border-glass last:border-0"
>
<td className="px-4 py-3 font-medium text-fg">
{user.username}
</td>
<td className="px-4 py-3 text-muted">{user.email}</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
user.is_admin
? "bg-patina/15 text-patina"
: "bg-fg/5 text-muted"
}`}
>
{user.is_admin ? "管理员" : "普通用户"}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 text-xs ${
user.is_active ? "text-patina" : "text-muted"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
user.is_active ? "bg-patina" : "bg-muted"
}`}
/>
{user.is_active ? "启用" : "禁用"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleToggleActive(user)}
className="rounded-lg px-2.5 py-1 text-[11px] font-medium transition-colors hover:bg-fg/5"
style={{
color: user.is_active
? "var(--cinnabar)"
: "var(--patina)",
}}
>
{user.is_active ? "禁用" : "启用"}
</button>
{!user.is_admin && (
<button
type="button"
onClick={() => handleToggleAdmin(user)}
className="rounded-lg px-2.5 py-1 text-[11px] font-medium transition-colors hover:bg-fg/5"
style={{ color: "var(--patina)" }}
>
设为管理员
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
function CreateUserForm({
onDone,
onCancel,
}: {
onDone: () => void;
onCancel: () => void;
}) {
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError(null);
try {
const body: AdminCreateUserBody = {
username,
email,
password,
is_admin: isAdmin,
};
await createUser(body);
onDone();
} catch (err) {
setError(err instanceof Error ? err.message : "创建失败");
} finally {
setSaving(false);
}
};
return (
<div className="glass-strong mb-6 rounded-xl border border-border-glass p-4">
<h2 className="mb-4 text-sm font-medium text-fg">创建新用户</h2>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-muted">
用户名
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
required
/>
</div>
<div>
<label className="block text-xs font-medium text-muted">邮箱</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
required
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-muted">密码</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-lg border border-border-glass bg-glass px-3 py-2 text-sm text-fg outline-none transition-colors focus:border-patina"
required
minLength={6}
/>
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={isAdmin}
onChange={(e) => setIsAdmin(e.target.checked)}
className="h-4 w-4 rounded border-border-glass accent-patina"
/>
<span className="text-xs text-muted">设为管理员</span>
</label>
{error && <p className="text-xs text-cinnabar">{error}</p>}
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={onCancel}
className="rounded-lg px-3 py-1.5 text-xs font-medium text-muted transition-colors hover:bg-fg/5"
>
取消
</button>
<button
type="submit"
disabled={saving}
className="rounded-lg bg-patina px-3 py-1.5 text-xs font-medium text-white transition-opacity disabled:opacity-50"
>
{saving ? "创建中…" : "创建"}
</button>
</div>
</form>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { PageContainer } from "@/components/layout/PageContainer";
import { WorldEntityList } from "@/components/admin/WorldEntityList";
import { ErrorState } from "@/components/shared/StateViews";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import type { WorldEntity } from "@/types/api";
export const dynamic = "force-dynamic";
export default async function AdminWorldPage() {
let entities: WorldEntity[] = [];
let error: string | null = null;
try {
entities = await serverApi.get("/admin/world/entities");
} catch (e) {
error = describeError(e);
}
return (
<PageContainer
title="河图枢"
description="世界实体管理——人物对世界的认知实体,支持增删改。"
maxWidth="max-w-5xl"
>
{error ? (
<ErrorState message={error} />
) : (
<WorldEntityList entities={entities ?? []} />
)}
</PageContainer>
);
}
+41
View File
@@ -0,0 +1,41 @@
/**
* BFF 认证 API Route — POST /api/auth/login
*
* 路由层:解析请求 → 调 authService.login → 返回响应(不含业务逻辑)。
*/
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import {
login as loginService,
AuthServiceError,
} from "@/lib/backend/services/auth";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password } = body;
if (!username || !password) {
return NextResponse.json(
{ error: { code: "invalid_request", message: "请提供用户名和密码。" } },
{ status: 400 },
);
}
const result = await loginService(username, password);
return NextResponse.json(result);
} catch (err) {
if (err instanceof AuthServiceError) {
return NextResponse.json(
{ error: { code: err.code, message: err.message } },
{ status: err.status },
);
}
console.error("[BFF /api/auth/login]", err);
return NextResponse.json(
{ error: { code: "internal_error", message: "服务内部错误。" } },
{ status: 500 },
);
}
}
+13
View File
@@ -0,0 +1,13 @@
/**
* BFF 认证 API Route — POST /api/auth/logout
*
* 路由层:调 authService.logout → 返回响应。
*/
import { NextResponse } from "next/server";
import { logout } from "@/lib/backend/services/auth";
export async function POST() {
await logout();
return NextResponse.json({ ok: true });
}
+53
View File
@@ -0,0 +1,53 @@
/**
* BFF 认证 API Route — GET /api/auth/me
*
* 路由层:读 token → 调 authService.getMe → 401 时 refresh 重试 → 返回响应。
*/
import { NextResponse } from "next/server";
import { getAccessToken } from "@/lib/backend/cookies";
import { getMe, refreshToken, logout } from "@/lib/backend/services/auth";
export async function GET() {
try {
const token = await getAccessToken();
if (!token) {
return NextResponse.json(
{ error: { code: "unauthorized", message: "未登录。" } },
{ status: 401 },
);
}
let user = await getMe();
if (!user) {
// 401 或 token 无效 → 尝试 refresh
const newToken = await refreshToken();
if (newToken) {
user = await getMe();
}
}
if (!user) {
await logout();
return NextResponse.json(
{
error: { code: "unauthorized", message: "会话已过期,请重新登录。" },
},
{ status: 401 },
);
}
const accessToken = await getAccessToken();
return NextResponse.json({
user,
access_token: accessToken ?? "",
refresh_token: "",
});
} catch (err) {
console.error("[BFF /api/auth/me]", err);
return NextResponse.json(
{ error: { code: "network_error", message: "无法连接后端服务。" } },
{ status: 502 },
);
}
}
@@ -0,0 +1,49 @@
/**
* BFF 认证 API Route — POST /api/auth/refresh
*
* 路由层:调 authService.refreshToken → 返回响应。
*/
import { NextResponse } from "next/server";
import { getAccessToken, getRefreshToken } from "@/lib/backend/cookies";
import { refreshToken, getMe, logout } from "@/lib/backend/services/auth";
export async function POST() {
try {
const refresh = await getRefreshToken();
if (!refresh) {
return NextResponse.json(
{ error: { code: "unauthorized", message: "未登录或会话已过期。" } },
{ status: 401 },
);
}
const newToken = await refreshToken();
if (!newToken) {
await logout();
return NextResponse.json(
{
error: { code: "unauthorized", message: "会话已过期,请重新登录。" },
},
{ status: 401 },
);
}
// refreshToken 成功后,cookie 已更新,返回用户信息
const user = await getMe();
const accessToken = await getAccessToken();
const newRefreshToken = await getRefreshToken();
return NextResponse.json({
user,
access_token: accessToken ?? "",
refresh_token: newRefreshToken ?? "",
expires_in: 7 * 86400,
});
} catch {
return NextResponse.json(
{ error: { code: "internal_error", message: "刷新令牌失败。" } },
{ status: 500 },
);
}
}
@@ -0,0 +1,66 @@
/**
* BFF 通用代理 — /api/proxy/<path>
*
* 所有需要鉴权的业务请求统一经此代理:浏览器同源请求自动携带 httpOnly cookie,
* 代理在服务端读取 access_token 并注入 Authorization,转发到后端。客户端 JS 永不
* 接触 token。
*
* 路由层职责:读路径 → 转发 → 401 重试 → 返回响应。底层函数在 lib/backend/proxy 中。
*/
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { cookies } from "next/headers";
import { BACKEND_ROOT } from "@/lib/backend/config";
import {
getAccessToken,
COOKIE_ACCESS,
COOKIE_REFRESH,
AUTH_COOKIE_CLEAR_OPTIONS,
} from "@/lib/backend/cookies";
import { refreshToken } from "@/lib/backend/services/auth";
import { buildTargetUrl, forward, proxyResponse } from "@/lib/backend/proxy";
const BACKEND = BACKEND_ROOT;
type Ctx = { params: Promise<{ path?: string[] }> };
async function handler(req: NextRequest, ctx: Ctx): Promise<Response> {
const { path = [] } = await ctx.params;
const target = buildTargetUrl(BACKEND, path, req.nextUrl.search);
// 缓冲请求体,使 401 重试可复用(GET/HEAD 无 body)
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const body = hasBody ? await req.arrayBuffer() : null;
const token = await getAccessToken();
let resp = await forward(target, req, token, body);
// 后端 401 → 刷新后重试一次
if (resp.status === 401) {
const refreshed = await refreshToken();
if (refreshed) {
resp = await forward(target, req, refreshed, body);
} else {
const store = await cookies();
store.set(COOKIE_ACCESS, "", AUTH_COOKIE_CLEAR_OPTIONS);
store.set(COOKIE_REFRESH, "", AUTH_COOKIE_CLEAR_OPTIONS);
return NextResponse.json(
{
error: { code: "unauthorized", message: "登录已过期,请重新登录。" },
},
{ status: 401 },
);
}
}
return proxyResponse(resp);
}
export {
handler as GET,
handler as POST,
handler as PUT,
handler as PATCH,
handler as DELETE,
};
@@ -0,0 +1,59 @@
/**
* BFF SSE 代理 — /api/sse/<path>
*
* EventSource 不支持自定义请求头,无法直接携带 Authorization;且 Next dev 的
* rewrites 代理会缓冲 SSE 流式响应。故把 SSE 端点经此 BFF 路由中转:
* - 浏览器 EventSource 同源请求,自动携带 httpOnly cookie;
* - BFF 在服务端读 access_token 注入 Authorization,fetch 后端 SSE 端点;
* - 后端响应体以 ReadableStream 原样 pipe 回客户端,不缓冲;
* - 透传 Last-Event-ID(EventSource 断线重连时浏览器自动带)。
*
* 路由层职责:读路径 → 建连 → 401 重试 → 返回流。底层函数在 lib/backend/sse 中。
*/
import type { NextRequest } from "next/server";
import { BACKEND_ROOT } from "@/lib/backend/config";
import { getAccessToken } from "@/lib/backend/cookies";
import { refreshToken } from "@/lib/backend/services/auth";
import { buildTargetUrl, connect } from "@/lib/backend/sse";
const BACKEND = BACKEND_ROOT;
/** 强制动态,避免被静态化缓存。 */
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ path?: string[] }> };
export async function GET(req: NextRequest, ctx: Ctx): Promise<Response> {
const { path = [] } = await ctx.params;
const target = buildTargetUrl(BACKEND, path, req.nextUrl.search);
const lastEventId = req.headers.get("last-event-id");
const token = await getAccessToken();
let resp = await connect(target, token, lastEventId);
// 初始建立 401 → 刷新一次再用新 token 建连
if (resp.status === 401) {
const refreshed = await refreshToken();
if (refreshed) {
resp = await connect(target, refreshed, lastEventId);
}
}
// 后端未就绪或鉴权失败 → 回传状态,前端 EventSource 触发 onerror
if (!resp.ok || !resp.body) {
return new Response(resp.body, {
status: resp.status,
statusText: resp.statusText,
});
}
// 流式透传 SSE
return new Response(resp.body, {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
},
});
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useEffect } from "react";
/**
* 根级错误:当根 layout.tsx 自身渲染崩溃时触发。
* 此时全局 layout 与 globals.css 均不可用,故必须自带 <html><body>
* 并使用内联样式(暗色墨夜底),不依赖任何外部样式与组件。
*/
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("[global-error]", error);
}, [error]);
return (
<html lang="zh-CN">
<body
style={{
margin: 0,
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#0b0f0e",
color: "#e6e2d6",
fontFamily:
'-apple-system, BlinkMacSystemFont, "PingFang SC", system-ui, sans-serif',
}}
>
<div style={{ textAlign: "center", padding: "2rem", maxWidth: 420 }}>
<p
style={{ fontSize: "1.125rem", fontWeight: 600, color: "#b5402f" }}
>
镜面碎裂
</p>
<p
style={{
fontSize: "0.8rem",
color: "#8a9389",
marginTop: "0.5rem",
lineHeight: 1.6,
}}
>
应用外壳加载失败。可尝试重新加载,或检查后端服务与配置后刷新。
</p>
{error.digest && (
<p
style={{
fontFamily: "monospace",
fontSize: "0.6rem",
color: "#8a9389",
marginTop: "0.75rem",
}}
>
trace: {error.digest}
</p>
)}
<button
type="button"
onClick={() => reset()}
style={{
marginTop: "1.25rem",
padding: "0.5rem 1.25rem",
borderRadius: "0.75rem",
border: "1px solid rgba(255,255,255,0.12)",
background: "rgba(255,255,255,0.08)",
color: "#e6e2d6",
fontSize: "0.875rem",
cursor: "pointer",
}}
>
重试
</button>
</div>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="44" fill="none" stroke="#9a7d3e" stroke-width="4" />
<circle cx="50" cy="50" r="36" fill="none" stroke="#9a7d3e" stroke-width="1" stroke-opacity="0.4" />
<circle cx="50" cy="50" r="11" fill="#3f7d72" />
</svg>

After

Width:  |  Height:  |  Size: 306 B

+89
View File
@@ -0,0 +1,89 @@
import type { Metadata, Viewport } from "next";
import { AuthInitializer } from "@/components/layout/AuthInitializer";
import { ThemeInitializer } from "@/components/layout/ThemeInitializer";
import { ToastContainer } from "@/components/ui/ToastContainer";
import "@/styles/globals.css";
export const metadata: Metadata = {
title: "魂镜",
description: "魂镜 · 数字生命控制台与观察窗",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
/**
* 视口适配 iOS safe-area:确保在刘海屏/圆角屏上不被安全区域遮挡。
* env(safe-area-inset-*) 的 padding 已在 globals.css body 上设置。
*/
viewportFit: "cover",
};
/**
* 防闪主题脚本:在 hydration 前根据 localStorage 中的主题偏好设置 .dark,
* 避免亮暗切换时的视觉闪烁。仅读取公开偏好,不含敏感信息。
*/
const themeBootstrap = `
(function () {
try {
var raw = localStorage.getItem('hunjing-settings');
var theme = 'system';
if (raw) {
var parsed = JSON.parse(raw);
theme = (parsed && parsed.state && parsed.state.theme) || 'system';
}
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (theme === 'dark' || (theme === 'system' && prefersDark)) {
document.documentElement.classList.add('dark');
}
} catch (e) {}
})();
`;
/**
* 极简根布局:仅承担 html/body 骨架、全局样式、主题初始化与 metadata。
* 壳层(AppShell / AdminShell)下沉到各 route group 的 layout,使主域与
* 司命台拥有各自独立的导航与视觉身份(详见 docs/superpowers/specs/
* 2026-06-27-admin-shell-and-trace-tracking-design.md §2)。
*/
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeBootstrap }} />
{/* Noto Serif CJK SC 全量 >15MB,Google Fonts API 自动返回 CJK 子集。
系统字体优先(Songti SC / Source Han Serif SC),此 CDN 仅作保底。
生产环境建议 glyph-subsetter 自托管:https://github.com/googlefonts/noto-cjk */}
<link
rel="preconnect"
href="https://fonts.googleapis.com"
crossOrigin="anonymous"
/>
<link
rel="preconnect"
href="https://fonts.gstatic.com"
crossOrigin="anonymous"
/>
{/* eslint-disable @next/next/no-page-custom-font -- CJK 15MB+ 无法用 next/font 替代 */}
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Noto+Serif+CJK+SC:wght@400;500;600;700&display=swap"
/>
{/* eslint-enable @next/next/no-page-custom-font */}
</head>
<body>
<div className="grain" />
<div className="content-layer">
<ThemeInitializer />
<AuthInitializer />
{children}
<ToastContainer />
</div>
</body>
</html>
);
}
+485
View File
@@ -0,0 +1,485 @@
"use client";
import { FormEvent, Suspense, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { motion } from "framer-motion";
import { useAuthStore } from "@/lib/stores/auth-store";
import { useSettingsStore } from "@/lib/stores/settings-store";
import type { ThemeMode } from "@/types/domain";
/**
* 铜镜徽 LOGO · 品牌图形标
*/
function LogoMark({ size = 68 }: { size?: number }) {
const s = size;
return (
<svg
width={s}
height={s}
viewBox="0 0 68 68"
className="mx-auto mb-4"
role="img"
aria-label="魂镜铜镜徽"
>
<circle cx="34" cy="34" r="32" className="logo-mark-ring" />
<circle cx="34" cy="34" r="27" className="logo-mark-inner" />
<circle cx="34" cy="34" r="22" className="logo-mark-char-ring" />
<circle cx="34" cy="34" r="15" className="logo-mark-char-ring" />
<circle cx="34" cy="34" r="8" fill="var(--patina-wash)" opacity="0.3" />
<text x="34" y="40" textAnchor="middle" className="logo-mark-char">
魂
</text>
<circle cx="34" cy="6" r="1.5" className="logo-mark-dot" />
<circle cx="34" cy="62" r="1.5" className="logo-mark-dot" />
<circle cx="6" cy="34" r="1.5" className="logo-mark-dot" />
<circle cx="62" cy="34" r="1.5" className="logo-mark-dot" />
</svg>
);
}
/**
* 小星盘 · 右上角装饰(140s 慢旋转)
*/
function MiniAstrolabe() {
return (
<div
className="pointer-events-none fixed right-[clamp(24px,5vw,72px)] top-[clamp(80px,12vh,120px)] hidden md:block"
style={{
width: 120,
height: 120,
opacity: "var(--astrolabe-opacity, 0.25)",
}}
role="img"
aria-label="九枢星盘"
>
<svg viewBox="0 0 100 100" className="h-full w-full">
<circle
cx="50"
cy="50"
r="48"
fill="none"
stroke="var(--gilt-dim)"
strokeWidth="0.6"
/>
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke="var(--gilt-faint)"
strokeWidth="0.3"
strokeDasharray="1.5 3"
/>
<g className="animate-slow-rot">
<circle
cx="50"
cy="50"
r="33"
fill="none"
stroke="var(--patina-dim)"
strokeWidth="0.5"
/>
<circle
cx="50"
cy="50"
r="24"
fill="none"
stroke="var(--gilt-faint)"
strokeWidth="0.4"
strokeDasharray="1 4"
/>
<g stroke="var(--gilt-faint)" strokeWidth="0.3" opacity="0.5">
<line x1="50" y1="17" x2="50" y2="4" />
<line x1="83" y1="30" x2="93" y2="22" />
<line x1="93" y1="62" x2="97" y2="68" />
<line x1="74" y1="81" x2="80" y2="89" />
<line x1="32" y1="82" x2="26" y2="90" />
<line x1="14" y1="56" x2="6" y2="60" />
<line x1="18" y1="26" x2="10" y2="20" />
</g>
<g fill="var(--gilt)" opacity="0.4">
<circle cx="50" cy="12" r="1.8" />
<circle cx="82" cy="28" r="1.5" />
<circle cx="88" cy="56" r="1.6" />
<circle cx="68" cy="80" r="1.4" />
<circle cx="34" cy="78" r="1.5" />
<circle cx="14" cy="52" r="1.7" />
<circle cx="22" cy="24" r="1.4" />
</g>
</g>
<circle
cx="50"
cy="50"
r="3.5"
fill="var(--patina-bright)"
opacity="0.5"
/>
<circle cx="50" cy="50" r="1.5" fill="var(--patina)" opacity="0.7" />
</svg>
</div>
);
}
function LoginForm() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [departing, setDeparting] = useState(false);
const router = useRouter();
const searchParams = useSearchParams();
const login = useAuthStore((s) => s.login);
const isLoading = useAuthStore((s) => s.isLoading);
const theme = useSettingsStore((s) => s.theme);
const setTheme = useSettingsStore((s) => s.setTheme);
const THEMES: { key: ThemeMode; label: string }[] = [
{ key: "light", label: "素绢" },
{ key: "dark", label: "夜铜" },
{ key: "system", label: "随系统" },
];
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError(null);
try {
await login({ username, password });
setDeparting(true);
await new Promise((r) => setTimeout(r, 400));
const redirect = searchParams.get("redirect") || "/chat";
router.push(redirect);
} catch (err) {
const msg =
err instanceof Error ? err.message : "用户名或密令错误,铜镜不识此名。";
setError(msg);
}
};
return (
<div className="relative flex min-h-screen flex-col overflow-hidden">
{/* 顶栏:主题切换 */}
<header
className="fixed top-0 left-0 right-0 z-40 flex items-center justify-end gap-4 px-[clamp(20px,4vw,48px)] py-3.5"
style={{
background: "color-mix(in srgb, var(--bg) 76%, transparent)",
backdropFilter: "blur(16px) saturate(140%)",
}}
>
<div
className="flex items-center gap-1 rounded-full border p-0.5"
style={{
borderColor: "var(--rule)",
background: "var(--surface)",
}}
>
{THEMES.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTheme(t.key)}
className="rounded-full px-3 py-1 text-[11px] font-medium transition-all duration-150"
style={{
background:
theme === t.key ? "var(--patina-wash)" : "transparent",
color: theme === t.key ? "var(--patina)" : "var(--fg-dim)",
}}
>
{t.label}
</button>
))}
</div>
</header>
<div className="flex min-h-screen items-center justify-center px-4">
{/* 星盘装饰 */}
<MiniAstrolabe />
{/* 登录卡片:抛光铜镜面 */}
<motion.div
initial={{ opacity: 0, y: 18 }}
animate={
departing
? { opacity: 0, y: -10, scale: 0.97, filter: "blur(2px)" }
: { opacity: 1, y: 0 }
}
transition={{
duration: departing ? 0.4 : 0.7,
ease: [0.16, 1, 0.3, 1],
delay: departing ? 0 : 0.12,
}}
className="mirror-face relative w-full max-w-sm overflow-hidden rounded-[6px] p-0"
style={{
background: "var(--face-sheen), var(--surface)",
boxShadow:
"inset 0 1px 0 var(--face-in-top), inset 0 -1px 0 var(--face-in-bot), var(--shadow)",
}}
>
{/* 扫描光泽层 */}
<div className="pointer-events-none absolute inset-0 z-0 animate-sweep" />
{/* 内容层 */}
<div className="relative z-10 px-10 pb-9 pt-11">
{/* 品牌区 */}
<div className="mb-8 text-center">
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
ease: [0.16, 1, 0.3, 1],
delay: 0.22,
}}
>
<LogoMark />
</motion.div>
<motion.h1
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
ease: [0.16, 1, 0.3, 1],
delay: 0.3,
}}
className="font-serif text-[clamp(26px,4vw,30px)] font-bold tracking-[0.14em] text-fg"
>
魂鏡
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
ease: [0.16, 1, 0.3, 1],
delay: 0.38,
}}
className="mt-2 font-mono text-[9.5px] font-medium uppercase tracking-[0.26em] text-gilt"
>
MIRROR OF THE HEART
</motion.p>
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.5,
ease: [0.16, 1, 0.3, 1],
delay: 0.46,
}}
className="mx-auto mt-3.5 h-px w-6 bg-gilt-dim"
aria-hidden
/>
</div>
{/* 表单 */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
delay: 0.5,
}}
className="flex flex-col gap-1.5"
>
<label
htmlFor="username"
className="text-[11.5px] font-medium tracking-[0.04em] text-fg-dim"
>
用户名
</label>
<div
className="flex items-center rounded-[4px] border transition-all duration-200"
style={{
borderColor: error ? "var(--cinnabar-dim)" : "var(--rule)",
boxShadow: error
? "0 0 0 1px var(--cinnabar-dim)"
: undefined,
background:
"var(--input-bg, color-mix(in srgb, var(--bg) 70%, transparent))",
}}
>
<span
className="flex w-9 flex-none items-center justify-center font-mono opacity-50 transition-opacity duration-200 text-fg-faint"
aria-hidden
>
◎
</span>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
onFocus={() => setError(null)}
placeholder="铜镜所照之名"
autoComplete="username"
autoFocus
className="min-w-0 flex-1 bg-transparent px-3 py-[11px] text-sm text-fg outline-none placeholder:text-[13px]"
required
/>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
delay: 0.58,
}}
className="flex flex-col gap-1.5"
>
<label
htmlFor="password"
className="text-[11.5px] font-medium tracking-[0.04em] text-fg-dim"
>
密令
</label>
<div
className="flex items-center rounded-[4px] border transition-all duration-200"
style={{
borderColor: error ? "var(--cinnabar-dim)" : "var(--rule)",
boxShadow: error
? "0 0 0 1px var(--cinnabar-dim)"
: undefined,
background:
"var(--input-bg, color-mix(in srgb, var(--bg) 70%, transparent))",
}}
>
<span
className="flex w-9 flex-none items-center justify-center font-mono opacity-50 transition-opacity duration-200 text-fg-faint"
aria-hidden
>
◉
</span>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onFocus={() => setError(null)}
placeholder="铭文锁钥"
autoComplete="current-password"
className="min-w-0 flex-1 bg-transparent px-3 py-[11px] text-sm text-fg outline-none placeholder:text-[13px]"
required
/>
</div>
</motion.div>
{/* 错误消息 */}
{error && (
<motion.div
initial={{ opacity: 0, x: -4 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="flex items-center gap-2 rounded-[4px] px-3.5 py-2.5 text-[12.5px]"
style={{
border: "1px solid var(--cinnabar-dim)",
background: "rgba(184,57,46,0.07)",
color: "var(--cinnabar)",
}}
role="alert"
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
aria-hidden
className="flex-none"
>
<circle
cx="8"
cy="8"
r="6"
stroke="currentColor"
strokeWidth="1.2"
/>
<path
d="M8 5v3M8 10.5v.5"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
/>
</svg>
{error}
</motion.div>
)}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
delay: 0.66,
}}
>
<button
type="submit"
disabled={isLoading}
className="mt-1 flex w-full items-center justify-center gap-2 rounded-[4px] px-5 py-3 font-serif text-[15px] font-semibold tracking-[0.1em] transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-55"
style={{
background: "var(--cinnabar)",
border: "1px solid var(--cinnabar-dim)",
color: "#ece6d8",
boxShadow: isLoading ? undefined : undefined,
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.boxShadow =
"0 0 20px var(--cinnabar-dim)";
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "none";
}}
>
{isLoading ? (
<span className="inline-flex items-center gap-2">
<span className="inline-block h-[7px] w-[7px] animate-pulse rounded-full bg-current opacity-100" />
登入灵台
</span>
) : (
"登入"
)}
</button>
</motion.div>
</form>
<p className="mb-4 mt-3 text-center text-xs text-muted">
尚未有缘?{" "}
<a href="/register" className="text-patina hover:underline">
注册魂镜
</a>
</p>
{/* 脚注 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.4,
ease: [0.16, 1, 0.3, 1],
delay: 0.74,
}}
className="mt-8 border-t border-rule pt-4 text-center"
>
<p className="font-mono text-[9.5px] tracking-[0.22em] text-fg-faint">
琉璃镜 · 照见数字生命
</p>
</motion.div>
</div>
</motion.div>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={null}>
<LoginForm />
</Suspense>
);
}
+146
View File
@@ -0,0 +1,146 @@
import Link from "next/link";
import { BronzeMirror } from "@/components/ui/BronzeMirror";
import { serverApi } from "@/lib/api/server-client";
import { FEATURE_FLAGS } from "@/lib/config";
export const dynamic = "force-dynamic";
const QUICK_LINKS = [
{
href: "/chat",
label: "对话",
desc: "与数字生命交谈",
gradient: "from-patina/20 to-patina/5",
},
{
href: "/characters",
label: "人物",
desc: "查看当前激活人物",
gradient: "from-gold/20 to-gold/5",
},
{
href: "/memory",
label: "记忆",
desc: "浏览长期记忆",
gradient: "from-cinnabar/20 to-cinnabar/5",
},
{
href: "/tools",
label: "工具",
desc: "能力包与工具调用",
gradient: "from-patina/15 to-patina/5",
},
];
export default async function HomePage() {
let reachable = true;
try {
// Server Component 直连后端健康检查(不走 BFF 代理——httpOnly cookie 不传递)
await serverApi.get("/health");
} catch {
reachable = false;
}
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center overflow-y-auto px-6 py-12">
{/* 中心品牌区 */}
<div className="flex w-full max-w-lg flex-col items-center text-center">
<BronzeMirror
size={88}
state={reachable ? "steady" : "breathing"}
className="mb-6"
/>
<h1 className="font-serif text-3xl font-semibold tracking-[0.15em] text-fg">
魂镜
</h1>
<p className="mt-2 text-sm tracking-wider text-muted">数字生命控制台</p>
<p className="mt-4 max-w-sm text-sm leading-relaxed text-fg/70">
点亮灵台,与你的数字生命对话;照见它的记忆、人格与运行轨迹。
</p>
{/* 后端状态指示 */}
{!reachable && (
<div className="glass mt-6 w-full rounded-2xl border border-cinnabar/20 px-5 py-4 text-left">
<div className="flex items-center gap-2">
<span className="h-2 w-2 shrink-0 rounded-full bg-cinnabar" />
<p className="text-sm font-medium text-cinnabar">后端未响应</p>
</div>
<p className="mt-1.5 text-xs leading-relaxed text-muted">
启动后端后刷新页面即可进入。也可在{" "}
<code className="rounded bg-fg/10 px-1.5 py-0.5 font-mono text-[11px]">
frontend/.env.local
</code>{" "}
中设置{" "}
<code className="font-mono text-[11px]">
NEXT_PUBLIC_ENABLE_MOCK=true
</code>{" "}
先行浏览界面。
</p>
</div>
)}
{reachable && (
<p className="mt-5 inline-flex items-center gap-1.5 rounded-full bg-success/10 px-3 py-1 text-xs font-medium text-success">
<span className="h-1.5 w-1.5 rounded-full bg-success" />
后端已就绪
</p>
)}
</div>
{/* 快捷入口卡片组 */}
<div className="mt-10 grid w-full max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2">
{QUICK_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="group relative overflow-hidden rounded-2xl border bg-gradient-to-br p-5 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
style={{ borderColor: "var(--glass-border)" }}
>
<div
className={`absolute inset-0 bg-gradient-to-br ${link.gradient} opacity-60`}
/>
<div className="relative">
<p className="font-serif text-base font-semibold text-fg">
{link.label}
</p>
<p className="mt-1 text-xs text-muted">{link.desc}</p>
</div>
</Link>
))}
</div>
{/* 底部功能导航 */}
<div className="mt-10 flex flex-wrap items-center justify-center gap-x-6 gap-y-2 text-xs text-muted/60">
<Link href="/personality" className="transition-colors hover:text-fg">
人格
</Link>
<Link href="/evolution" className="transition-colors hover:text-fg">
演化
</Link>
{FEATURE_FLAGS.drivePanel && (
<Link href="/drive" className="transition-colors hover:text-fg">
内驱力
</Link>
)}
{FEATURE_FLAGS.worldPanel && (
<Link href="/world" className="transition-colors hover:text-fg">
世界
</Link>
)}
{FEATURE_FLAGS.timePanel && (
<Link href="/time" className="transition-colors hover:text-fg">
时态
</Link>
)}
<Link href="/archive" className="transition-colors hover:text-fg">
归档
</Link>
<Link href="/settings" className="transition-colors hover:text-fg">
设置
</Link>
</div>
</div>
);
}
+256
View File
@@ -0,0 +1,256 @@
"use client";
import { FormEvent, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { motion } from "framer-motion";
import { useAuthStore } from "@/lib/stores/auth-store";
/**
* 铜镜徽 LOGO
*/
function LogoMark({ size = 68 }: { size?: number }) {
const s = size;
return (
<svg width={s} height={s} viewBox="0 0 68 68" fill="none" aria-hidden>
<defs>
<radialGradient id="m-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor="var(--patina)" stopOpacity="0.25" />
<stop offset="100%" stopColor="var(--patina)" stopOpacity="0" />
</radialGradient>
</defs>
<circle
cx="34"
cy="34"
r="32"
stroke="var(--gilt)"
strokeWidth="1.2"
opacity="0.6"
/>
<circle
cx="34"
cy="34"
r="28"
stroke="var(--patina)"
strokeWidth="1.8"
opacity="0.4"
/>
<circle
cx="34"
cy="34"
r="22"
stroke="var(--gilt)"
strokeWidth="0.8"
opacity="0.35"
/>
<circle
cx="34"
cy="34"
r="15"
stroke="var(--gilt)"
strokeWidth="1.4"
opacity="0.5"
/>
<path
d="M34 10v48M10 34h48"
stroke="var(--patina)"
strokeWidth="0.6"
opacity="0.25"
/>
<circle cx="34" cy="34" r="5" fill="var(--gilt)" opacity="0.3" />
</svg>
);
}
function RegisterForm() {
const router = useRouter();
const login = useAuthStore((s) => s.login);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!username.trim() || !email.trim() || !password) {
setError("请填写所有必填字段。");
return;
}
if (password !== confirm) {
setError("两次输入的密码不一致。");
return;
}
if (password.length < 6) {
setError("密码至少 6 位。");
return;
}
setBusy(true);
setError(null);
try {
// 调用后端注册端点
const resp = await fetch("/api/proxy/v1/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: username.trim(),
email: email.trim(),
password,
}),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}));
throw new Error(body?.error?.message || `注册失败 (${resp.status})`);
}
// 注册成功,自动登录
await login({ username: username.trim(), password });
router.push("/chat");
} catch (err) {
setError(err instanceof Error ? err.message : "注册失败");
} finally {
setBusy(false);
}
}
const inputCls =
"w-full rounded-xl border border-border-glass bg-glass/60 px-4 py-2.5 text-sm text-fg outline-none transition-all duration-200 placeholder:text-muted/50 focus:border-patina/60 focus:shadow-[0_0_0_3px] focus:shadow-patina/15";
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-3.5">
{error && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-lg bg-cinnabar/10 px-3.5 py-2 text-xs text-cinnabar"
>
{error}
</motion.div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-muted">
用户名
</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="如 jingwu"
className={inputCls}
autoComplete="username"
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">
邮箱
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
className={inputCls}
autoComplete="email"
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">
密码
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="至少 6 位"
className={inputCls}
autoComplete="new-password"
minLength={6}
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-muted">
确认密码
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
placeholder="再次输入密码"
className={inputCls}
autoComplete="new-password"
minLength={6}
required
/>
</div>
<button
type="submit"
disabled={busy}
className="mt-1 w-full rounded-xl bg-patina py-2.5 text-sm font-medium text-white transition-all hover:bg-patina-bright disabled:opacity-50"
>
{busy ? "注册中…" : "创建账号"}
</button>
<p className="mt-1 text-center text-xs text-muted">
已有账号?{" "}
<Link href="/login" className="text-patina hover:underline">
登录
</Link>
</p>
</form>
);
}
export default function RegisterPage() {
return (
<div className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4">
{/* 背景氛围 */}
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(54vmax 54vmax at 12% 4%, rgba(111,163,137,0.15), transparent 60%), radial-gradient(40vmax 40vmax at 92% 8%, rgba(201,162,74,0.08), transparent 60%), radial-gradient(60vmax 60vmax at 50% 112%, rgba(184,57,46,0.04), transparent 60%)",
}}
/>
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="relative w-full max-w-sm"
>
{/* 玻璃卡片 */}
<div
className="rounded-2xl border p-8 backdrop-blur-2xl"
style={{
borderColor: "var(--rule)",
background: "var(--surface)",
boxShadow: "0 8px 40px rgba(0,0,0,0.25)",
}}
>
{/* LOGO */}
<div className="mb-5 flex justify-center">
<LogoMark size={60} />
</div>
<h1 className="mb-1 text-center font-serif text-lg font-semibold text-fg">
注册魂镜
</h1>
<p className="mb-6 text-center text-xs text-muted">
创建你的数字生命伙伴
</p>
<RegisterForm />
</div>
</motion.div>
</div>
);
}