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:
@@ -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 })
|
||||
|
||||
@@ -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">
|
||||
开始时间 {banner.startsAtText}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#c99373]">|</span>
|
||||
<span className="min-w-0 truncate">
|
||||
结束时间 {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">
|
||||
开始时间 {banner.startsAtText}
|
||||
</span>
|
||||
<span className="shrink-0 text-[#c99373]">|</span>
|
||||
<span className="min-w-0 truncate">
|
||||
结束时间 {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_disabled(requestId: 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
Reference in New Issue
Block a user