diff --git a/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md b/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md index e36dafc2..53d24314 100644 --- a/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md +++ b/docs/technical/PUBLIC_WORK_CODE_MOBILE_SHARE_ENTRY_FIX_2026-04-25.md @@ -14,6 +14,17 @@ 6. 作品详情返回必须恢复打开详情前的平台来源 Tab;从分类进入回分类,从首页进入回首页,从创作中心进入回创作中心。 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 竖屏首页能直接看到并使用搜索入口。 @@ -23,3 +34,5 @@ 5. 桌面右侧趋势列表只显示排序和作品类型,不再显示 `1777110165.990127Z` 这类原始时间字符串,也不直接显示作品号。 6. 在内嵌浏览器 Clipboard API 拒绝写入时,详情页与创作中心作品号复制仍能通过降级路径完成,并显示 `已复制` 或 `复制失败`。 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=作品号` 的完整网址。 diff --git a/src/App.tsx b/src/App.tsx index 83e0285b..42c4ca23 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { APP_RUNTIME_ROUTES, normalizeAppPath, pushAppHistoryPath, + readPublicWorkCodeFromLocationSearch, resolvePathForSelectionStage, resolveSelectionStageFromPath, } from './routing/appPageRoutes'; @@ -40,6 +41,9 @@ export default function App() { const [selectionStage, setRawSelectionStage] = useState(() => resolveSelectionStageFromPath(window.location.pathname), ); + const [initialPublicWorkCode] = useState(() => + readPublicWorkCodeFromLocationSearch(window.location.search), + ); const setSelectionStage = useCallback((stage: SelectionStage) => { setRawSelectionStage(stage); @@ -114,6 +118,7 @@ export default function App() { 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(null); const lastTouchSampleRef = useRef(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) => { if (event.target instanceof HTMLElement && event.target.closest('button')) { @@ -373,6 +404,29 @@ export function BigFishRuntimeShell({
+ {sharePublicWorkCode?.trim() ? ( + + ) : null} ) : null} + + <> + + + ) : null}
diff --git a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts index d3d5c2f4..16273c4c 100644 --- a/src/components/rpg-entry/useRpgEntryLibraryDetail.ts +++ b/src/components/rpg-entry/useRpgEntryLibraryDetail.ts @@ -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, diff --git a/src/routing/appPageRoutes.test.ts b/src/routing/appPageRoutes.test.ts index b3c68e8f..bde8fb7f 100644 --- a/src/routing/appPageRoutes.test.ts +++ b/src/routing/appPageRoutes.test.ts @@ -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', + ); + }); }); diff --git a/src/routing/appPageRoutes.ts b/src/routing/appPageRoutes.ts index f8f0f413..c8474c2b 100644 --- a/src/routing/appPageRoutes.ts +++ b/src/routing/appPageRoutes.ts @@ -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); }