This commit is contained in:
2026-05-10 13:18:46 +08:00
parent dada5a4797
commit 1c16152708
17 changed files with 1197 additions and 99 deletions

View File

@@ -416,6 +416,43 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
});
});
test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => {
const onCreateFromForm = vi.fn();
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('上传拼图图片', { selector: 'input' }), {
target: {
files: [new File(['x'], 'first-level.png', { type: 'image/png' })],
},
});
await waitFor(() => {
expect(screen.getByAltText('拼图图片')).toBeTruthy();
});
fireEvent.change(screen.getByLabelText('画面AI重绘要求提示词'), {
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
});
fireEvent.click(screen.getByRole('button', { name: /稿/u }));
expect(onCreateFromForm).toHaveBeenCalledWith({
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
referenceImageSrc: uploadedDataUrl,
imageModel: 'gpt-image-2',
aiRedraw: true,
});
});
test('puzzle workspace shows AI redraw switch only after upload', async () => {
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
stubReferenceImageUpload(uploadedDataUrl);

View File

@@ -589,6 +589,45 @@ describe('PuzzleResultView', () => {
});
});
test('uses the saved level picture reference when regenerating a level image', () => {
const onExecuteAction = vi.fn();
const session = createSession({
draft: {
...createSession().draft!,
levels: [
{
...createSession().draft!.levels![0]!,
pictureReference: '/generated-puzzle-assets/history/saved-reference.png',
},
],
},
});
render(
<PuzzleResultView
session={session}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
referenceImageSrc: '/generated-puzzle-assets/history/saved-reference.png',
}),
);
});
test('passes the selected image model when regenerating a level image', () => {
const onExecuteAction = vi.fn();

View File

@@ -637,6 +637,8 @@ function PuzzleLevelDetailDialog({
);
const formalImageSrc = resolveLevelFormalImageSrc(level);
const hasFormalImage = Boolean(formalImageSrc);
const effectiveReferenceImageSrc =
referenceImageSrc.trim() || level.pictureReference?.trim() || '';
const isGenerationProgressVisible = isGenerationProgressActive;
const generationSecondsLeft = isBusy
? Math.max(generationCountdown, 1)
@@ -722,7 +724,7 @@ function PuzzleLevelDetailDialog({
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
effectiveReferenceImageSrc || undefined,
imageModel,
);
};
@@ -829,11 +831,11 @@ function PuzzleLevelDetailDialog({
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={referenceImageSrc ? '更换参考图' : '添加参考图'}
title={effectiveReferenceImageSrc ? '更换参考图' : '添加参考图'}
>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{referenceImageSrc ? '更换参考图' : '添加参考图'}
{effectiveReferenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<input
type="file"
@@ -861,11 +863,11 @@ function PuzzleLevelDetailDialog({
aria-label="图面参考"
/>
{referenceImageSrc ? (
{effectiveReferenceImageSrc ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={referenceImageSrc}
src={effectiveReferenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
@@ -882,6 +884,7 @@ function PuzzleLevelDetailDialog({
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
onLevelChange({ ...level, pictureReference: null });
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"

View File

@@ -310,6 +310,16 @@ const originalMatchMedia = window.matchMedia;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
function dispatchPointerEvent(
target: HTMLElement,
type: string,
options: { pointerId: number; clientY: number },
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, options);
target.dispatchEvent(event);
}
const puzzlePublicEntry = {
sourceType: 'puzzle',
workId: 'puzzle-work-public-1',
@@ -1298,6 +1308,132 @@ test('mobile recommend loading state is themed instead of hardcoded black', () =
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
});
test('logged in recommend runtime preloads adjacent work previews and drag switches like video feed', () => {
vi.useFakeTimers();
const onSelectNextRecommendEntry = vi.fn();
const onSelectPreviousRecommendEntry = vi.fn();
const firstEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-feed-1',
profileId: 'puzzle-profile-feed-1',
ownerUserId: 'user-feed-1',
publicWorkCode: 'PZ-FEED1',
worldName: '当前拼图',
coverImageSrc: 'current-cover.png',
} satisfies PlatformPublicGalleryCard;
const secondEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-feed-2',
profileId: 'puzzle-profile-feed-2',
ownerUserId: 'user-feed-2',
publicWorkCode: 'PZ-FEED2',
worldName: '下一拼图',
coverImageSrc: 'next-cover.png',
} satisfies PlatformPublicGalleryCard;
const thirdEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-feed-3',
profileId: 'puzzle-profile-feed-3',
ownerUserId: 'user-feed-3',
publicWorkCode: 'PZ-FEED3',
worldName: '上一拼图',
coverImageSrc: 'previous-cover.png',
} satisfies PlatformPublicGalleryCard;
render(
<AuthUiContext.Provider
value={{
user: {
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: (action) => action(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="home"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[firstEntry, secondEntry, thirdEntry]}
myEntries={[]}
historyEntries={[]}
profileDashboard={null}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
activeRecommendEntryKey="puzzle:user-feed-1:puzzle-profile-feed-1"
onSelectNextRecommendEntry={onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
/>
</AuthUiContext.Provider>,
);
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
expect(
document.querySelectorAll('.platform-recommend-runtime-preview'),
).toHaveLength(2);
expect(
document.querySelectorAll('.platform-recommend-swipe-card'),
).toHaveLength(3);
expect(screen.getAllByText('下一拼图').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2);
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
act(() => {
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 });
dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 210 });
});
const rail = document.querySelector(
'.platform-recommend-swipe-rail',
) as HTMLElement | null;
expect(rail?.className).toContain('platform-recommend-swipe-rail');
act(() => {
dispatchPointerEvent(meta, 'pointerup', { pointerId: 1, clientY: 210 });
vi.advanceTimersByTime(180);
});
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled();
vi.useRealTimers();
});
test('logged out active recommend bottom tab selects next work without login', async () => {
const user = userEvent.setup();
const onSelectNextRecommendEntry = vi.fn();

View File

@@ -26,6 +26,7 @@ import {
} from 'lucide-react';
import {
type ComponentType,
type CSSProperties,
type PointerEvent,
type ReactNode,
useCallback,
@@ -171,6 +172,8 @@ const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
const PROFILE_INVITE_REDEEM_ENTRY_VISIBLE_MS = 24 * 60 * 60 * 1000;
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type DiscoverChannel = 'recommend' | 'today' | 'category' | 'ranking';
@@ -727,18 +730,100 @@ function CreationLibraryCard({
);
}
function RecommendRuntimeMeta({
function RecommendRuntimePreviewCard({
entry,
position,
}: {
entry: PlatformPublicGalleryCard;
position: 'previous' | 'next';
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePublicGalleryCardKind(entry);
return (
<div
className="platform-recommend-runtime-preview"
aria-hidden="true"
data-preview-position={position}
>
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt=""
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.08),rgba(0,0,0,0.42))]" />
<div className="platform-recommend-runtime-preview__body">
<span className="platform-public-work-card__kind">{typeLabel}</span>
<span className="platform-recommend-runtime-preview__title">
{displayName}
</span>
</div>
</div>
);
}
function RecommendSwipeCard({
entry,
authorAvatarUrl,
onSelectNext,
onSelectPrevious,
isActive,
visual,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
onSelectNext?: () => void;
onSelectPrevious?: () => void;
isActive: boolean;
visual: ReactNode;
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
}) {
return (
<div
className={`platform-recommend-swipe-card ${isActive ? 'platform-recommend-swipe-card--active' : 'platform-recommend-swipe-card--preview'}`}
data-active={isActive ? 'true' : 'false'}
>
<div className="platform-recommend-swipe-card__visual">{visual}</div>
<div className="platform-recommend-swipe-card__meta">
<RecommendRuntimeMeta
entry={entry}
authorAvatarUrl={authorAvatarUrl}
isActive={isActive}
onDragPointerDown={onDragPointerDown}
onDragPointerMove={onDragPointerMove}
onDragPointerUp={onDragPointerUp}
onDragPointerCancel={onDragPointerCancel}
/>
</div>
</div>
);
}
function RecommendRuntimeMeta({
entry,
authorAvatarUrl,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
isActive = true,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
isActive?: boolean;
}) {
const swipeStartYRef = useRef<number | null>(null);
const playCount = getPlatformWorldPlayCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const likeCount = getPlatformWorldLikeCount(entry);
@@ -751,37 +836,23 @@ function RecommendRuntimeMeta({
{ label: '点赞', value: likeCount, icon: Heart },
{ label: '改造', value: remixCount, icon: MessageCircle },
];
const handlePointerEnd = (clientY: number) => {
const startY = swipeStartYRef.current;
swipeStartYRef.current = null;
if (startY === null) {
return;
}
const deltaY = clientY - startY;
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) {
return;
}
if (deltaY < 0) {
onSelectNext?.();
return;
}
onSelectPrevious?.();
};
return (
<section
className="platform-recommend-work-meta"
className={`platform-recommend-work-meta ${
isActive
? 'platform-recommend-work-meta--active'
: 'platform-recommend-work-meta--preview'
}`}
aria-label={`${entry.worldName} 作品信息`}
aria-hidden={!isActive}
data-active={isActive ? 'true' : 'false'}
onPointerDown={(event) => {
swipeStartYRef.current = event.clientY;
}}
onPointerUp={(event) => handlePointerEnd(event.clientY)}
onPointerCancel={() => {
swipeStartYRef.current = null;
onDragPointerDown?.(event);
}}
onPointerMove={onDragPointerMove}
onPointerUp={onDragPointerUp}
onPointerCancel={onDragPointerCancel}
>
<div className="platform-recommend-work-meta__stats">
{statItems.map(({ label, value, icon: Icon }) => (
@@ -3769,6 +3840,171 @@ export function RpgEntryHomeView({
) ??
recommendedFeedEntries[0] ??
null;
const activeRecommendIndex = activeRecommendEntry
? recommendedFeedEntries.findIndex(
(entry) =>
buildPublicGalleryCardKey(entry) ===
buildPublicGalleryCardKey(activeRecommendEntry),
)
: -1;
const previousRecommendEntry =
activeRecommendIndex >= 0 && recommendedFeedEntries.length > 1
? recommendedFeedEntries[
(activeRecommendIndex - 1 + recommendedFeedEntries.length) %
recommendedFeedEntries.length
]
: null;
const nextRecommendEntry =
activeRecommendIndex >= 0 && recommendedFeedEntries.length > 1
? recommendedFeedEntries[
(activeRecommendIndex + 1) % recommendedFeedEntries.length
]
: null;
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<1 | -1 | null>(null);
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{
pointerId: number;
startY: number;
dragging: boolean;
} | null>(null);
const commitRecommendDrag = useCallback(
(direction: 1 | -1) => {
if (recommendDragCommitDirection) {
return;
}
setRecommendDragCommitDirection(direction);
const panelHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
const commitDistance =
panelHeight > 0 ? panelHeight : window.innerHeight;
setRecommendDragOffsetY(
direction === 1 ? -commitDistance : commitDistance,
);
window.setTimeout(() => {
if (direction === 1) {
onSelectNextRecommendEntry?.();
} else {
onSelectPreviousRecommendEntry?.();
}
setRecommendDragOffsetY(0);
setRecommendDragCommitDirection(null);
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
},
[
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
recommendDragCommitDirection,
],
);
const beginRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
if (
recommendDragCommitDirection ||
!isAuthenticated ||
!activeRecommendEntry ||
recommendedFeedEntries.length <= 1
) {
return;
}
recommendDragStartRef.current = {
pointerId: event.pointerId,
startY: event.clientY,
dragging: false,
};
event.currentTarget.setPointerCapture?.(event.pointerId);
},
[
activeRecommendEntry,
isAuthenticated,
recommendDragCommitDirection,
recommendedFeedEntries.length,
],
);
const moveRecommendDrag = useCallback((event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (!drag) {
return;
}
const deltaY = event.clientY - drag.startY;
drag.dragging =
drag.dragging || Math.abs(deltaY) >= RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX / 2;
if (!drag.dragging) {
return;
}
event.preventDefault();
const cardHeight =
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
const dragLimit =
cardHeight > 0 ? cardHeight : RECOMMEND_ENTRY_DRAG_LIMIT_PX;
setRecommendDragOffsetY(
Math.max(
-dragLimit,
Math.min(dragLimit, deltaY),
),
);
}, []);
const endRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (!drag) {
return;
}
event.currentTarget.releasePointerCapture?.(drag.pointerId);
recommendDragStartRef.current = null;
const deltaY = event.clientY - drag.startY;
if (Math.abs(deltaY) < RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX) {
setRecommendDragOffsetY(0);
return;
}
commitRecommendDrag(deltaY < 0 ? 1 : -1);
},
[commitRecommendDrag],
);
const cancelRecommendDrag = useCallback(
(event: PointerEvent<HTMLElement>) => {
const drag = recommendDragStartRef.current;
if (drag) {
event.currentTarget.releasePointerCapture?.(drag.pointerId);
}
recommendDragStartRef.current = null;
setRecommendDragOffsetY(0);
},
[],
);
const recommendRailStyle = {
transform: `translate3d(0, ${recommendDragOffsetY}px, 0)`,
} satisfies CSSProperties;
const recommendRailClassName = recommendDragCommitDirection
? 'platform-recommend-swipe-rail--committing'
: recommendDragOffsetY === 0
? 'platform-recommend-swipe-rail--settled'
: 'platform-recommend-swipe-rail--dragging';
const selectNextRecommendEntry = useCallback(() => {
if (
isAuthenticated &&
activeRecommendEntry &&
recommendedFeedEntries.length > 1
) {
commitRecommendDrag(1);
return;
}
onSelectNextRecommendEntry?.();
}, [
activeRecommendEntry,
commitRecommendDrag,
isAuthenticated,
onSelectNextRecommendEntry,
recommendedFeedEntries.length,
]);
const openActiveRecommendEntry = useCallback(() => {
if (!activeRecommendEntry) {
return;
@@ -3786,12 +4022,6 @@ export function RpgEntryHomeView({
isAuthenticated,
openRecommendGalleryDetail,
]);
const selectNextRecommendEntry = useCallback(() => {
onSelectNextRecommendEntry?.();
}, [onSelectNextRecommendEntry]);
const selectPreviousRecommendEntry = useCallback(() => {
onSelectPreviousRecommendEntry?.();
}, [onSelectPreviousRecommendEntry]);
const leadPublicEntry = featuredShelf[0] ?? latestEntries[0] ?? null;
const openLeadPublicEntry = () => {
if (leadPublicEntry) {
@@ -3863,18 +4093,22 @@ export function RpgEntryHomeView({
</div>
) : null}
<section className="platform-recommend-runtime-panel">
{isLoadingPlatform ? (
{isLoadingPlatform ? (
<section className="platform-recommend-runtime-panel">
<div className="platform-recommend-runtime-state">
...
</div>
) : !isAuthenticated && activeRecommendEntry ? (
</section>
) : !isAuthenticated && activeRecommendEntry ? (
<section className="platform-recommend-runtime-panel">
<RecommendCoverOnlyCard
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
onClick={openActiveRecommendEntry}
/>
) : recommendRuntimeError ? (
</section>
) : recommendRuntimeError ? (
<section className="platform-recommend-runtime-panel">
<button
type="button"
onClick={() =>
@@ -3886,25 +4120,81 @@ export function RpgEntryHomeView({
>
{recommendRuntimeError}
</button>
) : isStartingRecommendEntry || !recommendRuntimeContent ? (
</section>
) : isStartingRecommendEntry || !recommendRuntimeContent ? (
<section className="platform-recommend-runtime-panel">
<div className="platform-recommend-runtime-state">...</div>
) : (
<div className="platform-recommend-runtime-viewport">
{recommendRuntimeContent}
</div>
)}
</section>
</section>
) : activeRecommendEntry ? (
<div
ref={recommendCardStageRef}
className="platform-recommend-swipe-stage"
>
<div
className={`platform-recommend-swipe-rail ${recommendRailClassName}`}
style={recommendRailStyle}
>
{previousRecommendEntry ? (
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--previous">
<RecommendSwipeCard
entry={previousRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
previousRecommendEntry,
)}
isActive={false}
visual={
<RecommendRuntimePreviewCard
entry={previousRecommendEntry}
position="previous"
/>
}
/>
</div>
) : null}
{activeRecommendEntry && isAuthenticated ? (
<RecommendRuntimeMeta
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
onSelectNext={selectNextRecommendEntry}
onSelectPrevious={selectPreviousRecommendEntry}
/>
) : !isLoadingPlatform ? (
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
) : null}
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--current">
<RecommendSwipeCard
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
activeRecommendEntry,
)}
isActive
visual={
<div className="platform-recommend-runtime-viewport">
{recommendRuntimeContent}
</div>
}
onDragPointerDown={beginRecommendDrag}
onDragPointerMove={moveRecommendDrag}
onDragPointerUp={endRecommendDrag}
onDragPointerCancel={cancelRecommendDrag}
/>
</div>
{nextRecommendEntry ? (
<div className="platform-recommend-swipe-page platform-recommend-swipe-page--next">
<RecommendSwipeCard
entry={nextRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(
nextRecommendEntry,
)}
isActive={false}
visual={
<RecommendRuntimePreviewCard
entry={nextRecommendEntry}
position="next"
/>
}
/>
</div>
) : null}
</div>
</div>
) : (
<section className="platform-recommend-runtime-panel">
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
</section>
)}
</div>
);

View File

@@ -2391,11 +2391,13 @@ body {
}
.platform-mobile-recommend-stage {
position: relative;
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 0.28rem;
overflow: hidden;
border: 0;
border-radius: 0;
background: transparent;
@@ -2421,6 +2423,108 @@ body {
min-width: 0;
overflow: hidden;
background: var(--platform-recommend-runtime-fill);
pointer-events: auto;
}
.platform-recommend-swipe-stage {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
pointer-events: auto;
}
.platform-recommend-swipe-rail {
position: absolute;
inset: 0;
will-change: transform;
}
.platform-recommend-swipe-rail--settled,
.platform-recommend-swipe-rail--committing {
transition: transform 180ms cubic-bezier(0.2, 0.78, 0.2, 1);
}
.platform-recommend-swipe-rail--dragging {
transition: none;
}
.platform-recommend-swipe-page {
position: absolute;
inset: 0;
min-width: 0;
overflow: hidden;
background: transparent;
}
.platform-recommend-swipe-page--previous {
transform: translate3d(0, -100%, 0);
}
.platform-recommend-swipe-page--current {
transform: translate3d(0, 0, 0);
}
.platform-recommend-swipe-page--next {
transform: translate3d(0, 100%, 0);
}
.platform-recommend-runtime-preview {
position: absolute;
inset: 0;
overflow: hidden;
background: var(--platform-recommend-runtime-fill);
color: white;
}
.platform-recommend-swipe-card {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 0.28rem;
}
.platform-recommend-swipe-card__visual {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border: 1px solid var(--platform-recommend-runtime-border);
border-radius: 1.65rem;
background: var(--platform-recommend-runtime-fill);
box-shadow: var(--platform-recommend-runtime-shadow);
}
.platform-recommend-swipe-card__meta {
flex: 0 0 auto;
min-width: 0;
}
.platform-recommend-runtime-preview__body {
position: absolute;
right: 1rem;
bottom: 1rem;
left: 1rem;
display: flex;
min-width: 0;
flex-direction: column;
align-items: flex-start;
gap: 0.52rem;
}
.platform-recommend-runtime-preview__title {
display: -webkit-box;
max-width: 100%;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
font-size: clamp(1.45rem, 6.4vw, 2.15rem);
font-weight: 950;
line-height: 1.05;
letter-spacing: 0;
text-shadow: 0 2px 14px rgba(0, 0, 0, 0.42);
}
.platform-recommend-cover-only {
@@ -2495,7 +2599,7 @@ body {
flex: 0 0 auto;
min-width: 0;
color: var(--platform-text-strong);
touch-action: pan-y;
touch-action: none;
user-select: none;
}