Merge branch 'master' into codex/puzzle-clear-template-runtime-fixes

# Conflicts:
#	.hermes/shared-memory/decision-log.md
#	.hermes/shared-memory/project-overview.md
#	docs/【开发运维】本地开发验证与生产运维-2026-05-15.md
#	scripts/dev.test.ts
#	server-rs/crates/api-server/src/creation_entry_config.rs
#	server-rs/crates/api-server/src/wooden_fish.rs
#	server-rs/crates/module-auth/src/lib.rs
#	server-rs/crates/spacetime-client/src/wooden_fish.rs
#	server-rs/crates/spacetime-module/src/auth/procedures.rs
#	src/components/custom-world-home/creationWorkShelf.ts
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/rpgEntryWorldPresentation.ts
#	src/services/miniGameDraftGenerationProgress.test.ts
#	src/services/miniGameDraftGenerationProgress.ts
This commit is contained in:
2026-06-04 11:24:14 +08:00
451 changed files with 18452 additions and 5266 deletions
@@ -142,12 +142,17 @@ describe('CustomWorldGenerationView', () => {
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('w-[400px]');
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('h-[400px]');
).toContain('max-w-full');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('aspect-square');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
+35 -33
View File
@@ -133,44 +133,14 @@ export function GenerationProgressHero({
const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
return (
<div className="relative mx-auto flex w-full max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
<div className="relative mx-auto flex w-full min-w-0 max-w-[60rem] flex-col items-center px-1 pb-1 pt-1 sm:pt-4">
<div className="sr-only">
{title}
{phaseLabel ? ` ${phaseLabel}` : ''}
</div>
<div className="relative w-full max-w-[56rem] sm:max-w-[60rem]">
<div className="relative w-full min-w-0 max-w-[56rem] sm:max-w-[60rem]">
<div
className="absolute left-0 top-1/2 z-20 w-[min(6.8rem,28vw)] -translate-y-1/2 rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:w-[8rem] sm:px-3 sm:py-2.5"
data-testid="generation-hero-wait-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<Hourglass className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{estimatedWaitText}
</div>
</div>
<div
className="absolute right-0 top-1/2 z-20 w-[min(6.8rem,28vw)] -translate-y-1/2 rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:w-[8rem] sm:px-3 sm:py-2.5"
data-testid="generation-hero-elapsed-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
<Clock3 className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{elapsedText}
</div>
</div>
<div
className="relative mx-auto h-[400px] w-[400px] shrink-0 overflow-visible rounded-full"
className="relative mx-auto aspect-square w-[min(400px,calc(100%_-_0.75rem))] max-w-full shrink-0 overflow-visible rounded-full"
role="progressbar"
aria-label={title}
aria-valuemin={0}
@@ -244,6 +214,38 @@ export function GenerationProgressHero({
</div>
</div>
</div>
<div className="relative z-20 mt-[-0.3rem] grid w-full grid-cols-2 gap-2 px-0.5 sm:absolute sm:inset-0 sm:mt-0 sm:block sm:px-0">
<div
className="w-full rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:absolute sm:left-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 sm:px-3 sm:py-2.5"
data-testid="generation-hero-wait-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<Hourglass className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{estimatedWaitText}
</div>
</div>
<div
className="w-full rounded-[1.1rem] border border-white/58 bg-white/58 px-2.5 py-2 text-center shadow-[0_14px_30px_rgba(112,57,30,0.10)] backdrop-blur-md sm:absolute sm:right-0 sm:top-1/2 sm:w-[8rem] sm:-translate-y-1/2 sm:px-3 sm:py-2.5"
data-testid="generation-hero-elapsed-card"
>
<div className="flex items-center justify-center gap-1.5 text-[#2a1c14]">
<div className="text-[9px] font-black tracking-[0.1em] text-[#7e421f] sm:text-[10px]">
</div>
<Clock3 className="h-3.5 w-3.5 shrink-0" strokeWidth={2.2} />
</div>
<div className="mt-1.5 break-keep text-[12px] font-black leading-tight text-[#161211] sm:text-[13px]">
{elapsedText}
</div>
</div>
</div>
</div>
</div>
);
@@ -130,12 +130,17 @@ describe('BarkBattleGeneratingView', () => {
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('w-[400px]');
).toContain('w-[min(400px,calc(100%_-_0.75rem))]');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('h-[400px]');
).toContain('max-w-full');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('aspect-square');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
@@ -26,6 +26,7 @@ type BigFishAgentWorkspaceProps = {
onBack: () => void;
onSubmitMessage: (payload: SendBigFishMessageRequest) => void;
onExecuteAction: (payload: ExecuteBigFishActionRequest) => void;
showBackButton?: boolean;
};
const BIG_FISH_AGENT_THEME: CreationAgentTheme = {
@@ -87,6 +88,7 @@ export function BigFishAgentWorkspace({
onBack,
onSubmitMessage,
onExecuteAction,
showBackButton = true,
}: BigFishAgentWorkspaceProps) {
return (
<CreationAgentWorkspace
@@ -100,6 +102,7 @@ export function BigFishAgentWorkspace({
isBusy={isBusy}
error={error}
quickActions={createCreationAgentChatQuickActions()}
showBackButton={showBackButton}
onBack={onBack}
onSubmitText={(text) => {
onSubmitMessage(
@@ -0,0 +1,206 @@
import { Mic, Pause, Upload } from 'lucide-react';
import { useRef, useState } from 'react';
export type CreativeAudioAsset = {
assetId: string;
audioSrc: string;
audioObjectKey: string;
assetObjectId: string;
source: string;
prompt?: string | null;
durationMs?: number | null;
};
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
disabled?: boolean;
title: string;
defaultLabel: string;
asset: TAsset | null;
buildRecordedFileName: () => string;
onAssetChange: (asset: TAsset | null) => void;
onError: (message: string | null) => void;
readFileAsAsset?: (
file: File,
source: 'uploaded' | 'recorded',
) => Promise<TAsset>;
};
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
file: File,
source: 'uploaded' | 'recorded',
) {
return new Promise<TAsset>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
reader.onload = () => {
if (typeof reader.result !== 'string') {
reject(new Error('音频读取失败,请重试。'));
return;
}
resolve({
assetId: `local-${source}-${Date.now()}`,
audioSrc: reader.result,
audioObjectKey: '',
assetObjectId: '',
source,
prompt: file.name,
durationMs: null,
} as TAsset);
};
reader.readAsDataURL(file);
});
}
export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
disabled = false,
title,
defaultLabel,
asset,
buildRecordedFileName,
onAssetChange,
onError,
readFileAsAsset = readCreativeAudioFileAsAsset,
}: CreativeAudioInputPanelProps<TAsset>) {
const [isRecording, setIsRecording] = useState(false);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const startRecording = async () => {
if (disabled || isRecording) {
return;
}
try {
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getUserMedia ||
typeof MediaRecorder === 'undefined'
) {
throw new Error('当前浏览器不支持录音。');
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, {
type: recorder.mimeType || 'audio/webm',
});
stream.getTracks().forEach((track) => track.stop());
const file = new File([blob], buildRecordedFileName(), {
type: blob.type,
});
void readFileAsAsset(file, 'recorded')
.then(onAssetChange)
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '录音保存失败。',
);
});
};
recorderRef.current = recorder;
recorder.start();
setIsRecording(true);
onError(null);
} catch (caughtError) {
onError(
caughtError instanceof Error ? caughtError.message : '录音启动失败。',
);
}
};
const stopRecording = () => {
recorderRef.current?.stop();
recorderRef.current = null;
setIsRecording(false);
};
return (
<section className="platform-subpanel rounded-[1.25rem] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-black text-[var(--platform-text-strong)]">
{title}
</div>
{asset ? (
<button
type="button"
onClick={() => onAssetChange(null)}
disabled={disabled}
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs"
>
</button>
) : null}
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<label
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${
disabled ? 'pointer-events-none opacity-55' : ''
}`}
>
<Upload className="h-4 w-4" />
<input
type="file"
accept="audio/*"
disabled={disabled}
className="sr-only"
onChange={(event) => {
const file = event.currentTarget.files?.[0] ?? null;
event.currentTarget.value = '';
if (!file) {
return;
}
void readFileAsAsset(file, 'uploaded')
.then((nextAsset) => {
onError(null);
onAssetChange(nextAsset);
})
.catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频读取失败。',
);
});
}}
/>
</label>
<button
type="button"
disabled={disabled}
onClick={() => {
if (isRecording) {
stopRecording();
return;
}
void startRecording();
}}
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm"
>
{isRecording ? (
<Pause className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
{isRecording ? '停止' : '录音'}
</button>
{asset?.audioSrc ? (
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
) : (
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
{asset ? '音效已选择' : defaultLabel}
</div>
)}
</div>
</section>
);
}
export default CreativeAudioInputPanel;
@@ -101,6 +101,97 @@ test('creative image input panel handles reference uploads and preview', () => {
expect(onSubmit).toHaveBeenCalledTimes(1);
});
test('creative image input panel can opt out of filling the parent height', () => {
const { container } = render(
<CreativeImageInputPanel
fillHeight={false}
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
const panel = container.querySelector('.creative-image-input-panel');
const body = container.querySelector('.creative-image-input-panel__body');
const section = container.querySelector('.creative-image-input-panel__section');
expect(panel?.className).toContain('flex-none');
expect(panel?.className).not.toContain('flex-1');
expect(body?.className).toContain('flex-none');
expect(body?.className).not.toContain('overflow-y-auto');
expect(section?.className).toContain('flex-none');
expect(section?.className).not.toContain('overflow-hidden');
});
test('creative image input panel fills the parent height by default', () => {
const { container } = render(
<CreativeImageInputPanel
uploadedImageSrc=""
uploadedImageAlt="拼图图片"
mainImageInputId="image-upload-input"
promptTextareaId="image-prompt-input"
prompt="旧街灯牌下的猫。"
promptLabel="画面描述"
aiRedraw
promptReferenceImages={[]}
imageModelPicker={null}
submitLabel="生成"
submitDisabled={false}
labels={{
imageField: '拼图画面',
uploadImage: '上传拼图图片',
replaceImage: '更换拼图图片',
emptyImageHint: '上传图片/填写画面描述',
removeImage: '移除拼图图片',
removeImageConfirmTitle: '移除拼图图片?',
removeImageConfirmBody: '移除后需要重新上传图片。',
promptReferenceUpload: '上传参考图',
promptReferencePreviewAlt: '参考图预览',
closePromptReferencePreview: '关闭参考图预览',
}}
onMainImageFileSelect={() => {}}
onMainImageRemove={() => {}}
onAiRedrawChange={() => {}}
onPromptChange={() => {}}
onSubmit={() => {}}
/>,
);
const panel = container.querySelector('.creative-image-input-panel');
const body = container.querySelector('.creative-image-input-panel__body');
const section = container.querySelector('.creative-image-input-panel__section');
expect(panel?.className).toContain('flex-1');
expect(panel?.className).not.toContain('flex-none');
expect(body?.className).toContain('flex-1');
expect(body?.className).toContain('overflow-y-auto');
expect(section?.className).toContain('flex-1');
expect(section?.className).toContain('overflow-hidden');
});
test('creative image input panel confirms before removing uploaded image', () => {
const onMainImageRemove = vi.fn();
@@ -33,6 +33,7 @@ export type CreativeImageInputPanelLabels = {
export type CreativeImageInputPanelProps = {
className?: string;
fillHeight?: boolean;
disabled?: boolean;
isSubmitting?: boolean;
mainImageMode?: 'edit' | 'preview';
@@ -77,6 +78,7 @@ const DEFAULT_PROMPT_REFERENCE_LIMIT = 5;
export function CreativeImageInputPanel({
className = '',
fillHeight = true,
disabled = false,
isSubmitting = false,
mainImageMode = 'edit',
@@ -143,29 +145,48 @@ export function CreativeImageInputPanel({
}
}, [previewReferenceImage, promptReferenceImages]);
const bodyClassName = fillHeight
? 'creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1'
: 'creative-image-input-panel__body puzzle-creation-form-body flex flex-none flex-col overflow-visible pr-0 lg:pr-1';
const sectionClassName = fillHeight
? 'creative-image-input-panel__section puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible'
: 'creative-image-input-panel__section puzzle-creation-form-section flex flex-none flex-col overflow-visible';
const gridSizeClassName = fillHeight ? 'min-h-0 flex-1' : 'flex-none';
const imageFieldClassName = fillHeight
? 'creative-image-input-panel__image-field puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col'
: 'creative-image-input-panel__image-field puzzle-image-field flex min-w-0 flex-none flex-col';
const imageFrameClassName = fillHeight
? 'creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center'
: 'creative-image-input-panel__image-frame puzzle-image-card-frame flex flex-none items-center justify-center';
const imageCardClassName = fillHeight
? 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem] lg:h-auto lg:w-full'
: 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square w-full min-h-[14rem] max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem]';
return (
<div
className={`creative-image-input-panel flex min-h-0 flex-1 flex-col ${className}`}
className={`creative-image-input-panel flex min-h-0 flex-col ${
fillHeight ? 'flex-1' : 'flex-none'
} ${className}`}
>
<div className="creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1">
<section className="creative-image-input-panel__section puzzle-creation-form-section flex min-h-0 flex-1 flex-col overflow-hidden lg:overflow-visible">
<div className={bodyClassName}>
<section className={sectionClassName}>
<div
className={`creative-image-input-panel__grid puzzle-creation-form-grid min-h-0 flex-1 gap-2.5 sm:gap-4 ${
className={`creative-image-input-panel__grid puzzle-creation-form-grid ${gridSizeClassName} gap-2.5 sm:gap-4 ${
showPrompt
? 'flex flex-col lg:grid lg:grid-cols-[minmax(15rem,0.9fr)_minmax(0,1.15fr)]'
: 'flex flex-col lg:grid lg:grid-cols-1'
}`}
>
<div
className={`creative-image-input-panel__image-field puzzle-image-field flex min-h-0 min-w-0 flex-1 flex-col ${
className={`${imageFieldClassName} ${
disabled ? 'opacity-55' : ''
}`}
>
<div className="mb-2 shrink-0 text-sm font-black text-[var(--platform-text-strong)]">
{labels.imageField}
</div>
<div className="creative-image-input-panel__image-frame puzzle-image-card-frame flex min-h-0 flex-1 items-center justify-center">
<div className="creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem] lg:h-auto lg:w-full">
<div className={imageFrameClassName}>
<div className={imageCardClassName}>
{canEditMainImage ? (
<>
<input
@@ -359,7 +359,15 @@ test('creation agent workspace hides hero copy area when title and summary are a
);
expect(screen.queryByText('统一共创')).toBeNull();
expect(screen.getByText('创作进度')).toBeTruthy();
expect(screen.getByText('创作进度').className).toContain(
'creation-agent-hero__progress-label',
);
expect(screen.getByText('60%').className).toContain(
'creation-agent-hero__progress-value',
);
expect(screen.getByText(//u).className).toContain(
'creation-agent-hero__progress-hint',
);
});
test('creation agent workspace stops auto-follow when user scrolls away from bottom', () => {
@@ -78,6 +78,7 @@ type CreationAgentWorkspaceProps = {
referenceImagePreviewSrc?: string | null;
referenceImageLabel?: string | null;
referenceImageError?: string | null;
showBackButton?: boolean;
onBack: () => void;
onSubmitText: (text: string, quickActionKey?: string) => void;
onPrimaryAction: () => void;
@@ -299,6 +300,7 @@ export function CreationAgentWorkspace({
referenceImagePreviewSrc = null,
referenceImageLabel = null,
referenceImageError = null,
showBackButton = true,
onBack,
onSubmitText,
onPrimaryAction,
@@ -465,18 +467,22 @@ export function CreationAgentWorkspace({
return (
<div className="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={`relative overflow-hidden rounded-[1.8rem] px-4 py-4 text-white shadow-[0_20px_60px_rgba(15,23,42,0.18)] sm:px-5 ${theme.heroClass}`}
className={`creation-agent-hero relative overflow-hidden rounded-[1.8rem] px-4 py-4 text-white shadow-[0_20px_60px_rgba(15,23,42,0.18)] sm:px-5 ${theme.heroClass}`}
>
<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>
{showBackButton ? (
<button
type="button"
aria-label="返回"
onClick={onBack}
disabled={isBusy}
className="creation-agent-hero__icon-button inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/20 bg-white/10 text-white/80 disabled:opacity-45"
>
<ArrowLeft className="h-4 w-4" />
</button>
) : (
<span aria-hidden="true" />
)}
{canShowPrimaryAction ? (
<button
type="button"
@@ -498,7 +504,7 @@ export function CreationAgentWorkspace({
</div>
) : null}
{session.assistantSummary ? (
<div className="mt-2 max-w-2xl text-sm leading-6 text-white/76">
<div className="creation-agent-hero__summary mt-2 max-w-2xl text-sm leading-6 text-white/75">
{session.assistantSummary}
</div>
) : null}
@@ -507,15 +513,15 @@ export function CreationAgentWorkspace({
<div className={hasHeroCopy ? 'mt-4' : 'mt-6'}>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-xs font-semibold tracking-[0.14em] text-white/72">
<span className="creation-agent-hero__progress-label text-xs font-semibold tracking-[0.14em] text-white/75">
</span>
<span className="text-sm font-semibold text-white/88">
<span className="creation-agent-hero__progress-value text-sm font-semibold text-white/90">
{progress}%
</span>
</div>
<div
className="h-2 overflow-hidden rounded-full bg-white/12"
className="creation-agent-hero__progress-track h-2 overflow-hidden rounded-full bg-white/20"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
@@ -526,7 +532,7 @@ export function CreationAgentWorkspace({
style={{ width: progressFillWidth }}
/>
</div>
<div className="mt-2 text-xs leading-5 text-white/64">
<div className="creation-agent-hero__progress-hint mt-2 text-xs leading-5 text-white/70">
{resolveCreationAgentProgressHint(progress, progressCopy)}
</div>
</div>
@@ -539,7 +545,7 @@ export function CreationAgentWorkspace({
type="button"
disabled={isBusy}
onClick={() => onQuickAction?.(action)}
className="rounded-full border border-white/14 bg-white/10 px-3 py-1.5 text-xs font-semibold text-white/78 disabled:cursor-not-allowed disabled:opacity-45"
className="creation-agent-hero__quick-action rounded-full border border-white/20 bg-white/10 px-3 py-1.5 text-xs font-semibold text-white/80 disabled:cursor-not-allowed disabled:opacity-45"
>
{action.label}
</button>
@@ -582,7 +588,7 @@ export function CreationAgentWorkspace({
</div>
{referenceImagePreviewSrc ? (
<div className="mx-4 mb-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-2">
<div className="mx-4 mb-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/70 px-3 py-2">
<div className="h-12 w-12 shrink-0 overflow-hidden rounded-[0.9rem] bg-[var(--platform-track-fill)]">
<img
src={referenceImagePreviewSrc}
@@ -28,6 +28,7 @@ type CreativeAgentWorkspaceProps = {
error: string | null;
eventLog: CreativeAgentSseEvent[];
onBack: () => void;
showBackButton?: boolean;
onSubmitMessage: (payload: {
clientMessageId: string;
content: CreativeAgentInputPart[];
@@ -101,6 +102,7 @@ export function CreativeAgentWorkspace({
error,
eventLog,
onBack,
showBackButton = true,
onSubmitMessage,
onConfirmTemplate,
onCancelTemplate,
@@ -131,17 +133,21 @@ export function CreativeAgentWorkspace({
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full flex-col xl:max-w-[min(100%,88rem)] xl:px-1">
<div className="mb-3 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
{showBackButton ? (
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
) : (
<span aria-hidden="true" />
)}
<div className="platform-pill platform-pill--cool px-3 text-[11px]">
{CREATIVE_AGENT_STAGE_LABEL[stage]}
</div>
@@ -27,6 +27,7 @@ type CustomWorldAgentWorkspaceProps = {
onBack: () => void;
onSubmitMessage: (payload: SendCustomWorldAgentMessageRequest) => void;
onExecuteAction: (payload: CustomWorldAgentActionRequest) => void;
showBackButton?: boolean;
};
const CUSTOM_WORLD_AGENT_THEME: CreationAgentTheme = {
@@ -164,6 +165,7 @@ export function CustomWorldAgentWorkspace({
onBack,
onSubmitMessage,
onExecuteAction,
showBackButton = true,
}: CustomWorldAgentWorkspaceProps) {
const isBusy =
isCreationAgentOperationBusy(activeOperation) || isStreamingReply;
@@ -194,6 +196,7 @@ export function CustomWorldAgentWorkspace({
isStreamingReply={isStreamingReply}
isBusy={isBusy}
quickActions={createCreationAgentChatQuickActions()}
showBackButton={showBackButton}
onBack={onBack}
onSubmitText={(text) => {
submitMessage(text);
@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen } from '@testing-library/react';
import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
@@ -43,9 +43,9 @@ const testEntryConfig = {
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -57,9 +57,9 @@ const testEntryConfig = {
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -71,9 +71,9 @@ const testEntryConfig = {
visible: true,
open: true,
sortOrder: 40,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -85,9 +85,9 @@ const testEntryConfig = {
visible: false,
open: true,
sortOrder: 50,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -99,9 +99,9 @@ const testEntryConfig = {
visible: false,
open: false,
sortOrder: 60,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
{
@@ -113,9 +113,9 @@ const testEntryConfig = {
visible: true,
open: false,
sortOrder: 70,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
],
@@ -128,6 +128,7 @@ const testCreationTypes = derivePlatformCreationTypes(
const originalClipboard = navigator.clipboard;
afterEach(() => {
vi.useRealTimers();
window.sessionStorage.clear();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -135,6 +136,57 @@ afterEach(() => {
});
});
test('creation entry banner automatically rotates through backend configured banners', () => {
vi.useFakeTimers();
render(
<CustomWorldCreationHub
items={[]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={{
...testEntryConfig,
eventBanners: [
{
...testEntryConfig.eventBanner,
title: '第一张后台 Banner',
},
{
...testEntryConfig.eventBanner,
title: '第二张后台 Banner',
coverImageSrc: '/creation-type-references/match3d.webp',
},
],
}}
creationTypes={testCreationTypes}
mode="start-only"
/>,
);
const bannerTrack = screen.getByLabelText('创作公告横幅');
Object.defineProperty(bannerTrack, 'clientWidth', {
configurable: true,
value: 360,
});
const scrollTo = vi.fn();
Object.defineProperty(bannerTrack, 'scrollTo', {
configurable: true,
value: scrollTo,
});
act(() => {
vi.advanceTimersByTime(4200);
});
expect(scrollTo).toHaveBeenCalledWith({
left: 360,
behavior: 'smooth',
});
});
test('creation hub shows published metric growth from cached page snapshot', async () => {
window.sessionStorage.setItem(
'genarrative.creationHub.publishedMetrics.v1',
@@ -560,7 +612,7 @@ test('creation hub shows RPG public work code from published library entry', ()
expect(screen.queryByText('CW-00000001')).toBeNull();
});
test('creation hub exposes persisted draft delete action directly on the card', () => {
test('creation hub keeps persisted draft delete action off the card header', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
@@ -579,7 +631,7 @@ test('creation hub exposes persisted draft delete action directly on the card',
expect(
container.querySelector('.creation-work-card__swipe-underlay'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
});
test('creation hub reveals persisted draft delete action from left swipe', () => {
@@ -641,6 +693,75 @@ test('creation hub reveals persisted draft delete action from keyboard', async (
expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
});
test('creation hub reveals persisted draft delete action from long press menu', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
const card = screen.getByRole('button', { name: //u });
fireEvent.contextMenu(card);
expect(
container.querySelector('.creation-work-card-shell--actions-visible'),
).toBeTruthy();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub gives every deletable work card a side delete action', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
babyObjectMatchItems={[babyObjectMatchDraftItem]}
puzzleItems={[
{
workId: 'puzzle:side-delete',
profileId: 'puzzle-profile-side-delete',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '侧边删除拼图',
summary: '不同来源都应有侧边删除。',
themeTags: ['灯塔'],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: new Date('2026-05-02T12:00:00.000Z').toISOString(),
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
onDeleteBabyObjectMatch={() => {}}
onDeletePuzzle={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(
container.querySelectorAll('.creation-work-card__swipe-underlay'),
).toHaveLength(3);
});
test('creation hub shows delete action for baby object match drafts', async () => {
const user = userEvent.setup();
const onDeleteBabyObjectMatch = vi.fn();
@@ -719,7 +840,7 @@ test('creation hub works-only tab filters bark battle draft and published works'
expect(onOpenBarkBattleDetail).toHaveBeenCalledWith(barkBattlePublishedItem);
});
test('creation hub published work delete action is directly visible', async () => {
test('creation hub published work delete action stays in revealed side actions', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
@@ -759,9 +880,11 @@ test('creation hub published work delete action is directly visible', async () =
/>,
);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeletePuzzle).toHaveBeenCalledWith(
@@ -770,7 +893,7 @@ test('creation hub published work delete action is directly visible', async () =
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});
test('creation hub exposes work delete action directly on card', async () => {
test('creation hub reveals draft work delete action from keyboard', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
@@ -810,6 +933,10 @@ test('creation hub exposes work delete action directly on card', async () => {
/>,
);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeletePuzzle).toHaveBeenCalledWith(
@@ -858,7 +985,9 @@ test('creation hub keeps swipe delete action available', async () => {
/>,
);
const card = screen.getByRole('button', { name: //u });
const card = screen.getByRole('button', {
name: //u,
});
fireEvent.touchStart(card, {
touches: [{ clientX: 180, clientY: 20 }],
});
File diff suppressed because it is too large Load Diff
@@ -21,10 +21,14 @@ import type {
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
type CreationWorkShelfItem,
type CreationWorkShelfMetricId,
type CreationWorkShelfRuntimeState,
} from './creationWorkShelf';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import {
CustomWorldCreationStartCard,
} from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
@@ -34,6 +38,9 @@ import {
const WORK_GRID_CLASS =
'creation-work-list grid min-w-0 gap-3 sm:gap-3.5 xl:gap-4';
const WORK_METRIC_CACHE_KEY = 'genarrative.creationHub.publishedMetrics.v1';
const RECENT_CREATION_WINDOW_DAYS = 7;
const RECENT_CREATION_WINDOW_MS =
RECENT_CREATION_WINDOW_DAYS * 24 * 60 * 60 * 1000;
type WorkMetricSnapshot = Record<
string,
@@ -67,7 +74,9 @@ type CustomWorldCreationHubProps = {
onOpenJumpHopDetail?: (item: JumpHopWorkSummaryResponse) => void;
onDeleteJumpHop?: ((item: JumpHopWorkSummaryResponse) => void) | null;
woodenFishItems?: WoodenFishWorkSummaryResponse[];
onOpenWoodenFishDetail?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
onOpenWoodenFishDetail?:
| ((item: WoodenFishWorkSummaryResponse) => void)
| null;
onDeleteWoodenFish?: ((item: WoodenFishWorkSummaryResponse) => void) | null;
puzzleClearItems?: PuzzleClearWorkSummaryResponse[];
onOpenPuzzleClearDetail?: ((item: PuzzleClearWorkSummaryResponse) => void) | null;
@@ -88,8 +97,10 @@ type CustomWorldCreationHubProps = {
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
getWorkState?: (
item: CreationWorkShelfItem,
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
) => CreationWorkShelfRuntimeState | null;
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
// 中文注释:底部加号入口可传入后端作品架摘要,用于推导最近使用过的模板。
recentWorkItems?: CreationWorkShelfItem[];
mode?: 'full' | 'start-only' | 'works-only';
};
@@ -156,6 +167,7 @@ function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
}
}
/** 渲染底部加号创作入口页与草稿作品架,最近创作复用最近使用过的模板入口。 */
export function CustomWorldCreationHub({
items,
loading,
@@ -204,6 +216,7 @@ export function CustomWorldCreationHub({
onDeleteVisualNovel = null,
getWorkState,
onOpenShelfItem,
recentWorkItems: recentWorkSourceItems,
mode = 'full',
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
@@ -299,7 +312,6 @@ export function CustomWorldCreationHub({
puzzleClearItems,
puzzleItems,
rpgLibraryEntries,
onOpenSquareHoleDetail,
onOpenJumpHopDetail,
jumpHopItems,
woodenFishItems,
@@ -325,6 +337,21 @@ export function CustomWorldCreationHub({
),
[activeFilter, shelfItems],
);
// 中文注释:最近创作只取 7 天内作品架摘要,再推导模板 ID 复用模板入口卡片。
const recentCreationCutoffMs = Date.now() - RECENT_CREATION_WINDOW_MS;
const recentWorkItems =
mode === 'start-only'
? (recentWorkSourceItems ?? shelfItems)
.filter(
(item) =>
getCreationWorkShelfItemTime(item.updatedAt) >=
recentCreationCutoffMs,
)
.slice(0, 4)
: [];
const recentCreationTypeIds = [
...new Set(recentWorkItems.map((item) => item.kind)),
];
function handleOpenShelfItem(item: CreationWorkShelfItem) {
onOpenShelfItem?.(item);
@@ -394,6 +421,8 @@ export function CustomWorldCreationHub({
busy={createBusy}
entryConfig={entryConfig}
creationTypes={creationTypes}
recentCreationTypeIds={recentCreationTypeIds}
recentWindowDays={RECENT_CREATION_WINDOW_DAYS}
onCreateType={onCreateType}
/>
) : null}
@@ -1,64 +1,160 @@
import { Coins, Trophy } from 'lucide-react';
import { type UIEvent,useMemo, useState } from 'react';
import { Coins, LockKeyhole, Trophy } from 'lucide-react';
import { type UIEvent, useEffect, useMemo, useRef, useState } from 'react';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import type {
CreationEntryConfig,
CreationEntryEventBannerConfig,
} from '../../services/creationEntryConfigService';
import {
groupVisiblePlatformCreationTypes,
type PlatformCreationTypeCard,
type PlatformCreationTypeId,
} from '../platform-entry/platformEntryCreationTypes';
/** 底部加号创作入口页的渲染参数,最近创作用作品架摘要推导模板入口。 */
type CustomWorldCreationStartCardProps = {
busy?: boolean;
entryConfig: CreationEntryConfig;
creationTypes: readonly PlatformCreationTypeCard[];
recentCreationTypeIds?: readonly PlatformCreationTypeId[];
recentWindowDays?: number;
onCreateType: (type: PlatformCreationTypeId) => void;
};
type CreationEventBannerCard = CreationEntryConfig['eventBanner'];
/** 创作入口公告卡兼容结构化和 HTML 两种后台配置。 */
type CreationEventBannerCard = CreationEntryEventBannerConfig;
const CREATION_ENTRY_BANNER_AUTOPLAY_MS = 4200;
const CREATION_ENTRY_RECENT_TAB_ID = '__recent_creation__';
/** 判断模板 badge 是否需要展示,普通可创建态不额外占用卡片空间。 */
function shouldShowCreationBadge(badge: string) {
const normalizedBadge = badge.trim();
return normalizedBadge !== '可创建' && normalizedBadge !== '可创作';
}
/** 从后端入口配置中解析创作入口公告位,保留旧单条字段兜底。 */
export function resolveCreationEntryEventBanners(
entryConfig: CreationEntryConfig,
): CreationEventBannerCard[] {
const configuredBanners = Array.isArray(entryConfig.eventBanners)
? entryConfig.eventBanners.filter((banner) => banner.title.trim())
: [];
return configuredBanners.length > 0
? configuredBanners
: [entryConfig.eventBanner];
}
/** 渲染创作入口公告位当前页指示点。 */
function CreationEntryBannerPager({
banners,
activeBannerIndex,
}: {
banners: readonly CreationEventBannerCard[];
activeBannerIndex: number;
}) {
return (
<div
className="creation-event-banner__pager flex items-center justify-center gap-1.5"
aria-hidden="true"
>
{banners.map((dotBanner, dotIndex) => (
<span
key={`${dotBanner.title}:dot:${dotIndex}`}
className={
dotIndex === activeBannerIndex
? 'h-1.5 w-5 rounded-full bg-[#d9793f]'
: 'h-1.5 w-1.5 rounded-full bg-[#eadfd7]'
}
/>
))}
</div>
);
}
/** 渲染底部加号进入的创作入口页,包括后台公告位和模板分类。 */
export function CustomWorldCreationStartCard({
busy = false,
entryConfig,
creationTypes,
recentCreationTypeIds = [],
recentWindowDays = 7,
onCreateType,
}: CustomWorldCreationStartCardProps) {
const creationTypeGroups = useMemo(
() => groupVisiblePlatformCreationTypes(creationTypes),
[creationTypes],
);
const recentCreationTypes = useMemo(() => {
const creationTypeById = new Map(
creationTypes
.filter((item) => !item.hidden)
.map((item) => [item.id, item] as const),
);
return [...new Set(recentCreationTypeIds)]
.map((id) => creationTypeById.get(id))
.filter((item): item is PlatformCreationTypeCard => Boolean(item));
}, [creationTypes, recentCreationTypeIds]);
const [activeCategoryId, setActiveCategoryId] = useState<string | null>(null);
const activeGroup =
creationTypeGroups.find((group) => group.id === activeCategoryId) ??
creationTypeGroups[0] ??
null;
const visibleCreationTypes = activeGroup?.items ?? [];
const eventBanners = useMemo<CreationEventBannerCard[]>(
() => [
{
...entryConfig.eventBanner,
title: '拼图主题创作赛',
description: '用拼图关卡接住本周主题。',
coverImageSrc: '/creation-type-references/puzzle.webp',
prizePoolMudPoints: 1000,
},
{
...entryConfig.eventBanner,
title: '抓大鹅主题创作赛',
description: '把抓大鹅关卡做成主题挑战。',
coverImageSrc: '/creation-type-references/match3d.webp',
prizePoolMudPoints: 1000,
},
],
[entryConfig.eventBanner],
const hasRecentCreationTypes = recentCreationTypes.length > 0;
const activeTabId =
activeCategoryId ??
(hasRecentCreationTypes
? CREATION_ENTRY_RECENT_TAB_ID
: (creationTypeGroups[0]?.id ?? null));
const isRecentTabActive =
hasRecentCreationTypes && activeTabId === CREATION_ENTRY_RECENT_TAB_ID;
const activeGroup = isRecentTabActive
? null
: (creationTypeGroups.find((group) => group.id === activeTabId) ??
creationTypeGroups[0] ??
null);
const visibleCreationTypes = isRecentTabActive
? recentCreationTypes
: (activeGroup?.items ?? []);
const eventBanners = useMemo(
() => resolveCreationEntryEventBanners(entryConfig),
[entryConfig],
);
const [activeBannerIndex, setActiveBannerIndex] = useState(0);
const bannerTrackRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setActiveBannerIndex(0);
}, [eventBanners.length]);
useEffect(() => {
if (hasRecentCreationTypes) {
return;
}
setActiveCategoryId((currentId) =>
currentId === CREATION_ENTRY_RECENT_TAB_ID ? null : currentId,
);
}, [hasRecentCreationTypes]);
useEffect(() => {
if (eventBanners.length <= 1) {
return undefined;
}
const intervalId = window.setInterval(() => {
setActiveBannerIndex((currentIndex) => {
const nextIndex = (currentIndex + 1) % eventBanners.length;
const track = bannerTrackRef.current;
if (track && typeof track.scrollTo === 'function') {
track.scrollTo({
left: track.clientWidth * nextIndex,
behavior: 'smooth',
});
}
return nextIndex;
});
}, CREATION_ENTRY_BANNER_AUTOPLAY_MS);
return () => window.clearInterval(intervalId);
}, [eventBanners.length]);
/** 同步手势滑动后的 banner 页码,避免自动轮播和手动滑动状态错位。 */
function handleBannerScroll(event: UIEvent<HTMLDivElement>) {
const { clientWidth, scrollLeft } = event.currentTarget;
if (clientWidth <= 0) {
@@ -78,74 +174,87 @@ export function CustomWorldCreationStartCard({
<div className="mx-auto flex w-full max-w-5xl flex-col gap-3 sm:gap-5">
<section className="creation-event-banner relative overflow-hidden rounded-[1.35rem] border border-[#f0d8ca] bg-[#fff8f3] shadow-[0_16px_36px_rgba(174,111,73,0.12)] sm:rounded-[1.65rem]">
<div
ref={bannerTrackRef}
className="creation-event-banner__track flex snap-x snap-mandatory overflow-x-auto overscroll-x-contain touch-pan-x scrollbar-hide"
onScroll={handleBannerScroll}
aria-label="创作赛事横幅"
aria-label="创作公告横幅"
>
{eventBanners.map((banner, index) => {
const prizePoolText =
banner.prizePoolMudPoints.toLocaleString('zh-CN');
const shouldRenderHtmlBanner =
banner.renderMode === 'html' && Boolean(banner.htmlCode?.trim());
return (
<article
key={`${banner.title}:${index}`}
className="creation-event-banner__slide relative w-full shrink-0 snap-center overflow-hidden"
>
<img
src={banner.coverImageSrc}
alt=""
className="absolute inset-y-0 right-0 h-full w-[58%] object-cover object-[70%_center] opacity-95"
loading={index === 0 ? 'eager' : 'lazy'}
/>
<div className="absolute inset-0 bg-[linear-gradient(90deg,#fff8f3_0%,rgba(255,248,243,0.94)_34%,rgba(255,248,243,0.36)_68%,rgba(255,248,243,0.08)_100%)]" />
<div className="relative z-10 flex min-h-[12rem] flex-col justify-between px-4 py-4 sm:min-h-[15rem] sm:px-7 sm:py-6">
<div className="w-[68%] min-w-0 sm:w-[56%]">
<div className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[#df7949] bg-white/72 px-2.5 py-1 text-xs font-black text-[#cf6332] shadow-sm">
<Trophy className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{banner.title}</span>
</div>
<div className="mt-3 line-clamp-2 text-sm font-semibold leading-5 text-[#695143] sm:mt-5 sm:leading-6">
{banner.description}
</div>
<div className="mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full bg-white/72 px-2.5 py-1.5 text-xs font-bold text-[#6f5140] shadow-sm">
<span className="grid h-5 w-5 place-items-center rounded-full bg-[#ffb64c] text-white">
<Coins className="h-3 w-3" />
</span>
<span className="shrink-0"></span>
<span className="text-sm font-black text-[#d36b2f]">
{prizePoolText}
</span>
<span className="shrink-0"></span>
</div>
{shouldRenderHtmlBanner ? (
<div className="relative min-h-[12rem] sm:min-h-[15rem]">
<iframe
title={banner.title}
sandbox=""
srcDoc={banner.htmlCode ?? ''}
className="absolute inset-0 h-full w-full border-0 bg-transparent"
/>
</div>
) : (
<>
<img
src={banner.coverImageSrc}
alt=""
className="absolute inset-y-0 right-0 h-full w-[58%] object-cover object-[70%_center] opacity-95"
loading={index === 0 ? 'eager' : 'lazy'}
/>
<div className="absolute inset-0 bg-[linear-gradient(90deg,#fff8f3_0%,rgba(255,248,243,0.94)_34%,rgba(255,248,243,0.36)_68%,rgba(255,248,243,0.08)_100%)]" />
<div className="relative z-10 flex min-h-[12rem] flex-col justify-between px-4 py-4 sm:min-h-[15rem] sm:px-7 sm:py-6">
<div className="w-[68%] min-w-0 sm:w-[56%]">
<div className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[#df7949] bg-white/72 px-2.5 py-1 text-xs font-black text-[#cf6332] shadow-sm">
<Trophy className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{banner.title}</span>
</div>
<div className="mt-3 line-clamp-2 text-sm font-semibold leading-5 text-[#695143] sm:mt-5 sm:leading-6">
{banner.description}
</div>
<div className="mt-3 inline-flex max-w-full items-center gap-1.5 rounded-full bg-white/72 px-2.5 py-1.5 text-xs font-bold text-[#6f5140] shadow-sm">
<span className="grid h-5 w-5 place-items-center rounded-full bg-[#ffb64c] text-white">
<Coins className="h-3 w-3" />
</span>
<span className="shrink-0"></span>
<span className="text-sm font-black text-[#d36b2f]">
{prizePoolText}
</span>
<span className="shrink-0"></span>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<div className="creation-event-banner__timebar flex min-w-0 items-center justify-center gap-1.5 rounded-full border border-[#e9c5b0] bg-white/72 px-2.5 py-1.5 text-center text-[10px] font-bold text-[#9a5a39] shadow-[0_8px_18px_rgba(174,111,73,0.1)] sm:gap-2 sm:px-5 sm:py-2.5 sm:text-[11px]">
<span className="min-w-0 truncate">
&nbsp;&nbsp;{banner.startsAtText}
</span>
<span className="shrink-0 text-[#c99373]">|</span>
<span className="min-w-0 truncate">
&nbsp;&nbsp;{banner.endsAtText}
</span>
</div>
<div
className="creation-event-banner__pager flex items-center justify-center gap-1.5"
aria-hidden="true"
>
{eventBanners.map((dotBanner, dotIndex) => (
<span
key={`${dotBanner.title}:dot:${dotIndex}`}
className={
dotIndex === activeBannerIndex
? 'h-1.5 w-5 rounded-full bg-[#d9793f]'
: 'h-1.5 w-1.5 rounded-full bg-[#eadfd7]'
}
<div className="mt-4 flex flex-col gap-2">
<div className="creation-event-banner__timebar flex min-w-0 items-center justify-center gap-1.5 rounded-full border border-[#e9c5b0] bg-white/72 px-2.5 py-1.5 text-center text-[10px] font-bold text-[#9a5a39] shadow-[0_8px_18px_rgba(174,111,73,0.1)] sm:gap-2 sm:px-5 sm:py-2.5 sm:text-[11px]">
<span className="min-w-0 truncate">
&nbsp;&nbsp;{banner.startsAtText}
</span>
<span className="shrink-0 text-[#c99373]">|</span>
<span className="min-w-0 truncate">
&nbsp;&nbsp;{banner.endsAtText}
</span>
</div>
<CreationEntryBannerPager
banners={eventBanners}
activeBannerIndex={activeBannerIndex}
/>
))}
</div>
</div>
</>
)}
{shouldRenderHtmlBanner ? (
<div className="absolute inset-x-0 bottom-3 z-10">
<CreationEntryBannerPager
banners={eventBanners}
activeBannerIndex={activeBannerIndex}
/>
</div>
</div>
) : null}
</article>
);
})}
@@ -156,8 +265,26 @@ export function CustomWorldCreationStartCard({
<div
className="-mx-0.5 flex snap-x items-center gap-2 overflow-x-auto px-0.5 pb-1 scrollbar-hide scroll-px-2 sm:gap-3"
role="tablist"
aria-label="玩法模板分类"
aria-label="创作入口页签"
>
{hasRecentCreationTypes ? (
<button
type="button"
role="tab"
aria-selected={isRecentTabActive}
onClick={() => setActiveCategoryId(CREATION_ENTRY_RECENT_TAB_ID)}
className={`relative min-h-8 shrink-0 rounded-full px-2.5 text-xs font-black transition sm:min-h-9 sm:px-3.5 sm:text-sm ${
isRecentTabActive
? 'text-[#6f2f21]'
: 'text-[#7a6558] hover:text-[#6f2f21]'
}`}
>
<span></span>
{isRecentTabActive ? (
<span className="absolute bottom-0 left-3 right-3 h-1 rounded-full bg-[#d9793f]" />
) : null}
</button>
) : null}
{creationTypeGroups.map((group) => {
const selected = group.id === activeGroup?.id;
return (
@@ -182,21 +309,29 @@ export function CustomWorldCreationStartCard({
})}
</div>
{isRecentTabActive ? (
<div className="creation-template-list__recent-window mt-2 text-[11px] font-bold leading-4 text-[#8b6654] sm:text-xs">
{recentWindowDays}使
</div>
) : null}
<div className="creation-template-list__grid mt-2 grid grid-cols-2 gap-2 sm:mt-3 sm:gap-3">
{visibleCreationTypes.map((item) => {
const disabled = item.locked || busy;
const lockedBadge = item.badge.trim() || '暂未开放';
return (
<button
key={item.id}
type="button"
disabled={disabled}
data-locked={item.locked ? 'true' : undefined}
onClick={() => {
onCreateType(item.id);
}}
className={`creation-template-card platform-interactive-card relative flex min-h-[12.5rem] flex-col overflow-hidden rounded-[1rem] border bg-white p-0 text-left transition sm:min-h-[15rem] sm:rounded-[1.2rem] ${
item.locked
? 'cursor-not-allowed border-[#eadbd3] text-[#725b4d] opacity-72'
? 'cursor-not-allowed border-[#d9ccc2] text-[#725b4d] shadow-[inset_0_0_0_1px_rgba(111,78,61,0.08)]'
: 'border-[#eadbd3] text-[#2f211b] hover:border-[#dc9a72] hover:shadow-[0_16px_34px_rgba(174,111,73,0.14)]'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
@@ -204,25 +339,70 @@ export function CustomWorldCreationStartCard({
<img
src={item.imageSrc}
alt=""
className="h-full w-full object-cover"
className={`h-full w-full object-cover ${
item.locked
? 'scale-[1.01] grayscale-[0.62] saturate-[0.55] brightness-[0.82]'
: ''
}`}
loading="lazy"
/>
{shouldShowCreationBadge(item.badge) ? (
<span className="absolute left-2 top-2 max-w-[calc(100%-1rem)] rounded-full bg-[#b66a3e] px-2 py-0.5 text-xs font-black text-white shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1">
{item.badge}
{item.locked ? (
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(52,36,27,0.22)_0%,rgba(52,36,27,0.52)_100%)]" />
) : null}
{item.locked || shouldShowCreationBadge(item.badge) ? (
<span
className={`absolute left-2 top-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full px-2 py-0.5 text-xs font-black shadow-sm sm:left-3 sm:top-3 sm:px-2.5 sm:py-1 ${
item.locked
? 'bg-[#3f3129]/90 text-white'
: 'bg-[#b66a3e] text-white'
}`}
>
{item.locked ? <LockKeyhole className="h-3 w-3" /> : null}
{item.locked ? lockedBadge : item.badge}
</span>
) : null}
<span className="creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full bg-[#fff7ec]/92 px-2 py-1 text-[11px] font-black leading-4 text-[#b65f2c] shadow-[0_8px_18px_rgba(119,72,44,0.16)]">
<Coins className="h-3 w-3 shrink-0" />
<span className="truncate">10-20</span>
{item.locked ? (
<span className="absolute left-1/2 top-1/2 inline-flex h-11 w-11 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full bg-white/88 text-[#5d4639] shadow-[0_12px_24px_rgba(46,31,23,0.22)]">
<LockKeyhole className="h-5 w-5" />
</span>
) : null}
<span
className={`creation-template-card__cost-badge absolute bottom-2 right-2 inline-flex max-w-[calc(100%-1rem)] items-center gap-1 rounded-full px-2 py-1 text-[11px] font-black leading-4 shadow-[0_8px_18px_rgba(119,72,44,0.16)] ${
item.locked
? 'bg-[#3f3129]/88 text-white'
: 'bg-[#fff7ec]/92 text-[#b65f2c]'
}`}
>
{item.locked ? (
<LockKeyhole className="h-3 w-3 shrink-0" />
) : (
<Coins className="h-3 w-3 shrink-0" />
)}
<span className="truncate">
{item.locked ? '暂未开放' : '10-20泥点数'}
</span>
</span>
</div>
<div className="creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col bg-white px-2.5 pb-2.5 pt-2.5 text-[#2f211b] sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5">
<div className="creation-template-card__title line-clamp-1 text-sm font-black leading-5 text-[#2f211b]">
<div
className={`creation-template-card__body flex min-h-[4.6rem] flex-1 flex-col px-2.5 pb-2.5 pt-2.5 sm:min-h-[5.4rem] sm:px-3.5 sm:pb-3.5 ${
item.locked
? 'bg-[#f3ece6] text-[#725b4d]'
: 'bg-white text-[#2f211b]'
}`}
>
<div
className={`creation-template-card__title line-clamp-1 text-sm font-black leading-5 ${
item.locked ? 'text-[#5d4639]' : 'text-[#2f211b]'
}`}
>
{item.title}
</div>
<div className="creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 text-[#6f5a4c] sm:leading-5">
<div
className={`creation-template-card__subtitle mt-1 line-clamp-2 text-xs font-semibold leading-4 sm:leading-5 ${
item.locked ? 'text-[#8a766a]' : 'text-[#6f5a4c]'
}`}
>
{item.subtitle}
</div>
</div>
@@ -230,7 +410,6 @@ export function CustomWorldCreationStartCard({
);
})}
</div>
</section>
</div>
);
@@ -1,5 +1,6 @@
import {
BadgeCheck,
CircleAlert,
Clock3,
Loader2,
Share2,
@@ -441,11 +442,8 @@ export function CustomWorldWorkCard({
return;
}
updateSwipeOffset(
gesture,
event.clientX,
event.clientY,
() => event.preventDefault(),
updateSwipeOffset(gesture, event.clientX, event.clientY, () =>
event.preventDefault(),
);
};
@@ -475,9 +473,7 @@ export function CustomWorldWorkCard({
}
};
const beginTouchSwipeGesture = (
event: ReactTouchEvent<HTMLDivElement>,
) => {
const beginTouchSwipeGesture = (event: ReactTouchEvent<HTMLDivElement>) => {
if (swipeRevealWidth <= 0) {
return;
}
@@ -496,20 +492,15 @@ export function CustomWorldWorkCard({
};
};
const updateTouchSwipeGesture = (
event: ReactTouchEvent<HTMLDivElement>,
) => {
const updateTouchSwipeGesture = (event: ReactTouchEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
const touch = event.touches[0];
if (!gesture || gesture.pointerId !== -1 || !touch) {
return;
}
updateSwipeOffset(
gesture,
touch.clientX,
touch.clientY,
() => event.preventDefault(),
updateSwipeOffset(gesture, touch.clientX, touch.clientY, () =>
event.preventDefault(),
);
};
@@ -678,8 +669,8 @@ export function CustomWorldWorkCard({
{displayTitle}
</span>
</div>
<div className="creation-work-card__quick-actions">
{canUseShareAction ? (
{canUseShareAction ? (
<div className="creation-work-card__quick-actions">
<button
type="button"
onClick={(event) => {
@@ -715,38 +706,8 @@ export function CustomWorldWorkCard({
>
<Share2 aria-hidden="true" className="h-4 w-4" />
</button>
) : null}
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
onDelete();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
onTouchStart={(event) => {
event.stopPropagation();
}}
disabled={deleteBusy}
title={deleteBusy ? '删除中' : '删除作品'}
aria-label={deleteBusy ? '删除中' : '删除'}
className="creation-work-card__quick-action-button creation-work-card__quick-action-button--danger"
>
{deleteBusy ? (
<span className="text-xs leading-none">...</span>
) : (
<Trash2 aria-hidden="true" className="h-4 w-4" />
)}
</button>
) : null}
</div>
</div>
) : null}
</div>
<div className="creation-work-card__meta platform-category-game-item__meta">
@@ -764,6 +725,16 @@ export function CustomWorldWorkCard({
{item.summary}
</div>
{item.hasGenerationFailure ? (
<div
aria-label={item.generationFailureSummary ?? '生成失败'}
className="creation-work-card__failure-status"
>
<CircleAlert aria-hidden="true" className="h-3.5 w-3.5" />
<span>{item.generationFailureSummary ?? '生成失败'}</span>
</div>
) : null}
{isPublished ? (
<div className="creation-work-card__published-info">
{item.pointIncentive ? (
@@ -93,7 +93,9 @@ test('buildCreationWorkShelfItems maps wooden fish items with WF public code', (
expect(items[0]?.sharePath).toContain('/works/detail?work=WF-12345678');
expect(items[0]?.openActionLabel).toBe('查看详情');
expect(items[0]?.badges.some((badge) => badge.label === '敲木鱼')).toBe(true);
expect(items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value).toBe(9);
expect(
items[0]?.metrics.find((metric) => metric.id === 'play-count')?.value,
).toBe(9);
expect(onOpenWoodenFishDetail).toHaveBeenCalledWith(woodenFishWork);
});
@@ -252,9 +254,9 @@ test('buildCreationWorkShelfItems keeps separate bark battle draft and published
expect(items.find((item) => item.status === 'published')?.id).toBe(
'BB-PUB00001',
);
expect(items.find((item) => item.status === 'published')?.publicWorkCode).toBe(
'BB-PUB00001',
);
expect(
items.find((item) => item.status === 'published')?.publicWorkCode,
).toBe('BB-PUB00001');
});
test('buildCreationWorkShelfItems falls back to deterministic RPG public work code when library entry is missing', () => {
@@ -344,10 +346,9 @@ test('buildCreationWorkShelfItems gives bark battle draft cover from character o
expect(items.find((item) => item.id === 'BB-COVER001')?.coverImageSrc).toBe(
'/draft-player-cover.png',
);
expect(items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs).toEqual([
'/draft-player-cover.png',
'/draft-opponent-cover.png',
]);
expect(
items.find((item) => item.id === 'BB-COVER001')?.coverCharacterImageSrcs,
).toEqual(['/draft-player-cover.png', '/draft-opponent-cover.png']);
expect(items.find((item) => item.id === 'BB-COVER002')?.coverImageSrc).toBe(
'/creation-type-references/bark-battle.webp',
);
@@ -498,14 +499,76 @@ test('buildCreationWorkShelfItems restores persisted generation state for puzzle
],
});
expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(
true,
);
expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(true);
expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe(
true,
);
});
test('buildCreationWorkShelfItems lets failure notice override persisted generating copy', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:failed-generating',
profileId: 'puzzle-profile-failed-generating',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-failed-generating',
authorDisplayName: '测试作者',
levelName: '失败拼图',
summary: '正在生成拼图草稿。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'generating',
},
],
getItemState: (item) =>
item.kind === 'puzzle'
? {
isGenerating: false,
suppressPersistedGenerating: true,
summaryOverride: '拼图草稿生成失败,可重新打开处理。',
}
: null,
});
expect(items[0]?.isGenerating).toBe(false);
expect(items[0]?.summary).toBe('拼图草稿生成失败,可重新打开处理。');
});
test('persisted failed puzzle draft is not treated as generating', () => {
const items = buildCreationWorkShelfItems({
rpgItems: [],
bigFishItems: [],
puzzleItems: [
{
workId: 'puzzle:failed',
profileId: 'puzzle-profile-failed',
ownerUserId: 'user-1',
sourceSessionId: 'puzzle-session-failed',
authorDisplayName: '测试作者',
levelName: '失败拼图',
summary: '服务端已回写失败。',
themeTags: [],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: '2026-05-08T00:00:00.000Z',
publishedAt: null,
publishReady: false,
generationStatus: 'failed',
},
],
});
expect(items[0]?.isGenerating).toBeFalsy();
expect(items[0]?.summary).toBe('服务端已回写失败。');
});
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
const onOpenBabyObjectMatchDetail = vi.fn();
const onDeleteBabyObjectMatch = vi.fn();
@@ -1129,7 +1192,6 @@ test('bark battle draft generating state only follows pending assets', () => {
).toBe(false);
});
test('CustomWorldWorkCard hides author on shelf draft and published cards', () => {
const buildItem = (
status: CreationWorkShelfItem['status'],
@@ -1151,7 +1213,11 @@ test('CustomWorldWorkCard hides author on shelf draft and published cards', () =
canDelete: false,
canShare: false,
badges: [
{ id: 'status', label: status === 'draft' ? '草稿' : '已发布', tone: 'neutral' },
{
id: 'status',
label: status === 'draft' ? '草稿' : '已发布',
tone: 'neutral',
},
{ id: 'type', label: '汪汪', tone: 'neutral' },
],
metrics: [],
@@ -132,6 +132,8 @@ export type CreationWorkShelfItem = {
kind: CreationWorkShelfKind;
status: CreationWorkShelfStatus;
isGenerating?: boolean;
hasGenerationFailure?: boolean;
generationFailureSummary?: string;
hasUnreadUpdate?: boolean;
title: string;
summary: string;
@@ -152,6 +154,16 @@ export type CreationWorkShelfItem = {
source: CreationWorkShelfSource;
};
export type CreationWorkShelfRuntimeState = {
isGenerating?: boolean;
hasGenerationFailure?: boolean;
generationFailureSummary?: string;
hasUnreadUpdate?: boolean;
suppressPersistedGenerating?: boolean;
titleOverride?: string;
summaryOverride?: string;
};
export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
@@ -202,7 +214,7 @@ export function buildCreationWorkShelfItems(params: {
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: (
item: CreationWorkShelfItem,
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
) => CreationWorkShelfRuntimeState | null;
}) {
const {
rpgItems,
@@ -328,18 +340,24 @@ export function buildCreationWorkShelfItems(params: {
.map((item) => {
const state = getItemState?.(item);
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);
return state
const isGenerating = Boolean(
state?.isGenerating ||
(!state?.suppressPersistedGenerating && persistedIsGenerating),
);
return state || isGenerating
? {
...item,
isGenerating: Boolean(state.isGenerating || persistedIsGenerating),
hasUnreadUpdate: state.hasUnreadUpdate,
title: state?.titleOverride ?? item.title,
summary: state?.summaryOverride ?? item.summary,
isGenerating,
hasGenerationFailure:
state?.hasGenerationFailure ?? item.hasGenerationFailure,
generationFailureSummary:
state?.generationFailureSummary ??
item.generationFailureSummary,
hasUnreadUpdate: state?.hasUnreadUpdate,
}
: persistedIsGenerating
? {
...item,
isGenerating: true,
}
: item;
: item;
})
.sort(
(left, right) =>
@@ -348,7 +366,6 @@ export function buildCreationWorkShelfItems(params: {
);
}
function mergeBarkBattleShelfSourceItems(
items: readonly BarkBattleWorkSummary[],
): BarkBattleWorkSummary[] {
@@ -397,8 +414,8 @@ function mapRpgWorkToShelfItem(
: null;
const publicWorkCode =
item.status === 'published'
? (libraryEntry?.publicWorkCode?.trim() ||
(item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null))
? libraryEntry?.publicWorkCode?.trim() ||
(item.profileId ? buildCustomWorldPublicWorkCode(item.profileId) : null)
: null;
const badges: CreationWorkShelfBadge[] = [
buildStatusBadge(item.status),
@@ -864,7 +881,9 @@ function mapWoodenFishWorkToShelfItem(
): CreationWorkShelfItem {
const status = item.publicationStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published' ? buildWoodenFishPublicWorkCode(item.profileId) : null;
status === 'published'
? buildWoodenFishPublicWorkCode(item.profileId)
: null;
const title = item.workTitle.trim() || '敲木鱼';
const summary =
item.workDescription.trim() || (status === 'draft' ? '未填写作品描述' : '');
@@ -955,10 +974,7 @@ function mapPuzzleClearWorkToShelfItem(
};
}
function resolveAuthorDisplayName(
...sources: Array<unknown>
) {
function resolveAuthorDisplayName(...sources: Array<unknown>) {
for (const source of sources) {
const authorDisplayName =
source &&
@@ -1032,7 +1048,8 @@ export function resolvePuzzleLevelCoverImageSrc(
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
level.candidates[level.candidates.length - 1]?.imageSrc,
);
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
const candidateImageSrc =
selectedCandidateImageSrc || fallbackCandidateImageSrc;
if (
candidateImageSrc &&
@@ -1055,7 +1072,9 @@ function resolveMatch3DWorkCoverImageSrc(item: Match3DWorkSummary) {
const topLevelContainerImageSrc =
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageSrc) ||
normalizeCoverImageSrc(item.generatedBackgroundAsset?.containerImageObjectKey);
normalizeCoverImageSrc(
item.generatedBackgroundAsset?.containerImageObjectKey,
);
if (topLevelContainerImageSrc) {
return topLevelContainerImageSrc;
}
@@ -1164,6 +1183,9 @@ function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
switch (item.source.kind) {
case 'match3d':
return item.source.item.generationStatus === 'generating';
case 'jump-hop':
// 中文注释:跳一跳后端生成中草稿也要同步到作品架,并参与最近模板推导。
return item.source.item.generationStatus === 'generating';
case 'puzzle':
return isPersistedPuzzleDraftGenerating(item.source.item);
case 'wooden-fish':
@@ -0,0 +1,144 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type { JumpHopDraftResponse } from '../../../packages/shared/src/contracts/jumpHop';
import { JumpHopResultView } from './JumpHopResultView';
const draft: JumpHopDraftResponse = {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'profile-1',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵', '星空'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '纸片小兔',
tilePrompt: '柔软云朵平台',
endMoodPrompt: '星光门',
characterAsset: {
assetId: 'character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '角色图',
width: 1024,
height: 1024,
},
tileAtlasAsset: {
assetId: 'tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '地块图',
width: 1024,
height: 1024,
},
tileAssets: [
{
tileType: 'start',
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
{
tileType: 'finish',
imageSrc: 'data:image/png;base64,tile-finish',
imageObjectKey: 'jump-hop/tile-finish.png',
assetObjectId: 'asset-tile-finish',
sourceAtlasCell: 'A2',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
],
path: {
seed: 'jump-hop-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-1',
tileType: 'start',
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
{
platformId: 'platform-2',
tileType: 'finish',
x: 16,
y: 18,
width: 60,
height: 42,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 2,
},
],
finishIndex: 1,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
},
coverComposite: 'data:image/png;base64,cover',
generationStatus: 'ready',
};
test('jump hop result view exposes test run and publish actions', async () => {
const user = userEvent.setup();
const onBack = vi.fn();
const onEdit = vi.fn();
const onStartTestRun = vi.fn();
const onPublish = vi.fn();
const onRegenerateCharacter = vi.fn();
const onRegenerateTiles = vi.fn();
render(
<JumpHopResultView
profile={draft}
onBack={onBack}
onEdit={onEdit}
onStartTestRun={onStartTestRun}
onPublish={onPublish}
onRegenerateCharacter={onRegenerateCharacter}
onRegenerateTiles={onRegenerateTiles}
/>,
);
expect(screen.getByText('云端跳台')).toBeTruthy();
expect(screen.getByRole('button', { name: '试玩' })).toBeTruthy();
expect(screen.getByRole('button', { name: '发布' })).toBeTruthy();
await user.click(screen.getByRole('button', { name: '试玩' }));
await user.click(screen.getByRole('button', { name: '发布' }));
await user.click(screen.getByRole('button', { name: '返回' }));
await user.click(screen.getByRole('button', { name: '返回编辑' }));
await user.click(screen.getByRole('button', { name: '角色' }));
await user.click(screen.getByRole('button', { name: '地块' }));
expect(onStartTestRun).toHaveBeenCalledTimes(1);
expect(onPublish).toHaveBeenCalledTimes(1);
expect(onBack).toHaveBeenCalledTimes(1);
expect(onEdit).toHaveBeenCalledTimes(1);
expect(onRegenerateCharacter).toHaveBeenCalledTimes(1);
expect(onRegenerateTiles).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,212 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, test, vi } from 'vitest';
import type {
JumpHopRuntimeRunSnapshotResponse,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import { JumpHopRuntimeShell } from './JumpHopRuntimeShell';
const profile: JumpHopWorkProfileResponse = {
summary: {
runtimeKind: 'jump-hop',
workId: 'work-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
sourceSessionId: 'session-1',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵'],
difficulty: 'standard',
stylePreset: 'paper-toy',
coverImageSrc: 'data:image/png;base64,cover',
publicationStatus: 'draft',
playCount: 0,
updatedAt: '2026-05-30T10:00:00.000Z',
publishedAt: null,
publishReady: true,
generationStatus: 'ready',
},
draft: {
templateId: 'jump-hop',
templateName: '跳一跳',
profileId: 'profile-1',
workTitle: '云端跳台',
workDescription: '一路跳到星星。',
themeTags: ['云朵'],
difficulty: 'standard',
stylePreset: 'paper-toy',
characterPrompt: '纸片小兔',
tilePrompt: '云朵平台',
endMoodPrompt: '星光门',
characterAsset: {
assetId: 'character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '角色图',
width: 1024,
height: 1024,
},
tileAtlasAsset: {
assetId: 'tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '地块图',
width: 1024,
height: 1024,
},
tileAssets: [
{
tileType: 'start',
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
],
path: {
seed: 'jump-hop-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-1',
tileType: 'start',
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
],
finishIndex: 0,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
},
coverComposite: 'data:image/png;base64,cover',
generationStatus: 'ready',
},
path: {
seed: 'jump-hop-seed',
difficulty: 'standard',
platforms: [
{
platformId: 'platform-1',
tileType: 'start',
x: 0,
y: 0,
width: 48,
height: 36,
landingRadius: 22,
perfectRadius: 12,
scoreValue: 1,
},
],
finishIndex: 0,
cameraPreset: 'default',
scoring: {
chargeToDistanceRatio: 1.2,
maxChargeMs: 1800,
hitBonus: 20,
perfectBonus: 50,
},
},
characterAsset: {
assetId: 'character-1',
imageSrc: 'data:image/png;base64,character',
imageObjectKey: 'jump-hop/character.png',
assetObjectId: 'asset-character',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '角色图',
width: 1024,
height: 1024,
},
tileAtlasAsset: {
assetId: 'tiles-1',
imageSrc: 'data:image/png;base64,tiles',
imageObjectKey: 'jump-hop/tiles.png',
assetObjectId: 'asset-tiles',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '地块图',
width: 1024,
height: 1024,
},
tileAssets: [
{
tileType: 'start',
imageSrc: 'data:image/png;base64,tile-start',
imageObjectKey: 'jump-hop/tile-start.png',
assetObjectId: 'asset-tile-start',
sourceAtlasCell: 'A1',
visualWidth: 128,
visualHeight: 96,
topSurfaceRadius: 24,
landingRadius: 28,
},
],
};
const run: JumpHopRuntimeRunSnapshotResponse = {
runId: 'run-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
status: 'playing',
currentPlatformIndex: 0,
score: 0,
combo: 0,
path: profile.path,
lastJump: null,
startedAtMs: 1000,
finishedAtMs: null,
};
test('jump hop runtime shell supports jump, restart and exit actions', async () => {
const user = userEvent.setup();
const onJump = vi.fn().mockResolvedValue(undefined);
const onRestart = vi.fn();
const onExit = vi.fn();
render(
<JumpHopRuntimeShell
profile={profile}
run={run}
onJump={onJump}
onRestart={onRestart}
onExit={onExit}
/>,
);
await user.pointer([
{ target: screen.getByRole('button', { name: '起跳' }), keys: '[MouseLeft>]' },
]);
await user.pointer([
{ target: screen.getByRole('button', { name: '起跳' }), keys: '[/MouseLeft]' },
]);
await waitFor(() => {
expect(onJump).toHaveBeenCalledWith({ chargeMs: expect.any(Number) });
});
await user.click(screen.getByRole('button', { name: '重开' }));
await user.click(screen.getByRole('button', { name: '返回' }));
expect(onRestart).toHaveBeenCalledTimes(1);
expect(onExit).toHaveBeenCalledTimes(1);
});
@@ -1,105 +0,0 @@
import { ArrowLeft, Edit3, Sparkles } from 'lucide-react';
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
type Match3DDraftReadyViewProps = {
session: Match3DAgentSessionSnapshot;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
};
export function Match3DDraftReadyView({
session,
isBusy = false,
error = null,
onBack,
}: Match3DDraftReadyViewProps) {
const draft = session.draft;
const title = draft?.gameName || '抓大鹅草稿';
return (
<div className="platform-remap-surface mx-auto flex h-full min-h-0 w-full max-w-4xl flex-col">
<div className="mb-4 flex items-center justify-between gap-3">
<button
type="button"
onClick={onBack}
disabled={isBusy}
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isBusy ? 'opacity-45' : ''}`}
>
<span className="inline-flex items-center gap-1.5">
<ArrowLeft className="h-3.5 w-3.5" />
</span>
</button>
</div>
<section className="platform-subpanel min-h-0 flex-1 overflow-y-auto rounded-[1.5rem] p-4 sm:p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="grid aspect-square w-full max-w-[10rem] shrink-0 place-items-center rounded-[1.2rem] bg-[radial-gradient(circle_at_30%_25%,rgba(190,242,100,0.36),transparent_34%),linear-gradient(135deg,rgba(16,185,129,0.18),rgba(251,146,60,0.16))] text-emerald-800">
<Sparkles className="h-10 w-10" />
</div>
<div className="min-w-0 flex-1">
<div className="text-2xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-3xl">
{title}
</div>
<div className="mt-2 text-sm leading-6 text-[var(--platform-text-base)]">
{draft?.summaryText ?? session.lastAssistantReply ?? ''}
</div>
{draft ? (
<div className="mt-5 grid gap-2 sm:grid-cols-3">
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{draft.themeText}
</div>
</div>
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-strong)]">
{draft.totalItemCount ?? draft.clearCount * 3}
</div>
</div>
<div className="rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/68 px-3 py-3">
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-1 text-sm font-semibold text-[var(--platform-text-strong)]">
{draft.difficulty}
</div>
</div>
</div>
) : null}
{error ? (
<div className="platform-banner platform-banner--danger mt-4 rounded-[1.25rem] text-sm leading-6">
{error}
</div>
) : null}
</div>
</div>
</section>
<div className="mt-4 flex justify-end pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
disabled
className="platform-button platform-button--primary cursor-not-allowed opacity-55"
>
<span className="inline-flex items-center gap-2">
<Edit3 className="h-4 w-4" />
</span>
</button>
</div>
</div>
);
}
export default Match3DDraftReadyView;
@@ -36,9 +36,9 @@ const entryConfig = {
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
categoryId: 'recommended',
categoryLabel: '热门推荐',
categorySortOrder: 20,
updatedAtMicros: 1,
},
],
@@ -1,4 +1,4 @@
import { ArrowRight } from 'lucide-react';
import { ArrowRight, LockKeyhole } from 'lucide-react';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { UnifiedModal } from '../common/UnifiedModal';
@@ -33,6 +33,7 @@ function CreationTypeCard(props: {
}) {
const { item, busy, onSelect } = props;
const disabled = item.locked || busy;
const lockedBadge = item.badge.trim() || '暂未开放';
return (
<button
@@ -60,12 +61,15 @@ function CreationTypeCard(props: {
/>
<div className="relative z-10 flex min-h-6 items-start justify-end gap-3 px-4 pt-4">
{item.locked ? (
<span className="platform-pill platform-pill--neutral px-3 text-[var(--platform-text-soft)]">
{item.badge}
<span className="platform-pill platform-pill--neutral gap-1 px-3 text-[var(--platform-text-soft)]">
<LockKeyhole className="h-3.5 w-3.5" />
{lockedBadge}
</span>
) : null}
{item.locked ? (
<span className="text-lg leading-none text-white/62">·</span>
<span className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-white/18 text-white/72">
<LockKeyhole className="h-3.5 w-3.5" />
</span>
) : (
<ArrowRight className="h-4 w-4 text-white/80" />
)}
@@ -169,7 +173,6 @@ export function PlatformEntryCreationTypeModal({
/>
))}
</div>
</UnifiedModal>
);
}
File diff suppressed because it is too large Load Diff
@@ -58,6 +58,22 @@ describe('PlatformErrorDialog', () => {
expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull();
});
test('does not render creation entry disabled errors', () => {
render(
<PlatformErrorDialog
error={{
source: '大鱼草稿',
message:
'creation_entry_disabledrequestId: req-big-fish-gallery',
}}
onClose={() => {}}
/>,
);
expect(screen.queryByRole('dialog', { name: '发生错误' })).toBeNull();
expect(screen.queryByText(/creation_entry_disabled/u)).toBeNull();
});
});
describe('PlatformTaskCompletionDialog', () => {

Some files were not shown because too many files have changed in this diff Show More