This commit is contained in:
476
src/components/puzzle-result/PuzzleResultView.tsx
Normal file
476
src/components/puzzle-result/PuzzleResultView.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
|
||||
import type {
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleResultViewProps = {
|
||||
session: PuzzleAgentSessionSnapshot;
|
||||
author: AuthUser | null;
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onExecuteAction: (payload: PuzzleAgentActionRequest) => void;
|
||||
};
|
||||
|
||||
type PuzzleImageStudioModalProps = {
|
||||
draft: PuzzleResultDraft;
|
||||
isBusy: boolean;
|
||||
onClose: () => void;
|
||||
onGenerate: (promptText?: string | null) => void;
|
||||
onSelectCandidate: (candidateId: string) => void;
|
||||
};
|
||||
|
||||
function normalizeThemeTagInput(value: string) {
|
||||
return value
|
||||
.split(/[\n,,、]/u)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function publishBlockedReason(session: PuzzleAgentSessionSnapshot) {
|
||||
if (!session.resultPreview) {
|
||||
return [];
|
||||
}
|
||||
return session.resultPreview.blockers.map((entry) => entry.message);
|
||||
}
|
||||
|
||||
function PuzzleImageStudioModal({
|
||||
draft,
|
||||
isBusy,
|
||||
onClose,
|
||||
onGenerate,
|
||||
onSelectCandidate,
|
||||
}: PuzzleImageStudioModalProps) {
|
||||
const [promptText, setPromptText] = useState(draft.summary);
|
||||
|
||||
return (
|
||||
<div className="platform-overlay fixed inset-0 z-[95] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="platform-modal-shell w-full max-w-5xl overflow-hidden rounded-[1.8rem]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] px-4 py-4">
|
||||
<div className="text-lg font-black text-[var(--platform-text-strong)]">
|
||||
拼图图片工坊
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
用当前锚点生成候选图,再选择一张作为正式图。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 px-4 py-4 lg:grid-cols-[20rem_minmax(0,1fr)]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
PROMPT
|
||||
</div>
|
||||
<textarea
|
||||
value={promptText}
|
||||
disabled={isBusy}
|
||||
rows={7}
|
||||
onChange={(event) => {
|
||||
setPromptText(event.target.value);
|
||||
}}
|
||||
className="mt-3 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onGenerate(promptText.trim() || undefined);
|
||||
}}
|
||||
className="mt-3 inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-2.5 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
生成 2 张候选图
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
当前正式图
|
||||
</div>
|
||||
<div className="mt-3 aspect-square overflow-hidden rounded-[1.2rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
|
||||
{draft.coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={draft.coverImageSrc}
|
||||
alt={draft.levelName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-white/66">
|
||||
还没有正式图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0">
|
||||
{draft.candidates.length > 0 ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{draft.candidates.map((candidate) => (
|
||||
<button
|
||||
key={candidate.candidateId}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onSelectCandidate(candidate.candidateId);
|
||||
}}
|
||||
className={`overflow-hidden rounded-[1.35rem] border text-left transition ${
|
||||
candidate.selected
|
||||
? 'border-amber-500 bg-amber-50 shadow-[0_18px_45px_rgba(180,83,9,0.14)]'
|
||||
: 'border-[var(--platform-subpanel-border)] bg-white/78'
|
||||
}`}
|
||||
>
|
||||
<div className="aspect-square overflow-hidden bg-[var(--platform-subpanel-fill)]">
|
||||
<ResolvedAssetImage
|
||||
src={candidate.imageSrc}
|
||||
alt={draft.levelName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
候选图 {candidate.candidateId.split('-').pop()}
|
||||
</div>
|
||||
{candidate.selected ? (
|
||||
<span className="rounded-full bg-amber-600 px-2.5 py-1 text-[0.68rem] font-semibold text-white">
|
||||
已应用
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="line-clamp-3 text-xs leading-5 text-[var(--platform-text-base)]">
|
||||
{candidate.actualPrompt || candidate.prompt}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[22rem] items-center justify-center rounded-[1.35rem] border border-dashed border-[var(--platform-subpanel-border)] bg-white/52 px-6 text-center text-sm text-[var(--platform-text-base)]">
|
||||
先生成候选图,再从这里确认正式图片。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t border-[var(--platform-subpanel-border)] px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isBusy}
|
||||
className="rounded-full border border-[var(--platform-subpanel-border)] px-4 py-2 text-sm font-semibold text-[var(--platform-text-base)] disabled:opacity-45"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼图结果页最小工作台。
|
||||
* 支持标题、摘要、标签编辑,候选图生成与发布,不额外扩成大表单系统。
|
||||
*/
|
||||
export function PuzzleResultView({
|
||||
session,
|
||||
author,
|
||||
isBusy = false,
|
||||
error = null,
|
||||
onBack,
|
||||
onExecuteAction,
|
||||
}: PuzzleResultViewProps) {
|
||||
const draft = session.draft;
|
||||
const preview = session.resultPreview;
|
||||
const [isStudioOpen, setIsStudioOpen] = useState(false);
|
||||
const [levelName, setLevelName] = useState(draft?.levelName ?? '');
|
||||
const [summary, setSummary] = useState(draft?.summary ?? '');
|
||||
const [themeTagsText, setThemeTagsText] = useState(
|
||||
draft?.themeTags.join(',') ?? '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
setLevelName(draft.levelName);
|
||||
setSummary(draft.summary);
|
||||
setThemeTagsText(draft.themeTags.join(','));
|
||||
}, [draft]);
|
||||
|
||||
const tagList = useMemo(
|
||||
() => normalizeThemeTagInput(themeTagsText),
|
||||
[themeTagsText],
|
||||
);
|
||||
const blockers = useMemo(() => publishBlockedReason(session), [session]);
|
||||
const qualityFindings = preview?.qualityFindings ?? [];
|
||||
const publishReady = Boolean(preview?.publishReady);
|
||||
|
||||
if (!draft) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="platform-subpanel rounded-2xl px-5 py-4 text-sm text-[var(--platform-text-base)]">
|
||||
还没有可编辑的拼图草稿
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-6xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
|
||||
<div className="platform-result-hero relative overflow-hidden rounded-[1.8rem] border border-amber-100/16 bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.18),transparent_32%),linear-gradient(135deg,rgba(76,29,19,0.98),rgba(15,23,42,0.98))] px-4 py-4 text-white sm:px-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="返回"
|
||||
onClick={onBack}
|
||||
disabled={isBusy}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-white/10 text-white/84 disabled:opacity-45"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setIsStudioOpen(true);
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
图片工坊
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy || !levelName.trim()}
|
||||
onClick={() => {
|
||||
onExecuteAction({
|
||||
action: 'publish_puzzle_work',
|
||||
levelName: levelName.trim(),
|
||||
summary: summary.trim(),
|
||||
themeTags: tagList,
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
发布到广场
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="text-2xl font-black leading-tight sm:text-3xl">
|
||||
拼图结果页
|
||||
</div>
|
||||
<div className="mt-2 max-w-2xl text-sm leading-6 text-amber-50/76">
|
||||
标题、标签和正式图都会进入广场与后续关卡链。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-[1.15rem] border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div className="min-h-0 overflow-y-auto pr-1">
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<section className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
正式封面
|
||||
</div>
|
||||
<div className="mt-3 aspect-square overflow-hidden rounded-[1.35rem] bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))]">
|
||||
{draft.coverImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
src={draft.coverImageSrc}
|
||||
alt={levelName || draft.levelName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-white/66">
|
||||
还没有正式图
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setIsStudioOpen(true);
|
||||
}}
|
||||
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-2.5 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
生成或更换图片
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="space-y-3 rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div>
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
关卡名
|
||||
</div>
|
||||
<input
|
||||
value={levelName}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => {
|
||||
setLevelName(event.target.value);
|
||||
}}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
Agent 理解摘要
|
||||
</div>
|
||||
<textarea
|
||||
value={summary}
|
||||
disabled={isBusy}
|
||||
rows={5}
|
||||
onChange={(event) => {
|
||||
setSummary(event.target.value);
|
||||
}}
|
||||
className="mt-2 w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||||
题材标签
|
||||
</div>
|
||||
<input
|
||||
value={themeTagsText}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => {
|
||||
setThemeTagsText(event.target.value);
|
||||
}}
|
||||
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/86 px-3 py-3 text-sm text-[var(--platform-text-strong)] outline-none"
|
||||
/>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{tagList.length > 0 ? (
|
||||
tagList.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded-full border border-amber-300/35 bg-amber-100/68 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-[var(--platform-text-soft)]">
|
||||
输入 3 到 6 个中文短标签,使用逗号分隔。
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="min-h-0 space-y-3 overflow-y-auto">
|
||||
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
作者预览
|
||||
</div>
|
||||
<div className="mt-3 rounded-[1.1rem] bg-white/76 px-4 py-4">
|
||||
<div className="text-lg font-black text-[var(--platform-text-strong)]">
|
||||
{author?.displayName || '玩家'}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-[var(--platform-text-base)]">
|
||||
关卡 HUD 将显示作者名与关卡名。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
发布校验
|
||||
</div>
|
||||
{publishReady ? (
|
||||
<div className="mt-3 rounded-[1.1rem] bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">
|
||||
已达到发布条件
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{blockers.length > 0 ? (
|
||||
blockers.map((message) => (
|
||||
<div
|
||||
key={message}
|
||||
className="rounded-[1rem] bg-amber-50 px-3 py-2 text-sm text-amber-700"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="rounded-[1rem] bg-white/76 px-3 py-2 text-sm text-[var(--platform-text-base)]">
|
||||
等待完善结果页
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{qualityFindings.length > 0 ? (
|
||||
<div className="rounded-[1.45rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)] p-4">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
质量提醒
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{qualityFindings.map((finding) => (
|
||||
<div
|
||||
key={finding.id}
|
||||
className="rounded-[1rem] bg-white/76 px-3 py-2 text-sm text-[var(--platform-text-base)]"
|
||||
>
|
||||
{finding.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{isStudioOpen ? (
|
||||
<PuzzleImageStudioModal
|
||||
draft={draft}
|
||||
isBusy={isBusy}
|
||||
onClose={() => {
|
||||
setIsStudioOpen(false);
|
||||
}}
|
||||
onGenerate={(promptText) => {
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_images',
|
||||
promptText,
|
||||
candidateCount: 2,
|
||||
});
|
||||
}}
|
||||
onSelectCandidate={(candidateId) => {
|
||||
onExecuteAction({
|
||||
action: 'select_puzzle_image',
|
||||
candidateId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PuzzleResultView;
|
||||
Reference in New Issue
Block a user