Merge branch 'master' into codex/auth-spacetime-fail-closed

This commit is contained in:
2026-05-28 00:52:15 +08:00
46 changed files with 1983 additions and 292 deletions

View File

@@ -142,17 +142,22 @@ describe('CustomWorldGenerationView', () => {
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('w-[min(35rem,94vw)]');
).toContain('w-[400px]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.className,
).toContain('sm:w-[52rem]');
).toContain('h-[400px]');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.getAttribute('data-ring-start-degrees'),
).toBe('155');
).toBe('135');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
.getAttribute('data-ring-fill-start-degrees'),
).toBe('135');
expect(
screen
.getByRole('progressbar', { name: progressTitle })
@@ -193,12 +198,12 @@ describe('CustomWorldGenerationView', () => {
screen
.getByTestId('generation-hero-progress-ring-track')
.getAttribute('transform'),
).toBe('rotate(155 200 200)');
).toBe('rotate(135 200 200)');
expect(
screen
.getByTestId('generation-hero-progress-ring-fill')
.getAttribute('transform'),
).toBe('rotate(155 200 200)');
).toBe('rotate(135 200 200)');
expect(
screen
.getByTestId('generation-hero-progress-ring-fill')

View File

@@ -4,8 +4,16 @@ import { useEffect, useId, useRef } from 'react';
import generationHeroVideo from '../../media/create_bg_video.mp4';
const GENERATION_PROGRESS_RING_START_DEGREES = 155;
const GENERATION_PROGRESS_RING_SWEEP_DEGREES = 270;
const GENERATION_PROGRESS_RING_GAP_DEGREES = 90;
const GENERATION_PROGRESS_RING_BOTTOM_DEGREES = 90;
// 中文注释SVG 圆从 3 点钟方向起笔;起点放在 135deg可让 90deg 开口居中落在正下方。
const GENERATION_PROGRESS_RING_START_DEGREES =
GENERATION_PROGRESS_RING_BOTTOM_DEGREES +
GENERATION_PROGRESS_RING_GAP_DEGREES / 2;
const GENERATION_PROGRESS_RING_FILL_START_DEGREES =
GENERATION_PROGRESS_RING_START_DEGREES;
const GENERATION_PROGRESS_RING_SWEEP_DEGREES =
360 - GENERATION_PROGRESS_RING_GAP_DEGREES;
const GENERATION_PROGRESS_RING_VIEWBOX = 400;
const GENERATION_PROGRESS_RING_CENTER = GENERATION_PROGRESS_RING_VIEWBOX / 2;
const GENERATION_PROGRESS_RING_RADIUS = 166;
@@ -118,7 +126,9 @@ export function GenerationProgressHero({
const safeProgress = clampGenerationProgress(progressValue);
const ringGradientId = useId().replace(/:/g, '');
const ringMetrics = buildGenerationRingMetrics(safeProgress);
const ringDegrees = Math.round((safeProgress / 100) * 270);
const ringDegrees = Math.round(
(safeProgress / 100) * GENERATION_PROGRESS_RING_SWEEP_DEGREES,
);
const ringTrackDasharray = `${ringMetrics.sweepLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
const ringFillDasharray = `${ringMetrics.progressLength.toFixed(2)} ${ringMetrics.circumference.toFixed(2)}`;
@@ -160,16 +170,19 @@ export function GenerationProgressHero({
</div>
<div
className="relative mx-auto aspect-square w-[min(35rem,94vw)] overflow-visible rounded-full sm:w-[52rem]"
className="relative mx-auto h-[400px] w-[400px] shrink-0 overflow-visible rounded-full"
role="progressbar"
aria-label={title}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={safeProgress}
data-ring-start-degrees={GENERATION_PROGRESS_RING_START_DEGREES}
data-ring-fill-start-degrees={
GENERATION_PROGRESS_RING_FILL_START_DEGREES
}
data-ring-sweep-degrees={GENERATION_PROGRESS_RING_SWEEP_DEGREES}
data-ring-fill-degrees={ringDegrees}
data-ring-gap-degrees={90}
data-ring-gap-degrees={GENERATION_PROGRESS_RING_GAP_DEGREES}
>
<svg
data-testid="generation-hero-progress-ring"
@@ -214,7 +227,7 @@ export function GenerationProgressHero({
strokeLinecap="round"
strokeWidth={GENERATION_PROGRESS_RING_STROKE_WIDTH}
strokeDasharray={ringFillDasharray}
transform={`rotate(${GENERATION_PROGRESS_RING_START_DEGREES} ${GENERATION_PROGRESS_RING_CENTER} ${GENERATION_PROGRESS_RING_CENTER})`}
transform={`rotate(${GENERATION_PROGRESS_RING_FILL_START_DEGREES} ${GENERATION_PROGRESS_RING_CENTER} ${GENERATION_PROGRESS_RING_CENTER})`}
vectorEffect="non-scaling-stroke"
shapeRendering="geometricPrecision"
/>

View File

@@ -130,12 +130,12 @@ describe('BarkBattleGeneratingView', () => {
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('w-[min(35rem,94vw)]');
).toContain('w-[400px]');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.className,
).toContain('sm:w-[52rem]');
).toContain('h-[400px]');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
@@ -145,7 +145,12 @@ describe('BarkBattleGeneratingView', () => {
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.getAttribute('data-ring-start-degrees'),
).toBe('155');
).toBe('135');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
.getAttribute('data-ring-fill-start-degrees'),
).toBe('135');
expect(
screen
.getByRole('progressbar', { name: '汪汪声浪素材生成进度' })
@@ -186,12 +191,12 @@ describe('BarkBattleGeneratingView', () => {
screen
.getByTestId('generation-hero-progress-ring-track')
.getAttribute('transform'),
).toBe('rotate(155 200 200)');
).toBe('rotate(135 200 200)');
expect(
screen
.getByTestId('generation-hero-progress-ring-fill')
.getAttribute('transform'),
).toBe('rotate(155 200 200)');
).toBe('rotate(135 200 200)');
expect(
screen
.getByTestId('generation-hero-progress-ring-fill')

View File

@@ -560,7 +560,7 @@ test('creation hub shows RPG public work code from published library entry', ()
expect(screen.queryByText('CW-00000001')).toBeNull();
});
test('creation hub hides persisted draft delete action behind swipe underlay', () => {
test('creation hub exposes persisted draft delete action directly on the card', () => {
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
@@ -579,7 +579,7 @@ test('creation hub hides persisted draft delete action behind swipe underlay', (
expect(
container.querySelector('.creation-work-card__swipe-underlay'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub reveals persisted draft delete action from left swipe', () => {
@@ -607,7 +607,9 @@ test('creation hub reveals persisted draft delete action from left swipe', () =>
});
fireEvent.touchEnd(card);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(
container.querySelector('.creation-work-card__swipe-button--danger'),
).toBeTruthy();
expect(
container.querySelector('.creation-work-card-shell--actions-visible'),
).toBeTruthy();
@@ -615,7 +617,7 @@ test('creation hub reveals persisted draft delete action from left swipe', () =>
test('creation hub reveals persisted draft delete action from keyboard', async () => {
const user = userEvent.setup();
render(
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
@@ -633,7 +635,9 @@ test('creation hub reveals persisted draft delete action from keyboard', async (
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(
container.querySelector('.creation-work-card__swipe-button--danger'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: '分享' })).toBeNull();
});
@@ -642,7 +646,7 @@ test('creation hub shows delete action for baby object match drafts', async () =
const onDeleteBabyObjectMatch = vi.fn();
const onOpenBabyObjectMatchDetail = vi.fn();
render(
const { container } = render(
<CustomWorldCreationHub
items={[]}
babyObjectMatchItems={[babyObjectMatchDraftItem]}
@@ -662,7 +666,11 @@ test('creation hub shows delete action for baby object match drafts', async () =
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
await user.click(screen.getByRole('button', { name: '删除' }));
await user.click(
container.querySelector(
'.creation-work-card__swipe-button--danger',
) as HTMLButtonElement,
);
expect(onDeleteBabyObjectMatch).toHaveBeenCalledWith(
babyObjectMatchDraftItem,
@@ -711,7 +719,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 revealed without opening card', async () => {
test('creation hub published work delete action is directly visible', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
@@ -751,12 +759,6 @@ test('creation hub published work delete action is revealed without opening card
/>,
);
expect(screen.queryByRole('button', { name: '删除' })).toBeNull();
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
screen.getByRole('button', { name: //u }).focus();
await user.keyboard('{ArrowLeft}');
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.getByRole('button', { name: '分享' })).toBeTruthy();
@@ -768,6 +770,115 @@ test('creation hub published work delete action is revealed without opening card
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});
test('creation hub exposes work delete action directly on card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:direct-delete',
profileId: 'puzzle-profile-direct-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={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail}
onDeletePuzzle={onDeletePuzzle}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
await user.click(screen.getByRole('button', { name: '删除' }));
expect(onDeletePuzzle).toHaveBeenCalledWith(
expect.objectContaining({ profileId: 'puzzle-profile-direct-delete' }),
);
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});
test('creation hub keeps swipe delete action available', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
const { container } = render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:swipe-delete',
profileId: 'puzzle-profile-swipe-delete',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '左滑删除拼图',
summary: '左滑仍然保留辅助删除入口。',
themeTags: ['灯塔'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-05-02T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-05-02T12:10:00.000Z').toISOString(),
playCount: 8,
remixCount: 2,
likeCount: 1,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail}
onDeletePuzzle={onDeletePuzzle}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
/>,
);
const card = screen.getByRole('button', { name: //u });
fireEvent.touchStart(card, {
touches: [{ clientX: 180, clientY: 20 }],
});
fireEvent.touchMove(card, {
touches: [{ clientX: 80, clientY: 22 }],
});
fireEvent.touchEnd(card);
const swipeDeleteButton = container.querySelector(
'.creation-work-card__swipe-button--danger',
) as HTMLButtonElement | null;
expect(swipeDeleteButton).toBeTruthy();
await user.click(swipeDeleteButton!);
expect(onDeletePuzzle).toHaveBeenCalledWith(
expect.objectContaining({ profileId: 'puzzle-profile-swipe-delete' }),
);
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});
test('creation hub opens persisted rpg drafts by card click', async () => {
const user = userEvent.setup();
const openedItems: CustomWorldWorkSummary[] = [];
@@ -942,7 +1053,7 @@ test('creation hub left swipe draft reveals delete without opening card', () =>
const onDeletePublished = vi.fn();
const onOpenDraft = vi.fn();
render(
const { container } = render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
@@ -966,6 +1077,8 @@ test('creation hub left swipe draft reveals delete without opening card', () =>
});
fireEvent.touchEnd(card);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(
container.querySelector('.creation-work-card__swipe-button--danger'),
).toBeTruthy();
expect(onOpenDraft).not.toHaveBeenCalled();
});

View File

@@ -676,43 +676,75 @@ export function CustomWorldWorkCard({
{displayTitle}
</span>
</div>
{canUseShareAction ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
onTouchStart={(event) => {
event.stopPropagation();
}}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="creation-work-card__share-button"
>
<Share2 aria-hidden="true" className="h-4 w-4" />
</button>
) : null}
<div className="creation-work-card__quick-actions">
{canUseShareAction ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
suppressOpenRef.current = false;
closeSwipeActions();
copyShareText();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
onPointerDown={(event) => {
event.stopPropagation();
}}
onTouchStart={(event) => {
event.stopPropagation();
}}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
className="creation-work-card__quick-action-button"
>
<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>
<div className="creation-work-card__meta platform-category-game-item__meta">

View File

@@ -2081,7 +2081,7 @@ function pickDraftCompletionDialogSourceId(
function buildDraftCompletionDialogSource(
kind: CreationWorkShelfKind,
ids: Array<string | null | undefined>,
) {
): string {
const sourceId = pickDraftCompletionDialogSourceId(ids);
switch (kind) {
case 'rpg':
@@ -2105,6 +2105,7 @@ function buildDraftCompletionDialogSource(
case 'baby-object-match':
return formatPlatformTaskCompletionSource('宝贝识物草稿', sourceId);
}
return formatPlatformTaskCompletionSource('创作草稿', sourceId);
}
function createMiniGameDraftGenerationStateForRestoredDraft(

View File

@@ -3246,6 +3246,41 @@ test('logged out active recommend bottom tab selects next work without login', a
expect(openLoginModal).not.toHaveBeenCalled();
});
test('logged out recommend card supports vertical swipe without login', () => {
vi.useFakeTimers();
const onSelectNextRecommendEntry = vi.fn();
const openLoginModal = vi.fn();
renderLoggedOutHomeView(openLoginModal, {
latestEntries: [
puzzlePublicEntry,
{
...puzzlePublicEntry,
workId: 'puzzle-work-guest-next',
profileId: 'puzzle-profile-guest-next',
ownerUserId: 'user-guest-next',
publicWorkCode: 'PZ-GUEST-NEXT',
worldName: '访客下一张',
},
],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
onSelectNextRecommendEntry,
recommendRuntimeContent: <div data-testid="guest-recommend-runtime" />,
});
const meta = screen.getByLabelText('奇幻拼图 作品信息') as HTMLElement;
act(() => {
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 320 });
dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 220 });
dispatchPointerEvent(meta, 'pointerup', { pointerId: 1, clientY: 220 });
vi.advanceTimersByTime(180);
});
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
expect(openLoginModal).not.toHaveBeenCalled();
vi.useRealTimers();
});
test('mobile recommend meta loads real author avatar from public user summary', async () => {
mockGetPublicAuthUserById.mockResolvedValueOnce({
id: 'user-2',

View File

@@ -5282,7 +5282,6 @@ export function RpgEntryHomeView({
(event: PointerEvent<HTMLElement>) => {
if (
recommendDragCommitDirection ||
!isAuthenticated ||
!activeRecommendEntry ||
recommendedFeedEntries.length <= 1
) {
@@ -5298,7 +5297,6 @@ export function RpgEntryHomeView({
},
[
activeRecommendEntry,
isAuthenticated,
recommendDragCommitDirection,
recommendedFeedEntries.length,
],