add public work share links
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-27 22:49:13 +08:00
parent 271db02e4a
commit 1348b2e940
23 changed files with 1038 additions and 248 deletions

View File

@@ -11,6 +11,7 @@ import {
APP_RUNTIME_ROUTES,
normalizeAppPath,
pushAppHistoryPath,
readPublicWorkCodeFromLocationSearch,
resolvePathForSelectionStage,
resolveSelectionStageFromPath,
} from './routing/appPageRoutes';
@@ -45,6 +46,9 @@ export default function App() {
);
const [runtimeReturnStage, setRuntimeReturnStage] =
useState<SelectionStage>('platform');
const [initialPublicWorkCode] = useState(() =>
readPublicWorkCodeFromLocationSearch(window.location.search),
);
const setSelectionStage = useCallback((stage: SelectionStage) => {
setRawSelectionStage(stage);
@@ -132,6 +136,7 @@ export default function App() {
<PlatformEntryFlowShell
selectionStage={selectionStage}
setSelectionStage={setSelectionStage}
initialPublicWorkCode={initialPublicWorkCode}
hasSavedGame={false}
savedSnapshot={null}
handleContinueGame={handleContinueGame}

View File

@@ -1,4 +1,4 @@
import { ArrowLeft, CircleHelp, Loader2, RotateCcw } from 'lucide-react';
import { ArrowLeft, CircleHelp, Loader2, RotateCcw, Share2 } from 'lucide-react';
import { type PointerEvent, useEffect, useRef, useState } from 'react';
import type {
@@ -7,6 +7,8 @@ import type {
BigFishRuntimeSnapshotResponse,
SubmitBigFishInputRequest,
} from '../../../packages/shared/src/contracts/bigFish';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { UnifiedModal } from '../common/UnifiedModal';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -21,6 +23,8 @@ type TouchSample = TouchOrigin;
type BigFishRuntimeShellProps = {
run: BigFishRuntimeSnapshotResponse | null;
assetSlots?: BigFishAssetSlotResponse[];
shareTitle?: string | null;
sharePublicWorkCode?: string | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
@@ -219,6 +223,8 @@ function BigFishEntityDot({
export function BigFishRuntimeShell({
run,
assetSlots = [],
shareTitle = null,
sharePublicWorkCode = null,
isBusy = false,
error = null,
onBack,
@@ -230,6 +236,9 @@ export function BigFishRuntimeShell({
const currentTouchRef = useRef<TouchSample | null>(null);
const lastTouchSampleRef = useRef<TouchSample | null>(null);
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [stick, setStick] = useState({ x: 0, y: 0 });
const stickRef = useRef(stick);
@@ -282,6 +291,28 @@ export function BigFishRuntimeShell({
setStick(direction);
onSubmitInput(direction);
};
const sharePublicWork = () => {
const publicWorkCode = sharePublicWorkCode?.trim();
if (!publicWorkCode) {
return;
}
const sharePath = buildPublicWorkStagePath(
'big-fish-runtime',
publicWorkCode,
);
const shareUrl =
typeof window === 'undefined'
? sharePath
: new URL(sharePath, window.location.origin).href;
const title = shareTitle?.trim() || '大鱼吃小鱼';
const shareText = `邀请你来玩《${title}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
};
const beginTouchControl = (event: PointerEvent<HTMLDivElement>) => {
if (event.target instanceof HTMLElement && event.target.closest('button')) {
@@ -373,6 +404,29 @@ export function BigFishRuntimeShell({
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
{sharePublicWorkCode?.trim() ? (
<button
type="button"
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享作品'
}
title={
shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'
}
onClick={sharePublicWork}
className="pointer-events-auto inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/28 text-white backdrop-blur"
>
<Share2 className="h-4 w-4" />
</button>
) : null}
<button
type="button"
aria-label="查看规则"

View File

@@ -38,6 +38,10 @@ import type {
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import {
buildPublicWorkStagePath,
pushAppHistoryPath,
} from '../../routing/appPageRoutes';
import {
getPublicAuthUserByCode,
getPublicAuthUserById,
@@ -48,11 +52,11 @@ import {
getBigFishCreationSession,
streamBigFishCreationMessage,
} from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
advanceLocalBigFishRuntimeRun,
startLocalBigFishRuntimeRun,
} from '../../services/big-fish-runtime';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
deleteBigFishWork,
listBigFishWorks,
@@ -70,6 +74,8 @@ import {
} from '../../services/miniGameDraftGenerationProgress';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import {
buildBigFishPublicWorkCode,
buildPuzzlePublicWorkCode,
isSameBigFishPublicWorkCode,
isSamePuzzlePublicWorkCode,
} from '../../services/publicWorkCode';
@@ -407,6 +413,7 @@ export function PlatformEntryFlowShellImpl({
handleContinueGame,
handleStartNewGame,
handleCustomWorldSelect,
initialPublicWorkCode,
}: PlatformEntryFlowShellProps) {
const authUi = useAuthUi();
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
@@ -418,6 +425,10 @@ export function PlatformEntryFlowShellImpl({
>([]);
const [bigFishRun, setBigFishRun] =
useState<BigFishRuntimeSnapshotResponse | null>(null);
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
title: string;
publicWorkCode: string;
} | null>(null);
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
const [bigFishGenerationState, setBigFishGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
@@ -455,6 +466,7 @@ export function PlatformEntryFlowShellImpl({
readCustomWorldAgentUiState().activeSessionId &&
shouldRestoreCustomWorldAgentUiState(),
);
const handledInitialPublicWorkCodeRef = useRef<string | null>(null);
const platformBootstrap = usePlatformEntryBootstrap({
user: authUi?.user,
@@ -926,6 +938,12 @@ export function PlatformEntryFlowShellImpl({
);
setSelectedPuzzleDetail(galleryDetail.item);
setSelectionStage('puzzle-gallery-detail');
pushAppHistoryPath(
buildPublicWorkStagePath(
'puzzle-gallery-detail',
buildPuzzlePublicWorkCode(galleryDetail.item.profileId),
),
);
}
},
beforeExecuteAction: ({ payload }) => {
@@ -1002,6 +1020,7 @@ export function PlatformEntryFlowShellImpl({
setSelectedDetailEntry(null);
setBigFishWorks([]);
setBigFishRun(null);
setBigFishRuntimeShare(null);
setBigFishGenerationState(null);
setBigFishError(null);
setPuzzleOperation(null);
@@ -1118,6 +1137,7 @@ export function PlatformEntryFlowShellImpl({
}
setBigFishError(null);
setBigFishRuntimeShare(null);
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
}, [bigFishSession, setSelectionStage]);
@@ -1128,6 +1148,7 @@ export function PlatformEntryFlowShellImpl({
}
setBigFishError(null);
setBigFishRuntimeShare(null);
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
}, [bigFishRun, bigFishSession, setSelectionStage]);
@@ -1147,6 +1168,12 @@ export function PlatformEntryFlowShellImpl({
setPuzzleRun(startLocalPuzzleRun(item));
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
setSelectionStage('puzzle-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath(
'puzzle-runtime',
buildPuzzlePublicWorkCode(item.profileId),
),
);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
} finally {
@@ -1600,6 +1627,12 @@ export function PlatformEntryFlowShellImpl({
setSelectedPuzzleDetail(item);
setPuzzleDetailReturnTarget(returnTarget);
setSelectionStage('puzzle-gallery-detail');
pushAppHistoryPath(
buildPublicWorkStagePath(
'puzzle-gallery-detail',
buildPuzzlePublicWorkCode(item.profileId),
),
);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
} finally {
@@ -1640,17 +1673,25 @@ export function PlatformEntryFlowShellImpl({
const startBigFishRunFromWork = useCallback(
(item: BigFishWorkSummary) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
setBigFishError(null);
bigFishFlow.setSession(null);
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
setSelectionStage('big-fish-runtime');
},
const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId);
setBigFishError(null);
bigFishFlow.setSession(null);
setBigFishRuntimeShare({
title: item.title,
publicWorkCode,
});
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
setSelectionStage('big-fish-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
);
},
[bigFishFlow, setSelectionStage],
);
@@ -1800,6 +1841,19 @@ export function PlatformEntryFlowShellImpl({
],
);
useEffect(() => {
const publicWorkCode = initialPublicWorkCode?.trim();
if (
!publicWorkCode ||
handledInitialPublicWorkCodeRef.current === publicWorkCode
) {
return;
}
handledInitialPublicWorkCodeRef.current = publicWorkCode;
void handlePublicCodeSearch(publicWorkCode);
}, [handlePublicCodeSearch, initialPublicWorkCode]);
const openBigFishDraft = useCallback(
async (item: BigFishWorkSummary) => {
setBigFishRun(null);
@@ -2288,6 +2342,10 @@ export function PlatformEntryFlowShellImpl({
<BigFishRuntimeShell
run={bigFishRun}
assetSlots={bigFishSession?.assetSlots ?? []}
shareTitle={bigFishRuntimeShare?.title ?? null}
sharePublicWorkCode={
bigFishRuntimeShare?.publicWorkCode ?? null
}
isBusy={isBigFishBusy}
error={bigFishError}
onBack={() => {

View File

@@ -41,6 +41,7 @@ export type SyncedAgentDraftResult = {
export type PlatformEntryFlowShellProps = {
selectionStage: SelectionStage;
setSelectionStage: (stage: SelectionStage) => void;
initialPublicWorkCode?: string | null;
hasSavedGame: boolean;
savedSnapshot: HydratedSavedGameSnapshot | null;
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;

View File

@@ -1,7 +1,8 @@
import { ArrowLeft, Copy, Pencil, Play, UserRound } from 'lucide-react';
import { ArrowLeft, Copy, Pencil, Play, Share2, UserRound } from 'lucide-react';
import { useState } from 'react';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -31,12 +32,31 @@ export function PuzzleGalleryDetailView({
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const copyPublicWorkCode = () => {
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
const sharePublicWork = () => {
const sharePath = buildPublicWorkStagePath(
'puzzle-gallery-detail',
publicWorkCode,
);
const shareUrl =
typeof window === 'undefined'
? sharePath
: new URL(sharePath, window.location.origin).href;
const shareText = `邀请你来玩《${item.levelName}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
};
return (
<div className="mx-auto flex h-full min-h-0 w-full max-w-5xl flex-col gap-3 overflow-hidden px-1 sm:px-0">
@@ -62,6 +82,19 @@ export function PuzzleGalleryDetailView({
</button>
) : null}
<button
type="button"
disabled={isBusy}
onClick={sharePublicWork}
className="inline-flex items-center gap-2 rounded-full bg-white/12 px-4 py-2 text-sm font-bold text-white disabled:opacity-45"
>
<Share2 className="h-4 w-4" />
{shareState === 'copied'
? '已复制'
: shareState === 'failed'
? '复制失败'
: '分享作品'}
</button>
<button
type="button"
disabled={isBusy}

View File

@@ -1,8 +1,9 @@
import { ArrowLeft, Copy } from 'lucide-react';
import { ArrowLeft, Copy, Share2 } from 'lucide-react';
import { useState } from 'react';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import type { CustomWorldProfile } from '../../types';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -74,6 +75,9 @@ export function RpgEntryWorldDetailView({
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const canStartGame = entry.visibility === 'published';
const previewCharacters = buildCustomWorldPlayableCharacters(
entry.profile,
@@ -96,6 +100,19 @@ export function RpgEntryWorldDetailView({
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
const sharePublicWork = () => {
if (!publicWorkCode) {
return;
}
const shareUrl = buildPublicWorkDetailUrl(publicWorkCode);
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${shareUrl}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
};
return (
<div className="flex h-full min-h-0 flex-col">
@@ -146,21 +163,38 @@ export function RpgEntryWorldDetailView({
: '仅自己可见'}
</span>
{publicWorkCode ? (
<button
type="button"
onClick={copyPublicWorkCode}
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
>
<span> {publicWorkCode}</span>
<Copy className="h-3 w-3" />
{copyState !== 'idle' ? (
<span className="text-xs">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
<>
<button
type="button"
onClick={copyPublicWorkCode}
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
aria-label={`复制作品号 ${publicWorkCode}`}
title="复制作品号"
>
<span> {publicWorkCode}</span>
<Copy className="h-3 w-3" />
{copyState !== 'idle' ? (
<span className="text-xs">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
<button
type="button"
onClick={sharePublicWork}
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
aria-label={`分享作品 ${entry.worldName}`}
title="分享作品"
>
<Share2 className="h-3 w-3" />
<span></span>
{shareState !== 'idle' ? (
<span className="text-xs">
{shareState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
</>
) : null}
</div>
<div className="mt-4 text-3xl font-black text-white">

View File

@@ -9,6 +9,11 @@ import type {
CustomWorldLibraryEntry,
PlatformBrowseHistoryWriteEntry,
} from '../../../packages/shared/src/contracts/runtime';
import {
buildPublicWorkDetailPath,
pushAppHistoryPath,
} from '../../routing/appPageRoutes';
import { ApiClientError } from '../../services/apiClient';
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetail,
@@ -16,7 +21,6 @@ import {
publishRpgEntryWorldProfile,
unpublishRpgEntryWorldProfile,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { ApiClientError } from '../../services/apiClient';
import type { CustomWorldProfile } from '../../types';
import {
normalizeRpgEntryAgentBackedProfile,
@@ -167,6 +171,9 @@ export function useRpgEntryLibraryDetail(
setSelectedDetailEntry(entry);
setDetailError(null);
setSelectionStage('detail');
if (entry.publicWorkCode?.trim()) {
pushAppHistoryPath(buildPublicWorkDetailPath(entry.publicWorkCode));
}
},
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
);
@@ -183,6 +190,11 @@ export function useRpgEntryLibraryDetail(
entry.profileId,
);
setSelectedDetailEntry(detailEntry);
if (detailEntry.publicWorkCode?.trim()) {
pushAppHistoryPath(
buildPublicWorkDetailPath(detailEntry.publicWorkCode),
);
}
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,

View File

@@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest';
import {
APP_RUNTIME_ROUTES,
buildPublicWorkDetailPath,
buildPublicWorkDetailUrl,
buildPublicWorkStagePath,
isKnownMainAppPagePath,
normalizeAppPath,
readPublicWorkCodeFromLocationSearch,
resolvePathForSelectionStage,
resolveSelectionStageFromPath,
} from './appPageRoutes';
@@ -45,4 +49,22 @@ describe('appPageRoutes', () => {
).toBe(true);
expect(isKnownMainAppPagePath('/runtime/rpg/adventure/')).toBe(true);
});
it('builds and reads public work detail query routes', () => {
expect(buildPublicWorkDetailPath('CW-00000001')).toBe(
'/worlds/detail?work=CW-00000001',
);
expect(buildPublicWorkDetailUrl('CW-00000001', 'https://example.test')).toBe(
'https://example.test/worlds/detail?work=CW-00000001',
);
expect(readPublicWorkCodeFromLocationSearch('?work=CW-00000001')).toBe(
'CW-00000001',
);
expect(
buildPublicWorkStagePath('puzzle-gallery-detail', 'PZ-00000002'),
).toBe('/gallery/puzzle/detail?work=PZ-00000002');
expect(buildPublicWorkStagePath('big-fish-runtime', 'BF-00000003')).toBe(
'/runtime/big-fish?work=BF-00000003',
);
});
});

View File

@@ -2,6 +2,8 @@ import type { SelectionStage } from '../components/platform-entry';
export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure';
export const PUBLIC_WORK_QUERY_PARAM = 'work';
const STAGE_ROUTE_ENTRIES = [
['platform', '/'],
['detail', '/worlds/detail'],
@@ -49,6 +51,37 @@ export function resolvePathForSelectionStage(stage: SelectionStage) {
return APP_STAGE_ROUTES[stage] ?? APP_STAGE_ROUTES.platform;
}
export function readPublicWorkCodeFromLocationSearch(search: string) {
const params = new URLSearchParams(search);
return params.get(PUBLIC_WORK_QUERY_PARAM)?.trim() || null;
}
export function buildPublicWorkDetailPath(publicWorkCode: string) {
return buildPublicWorkStagePath('detail', publicWorkCode);
}
export function buildPublicWorkStagePath(
stage: SelectionStage,
publicWorkCode: string,
) {
const code = publicWorkCode.trim();
const stagePath = resolvePathForSelectionStage(stage);
if (!code) {
return stagePath;
}
const params = new URLSearchParams();
params.set(PUBLIC_WORK_QUERY_PARAM, code);
return `${stagePath}?${params.toString()}`;
}
export function buildPublicWorkDetailUrl(
publicWorkCode: string,
origin = window.location.origin,
) {
return new URL(buildPublicWorkDetailPath(publicWorkCode), origin).href;
}
export function isKnownMainAppPagePath(pathname: string) {
const normalizedPath = normalizeAppPath(pathname);
const runtimePaths: readonly string[] = Object.values(APP_RUNTIME_ROUTES);
@@ -59,11 +92,14 @@ export function isKnownMainAppPagePath(pathname: string) {
}
export function pushAppHistoryPath(path: string) {
const normalizedPath = normalizeAppPath(path);
if (normalizeAppPath(window.location.pathname) === normalizedPath) {
const nextUrl = new URL(path, window.location.origin);
const normalizedPath = normalizeAppPath(nextUrl.pathname);
const nextRelativeUrl = `${normalizedPath}${nextUrl.search}`;
const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`;
if (currentRelativeUrl === nextRelativeUrl) {
return;
}
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
window.history.pushState(null, '', normalizedPath);
window.history.pushState(null, '', nextRelativeUrl);
}