初始化提交

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

No files matched your search

@@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader } from "@/components/ui/Card";
import { extractionApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import { formatDateTime } from "@/lib/utils/format";
import type { MemoryCandidate, WorldEntity } from "@/types/api";
/**
* 候选审核面板(跨来源统一入口)。
* 记忆候选(cycle 大周天 / import 资料导入)与世界实体候选逐条 approve/reject。
* 审核后 router.refresh()。与 ImportWizard 的批量审核互补——此处为跨来源统一待审队列。
*/
export function CandidateReviewPanel({
memoryCandidates,
worldProposals,
}: {
memoryCandidates: MemoryCandidate[];
worldProposals: WorldEntity[];
}) {
const router = useRouter();
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function reviewMemory(id: string, approve: boolean) {
setBusyId(id);
setError(null);
try {
if (approve) await extractionApi.approveMemoryCandidate(id);
else await extractionApi.rejectMemoryCandidate(id);
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusyId(null);
}
}
async function reviewWorld(id: string, approve: boolean) {
setBusyId(id);
setError(null);
try {
if (approve) await extractionApi.approveWorldEntity(id);
else await extractionApi.rejectWorldEntity(id);
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusyId(null);
}
}
return (
<div className="space-y-4">
<Card className="p-5">
<CardHeader
title="记忆候选"
subtitle="来自大周天反思与资料导入的待审记忆,逐条裁定。"
/>
{memoryCandidates.length === 0 ? (
<p className="text-sm text-muted">暂无待审记忆候选。</p>
) : (
<ul className="space-y-2">
{memoryCandidates.map((c) => (
<li key={c.id} className="rounded-lg bg-glass/40 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold">
{c.type}
</span>
<span className="font-serif text-sm font-medium text-fg">
{c.title}
</span>
<span className="text-[10px] text-muted">
{c.import_job_id ? "资料导入" : c.cycle_id ? "大周天" : ""}
{` · ${formatDateTime(c.created_at)}`}
</span>
<span className="ml-auto font-mono text-[10px] text-muted">
{(c.confidence ?? 0).toFixed(2)}
</span>
</div>
<p className="mt-1 text-xs text-fg/80">{c.content}</p>
<div className="mt-1.5 flex gap-2">
<Button
variant="primary"
size="sm"
loading={busyId === c.id}
onClick={() => reviewMemory(c.id, true)}
>
通过
</Button>
<Button
variant="danger"
size="sm"
loading={busyId === c.id}
onClick={() => reviewMemory(c.id, false)}
>
驳回
</Button>
</div>
</li>
))}
</ul>
)}
</Card>
<Card className="p-5">
<CardHeader
title="世界实体候选"
subtitle="待确认的世界实体,通过后进入世界模型(confirmed)。"
/>
{worldProposals.length === 0 ? (
<p className="text-sm text-muted">暂无待审世界实体候选。</p>
) : (
<ul className="space-y-2">
{worldProposals.map((e) => (
<li key={e.id} className="rounded-lg bg-glass/40 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded bg-gold/15 px-1.5 py-0.5 text-[10px] text-gold">
{e.type}
</span>
<span className="font-serif text-sm font-medium text-fg">
{e.name}
</span>
<span className="ml-auto font-mono text-[10px] text-muted">
{e.confidence.toFixed(2)}
</span>
</div>
<div className="mt-1.5 flex gap-2">
<Button
variant="primary"
size="sm"
loading={busyId === e.id}
onClick={() => reviewWorld(e.id, true)}
>
通过
</Button>
<Button
variant="danger"
size="sm"
loading={busyId === e.id}
onClick={() => reviewWorld(e.id, false)}
>
驳回
</Button>
</div>
</li>
))}
</ul>
)}
</Card>
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -0,0 +1,113 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { BronzeMirror } from "@/components/ui/BronzeMirror";
import { describeError } from "@/lib/utils/error";
/**
* 角色头像组件:展示头像图片(优先)或铜镜占位,hover 显示上传按钮。
* 上传后调用 API 更新,成功后 refresh 页面。
*/
export function CharacterAvatar({
characterId,
avatarUrl,
name,
}: {
characterId: string;
avatarUrl?: string | null;
name: string;
}) {
const router = useRouter();
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
// 客户端校验
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (!allowed.includes(file.type)) {
setError("仅支持 JPG / PNG / WebP 格式");
return;
}
if (file.size > 5 * 1024 * 1024) {
setError("文件不能超过 5MB");
return;
}
setUploading(true);
setError(null);
try {
const formData = new FormData();
formData.append("file", file);
const res = await fetch(
`/api/proxy/v1/characters/${characterId}/avatar`,
{ method: "POST", body: formData },
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error?.message || `上传失败 (${res.status})`);
}
router.refresh();
} catch (err) {
setError(describeError(err));
} finally {
setUploading(false);
// 重置 input 允许重复选同一文件
if (inputRef.current) inputRef.current.value = "";
}
}
return (
<div className="group relative">
{/* 头像 */}
<div className="relative h-16 w-16 overflow-hidden rounded-full border-2 border-gilt shadow-lg shadow-gilt/20">
{avatarUrl ? (
<Image
src={avatarUrl}
alt={name}
fill
className="object-cover"
sizes="64px"
/>
) : (
<BronzeMirror size={64} state="steady" />
)}
</div>
{/* Hover 上传遮罩 */}
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100 disabled:opacity-0"
title="上传头像"
>
<span className="text-xs font-medium text-white">
{uploading ? "…" : "📷"}
</span>
</button>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleFile}
/>
{error && (
<p className="absolute left-0 top-full mt-1 whitespace-nowrap text-xs text-cinnabar">
{error}
</p>
)}
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { Card } from "@/components/ui/Card";
import { CharacterStatusBadge } from "@/components/ui/StatusBadge";
import { cn } from "@/lib/utils/cn";
import type { CharacterListItem } from "@/types/api";
/** 人物列表卡片:悬停轻浮,spring 过渡 */
export function CharacterCard({
character,
active = false,
}: {
character: CharacterListItem;
active?: boolean;
}) {
const initial = (character.display_name || character.name).charAt(0);
return (
<motion.div
whileHover={{ y: -4 }}
transition={{ type: "spring", stiffness: 300, damping: 22 }}
>
<Link href={`/characters/${character.id}`} className="block">
<Card
className={cn(
"relative p-5 transition-all duration-200",
active && "ring-2 ring-patina/40",
!active && "hover:border-patina/30",
)}
>
{/* 活跃指示器 */}
{active && (
<span className="absolute right-3 top-3 rounded-full bg-patina/15 px-2 py-0.5 text-[10px] font-medium text-patina">
当前
</span>
)}
{/* 头像 + 名称行 */}
<div className="flex items-center gap-3">
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl text-sm font-bold text-white",
active ? "bg-patina" : "bg-overlay-hover text-muted",
)}
>
{initial}
</div>
<div className="min-w-0 flex-1">
<h3 className="truncate font-serif text-base font-bold text-fg">
{character.display_name || character.name}
</h3>
<p className="truncate text-[11px] text-muted">
{character.name !== character.display_name && character.name}
</p>
</div>
<CharacterStatusBadge status={character.status} />
</div>
{/* 详情行 */}
<div className="mt-4 flex items-center gap-4 border-t border-border-glass pt-3 text-xs text-muted">
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-patina/40" />
记忆 {character.memory_count ?? 0}
</span>
<span className="flex items-center gap-1">
<span className="h-1.5 w-1.5 rounded-full bg-gold/40" />
人格 v{character.personality_version ?? "-"}
</span>
{character.activated_at && (
<span className="ml-auto text-[10px] text-muted/60">
{new Date(character.activated_at).toLocaleDateString("zh-CN")}
</span>
)}
</div>
</Card>
</Link>
</motion.div>
);
}
@@ -0,0 +1,261 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { charactersApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { CharacterTemplateSummary } from "@/types/api";
/**
* 创建人物入口(自包含):触发按钮 + 表单 Modal。
*
* 双模式:
* - 管理员:从后端获取系统模板列表,可选模板快速填充(也可空白创建)。
* - 普通用户:简化表单(仅 name/display_name/background),模板列表 403 时静默降级。
* name 必填。提交带幂等键(crypto.randomUUID,存 ref 防 StrictMode 双调用)。
*/
export function CharacterCreateDialog() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [templates, setTemplates] = useState<CharacterTemplateSummary[] | null>(
null,
);
const [isAdmin, setIsAdmin] = useState(false);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
const [background, setBackground] = useState("");
const [speakingStyle, setSpeakingStyle] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const idempotencyRef = useRef<string | null>(null);
// 组件挂载时获取模板列表(只取一次)
const fetchedRef = useRef(false);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
charactersApi
.listCharacterTemplates()
.then((data) => {
setTemplates(data);
setIsAdmin(true);
})
.catch(() => {
/* 普通用户:403 静默降级,templates 保持 null */
setTemplates([]);
setIsAdmin(false);
});
}, []);
function openDialog() {
setError(null);
setOpen(true);
}
function close() {
setOpen(false);
setName("");
setDisplayName("");
setBackground("");
setSpeakingStyle("");
setSelectedKey(null);
setError(null);
idempotencyRef.current = null;
}
/** 选中某个模板 → 自动填充字段 */
function selectTemplate(key: string) {
if (!templates) return;
const tpl = templates.find((t) => t.key === key);
if (!tpl) return;
setSelectedKey(key);
setName(tpl.name);
setDisplayName(tpl.display_name ?? "");
setBackground("");
setSpeakingStyle(tpl.speaking_style ?? "");
}
/** 取消模板选择 → 清空填充 */
function clearTemplate() {
setSelectedKey(null);
setName("");
setDisplayName("");
setBackground("");
setSpeakingStyle("");
}
async function submit() {
if (!name.trim()) {
setError("请填写人物内部名称。");
return;
}
if (!idempotencyRef.current) {
idempotencyRef.current = crypto.randomUUID();
}
setBusy(true);
setError(null);
try {
const created = await charactersApi.createCharacter(
{
name: name.trim(),
display_name: displayName.trim() || undefined,
template_key: isAdmin ? (selectedKey ?? undefined) : undefined,
profile: {
background: background.trim() || undefined,
speaking_style: speakingStyle.trim() || undefined,
},
},
idempotencyRef.current,
);
close();
router.push(`/characters/${created.id}`);
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
const inputCls =
"mt-1 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
return (
<>
<Button variant="primary" size="sm" onClick={openDialog}>
创建人物
</Button>
<Modal
open={open}
onClose={close}
title={isAdmin ? "创建人物" : "新建空白人物"}
footer={
<>
<Button variant="ghost" size="sm" onClick={close}>
取消
</Button>
<Button variant="primary" size="sm" loading={busy} onClick={submit}>
创建
</Button>
</>
}
>
<div className="space-y-4">
{/* ── 管理员:模板选择 ── */}
{isAdmin && (
<div>
<label className="text-xs text-muted">
选择模板(可选,快速填充)
</label>
{templates === null ? (
<p className="mt-1 text-xs text-muted">加载模板中…</p>
) : templates.length > 0 ? (
<div className="mt-1 grid grid-cols-2 gap-2">
{templates.map((tpl) => {
const active = selectedKey === tpl.key;
return (
<button
key={tpl.key}
type="button"
onClick={() =>
active ? clearTemplate() : selectTemplate(tpl.key)
}
className={`rounded-lg border p-2 text-left text-xs transition ${
active
? "border-accent-gold bg-accent-gold/10"
: "border-border-glass bg-glass hover:border-accent-gold/50"
}`}
>
<span className="block font-medium text-fg">
{tpl.display_name ?? tpl.name}
</span>
{tpl.temperament && (
<span className="mt-0.5 block text-muted">
{tpl.temperament}
</span>
)}
{tpl.description && (
<span className="mt-0.5 block truncate text-muted/70">
{tpl.description}
</span>
)}
</button>
);
})}
<button
type="button"
onClick={clearTemplate}
className={`rounded-lg border p-2 text-left text-xs transition ${
selectedKey === null
? "border-accent-gold bg-accent-gold/10"
: "border-border-glass bg-glass hover:border-muted/50"
}`}
>
<span className="block font-medium text-fg">空白创建</span>
<span className="mt-0.5 block text-muted">
自定义全部内容
</span>
</button>
</div>
) : (
<p className="mt-1 text-xs text-muted">
未加载到可选模板,将创建空白人物。
</p>
)}
</div>
)}
{/* ── 表单字段 ── */}
{!isAdmin && (
<div className="rounded-xl border border-dashed border-gilt-dim bg-gilt-faint/30 px-3 py-2 text-xs text-gilt">
创建空白人物后,可在详情页完善档案。
</div>
)}
<div>
<label className="text-xs text-muted">
内部名称 <span className="text-cinnabar">*</span>
</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="如 jingwu"
className={inputCls}
/>
</div>
<div>
<label className="text-xs text-muted">显示名</label>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="如 镜梧"
className={inputCls}
/>
</div>
<div>
<label className="text-xs text-muted">背景</label>
<textarea
value={background}
onChange={(e) => setBackground(e.target.value)}
rows={2}
className={inputCls}
/>
</div>
{isAdmin && (
<div>
<label className="text-xs text-muted">表达风格</label>
<input
value={speakingStyle}
onChange={(e) => setSpeakingStyle(e.target.value)}
className={inputCls}
/>
</div>
)}
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
</Modal>
</>
);
}
@@ -0,0 +1,102 @@
import { Card } from "@/components/ui/Card";
import { CharacterStatusBadge } from "@/components/ui/StatusBadge";
import { CharacterAvatar } from "@/components/characters/CharacterAvatar";
import type { Character } from "@/types/api";
import { formatDateTime } from "@/lib/utils/format";
/**
* 角色卡 —— 人物详情页顶部的视觉化身份卡片。
* 展示头像(图片或铜镜占位)、名称、状态、关键档案字段与背景故事。
*/
export function CharacterIdentityCard({ character }: { character: Character }) {
const profile = character.profile ?? {};
const mood = character.current_mood;
return (
<Card className="overflow-hidden">
{/* 横幅背景 */}
<div className="h-24 bg-gradient-to-r from-patina/20 via-glass to-accent-gold/10" />
<div className="relative px-5 pb-5">
{/* 头像(叠在横幅边界) */}
<div className="absolute -top-10 left-5">
<CharacterAvatar
characterId={character.id}
avatarUrl={character.avatar_url}
name={character.display_name || character.name}
/>
</div>
{/* 名称与状态 */}
<div className="flex items-end justify-between pt-8">
<div className="min-w-0">
<h2 className="truncate font-serif text-xl font-bold text-fg">
{character.display_name || character.name}
</h2>
{character.display_name && (
<p className="text-xs text-muted">内部名称:{character.name}</p>
)}
</div>
<CharacterStatusBadge status={character.status} />
</div>
{/* 关键档案字段:两列网格 */}
{(profile.birthday ||
profile.height ||
profile.weight ||
mood ||
character.language) && (
<dl className="mt-4 grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
{profile.birthday && (
<InfoItem label="生日" value={profile.birthday} />
)}
{profile.height && <InfoItem label="身高" value={profile.height} />}
{profile.weight && <InfoItem label="体重" value={profile.weight} />}
{mood && (
<InfoItem
label="当前心情"
value={`${mood.mood}(${Math.round((mood.intensity ?? 0) * 100)}%)`}
/>
)}
{character.language && (
<InfoItem label="语言" value={character.language} />
)}
<InfoItem
label="创建时间"
value={formatDateTime(character.created_at)}
/>
</dl>
)}
{/* 表达风格 */}
{profile.speaking_style && (
<div className="mt-4">
<p className="mb-1 text-xs font-medium text-muted">表达风格</p>
<p className="text-sm italic text-fg/80">
「{profile.speaking_style}」
</p>
</div>
)}
{/* 背景故事 */}
{profile.background && (
<div className="mt-4 border-t border-border-glass pt-4">
<p className="mb-1 text-xs font-medium text-muted">背景</p>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-fg/90">
{profile.background}
</p>
</div>
)}
</div>
</Card>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs text-muted">{label}</dt>
<dd className="font-medium text-fg">{value}</dd>
</div>
);
}
@@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { charactersApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { Character } from "@/types/api";
/**
* 人物生命周期操作条:按当前状态渲染可执行动作。
* active → 暂停 / 归档;paused → 恢复 / 归档;
* archived 及初始态(wuji/named/seeded/initialized)→ 激活。
* 归档为不可逆倾向操作,二次确认;写操作后 router.refresh() 以服务端数据为准。
*/
export function CharacterLifecycleBar({ character }: { character: Character }) {
const router = useRouter();
const [busy, setBusy] = useState(false);
const [confirmArchive, setConfirmArchive] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run(action: () => Promise<unknown>) {
setBusy(true);
setError(null);
try {
await action();
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
const { id, status } = character;
const initial = [
"wuji",
"named",
"seeded",
"initialized",
"archived",
].includes(status);
return (
<div className="flex flex-wrap items-center gap-2">
{status === "active" && (
<Button
variant="secondary"
size="sm"
loading={busy}
onClick={() => run(() => charactersApi.pauseCharacter(id))}
>
暂停
</Button>
)}
{status === "paused" && (
<Button
variant="primary"
size="sm"
loading={busy}
onClick={() => run(() => charactersApi.resumeCharacter(id))}
>
恢复
</Button>
)}
{initial && (
<Button
variant="primary"
size="sm"
loading={busy}
onClick={() => run(() => charactersApi.activateCharacter(id))}
>
激活
</Button>
)}
{status !== "archived" && (
<Button
variant="danger"
size="sm"
loading={busy}
onClick={() => setConfirmArchive(true)}
>
归档
</Button>
)}
<Modal
open={confirmArchive}
onClose={() => setConfirmArchive(false)}
title="确认归档此人物?"
footer={
<>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmArchive(false)}
>
取消
</Button>
<Button
variant="danger"
size="sm"
loading={busy}
onClick={() => {
setConfirmArchive(false);
run(() => charactersApi.archiveCharacter(id));
}}
>
确认归档
</Button>
</>
}
>
归档后该人物不可对话,但可随时重新激活。此操作会留痕。
</Modal>
{error && <p className="text-xs text-cinnabar">{error}</p>}
</div>
);
}
@@ -0,0 +1,201 @@
"use client";
import { CharacterLifecycleBar } from "./CharacterLifecycleBar";
import type { Character } from "@/types/api";
/**
* 魂册 · 镜面卡(设计稿 characters-preview.html §soul-card)。
*
* 镜面材质 Hero + 圆形镜面头像 + 七阶生命周期轨 + 档案字段网格 + 关键指标侧栏。
* 设计稿三层镜面纹理(face-sheen 光泽 + sweep-gradient 静态漫反射 + grain 颗粒)
* 通过 soul-card / mirror-sweep / mirror-grain CSS 复合实现。
*/
export function CharacterProfilePanel({ character }: { character: Character }) {
const profile = character.profile ?? {};
const mood = character.current_mood;
const displayName = character.display_name || character.name;
const initial = displayName.charAt(0);
const lifecycle = character.status;
// 生命周期站点(设计稿 .lifecycle-track)
const STATIONS = [
{ key: "wuji", label: "无极" },
{ key: "named", label: "得名" },
{ key: "seeded", label: "播种" },
{ key: "initialized", label: "降生" },
{ key: "active", label: "起居" },
{ key: "paused", label: "息影" },
{ key: "archived", label: "归藏" },
] as const;
const currentIdx = STATIONS.findIndex((s) => s.key === lifecycle);
const stationIndex = currentIdx >= 0 ? currentIdx : -1;
return (
<div className="soul-card">
{/* 镜面静态光泽层(::before 由 soul-card 提供) */}
{/* 铜面颗粒层(::after 由 soul-card 提供) */}
{/* ===== 上层:头像 + 名称 + 情绪 + 标签 ===== */}
<div className="soul-top">
{/* 铜镜圆形头像(设计稿 .soul-mirror) */}
<div className="soul-mirror mirror-sweep mirror-grain">
<div className="inner">{initial}</div>
<div className="halo" />
</div>
{/* 名称区 */}
<div className="soul-id">
<h2 className="soul-name">{displayName}</h2>
{character.display_name && (
<p className="soul-realname">
{character.name}
<span className="ml-3 font-mono text-[10px] text-fg-faint">
ID · {character.id.slice(0, 8)}
</span>
</p>
)}
{/* 情绪(设计稿 .soul-mood) */}
{mood && (
<div className="soul-mood">
<span className="pulse-dot" />
<span>{mood.mood}</span>
<span className="font-mono text-[11px] text-fg-faint">
{Math.round((mood.intensity ?? 0) * 100)}%
</span>
</div>
)}
{/* 标签行 */}
<div className="mt-2.5 flex flex-wrap items-center gap-2">
{character.language && (
<span className="soul-tag">{character.language}</span>
)}
{character.status === "active" && (
<span className="soul-tag bg-patina-wash text-patina">
· 在场
</span>
)}
</div>
</div>
{/* 生命周期操作(右侧) */}
<div className="ml-auto shrink-0">
<CharacterLifecycleBar character={character} />
</div>
</div>
{/* ===== 生命周期轨(设计稿 .lifecycle) ===== */}
<div className="lifecycle">
<div className="lifecycle-label">生命周期 · Lifecycle</div>
<div className="lifecycle-track">
{STATIONS.map((s, i) => {
const reached = i <= stationIndex && stationIndex >= 0;
const current = i === stationIndex;
return (
<div key={s.key} className="flex items-center">
<div
className={`lc-station ${reached ? "reached" : ""} ${current ? "current" : ""}`}
>
<div className="lc-dot" />
<span className="lc-name">{s.label}</span>
</div>
{i < STATIONS.length - 1 && (
<div className={`lc-link ${reached ? "reached" : ""}`} />
)}
</div>
);
})}
</div>
</div>
{/* ===== 档案字段网格 + 关键指标(设计稿 .soul-grid) ===== */}
<div className="soul-grid">
{/* 左栏:字段网格 */}
<div>
<div className="soul-fields">
<Field label="语言" value={character.language} />
<Field label="表达风格" value={profile.speaking_style} />
<Field label="生日" value={profile.birthday} />
<Field label="身高" value={profile.height} />
<Field label="体重" value={profile.weight} />
<Field label="创建时间" value={formatDate(character.created_at)} />
<Field
label="模型策略"
value={
character.model_policy?.default_chat_model_hint ??
"default-chat"
}
/>
<Field
label="隐私等级"
value={character.model_policy?.privacy_level ?? "—"}
/>
</div>
{profile.background && (
<div className="soul-bg">
<div className="lab">背景 · Background</div>
<p>{profile.background}</p>
</div>
)}
</div>
{/* 右栏:关键指标(设计稿 .soul-stats) */}
<div className="soul-stats">
<div className="soul-stat">
<div className="k">记忆 · Memory</div>
<div className="v">
—<small>条</small>
</div>
</div>
<div className="soul-stat">
<div className="k">人格版本 · Personality</div>
<div className="v">
{character.current_personality_version_id
? `v${character.current_personality_version_id.slice(0, 7)}`
: "—"}
<small>{character.status === "active" ? "活跃中" : ""}</small>
</div>
</div>
<div className="soul-stat">
<div className="k">状态 · Status</div>
<div className="v">
{character.status}
<small>{character.language ?? ""}</small>
</div>
</div>
{mood && (
<div className="soul-stat">
<div className="k">情绪强度 · Intensity</div>
<div className="v">
{Math.round((mood.intensity ?? 0) * 100)}
<small>%</small>
</div>
</div>
)}
</div>
</div>
</div>
);
}
function Field({ label, value }: { label: string; value?: string }) {
return (
<dl className="field">
<dt>{label}</dt>
<dd>{value || "—"}</dd>
</dl>
);
}
function formatDate(iso?: string): string {
if (!iso) return "—";
try {
const d = new Date(iso);
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`;
} catch {
return iso;
}
}
@@ -0,0 +1,100 @@
"use client";
import { useState } from "react";
import { Card, CardHeader } from "@/components/ui/Card";
import { importsApi } from "@/lib/api";
import { formatDateTime } from "@/lib/utils/format";
import type { ImportItem, ImportJob } from "@/types/api";
/**
* 导入历史列表(客户端组件,支持展开查看条目)。
*/
export function ImportHistory({
characterId,
initialHistory,
}: {
characterId: string;
initialHistory: ImportJob[];
}) {
const [expandedId, setExpandedId] = useState<string | null>(null);
const [itemsMap, setItemsMap] = useState<Record<string, ImportItem[]>>({});
async function toggleExpand(jobId: string) {
if (expandedId === jobId) {
setExpandedId(null);
return;
}
setExpandedId(jobId);
if (!itemsMap[jobId]) {
try {
const result = await importsApi.listImportItems(characterId, jobId);
setItemsMap((m) => ({ ...m, [jobId]: result.items }));
} catch {
setItemsMap((m) => ({ ...m, [jobId]: [] }));
}
}
}
if (initialHistory.length === 0) return null;
return (
<Card className="p-5">
<CardHeader
title="导入历史"
subtitle={`共 ${initialHistory.length} 次导入`}
/>
<ul className="space-y-2">
{initialHistory.map((job) => {
const expanded = expandedId === job.id;
const items = itemsMap[job.id];
return (
<li key={job.id} className="rounded-lg bg-glass/40 px-3 py-2">
<button
type="button"
onClick={() => toggleExpand(job.id)}
className="flex w-full items-center gap-2 text-left"
>
<span
className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${
job.status === "completed" || job.status === "reviewing"
? "bg-patina/15 text-patina"
: job.status === "parsed"
? "bg-gilt/15 text-gilt"
: "bg-glass text-muted"
}`}
>
{job.status}
</span>
<span className="text-xs text-fg-dim">{job.material_type}</span>
<span className="text-xs text-muted">{job.title || "-"}</span>
<span className="ml-auto text-[11px] text-muted">
{formatDateTime(job.created_at)}
</span>
<span className="text-xs text-muted">
{expanded ? "▲" : "▼"}
</span>
</button>
{expanded && items && (
<ul className="mt-2 space-y-1 border-t border-border-glass pt-2">
{items.length === 0 ? (
<li className="text-xs text-muted">加载中…</li>
) : (
items.map((it) => (
<li key={it.id} className="text-xs text-fg/80">
<span className="text-[10px] text-muted">
[{it.item_type}]
</span>{" "}
{it.content}
</li>
))
)}
</ul>
)}
</li>
);
})}
</ul>
</Card>
);
}
@@ -0,0 +1,444 @@
"use client";
import { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader } from "@/components/ui/Card";
import { importsApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import { formatDateTime } from "@/lib/utils/format";
import type {
ImportItem,
ImportJob,
MemoryCandidate,
ProfileCandidate,
} from "@/types/api";
import type { MaterialType } from "@/types/domain";
const MATERIAL_OPTIONS: MaterialType[] = [
"background_story",
"character_profile",
"chat_history",
"role_card",
"world_setting",
"style_sample",
"diary",
"other",
];
/**
* 资料导入向导(§10,挂在 /characters/{id}/imports 下)。
* 支持 文本粘贴 / 文件上传 两种方式。
* 流程:创建 → 解析条目 → 触发候选抽取 → 勾选候选批量裁定。
*/
export function ImportWizard({ characterId }: { characterId: string }) {
const router = useRouter();
const [tab, setTab] = useState<"text" | "file">("text");
const [materialType, setMaterialType] =
useState<MaterialType>("background_story");
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [file, setFile] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [job, setJob] = useState<ImportJob | null>(null);
const [items, setItems] = useState<ImportItem[]>([]);
const [profileCandidates, setProfileCandidates] = useState<
ProfileCandidate[]
>([]);
const [memoryCandidates, setMemoryCandidates] = useState<MemoryCandidate[]>(
[],
);
const [decisions, setDecisions] = useState<
Record<string, "approve" | "reject">
>({});
const idempotencyRef = useRef<string | null>(null);
async function submitImport() {
if (tab === "text") {
if (!content.trim()) {
setError("请粘贴资料正文。");
return;
}
} else {
if (!file) {
setError("请选择文件。");
return;
}
}
if (!idempotencyRef.current) idempotencyRef.current = crypto.randomUUID();
setBusy(true);
setError(null);
try {
let created: ImportJob;
if (tab === "text") {
created = await importsApi.createImport(
characterId,
{
material_type: materialType,
content: content.trim(),
title: title.trim() || undefined,
},
idempotencyRef.current,
);
} else {
created = await importsApi.uploadImport(characterId, file!, {
title: title.trim() || undefined,
});
}
setJob(created);
const fetchedItems = await importsApi.listImportItems(
characterId,
created.id,
);
setItems(fetchedItems.items);
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
async function extract() {
if (!job) return;
setBusy(true);
setError(null);
try {
await Promise.all([
importsApi.extractProfileCandidates(characterId, job.id),
importsApi.extractMemoryCandidates(characterId, job.id),
importsApi.generatePersonalityProposals(characterId, job.id),
]);
const [profile, memory] = await Promise.all([
importsApi.listProfileCandidates(characterId, job.id),
importsApi.listMemoryCandidates(characterId, job.id),
]);
setProfileCandidates(profile.items);
setMemoryCandidates(memory.items);
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
function setDecision(id: string, decision: "approve" | "reject") {
setDecisions((d) => {
const next = { ...d };
if (next[id] === decision) delete next[id];
else next[id] = decision;
return next;
});
}
async function batchReview() {
if (!job) return;
const decisionList = Object.entries(decisions).map(
([candidate_id, decision]) => ({ candidate_id, decision }),
);
if (decisionList.length === 0) {
setError("请先勾选候选并裁定。");
return;
}
setBusy(true);
setError(null);
try {
const profileIds = decisionList.filter((d) =>
d.candidate_id.startsWith("profile_candidate_"),
);
const memoryIds = decisionList.filter((d) =>
d.candidate_id.startsWith("memory_candidate_"),
);
if (profileIds.length > 0)
await importsApi.batchReviewProfileCandidates(characterId, {
decisions: profileIds,
});
if (memoryIds.length > 0)
await importsApi.batchReviewMemoryCandidates(characterId, {
decisions: memoryIds,
});
setDecisions({});
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
function reset() {
setJob(null);
setItems([]);
setProfileCandidates([]);
setMemoryCandidates([]);
setDecisions({});
setTitle("");
setContent("");
setFile(null);
idempotencyRef.current = null;
}
const inputCls =
"mt-1 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
return (
<Card className="p-5">
<CardHeader
title="资料导入"
subtitle="粘贴文本或上传文件,解析后抽取候选并批量审核。"
/>
{!job ? (
<div className="space-y-3">
{/* 方式切换 */}
<div className="flex gap-2 border-b border-border-glass pb-2">
<button
type="button"
onClick={() => setTab("text")}
className={`rounded-lg px-3 py-1 text-xs font-medium transition ${
tab === "text"
? "bg-patina/15 text-patina"
: "text-muted hover:text-fg"
}`}
>
📝 粘贴文本
</button>
<button
type="button"
onClick={() => setTab("file")}
className={`rounded-lg px-3 py-1 text-xs font-medium transition ${
tab === "file"
? "bg-patina/15 text-patina"
: "text-muted hover:text-fg"
}`}
>
📁 上传文件
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="text-xs text-muted">资料类型</label>
<select
value={materialType}
onChange={(e) =>
setMaterialType(e.target.value as MaterialType)
}
className={inputCls}
>
{MATERIAL_OPTIONS.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<div>
<label className="text-xs text-muted">标题(可选)</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className={inputCls}
/>
</div>
</div>
{tab === "text" ? (
<div>
<label className="text-xs text-muted">资料正文</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={6}
className={inputCls}
placeholder="粘贴人物设定、背景故事、对话记录…"
/>
</div>
) : (
<div>
<label className="text-xs text-muted">选择文件</label>
<div
onClick={() => fileInputRef.current?.click()}
className="mt-1 flex cursor-pointer items-center justify-center rounded-lg border-2 border-dashed border-border-glass bg-glass px-4 py-6 text-sm text-muted transition-colors hover:border-patina/50"
>
{file ? (
<span>
📄 {file.name} ({(file.size / 1024).toFixed(1)} KB)
</span>
) : (
<span>点击选择文件(文本 / 角色卡 / 音频样本)</span>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept=".txt,.md,.json,.yaml,.yml,.csv,.html"
className="hidden"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</div>
)}
<Button
variant="primary"
size="sm"
loading={busy}
onClick={submitImport}
>
提交并解析
</Button>
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
<span className="rounded bg-patina/15 px-1.5 py-0.5 text-patina">
{job.status}
</span>
<span>{job.material_type}</span>
<span>· {items.length} 条</span>
<span className="ml-auto">{formatDateTime(job.created_at)}</span>
</div>
{items.length > 0 && (
<details className="rounded-lg bg-glass/40 px-3 py-2">
<summary className="cursor-pointer text-xs text-muted">
解析条目({items.length})
</summary>
<ul className="mt-2 space-y-1 text-sm">
{items.map((it) => (
<li key={it.id} className="text-fg/80">
<span className="text-[10px] text-muted">
[{it.item_type}/{it.role}]
</span>{" "}
{it.content}
</li>
))}
</ul>
</details>
)}
{(profileCandidates.length > 0 || memoryCandidates.length > 0) && (
<>
{profileCandidates.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted">
档案候选({profileCandidates.length})
</p>
{profileCandidates.map((c) => (
<CandidateRow
key={c.id}
label={`${c.field}:${c.value}`}
confidence={c.confidence}
decision={decisions[c.id]}
disabled={busy}
onToggle={(d) => setDecision(c.id, d)}
/>
))}
</div>
)}
{memoryCandidates.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted">
记忆候选({memoryCandidates.length})
</p>
{memoryCandidates.map((c) => (
<CandidateRow
key={c.id}
label={c.title}
sub={c.content}
confidence={c.confidence}
decision={decisions[c.id]}
disabled={busy}
onToggle={(d) => setDecision(c.id, d)}
/>
))}
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
loading={busy}
onClick={batchReview}
>
提交裁定({Object.keys(decisions).length})
</Button>
<Button variant="ghost" size="sm" onClick={reset}>
导入新资料
</Button>
</div>
</>
)}
{profileCandidates.length === 0 && memoryCandidates.length === 0 && (
<Button
variant="secondary"
size="sm"
loading={busy}
onClick={extract}
>
抽取档案 / 记忆 / 人格候选
</Button>
)}
</div>
)}
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</Card>
);
}
function CandidateRow({
label,
sub,
confidence,
decision,
disabled,
onToggle,
}: {
label: string;
sub?: string;
confidence: number;
decision?: "approve" | "reject";
disabled?: boolean;
onToggle: (d: "approve" | "reject") => void;
}) {
return (
<div className="rounded-lg bg-glass/40 px-3 py-2">
<div className="flex items-center gap-2">
<span className="text-sm text-fg">{label}</span>
<span className="ml-auto font-mono text-[10px] text-muted">
{(confidence ?? 0).toFixed(2)}
</span>
</div>
{sub && <p className="mt-0.5 text-xs text-fg/70">{sub}</p>}
<div className="mt-1.5 flex gap-2">
<button
type="button"
disabled={disabled}
onClick={() => onToggle("approve")}
className={`rounded px-2 py-0.5 text-[11px] disabled:opacity-50 ${
decision === "approve"
? "bg-patina/30 text-patina"
: "bg-glass text-muted"
}`}
>
通过
</button>
<button
type="button"
disabled={disabled}
onClick={() => onToggle("reject")}
className={`rounded px-2 py-0.5 text-[11px] disabled:opacity-50 ${
decision === "reject"
? "bg-cinnabar/30 text-cinnabar"
: "bg-glass text-muted"
}`}
>
驳回
</button>
</div>
</div>
);
}
@@ -0,0 +1,186 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader } from "@/components/ui/Card";
import { charactersApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { Character, UpdateModelPolicyRequest } from "@/types/api";
type HintKey =
| "default_chat_model_hint"
| "memory_extraction_model_hint"
| "personality_analysis_model_hint"
| "embedding_model_hint";
interface HintOption {
value: string;
label: string;
}
interface HintField {
key: HintKey;
label: string;
options: HintOption[];
}
const HINT_FIELDS: HintField[] = [
{
key: "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" },
],
},
{
key: "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" },
],
},
{
key: "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" },
],
},
{
key: "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" },
],
},
];
const PRIVACY_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "normal", label: "normal" },
{ value: "sensitive", label: "sensitive" },
{ value: "local_only", label: "local_only" },
];
/**
* 人物模型策略编辑表单:model hints 选择框 + 隐私等级下拉。
* 提交仅发改动字段,成功后 router.refresh()。
*/
export function ModelPolicyEditForm({ character }: { character: Character }) {
const router = useRouter();
const policy = character.model_policy;
const [draft, setDraft] = useState<UpdateModelPolicyRequest>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirtyKeys = Object.keys(draft) as (keyof UpdateModelPolicyRequest)[];
const hasEdits = dirtyKeys.length > 0;
function hintValue(key: HintKey): string {
if (key in draft) return (draft[key] as string | undefined) ?? "";
return (policy?.[key] as string | undefined) ?? "";
}
function privacyValue(): string {
if ("privacy_level" in draft)
return (draft.privacy_level as string | undefined) ?? "normal";
return policy?.privacy_level ?? "normal";
}
function setHint(key: HintKey, v: string) {
setDraft((d) => ({ ...d, [key]: v }));
}
function setPrivacy(v: string) {
setDraft((d) => ({
...d,
privacy_level: v as UpdateModelPolicyRequest["privacy_level"],
}));
}
async function submit() {
setBusy(true);
setError(null);
try {
await charactersApi.updateModelPolicy(character.id, draft);
setDraft({});
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
const selectCls =
"mt-1 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
return (
<Card className="p-5">
<CardHeader
title="模型策略"
subtitle="选择逻辑模型槽位,留空则使用默认。"
/>
<div className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{HINT_FIELDS.map(({ key, label, options }) => (
<div key={key}>
<label className="text-xs text-muted">{label}</label>
<select
value={hintValue(key)}
onChange={(e) => setHint(key, e.target.value)}
className={selectCls}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
))}
<div>
<label className="text-xs text-muted">隐私等级</label>
<select
value={privacyValue()}
onChange={(e) => setPrivacy(e.target.value)}
className={selectCls}
>
{PRIVACY_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
{hasEdits && (
<div className="mt-4 flex items-center gap-2">
<Button variant="primary" size="sm" loading={busy} onClick={submit}>
保存策略
</Button>
<Button variant="ghost" size="sm" onClick={() => setDraft({})}>
放弃
</Button>
<span className="ml-auto text-xs text-muted">
{dirtyKeys.length} 项已改
</span>
</div>
)}
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</Card>
);
}
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
const ONBOARDING_KEY = "hunjing_characters_onboarding_dismissed";
/**
* 首次使用引导提示(客户端组件)。
* 用户关闭后写入 localStorage,下次不再显示。
*/
export function OnboardingBanner() {
const [visible, setVisible] = useState(true);
useEffect(() => {
try {
const dismissed = localStorage.getItem(ONBOARDING_KEY);
if (dismissed) {
// 延迟到下一个 microtask 以避免级联渲染 lint
queueMicrotask(() => setVisible(false));
}
} catch {
/* localStorage 不可用时始终显示 */
}
}, []);
function dismiss() {
setVisible(false);
try {
localStorage.setItem(ONBOARDING_KEY, "1");
} catch {
/* 静默 */
}
}
if (!visible) return null;
return (
<div
className="relative mb-6 overflow-hidden rounded-xl border border-gilt-dim/30 bg-gilt-faint/20 px-5 py-4"
role="alert"
>
<button
type="button"
onClick={dismiss}
className="absolute right-3 top-3 text-xs text-muted/50 transition-colors hover:text-muted"
aria-label="关闭"
>
✕
</button>
<p className="text-sm font-medium text-gilt">欢迎来到魂镜</p>
<p className="mt-1 text-xs text-muted">
你的默认人物已就绪。可以在下方查看所有人物,或创建新的空白人物逐步完善。
管理员可管理系统的角色模板。
</p>
</div>
);
}
@@ -0,0 +1,218 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/Button";
import { Card, CardHeader } from "@/components/ui/Card";
import { charactersApi } from "@/lib/api";
import { describeError } from "@/lib/utils/error";
import type { Character, UpdateProfileRequest } from "@/types/api";
// ── 标签输入组件 ──
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="mt-1 flex flex-wrap items-center gap-1.5 rounded-lg border border-border-glass bg-glass px-2 py-1 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 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-[70px] 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>
);
}
// ── 表单字段定义 ──
type FieldGroup = Array<{
key: keyof UpdateProfileRequest;
label: string;
area?: boolean;
tag?: boolean;
full?: boolean;
}>;
const BASIC_FIELDS: FieldGroup = [
{ key: "display_name", label: "显示名" },
{ key: "description", label: "一句话简介", full: true },
];
const BODY_FIELDS: FieldGroup = [
{ key: "birthday", label: "生日" },
{ key: "height", label: "身高" },
{ key: "weight", label: "体重" },
{ key: "temperament", label: "性情气质" },
{ key: "speaking_style", label: "表达风格" },
{ key: "background", label: "背景", area: true, full: true },
];
const TAG_FIELDS: FieldGroup = [
{ key: "hobbies", label: "兴趣爱好", tag: true },
{ key: "values", label: "价值观", tag: true },
];
/**
* 人物档案编辑表单:draft 模式,提交时仅发改动字段(PATCH /characters/{id}/profile)。
* display_name 属顶层字段,其余属 profile。手动编辑视为用户确认。成功后 router.refresh()。
*/
export function ProfileEditForm({ character }: { character: Character }) {
const router = useRouter();
const profile = character.profile ?? {};
const [draft, setDraft] = useState<UpdateProfileRequest>({});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const dirtyKeys = Object.keys(draft) as (keyof UpdateProfileRequest)[];
const hasEdits = dirtyKeys.length > 0;
function valueFor(key: keyof UpdateProfileRequest): string {
if (key in draft) return (draft[key] as string | undefined) ?? "";
if (key === "display_name") return character.display_name ?? "";
return (profile[key as keyof typeof profile] as string | undefined) ?? "";
}
function listFor(key: "hobbies" | "values"): string[] {
if (key in draft) return (draft[key] as string[]) ?? [];
const raw = profile[key];
return Array.isArray(raw) ? (raw as string[]) : [];
}
function setField(key: keyof UpdateProfileRequest, v: string) {
setDraft((d) => ({ ...d, [key]: v }));
}
function setList(key: "hobbies" | "values", v: string[]) {
setDraft((d) => ({ ...d, [key]: v }));
}
async function submit() {
setBusy(true);
setError(null);
try {
await charactersApi.updateProfile(character.id, draft);
setDraft({});
router.refresh();
} catch (e) {
setError(describeError(e));
} finally {
setBusy(false);
}
}
const inputCls =
"mt-1 w-full rounded-lg border border-border-glass bg-glass px-2 py-1 text-sm text-fg";
return (
<Card className="p-5">
<CardHeader
title="人物档案"
subtitle="手动编辑视为用户确认,直接写入。"
/>
<div className="space-y-4">
{/* 基础字段:两列 */}
<div className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{BASIC_FIELDS.map(({ key, label, full }) => (
<div key={key} className={full ? "sm:col-span-2" : ""}>
<label className="text-xs text-muted">{label}</label>
<input
value={valueFor(key)}
onChange={(e) => setField(key, e.target.value)}
className={inputCls}
/>
</div>
))}
</div>
{/* 身体 + 性格字段 */}
<div className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{BODY_FIELDS.map(({ key, label, area, full }) => (
<div key={key} className={full ? "sm:col-span-2" : ""}>
<label className="text-xs text-muted">{label}</label>
{area ? (
<textarea
value={valueFor(key)}
onChange={(e) => setField(key, e.target.value)}
rows={3}
className={inputCls}
/>
) : (
<input
value={valueFor(key)}
onChange={(e) => setField(key, e.target.value)}
className={inputCls}
/>
)}
</div>
))}
</div>
{/* 标签字段 */}
<div className="grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{TAG_FIELDS.map(({ key, label }) => (
<div key={key}>
<label className="text-xs text-muted">{label}</label>
<TagInput
values={listFor(key as "hobbies" | "values")}
onChange={(v) => setList(key as "hobbies" | "values", v)}
placeholder={`输入${label}后回车…`}
/>
</div>
))}
</div>
</div>
{hasEdits && (
<div className="mt-4 flex items-center gap-2 border-t border-border-glass pt-4">
<Button variant="primary" size="sm" loading={busy} onClick={submit}>
保存档案
</Button>
<Button variant="ghost" size="sm" onClick={() => setDraft({})}>
放弃
</Button>
<span className="ml-auto text-xs text-muted">
{dirtyKeys.length} 项已改
</span>
</div>
)}
{error && <p className="mt-2 text-xs text-cinnabar">{error}</p>}
</Card>
);
}