Files
hunjing/frontend/src/components/characters/CharactersPagePanel.tsx
T
2026-09-21 11:19:07 +08:00

219 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { api, ApiError, type Character, type CursorPage } from "@/lib/api";
import { PanelState } from "@/lib/queries";
/** 角色状态 → 徽章(稳定值来自后端,文化名前端本地化) */
const STATUS_LABEL: Record<string, { cn: string; plain: string; tone: "info" | "muted" | "warn" | "ok" }> = {
draft: { cn: "灵胚", plain: "草稿", tone: "warn" },
active: { cn: "临照", plain: "运行中", tone: "info" },
resting: { cn: "静修", plain: "停止运行", tone: "muted" },
archived: { cn: "归藏", plain: "已隐藏", tone: "muted" },
deceased: { cn: "死亡", plain: "永久闭合", tone: "muted" },
};
function StatusBadge({ status }: { status: string }) {
const label = STATUS_LABEL[status] ?? { cn: status, plain: "未知状态", tone: "muted" as const };
return (
<span className={`badge badge--${label.tone}`} style={{ fontSize: 11, padding: "1px 8px" }}>
<span className="badge__dot" />
{label.cn} · {label.plain}
</span>
);
}
/** 镜生页:左列真实列表 + 右列真实详情(选中联动) */
export function CharactersPagePanel() {
const qc_client = { key: "characters" };
const [selectedId, setSelectedId] = useState<string | null>(null);
const [name, setName] = useState("");
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const list = useQuery({
queryKey: [qc_client.key],
queryFn: () => api<CursorPage<Character>>("/v1/characters?limit=20"),
});
const detail = useQuery({
queryKey: ["character", selectedId],
enabled: !!selectedId,
queryFn: () => api<Character>(`/v1/characters/${selectedId}`),
});
async function create(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) return;
setError(null);
try {
const c = await api<Character>("/v1/characters", { method: "POST", json: { name: name.trim(), summary: "" } });
setName("");
setCreating(false);
list.refetch();
setSelectedId(c.id);
} catch (err) {
setError(err instanceof ApiError ? err.message : "创建失败");
}
}
async function activate(id: string) {
try {
await api(`/v1/characters/${id}/activations`, { method: "POST", json: {} });
list.refetch();
detail.refetch();
} catch (err) {
setError(err instanceof ApiError ? err.message : "启镜失败");
}
}
const items = list.data?.data ?? [];
return (
<div className="char-layout">
<div>
<div className="card" style={{ padding: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "4px 8px 10px" }}>
<span className="cn" style={{ fontSize: 15, fontWeight: 600 }}>
镜生<span className="cn__plain">{items.length} 位角色</span>
</span>
<button className="btn btn--outline btn--sm" onClick={() => setCreating((v) => !v)}>创建</button>
</div>
{list.isPending && <p style={{ fontSize: 13, color: "var(--text-3)", padding: "18px 8px" }}>正在读取…</p>}
{list.isError && (
<div style={{ padding: "18px 8px" }}>
{(list.error as ApiError).status === 401 || (list.error as ApiError).status === 403 ? (
<>
<p style={{ fontSize: 13.5, color: "var(--text-2)", marginBottom: 12 }}>尚未登入,无法读取你的镜域。</p>
<a className="btn btn--primary btn--sm" href="/login">去登入</a>
</>
) : (
<>
<p style={{ fontSize: 13, color: "var(--err)", marginBottom: 10 }}>读取失败</p>
<button className="btn btn--outline btn--sm" onClick={() => list.refetch()}>重试</button>
</>
)}
</div>
)}
{items.length === 0 && !list.isPending && !list.isError && (
<div style={{ padding: "22px 12px", textAlign: "center" }}>
<p style={{ fontSize: 13.5, color: "var(--text-2)", marginBottom: 4 }}>镜域里还没有角色。</p>
<p style={{ fontSize: 12, color: "var(--text-3)" }}>从一位灵胚(角色草稿)开始——可删除,不等于角色死亡。</p>
</div>
)}
{items.map((c) => (
<div
key={c.id}
className={selectedId === c.id ? "char-list-item is-active" : "char-list-item"}
style={{ cursor: "pointer" }}
onClick={() => setSelectedId(c.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter") setSelectedId(c.id); }}
aria-current={selectedId === c.id ? "true" : undefined}
>
<span className="avatar">{c.name.slice(0, 1)}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="char-list-item__name">{c.name}</div>
<div className="char-list-item__sub">
{c.status === "draft" ? "灵胚 · 未启镜" : c.status} · {new Date(c.created_at).toLocaleDateString("zh-CN")}
</div>
</div>
<StatusBadge status={c.status} />
</div>
))}
{creating && (
<form style={{ padding: "10px 8px 6px", display: "flex", gap: 8 }} onSubmit={create}>
<input className="input" placeholder="角色名,如:青梧" value={name} onChange={(e) => setName(e.target.value)} aria-label="新角色名" autoFocus />
<button className="btn btn--primary btn--sm" type="submit" disabled={!name.trim()}>创建灵胚</button>
</form>
)}
{error && <p style={{ fontSize: 12, color: "var(--err)", padding: "4px 8px" }}>{error}</p>}
</div>
</div>
<div>
{!selectedId && (
<div className="card">
<div className="card__head">
<span className="cn">镜生<span className="cn__plain">选择一位角色查看详情</span></span>
</div>
<p style={{ fontSize: 13, color: "var(--text-3)" }}>
详情包含道体、内驱力(可编辑)、大周天与生命周期操作——全部来自真实接口。
</p>
</div>
)}
{selectedId && (
<PanelState pending={detail.isPending} error={detail.error} onRetry={() => detail.refetch()}>
{(() => {
const c = detail.data as Character;
if (!c) return null;
return (
<div>
<div className="char-banner" aria-hidden="true" />
<div className="char-head">
<span className="avatar avatar--lg">{c.name.slice(0, 1)}</span>
<div className="char-head__title">
<h2 style={{ display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap" }}>
{c.name} <StatusBadge status={c.status} />
</h2>
<div className="char-head__motto mono" style={{ fontSize: 12 }}>{c.id}</div>
</div>
<div className="char-head__actions">
{c.status === "draft" && (
<button className="btn btn--primary" onClick={() => activate(c.id)}>启镜 · 正式创建</button>
)}
<a className="btn btn--outline" href="/conversation">进入镜树</a>
</div>
</div>
<section className="section card">
<div className="card__head">
<span className="cn">道体<span className="cn__plain">稳定身份基线</span></span>
</div>
<p style={{ fontSize: 13.5, color: "var(--text-2)" }}>
{Object.keys(c.identity_baseline ?? {}).length === 0
? "道体尚未设定——启镜并完成初照自陈后形成首个版本。"
: JSON.stringify(c.identity_baseline, null, 2)}
</p>
</section>
<section className="section card">
<div className="card__head">
<span className="cn">内驱力<span className="cn__plain">动机维度 · 可编辑(PATCH drives)</span></span>
</div>
<p style={{ fontSize: 13.5, color: "var(--text-2)" }}>
{(c.drives ?? []).length === 0 ? "尚未声明内驱力维度。" : JSON.stringify(c.drives, null, 2)}
</p>
</section>
<section className="section card">
<div className="card__head">
<span className="cn">生命周期<span className="cn__plain">与数据删除分离</span></span>
</div>
<div style={{ display: "flex", gap: 10, flexWrap: "wrap" }}>
{["静修 · 停止运行", "归藏 · 隐藏可恢复", "复照 · 从旧版本重开"].map((op) => (
<button key={op} className="btn btn--outline btn--sm" title="生命周期操作端点接入后启用">{op}</button>
))}
{["铸魂 · 重大重塑(强确认)", "死亡 · 永久闭合(冷静期)"].map((op) => (
<button key={op} className="btn btn--danger btn--sm" title="生命周期操作端点接入后启用">{op}</button>
))}
</div>
<p style={{ fontSize: 12, color: "var(--text-3)", marginTop: 10 }}>
铸魂与死亡走强确认与谱系留痕;后端 lifecycle 端点(POST /v1/characters/{"{id}"}/{"{action}"})已具备,交互流程待接入。
</p>
</section>
</div>
);
})()}
</PanelState>
)}
</div>
</div>
);
}