完善游戏审核试玩与客户端版本更新

后台审核详情复用公开游戏详情样式,补齐发布者、冻结资料、素材换签和待审 ZIP 试玩。

AGC 保存已发布作品绑定,支持同一 gameId 更新版本并由用户填写目标版本号。

保留 SpacetimeDB 表结构不变,增加版本号请求校验、幂等边界和本地清单字段。
This commit is contained in:
2026-10-03 12:35:57 +08:00
parent 783ca014ef
commit 44f02ee19b
42 changed files with 3721 additions and 488 deletions
@@ -1,13 +1,10 @@
import './gameDistribution.css';
import './gameDistributionShowcase.css';
import {
ArrowLeft,
Gamepad2,
Maximize2,
Smartphone,
Sparkles,
} from 'lucide-react';
GameDetailDisplay,
type GameDetailAssetRenderInput,
} from '@genarrative/shared/components';
import { ArrowLeft, Gamepad2, Maximize2 } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
@@ -32,7 +29,6 @@ import {
normalizeGameId,
useIsMobileViewport,
} from './gameDistributionGuards';
import { GameCover } from './GameGalleryPage';
import { GAME_METADATA_INPUT_MODE_LABELS } from './gameMetadataOptions';
import { GameRatingSummary, GameReviews } from './GameReviews';
import { GameVersionHistory } from './GameVersionHistory';
@@ -83,74 +79,23 @@ function resolveControlsLabel(
return fallback || '未标注操作方式';
}
/** 缩略图自己换签,失败时退回空占位,不阻塞 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>
);
}
/**
* 详情页封面区:默认展示封面,存在截图时在下方给出可点击的缩略图条。
*
* 缩略图第一项固定是封面本身,方便作者/玩家从截图切回封面;只有封面时整条不渲染。
* 展示层需要的素材换签桥接:公开详情仍按 objectKey 逐个换签,
* 只是把结果交给共享展示组件渲染,换签失败时保持占位。
*/
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;
function GameDetailResolvedAsset({
objectKey,
role,
}: GameDetailAssetRenderInput) {
const { resolvedUrl } = useResolvedAssetReadUrl(null, { objectKey });
if (!resolvedUrl) return null;
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>
<img
src={resolvedUrl}
alt=""
loading={role === 'cover' ? 'eager' : 'lazy'}
decoding="async"
/>
);
}
@@ -359,22 +304,18 @@ function GameDetailContent({
<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__rating">
<GameRatingSummary state={reviews} />
</div>
<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">
<GameDetailDisplay
game={game}
coverObjectKey={game.coverObjectKey}
screenshotObjectKeys={game.screenshots}
coverColor={game.coverColor}
icon={game.icon}
renderAsset={(asset) => <GameDetailResolvedAsset {...asset} />}
controlsLabel={resolveControlsLabel(version, game.inputModes)}
versionLabel={versionLabel}
rating={<GameRatingSummary state={reviews} />}
actions={
<>
<PlatformActionButton
size="lg"
shape="pill"
@@ -402,76 +343,44 @@ function GameDetailContent({
作品尚未公开,暂时没有在线可玩版本
</span>
) : null}
<span className="game-detail-hero__play-count">
{game.playCount.toLocaleString()} 次游玩
</span>
</div>
</div>
</section>
{ownerView && ownerGame ? (
<section className="game-owner-panel" aria-label="作品状态">
<div className="game-owner-panel__heading">
<h2>作品状态</h2>
<span
className={`my-game-status my-game-status--${ownerGame.status}`}
>
{GAME_STATUS_LABEL[ownerGame.status] ?? ownerGame.status}
</span>
</div>
<div className="game-owner-panel__meta">
{latestVersion ? (
<span>
最新版本 v{latestVersion.versionNumber} ·{' '}
{VERSION_STATUS_LABEL[latestVersion.status] ??
latestVersion.status}
</>
}
>
{ownerView && ownerGame ? (
<section className="game-owner-panel" aria-label="作品状态">
<div className="game-owner-panel__heading">
<h2>作品状态</h2>
<span
className={`my-game-status my-game-status--${ownerGame.status}`}
>
{GAME_STATUS_LABEL[ownerGame.status] ?? ownerGame.status}
</span>
) : (
<span>还没有提交过版本</span>
)}
</div>
{latestReviewReason ? (
<p className="game-owner-panel__reason">
驳回理由:{latestReviewReason}
</p>
) : null}
{ownerGame.versions.length > 0 ? (
<GameVersionHistory
versions={ownerGame.versions}
recoveryActions={recoveryActions}
/>
) : null}
</section>
) : null}
<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>{versionLabel || '—'}</span>
</div>
</aside>
</section>
</div>
<div className="game-owner-panel__meta">
{latestVersion ? (
<span>
最新版本 v{latestVersion.versionNumber} ·{' '}
{VERSION_STATUS_LABEL[latestVersion.status] ??
latestVersion.status}
</span>
) : (
<span>还没有提交过版本</span>
)}
</div>
{latestReviewReason ? (
<p className="game-owner-panel__reason">
驳回理由:{latestReviewReason}
</p>
) : null}
{ownerGame.versions.length > 0 ? (
<GameVersionHistory
versions={ownerGame.versions}
recoveryActions={recoveryActions}
/>
) : null}
</section>
) : null}
</GameDetailDisplay>
<GameReviews state={reviews} canReview={canReview} onLogin={onLogin} />
<p className="game-detail-footnote">
<Maximize2 aria-hidden="true" />{' '}
@@ -679,7 +679,7 @@ describe('游戏封面与截图展示', () => {
await screen.findByRole('heading', { name: '星轨防线' });
const heroImage = () =>
document
.querySelector('.game-detail-showcase img.game-cover__image')
.querySelector('.game-detail-cover img')
?.getAttribute('src');
expect(heroImage()).toBe(
'https://assets.example.test/game-distribution/cover/alpha.png',
@@ -724,7 +724,9 @@ describe('游戏封面与截图展示', () => {
);
await screen.findByRole('heading', { name: '星轨防线' });
expect(document.querySelectorAll('img.game-cover__image')).toHaveLength(0);
expect(document.querySelectorAll('.game-cover__icon')).toHaveLength(1);
expect(document.querySelectorAll('.game-detail-cover img')).toHaveLength(0);
expect(
document.querySelectorAll('.game-detail-cover__icon'),
).toHaveLength(1);
});
});
@@ -351,38 +351,6 @@
.game-back-button svg {
width: 1rem;
}
.game-detail-hero {
display: grid;
grid-template-columns: minmax(13rem, 21rem) minmax(0, 1fr);
align-items: center;
gap: clamp(1.4rem, 4vw, 3.5rem);
margin-top: 1.5rem;
}
.game-detail-hero .game-cover {
border-radius: 1.7rem;
box-shadow: 0 1.25rem 2.5rem rgba(70, 42, 34, 0.18);
}
.game-detail-hero h1 {
margin: 0.65rem 0 0;
font-size: clamp(2rem, 4.8vw, 3.7rem);
letter-spacing: -0.055em;
}
.game-detail-hero__summary {
max-width: 32rem;
margin: 0.8rem 0 0;
color: var(--platform-text-base);
font-size: 1rem;
line-height: 1.75;
}
.game-detail-hero__author {
display: flex;
align-items: center;
gap: 0.55rem;
margin-top: 1.15rem;
color: var(--platform-text-soft);
font-size: 0.78rem;
font-weight: 700;
}
.game-avatar {
display: grid;
width: 1.8rem;
@@ -393,91 +361,11 @@
color: var(--platform-accent-strong);
font-weight: 900;
}
.game-detail-hero__actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1.4rem;
}
.game-detail-hero__play-count {
color: var(--platform-text-soft);
font-size: 0.75rem;
}
.game-device-warning {
color: var(--platform-accent-strong);
font-size: 0.75rem;
font-weight: 800;
}
.game-detail-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 17rem;
gap: 3rem;
margin-top: 3rem;
border-top: 1px solid var(--platform-surface-border);
padding-top: 2rem;
}
.game-detail-main h2 {
margin: 0;
font-size: 1.25rem;
}
.game-detail-main p {
max-width: 45rem;
margin: 0.75rem 0 0;
color: var(--platform-text-base);
line-height: 1.9;
}
.game-tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
margin-top: 1.3rem;
}
.game-tag-list span {
border: 1px solid var(--platform-surface-border);
border-radius: 999px;
padding: 0.35rem 0.65rem;
color: var(--platform-text-soft);
font-size: 0.7rem;
font-weight: 700;
}
.game-detail-side {
display: grid;
gap: 0.7rem;
align-content: start;
}
.game-info-card {
display: grid;
gap: 0.3rem;
border: 1px solid var(--platform-surface-border);
border-radius: 1rem;
padding: 0.8rem 0.9rem;
background: var(--platform-panel-fill);
}
.game-info-card strong {
font-size: 0.72rem;
}
.game-info-card span {
display: inline-flex;
align-items: center;
gap: 0.28rem;
color: var(--platform-text-soft);
font-size: 0.72rem;
line-height: 1.45;
}
.game-info-card svg {
width: 0.85rem;
}
.game-detail-footnote {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 2.2rem;
color: var(--platform-text-soft);
font-size: 0.7rem;
}
.game-detail-footnote svg {
width: 0.9rem;
}
.game-loading-panel {
display: grid;
min-height: 18rem;
@@ -646,25 +534,11 @@
.game-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.game-detail-hero {
grid-template-columns: 9rem minmax(0, 1fr);
gap: 1.15rem;
}
.game-detail-hero .game-cover {
min-height: 9rem;
}
.game-cover__icon {
width: 3.7rem;
border-radius: 1rem;
font-size: 2rem;
}
.game-detail-body {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.game-detail-side {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 560px) {
@@ -704,19 +578,6 @@
.game-cover {
min-height: 10.5rem;
}
.game-detail-hero {
display: block;
}
.game-detail-hero .game-cover {
min-height: 13rem;
margin-top: 1.2rem;
}
.game-detail-hero h1 {
margin-top: 1.4rem;
}
.game-detail-side {
grid-template-columns: 1fr;
}
.game-play-page {
padding: 0.7rem;
}
@@ -1,8 +1,10 @@
/*
* 游戏分发封面与截图的展示层补充样式。
* `.game-cover` 的「图片已就绪」态样式。
*
* 真实封面/截图换签成功后覆盖原有渐变占位;未上传、换签失败或仍在解析时保持
* gameDistribution.css 里的占位视觉,因此这里的规则只负责“图片已就绪”这一态。
* 列表卡片、播放页与作者中心卡片共用:真实封面/截图换签成功后覆盖
* gameDistribution.css 里的渐变占位;未上传、换签失败或仍在解析时保持占位视觉,
* 所以这里只负责这一态。详情展示层有自己的 `.game-detail-cover`,见
* packages/shared/src/components/GameDetailDisplay/index.css。
*/
.game-cover__image {
position: absolute;
@@ -12,64 +14,3 @@
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;
}
}