完善游戏审核试玩与客户端版本更新
后台审核详情复用公开游戏详情样式,补齐发布者、冻结资料、素材换签和待审 ZIP 试玩。 AGC 保存已发布作品绑定,支持同一 gameId 更新版本并由用户填写目标版本号。 保留 SpacetimeDB 表结构不变,增加版本号请求校验、幂等边界和本地清单字段。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user