初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user