add public work share links
This commit is contained in:
@@ -14,6 +14,17 @@
|
|||||||
6. 作品详情返回必须恢复打开详情前的平台来源 Tab;从分类进入回分类,从首页进入回首页,从创作中心进入回创作中心。
|
6. 作品详情返回必须恢复打开详情前的平台来源 Tab;从分类进入回分类,从首页进入回首页,从创作中心进入回创作中心。
|
||||||
7. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。
|
7. 所有入口保持轻量 UI,不写规则说明文案,不改变发布、下架、进入游戏的后端语义。
|
||||||
|
|
||||||
|
## 作品分享路由补充
|
||||||
|
|
||||||
|
1. 公开作品入口路由统一使用当前作品页面路径加 `work=作品号`:RPG 为 `/worlds/detail?work=CW-00000001`,拼图为 `/gallery/puzzle/detail?work=PZ-00000001`,大鱼玩法为 `/runtime/big-fish?work=BF-00000001`。
|
||||||
|
2. 从公开广场、最近浏览、创作中心打开已发布作品详情或玩法时,若当前作品有公开作品号,地址栏必须同步追加 `work=作品号`;没有作品号的草稿详情仍保持无查询参数路径。
|
||||||
|
3. 首次进入主应用时若 URL 带 `work` 查询参数,平台入口自动复用现有公开编号搜索逻辑打开对应作品详情,不新增独立详情系统。
|
||||||
|
4. 详情页必须保留“复制作品号”和“分享作品”两个独立动作:
|
||||||
|
- 复制作品号只复制 `CW / PZ / BF` 编号。
|
||||||
|
- 分享作品复制一段邀请好友来玩的中文文本,文本内必须包含作品名、作品号和带 `work` 查询参数的完整网址。
|
||||||
|
5. 分享复制使用现有剪切板兼容工具,Clipboard API 权限失败时走降级复制,并在按钮内短暂反馈 `已复制` 或 `复制失败`。
|
||||||
|
6. UI 中只保留按钮级短文案,不写规则说明,不在详情页新增大段分享说明。
|
||||||
|
|
||||||
## 验收
|
## 验收
|
||||||
|
|
||||||
1. 399px 竖屏首页能直接看到并使用搜索入口。
|
1. 399px 竖屏首页能直接看到并使用搜索入口。
|
||||||
@@ -23,3 +34,5 @@
|
|||||||
5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。
|
5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。
|
||||||
6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制` 或 `复制失败`。
|
6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制` 或 `复制失败`。
|
||||||
7. 打开拼图详情后点击返回,不再固定跳到创作中心,而是回到打开详情前的平台 Tab。
|
7. 打开拼图详情后点击返回,不再固定跳到创作中心,而是回到打开详情前的平台 Tab。
|
||||||
|
8. 打开 `/?work=CW-00000001`、`/worlds/detail?work=CW-00000001`、`/gallery/puzzle/detail?work=PZ-00000001` 或 `/runtime/big-fish?work=BF-00000001` 后能自动进入对应公开作品详情或玩法。
|
||||||
|
9. 点击详情页“分享作品”后,剪切板内容包含邀请文本、作品号和当前站点下带 `work=作品号` 的完整网址。
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
APP_RUNTIME_ROUTES,
|
APP_RUNTIME_ROUTES,
|
||||||
normalizeAppPath,
|
normalizeAppPath,
|
||||||
pushAppHistoryPath,
|
pushAppHistoryPath,
|
||||||
|
readPublicWorkCodeFromLocationSearch,
|
||||||
resolvePathForSelectionStage,
|
resolvePathForSelectionStage,
|
||||||
resolveSelectionStageFromPath,
|
resolveSelectionStageFromPath,
|
||||||
} from './routing/appPageRoutes';
|
} from './routing/appPageRoutes';
|
||||||
@@ -40,6 +41,9 @@ export default function App() {
|
|||||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
||||||
resolveSelectionStageFromPath(window.location.pathname),
|
resolveSelectionStageFromPath(window.location.pathname),
|
||||||
);
|
);
|
||||||
|
const [initialPublicWorkCode] = useState(() =>
|
||||||
|
readPublicWorkCodeFromLocationSearch(window.location.search),
|
||||||
|
);
|
||||||
|
|
||||||
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
||||||
setRawSelectionStage(stage);
|
setRawSelectionStage(stage);
|
||||||
@@ -114,6 +118,7 @@ export default function App() {
|
|||||||
<PlatformEntryFlowShell
|
<PlatformEntryFlowShell
|
||||||
selectionStage={selectionStage}
|
selectionStage={selectionStage}
|
||||||
setSelectionStage={setSelectionStage}
|
setSelectionStage={setSelectionStage}
|
||||||
|
initialPublicWorkCode={initialPublicWorkCode}
|
||||||
hasSavedGame={false}
|
hasSavedGame={false}
|
||||||
savedSnapshot={null}
|
savedSnapshot={null}
|
||||||
handleContinueGame={handleContinueGame}
|
handleContinueGame={handleContinueGame}
|
||||||
|
|||||||
@@ -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 PointerEvent, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
BigFishRuntimeSnapshotResponse,
|
BigFishRuntimeSnapshotResponse,
|
||||||
SubmitBigFishInputRequest,
|
SubmitBigFishInputRequest,
|
||||||
} from '../../../packages/shared/src/contracts/bigFish';
|
} from '../../../packages/shared/src/contracts/bigFish';
|
||||||
|
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||||
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import { UnifiedModal } from '../common/UnifiedModal';
|
import { UnifiedModal } from '../common/UnifiedModal';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
|
|
||||||
@@ -21,6 +23,8 @@ type TouchSample = TouchOrigin;
|
|||||||
type BigFishRuntimeShellProps = {
|
type BigFishRuntimeShellProps = {
|
||||||
run: BigFishRuntimeSnapshotResponse | null;
|
run: BigFishRuntimeSnapshotResponse | null;
|
||||||
assetSlots?: BigFishAssetSlotResponse[];
|
assetSlots?: BigFishAssetSlotResponse[];
|
||||||
|
shareTitle?: string | null;
|
||||||
|
sharePublicWorkCode?: string | null;
|
||||||
isBusy?: boolean;
|
isBusy?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -219,6 +223,8 @@ function BigFishEntityDot({
|
|||||||
export function BigFishRuntimeShell({
|
export function BigFishRuntimeShell({
|
||||||
run,
|
run,
|
||||||
assetSlots = [],
|
assetSlots = [],
|
||||||
|
shareTitle = null,
|
||||||
|
sharePublicWorkCode = null,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
error = null,
|
error = null,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -230,6 +236,9 @@ export function BigFishRuntimeShell({
|
|||||||
const currentTouchRef = useRef<TouchSample | null>(null);
|
const currentTouchRef = useRef<TouchSample | null>(null);
|
||||||
const lastTouchSampleRef = useRef<TouchSample | null>(null);
|
const lastTouchSampleRef = useRef<TouchSample | null>(null);
|
||||||
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
|
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
|
||||||
|
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
|
||||||
|
'idle',
|
||||||
|
);
|
||||||
const [stick, setStick] = useState({ x: 0, y: 0 });
|
const [stick, setStick] = useState({ x: 0, y: 0 });
|
||||||
const stickRef = useRef(stick);
|
const stickRef = useRef(stick);
|
||||||
|
|
||||||
@@ -282,6 +291,28 @@ export function BigFishRuntimeShell({
|
|||||||
setStick(direction);
|
setStick(direction);
|
||||||
onSubmitInput(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>) => {
|
const beginTouchControl = (event: PointerEvent<HTMLDivElement>) => {
|
||||||
if (event.target instanceof HTMLElement && event.target.closest('button')) {
|
if (event.target instanceof HTMLElement && event.target.closest('button')) {
|
||||||
@@ -373,6 +404,29 @@ export function BigFishRuntimeShell({
|
|||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="查看规则"
|
aria-label="查看规则"
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ import type {
|
|||||||
CustomWorldLibraryEntry,
|
CustomWorldLibraryEntry,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||||
|
import {
|
||||||
|
buildPublicWorkStagePath,
|
||||||
|
pushAppHistoryPath,
|
||||||
|
} from '../../routing/appPageRoutes';
|
||||||
import {
|
import {
|
||||||
getPublicAuthUserByCode,
|
getPublicAuthUserByCode,
|
||||||
getPublicAuthUserById,
|
getPublicAuthUserById,
|
||||||
@@ -45,11 +49,11 @@ import {
|
|||||||
getBigFishCreationSession,
|
getBigFishCreationSession,
|
||||||
streamBigFishCreationMessage,
|
streamBigFishCreationMessage,
|
||||||
} from '../../services/big-fish-creation';
|
} from '../../services/big-fish-creation';
|
||||||
|
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
||||||
import {
|
import {
|
||||||
advanceLocalBigFishRuntimeRun,
|
advanceLocalBigFishRuntimeRun,
|
||||||
startLocalBigFishRuntimeRun,
|
startLocalBigFishRuntimeRun,
|
||||||
} from '../../services/big-fish-runtime';
|
} from '../../services/big-fish-runtime';
|
||||||
import { listBigFishGallery } from '../../services/big-fish-gallery';
|
|
||||||
import {
|
import {
|
||||||
deleteBigFishWork,
|
deleteBigFishWork,
|
||||||
listBigFishWorks,
|
listBigFishWorks,
|
||||||
@@ -67,6 +71,8 @@ import {
|
|||||||
} from '../../services/miniGameDraftGenerationProgress';
|
} from '../../services/miniGameDraftGenerationProgress';
|
||||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||||
import {
|
import {
|
||||||
|
buildBigFishPublicWorkCode,
|
||||||
|
buildPuzzlePublicWorkCode,
|
||||||
isSameBigFishPublicWorkCode,
|
isSameBigFishPublicWorkCode,
|
||||||
isSamePuzzlePublicWorkCode,
|
isSamePuzzlePublicWorkCode,
|
||||||
} from '../../services/publicWorkCode';
|
} from '../../services/publicWorkCode';
|
||||||
@@ -401,6 +407,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
handleContinueGame,
|
handleContinueGame,
|
||||||
handleStartNewGame,
|
handleStartNewGame,
|
||||||
handleCustomWorldSelect,
|
handleCustomWorldSelect,
|
||||||
|
initialPublicWorkCode,
|
||||||
}: PlatformEntryFlowShellProps) {
|
}: PlatformEntryFlowShellProps) {
|
||||||
const authUi = useAuthUi();
|
const authUi = useAuthUi();
|
||||||
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
|
||||||
@@ -412,6 +419,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
>([]);
|
>([]);
|
||||||
const [bigFishRun, setBigFishRun] =
|
const [bigFishRun, setBigFishRun] =
|
||||||
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
useState<BigFishRuntimeSnapshotResponse | null>(null);
|
||||||
|
const [bigFishRuntimeShare, setBigFishRuntimeShare] = useState<{
|
||||||
|
title: string;
|
||||||
|
publicWorkCode: string;
|
||||||
|
} | null>(null);
|
||||||
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
|
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
|
||||||
const [bigFishGenerationState, setBigFishGenerationState] =
|
const [bigFishGenerationState, setBigFishGenerationState] =
|
||||||
useState<MiniGameDraftGenerationState | null>(null);
|
useState<MiniGameDraftGenerationState | null>(null);
|
||||||
@@ -447,6 +458,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
readCustomWorldAgentUiState().activeSessionId &&
|
readCustomWorldAgentUiState().activeSessionId &&
|
||||||
shouldRestoreCustomWorldAgentUiState(),
|
shouldRestoreCustomWorldAgentUiState(),
|
||||||
);
|
);
|
||||||
|
const handledInitialPublicWorkCodeRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const platformBootstrap = usePlatformEntryBootstrap({
|
const platformBootstrap = usePlatformEntryBootstrap({
|
||||||
user: authUi?.user,
|
user: authUi?.user,
|
||||||
@@ -918,6 +930,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
);
|
);
|
||||||
setSelectedPuzzleDetail(galleryDetail.item);
|
setSelectedPuzzleDetail(galleryDetail.item);
|
||||||
setSelectionStage('puzzle-gallery-detail');
|
setSelectionStage('puzzle-gallery-detail');
|
||||||
|
pushAppHistoryPath(
|
||||||
|
buildPublicWorkStagePath(
|
||||||
|
'puzzle-gallery-detail',
|
||||||
|
buildPuzzlePublicWorkCode(galleryDetail.item.profileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
beforeExecuteAction: ({ payload }) => {
|
beforeExecuteAction: ({ payload }) => {
|
||||||
@@ -994,6 +1012,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setSelectedDetailEntry(null);
|
setSelectedDetailEntry(null);
|
||||||
setBigFishWorks([]);
|
setBigFishWorks([]);
|
||||||
setBigFishRun(null);
|
setBigFishRun(null);
|
||||||
|
setBigFishRuntimeShare(null);
|
||||||
setBigFishGenerationState(null);
|
setBigFishGenerationState(null);
|
||||||
setBigFishError(null);
|
setBigFishError(null);
|
||||||
setPuzzleOperation(null);
|
setPuzzleOperation(null);
|
||||||
@@ -1110,6 +1129,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBigFishError(null);
|
setBigFishError(null);
|
||||||
|
setBigFishRuntimeShare(null);
|
||||||
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
||||||
setSelectionStage('big-fish-runtime');
|
setSelectionStage('big-fish-runtime');
|
||||||
}, [bigFishSession, setSelectionStage]);
|
}, [bigFishSession, setSelectionStage]);
|
||||||
@@ -1120,6 +1140,7 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setBigFishError(null);
|
setBigFishError(null);
|
||||||
|
setBigFishRuntimeShare(null);
|
||||||
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
|
||||||
setSelectionStage('big-fish-runtime');
|
setSelectionStage('big-fish-runtime');
|
||||||
}, [bigFishRun, bigFishSession, setSelectionStage]);
|
}, [bigFishRun, bigFishSession, setSelectionStage]);
|
||||||
@@ -1139,6 +1160,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setPuzzleRun(startLocalPuzzleRun(item));
|
setPuzzleRun(startLocalPuzzleRun(item));
|
||||||
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
|
||||||
setSelectionStage('puzzle-runtime');
|
setSelectionStage('puzzle-runtime');
|
||||||
|
pushAppHistoryPath(
|
||||||
|
buildPublicWorkStagePath(
|
||||||
|
'puzzle-runtime',
|
||||||
|
buildPuzzlePublicWorkCode(item.profileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
|
setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1548,6 +1575,12 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
setSelectedPuzzleDetail(item);
|
setSelectedPuzzleDetail(item);
|
||||||
setPuzzleDetailReturnTarget(returnTarget);
|
setPuzzleDetailReturnTarget(returnTarget);
|
||||||
setSelectionStage('puzzle-gallery-detail');
|
setSelectionStage('puzzle-gallery-detail');
|
||||||
|
pushAppHistoryPath(
|
||||||
|
buildPublicWorkStagePath(
|
||||||
|
'puzzle-gallery-detail',
|
||||||
|
buildPuzzlePublicWorkCode(item.profileId),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
|
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1588,17 +1621,25 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
|
|
||||||
const startBigFishRunFromWork = useCallback(
|
const startBigFishRunFromWork = useCallback(
|
||||||
(item: BigFishWorkSummary) => {
|
(item: BigFishWorkSummary) => {
|
||||||
const sessionId = item.sourceSessionId?.trim();
|
const sessionId = item.sourceSessionId?.trim();
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
|
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBigFishError(null);
|
const publicWorkCode = buildBigFishPublicWorkCode(item.sourceSessionId);
|
||||||
bigFishFlow.setSession(null);
|
setBigFishError(null);
|
||||||
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
|
bigFishFlow.setSession(null);
|
||||||
setSelectionStage('big-fish-runtime');
|
setBigFishRuntimeShare({
|
||||||
},
|
title: item.title,
|
||||||
|
publicWorkCode,
|
||||||
|
});
|
||||||
|
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
|
||||||
|
setSelectionStage('big-fish-runtime');
|
||||||
|
pushAppHistoryPath(
|
||||||
|
buildPublicWorkStagePath('big-fish-runtime', publicWorkCode),
|
||||||
|
);
|
||||||
|
},
|
||||||
[bigFishFlow, setSelectionStage],
|
[bigFishFlow, setSelectionStage],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1748,6 +1789,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(
|
const openBigFishDraft = useCallback(
|
||||||
async (item: BigFishWorkSummary) => {
|
async (item: BigFishWorkSummary) => {
|
||||||
setBigFishRun(null);
|
setBigFishRun(null);
|
||||||
@@ -2236,6 +2290,10 @@ export function PlatformEntryFlowShellImpl({
|
|||||||
<BigFishRuntimeShell
|
<BigFishRuntimeShell
|
||||||
run={bigFishRun}
|
run={bigFishRun}
|
||||||
assetSlots={bigFishSession?.assetSlots ?? []}
|
assetSlots={bigFishSession?.assetSlots ?? []}
|
||||||
|
shareTitle={bigFishRuntimeShare?.title ?? null}
|
||||||
|
sharePublicWorkCode={
|
||||||
|
bigFishRuntimeShare?.publicWorkCode ?? null
|
||||||
|
}
|
||||||
isBusy={isBigFishBusy}
|
isBusy={isBigFishBusy}
|
||||||
error={bigFishError}
|
error={bigFishError}
|
||||||
onBack={() => {
|
onBack={() => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type SyncedAgentDraftResult = {
|
|||||||
export type PlatformEntryFlowShellProps = {
|
export type PlatformEntryFlowShellProps = {
|
||||||
selectionStage: SelectionStage;
|
selectionStage: SelectionStage;
|
||||||
setSelectionStage: (stage: SelectionStage) => void;
|
setSelectionStage: (stage: SelectionStage) => void;
|
||||||
|
initialPublicWorkCode?: string | null;
|
||||||
hasSavedGame: boolean;
|
hasSavedGame: boolean;
|
||||||
savedSnapshot: HydratedSavedGameSnapshot | null;
|
savedSnapshot: HydratedSavedGameSnapshot | null;
|
||||||
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
handleContinueGame: (snapshot?: HydratedSavedGameSnapshot | null) => void;
|
||||||
|
|||||||
@@ -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 { useState } from 'react';
|
||||||
|
|
||||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||||
|
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
|
||||||
import { copyTextToClipboard } from '../../services/clipboard';
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
@@ -31,12 +32,31 @@ export function PuzzleGalleryDetailView({
|
|||||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||||
'idle',
|
'idle',
|
||||||
);
|
);
|
||||||
|
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
|
||||||
|
'idle',
|
||||||
|
);
|
||||||
const copyPublicWorkCode = () => {
|
const copyPublicWorkCode = () => {
|
||||||
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
void copyTextToClipboard(publicWorkCode).then((copied) => {
|
||||||
setCopyState(copied ? 'copied' : 'failed');
|
setCopyState(copied ? 'copied' : 'failed');
|
||||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
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 (
|
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">
|
<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>
|
</button>
|
||||||
) : null}
|
) : 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ArrowLeft, Copy } from 'lucide-react';
|
import { ArrowLeft, Copy, Share2 } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
|
||||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||||
|
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||||
import { copyTextToClipboard } from '../../services/clipboard';
|
import { copyTextToClipboard } from '../../services/clipboard';
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||||
@@ -74,6 +75,9 @@ export function RpgEntryWorldDetailView({
|
|||||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
|
||||||
'idle',
|
'idle',
|
||||||
);
|
);
|
||||||
|
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
|
||||||
|
'idle',
|
||||||
|
);
|
||||||
const canStartGame = entry.visibility === 'published';
|
const canStartGame = entry.visibility === 'published';
|
||||||
const previewCharacters = buildCustomWorldPlayableCharacters(
|
const previewCharacters = buildCustomWorldPlayableCharacters(
|
||||||
entry.profile,
|
entry.profile,
|
||||||
@@ -96,6 +100,19 @@ export function RpgEntryWorldDetailView({
|
|||||||
window.setTimeout(() => setCopyState('idle'), 1400);
|
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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col">
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
@@ -146,21 +163,38 @@ export function RpgEntryWorldDetailView({
|
|||||||
: '仅自己可见'}
|
: '仅自己可见'}
|
||||||
</span>
|
</span>
|
||||||
{publicWorkCode ? (
|
{publicWorkCode ? (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={copyPublicWorkCode}
|
type="button"
|
||||||
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
|
onClick={copyPublicWorkCode}
|
||||||
aria-label={`复制作品号 ${publicWorkCode}`}
|
className="platform-pill platform-pill--neutral flex items-center gap-1 px-3"
|
||||||
title="复制作品号"
|
aria-label={`复制作品号 ${publicWorkCode}`}
|
||||||
>
|
title="复制作品号"
|
||||||
<span>作品号 {publicWorkCode}</span>
|
>
|
||||||
<Copy className="h-3 w-3" />
|
<span>作品号 {publicWorkCode}</span>
|
||||||
{copyState !== 'idle' ? (
|
<Copy className="h-3 w-3" />
|
||||||
<span className="text-xs">
|
{copyState !== 'idle' ? (
|
||||||
{copyState === 'copied' ? '已复制' : '复制失败'}
|
<span className="text-xs">
|
||||||
</span>
|
{copyState === 'copied' ? '已复制' : '复制失败'}
|
||||||
) : null}
|
</span>
|
||||||
</button>
|
) : 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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 text-3xl font-black text-white">
|
<div className="mt-4 text-3xl font-black text-white">
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import type {
|
|||||||
CustomWorldLibraryEntry,
|
CustomWorldLibraryEntry,
|
||||||
PlatformBrowseHistoryWriteEntry,
|
PlatformBrowseHistoryWriteEntry,
|
||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
|
import {
|
||||||
|
buildPublicWorkDetailPath,
|
||||||
|
pushAppHistoryPath,
|
||||||
|
} from '../../routing/appPageRoutes';
|
||||||
|
import { ApiClientError } from '../../services/apiClient';
|
||||||
import {
|
import {
|
||||||
deleteRpgEntryWorldProfile,
|
deleteRpgEntryWorldProfile,
|
||||||
getRpgEntryWorldGalleryDetail,
|
getRpgEntryWorldGalleryDetail,
|
||||||
@@ -16,7 +21,6 @@ import {
|
|||||||
publishRpgEntryWorldProfile,
|
publishRpgEntryWorldProfile,
|
||||||
unpublishRpgEntryWorldProfile,
|
unpublishRpgEntryWorldProfile,
|
||||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||||
import { ApiClientError } from '../../services/apiClient';
|
|
||||||
import type { CustomWorldProfile } from '../../types';
|
import type { CustomWorldProfile } from '../../types';
|
||||||
import {
|
import {
|
||||||
normalizeRpgEntryAgentBackedProfile,
|
normalizeRpgEntryAgentBackedProfile,
|
||||||
@@ -167,6 +171,9 @@ export function useRpgEntryLibraryDetail(
|
|||||||
setSelectedDetailEntry(entry);
|
setSelectedDetailEntry(entry);
|
||||||
setDetailError(null);
|
setDetailError(null);
|
||||||
setSelectionStage('detail');
|
setSelectionStage('detail');
|
||||||
|
if (entry.publicWorkCode?.trim()) {
|
||||||
|
pushAppHistoryPath(buildPublicWorkDetailPath(entry.publicWorkCode));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
|
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
|
||||||
);
|
);
|
||||||
@@ -183,6 +190,11 @@ export function useRpgEntryLibraryDetail(
|
|||||||
entry.profileId,
|
entry.profileId,
|
||||||
);
|
);
|
||||||
setSelectedDetailEntry(detailEntry);
|
setSelectedDetailEntry(detailEntry);
|
||||||
|
if (detailEntry.publicWorkCode?.trim()) {
|
||||||
|
pushAppHistoryPath(
|
||||||
|
buildPublicWorkDetailPath(detailEntry.publicWorkCode),
|
||||||
|
);
|
||||||
|
}
|
||||||
void appendBrowseHistoryEntry({
|
void appendBrowseHistoryEntry({
|
||||||
ownerUserId: detailEntry.ownerUserId,
|
ownerUserId: detailEntry.ownerUserId,
|
||||||
profileId: detailEntry.profileId,
|
profileId: detailEntry.profileId,
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
APP_RUNTIME_ROUTES,
|
APP_RUNTIME_ROUTES,
|
||||||
|
buildPublicWorkDetailPath,
|
||||||
|
buildPublicWorkDetailUrl,
|
||||||
|
buildPublicWorkStagePath,
|
||||||
isKnownMainAppPagePath,
|
isKnownMainAppPagePath,
|
||||||
normalizeAppPath,
|
normalizeAppPath,
|
||||||
|
readPublicWorkCodeFromLocationSearch,
|
||||||
resolvePathForSelectionStage,
|
resolvePathForSelectionStage,
|
||||||
resolveSelectionStageFromPath,
|
resolveSelectionStageFromPath,
|
||||||
} from './appPageRoutes';
|
} from './appPageRoutes';
|
||||||
@@ -45,4 +49,22 @@ describe('appPageRoutes', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(isKnownMainAppPagePath('/runtime/rpg/adventure/')).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',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import type { SelectionStage } from '../components/platform-entry';
|
|||||||
|
|
||||||
export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure';
|
export type RuntimePageRoute = 'rpg-character-select' | 'rpg-adventure';
|
||||||
|
|
||||||
|
export const PUBLIC_WORK_QUERY_PARAM = 'work';
|
||||||
|
|
||||||
const STAGE_ROUTE_ENTRIES = [
|
const STAGE_ROUTE_ENTRIES = [
|
||||||
['platform', '/'],
|
['platform', '/'],
|
||||||
['detail', '/worlds/detail'],
|
['detail', '/worlds/detail'],
|
||||||
@@ -49,6 +51,37 @@ export function resolvePathForSelectionStage(stage: SelectionStage) {
|
|||||||
return APP_STAGE_ROUTES[stage] ?? APP_STAGE_ROUTES.platform;
|
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) {
|
export function isKnownMainAppPagePath(pathname: string) {
|
||||||
const normalizedPath = normalizeAppPath(pathname);
|
const normalizedPath = normalizeAppPath(pathname);
|
||||||
const runtimePaths: readonly string[] = Object.values(APP_RUNTIME_ROUTES);
|
const runtimePaths: readonly string[] = Object.values(APP_RUNTIME_ROUTES);
|
||||||
@@ -59,11 +92,14 @@ export function isKnownMainAppPagePath(pathname: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function pushAppHistoryPath(path: string) {
|
export function pushAppHistoryPath(path: string) {
|
||||||
const normalizedPath = normalizeAppPath(path);
|
const nextUrl = new URL(path, window.location.origin);
|
||||||
if (normalizeAppPath(window.location.pathname) === normalizedPath) {
|
const normalizedPath = normalizeAppPath(nextUrl.pathname);
|
||||||
|
const nextRelativeUrl = `${normalizedPath}${nextUrl.search}`;
|
||||||
|
const currentRelativeUrl = `${normalizeAppPath(window.location.pathname)}${window.location.search}`;
|
||||||
|
if (currentRelativeUrl === nextRelativeUrl) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
|
// 页面阶段变化是用户可感知导航,写入 history 以支持前进后退。
|
||||||
window.history.pushState(null, '', normalizedPath);
|
window.history.pushState(null, '', nextRelativeUrl);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user