init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View 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;