Increase VectorEngine timeouts and add image UI

Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
This commit is contained in:
2026-05-15 02:40:59 +08:00
parent 4642855fd0
commit 74fd9a33ac
87 changed files with 5508 additions and 1261 deletions

View File

@@ -1,8 +1,15 @@
import { Share2, Trash2 } from 'lucide-react';
import {
BadgeCheck,
Clock3,
Loader2,
Share2,
Trash2,
} from 'lucide-react';
import {
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type TouchEvent as ReactTouchEvent,
useEffect,
useMemo,
useRef,
@@ -239,7 +246,8 @@ export function CustomWorldWorkCard({
const [isSwipeActionRevealed, setIsSwipeActionRevealed] = useState(false);
const [swipeOffset, setSwipeOffset] = useState(0);
const isPublished = item.status === 'published';
const canUseShareAction = isPublished && item.canShare && Boolean(item.sharePath);
const canUseShareAction =
isPublished && item.canShare && Boolean(item.sharePath);
const swipeActionCount = (canUseShareAction ? 1 : 0) + (onDelete ? 1 : 0);
const swipeRevealWidth = swipeActionCount * SWIPE_ACTION_WIDTH_PX;
const canClaimPointIncentive =
@@ -252,54 +260,28 @@ export function CustomWorldWorkCard({
isPublished ? item.metrics : EMPTY_PUBLISHED_METRICS,
previousMetricValues,
);
const surfaceOffset = isSwipeDragging
const coverFadeStyle = {
WebkitMaskImage:
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.04) 18%, rgba(0, 0, 0, 0.18) 42%, rgba(0, 0, 0, 0.48) 70%, rgba(0, 0, 0, 0.72) 100%)',
maskImage:
'linear-gradient(90deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.04) 18%, rgba(0, 0, 0, 0.18) 42%, rgba(0, 0, 0, 0.48) 70%, rgba(0, 0, 0, 0.72) 100%)',
} as CSSProperties;
const currentSwipeOffset = isSwipeDragging
? swipeOffset
: isSwipeActionRevealed
? -swipeRevealWidth
: 0;
const swipeActionOpacity =
swipeRevealWidth > 0 ? Math.min(1, Math.abs(surfaceOffset) / swipeRevealWidth) : 0;
const swipeSurfaceStyle = {
'--creation-work-card-swipe-offset': `${surfaceOffset}px`,
} as CSSProperties;
const swipeShellStyle = {
'--creation-work-card-action-opacity': `${swipeActionOpacity}`,
} as CSSProperties;
const sideCoverStyle = {
const cardSurfaceStyle = {
'--creation-work-card-swipe-offset': `${currentSwipeOffset}px`,
'--creation-work-card-cover-fallback': `url(${fallbackCoverImageSrc})`,
} as CSSProperties;
const closeSwipeActions = () => {
setIsSwipeActionRevealed(false);
setSwipeOffset(0);
lastSwipeOffsetRef.current = 0;
};
const revealSwipeActions = () => {
if (swipeRevealWidth <= 0) {
closeSwipeActions();
return;
}
setIsSwipeActionRevealed(true);
setSwipeOffset(-swipeRevealWidth);
lastSwipeOffsetRef.current = -swipeRevealWidth;
};
const scheduleOpenSuppressReset = () => {
if (typeof window === 'undefined') {
return;
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
suppressOpenResetTimerRef.current = window.setTimeout(() => {
suppressOpenResetTimerRef.current = null;
suppressOpenRef.current = false;
}, 260);
};
const swipeShellStyle = {
'--creation-work-card-action-opacity': `${
swipeRevealWidth > 0
? Math.min(1, Math.abs(currentSwipeOffset) / swipeRevealWidth)
: 0
}`,
} as CSSProperties;
const copyShareText = () => {
const publicWorkCode = item.publicWorkCode?.trim();
@@ -324,57 +306,42 @@ export function CustomWorldWorkCard({
}, 1400);
});
};
useEffect(
() => () => {
useEffect(() => {
return () => {
if (shareResetTimerRef.current !== null) {
window.clearTimeout(shareResetTimerRef.current);
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
},
[],
);
useEffect(() => {
if (swipeActionCount > 0) {
return;
}
closeSwipeActions();
}, [swipeActionCount]);
const beginSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
if (swipeRevealWidth <= 0) {
return;
}
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
swipeGestureRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
isDragging: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
}, []);
const closeSwipeActions = () => {
setIsSwipeActionRevealed(false);
setSwipeOffset(0);
lastSwipeOffsetRef.current = 0;
};
const updateSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
const revealSwipeActions = () => {
if (swipeRevealWidth <= 0) {
closeSwipeActions();
return;
}
const deltaX = event.clientX - gesture.startX;
const deltaY = event.clientY - gesture.startY;
setIsSwipeActionRevealed(true);
setSwipeOffset(-swipeRevealWidth);
lastSwipeOffsetRef.current = -swipeRevealWidth;
};
const updateSwipeOffset = (
gesture: NonNullable<typeof swipeGestureRef.current>,
clientX: number,
clientY: number,
preventDefault: () => void,
) => {
const deltaX = clientX - gesture.startX;
const deltaY = clientY - gesture.startY;
if (!gesture.isDragging) {
if (
Math.abs(deltaX) < SWIPE_DIRECTION_LOCK_PX &&
@@ -393,7 +360,7 @@ export function CustomWorldWorkCard({
}
// 中文注释:横向手势只移动卡片表层,删除动作保持在底层,避免列表滚动时误触。
event.preventDefault();
preventDefault();
suppressOpenRef.current = true;
const nextOffset = clampSwipeOffset(
gesture.startOffset + deltaX,
@@ -403,19 +370,9 @@ export function CustomWorldWorkCard({
setSwipeOffset(nextOffset);
};
const endSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
event.currentTarget.releasePointerCapture?.(event.pointerId);
swipeGestureRef.current = null;
const finishSwipeGesture = (wasDragging: boolean) => {
setIsSwipeDragging(false);
if (!gesture.isDragging) {
if (!wasDragging) {
return;
}
@@ -431,9 +388,74 @@ export function CustomWorldWorkCard({
scheduleOpenSuppressReset();
};
const cancelSwipeGesture = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const scheduleOpenSuppressReset = () => {
if (typeof window === 'undefined') {
return;
}
if (suppressOpenResetTimerRef.current !== null) {
window.clearTimeout(suppressOpenResetTimerRef.current);
}
suppressOpenResetTimerRef.current = window.setTimeout(() => {
suppressOpenResetTimerRef.current = null;
suppressOpenRef.current = false;
}, 260);
};
useEffect(() => {
if (swipeActionCount > 0) {
return;
}
closeSwipeActions();
}, [swipeActionCount]);
const beginSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
if (swipeRevealWidth <= 0) {
return;
}
if (event.pointerType === 'mouse' && event.button !== 0) {
return;
}
swipeGestureRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
isDragging: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
};
const updateSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
updateSwipeOffset(
gesture,
event.clientX,
event.clientY,
() => event.preventDefault(),
);
};
const endSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) {
return;
}
event.currentTarget.releasePointerCapture?.(event.pointerId);
swipeGestureRef.current = null;
finishSwipeGesture(gesture.isDragging);
};
const cancelSwipeGesture = (event: ReactPointerEvent<HTMLDivElement>) => {
const gesture = swipeGestureRef.current;
if (gesture?.pointerId === event.pointerId) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
@@ -448,6 +470,69 @@ export function CustomWorldWorkCard({
}
};
const beginTouchSwipeGesture = (
event: ReactTouchEvent<HTMLDivElement>,
) => {
if (swipeRevealWidth <= 0) {
return;
}
const touch = event.touches[0];
if (!touch) {
return;
}
swipeGestureRef.current = {
pointerId: -1,
startX: touch.clientX,
startY: touch.clientY,
startOffset: isSwipeActionRevealed ? -swipeRevealWidth : 0,
isDragging: false,
};
};
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(),
);
};
const endTouchSwipeGesture = () => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== -1) {
return;
}
swipeGestureRef.current = null;
finishSwipeGesture(gesture.isDragging);
};
const cancelTouchSwipeGesture = () => {
const gesture = swipeGestureRef.current;
if (!gesture || gesture.pointerId !== -1) {
return;
}
swipeGestureRef.current = null;
setIsSwipeDragging(false);
if (isSwipeActionRevealed) {
revealSwipeActions();
} else {
closeSwipeActions();
}
};
const handleCardOpen = () => {
if (isSwipeActionRevealed) {
closeSwipeActions();
@@ -458,7 +543,12 @@ export function CustomWorldWorkCard({
};
const handleCardKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft' && swipeRevealWidth > 0) {
if (
(event.key === 'ArrowLeft' ||
event.key === 'ContextMenu' ||
(event.shiftKey && event.key === 'F10')) &&
swipeRevealWidth > 0
) {
event.preventDefault();
revealSwipeActions();
return;
@@ -578,38 +668,59 @@ export function CustomWorldWorkCard({
onPointerMove={updateSwipeGesture}
onPointerUp={endSwipeGesture}
onPointerCancel={cancelSwipeGesture}
style={swipeSurfaceStyle}
onTouchStart={beginTouchSwipeGesture}
onTouchMove={updateTouchSwipeGesture}
onTouchEnd={endTouchSwipeGesture}
onTouchCancel={cancelTouchSwipeGesture}
onContextMenu={(event) => {
if (swipeRevealWidth <= 0) {
return;
}
event.preventDefault();
revealSwipeActions();
}}
style={cardSurfaceStyle}
className={`creation-work-card platform-category-game-item platform-interactive-card cursor-pointer overflow-hidden text-left ${isPublished ? 'creation-work-card--published' : 'creation-work-card--draft'} ${item.isGenerating ? 'creation-work-card--generating' : ''} ${isSwipeDragging ? 'creation-work-card--swiping' : ''}`}
>
<div className="creation-work-card__body platform-category-game-item__body">
<div className="creation-work-card__title-row platform-category-game-item__title-row">
<span className="creation-work-card__title platform-category-game-item__title">
{displayTitle}
</span>
<span
className={`creation-work-card__status-pill creation-work-card__status-pill--${
item.isGenerating ? 'generating' : item.status
}`}
>
{item.isGenerating
? '生成中'
: item.status === 'published'
? '已发布'
: '草稿'}
</span>
<div className="creation-work-card__title-lockup">
<span
aria-label={
item.isGenerating
? '生成中'
: item.status === 'published'
? '已发布'
: '草稿'
}
className={`creation-work-card__state-mark creation-work-card__state-mark--${
item.isGenerating ? 'generating' : item.status
}`}
>
{item.isGenerating ? (
<Loader2 aria-hidden="true" className="h-3.5 w-3.5" />
) : item.status === 'published' ? (
<BadgeCheck aria-hidden="true" className="h-3.5 w-3.5" />
) : (
<Clock3 aria-hidden="true" className="h-3.5 w-3.5" />
)}
</span>
<span className="creation-work-card__title platform-category-game-item__title">
{displayTitle}
</span>
</div>
</div>
<div className="creation-work-card__meta platform-category-game-item__meta">
{item.badges
.slice(1)
.map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`creation-work-card__badge platform-pill ${BADGE_TONE_CLASS[badge.tone]}`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
{item.badges.slice(1).map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`creation-work-card__badge platform-pill ${BADGE_TONE_CLASS[badge.tone]}`}
>
{formatPlatformWorkDisplayTag(badge.label)}
</span>
))}
</div>
<div className="creation-work-card__summary platform-category-game-item__summary">
@@ -698,18 +809,20 @@ export function CustomWorldWorkCard({
<div
className="creation-work-card__side-cover"
style={sideCoverStyle}
style={coverFadeStyle}
aria-hidden="true"
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
fallbackImageSrc={fallbackCoverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="absolute inset-0"
/>
<div className="creation-work-card__side-cover-inner">
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
fallbackImageSrc={fallbackCoverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="absolute inset-0"
/>
</div>
</div>
{item.hasUnreadUpdate ? (
<span
@@ -719,7 +832,10 @@ export function CustomWorldWorkCard({
) : null}
{item.isGenerating ? (
<div className="creation-work-card__generating-mask" aria-hidden="true">
<div
className="creation-work-card__generating-mask"
aria-hidden="true"
>
<span className="creation-work-card__spinner" />
<span>...</span>
</div>