Files
hunjing/frontend/src/app/(app)/characters/[characterId]/page.tsx
T
chenxin b419e0afde M7:形仪与语音——真形锚点、候选定相、声音档案与克隆授权、ASR 接入
- 形仪域 domains/xingyi:form_anchors(稳定辨识特征/年龄表现/许可禁忌/可变范围——生成内容不可静默覆盖锚点);forms(主仪/化仪/文字形仪,candidate→adopt 定相治理:未定相不进呈现位、主仪唯一、新主仪自动退役旧主仪);voice_profiles(三来源,授权克隆必填用途与传播范围,revoke-grant 撤回立即生效,assert-usable 合成前置校验拒绝撤回/过期)
- ASR:元炁 SenseVoice /v1/audio/transcriptions 透传(Whisper 兼容 multipart);YUANQI_BASE_URL 未配置时 503 不降级不伪造——多模态关闭纪律
- 前端形仪工坊(人物详情页):真形锚点编辑(特征逐行解析)/候选登记定相废弃/声音档案建立与授权撤回;头像呈现位改走定相主仪(forms/primary);xingyi api 库 + mock + 组件 4 用例
- 退役:旧 avatar 模块(services/endpoints/avatar、avatar_dir 配置、avatar_url 字段、前端直传组件与测试)
- 验收(test_m7_xingyi 三用例):未定相不成为正式形仪(含主仪唯一与旧主仪 retired 断言);克隆授权可撤回(缺用途拒/撤回即拒/过期同理/预设不受约束);关闭全部多模态后文字相伴完整可用(ASR 503 时镜树缘契百工照常——回归)
- 四套件:后端 376、前端 203、BFF 4、神识 5 包全绿(2 个预存环境失败不变);迁移 008 净库+±1 干净
2026-08-27 20:26:52 +08:00

128 lines
4.1 KiB
TypeScript

import Link from "next/link";
import { notFound } from "next/navigation";
import { CharacterIdentityCard } from "@/components/characters/CharacterIdentityCard";
import { CovenantPanel } from "@/components/characters/CovenantPanel";
import { FormStudio } from "@/components/xingyi/FormStudio";
import { CharacterLifecycleBar } from "@/components/characters/CharacterLifecycleBar";
import { ModelPolicyEditForm } from "@/components/characters/ModelPolicyEditForm";
import { ProfileEditForm } from "@/components/characters/ProfileEditForm";
import { PageContainer } from "@/components/layout/PageContainer";
import { ErrorState } from "@/components/shared/StateViews";
import { CharacterStatusBadge } from "@/components/ui/StatusBadge";
import { serverApi } from "@/lib/api/server-client";
import { describeError } from "@/lib/utils/error";
import { formatDateTime } from "@/lib/utils/format";
import type { Covenant } from "@/lib/api/covenants";
import type {
FormAnchorData,
FormData,
VoiceProfileData,
} from "@/lib/api/xingyi";
import type { Character } from "@/types/api";
export const dynamic = "force-dynamic";
/**
* 人物详情页:生命周期操作 + 档案编辑 + 模型策略编辑。
* Server Component 预取 getCharacter;写操作后 router.refresh() 重新拉取,以服务端数据为准。
*/
export default async function CharacterDetailPage({
params,
}: {
params: Promise<{ characterId: string }>;
}) {
const { characterId } = await params;
let character = null;
let covenant: Covenant | null = null;
let error: string | null = null;
try {
character = await serverApi.get<Character>(`/characters/${characterId}`);
} catch (e) {
error = describeError(e);
}
// 缘契独立拉取,失败静默(无缘契是合法状态)
try {
covenant = await serverApi.get<Covenant | null>(
`/covenants/by-character/${characterId}`,
);
} catch {
covenant = null;
}
// 形仪三区独立拉取,失败静默(无形仪是合法状态——启镜只需最小形仪)
let anchor: FormAnchorData = {
character_id: characterId,
features: {},
age_presentation: "adult",
permissions: {},
mutable_scope: {},
};
let forms: FormData[] = [];
let voices: VoiceProfileData[] = [];
let primary: FormData | null = null;
try {
[anchor, forms, voices, primary] = await Promise.all([
serverApi.get<FormAnchorData>(`/characters/${characterId}/form-anchor`),
serverApi.get<FormData[]>(`/characters/${characterId}/forms`),
serverApi.get<VoiceProfileData[]>(`/characters/${characterId}/voices`),
serverApi.get<FormData | null>(
`/characters/${characterId}/forms/primary`,
),
]);
} catch {
/* 空态合法 */
}
if (error) {
return (
<PageContainer title="人物详情">
<ErrorState message={error} />
</PageContainer>
);
}
if (!character) return notFound();
return (
<PageContainer
title={character.display_name || character.name}
description={`内部名称:${character.name} · 创建于 ${formatDateTime(character.created_at)}`}
>
<div className="mb-4 flex flex-wrap items-center gap-3">
<CharacterStatusBadge status={character.status} />
<CharacterLifecycleBar character={character} />
<Link
href={`/characters/${character.id}/materials`}
className="ml-auto rounded-lg border border-border-glass bg-glass px-3 py-1 text-xs text-patina hover:underline"
>
资料与沉淀 →
</Link>
</div>
<CharacterIdentityCard
character={character}
primaryMediaRef={primary?.media_ref ?? null}
/>
<div className="mt-6">
<CovenantPanel characterId={character.id} initialCovenant={covenant} />
</div>
<div className="mt-6">
<FormStudio
characterId={character.id}
initialAnchor={anchor}
initialForms={forms}
initialVoices={voices}
/>
</div>
<div className="mt-6 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ProfileEditForm character={character} />
<ModelPolicyEditForm character={character} />
</div>
</PageContainer>
);
}