219 lines
10 KiB
TypeScript
219 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|