完善前端
This commit is contained in:
1 parent
7c57d27494
commit
b63542add3
81 files changed
+12075
-1441
No files matched your search
@@ -0,0 +1,218 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user