合并 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user