合并 origin/master(51cb05f41,22 个提交)到 feat/game-purchase

- 逐块合并 server-rs/crates/api-server/src/modules/game_distribution.rs:以 master 的 ReleaseAssetResponseInput / 5 参 release_asset_response_with_cache / ETag / 304 / gzip 结构为准,并在其上保留本分支的付费 404 守卫(price_mud_points > 0 仍在 release_package_bytes 读包之前返回);播放会话资源响应改用同一结构(etag None、cache_control no-store、沿用 accept-encoding 协商)。
- 合并 src/components/game-distribution/GamePlayPage.tsx:接入 master 的共享 PlatformGameLoadingSurface 与 PLATFORM_GAME_LOADING_TIMEOUT_MS,删除本地重复常量 GAME_PLAY_STARTUP_TIMEOUT_MS 与裸 div,买断制播放会话准备态改由共享加载面承载。
- 合并 src/components/game-distribution/GameDetailPage.tsx:同时保留 master 的 onOpenCreator 作者插槽与本分支的买断制购买弹窗、onOpenRecharge 充值入口。
- 合并 vite.config.ts:保留 master 的 /api/creators 代理,并保留 play-sessions 前缀清 Cookie 规则(顺序仍在通用 /api/game-distribution 之前)。
- 合并 deploy/nginx/README.md:保留 master 的 SPA allowlist 门禁口径(含 /creators、/creators/connections)与本分支的播放会话前缀章节;三份 nginx 模板的 ^~ play-sessions location 与清 Cookie 原样保留。
- 合并 apps/admin-web/src/pages/AdminGameDistributionReviewPage.test.tsx:保留 master 的加载面/超时用例与本分支的审核价格(冻结价优先、历史按 0)用例。
- 合并 src/components/game-distribution/GameDistributionPages.test.tsx:保留双方 mock,并新增「付费作品在会话签发期间显示共享加载面」用例。
- 合并 docs/【玩法创作】平台入口与玩法链路-2026-05-15.md(保留买断制合同并回填 master 的创作者主页与关注粉丝合同)、decision-log.md、pitfalls.md:追加双方条目,不改写任一侧正文。
- 保留 master 侧新增能力:release_asset_etag / if_none_match_matches / accepts_gzip_encoding / gzip_release_asset / release_asset_not_modified_response、SPA 加载面、创作者主页与关注粉丝(user_follow 表、creator 查询与 author_id 过滤)。
This commit is contained in:
2026-10-06 00:28:36 +08:00
132 changed files with 15843 additions and 5874 deletions
@@ -6,6 +6,7 @@ import {
Palette,
Search,
UserRound,
UsersRound,
} from 'lucide-react';
import {
type ComponentType,
@@ -27,6 +28,11 @@ import {
pushAppHistoryPath,
replaceAppHistoryPath,
} from '../../routing/activeAppPageRoutes';
import {
connectionsPath,
type CreatorConnectionKind,
creatorPath,
} from '../../services/creatorProfileClient';
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
@@ -35,6 +41,7 @@ import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformSubpanel } from '../common/PlatformSubpanel';
import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry';
import { setCreatorViewer } from '../creator/creatorRelationshipState';
import { PaymentCheckoutView } from './PaymentCheckoutView';
import {
PlatformActiveMobileWelcomeDialog,
@@ -80,6 +87,14 @@ const GameGalleryPage = lazy(async () => {
return { default: module.GameGalleryPage };
});
const CreatorPage = lazy(async () => {
const module = await import('../creator/CreatorPage');
return { default: module.CreatorPage };
});
const CreatorConnectionsPage = lazy(async () => {
const module = await import('../creator/CreatorConnectionsPage');
return { default: module.CreatorConnectionsPage };
});
const GameDetailPage = lazy(async () => {
const module = await import('../game-distribution/GameDetailPage');
return { default: module.GameDetailPage };
@@ -181,16 +196,20 @@ function MobileProfileDock({
onOpenProfile,
gameActive,
onOpenGames,
creatorActive,
onOpenCreator,
}: {
active: boolean;
onOpenProfile: () => void;
gameActive: boolean;
onOpenGames: () => void;
creatorActive: boolean;
onOpenCreator: () => void;
}) {
return (
<div className="platform-mobile-bottom-dock min-w-0 shrink-0 lg:hidden">
<nav
className="platform-bottom-nav grid grid-cols-2"
className="platform-bottom-nav grid grid-cols-3"
aria-label="移动平台导航"
>
<ActiveBottomNavButton
@@ -199,6 +218,12 @@ function MobileProfileDock({
label="游戏"
onClick={onOpenGames}
/>
<ActiveBottomNavButton
active={creatorActive}
icon={UsersRound}
label="创作者主页"
onClick={onOpenCreator}
/>
<ActiveBottomNavButton
active={active}
icon={UserRound}
@@ -410,6 +435,42 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('profile-payment', { path: '/profile/payment' });
}, [setSelectionStage]);
const creatorViewer = authUi?.canAccessProtectedData
? (authUi.user?.id ?? null)
: null;
useEffect(() => {
setCreatorViewer(creatorViewer);
}, [creatorViewer]);
const openCreator = useCallback(
(id?: string) => {
const path = id ? creatorPath(id) : '/creators';
pushAppHistoryPath(path);
setSelectionStage('creator-home', { path });
},
[setSelectionStage],
);
const openCreatorConnections = useCallback(
(id: string, tab: CreatorConnectionKind) => {
const path = connectionsPath(id, tab);
pushAppHistoryPath(path);
setSelectionStage('creator-connections', { path });
},
[setSelectionStage],
);
const backFromCreator = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
const owner =
selectionStage === 'creator-connections'
? new URLSearchParams(window.location.search).get('id')
: null;
// 兜底返回替换当前条目,避免主页深链与本人主页互相新增历史。
const path = owner ? creatorPath(owner) : '/games';
replaceAppHistoryPath(path);
setSelectionStage(owner ? 'creator-home' : 'games', { path });
}, [selectionStage, setSelectionStage]);
const openGames = useCallback(() => {
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
@@ -561,6 +622,8 @@ export function PlatformEntryFlowShellImpl({
<MobileProfileDock
active={false}
gameActive={false}
creatorActive={false}
onOpenCreator={() => openCreator()}
onOpenProfile={openProfile}
onOpenGames={openGames}
/>
@@ -599,6 +662,9 @@ export function PlatformEntryFlowShellImpl({
const isCreationStage =
!isProfileStage &&
(selectionStage === 'platform' || selectionStage === 'creation-home');
const isCreatorStage =
selectionStage === 'creator-home' ||
selectionStage === 'creator-connections';
const isGamesStage =
selectionStage === 'games' ||
selectionStage === 'game-detail' ||
@@ -614,6 +680,7 @@ export function PlatformEntryFlowShellImpl({
typeof window === 'undefined' ? '' : window.location.search,
);
const gameId = gameSearchParams.get('id');
const creatorConnectionTab = gameSearchParams.get('tab');
const publishGameId = gameSearchParams.get('game');
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
const avatarLabel = resolveActiveUserAvatarLabel(authUi?.user);
@@ -677,6 +744,13 @@ export function PlatformEntryFlowShellImpl({
label="游戏"
onClick={openGames}
/>
<ActiveRailButton
active={isCreatorStage}
icon={UsersRound}
iconSrc="/creation-home/nav-profile.png"
label="创作者主页"
onClick={() => openCreator()}
/>
<ActiveRailButton
active={isProfileStage}
icon={UserRound}
@@ -829,6 +903,32 @@ export function PlatformEntryFlowShellImpl({
searchKeyword={activeSearchKeyword}
/>
</Suspense>
) : isCreatorStage ? (
<Suspense
fallback={<LoadingPanel label="正在加载创作者主页…" />}
>
{selectionStage === 'creator-connections' ? (
<CreatorConnectionsPage
id={gameId}
tab={
creatorConnectionTab === 'following' ||
creatorConnectionTab === 'followers'
? creatorConnectionTab
: null
}
onOpenCreator={openCreator}
onOpenConnections={openCreatorConnections}
onBack={backFromCreator}
/>
) : (
<CreatorPage
id={gameId}
onOpenConnections={openCreatorConnections}
onOpenGame={openGameDetail}
onBack={backFromCreator}
/>
)}
</Suspense>
) : isGamesStage ? (
<Suspense
fallback={<LoadingPanel label="正在加载游戏广场..." />}
@@ -867,6 +967,7 @@ export function PlatformEntryFlowShellImpl({
) : selectionStage === 'game-detail' ? (
<GameDetailPage
gameId={gameId}
onOpenCreator={openCreator}
ownerView={gameSearchParams.get('owner') === '1'}
onBack={backFromGameDetail}
onPlay={openGamePlay}
@@ -890,6 +991,8 @@ export function PlatformEntryFlowShellImpl({
<MobileProfileDock
active={isProfileStage}
gameActive={isGamesStage}
creatorActive={isCreatorStage}
onOpenCreator={() => openCreator()}
onOpenProfile={openProfile}
onOpenGames={openGames}
/>