初始化提交
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user