合并 origin/master:保留 3D 契约与 provider checkpoint,旧玩法表随主线退役

- server-rs/crates/shared-contracts/src/lib.rs:采用主线的模块裁剪(删掉 cfg(any()) 遗留模块、补上 agc_analytics 与 game_distribution),同时保留本分支的 editor_canvas、model3d 模块与 EDITOR_GENERATION_OPERATION_KINDS 导出
- server-rs/crates/spacetime-module/src/migration.rs:主线删除的 410 行旧玩法表 normalize 段保持删除,保留本分支的 provider_kind / provider_task_id 兼容段与对应用例
- docs/【开发运维】本地开发验证与生产运维-2026-05-15.md:校验清单同时保留 Tripo 3D 生成任务与 editor_background_music_generation / model3d_text_to_model / model3d_image_to_model
- .gitignore:补回本分支新增的 3D 模型文件忽略规则(*.glb / *.gltf 等 12 行),压测数据段随主线一并删除
- 共享记忆:本分支的 3D 决策与踩坑条目保留在主线重排后的 decision-log.md 与 pitfalls.md 中
This commit is contained in:
2026-09-23 19:39:30 +08:00
997 changed files with 56264 additions and 80302 deletions
@@ -0,0 +1,298 @@
import './gameDistribution.css';
import './gameDistributionShowcase.css';
import {
ArrowLeft,
Gamepad2,
Maximize2,
Smartphone,
Sparkles,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
type GameDistributionGame,
type GameDistributionInputMode,
getGame,
} from '../../services/gameDistributionClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
normalizeGameEntryUrl,
normalizeGameId,
useIsMobileViewport,
} from './gameDistributionGuards';
import { GameCover } from './GameGalleryPage';
type GameDetailPageProps = {
gameId: string | null;
onBack: () => void;
onPlay: (gameId: string) => void;
};
const INPUT_MODE_LABELS: Record<GameDistributionInputMode, string> = {
keyboard: '键盘',
mouse: '鼠标',
touch: '触屏',
};
/**
* 游玩方式优先取版本冻结时声明的输入方式(公开投影里的 `inputModes`),
* 再退回版本自由文本 `controls`;两者都没有才显示“未标注操作方式”。
*/
function resolveControlsLabel(
version: GameDistributionGame['currentVersion'],
inputModes?: GameDistributionInputMode[],
) {
if (!version) return '暂未发布可玩版本';
const declared = Array.from(
new Set((inputModes ?? []).map((mode) => INPUT_MODE_LABELS[mode] ?? mode)),
).filter(Boolean);
if (declared.length > 0) return declared.join(' · ');
const controls = version.controls.join(' · ').trim();
return controls || '未标注操作方式';
}
/** 缩略图自己换签,失败时退回空占位,不阻塞 hero 与主行动作。 */
function GameShowcaseThumb({
objectKey,
label,
isActive,
onSelect,
}: {
objectKey: string;
label: string;
isActive: boolean;
onSelect: () => void;
}) {
const { resolvedUrl } = useResolvedAssetReadUrl(null, { objectKey });
return (
<button
type="button"
className="game-detail-thumb"
aria-pressed={isActive}
aria-label={label}
onClick={onSelect}
>
{resolvedUrl ? (
<img src={resolvedUrl} alt="" loading="lazy" decoding="async" />
) : (
<span className="game-detail-thumb__placeholder" />
)}
</button>
);
}
/**
* 详情页封面区:默认展示封面,存在截图时在下方给出可点击的缩略图条。
*
* 缩略图第一项固定是封面本身,方便作者/玩家从截图切回封面;只有封面时整条不渲染。
*/
function GameShowcase({ game }: { game: GameDistributionGame }) {
const coverKey = (game.coverObjectKey ?? '').trim();
const screenshotKeys = (game.screenshots ?? [])
.map((key) => key.trim())
.filter(Boolean)
.slice(0, 6);
const items = [
...(coverKey ? [{ key: coverKey, label: '封面' }] : []),
...screenshotKeys.map((key, index) => ({
key,
label: `截图 ${index + 1}`,
})),
];
const [activeIndex, setActiveIndex] = useState(0);
const activeKey = items[activeIndex]?.key ?? coverKey;
return (
<div className="game-detail-showcase">
<GameCover game={game} priority objectKey={activeKey} />
{items.length > 1 ? (
<div className="game-detail-thumbs" role="group" aria-label="游戏截图">
{items.map((item, index) => (
<GameShowcaseThumb
key={item.key}
objectKey={item.key}
label={`${item.label}(查看大图)`}
isActive={index === activeIndex}
onSelect={() => setActiveIndex(index)}
/>
))}
</div>
) : null}
</div>
);
}
export function GameDetailPage({
gameId,
onBack,
onPlay,
}: GameDetailPageProps) {
const [game, setGame] = useState<GameDistributionGame | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const requestSerialRef = useRef(0);
const isMobileViewport = useIsMobileViewport();
const loadGame = useCallback(() => {
const requestSerial = ++requestSerialRef.current;
const normalizedGameId = normalizeGameId(gameId);
setGame(null);
setError('');
if (!normalizedGameId) {
setIsLoading(false);
setError('游戏编号无效');
return;
}
setIsLoading(true);
void getGame(normalizedGameId)
.then((value) => {
if (requestSerial !== requestSerialRef.current) return;
if (value?.currentVersion) {
const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl);
if (!entryUrl) {
setError('游戏入口地址无效');
return;
}
setGame({
...value,
currentVersion: { ...value.currentVersion, entryUrl },
});
return;
}
setGame(value);
if (!value) setError('找不到这款游戏');
})
.catch((loadError: unknown) => {
if (requestSerial !== requestSerialRef.current) return;
setError(
loadError instanceof Error ? loadError.message : '游戏详情加载失败',
);
})
.finally(() => {
if (requestSerial === requestSerialRef.current) setIsLoading(false);
});
}, [gameId]);
useEffect(() => {
void loadGame();
return () => {
requestSerialRef.current += 1;
};
}, [loadGame]);
if (isLoading)
return (
<div className="game-detail-page">
<div className="game-loading-panel">正在加载游戏详情…</div>
</div>
);
if (error || !game)
return (
<div className="game-detail-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回游戏广场
</button>
<PlatformStatusMessage
tone="error"
surface="platform"
className="game-status-message"
>
<span>{error || '找不到这款游戏'}</span>
<button type="button" onClick={loadGame}>
重新加载
</button>
</PlatformStatusMessage>
</div>
);
const version = game.currentVersion;
const mobilePlaybackBlocked = isMobileViewport && !game.deviceSupport.mobile;
const playbackBlocked = !version || mobilePlaybackBlocked;
return (
<div className="game-detail-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回游戏广场
</button>
<section className="game-detail-hero">
<GameShowcase game={game} />
<div className="game-detail-hero__copy">
<span className="game-eyebrow">
<Sparkles aria-hidden="true" /> {game.category}
</span>
<h1>{game.title}</h1>
<p className="game-detail-hero__summary">{game.summary}</p>
<div className="game-detail-hero__author">
<span className="game-avatar">{game.author.name.slice(0, 1)}</span>
<span>由 {game.author.name} 制作</span>
</div>
<div className="game-detail-hero__actions">
<PlatformActionButton
size="lg"
shape="pill"
onClick={() => onPlay(game.id)}
disabled={playbackBlocked}
aria-describedby={
mobilePlaybackBlocked
? 'game-mobile-playback-warning'
: undefined
}
>
<Gamepad2 /> 立即玩
</PlatformActionButton>
{mobilePlaybackBlocked ? (
<span
id="game-mobile-playback-warning"
className="game-device-warning"
role="status"
>
请在电脑上游玩
</span>
) : null}
<span className="game-detail-hero__play-count">
{game.playCount.toLocaleString()} 次游玩
</span>
</div>
</div>
</section>
<section className="game-detail-body">
<div className="game-detail-main">
<h2>关于这款游戏</h2>
<p>{game.description}</p>
<div className="game-tag-list">
{game.tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</div>
<aside className="game-detail-side">
<div className="game-info-card">
<strong>游玩方式</strong>
<span>{resolveControlsLabel(version, game.inputModes)}</span>
</div>
<div className="game-info-card">
<strong>设备支持</strong>
<span>
<Smartphone aria-hidden="true" />{' '}
{game.deviceSupport.mobile ? '移动端' : ''}
{game.deviceSupport.desktop ? ' · 桌面端' : ''}
{game.deviceSupport.touch ? ' · 支持触屏' : ''}
</span>
</div>
<div className="game-info-card">
<strong>当前版本</strong>
<span>{version?.version || '—'}</span>
</div>
</aside>
</section>
<p className="game-detail-footnote">
<Maximize2 aria-hidden="true" />{' '}
游玩时可以切换全屏,游戏运行在独立的安全容器中。
</p>
</div>
);
}
export default GameDetailPage;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,419 @@
import './gameDistribution.css';
import './gameDistributionShowcase.css';
import { Gamepad2, Monitor, Search, Smartphone, Sparkles } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
type GameCategory,
type GameDistributionGame,
listGames,
} from '../../services/gameDistributionClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
GAME_GALLERY_CATEGORIES as CATEGORIES,
GAME_GALLERY_DEVICE_FILTERS as DEVICE_FILTERS,
GAME_GALLERY_SCROLL_CONTAINER_SELECTOR,
type GameDeviceFilter,
type GameGalleryFilters,
gameGalleryScrollKey,
matchesGameDeviceFilter,
readGameGalleryQueryFilters,
readStoredGameGalleryFilters,
readStoredGameGalleryScrollTop,
syncGameGalleryQuery,
writeStoredGameGalleryFilters,
writeStoredGameGalleryScrollTop,
} from './gameGalleryViewState';
type GameGalleryPageProps = {
searchKeyword?: string;
onOpenDetail: (gameId: string) => void;
onOpenMyGames?: () => void;
onOpenPublish?: () => void;
};
/**
* 游戏封面。
*
* 有平台封面素材时换签名地址后显示真实封面,未上传、换签失败或还在解析时保留原有的
* 渐变色 + 图标占位,保证首屏不出现空框或布局跳动;`objectKey` 允许详情页把封面位临时
* 展示成选中的截图。
*/
export function GameCover({
game,
compact = false,
priority = false,
objectKey,
}: {
game: GameDistributionGame;
compact?: boolean;
/** 首屏 hero 用的封面立即加载,列表卡片保持延迟加载。 */
priority?: boolean;
objectKey?: string | null;
}) {
const targetKey = (objectKey ?? game.coverObjectKey ?? '').trim();
const { resolvedUrl } = useResolvedAssetReadUrl(null, {
objectKey: targetKey,
});
return (
<div
className={`game-cover ${compact ? 'game-cover--compact' : ''}`}
style={{
background: `linear-gradient(145deg, ${game.coverColor}, #16151c)`,
}}
aria-hidden="true"
>
{resolvedUrl ? (
<img
className="game-cover__image"
src={resolvedUrl}
alt=""
loading={priority ? 'eager' : 'lazy'}
decoding="async"
/>
) : (
<>
<span className="game-cover__glow" />
<span className="game-cover__icon">{game.icon}</span>
<span className="game-cover__grid" />
</>
)}
</div>
);
}
function GameCard({
game,
onOpen,
}: {
game: GameDistributionGame;
onOpen: () => void;
}) {
return (
<button type="button" className="game-card" onClick={onOpen}>
<GameCover game={game} />
<span className="game-card__body">
<span className="game-card__title-row">
<strong>{game.title}</strong>
<span className="game-card__play-count">
{game.playCount.toLocaleString()} 次游玩
</span>
</span>
<span className="game-card__summary">{game.summary}</span>
<span className="game-card__meta">
<span>{game.category}</span>
<span>·</span>
<span>{game.author.name}</span>
</span>
</span>
</button>
);
}
export function GameGalleryPage({
searchKeyword = '',
onOpenDetail,
onOpenMyGames,
onOpenPublish,
}: GameGalleryPageProps) {
const initialFiltersRef = useRef<GameGalleryFilters | null>(null);
if (initialFiltersRef.current === null) {
const queryFilters =
typeof window === 'undefined'
? {}
: readGameGalleryQueryFilters(window.location.search);
const storedFilters = readStoredGameGalleryFilters();
initialFiltersRef.current = {
keyword: queryFilters.keyword ?? storedFilters.keyword ?? searchKeyword,
category: queryFilters.category ?? storedFilters.category ?? '全部',
device: queryFilters.device ?? storedFilters.device ?? '全部',
};
}
const initialFilters = initialFiltersRef.current;
const [category, setCategory] = useState<GameCategory | '全部'>(
initialFilters.category,
);
const [device, setDevice] = useState<GameDeviceFilter>(initialFilters.device);
const [keywordInput, setKeywordInput] = useState(initialFilters.keyword);
const [activeKeyword, setActiveKeyword] = useState(initialFilters.keyword);
const [games, setGames] = useState<GameDistributionGame[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const requestSerialRef = useRef(0);
const scrollContainerRef = useRef<HTMLElement | null>(null);
const hasRestoredScrollRef = useRef(false);
const didSyncPropKeywordRef = useRef(false);
const filters = useMemo<GameGalleryFilters>(
() => ({ keyword: activeKeyword, category, device }),
[activeKeyword, category, device],
);
const scrollKey = gameGalleryScrollKey(filters);
const attachPageRoot = useCallback((node: HTMLDivElement | null) => {
if (!node) return;
const container = node.closest(GAME_GALLERY_SCROLL_CONTAINER_SELECTOR);
scrollContainerRef.current =
container instanceof HTMLElement ? container : null;
}, []);
const loadGames = useCallback(() => {
const requestSerial = ++requestSerialRef.current;
setIsLoading(true);
setError('');
void listGames({ keyword: activeKeyword, category })
.then((nextGames) => {
if (requestSerial !== requestSerialRef.current) return;
setGames(nextGames);
})
.catch((loadError: unknown) => {
if (requestSerial !== requestSerialRef.current) return;
setError(
loadError instanceof Error ? loadError.message : '游戏目录加载失败',
);
})
.finally(() => {
if (requestSerial === requestSerialRef.current) setIsLoading(false);
});
}, [activeKeyword, category]);
useEffect(() => {
if (!didSyncPropKeywordRef.current) {
didSyncPropKeywordRef.current = true;
return;
}
setKeywordInput(searchKeyword);
setActiveKeyword(searchKeyword);
}, [searchKeyword]);
useEffect(() => {
void loadGames();
return () => {
requestSerialRef.current += 1;
};
}, [loadGames]);
useEffect(() => {
writeStoredGameGalleryFilters(filters);
syncGameGalleryQuery(filters);
}, [filters]);
useEffect(() => {
hasRestoredScrollRef.current = false;
}, [scrollKey]);
useEffect(() => {
if (hasRestoredScrollRef.current || isLoading || games.length === 0) return;
hasRestoredScrollRef.current = true;
const container = scrollContainerRef.current;
const storedScrollTop = readStoredGameGalleryScrollTop(scrollKey);
if (!container || storedScrollTop <= 0) return;
container.scrollTop = storedScrollTop;
}, [games.length, isLoading, scrollKey]);
useEffect(() => {
const key = scrollKey;
return () => {
const container = scrollContainerRef.current;
if (container) writeStoredGameGalleryScrollTop(key, container.scrollTop);
};
}, [scrollKey]);
const visibleGames = useMemo(
() => games.filter((game) => matchesGameDeviceFilter(game, device)),
[device, games],
);
const featuredGame = useMemo(() => visibleGames[0] ?? null, [visibleGames]);
const hasActiveFilters =
activeKeyword.trim().length > 0 || category !== '全部' || device !== '全部';
const resetFilters = () => {
setKeywordInput('');
setActiveKeyword('');
setCategory('全部');
setDevice('全部');
};
const openDetail = useCallback(
(gameId: string) => {
const container = scrollContainerRef.current;
if (container)
writeStoredGameGalleryScrollTop(scrollKey, container.scrollTop);
onOpenDetail(gameId);
},
[onOpenDetail, scrollKey],
);
return (
<div className="game-page" ref={attachPageRoot}>
<section className="game-hero">
<div className="game-hero__copy">
<span className="game-eyebrow">
<Sparkles aria-hidden="true" /> 陶泥儿游戏分发
</span>
<h1>
把灵感做成游戏,
<br />
<em>现在就开始玩。</em>
</h1>
<p>
来自 AGC
创作者的轻量游戏集合。打开网页即可游玩,每一次尝试都值得被看见。
</p>
<div className="game-hero__actions">
<PlatformActionButton
size="md"
shape="pill"
onClick={() => featuredGame && openDetail(featuredGame.id)}
>
<Gamepad2 aria-hidden="true" /> 立即试玩
</PlatformActionButton>
<span className="game-hero__hint">支持桌面端与移动端</span>
</div>
</div>
<div className="game-hero__orb" aria-hidden="true">
<span>✦</span>
</div>
</section>
<div className="game-toolbar">
<div className="game-toolbar__title">
<span className="game-eyebrow">发现好玩的</span>
<h2>游戏广场</h2>
</div>
<div className="game-toolbar__actions">
{onOpenPublish ? (
<PlatformActionButton
size="sm"
shape="pill"
onClick={onOpenPublish}
>
发布游戏
</PlatformActionButton>
) : null}
{onOpenMyGames ? (
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
onClick={onOpenMyGames}
>
我的游戏
</PlatformActionButton>
) : null}
<label className="game-search">
<Search aria-hidden="true" />
<input
value={keywordInput}
placeholder="搜索游戏、作者或标签"
aria-label="搜索游戏"
onChange={(event) => {
const nextKeyword = event.target.value;
setKeywordInput(nextKeyword);
setActiveKeyword(nextKeyword);
}}
/>
</label>
</div>
</div>
<div className="game-category-tabs" role="tablist" aria-label="游戏分类">
{CATEGORIES.map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={category === item}
className={
category === item
? 'game-category-tab game-category-tab--active'
: 'game-category-tab'
}
onClick={() => setCategory(item)}
>
{item}
</button>
))}
</div>
<div
className="game-category-tabs game-category-tabs--device"
role="tablist"
aria-label="设备筛选"
>
{DEVICE_FILTERS.map((item) => (
<button
key={item}
type="button"
role="tab"
aria-selected={device === item}
className={
device === item
? 'game-category-tab game-category-tab--active'
: 'game-category-tab'
}
onClick={() => setDevice(item)}
>
{item === '桌面' ? <Monitor aria-hidden="true" /> : null}
{item === '移动' ? <Smartphone aria-hidden="true" /> : null}
{item}
</button>
))}
</div>
{isLoading ? (
<div className="game-loading-grid" aria-label="正在加载游戏">
<span />
<span />
<span />
</div>
) : null}
{!isLoading && error ? (
<PlatformStatusMessage
tone="error"
surface="platform"
className="game-status-message"
>
<span>{error}</span>
<button type="button" onClick={loadGames}>
重新加载
</button>
</PlatformStatusMessage>
) : null}
{!isLoading && !error && visibleGames.length === 0 ? (
<PlatformEmptyState surface="subpanel" size="panel">
<strong>还没有找到对应游戏</strong>
<br />
<span>
{hasActiveFilters
? '换个关键词、分类或设备条件再试试。'
: '新的游戏正在路上,稍后再来看看。'}
</span>
{hasActiveFilters ? (
<div className="game-empty-actions">
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
onClick={resetFilters}
>
重置筛选
</PlatformActionButton>
</div>
) : null}
</PlatformEmptyState>
) : null}
{!isLoading && !error && visibleGames.length > 0 ? (
<div className="game-grid">
{visibleGames.map((game) => (
<GameCard
key={game.id}
game={game}
onOpen={() => openDetail(game.id)}
/>
))}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,247 @@
import './gameDistribution.css';
import {
ArrowLeft,
Expand,
Gamepad2,
RotateCcw,
Smartphone,
} from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
type GameDistributionGame,
getGame,
} from '../../services/gameDistributionClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import {
normalizeGameEntryUrl,
normalizeGameId,
useIsMobileViewport,
useIsPortraitViewport,
} from './gameDistributionGuards';
import { GameCover } from './GameGalleryPage';
type GamePlayPageProps = { gameId: string | null; onBack: () => void };
/** 发行包首屏加载上限:超过后给出显式超时与重试,不用无限期“正在启动”。 */
export const GAME_PLAY_STARTUP_TIMEOUT_MS = 20_000;
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout';
export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
const frameRef = useRef<HTMLIFrameElement>(null);
const [game, setGame] = useState<GameDistributionGame | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [startupState, setStartupState] = useState<GameStartupState>('idle');
const [hasStarted, setHasStarted] = useState(false);
const [frameRunId, setFrameRunId] = useState(0);
const [error, setError] = useState('');
const requestSerialRef = useRef(0);
const isMobileViewport = useIsMobileViewport();
const isPortraitViewport = useIsPortraitViewport();
const loadGame = useCallback(() => {
const requestSerial = ++requestSerialRef.current;
const normalizedGameId = normalizeGameId(gameId);
setGame(null);
setHasStarted(false);
setStartupState('idle');
setError('');
if (!normalizedGameId) {
setError('缺少游戏编号');
setIsLoading(false);
return;
}
setIsLoading(true);
void getGame(normalizedGameId)
.then((value) => {
if (requestSerial !== requestSerialRef.current) return;
if (!value?.currentVersion?.entryUrl) {
throw new Error('这款游戏暂时没有可玩的版本');
}
const entryUrl = normalizeGameEntryUrl(value.currentVersion.entryUrl);
if (!entryUrl) throw new Error('游戏入口地址无效');
setGame({
...value,
currentVersion: { ...value.currentVersion, entryUrl },
});
})
.catch((loadError: unknown) => {
if (requestSerial !== requestSerialRef.current) return;
setError(
loadError instanceof Error ? loadError.message : '游戏加载失败',
);
})
.finally(() => {
if (requestSerial === requestSerialRef.current) setIsLoading(false);
});
}, [gameId]);
useEffect(() => {
void loadGame();
return () => {
requestSerialRef.current += 1;
};
}, [loadGame]);
useEffect(() => {
if (!hasStarted || startupState !== 'loading') return undefined;
const timeoutId = window.setTimeout(() => {
setStartupState((current) =>
current === 'loading' ? 'timeout' : current,
);
}, GAME_PLAY_STARTUP_TIMEOUT_MS);
return () => window.clearTimeout(timeoutId);
}, [hasStarted, startupState]);
const enterFullscreen = () => {
void frameRef.current?.requestFullscreen?.();
};
const mobilePlaybackBlocked = isMobileViewport && !game?.deviceSupport.mobile;
const showRotationHint =
Boolean(game) && game?.orientation === 'landscape' && isPortraitViewport;
const startGame = () => {
if (mobilePlaybackBlocked) return;
setHasStarted(true);
setStartupState('loading');
setFrameRunId((runId) => runId + 1);
};
/** 重试只重挂载发行页,不改变已开始的会话,也不会在退出后自动重启。 */
const retryGame = () => {
setStartupState('loading');
setFrameRunId((runId) => runId + 1);
};
if (isLoading)
return (
<div className="game-play-page">
<div className="game-loading-panel">正在准备游戏…</div>
</div>
);
if (error || !game?.currentVersion)
return (
<div className="game-play-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回详情
</button>
<PlatformStatusMessage
tone="error"
surface="platform"
className="game-status-message"
>
<span>{error || '游戏版本不可用'}</span>
<button type="button" onClick={loadGame}>
重新加载
</button>
</PlatformStatusMessage>
</div>
);
return (
<div className="game-play-page">
<div className="game-play-toolbar">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> {game.title}
</button>
<div className="game-play-toolbar__actions">
<span className="game-play-device-hint">
<Smartphone aria-hidden="true" />{' '}
{game.deviceSupport.touch ? '支持触屏' : '桌面端游戏'}
</span>
<button
type="button"
className="game-icon-button"
aria-label="重新加载游戏"
onClick={loadGame}
>
<RotateCcw />
</button>
<PlatformActionButton
size="sm"
shape="pill"
onClick={enterFullscreen}
disabled={!hasStarted}
>
<Expand /> 全屏
</PlatformActionButton>
</div>
</div>
{showRotationHint ? (
<p className="game-device-warning" role="status">
这款游戏以横屏设计,旋转设备即可获得完整画面。
</p>
) : null}
<div className="game-player-shell">
{!hasStarted ? (
<div className="game-player-launch">
<GameCover game={game} compact />
<div className="game-player-launch__copy">
<h1>{game.title}</h1>
<p>{game.summary}</p>
{mobilePlaybackBlocked ? (
<p
className="game-device-warning"
role="status"
id="game-play-mobile-warning"
>
请在电脑上游玩
</p>
) : null}
<PlatformActionButton
size="lg"
shape="pill"
onClick={startGame}
disabled={mobilePlaybackBlocked}
aria-describedby={
mobilePlaybackBlocked ? 'game-play-mobile-warning' : undefined
}
>
<Gamepad2 aria-hidden="true" /> 开始游戏
</PlatformActionButton>
</div>
</div>
) : null}
{hasStarted && startupState === 'loading' ? (
<div className="game-player-loading">游戏正在启动…</div>
) : null}
{hasStarted && startupState === 'timeout' ? (
<PlatformStatusMessage
tone="error"
surface="platform"
className="game-status-message game-player-timeout"
>
<span>游戏启动超时,可能是网络较慢或发行包暂时不可用。</span>
<button type="button" onClick={retryGame}>
重试
</button>
<button type="button" onClick={onBack}>
返回详情
</button>
</PlatformStatusMessage>
) : null}
{hasStarted ? (
<iframe
ref={frameRef}
key={`${game.currentVersion.id}:${frameRunId}`}
title={`${game.title} 在线游玩`}
src={game.currentVersion.entryUrl}
className={`game-player-frame ${startupState === 'ready' ? 'game-player-frame--ready' : ''}`}
sandbox="allow-scripts"
allow="fullscreen"
referrerPolicy="no-referrer"
onLoad={() => setStartupState('ready')}
/>
) : null}
</div>
<p className="game-play-safety-note">
游戏运行在隔离容器中,不会读取你的账号信息或平台数据。
</p>
</div>
);
}
export default GamePlayPage;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,252 @@
/* @vitest-environment jsdom */
import { render, screen, waitFor } from '@testing-library/react';
import { fireEvent } from '@testing-library/react';
import type { ContextType } from 'react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
cancelGameVersion,
type GameDistributionMyGame,
listMyGames,
unpublishGame,
} from '../../services/gameDistributionClient';
import { AuthUiContext } from '../auth/AuthUiContext';
import { MyGamesPage } from './MyGamesPage';
vi.mock('../../services/gameDistributionClient', () => ({
cancelGameVersion: vi.fn(),
listMyGames: vi.fn(),
unpublishGame: vi.fn(),
}));
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
return {
user: {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试作者',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: vi.fn((action: () => void) => action()),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(),
musicVolume: 0.5,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
...overrides,
};
}
function createGame(
overrides: Partial<GameDistributionMyGame> = {},
): GameDistributionMyGame {
return {
id: 'game-1',
title: '我的游戏',
summary: '作者自有游戏',
description: '',
category: '益智',
tags: [],
coverColor: '#49316d',
icon: '✦',
author: { id: 'user-1', name: '测试作者' },
deviceSupport: { desktop: true, mobile: false, touch: false },
status: 'unpublished',
publicationRevision: 2,
currentVersion: null,
playCount: 3,
createdAt: '2026-09-20T08:00:00Z',
...overrides,
} as GameDistributionMyGame;
}
function renderPage(
authValue: AuthValue | null,
overrides: { onPublishVersion?: (gameId: string) => void } = {},
) {
return render(
<AuthUiContext.Provider value={authValue}>
<MyGamesPage
onBack={vi.fn()}
onOpenDetail={vi.fn()}
onPublishVersion={overrides.onPublishVersion}
/>
</AuthUiContext.Provider>,
);
}
beforeEach(() => {
vi.mocked(cancelGameVersion).mockReset();
vi.mocked(listMyGames).mockReset();
vi.mocked(unpublishGame).mockReset();
vi.mocked(listMyGames).mockResolvedValue([]);
});
test('未登录时提示登录且不请求作者接口', () => {
renderPage(null);
expect(
screen.getByText('登录后可以查看已发布的游戏与审核状态。'),
).toBeTruthy();
expect(listMyGames).not.toHaveBeenCalled();
});
test('展示审核中状态与驳回理由', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({
status: 'unpublished',
latestVersion: {
versionId: 'version-1',
gameId: 'game-1',
versionNumber: 3,
packageSha256: 'a'.repeat(64),
packageBytes: 1024,
status: 'rejected',
publicationRevision: 2,
reviewReason: '封面需要替换',
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
versions: [],
}),
]);
renderPage(createAuthValue());
expect(await screen.findByText('我的游戏')).toBeTruthy();
expect(screen.getByText(/版本 v3 · 已拒绝/)).toBeTruthy();
expect(screen.getByText('驳回理由:封面需要替换')).toBeTruthy();
});
test('下架已公开游戏会带上当前 publicationRevision', async () => {
const game = createGame({
status: 'published',
publicationRevision: 7,
latestVersion: null,
versions: [],
});
vi.mocked(listMyGames).mockResolvedValue([game]);
vi.mocked(unpublishGame).mockResolvedValue({
game: { id: 'game-1', status: 'unpublished' },
});
renderPage(createAuthValue());
fireEvent.click(await screen.findByRole('button', { name: '下架' }));
await waitFor(() => expect(unpublishGame).toHaveBeenCalledTimes(1));
const [gameId, revision, idempotencyKey] =
vi.mocked(unpublishGame).mock.calls[0] ?? [];
expect(gameId).toBe('game-1');
expect(revision).toBe(7);
expect(String(idempotencyKey)).toContain('game-1');
await waitFor(() => expect(listMyGames).toHaveBeenCalledTimes(2));
});
test('未公开游戏不提供下架动作', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ status: 'unpublished' }),
]);
renderPage(createAuthValue());
await screen.findByText('我的游戏');
expect(screen.queryByRole('button', { name: '下架' })).toBeNull();
});
test('审核中的版本二次确认后撤回并带上 publicationRevision', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({
status: 'unpublished',
publicationRevision: 5,
latestVersion: {
versionId: 'version-9',
gameId: 'game-1',
versionNumber: 2,
packageSha256: 'b'.repeat(64),
packageBytes: 2048,
status: 'pending_review',
publicationRevision: 5,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
versions: [],
}),
]);
vi.mocked(cancelGameVersion).mockResolvedValue({
game: {} as never,
version: {} as never,
replayed: false,
});
renderPage(createAuthValue());
fireEvent.click(await screen.findByRole('button', { name: '撤回审核' }));
// 首次点击只进入确认态,不发送请求。
expect(cancelGameVersion).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: '取消' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '确认撤回' }));
await waitFor(() => expect(cancelGameVersion).toHaveBeenCalledTimes(1));
const [versionId, revision, idempotencyKey] =
vi.mocked(cancelGameVersion).mock.calls[0] ?? [];
expect(versionId).toBe('version-9');
expect(revision).toBe(5);
expect(String(idempotencyKey)).toContain('version-9');
await waitFor(() => expect(listMyGames).toHaveBeenCalledTimes(2));
});
test('已公开版本只能下架,不显示撤回审核', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({
status: 'published',
publicationRevision: 4,
latestVersion: {
versionId: 'version-3',
gameId: 'game-1',
versionNumber: 3,
packageSha256: 'c'.repeat(64),
packageBytes: 4096,
status: 'published',
publicationRevision: 4,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
versions: [],
}),
]);
renderPage(createAuthValue());
await screen.findByText('我的游戏');
expect(screen.queryByRole('button', { name: '撤回审核' })).toBeNull();
expect(screen.getByRole('button', { name: '下架' })).toBeTruthy();
});
test('作者中心提供发布新版本入口并回传同一个 gameId', async () => {
vi.mocked(listMyGames).mockResolvedValue([
createGame({ id: 'game-42', status: 'published', publicationRevision: 3 }),
]);
const onPublishVersion = vi.fn();
renderPage(createAuthValue(), { onPublishVersion });
fireEvent.click(await screen.findByRole('button', { name: '发布新版本' }));
expect(onPublishVersion).toHaveBeenCalledWith('game-42');
});
@@ -0,0 +1,346 @@
import './gameDistribution.css';
import { ArrowLeft, RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
cancelGameVersion,
type GameDistributionMyGame,
type GameDistributionVersionStatusEntry,
listMyGames,
unpublishGame,
} from '../../services/gameDistributionClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { GameCover } from './GameGalleryPage';
const STATUS_LABEL: Record<string, string> = {
unpublished: '未公开',
published: '已公开',
suspended: '已被封禁',
};
const VERSION_STATUS_LABEL: Record<string, string> = {
awaiting_upload: '待上传',
uploaded: '已上传',
validating: '校验中',
pending_review: '审核中',
published: '已发布',
upload_failed: '上传失败',
validation_failed: '校验失败',
rejected: '已拒绝',
cancelled: '已撤回',
revoked: '已撤回',
};
type MyGamesPageProps = {
onBack: () => void;
onOpenDetail: (gameId: string) => void;
/** 进入发布页的“新版本”模式;回调缺失时不显示该动作。 */
onPublishVersion?: (gameId: string) => void;
};
let unpublishKeySeed = 0;
function createUnpublishKey(gameId: string) {
unpublishKeySeed += 1;
return `game-unpublish-${gameId}-${Date.now()}-${unpublishKeySeed}`;
}
/** 与服务端 `can_cancel` 保持一致:已公开版本只能下架,不能撤回。 */
const CANCELLABLE_VERSION_STATUS = new Set([
'awaiting_upload',
'uploaded',
'validating',
'pending_review',
'upload_failed',
'validation_failed',
]);
let cancelKeySeed = 0;
function createCancelKey(versionId: string) {
cancelKeySeed += 1;
return `game-cancel-${versionId}-${Date.now()}-${cancelKeySeed}`;
}
function resolveVersionStatus(game: GameDistributionMyGame) {
const latest = game.latestVersion;
if (!latest) return null;
return {
label: VERSION_STATUS_LABEL[latest.status] ?? latest.status,
versionNumber: latest.versionNumber,
reviewReason: latest.reviewReason?.trim() || '',
};
}
export function MyGamesPage({
onBack,
onOpenDetail,
onPublishVersion,
}: MyGamesPageProps) {
const authUi = useAuthUi();
const [games, setGames] = useState<GameDistributionMyGame[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [busyGameId, setBusyGameId] = useState('');
const [cancellingVersionId, setCancellingVersionId] = useState('');
const [confirmingVersionId, setConfirmingVersionId] = useState('');
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
const canLoad = Boolean(authUi?.user && authUi.canAccessProtectedData);
const loadGames = useCallback(async () => {
if (!canLoad) {
setGames([]);
return;
}
setIsLoading(true);
setError('');
try {
setGames(await listMyGames());
} catch (loadError: unknown) {
setError(
loadError instanceof Error ? loadError.message : '读取我的游戏失败',
);
} finally {
setIsLoading(false);
}
}, [canLoad]);
useEffect(() => {
void loadGames();
}, [loadGames]);
async function handleCancelVersion(
game: GameDistributionMyGame,
version: GameDistributionVersionStatusEntry,
) {
setCancellingVersionId(version.versionId);
setConfirmingVersionId('');
setError('');
setNotice('');
try {
await cancelGameVersion(
version.versionId,
version.publicationRevision,
createCancelKey(version.versionId),
);
setNotice(`版本 v${version.versionNumber} 已撤回,可以重新上传发行包。`);
await loadGames();
} catch (cancelError: unknown) {
setError(
cancelError instanceof Error ? cancelError.message : '撤回版本失败',
);
} finally {
setCancellingVersionId('');
}
}
async function handleUnpublish(game: GameDistributionMyGame) {
setBusyGameId(game.id);
setError('');
try {
await unpublishGame(
game.id,
game.publicationRevision,
createUnpublishKey(game.id),
);
await loadGames();
} catch (unpublishError: unknown) {
setError(
unpublishError instanceof Error
? unpublishError.message
: '下架游戏失败',
);
} finally {
setBusyGameId('');
}
}
return (
<div className="game-page">
<button type="button" className="game-back-button" onClick={onBack}>
<ArrowLeft /> 返回游戏广场
</button>
<div className="game-toolbar">
<div className="game-toolbar__title">
<span className="game-eyebrow">作者中心</span>
<h2>我的游戏</h2>
</div>
{canLoad ? (
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
onClick={() => void loadGames()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" /> 刷新
</PlatformActionButton>
) : null}
</div>
{error ? (
<PlatformStatusMessage tone="error" surface="platform">
{error}
</PlatformStatusMessage>
) : null}
{notice ? (
<PlatformStatusMessage tone="success" surface="platform">
{notice}
</PlatformStatusMessage>
) : null}
{!canLoad ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>登录后可以查看已发布的游戏与审核状态。</span>
<PlatformActionButton
size="sm"
shape="pill"
onClick={() => authUi?.openLoginModal(() => void loadGames())}
>
登录
</PlatformActionButton>
</PlatformEmptyState>
) : null}
{canLoad && isLoading ? (
<div className="game-loading-panel">正在加载我的游戏…</div>
) : null}
{canLoad && !isLoading && games.length === 0 && !error ? (
<PlatformEmptyState surface="subpanel" size="panel">
<span>还没有在平台上发布过游戏。</span>
</PlatformEmptyState>
) : null}
{canLoad && games.length > 0 ? (
<div className="my-game-list">
{games.map((game) => {
const version = resolveVersionStatus(game);
const latestVersion = game.latestVersion;
return (
<article key={game.id} className="my-game-card">
<GameCover game={game} compact />
<div className="my-game-card__body">
<div className="my-game-card__heading">
<button
type="button"
className="my-game-card__title"
onClick={() => onOpenDetail(game.id)}
>
{game.title}
</button>
<span
className={`my-game-status my-game-status--${game.status}`}
>
{STATUS_LABEL[game.status] ?? game.status}
</span>
</div>
<p className="my-game-card__summary">{game.summary}</p>
<div className="my-game-card__meta">
<span>{game.category}</span>
<span>·</span>
<span>{game.playCount.toLocaleString()} 次游玩</span>
{version ? (
<>
<span>·</span>
<span>
版本 v{version.versionNumber} · {version.label}
</span>
</>
) : null}
</div>
{version?.reviewReason ? (
<p className="my-game-card__reason">
驳回理由:{version.reviewReason}
</p>
) : null}
<div className="my-game-card__actions">
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
onClick={() => onOpenDetail(game.id)}
>
查看详情
</PlatformActionButton>
{onPublishVersion ? (
<PlatformActionButton
tone="primary"
size="sm"
shape="pill"
onClick={() => onPublishVersion(game.id)}
>
发布新版本
</PlatformActionButton>
) : null}
{game.status === 'published' ? (
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
disabled={busyGameId === game.id}
onClick={() => void handleUnpublish(game)}
>
下架
</PlatformActionButton>
) : null}
{latestVersion &&
CANCELLABLE_VERSION_STATUS.has(latestVersion.status) ? (
confirmingVersionId === latestVersion.versionId ? (
<>
<PlatformActionButton
tone="danger"
size="sm"
shape="pill"
disabled={
cancellingVersionId === latestVersion.versionId
}
onClick={() =>
void handleCancelVersion(game, latestVersion)
}
>
确认撤回
</PlatformActionButton>
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
disabled={
cancellingVersionId === latestVersion.versionId
}
onClick={() => setConfirmingVersionId('')}
>
取消
</PlatformActionButton>
</>
) : (
<PlatformActionButton
tone="secondary"
size="sm"
shape="pill"
disabled={
cancellingVersionId === latestVersion.versionId
}
onClick={() =>
setConfirmingVersionId(latestVersion.versionId)
}
>
撤回审核
</PlatformActionButton>
)
) : null}
</div>
</div>
</article>
);
})}
</div>
) : null}
</div>
);
}
export default MyGamesPage;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react';
const MAX_GAME_ID_LENGTH = 128;
const MAX_ENTRY_URL_LENGTH = 4096;
function containsControlCharacter(value: string) {
for (const character of value) {
const codePoint = character.codePointAt(0);
if (codePoint !== undefined && (codePoint < 0x20 || codePoint === 0x7f)) {
return true;
}
}
return false;
}
/**
* Route query values are untrusted input. Keep the client side guard small and
* permissive enough for server assigned ids while rejecting values that can
* become ambiguous path segments or control characters in diagnostics.
*/
export function normalizeGameId(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
if (
!normalized ||
normalized.length > MAX_GAME_ID_LENGTH ||
containsControlCharacter(normalized)
) {
return null;
}
return normalized;
}
/**
* The API is the authority for which release host is allowed. The browser
* still refuses executable URL schemes and credential-bearing URLs before a
* value can reach an iframe.
*/
export function normalizeGameEntryUrl(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
if (
!normalized ||
normalized.length > MAX_ENTRY_URL_LENGTH ||
containsControlCharacter(normalized)
) {
return null;
}
try {
const url = new URL(
normalized,
typeof window === 'undefined'
? 'http://localhost'
: window.location.origin,
);
const isLocalDevelopmentHttp =
url.protocol === 'http:' &&
(url.hostname === 'localhost' ||
url.hostname === '127.0.0.1' ||
url.hostname === '[::1]');
if (
(url.protocol !== 'https:' && !isLocalDevelopmentHttp) ||
url.username ||
url.password ||
(typeof window !== 'undefined' &&
url.origin === window.location.origin &&
!import.meta.env.DEV)
) {
return null;
}
return url.href;
} catch {
return null;
}
}
function readMobileViewport() {
if (typeof window === 'undefined') return false;
if (typeof window.matchMedia === 'function') {
return window.matchMedia('(max-width: 820px)').matches;
}
return window.innerWidth <= 820;
}
export function useIsMobileViewport() {
const [isMobileViewport, setIsMobileViewport] = useState(readMobileViewport);
useEffect(() => {
if (typeof window === 'undefined') return undefined;
const mediaQuery =
typeof window.matchMedia === 'function'
? window.matchMedia('(max-width: 820px)')
: null;
const update = () => setIsMobileViewport(readMobileViewport());
update();
window.addEventListener('resize', update);
mediaQuery?.addEventListener?.('change', update);
return () => {
window.removeEventListener('resize', update);
mediaQuery?.removeEventListener?.('change', update);
};
}, []);
return isMobileViewport;
}
function readPortraitViewport() {
if (typeof window === 'undefined') return false;
const { innerHeight, innerWidth } = window;
if (
typeof innerHeight === 'number' &&
typeof innerWidth === 'number' &&
innerHeight > 0 &&
innerWidth > 0 &&
innerHeight !== innerWidth
) {
return innerHeight > innerWidth;
}
if (typeof window.matchMedia === 'function') {
return window.matchMedia('(orientation: portrait)').matches;
}
return false;
}
/**
* 横屏游戏的旋转提示只在竖屏视口出现;视口比例优先,matchMedia 作为兜底,
* 让没有方向查询能力的嵌入浏览器也能给出提示。
*/
export function useIsPortraitViewport() {
const [isPortrait, setIsPortrait] = useState(readPortraitViewport);
useEffect(() => {
if (typeof window === 'undefined') return undefined;
const mediaQuery =
typeof window.matchMedia === 'function'
? window.matchMedia('(orientation: portrait)')
: null;
const update = () => setIsPortrait(readPortraitViewport());
update();
window.addEventListener('resize', update);
mediaQuery?.addEventListener?.('change', update);
return () => {
window.removeEventListener('resize', update);
mediaQuery?.removeEventListener?.('change', update);
};
}, []);
return isPortrait;
}
@@ -0,0 +1,75 @@
/*
* 游戏分发封面与截图的展示层补充样式。
*
* 真实封面/截图换签成功后覆盖原有渐变占位;未上传、换签失败或仍在解析时保持
* gameDistribution.css 里的占位视觉,因此这里的规则只负责“图片已就绪”这一态。
*/
.game-cover__image {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.game-detail-showcase {
display: grid;
gap: 0.7rem;
min-width: 0;
}
.game-detail-thumbs {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.15rem;
scroll-snap-type: x proximity;
scrollbar-width: thin;
}
.game-detail-thumb {
flex: 0 0 auto;
width: 5.4rem;
aspect-ratio: 16 / 9;
overflow: hidden;
border: 1px solid var(--platform-surface-border);
border-radius: 0.7rem;
padding: 0;
background: linear-gradient(
135deg,
rgba(241, 159, 95, 0.24),
rgba(91, 49, 42, 0.16)
);
cursor: pointer;
scroll-snap-align: start;
transition: 160ms ease;
}
.game-detail-thumb:hover {
border-color: rgba(199, 101, 61, 0.45);
}
.game-detail-thumb[aria-pressed='true'] {
border-color: rgba(199, 101, 61, 0.62);
box-shadow: 0 0 0 2px rgba(199, 101, 61, 0.22);
}
.game-detail-thumb img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.game-detail-thumb__placeholder {
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 560px) {
.game-detail-thumb {
width: 4.6rem;
}
}
@@ -0,0 +1,183 @@
import type {
GameCategory,
GameDistributionGame,
} from '../../services/gameDistributionClient';
export const GAME_GALLERY_CATEGORIES: Array<GameCategory | '全部'> = [
'全部',
'动作',
'益智',
'冒险',
'休闲',
'模拟',
'策略',
'其他',
];
export type GameDeviceFilter = '全部' | '桌面' | '移动';
export const GAME_GALLERY_DEVICE_FILTERS: GameDeviceFilter[] = [
'全部',
'桌面',
'移动',
];
/** 目录筛选写入地址栏,分享和刷新都能回到同一份上下文。 */
const GALLERY_QUERY_KEYS = {
keyword: 'keyword',
category: 'category',
device: 'device',
} as const;
const GALLERY_STORAGE_KEYS = {
filters: 'game-gallery-filters',
scroll: 'game-gallery-scroll',
} as const;
/** 画廊挂在平台页签的滚动容器里,返回时按同一容器恢复位置。 */
export const GAME_GALLERY_SCROLL_CONTAINER_SELECTOR = '.platform-tab-panel';
export type GameGalleryFilters = {
keyword: string;
category: GameCategory | '全部';
device: GameDeviceFilter;
};
function readGalleryStorage() {
try {
return globalThis.sessionStorage ?? null;
} catch {
return null;
}
}
function normalizeCategory(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
return (
GAME_GALLERY_CATEGORIES.find(
(item) => item !== '全部' && item === normalized,
) ?? '全部'
);
}
function normalizeDevice(value: string | null | undefined) {
const normalized = value?.trim() ?? '';
return (
GAME_GALLERY_DEVICE_FILTERS.find((item) => item === normalized) ?? '全部'
);
}
export function gameGalleryScrollKey(filters: GameGalleryFilters) {
return `${GALLERY_STORAGE_KEYS.scroll}:${filters.keyword.trim()}|${filters.category}|${filters.device}`;
}
/**
* 只有地址栏里明确出现的筛选项才覆盖返回上下文,避免把用户分享的
* `?category=动作` 当成“其他筛选项已被清空”。
*/
export function readGameGalleryQueryFilters(
search: string,
): Partial<GameGalleryFilters> {
const params = new URLSearchParams(search);
const filters: Partial<GameGalleryFilters> = {};
const keyword = params.get(GALLERY_QUERY_KEYS.keyword);
if (keyword !== null) filters.keyword = keyword;
const category = params.get(GALLERY_QUERY_KEYS.category);
if (category !== null) filters.category = normalizeCategory(category);
const device = params.get(GALLERY_QUERY_KEYS.device);
if (device !== null) filters.device = normalizeDevice(device);
return filters;
}
export function readStoredGameGalleryFilters(): Partial<GameGalleryFilters> {
const storage = readGalleryStorage();
if (!storage) return {};
try {
const raw = storage.getItem(GALLERY_STORAGE_KEYS.filters);
if (!raw) return {};
const parsed = JSON.parse(raw) as Partial<GameGalleryFilters> | null;
if (!parsed || typeof parsed !== 'object') return {};
const filters: Partial<GameGalleryFilters> = {};
if (typeof parsed.keyword === 'string') filters.keyword = parsed.keyword;
if (typeof parsed.category === 'string') {
filters.category = normalizeCategory(parsed.category);
}
if (typeof parsed.device === 'string') {
filters.device = normalizeDevice(parsed.device);
}
return filters;
} catch {
return {};
}
}
export function writeStoredGameGalleryFilters(filters: GameGalleryFilters) {
const storage = readGalleryStorage();
if (!storage) return;
try {
storage.setItem(GALLERY_STORAGE_KEYS.filters, JSON.stringify(filters));
} catch {
// 存储被禁用时筛选仍在本页生效,只是刷新后不再恢复。
}
}
export function readStoredGameGalleryScrollTop(key: string) {
const storage = readGalleryStorage();
if (!storage) return 0;
try {
const value = Number(storage.getItem(key));
return Number.isFinite(value) && value > 0 ? value : 0;
} catch {
return 0;
}
}
export function writeStoredGameGalleryScrollTop(
key: string,
scrollTop: number,
) {
const storage = readGalleryStorage();
if (!storage) return;
try {
if (scrollTop > 0) storage.setItem(key, String(Math.round(scrollTop)));
else storage.removeItem(key);
} catch {
// 忽略存储失败,滚动位置不是业务状态。
}
}
export function syncGameGalleryQuery(filters: GameGalleryFilters) {
if (typeof window === 'undefined' || !window.history?.replaceState) return;
try {
const url = new URL(window.location.href);
const assign = (key: string, value: string) => {
if (value) url.searchParams.set(key, value);
else url.searchParams.delete(key);
};
assign(GALLERY_QUERY_KEYS.keyword, filters.keyword.trim());
assign(
GALLERY_QUERY_KEYS.category,
filters.category === '全部' ? '' : filters.category,
);
assign(
GALLERY_QUERY_KEYS.device,
filters.device === '全部' ? '' : filters.device,
);
window.history.replaceState(
window.history.state,
'',
`${url.pathname}${url.search}${url.hash}`,
);
} catch {
// 地址栏同步失败不影响筛选本身。
}
}
export function matchesGameDeviceFilter(
game: GameDistributionGame,
device: GameDeviceFilter,
) {
if (device === '桌面') return game.deviceSupport.desktop;
if (device === '移动') return game.deviceSupport.mobile;
return true;
}
@@ -0,0 +1,90 @@
import { getSignedAssetReadUrl } from '../../services/assetReadUrlService';
import { uploadEditorMediaAssetFile } from '../../services/image-editor/editorMediaAssetUploadClient';
/** 游戏截图数量上限与服务端 `MAX_GAME_SCREENSHOTS` 保持一致。 */
export const MAX_GAME_SCREENSHOTS = 6;
/** 封面与截图都是公开展示素材,限制单张体积,避免手机原图直传拖垮发布流程。 */
export const GAME_PUBLISH_IMAGE_MAX_BYTES = 6 * 1024 * 1024;
export const GAME_PUBLISH_IMAGE_ACCEPT =
'image/png,image/jpeg,image/webp,image/gif';
const GAME_PUBLISH_IMAGE_PREVIEW_EXPIRE_SECONDS = 60 * 60;
export type GamePublishImageKind = 'cover' | 'screenshot';
/** 已上传到平台的封面/截图素材:素材 ID 用于冻结资料,对象键用于换签展示。 */
export type GamePublishImageAsset = {
assetObjectId: string;
objectKey: string;
/** 签名预览地址;换签失败时为空字符串,此时只显示占位,不影响继续发布。 */
src: string;
name: string;
};
function formatBytes(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
}
export function resolveGamePublishImageKindLabel(kind: GamePublishImageKind) {
return kind === 'cover' ? '游戏封面' : '游戏截图';
}
/** 本地预检;服务端仍会校验素材归属与图片类型,这里只拦掉明显不合规的文件。 */
export function validateGamePublishImageFile(
file: File,
kind: GamePublishImageKind,
) {
const label = resolveGamePublishImageKindLabel(kind);
if (file.size <= 0) {
throw new Error(`${label}文件为空,请重新选择。`);
}
if (file.size > GAME_PUBLISH_IMAGE_MAX_BYTES) {
throw new Error(
`${label}过大,请压缩后再上传(当前 ${formatBytes(file.size)},最多 6MB)。`,
);
}
const contentType = file.type.trim();
if (contentType && !contentType.startsWith('image/')) {
throw new Error(`${label}必须是图片文件。`);
}
}
/**
* 上传一张封面/截图并返回可用于冻结资料的素材标识。
*
* 复用平台图片上传通道(直传凭证 + 直传 + confirm),对象键由服务端派生;这里不把
* 本地文件路径或外链写进资料,避免资料冻结时引用到平台之外的内容。
*/
export async function uploadGamePublishImageAsset(
file: File,
options: { kind: GamePublishImageKind; signal?: AbortSignal },
): Promise<GamePublishImageAsset> {
validateGamePublishImageFile(file, options.kind);
const uploaded = await uploadEditorMediaAssetFile(file, 'image', {
assetKind: `game_distribution_${options.kind}`,
pathSegments: ['game-distribution', options.kind, `${Date.now()}`],
entityId: `game-distribution-${options.kind}`,
metadata: { game_distribution_media: options.kind },
signal: options.signal,
});
return {
assetObjectId: uploaded.assetObjectId,
objectKey: uploaded.objectKey,
src: uploaded.src,
name: file.name.trim() || resolveGamePublishImageKindLabel(options.kind),
};
}
/** 作者续发时按冻结资料里的对象键换签预览;失败返回空字符串,由调用方回退占位。 */
export async function resolveGamePublishImagePreview(objectKey: string) {
const normalizedKey = objectKey.trim();
if (!normalizedKey) return '';
try {
return await getSignedAssetReadUrl({
objectKey: normalizedKey,
expireSeconds: GAME_PUBLISH_IMAGE_PREVIEW_EXPIRE_SECONDS,
});
} catch {
return '';
}
}
@@ -0,0 +1,118 @@
import type { GameDistributionRecoveryAction } from '../../../packages/shared/src/contracts/gameDistribution';
/**
* 网页发布的恢复标识。
*
* 只保存服务端已经分配的游戏/版本标识与本地标题,用于窗口关闭或网络中断后回到同一
* 版本继续上传;不保存 ZIP 字节、凭据或账号资料。`ownerUserId` 用于换账号时拒绝恢复,
* 避免把上一个账号的私有状态展示给新账号。
*/
export type GamePublishDraft = {
ownerUserId: string;
gameId: string;
versionId: string;
versionNumber: number;
title: string;
updatedAt: string;
};
export const GAME_PUBLISH_DRAFT_STORAGE_KEY =
'genarrative.game-distribution.publish-draft.v1';
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
function resolveStorage(storage?: StorageLike): StorageLike | null {
if (storage) return storage;
if (typeof window === 'undefined') return null;
try {
// 某些隐私模式会直接抛 SecurityError,读不到存储时放弃恢复能力而不是让页面崩溃。
return window.localStorage ?? null;
} catch {
return null;
}
}
function isDraft(value: unknown): value is GamePublishDraft {
if (!value || typeof value !== 'object') return false;
const draft = value as Partial<GamePublishDraft>;
return Boolean(
draft.ownerUserId &&
draft.gameId &&
draft.versionId &&
typeof draft.versionNumber === 'number' &&
typeof draft.title === 'string',
);
}
export function readPublishDraft(
storage?: StorageLike,
): GamePublishDraft | null {
const target = resolveStorage(storage);
if (!target) return null;
try {
const raw = target.getItem(GAME_PUBLISH_DRAFT_STORAGE_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
return isDraft(parsed) ? parsed : null;
} catch {
return null;
}
}
export function writePublishDraft(
draft: GamePublishDraft,
storage?: StorageLike,
): void {
const target = resolveStorage(storage);
if (!target) return;
try {
target.setItem(GAME_PUBLISH_DRAFT_STORAGE_KEY, JSON.stringify(draft));
} catch {
// 隐私模式或配额不足时放弃恢复能力,不影响本次发布。
}
}
export function clearPublishDraft(storage?: StorageLike): void {
const target = resolveStorage(storage);
if (!target) return;
try {
target.removeItem(GAME_PUBLISH_DRAFT_STORAGE_KEY);
} catch {
// 清理失败时保留草稿,最多多显示一次恢复提示。
}
}
export type PublishDraftRecoveryCopy = {
title: string;
description: string;
actionLabel: string | null;
};
/** 恢复提示的文案完全由服务端 recoveryAction 派生,前端不推断版本状态。 */
export function resolvePublishDraftRecoveryCopy(
action: GameDistributionRecoveryAction,
versionNumber: number,
): PublishDraftRecoveryCopy {
switch (action) {
case 'upload':
case 'reupload':
return {
title: `上次发布未完成(版本 v${versionNumber})`,
description: '重新选择同一个发行包 ZIP,会沿用原版本继续上传并送审。',
actionLabel: '继续上传',
};
case 'submit':
return {
title: `发行包已上传(版本 v${versionNumber})`,
description: '可以直接继续提交审核,不需要重新上传。',
actionLabel: '继续送审',
};
default:
return {
title: `上次发布已交接(版本 v${versionNumber})`,
description:
'该版本已经进入服务端流程,可以在「我的游戏」查看最新状态。',
actionLabel: null,
};
}
}
@@ -0,0 +1,35 @@
import type { GameDistributionDeviceSupport } from '../../../packages/shared/src/contracts/gameDistribution';
import { MAX_GAME_SCREENSHOTS } from './gamePublishAssets';
/** 作者侧元数据校验;口径与服务端 `validate_game_metadata` 保持一致。 */
export function resolvePublishMetadataError(input: {
title: string;
summary: string;
deviceSupport: GameDistributionDeviceSupport;
/** 封面素材 ID;服务端要求发布必须带封面,缺失时在本地先拦一次。 */
coverAssetId?: string | null;
/** 已选截图数量;上限与服务端 `MAX_GAME_SCREENSHOTS` 一致。 */
screenshotCount?: number;
}) {
const title = input.title.trim();
const summary = input.summary.trim();
if (!title || Array.from(title).length > 40) {
return '游戏标题必须为 1 到 40 个字符';
}
if (!summary || Array.from(summary).length > 120) {
return '游戏简介必须为 1 到 120 个字符';
}
if (!input.deviceSupport.desktop && !input.deviceSupport.mobile) {
return '至少选择桌面端或移动端';
}
if (input.deviceSupport.mobile && !input.deviceSupport.touch) {
return '选择移动端时必须支持触控';
}
if (!input.coverAssetId?.trim()) {
return '发布游戏必须提供封面';
}
if ((input.screenshotCount ?? 0) > MAX_GAME_SCREENSHOTS) {
return '游戏截图最多 6 张';
}
return '';
}
@@ -0,0 +1,55 @@
/* @vitest-environment jsdom */
import JSZip from 'jszip';
import { describe, expect, it } from 'vitest';
import { prepareGamePackage } from './gameZipPackage';
async function buildZip(files: Record<string, string>) {
const zip = new JSZip();
for (const [name, content] of Object.entries(files)) {
zip.file(name, content);
}
const bytes = await zip.generateAsync({ type: 'uint8array' });
return new File([bytes as unknown as BlobPart], 'game.zip', {
type: 'application/zip',
});
}
describe('prepareGamePackage', () => {
it('读取根 index.html、统计文件数并给出稳定摘要', async () => {
const file = await buildZip({
'index.html': '<html></html>',
'assets/app.js': 'console.log(1)',
});
const prepared = await prepareGamePackage(file);
expect(prepared.fileCount).toBe(2);
expect(prepared.totalBytes).toBe(file.size);
expect(prepared.sha256).toMatch(/^[0-9a-f]{64}$/u);
const again = await prepareGamePackage(file);
expect(again.sha256).toBe(prepared.sha256);
});
it('缺少根 index.html 时失败关闭', async () => {
const file = await buildZip({ 'game/index.html': '<html></html>' });
await expect(prepareGamePackage(file)).rejects.toThrow(
'发行包根目录必须包含 index.html',
);
});
it('拒绝空文件与非 ZIP 内容', async () => {
await expect(
prepareGamePackage(
new File([], 'empty.zip', { type: 'application/zip' }),
),
).rejects.toThrow('请选择非空的发行包 ZIP');
await expect(
prepareGamePackage(
new File(['not a zip' as unknown as BlobPart], 'bad.zip', {
type: 'application/zip',
}),
),
).rejects.toThrow('发行包不是有效的 ZIP');
});
});
@@ -0,0 +1,92 @@
import JSZip from 'jszip';
export const GAME_PACKAGE_MAX_BYTES = 100 * 1024 * 1024;
export const GAME_PACKAGE_MAX_FILE_COUNT = 10_000;
export type PreparedGamePackage = {
bytes: Uint8Array;
sha256: string;
fileCount: number;
totalBytes: number;
};
function toHex(buffer: ArrayBuffer) {
return Array.from(new Uint8Array(buffer))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
/** 浏览器端发行包摘要;与服务端 `packageSha256` 同口径。 */
export async function computeGamePackageSha256(bytes: Uint8Array) {
const digest = globalThis.crypto?.subtle;
if (!digest) {
throw new Error('当前浏览器无法计算发行包摘要,请升级浏览器后重试');
}
const copy = new Uint8Array(bytes);
return toHex(await digest.digest('SHA-256', copy.buffer as ArrayBuffer));
}
/**
* 上传前读取并校验 ZIP。
*
* 这里只做作者侧的前置校验,服务端仍会独立重算摘要与文件清单;两边口径必须一致,
* 前端校验通过不代表服务端一定接受。
*/
/** 读取 Blob 字节;FileReader 兜底覆盖不实现 `Blob.arrayBuffer()` 的旧 WebView 与 jsdom。 */
export function readGamePackageBytes(blob: Blob): Promise<Uint8Array> {
if (typeof blob.arrayBuffer === 'function') {
return blob.arrayBuffer().then((buffer) => new Uint8Array(buffer));
}
return new Promise<Uint8Array>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
if (result instanceof ArrayBuffer) {
resolve(new Uint8Array(result));
return;
}
reject(new Error('读取发行包失败'));
};
reader.onerror = () => reject(new Error('读取发行包失败'));
reader.readAsArrayBuffer(blob);
});
}
export async function prepareGamePackage(
file: Blob,
): Promise<PreparedGamePackage> {
if (!file || file.size === 0) {
throw new Error('请选择非空的发行包 ZIP');
}
if (file.size > GAME_PACKAGE_MAX_BYTES) {
throw new Error('发行包不能超过 100 MiB');
}
const bytes = await readGamePackageBytes(file);
let archive: JSZip;
try {
archive = await JSZip.loadAsync(bytes);
} catch {
throw new Error('发行包不是有效的 ZIP');
}
const entries = Object.values(archive.files).filter((entry) => !entry.dir);
if (entries.length === 0) {
throw new Error('发行包至少需要一个文件');
}
if (entries.length > GAME_PACKAGE_MAX_FILE_COUNT) {
throw new Error(`发行包文件数量不能超过 ${GAME_PACKAGE_MAX_FILE_COUNT} 个`);
}
if (!entries.some((entry) => entry.name === 'index.html')) {
throw new Error('发行包根目录必须包含 index.html');
}
// 展开总量、单文件上限与压缩比由服务端 ZIP 校验独立判定;前端不在浏览器里解压
// 整包,避免大包在客户端产生额外内存与耗时。
return {
bytes,
sha256: await computeGamePackageSha256(bytes),
fileCount: entries.length,
totalBytes: bytes.byteLength,
};
}
@@ -43,6 +43,30 @@ const profileCenterMock = vi.hoisted(() => ({
rechargeCenter: null as { walletBalance: number } | null,
}));
const gameDistributionMock = vi.hoisted(() => ({
cancelGameVersion: vi.fn(),
createGame: vi.fn(),
createGameVersion: vi.fn(),
getGame: vi.fn(),
getGameVersion: vi.fn(),
listGames: vi.fn(),
listMyGames: vi.fn(),
submitGameVersion: vi.fn(),
unpublishGame: vi.fn(),
uploadGamePackage: vi.fn(),
}));
vi.mock('../../services/gameDistributionClient', () => gameDistributionMock);
const frontendRuntimeConfigMock = vi.hoisted(() => ({
loadFrontendRuntimeConfig: vi.fn(),
}));
vi.mock('../../services/frontendRuntimeConfigService', () => ({
loadFrontendRuntimeConfig: (...args: unknown[]) =>
frontendRuntimeConfigMock.loadFrontendRuntimeConfig(...args),
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => authUiMock.value,
}));
@@ -187,6 +211,16 @@ describe('PlatformEntryActiveFlowShell', () => {
profileCenterMock.rechargeCenter = null;
authUiMock.value.user = null;
authUiMock.value.canAccessProtectedData = false;
window.history.replaceState(null, '', '/creation');
for (const mock of Object.values(gameDistributionMock)) {
mock.mockReset();
}
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockReset();
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockResolvedValue({
imageEditorAgentSidebarEnabled: false,
agcTemplateLibraryEnabled: false,
gameDistributionPublishEnabled: true,
});
authUiMock.value.openLoginModal.mockReset();
responsiveMock.isDesktopLayout = true;
});
@@ -417,7 +451,7 @@ describe('PlatformEntryActiveFlowShell', () => {
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['创作', '项目', '我的']);
).toEqual(['创作', '项目', '游戏', '我的']);
expect(
within(navigation)
.getByRole('button', { name: '创作' })
@@ -466,7 +500,7 @@ describe('PlatformEntryActiveFlowShell', () => {
).toBe('page');
});
it('keeps only profile reachable from the mobile dock', async () => {
it('keeps only games and profile reachable from the mobile dock', async () => {
responsiveMock.isDesktopLayout = false;
const setSelectionStage = vi.fn();
render(
@@ -483,7 +517,7 @@ describe('PlatformEntryActiveFlowShell', () => {
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['我的']);
).toEqual(['游戏', '我的']);
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
expect(
@@ -568,7 +602,7 @@ describe('PlatformEntryActiveFlowShell', () => {
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['我的']);
).toEqual(['游戏', '我的']);
expect(
within(navigation)
.getByRole('button', { name: '我的' })
@@ -653,6 +687,146 @@ describe('PlatformEntryActiveFlowShell', () => {
});
});
it('在我的游戏里进入发布新版本时沿用同一个 gameId', async () => {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
gameDistributionMock.listMyGames.mockResolvedValue([
{
id: 'game-1',
title: '星轨防线',
summary: '守住轨道城',
description: '旧版资料',
category: '动作',
tags: ['塔防'],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'user-1', name: '测试用户' },
deviceSupport: { desktop: true, mobile: false, touch: false },
status: 'published',
publicationRevision: 3,
currentVersion: null,
playCount: 1,
createdAt: '2026-09-20T08:00:00Z',
latestVersion: {
versionId: 'gamever-2',
gameId: 'game-1',
versionNumber: 2,
packageSha256: 'e'.repeat(64),
packageBytes: 1024,
status: 'published',
publicationRevision: 3,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
versions: [],
},
]);
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="game-mine"
setSelectionStage={setSelectionStage}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '发布新版本' }));
expect(setSelectionStage).toHaveBeenCalledWith('game-publish', {
path: '/games/publish?game=game-1',
});
});
it('发布页带 game 参数时进入更新模式并预填既有资料', async () => {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试用户',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
gameDistributionMock.listMyGames.mockResolvedValue([
{
id: 'game-9',
title: '星轨防线',
summary: '守住轨道城',
description: '旧版资料',
category: '动作',
tags: ['塔防'],
coverColor: '#d77a51',
icon: '✦',
author: { id: 'user-1', name: '测试用户' },
deviceSupport: { desktop: true, mobile: false, touch: false },
status: 'published',
publicationRevision: 5,
currentVersion: null,
playCount: 1,
createdAt: '2026-09-20T08:00:00Z',
latestVersion: {
versionId: 'gamever-9',
gameId: 'game-9',
versionNumber: 4,
packageSha256: 'f'.repeat(64),
packageBytes: 2048,
status: 'published',
publicationRevision: 5,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T09:00:00Z',
},
versions: [],
},
]);
gameDistributionMock.getGameVersion.mockResolvedValue({
game: {
id: 'game-9',
title: '星轨防线',
summary: '守住轨道城',
description: '旧版资料',
category: '动作',
tags: ['塔防'],
publicationRevision: 5,
},
version: {
versionId: 'gamever-9',
versionNumber: 4,
status: 'published',
publicationRevision: 5,
recoveryAction: 'none',
},
});
window.history.replaceState(null, '', '/games/publish?game=game-9');
render(
<PlatformEntryFlowShellImpl
selectionStage="game-publish"
setSelectionStage={vi.fn()}
/>,
);
expect(
await screen.findByText(/正在为《星轨防线》发布新版本 v5/u),
).toBeTruthy();
expect(screen.getByLabelText('游戏名称')).toHaveProperty(
'value',
'星轨防线',
);
});
it('keeps guide and tool intent in editor navigation URLs', async () => {
const setSelectionStage = vi.fn();
const { rerender } = render(
@@ -691,3 +865,47 @@ describe('PlatformEntryActiveFlowShell', () => {
});
});
});
describe('游戏发布灰度入口', () => {
function loginAsAuthor() {
authUiMock.value.user = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试作者',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
};
authUiMock.value.canAccessProtectedData = true;
}
it('灰度未命中时广场不展示发布入口', async () => {
loginAsAuthor();
frontendRuntimeConfigMock.loadFrontendRuntimeConfig.mockResolvedValue({
imageEditorAgentSidebarEnabled: false,
agcTemplateLibraryEnabled: false,
gameDistributionPublishEnabled: false,
});
gameDistributionMock.listGames.mockResolvedValue([]);
render(<StatefulPlatformEntryFlowShell initialStage="games" />);
expect(await screen.findByText('游戏广场')).toBeTruthy();
await waitFor(() =>
expect(screen.queryByRole('button', { name: '发布游戏' })).toBeNull(),
);
});
it('灰度命中时广场展示发布入口', async () => {
loginAsAuthor();
gameDistributionMock.listGames.mockResolvedValue([]);
render(<StatefulPlatformEntryFlowShell initialStage="games" />);
expect(
await screen.findByRole('button', { name: '发布游戏' }),
).not.toBeNull();
});
});
@@ -1,5 +1,6 @@
import {
FolderKanban,
Gamepad2,
Home,
Monitor,
Palette,
@@ -24,6 +25,7 @@ import {
pushAppHistoryPath,
replaceAppHistoryPath,
} from '../../routing/activeAppPageRoutes';
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -66,6 +68,31 @@ const ProjectGalleryView = lazy(async () => {
return { default: module.ProjectGalleryView };
});
const GameGalleryPage = lazy(async () => {
const module = await import('../game-distribution/GameGalleryPage');
return { default: module.GameGalleryPage };
});
const GameDetailPage = lazy(async () => {
const module = await import('../game-distribution/GameDetailPage');
return { default: module.GameDetailPage };
});
const GamePublishPage = lazy(async () => {
const module = await import('../game-distribution/GamePublishPage');
return { default: module.GamePublishPage };
});
const MyGamesPage = lazy(async () => {
const module = await import('../game-distribution/MyGamesPage');
return { default: module.MyGamesPage };
});
const GamePlayPage = lazy(async () => {
const module = await import('../game-distribution/GamePlayPage');
return { default: module.GamePlayPage };
});
type ActiveRailButtonProps = {
active: boolean;
emphasized?: boolean;
@@ -145,16 +172,26 @@ function ActiveBottomNavButton({
function MobileProfileDock({
active,
onOpenProfile,
gameActive,
onOpenGames,
}: {
active: boolean;
onOpenProfile: () => void;
gameActive: boolean;
onOpenGames: () => void;
}) {
return (
<div className="platform-mobile-bottom-dock min-w-0 shrink-0 lg:hidden">
<nav
className="platform-bottom-nav grid grid-cols-1"
className="platform-bottom-nav grid grid-cols-2"
aria-label="移动平台导航"
>
<ActiveBottomNavButton
active={gameActive}
icon={Gamepad2}
label="游戏"
onClick={onOpenGames}
/>
<ActiveBottomNavButton
active={active}
icon={UserRound}
@@ -244,6 +281,9 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage,
}: PlatformEntryFlowShellProps) {
const authUi = useAuthUi();
// 发布灰度:命中才暴露「发布游戏 / 发布新版本」入口;读取失败按放行处理,由后端写入口把关。
const [gamePublishGateAllowed, setGamePublishGateAllowed] = useState(true);
const gamePublishGateUserId = authUi?.user?.id ?? '';
const [dashboard, setDashboard] = useState<ProfileDashboardSummary | null>(
null,
);
@@ -358,6 +398,65 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('profile', { path: '/profile' });
}, [setSelectionStage]);
const openGames = useCallback(() => {
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
useEffect(() => {
if (!gamePublishGateUserId) {
// 未登录时不展示发布入口,登录后由 authUi 变化触发重新读取。
setGamePublishGateAllowed(false);
return;
}
let cancelled = false;
loadFrontendRuntimeConfig()
.then((config) => {
if (!cancelled) {
setGamePublishGateAllowed(
config.gameDistributionPublishEnabled !== false,
);
}
})
.catch(() => {
if (!cancelled) setGamePublishGateAllowed(true);
});
return () => {
cancelled = true;
};
}, [gamePublishGateUserId]);
const openGamePublish = useCallback(() => {
setSelectionStage('game-publish', { path: '/games/publish' });
}, [setSelectionStage]);
const openGamePublishVersion = useCallback(
(gameId: string) => {
const path = `/games/publish?game=${encodeURIComponent(gameId)}`;
setSelectionStage('game-publish', { path });
},
[setSelectionStage],
);
const openMyGames = useCallback(() => {
setSelectionStage('game-mine', { path: '/games/mine' });
}, [setSelectionStage]);
const openGameDetail = useCallback(
(gameId: string) => {
const path = `/games/detail?id=${encodeURIComponent(gameId)}`;
setSelectionStage('game-detail', { path });
},
[setSelectionStage],
);
const openGamePlay = useCallback(
(gameId: string) => {
const path = `/games/play?id=${encodeURIComponent(gameId)}`;
setSelectionStage('game-play', { path });
},
[setSelectionStage],
);
const openEditorProject = useCallback(
(projectId: string, options?: { guide?: boolean; tool?: string }) => {
if (!isDesktopLayout) {
@@ -402,7 +501,12 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('platform', { path: '/' });
}}
/>
<MobileProfileDock active={false} onOpenProfile={openProfile} />
<MobileProfileDock
active={false}
gameActive={false}
onOpenProfile={openProfile}
onOpenGames={openGames}
/>
<PlatformActiveMobileWelcomeDialog
open={shouldOpenMobileHomeWelcome}
platformThemeClass={platformThemeClass}
@@ -430,6 +534,17 @@ export function PlatformEntryFlowShellImpl({
const isCreationStage =
!isProfileStage &&
(selectionStage === 'platform' || selectionStage === 'creation-home');
const isGamesStage =
selectionStage === 'games' ||
selectionStage === 'game-detail' ||
selectionStage === 'game-play' ||
selectionStage === 'game-mine' ||
selectionStage === 'game-publish';
const gameSearchParams = new URLSearchParams(
typeof window === 'undefined' ? '' : window.location.search,
);
const gameId = gameSearchParams.get('id');
const publishGameId = gameSearchParams.get('game');
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
const avatarLabel = resolveActiveUserAvatarLabel(authUi?.user);
const publicUserCode = resolveActivePublicUserCode(authUi?.user);
@@ -484,6 +599,14 @@ export function PlatformEntryFlowShellImpl({
label="项目"
onClick={openProjects}
/>
<ActiveRailButton
active={isGamesStage}
emphasized={isGamesStage}
icon={Gamepad2}
iconSrc="/creation-home/nav-projects.png"
label="游戏"
onClick={openGames}
/>
<ActiveRailButton
active={isProfileStage}
icon={UserRound}
@@ -515,8 +638,12 @@ export function PlatformEntryFlowShellImpl({
<input
type="search"
value={searchInput}
placeholder="搜索项目、素材、作者或描述"
aria-label="搜索项目和素材"
placeholder={
isGamesStage
? '搜索游戏、作者或标签'
: '搜索项目、素材、作者或描述'
}
aria-label={isGamesStage ? '搜索游戏' : '搜索项目和素材'}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--platform-text-strong)] outline-none placeholder:text-[var(--platform-text-soft)]"
onChange={(event) => {
const value = event.target.value;
@@ -622,6 +749,50 @@ export function PlatformEntryFlowShellImpl({
searchKeyword={activeSearchKeyword}
/>
</Suspense>
) : isGamesStage ? (
<Suspense
fallback={<LoadingPanel label="正在加载游戏广场..." />}
>
{selectionStage === 'games' ? (
<GameGalleryPage
searchKeyword={activeSearchKeyword}
onOpenDetail={openGameDetail}
onOpenMyGames={openMyGames}
onOpenPublish={
gamePublishGateAllowed ? openGamePublish : undefined
}
/>
) : selectionStage === 'game-publish' ? (
<GamePublishPage
onBack={openGames}
onOpenMyGames={openMyGames}
updateGameId={publishGameId}
/>
) : selectionStage === 'game-mine' ? (
<MyGamesPage
onBack={openGames}
onOpenDetail={openGameDetail}
onPublishVersion={
gamePublishGateAllowed
? openGamePublishVersion
: undefined
}
/>
) : selectionStage === 'game-detail' ? (
<GameDetailPage
gameId={gameId}
onBack={openGames}
onPlay={openGamePlay}
/>
) : (
<GamePlayPage
gameId={gameId}
onBack={() =>
gameId ? openGameDetail(gameId) : openGames()
}
/>
)}
</Suspense>
) : (
<Suspense fallback={<LoadingPanel label="正在加载项目..." />}>
<ProjectGalleryView
@@ -635,7 +806,9 @@ export function PlatformEntryFlowShellImpl({
</div>
<MobileProfileDock
active={isProfileStage}
gameActive={isGamesStage}
onOpenProfile={openProfile}
onOpenGames={openGames}
/>
</div>
</div>
@@ -3,7 +3,12 @@ export type SelectionStage =
| 'creation-home'
| 'project'
| 'profile'
| 'image-editor';
| 'image-editor'
| 'games'
| 'game-detail'
| 'game-play'
| 'game-mine'
| 'game-publish';
export type PlatformEntryFlowShellProps = {
selectionStage: SelectionStage;
+27 -11
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import viteConfig from '../../vite.config';
import viteConfig, { isRetiredApiPath } from '../../vite.config';
describe('vite dev api proxy', () => {
it('forwards the profile main route to the Rust API server', async () => {
@@ -22,22 +22,19 @@ describe('vite dev api proxy', () => {
);
});
it('forwards the public creation entry config route to the Rust API server', async () => {
it('keeps the retired creation entry config route out of the dev proxy', async () => {
const resolvedConfig =
typeof viteConfig === 'function'
? await viteConfig({ command: 'serve', mode: 'test' })
: viteConfig;
// 中文注释:创作入口配置是底部加号入口的首屏事实源,漏配代理会回退 index.html。
expect(resolvedConfig.server?.proxy).toEqual(
expect.objectContaining({
'/api/creation-entry': expect.objectContaining({
target: expect.any(String),
changeOrigin: true,
secure: false,
}),
}),
// 中文注释:`/api/creation-entry/config` 已在 api-server 侧退役(见
// `app.rs` 的 retired_creation_template_routes_are_not_mounted),dev 中间件按
// 退役路径返回 404;这里断言它不会被代理回真实 API,避免把已退役接口重新接上。
expect(resolvedConfig.server?.proxy).not.toHaveProperty(
'/api/creation-entry',
);
expect(isRetiredApiPath('/api/creation-entry/config')).toBe(true);
});
it('forwards the admin route to the admin dev server', async () => {
@@ -57,4 +54,23 @@ describe('vite dev api proxy', () => {
}),
);
});
it('forwards the game distribution route to the Rust API server', async () => {
const resolvedConfig =
typeof viteConfig === 'function'
? await viteConfig({ command: 'serve', mode: 'test' })
: viteConfig;
// 中文注释:游戏目录、详情、发行网关和发布写入都在 `/api/game-distribution/*`;
// 漏配代理会回退 index.html,前端解析 JSON 时直接失败。
expect(resolvedConfig.server?.proxy).toEqual(
expect.objectContaining({
'/api/game-distribution': expect.objectContaining({
target: expect.any(String),
changeOrigin: true,
secure: false,
}),
}),
);
});
});
+34 -7
View File
@@ -1602,7 +1602,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
justify-content: center;
border: 1px solid var(--platform-subpanel-border);
border-radius: 0.78rem;
background: rgba(255, 255, 255, 0.04);
background: var(--platform-chip-idle-fill, rgba(255, 253, 250, 0.72));
color: var(--platform-text-base);
padding: 0 0.92rem;
font-size: 0.88rem;
@@ -1610,10 +1610,30 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
white-space: nowrap;
}
/* 与共享样式表同一口径:未选是浅底描边,选中是实心填充 + 反白文字。
这里的规则必须写在共享样式之后(本文件在 @import 之后),否则会被
`.platform-category-chip` 的基础底色压回去。 */
.platform-category-chip--active {
border-color: var(--platform-cool-border);
background: var(--platform-cool-bg);
color: var(--platform-cool-text);
border-color: var(--platform-chip-active-border, #7d3719);
background: var(
--platform-chip-active-fill,
linear-gradient(135deg, #b3542f, #8f3f22)
);
color: var(--platform-chip-active-text, #fffaf5);
box-shadow: var(
--platform-chip-active-shadow,
0 6px 14px rgba(150, 71, 39, 0.24)
);
}
.platform-category-chip__count {
font-size: 0.72rem;
font-weight: 600;
color: var(--platform-text-soft);
}
.platform-category-chip--active .platform-category-chip__count {
color: inherit;
}
.platform-category-sort-button {
@@ -1652,9 +1672,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.platform-category-filter-dialog__option--active {
border-color: var(--platform-cool-border);
background: var(--platform-cool-bg);
color: var(--platform-cool-text);
border-color: var(--platform-chip-active-border, #7d3719);
background: var(
--platform-chip-active-fill,
linear-gradient(135deg, #b3542f, #8f3f22)
);
color: var(--platform-chip-active-text, #fffaf5);
box-shadow: var(
--platform-chip-active-shadow,
0 6px 14px rgba(150, 71, 39, 0.24)
);
}
.platform-category-filter-dialog__actions {
+10
View File
@@ -22,6 +22,13 @@ describe('appPageRoutes', () => {
expect(resolveSelectionStageFromPath('/EDITOR/CANVAS/')).toBe(
'image-editor',
);
expect(resolveSelectionStageFromPath('/games')).toBe('games');
expect(resolveSelectionStageFromPath('/games/detail/')).toBe('game-detail');
expect(resolveSelectionStageFromPath('/games/play')).toBe('game-play');
expect(resolveSelectionStageFromPath('/games/mine')).toBe('game-mine');
expect(resolveSelectionStageFromPath('/games/publish')).toBe(
'game-publish',
);
expect(resolveSelectionStageFromPath('/creation/rpg')).toBe('platform');
expect(resolveSelectionStageFromPath('/runtime/puzzle')).toBe('platform');
expect(isKnownMainAppPagePath('/creation')).toBe(true);
@@ -31,6 +38,9 @@ describe('appPageRoutes', () => {
expect(resolvePathForSelectionStage('creation-home')).toBe('/creation');
expect(resolvePathForSelectionStage('project')).toBe('/project');
expect(resolvePathForSelectionStage('profile')).toBe('/profile');
expect(resolvePathForSelectionStage('games')).toBe('/games');
expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine');
expect(resolvePathForSelectionStage('game-publish')).toBe('/games/publish');
});
it('requires a project id for direct editor navigation', () => {
+5
View File
@@ -9,6 +9,11 @@ const STAGE_ROUTE_ENTRIES = [
['project', '/project'],
['profile', '/profile'],
['image-editor', '/editor/canvas'],
['games', '/games'],
['game-detail', '/games/detail'],
['game-play', '/games/play'],
['game-mine', '/games/mine'],
['game-publish', '/games/publish'],
] as const satisfies readonly (readonly [SelectionStage, string])[];
export const APP_STAGE_ROUTES: Record<SelectionStage, string> =
+7
View File
@@ -32,6 +32,13 @@ describe('activeAppTitle', () => {
expect(resolveAppTitleForSelectionStage('image-editor')).toBe(
'美术编辑器 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('games')).toBe('游戏 - 陶泥儿');
expect(resolveAppTitleForSelectionStage('game-mine')).toBe(
'我的游戏 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('game-publish')).toBe(
'发布游戏 - 陶泥儿',
);
});
test('syncs browser and host titles', () => {

Some files were not shown because too many files have changed in this diff Show More