重构作品分享链路

统一发布分享弹窗为作品分享卡片

支持下载分享卡与小程序九宫切图保存

小程序复制链接改为可直达作品详情的 web-view 路径

修复本地 dev Rust 构建绕过损坏 sccache

补充分享链路与 dev 启动文档和测试
This commit is contained in:
2026-06-11 21:32:29 +08:00
parent ccb5023197
commit c5763fdf25
37 changed files with 1958 additions and 305 deletions

View File

@@ -10,24 +10,39 @@ import {
import { afterEach, describe, expect, test, vi } from 'vitest';
import * as clipboardService from '../../services/clipboard';
import * as shareGridService from '../../services/wechatMiniProgramShareGrid';
import { PublishShareModal } from './PublishShareModal';
import {
buildMiniProgramPublishSharePath,
buildPublishShareCardFileName,
buildPublishShareCopyUrl,
buildPublishShareText,
buildPublishShareUrl,
type PublishShareModalPayload,
} from './publishShareModalModel';
vi.mock('../../services/clipboard', () => ({
copyTextToClipboard: vi.fn(),
}));
vi.mock('../../services/wechatMiniProgramShareGrid', () => ({
canUseWechatMiniProgramShareGrid: vi.fn(() => false),
openWechatMiniProgramShareGridPage: vi.fn(),
}));
const payload: PublishShareModalPayload = {
title: '暖灯猫街',
publicWorkCode: 'PZ-00000001',
stage: 'puzzle-gallery-detail',
workTypeLabel: '拼图',
coverImageSrc: '/cover.png',
};
afterEach(() => {
vi.clearAllMocks();
vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue(
false,
);
window.history.replaceState(null, '', '/');
});
describe('PublishShareModal', () => {
@@ -39,7 +54,40 @@ describe('PublishShareModal', () => {
expect(text).toContain('/gallery/puzzle/detail?work=PZ-00000001');
});
test('renders share text and channel icons, then copies from main button', async () => {
test('builds the card file name without unsafe path characters', () => {
expect(
buildPublishShareCardFileName({
title: '暖灯:猫街',
publicWorkCode: 'PZ-00000001',
}),
).toBe('暖灯猫街-PZ-00000001.png');
});
test('builds a mini program share path with public work detail params', () => {
const sharePath = buildMiniProgramPublishSharePath(payload);
const url = new URL(sharePath, 'https://mini.test');
expect(url.pathname).toBe('/pages/web-view/index');
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
expect(url.searchParams.get('work')).toBe('PZ-00000001');
expect(buildPublishShareCopyUrl(payload, { miniProgramRuntime: true })).toBe(
sharePath,
);
});
test('keeps existing mini program share params and fills missing detail params', () => {
const sharePath = buildMiniProgramPublishSharePath(
payload,
'/pages/web-view/index?scene=poster',
);
const url = new URL(sharePath, 'https://mini.test');
expect(url.searchParams.get('scene')).toBe('poster');
expect(url.searchParams.get('targetPath')).toBe('/works/detail');
expect(url.searchParams.get('work')).toBe('PZ-00000001');
});
test('renders the share card and copies the public link', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
render(
@@ -52,26 +100,55 @@ describe('PublishShareModal', () => {
expect(dialog.className).toContain('platform-modal-shell');
expect(dialog.className).toContain('rounded-[1.75rem]');
expect(dialog.getAttribute('style')).toBeNull();
expect(within(dialog).getByText(//u)).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy();
expect(
within(dialog).getByTestId('share-channel-logo-wechat'),
).toBeTruthy();
expect(within(dialog).getByTestId('share-channel-logo-qq')).toBeTruthy();
expect(
within(dialog).getByTestId('share-channel-logo-douyin'),
).toBeTruthy();
expect(within(dialog).getByRole('region', { name: '分享卡片' })).toBeTruthy();
expect(within(dialog).getByText('拼图')).toBeTruthy();
expect(within(dialog).getByText('暖灯猫街')).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '复制链接' })).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '下载卡片' })).toBeTruthy();
expect(within(dialog).queryByRole('button', { name: '九宫切图' })).toBeNull();
fireEvent.click(within(dialog).getByRole('button', { name: '分享' }));
fireEvent.click(within(dialog).getByRole('button', { name: '复制链接' }));
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-00000001'),
buildPublishShareUrl(payload),
);
await waitFor(() => {
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy();
});
});
test('copies the mini program link inside mini program web-view', async () => {
vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true);
window.history.replaceState(
null,
'',
'/?clientRuntime=wechat_mini_program',
);
render(
<PublishShareModal open payload={payload} onClose={() => {}} />,
);
const dialog = screen.getByRole('dialog', { name: '分享给朋友' });
fireEvent.click(within(dialog).getByRole('button', { name: '复制链接' }));
expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith(
buildMiniProgramPublishSharePath(payload),
);
await waitFor(() => {
expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy();
});
});
test('shows the mini program grid action only inside mini program runtime', () => {
vi.mocked(shareGridService.canUseWechatMiniProgramShareGrid).mockReturnValue(
true,
);
render(
<PublishShareModal open payload={payload} onClose={() => {}} />,
);
expect(screen.getByRole('button', { name: '九宫切图' })).toBeTruthy();
});
});

View File

@@ -1,10 +1,18 @@
import { Check, Copy } from 'lucide-react';
import { Check, Copy, Download, Grid3X3, Link2 } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
import { copyTextToClipboard } from '../../services/clipboard';
import { useAuthUi } from '../auth/AuthUiContext';
import {
buildPublishShareText,
canUseWechatMiniProgramShareGrid,
openWechatMiniProgramShareGridPage,
} from '../../services/wechatMiniProgramShareGrid';
import { useAuthUi } from '../auth/AuthUiContext';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { downloadPublishShareCardImage } from './publishShareCardImage';
import {
buildPublishShareCopyUrl,
type PublishShareModalPayload,
} from './publishShareModalModel';
import { UnifiedModal } from './UnifiedModal';
@@ -15,78 +23,27 @@ type PublishShareModalProps = {
onClose: () => void;
};
type ShareChannelId = 'wechat' | 'qq' | 'douyin';
type ActionState = 'idle' | 'success' | 'failed';
type ShareChannel = {
id: ShareChannelId;
label: string;
iconClassName: string;
};
// 中文注释:渠道图标只承载品牌轮廓,不复用社群二维码或通用聊天图标。
const SHARE_CHANNEL_ICON_PATHS: Record<ShareChannelId, string> = {
wechat:
'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z',
qq: 'M21.395 15.035a40 40 0 0 0-.803-2.264l-1.079-2.695c.001-.032.014-.562.014-.836C19.526 4.632 17.351 0 12 0S4.474 4.632 4.474 9.241c0 .274.013.804.014.836l-1.08 2.695a39 39 0 0 0-.802 2.264c-1.021 3.283-.69 4.643-.438 4.673.54.065 2.103-2.472 2.103-2.472 0 1.469.756 3.387 2.394 4.771-.612.188-1.363.479-1.845.835-.434.32-.379.646-.301.778.343.578 5.883.369 7.482.189 1.6.18 7.14.389 7.483-.189.078-.132.132-.458-.301-.778-.483-.356-1.233-.646-1.846-.836 1.637-1.384 2.393-3.302 2.393-4.771 0 0 1.563 2.537 2.103 2.472.251-.03.581-1.39-.438-4.673',
douyin:
'M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z',
};
const SHARE_CHANNELS = [
{
id: 'wechat',
label: '微信',
iconClassName: 'bg-[#07c160] text-white',
},
{
id: 'qq',
label: 'QQ',
iconClassName: 'bg-[#12b7f5] text-white',
},
{
id: 'douyin',
label: '抖音',
iconClassName: 'bg-black text-white',
},
] as const satisfies readonly ShareChannel[];
function ShareChannelLogo({ channel }: { channel: ShareChannel }) {
const iconPath = SHARE_CHANNEL_ICON_PATHS[channel.id];
if (channel.id === 'douyin') {
return (
<svg
viewBox="-1 -1 26 26"
aria-hidden="true"
focusable="false"
className="h-6 w-6 overflow-visible"
data-share-channel-logo={channel.id}
data-testid={`share-channel-logo-${channel.id}`}
>
<path d={iconPath} fill="#25f4ee" transform="translate(-0.75 0.45)" />
<path d={iconPath} fill="#fe2c55" transform="translate(0.75 -0.45)" />
<path d={iconPath} fill="currentColor" />
</svg>
);
}
function normalizePayloadTitle(payload: PublishShareModalPayload | null) {
return payload?.title.trim() || '我的作品';
}
function resolvePayloadCoverImageSrc(payload: PublishShareModalPayload | null) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
className="h-6 w-6"
data-share-channel-logo={channel.id}
data-testid={`share-channel-logo-${channel.id}`}
>
<path d={iconPath} fill="currentColor" />
</svg>
payload?.coverImageSrc?.trim() ||
payload?.fallbackCoverImageSrc?.trim() ||
''
);
}
function resolvePayloadWorkTypeLabel(payload: PublishShareModalPayload | null) {
return payload?.workTypeLabel?.trim() || '互动作品';
}
/**
* 发布完成后的分享弹窗。
* 目前各渠道先统一复制分享文本,后续如接入微信/QQ/抖音 SDK可以只替换这里的渠道点击逻辑
* 发布完成后的通用分享弹窗。
* 分享事实仍来自公开作品号与 stage弹窗只负责把它表现成可复制、可下载的分享卡
*/
export function PublishShareModal({
open,
@@ -94,14 +51,24 @@ export function PublishShareModal({
onClose,
}: PublishShareModalProps) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [copyState, setCopyState] = useState<ActionState>('idle');
const [downloadState, setDownloadState] = useState<ActionState>('idle');
const [gridState, setGridState] = useState<ActionState>('idle');
const resetTimerRef = useRef<number | null>(null);
const shareText = useMemo(
() => (payload ? buildPublishShareText(payload) : ''),
const shareCopyUrl = useMemo(
() =>
payload
? buildPublishShareCopyUrl(payload, {
miniProgramRuntime: isWechatMiniProgramWebViewRuntime(),
})
: '',
[payload],
);
const title = normalizePayloadTitle(payload);
const coverImageSrc = resolvePayloadCoverImageSrc(payload);
const workTypeLabel = resolvePayloadWorkTypeLabel(payload);
const showMiniProgramGridButton =
canUseWechatMiniProgramShareGrid() && Boolean(coverImageSrc);
useEffect(
() => () => {
@@ -114,25 +81,92 @@ export function PublishShareModal({
useEffect(() => {
setCopyState('idle');
setDownloadState('idle');
setGridState('idle');
}, [payload?.publicWorkCode]);
const copyShareText = () => {
if (!shareText) {
const scheduleStateReset = () => {
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
resetTimerRef.current = window.setTimeout(() => {
resetTimerRef.current = null;
setCopyState('idle');
setDownloadState('idle');
setGridState('idle');
}, 1400);
};
const copyShareLink = () => {
if (!shareCopyUrl) {
return;
}
void copyTextToClipboard(shareText).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
if (resetTimerRef.current !== null) {
window.clearTimeout(resetTimerRef.current);
}
resetTimerRef.current = window.setTimeout(() => {
resetTimerRef.current = null;
setCopyState('idle');
}, 1400);
void copyTextToClipboard(shareCopyUrl).then((copied) => {
setCopyState(copied ? 'success' : 'failed');
scheduleStateReset();
});
};
const resolveMiniProgramGridCover = async () => {
if (!coverImageSrc) {
return '';
}
return await resolveAssetReadUrl(coverImageSrc, {
expireSeconds: 600,
}).catch(() => coverImageSrc);
};
const downloadShareCard = () => {
if (!payload) {
return;
}
setDownloadState('idle');
void downloadPublishShareCardImage(
{
...payload,
title,
workTypeLabel,
coverImageSrc,
},
coverImageSrc,
)
.then((downloaded) => {
setDownloadState(downloaded ? 'success' : 'failed');
scheduleStateReset();
})
.catch(() => {
setDownloadState('failed');
scheduleStateReset();
});
};
const openMiniProgramGridDownload = () => {
if (!payload || !coverImageSrc) {
return;
}
setGridState('idle');
void resolveMiniProgramGridCover()
.then((resolvedCoverImageSrc) =>
openWechatMiniProgramShareGridPage({
imageUrl: resolvedCoverImageSrc,
title,
publicWorkCode: payload.publicWorkCode,
}),
)
.then((opened) => {
setGridState(opened ? 'success' : 'failed');
scheduleStateReset();
})
.catch(() => {
setGridState('failed');
scheduleStateReset();
});
};
return (
<UnifiedModal
open={open && Boolean(payload)}
@@ -142,53 +176,98 @@ export function PublishShareModal({
overlayClassName={`platform-theme platform-theme--${platformTheme} !items-center`}
panelClassName="platform-remap-surface rounded-[1.75rem]"
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5"
footerClassName="border-t-0 px-4 pb-5 pt-0 sm:px-5"
footer={
<div className="grid w-full grid-cols-3 gap-3">
{SHARE_CHANNELS.map((channel) => {
return (
<button
key={channel.id}
type="button"
onClick={copyShareText}
className="flex min-w-0 flex-col items-center gap-2 rounded-[1rem] px-2 py-2.5 text-xs font-bold text-[var(--platform-text-base)] transition hover:bg-white/62"
aria-label={`分享到${channel.label}`}
title={channel.label}
>
<span
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.iconClassName}`}
>
<ShareChannelLogo channel={channel} />
</span>
<span>{channel.label}</span>
</button>
);
})}
<div
className={`grid w-full gap-3 ${
showMiniProgramGridButton ? 'grid-cols-1 sm:grid-cols-3' : 'grid-cols-2'
}`}
>
<button
type="button"
onClick={copyShareLink}
disabled={!shareCopyUrl}
className="platform-button platform-button--primary min-h-11 justify-center gap-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
>
{copyState === 'success' ? (
<Check className="h-4 w-4" />
) : (
<Link2 className="h-4 w-4" />
)}
{copyState === 'success'
? '已复制'
: copyState === 'failed'
? '复制失败'
: '复制链接'}
</button>
<button
type="button"
onClick={downloadShareCard}
disabled={!payload}
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
>
{downloadState === 'success' ? (
<Check className="h-4 w-4" />
) : (
<Download className="h-4 w-4" />
)}
{downloadState === 'success'
? '已下载'
: downloadState === 'failed'
? '下载失败'
: '下载卡片'}
</button>
{showMiniProgramGridButton ? (
<button
type="button"
onClick={openMiniProgramGridDownload}
className="platform-button platform-button--secondary min-h-11 justify-center gap-2 text-sm"
>
{gridState === 'success' ? (
<Check className="h-4 w-4" />
) : (
<Grid3X3 className="h-4 w-4" />
)}
{gridState === 'success'
? '已打开'
: gridState === 'failed'
? '打开失败'
: '九宫切图'}
</button>
) : null}
</div>
}
>
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
{shareText}
</div>
</div>
<button
type="button"
onClick={copyShareText}
disabled={!shareText}
className="platform-button platform-button--primary w-full justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-55"
<section
className="overflow-hidden rounded-lg border border-[var(--platform-subpanel-border)] bg-white/78 shadow-[0_18px_42px_rgba(127,85,57,0.12)]"
aria-label="分享卡片"
>
{copyState === 'copied' ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
{copyState === 'copied'
? '已复制'
: copyState === 'failed'
? '复制失败'
: '分享'}
</button>
<div className="relative aspect-square overflow-hidden bg-[linear-gradient(135deg,#f4c38b,#e7b5b7_48%,#9bbfd1)]">
{coverImageSrc ? (
<ResolvedAssetImage
src={coverImageSrc}
alt={title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-6xl font-black text-white/84">
{Array.from(title)[0] ?? ''}
</div>
)}
</div>
<div className="space-y-3 px-4 py-4">
<div className="inline-flex max-w-full items-center rounded-full bg-[var(--platform-neutral-bg)] px-3 py-1 text-xs font-black text-[var(--platform-accent-text)]">
<span className="truncate">{workTypeLabel}</span>
</div>
<h3 className="line-clamp-2 text-lg font-black leading-snug text-[var(--platform-text-strong)]">
{title}
</h3>
<div className="flex min-w-0 items-center gap-2 text-xs font-bold text-[var(--platform-text-muted)]">
<Copy className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{payload?.publicWorkCode}</span>
</div>
</div>
</section>
</UnifiedModal>
);
}

View File

@@ -0,0 +1,146 @@
/* @vitest-environment jsdom */
import { afterEach, describe, expect, test, vi } from 'vitest';
import { readAssetBytes } from '../../services/assetReadUrlService';
import {
downloadPublishShareCardImage,
resolvePublishShareCardCanvasImageSource,
} from './publishShareCardImage';
vi.mock('../../services/assetReadUrlService', async () => {
const actual =
await vi.importActual<typeof import('../../services/assetReadUrlService')>(
'../../services/assetReadUrlService',
);
return {
...actual,
readAssetBytes: vi.fn(),
};
});
const createObjectUrl = vi.fn(() => 'blob:share-card-cover');
const revokeObjectUrl = vi.fn();
const fillTextCalls: string[] = [];
function installObjectUrlMocks() {
Object.defineProperty(URL, 'createObjectURL', {
configurable: true,
value: createObjectUrl,
});
Object.defineProperty(URL, 'revokeObjectURL', {
configurable: true,
value: revokeObjectUrl,
});
}
function installCanvasMocks() {
class MockImage {
crossOrigin = '';
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
naturalWidth = 900;
naturalHeight = 900;
width = 900;
height = 900;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal('Image', MockImage);
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
vi.spyOn(document.body, 'removeChild').mockImplementation((node) => node);
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
beginPath: vi.fn(),
clearRect: vi.fn(),
clip: vi.fn(),
closePath: vi.fn(),
createLinearGradient: vi.fn(() => ({
addColorStop: vi.fn(),
})),
drawImage: vi.fn(),
fill: vi.fn(),
fillRect: vi.fn(),
fillText: vi.fn((text: string) => {
fillTextCalls.push(text);
}),
lineTo: vi.fn(),
measureText: vi.fn((text: string) => ({
width: Array.from(text).length * 32,
})),
moveTo: vi.fn(),
quadraticCurveTo: vi.fn(),
restore: vi.fn(),
save: vi.fn(),
stroke: vi.fn(),
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation(
(callback: BlobCallback) => {
callback(new Blob(['share-card'], { type: 'image/png' }));
},
);
}
afterEach(() => {
vi.clearAllMocks();
vi.unstubAllGlobals();
fillTextCalls.length = 0;
});
describe('publishShareCardImage', () => {
test('loads generated covers through same-origin bytes before drawing to canvas', async () => {
installObjectUrlMocks();
vi.mocked(readAssetBytes).mockResolvedValue(
new Response(new Blob(['cover-bytes'], { type: 'image/png' })),
);
const imageSource = await resolvePublishShareCardCanvasImageSource(
'/generated-puzzle-assets/session/profile/covers/main.png',
);
expect(readAssetBytes).toHaveBeenCalledWith(
'/generated-puzzle-assets/session/profile/covers/main.png',
{ expireSeconds: 600 },
);
expect(imageSource.src).toBe('blob:share-card-cover');
imageSource.release();
expect(revokeObjectUrl).toHaveBeenCalledWith('blob:share-card-cover');
});
test('keeps ordinary public covers as their original source', async () => {
const imageSource = await resolvePublishShareCardCanvasImageSource(
'/creation-type-references/puzzle.webp',
);
expect(readAssetBytes).not.toHaveBeenCalled();
expect(imageSource.src).toBe('/creation-type-references/puzzle.webp');
});
test('exports the same card content as the modal instead of adding extra branding', async () => {
installObjectUrlMocks();
installCanvasMocks();
await expect(
downloadPublishShareCardImage(
{
title: '三叶草',
publicWorkCode: 'PZ-BE68CC73',
stage: 'puzzle-gallery-detail',
workTypeLabel: '拼图',
coverImageSrc: '/cover.png',
},
'/cover.png',
),
).resolves.toBe(true);
expect(fillTextCalls).toContain('拼图');
expect(fillTextCalls).toContain('三叶草');
expect(fillTextCalls).toContain('PZ-BE68CC73');
expect(fillTextCalls).not.toContain('陶泥儿');
});
});

View File

@@ -0,0 +1,403 @@
import {
readAssetBytes,
shouldResolveAssetReadUrl,
} from '../../services/assetReadUrlService';
import {
buildPublishShareCardFileName,
type PublishShareModalPayload,
} from './publishShareModalModel';
const CARD_WIDTH = 1080;
const CARD_HEIGHT = 1440;
const CARD_RADIUS = 24;
const COVER_X = 0;
const COVER_Y = 0;
const COVER_SIZE = CARD_WIDTH;
const CONTENT_PADDING_X = 48;
const CONTENT_TOP = COVER_Y + COVER_SIZE + 48;
const TYPE_PILL_HEIGHT = 64;
type PublishShareCardTheme = {
background: string;
border: string;
neutralBackground: string;
accentText: string;
titleText: string;
mutedText: string;
};
function resolveCssColor(variableName: string, fallback: string) {
if (typeof document === 'undefined') {
return fallback;
}
const value = getComputedStyle(document.documentElement)
.getPropertyValue(variableName)
.trim();
return value || fallback;
}
function resolvePublishShareCardTheme(): PublishShareCardTheme {
return {
background: '#fffaf4',
border: resolveCssColor('--platform-subpanel-border', '#ead9c7'),
neutralBackground: resolveCssColor('--platform-neutral-bg', '#f2e3d5'),
accentText: resolveCssColor('--platform-accent-text', '#7f5539'),
titleText: resolveCssColor('--platform-text-strong', '#332820'),
mutedText: resolveCssColor('--platform-text-muted', '#a88e7c'),
};
}
function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
context.beginPath();
context.moveTo(x + radius, y);
context.lineTo(x + width - radius, y);
context.quadraticCurveTo(x + width, y, x + width, y + radius);
context.lineTo(x + width, y + height - radius);
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
context.lineTo(x + radius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - radius);
context.lineTo(x, y + radius);
context.quadraticCurveTo(x, y, x + radius, y);
context.closePath();
}
function drawWrappedText(
context: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
lineHeight: number,
maxLines: number,
) {
const chars = Array.from(text.trim() || '我的作品');
const lines: string[] = [];
let currentLine = '';
for (const char of chars) {
const nextLine = `${currentLine}${char}`;
if (currentLine && context.measureText(nextLine).width > maxWidth) {
lines.push(currentLine);
currentLine = char;
if (lines.length >= maxLines) {
break;
}
} else {
currentLine = nextLine;
}
}
if (lines.length < maxLines && currentLine) {
lines.push(currentLine);
}
lines.slice(0, maxLines).forEach((line, index) => {
const isLast = index === maxLines - 1 && lines.length >= maxLines;
let displayLine = line;
while (
isLast &&
displayLine.length > 1 &&
context.measureText(`${displayLine}...`).width > maxWidth
) {
displayLine = displayLine.slice(0, -1);
}
context.fillText(isLast ? `${displayLine}...` : displayLine, x, y + index * lineHeight);
});
}
function shouldLoadImageWithAnonymousCors(src: string) {
if (
src.startsWith('data:') ||
src.startsWith('blob:') ||
typeof window === 'undefined'
) {
return false;
}
try {
const parsedUrl = new URL(src, window.location.origin);
return (
/^https?:$/u.test(parsedUrl.protocol) &&
parsedUrl.origin !== window.location.origin
);
} catch {
return false;
}
}
export async function resolvePublishShareCardCanvasImageSource(src: string) {
const normalizedSrc = src.trim();
if (!normalizedSrc) {
return {
src: '',
release() {},
};
}
if (!shouldResolveAssetReadUrl(normalizedSrc)) {
return {
src: normalizedSrc,
release() {},
};
}
const response = await readAssetBytes(normalizedSrc, {
expireSeconds: 600,
});
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
return {
src: objectUrl,
release() {
URL.revokeObjectURL(objectUrl);
},
};
}
function loadCanvasImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
if (shouldLoadImageWithAnonymousCors(src)) {
image.crossOrigin = 'anonymous';
}
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('分享卡封面加载失败'));
image.src = src;
});
}
function drawImageCover(
context: CanvasRenderingContext2D,
image: HTMLImageElement,
x: number,
y: number,
width: number,
height: number,
) {
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
const sourceRatio = sourceWidth / Math.max(1, sourceHeight);
const targetRatio = width / Math.max(1, height);
const cropWidth = sourceRatio > targetRatio ? sourceHeight * targetRatio : sourceWidth;
const cropHeight = sourceRatio > targetRatio ? sourceHeight : sourceWidth / targetRatio;
const cropX = (sourceWidth - cropWidth) / 2;
const cropY = (sourceHeight - cropHeight) / 2;
context.drawImage(
image,
cropX,
cropY,
cropWidth,
cropHeight,
x,
y,
width,
height,
);
}
function drawCoverFallback(
context: CanvasRenderingContext2D,
payload: PublishShareModalPayload,
) {
const gradient = context.createLinearGradient(
COVER_X,
COVER_Y,
COVER_X + COVER_SIZE,
COVER_Y + COVER_SIZE,
);
gradient.addColorStop(0, '#f6c58d');
gradient.addColorStop(0.48, '#e7b7b7');
gradient.addColorStop(1, '#9bbfd1');
context.fillStyle = gradient;
context.fillRect(COVER_X, COVER_Y, COVER_SIZE, COVER_SIZE);
context.fillStyle = 'rgba(255, 255, 255, 0.82)';
context.font = '900 156px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
const initial = Array.from(payload.title.trim() || '陶')[0] ?? '陶';
context.fillText(initial, CARD_WIDTH / 2, COVER_Y + COVER_SIZE / 2);
}
function drawCopyIcon(
context: CanvasRenderingContext2D,
x: number,
y: number,
color: string,
) {
context.strokeStyle = color;
context.lineWidth = 4;
drawRoundedRect(context, x + 10, y, 24, 30, 4);
context.stroke();
drawRoundedRect(context, x, y + 10, 24, 30, 4);
context.stroke();
}
async function drawShareCard(
context: CanvasRenderingContext2D,
payload: PublishShareModalPayload,
coverImageSrc: string,
) {
const theme = resolvePublishShareCardTheme();
context.clearRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
context.save();
drawRoundedRect(context, 0, 0, CARD_WIDTH, CARD_HEIGHT, CARD_RADIUS);
context.clip();
context.fillStyle = theme.background;
context.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
if (coverImageSrc) {
const canvasImageSource =
await resolvePublishShareCardCanvasImageSource(coverImageSrc);
try {
const image = await loadCanvasImage(canvasImageSource.src);
drawImageCover(context, image, COVER_X, COVER_Y, COVER_SIZE, COVER_SIZE);
} finally {
canvasImageSource.release();
}
} else {
drawCoverFallback(context, payload);
}
const typeLabel = payload.workTypeLabel?.trim() || '互动作品';
context.font = '800 36px sans-serif';
const pillWidth = Math.min(
CARD_WIDTH - CONTENT_PADDING_X * 2,
Math.max(180, context.measureText(typeLabel).width + 72),
);
const pillY = CONTENT_TOP;
context.fillStyle = theme.neutralBackground;
drawRoundedRect(
context,
CONTENT_PADDING_X,
pillY,
pillWidth,
TYPE_PILL_HEIGHT,
TYPE_PILL_HEIGHT / 2,
);
context.fill();
context.fillStyle = theme.accentText;
context.textAlign = 'left';
context.textBaseline = 'middle';
context.fillText(typeLabel, CONTENT_PADDING_X + 36, pillY + TYPE_PILL_HEIGHT / 2);
context.fillStyle = theme.titleText;
context.font = '900 72px sans-serif';
context.textBaseline = 'top';
drawWrappedText(
context,
payload.title,
CONTENT_PADDING_X,
pillY + 92,
CARD_WIDTH - CONTENT_PADDING_X * 2,
84,
2,
);
const code = payload.publicWorkCode.trim();
if (code) {
const codeY = CARD_HEIGHT - 74;
context.fillStyle = theme.mutedText;
context.font = '700 34px sans-serif';
context.textBaseline = 'middle';
drawCopyIcon(context, CONTENT_PADDING_X, codeY - 20, theme.mutedText);
context.fillText(code, CONTENT_PADDING_X + 54, codeY);
}
context.restore();
context.strokeStyle = theme.border;
context.lineWidth = 3;
drawRoundedRect(context, 1.5, 1.5, CARD_WIDTH - 3, CARD_HEIGHT - 3, CARD_RADIUS);
context.stroke();
}
function canvasToBlob(canvas: HTMLCanvasElement) {
return new Promise<Blob>((resolve, reject) => {
if (typeof canvas.toBlob !== 'function') {
try {
const dataUrl = canvas.toDataURL('image/png');
const binary = atob(dataUrl.split(',')[1] ?? '');
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
resolve(new Blob([bytes], { type: 'image/png' }));
} catch (error) {
reject(error);
}
return;
}
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('分享卡导出失败'));
}
}, 'image/png');
});
}
function triggerDownload(blob: Blob, fileName: string) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}
export async function downloadPublishShareCardImage(
payload: PublishShareModalPayload,
coverImageSrc: string,
) {
if (typeof document === 'undefined') {
return false;
}
const canvas = document.createElement('canvas');
canvas.width = CARD_WIDTH;
canvas.height = CARD_HEIGHT;
const context = canvas.getContext('2d');
if (!context) {
return false;
}
try {
await drawShareCard(context, payload, coverImageSrc);
triggerDownload(
await canvasToBlob(canvas),
buildPublishShareCardFileName(payload),
);
return true;
} catch {
const fallbackCanvas = document.createElement('canvas');
fallbackCanvas.width = CARD_WIDTH;
fallbackCanvas.height = CARD_HEIGHT;
const fallbackContext = fallbackCanvas.getContext('2d');
if (!fallbackContext) {
return false;
}
await drawShareCard(fallbackContext, payload, '');
triggerDownload(
await canvasToBlob(fallbackCanvas),
buildPublishShareCardFileName(payload),
);
return true;
}
}

View File

@@ -1,13 +1,19 @@
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import type { SelectionStage } from '../platform-entry/platformEntryTypes';
const MINI_PROGRAM_WEB_VIEW_PAGE_PATH = '/pages/web-view/index';
const MINI_PROGRAM_PUBLIC_WORK_DETAIL_PATH = '/works/detail';
export type PublishShareModalPayload = {
title: string;
publicWorkCode: string;
stage: SelectionStage;
workTypeLabel?: string | null;
coverImageSrc?: string | null;
fallbackCoverImageSrc?: string | null;
};
function buildShareUrl(payload: PublishShareModalPayload) {
export function buildPublishShareUrl(payload: PublishShareModalPayload) {
const sharePath = buildPublicWorkStagePath(
payload.stage,
payload.publicWorkCode,
@@ -18,13 +24,56 @@ function buildShareUrl(payload: PublishShareModalPayload) {
: new URL(sharePath, window.location.origin).href;
}
export function buildMiniProgramPublishSharePath(
payload: PublishShareModalPayload,
basePath = MINI_PROGRAM_WEB_VIEW_PAGE_PATH,
) {
const [path = MINI_PROGRAM_WEB_VIEW_PAGE_PATH, rawSearch = ''] =
basePath.split('?');
const params = new URLSearchParams(rawSearch);
const publicWorkCode = payload.publicWorkCode.trim();
if (!params.has('targetPath')) {
params.set('targetPath', MINI_PROGRAM_PUBLIC_WORK_DETAIL_PATH);
}
if (publicWorkCode && !params.has('work')) {
params.set('work', publicWorkCode);
}
const queryString = params.toString();
return queryString ? `${path}?${queryString}` : path;
}
export function buildPublishShareCopyUrl(
payload: PublishShareModalPayload,
options: { miniProgramRuntime?: boolean } = {},
) {
return options.miniProgramRuntime
? buildMiniProgramPublishSharePath(payload)
: buildPublishShareUrl(payload);
}
export function buildPublishShareText(payload: PublishShareModalPayload) {
const publicWorkCode = payload.publicWorkCode.trim();
const title = payload.title.trim() || '我的作品';
return `邀请你来玩《${title}\n作品号${publicWorkCode}\n${buildShareUrl({
return `邀请你来玩《${title}\n作品号${publicWorkCode}\n${buildPublishShareUrl({
...payload,
publicWorkCode,
title,
})}`;
}
export function buildPublishShareCardFileName(
payload: Pick<PublishShareModalPayload, 'title' | 'publicWorkCode'>,
) {
const title = payload.title.trim() || '我的作品';
const publicWorkCode = payload.publicWorkCode.trim() || 'share';
const safeTitle = Array.from(title)
.filter((char) => !/[\\/:*?"<>|]/u.test(char))
.join('')
.replace(/\s+/gu, '-')
.slice(0, 28)
.trim();
return `${safeTitle || '我的作品'}-${publicWorkCode}.png`;
}

View File

@@ -1093,6 +1093,9 @@ test('creation hub published share icon opens unified share payload without open
title: '沉钟拼图',
publicWorkCode: 'PZ-PROFILE1',
stage: 'puzzle-gallery-detail',
workTypeLabel: '拼图',
coverImageSrc: null,
fallbackCoverImageSrc: '/creation-type-references/puzzle.webp',
});
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
});

View File

@@ -8,10 +8,10 @@ import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/co
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { resolveSelectionStageFromPath } from '../../routing/appPageRoutes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import type { CustomWorldProfile } from '../../types';
@@ -23,10 +23,12 @@ import type {
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
getCreationWorkShelfItemTime,
CREATION_WORK_KIND_FALLBACK_COVER,
type CreationWorkShelfItem,
type CreationWorkShelfMetricId,
type CreationWorkShelfRuntimeState,
describeCreationWorkShelfKind,
getCreationWorkShelfItemTime,
} from './creationWorkShelf';
import {
CustomWorldCreationStartCard,
@@ -202,6 +204,9 @@ function buildCreationWorkShelfSharePayload(
title: item.title,
publicWorkCode,
stage,
workTypeLabel: describeCreationWorkShelfKind(item.kind),
coverImageSrc: item.coverImageSrc,
fallbackCoverImageSrc: CREATION_WORK_KIND_FALLBACK_COVER[item.kind],
};
}
@@ -351,6 +356,7 @@ export function CustomWorldCreationHub({
puzzleClearItems,
puzzleItems,
rpgLibraryEntries,
squareHoleItems,
onOpenJumpHopDetail,
jumpHopItems,
woodenFishItems,

View File

@@ -7,8 +7,8 @@ import {
Trash2,
} from 'lucide-react';
import {
default as React,
type CSSProperties,
default as React,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type TouchEvent as ReactTouchEvent,
@@ -24,9 +24,9 @@ import {
formatPlatformWorkDisplayTag,
} from '../rpg-entry/rpgEntryWorldPresentation';
import {
CREATION_WORK_KIND_FALLBACK_COVER,
type CreationWorkShelfBadgeTone,
type CreationWorkShelfItem,
type CreationWorkShelfKind,
type CreationWorkShelfMetric,
type CreationWorkShelfMetricId,
formatCreationMetricCount,
@@ -55,21 +55,6 @@ const SWIPE_ACTION_WIDTH_PX = 76;
const SWIPE_REVEAL_THRESHOLD_PX = 42;
const SWIPE_DIRECTION_LOCK_PX = 8;
const EMPTY_PUBLISHED_METRICS: CreationWorkShelfMetric[] = [];
const CREATION_WORK_KIND_FALLBACK_COVER: Record<CreationWorkShelfKind, string> =
{
rpg: '/creation-type-references/rpg.webp',
'big-fish': '/creation-type-references/big-fish.webp',
match3d: '/creation-type-references/match3d.webp',
'square-hole': '/creation-type-references/square-hole.webp',
'jump-hop': '/creation-type-references/jump-hop.webp',
'wooden-fish': '/wooden-fish/default-hit-object.png',
'puzzle-clear': '/creation-type-references/puzzle.webp',
puzzle: '/creation-type-references/puzzle.webp',
'baby-object-match': '/creation-type-references/creative-agent.webp',
'bark-battle': '/creation-type-references/bark-battle.webp',
'visual-novel': '/creation-type-references/visual-novel.webp',
};
function easeOutCubic(progress: number) {
return 1 - (1 - progress) ** 3;
}

View File

@@ -2,20 +2,20 @@ import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contrac
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleClearWorkSummaryResponse } from '../../../packages/shared/src/contracts/puzzleClear';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel';
import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop';
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import {
buildBabyObjectMatchPublicWorkCode,
buildCustomWorldPublicWorkCode,
buildBarkBattlePublicWorkCode,
buildBigFishPublicWorkCode,
buildCustomWorldPublicWorkCode,
buildJumpHopPublicWorkCode,
buildMatch3DPublicWorkCode,
buildPuzzleClearPublicWorkCode,
@@ -44,6 +44,50 @@ export type CreationWorkShelfKind =
| 'baby-object-match'
| 'bark-battle'
| 'visual-novel';
export const CREATION_WORK_KIND_FALLBACK_COVER: Record<
CreationWorkShelfKind,
string
> = {
rpg: '/creation-type-references/rpg.webp',
'big-fish': '/creation-type-references/big-fish.webp',
match3d: '/creation-type-references/match3d.webp',
'square-hole': '/creation-type-references/square-hole.webp',
'jump-hop': '/creation-type-references/jump-hop.webp',
'wooden-fish': '/wooden-fish/default-hit-object.png',
'puzzle-clear': '/creation-type-references/puzzle.webp',
puzzle: '/creation-type-references/puzzle.webp',
'baby-object-match': '/creation-type-references/creative-agent.webp',
'bark-battle': '/creation-type-references/bark-battle.webp',
'visual-novel': '/creation-type-references/visual-novel.webp',
};
export function describeCreationWorkShelfKind(kind: CreationWorkShelfKind) {
switch (kind) {
case 'rpg':
return 'RPG世界';
case 'big-fish':
return '大鱼吃小鱼';
case 'match3d':
return '抓大鹅';
case 'square-hole':
return '方洞挑战';
case 'jump-hop':
return '跳一跳';
case 'wooden-fish':
return '敲木鱼';
case 'puzzle-clear':
return '拼消消';
case 'puzzle':
return '拼图';
case 'baby-object-match':
return '宝贝识物';
case 'bark-battle':
return '汪汪声浪';
case 'visual-novel':
return '视觉小说';
}
}
export type CreationWorkShelfStatus = 'draft' | 'published';
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from 'vitest';
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import {
resolveMiniGameGenerationProgressTickState,
} from './PlatformEntryFlowShellImpl';
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
} from './platformGenerationProgressTickState';
describe('resolveMiniGameGenerationProgressTickState', () => {
test('returns jump hop and wooden fish generation states for progress ticking', () => {

View File

@@ -382,6 +382,7 @@ import { UnifiedModal } from '../common/UnifiedModal';
import { resolveCreativeAgentTargetSelectionStage } from '../creative-agent/creativeAgentViewModel';
import {
buildCreationWorkShelfItems,
CREATION_WORK_KIND_FALLBACK_COVER,
type CreationWorkShelfItem,
isPersistedBarkBattleDraftGenerating,
isPersistedPuzzleDraftGenerating,
@@ -389,6 +390,7 @@ import {
} from '../custom-world-home/creationWorkShelf';
import {
buildPlatformPublicGalleryCardKey,
describePublicGalleryCardKind,
isBarkBattleGalleryEntry,
isBigFishGalleryEntry,
isCustomWorldGalleryEntry,
@@ -412,6 +414,8 @@ import {
mapWoodenFishWorkToPlatformGalleryCard,
type PlatformPublicGalleryCard,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldFallbackCoverImage,
} from '../rpg-entry/rpgEntryWorldPresentation';
import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreationAgentOperationPolling';
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
@@ -471,6 +475,7 @@ import {
type PlatformErrorDialogPayload,
} from './PlatformErrorDialog';
import { PlatformFeedbackView } from './PlatformFeedbackView';
import { resolveMiniGameGenerationProgressTickState } from './platformGenerationProgressTickState';
import { buildPlatformRecommendedEntries } from './platformRecommendation';
import {
PlatformTaskCompletionDialog,
@@ -523,31 +528,6 @@ type PuzzleBackgroundCompileTask = {
error: string | null;
};
type MiniGameGenerationProgressTickStateMap = Partial<
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
>;
export function resolveMiniGameGenerationProgressTickState(
selectionStage: SelectionStage,
states: MiniGameGenerationProgressTickStateMap,
) {
const stageKindMap: Partial<
Record<SelectionStage, MiniGameDraftGenerationKind>
> = {
'puzzle-generating': 'puzzle',
'big-fish-generating': 'big-fish',
'square-hole-generating': 'square-hole',
'match3d-generating': 'match3d',
'baby-object-match-generating': 'baby-object-match',
'jump-hop-generating': 'jump-hop',
'puzzle-clear-generating': 'puzzle-clear',
'wooden-fish-generating': 'wooden-fish',
};
const kind = stageKindMap[selectionStage];
return kind ? (states[kind] ?? null) : null;
}
type PuzzleDetailReturnTarget = {
tab: PlatformHomeTab;
};
@@ -5274,6 +5254,9 @@ export function PlatformEntryFlowShellImpl({
title: entry.worldName,
publicWorkCode,
stage: resolveRecommendEntryShareStage(entry),
workTypeLabel: describePublicGalleryCardKind(entry),
coverImageSrc: resolvePlatformWorldCoverImage(entry),
fallbackCoverImageSrc: resolvePlatformWorldFallbackCoverImage(entry),
});
},
[openPublishShareModal],
@@ -5302,6 +5285,9 @@ export function PlatformEntryFlowShellImpl({
title: galleryEntry?.worldName || profileName,
publicWorkCode,
stage: 'work-detail',
workTypeLabel: 'RPG世界',
coverImageSrc: galleryEntry?.coverImageSrc ?? null,
fallbackCoverImageSrc: CREATION_WORK_KIND_FALLBACK_COVER.rpg,
});
},
[openPublishShareModal, platformBootstrap],
@@ -6022,6 +6008,12 @@ export function PlatformEntryFlowShellImpl({
response.session.sessionId,
),
stage: 'big-fish-runtime',
workTypeLabel: '大鱼吃小鱼',
coverImageSrc:
response.session.assetSlots.find(
(slot) => slot.status === 'ready' && slot.assetUrl?.trim(),
)?.assetUrl ?? null,
fallbackCoverImageSrc: CREATION_WORK_KIND_FALLBACK_COVER['big-fish'],
});
}
if (payload.action !== 'big_fish_compile_draft') {
@@ -6745,6 +6737,9 @@ export function PlatformEntryFlowShellImpl({
galleryDetail.item.profileId,
),
stage: 'puzzle-gallery-detail',
workTypeLabel: '拼图',
coverImageSrc: galleryDetail.item.coverImageSrc,
fallbackCoverImageSrc: CREATION_WORK_KIND_FALLBACK_COVER.puzzle,
});
}
},
@@ -9300,6 +9295,10 @@ export function PlatformEntryFlowShellImpl({
title: publishedWithAssets.title,
publicWorkCode,
stage: 'work-detail',
workTypeLabel: '汪汪声浪',
coverImageSrc: publishedWithAssets.uiBackgroundImageSrc ?? null,
fallbackCoverImageSrc:
CREATION_WORK_KIND_FALLBACK_COVER['bark-battle'],
});
} catch (error) {
setBarkBattleError(
@@ -9428,6 +9427,13 @@ export function PlatformEntryFlowShellImpl({
response.publicWorkCode ||
buildBabyObjectMatchPublicWorkCode(response.draft.profileId),
stage: 'work-detail',
workTypeLabel: '宝贝识物',
coverImageSrc:
response.draft.visualPackage?.assets[0]?.imageSrc ??
response.draft.itemAssets[0]?.imageSrc ??
null,
fallbackCoverImageSrc:
CREATION_WORK_KIND_FALLBACK_COVER['baby-object-match'],
});
} catch (error) {
setBabyObjectMatchError(
@@ -9664,6 +9670,10 @@ export function PlatformEntryFlowShellImpl({
publishedResponse.work.summary.profileId,
),
stage: 'work-detail',
workTypeLabel: '视觉小说',
coverImageSrc: publishedResponse.work.summary.coverImageSrc,
fallbackCoverImageSrc:
CREATION_WORK_KIND_FALLBACK_COVER['visual-novel'],
});
} catch (error) {
setVisualNovelError(
@@ -10252,6 +10262,9 @@ export function PlatformEntryFlowShellImpl({
response.item.summary.profileId,
),
stage: 'work-detail',
workTypeLabel: '跳一跳',
coverImageSrc: response.item.summary.coverImageSrc,
fallbackCoverImageSrc: CREATION_WORK_KIND_FALLBACK_COVER['jump-hop'],
});
} catch (error) {
setJumpHopError(
@@ -10679,6 +10692,10 @@ export function PlatformEntryFlowShellImpl({
title: response.item.summary.workTitle || '拼消消',
publicWorkCode,
stage: 'work-detail',
workTypeLabel: '拼消消',
coverImageSrc: response.item.summary.coverImageSrc,
fallbackCoverImageSrc:
CREATION_WORK_KIND_FALLBACK_COVER['puzzle-clear'],
});
} catch (error) {
setPuzzleClearError(
@@ -11213,6 +11230,10 @@ export function PlatformEntryFlowShellImpl({
response.item.summary.profileId,
),
stage: 'work-detail',
workTypeLabel: '敲木鱼',
coverImageSrc: response.item.summary.coverImageSrc,
fallbackCoverImageSrc:
CREATION_WORK_KIND_FALLBACK_COVER['wooden-fish'],
});
} catch (error) {
setWoodenFishError(
@@ -18510,6 +18531,13 @@ export function PlatformEntryFlowShellImpl({
normalizedProfile.profileId,
),
stage: 'work-detail',
workTypeLabel: '抓大鹅',
coverImageSrc:
normalizedProfile.coverImageSrc ??
normalizedProfile.backgroundImageSrc ??
null,
fallbackCoverImageSrc:
CREATION_WORK_KIND_FALLBACK_COVER.match3d,
});
}}
onStartTestRun={(profile, options) => {
@@ -18972,6 +19000,10 @@ export function PlatformEntryFlowShellImpl({
profile.profileId,
),
stage: 'work-detail',
workTypeLabel: '方洞挑战',
coverImageSrc: profile.coverImageSrc ?? null,
fallbackCoverImageSrc:
CREATION_WORK_KIND_FALLBACK_COVER['square-hole'],
});
}}
onStartTestRun={(profile) => {

View File

@@ -0,0 +1,30 @@
import type {
MiniGameDraftGenerationKind,
MiniGameDraftGenerationState,
} from '../../services/miniGameDraftGenerationProgress';
import type { SelectionStage } from './platformEntryTypes';
type MiniGameGenerationProgressTickStateMap = Partial<
Record<MiniGameDraftGenerationKind, MiniGameDraftGenerationState | null>
>;
export function resolveMiniGameGenerationProgressTickState(
selectionStage: SelectionStage,
states: MiniGameGenerationProgressTickStateMap,
) {
const stageKindMap: Partial<
Record<SelectionStage, MiniGameDraftGenerationKind>
> = {
'puzzle-generating': 'puzzle',
'big-fish-generating': 'big-fish',
'square-hole-generating': 'square-hole',
'match3d-generating': 'match3d',
'baby-object-match-generating': 'baby-object-match',
'jump-hop-generating': 'jump-hop',
'puzzle-clear-generating': 'puzzle-clear',
'wooden-fish-generating': 'wooden-fish',
};
const kind = stageKindMap[selectionStage];
return kind ? (states[kind] ?? null) : null;
}

View File

@@ -144,6 +144,7 @@ import {
buildPlatformPublicGalleryCardKey,
buildPlatformWorldDisplayTags,
describePlatformThemeLabel,
describePublicGalleryCardKind,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTag,
formatPlatformWorldTime,
@@ -2374,39 +2375,6 @@ async function getPublicWorkAuthorSummary(
return null;
}
function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
if (isBigFishGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('大鱼吃小鱼');
}
if (isPuzzleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('拼图');
}
if (isPuzzleClearGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('拼消消');
}
if (isMatch3DGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('抓大鹅');
}
if (isSquareHoleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('方洞挑战');
}
if (isJumpHopGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('跳一跳');
}
if (isWoodenFishGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('敲木鱼');
}
if (isVisualNovelGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('视觉小说');
}
if (isBarkBattleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('汪汪声浪');
}
if (isEdutainmentGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag(entry.templateName);
}
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
}
function getPublicAuthorAvatarLabel(authorDisplayName: string) {
return Array.from(authorDisplayName.trim() || '玩')[0] ?? '玩';
}

View File

@@ -1,5 +1,5 @@
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contracts/barkBattle';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import { BABY_OBJECT_MATCH_EDUTAINMENT_TAG } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
@@ -7,23 +7,23 @@ import type {
JumpHopGalleryCardResponse,
JumpHopWorkProfileResponse,
} from '../../../packages/shared/src/contracts/jumpHop';
import type {
PuzzleClearGalleryCardResponse,
PuzzleClearWorkProfileResponse,
PuzzleClearWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import type {
Match3DGeneratedBackgroundAsset,
Match3DGeneratedItemAsset,
Match3DWorkSummary,
} from '../../../packages/shared/src/contracts/match3dWorks';
import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type {
PuzzleClearGalleryCardResponse,
PuzzleClearWorkProfileResponse,
PuzzleClearWorkSummaryResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { PublicWorkSourceType } from '../../../packages/shared/src/contracts/playTypes';
import type {
SquareHoleHoleOption,
SquareHoleShapeOption,
@@ -1077,6 +1077,42 @@ export function buildPlatformWorldDisplayTags(
return formatPlatformWorkDisplayTags(buildPlatformWorldTags(entry), limit);
}
export function describePublicGalleryCardKind(
entry: PlatformPublicGalleryCard,
) {
if (isBigFishGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('大鱼吃小鱼');
}
if (isPuzzleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('拼图');
}
if (isPuzzleClearGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('拼消消');
}
if (isMatch3DGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('抓大鹅');
}
if (isSquareHoleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('方洞挑战');
}
if (isJumpHopGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('跳一跳');
}
if (isWoodenFishGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('敲木鱼');
}
if (isVisualNovelGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('视觉小说');
}
if (isBarkBattleGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag('汪汪声浪');
}
if (isEdutainmentGalleryEntry(entry)) {
return formatPlatformWorkDisplayTag(entry.templateName);
}
return formatPlatformWorkDisplayTag(describePlatformThemeLabel(entry.themeMode));
}
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
if (isBigFishGalleryEntry(entry)) {
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];