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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user