Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / Backend tests (pull_request) Failing after 48s
Project CI / Native shell tests (pull_request) Failing after 3m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m21s
Project CI / Frontend tests (pull_request) Failing after 47s
Project CI / AI game creator shell web tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m23s
Project CI / Repository checks (pull_request) Successful in 6m31s
Project CI / Backend tests (pull_request) Failing after 48s
Project CI / Native shell tests (pull_request) Failing after 3m50s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 5m21s
Project CI / Frontend tests (pull_request) Failing after 47s
Project CI / AI game creator shell web tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m6s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m23s
Project CI / Repository checks (pull_request) Successful in 6m31s
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -0,0 +1,349 @@
|
||||
/*
|
||||
* 游戏详情展示层样式:公开游戏详情与后台审核详情共用同一套 hero、封面/截图、正文与资料侧栏。
|
||||
*
|
||||
* 这里只放表现。游戏数据加载、游玩入口、评价与审核动作都留在各自宿主里,
|
||||
* 组件本身不取数、不鉴权。类名统一用 `game-detail-` 前缀,避免与宿主自己的
|
||||
* 游戏卡片(`.game-cover` / `.game-eyebrow` / `.game-avatar` 等)互相影响。
|
||||
*/
|
||||
|
||||
.game-detail-display {
|
||||
min-width: 0;
|
||||
color: var(--platform-text-strong);
|
||||
font-family: var(--platform-font-family, system-ui, sans-serif);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.game-detail-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
color: var(--platform-accent-strong);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.game-detail-eyebrow svg {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
}
|
||||
|
||||
/* 封面:素材未就绪时用渐变 + 图标占位,换签成功后图片覆盖在占位之上。 */
|
||||
.game-detail-cover {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 12rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.game-detail-cover__grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.22;
|
||||
background-image: linear-gradient(
|
||||
rgba(255, 255, 255, 0.2) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.2) 1px, transparent 1px);
|
||||
background-size: 2rem 2rem;
|
||||
transform: perspective(12rem) rotateX(58deg) scale(1.7) translateY(25%);
|
||||
}
|
||||
|
||||
.game-detail-cover__glow {
|
||||
position: absolute;
|
||||
width: 70%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 236, 204, 0.5),
|
||||
transparent 66%
|
||||
);
|
||||
filter: blur(0.6rem);
|
||||
}
|
||||
|
||||
.game-detail-cover__icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
width: 5.4rem;
|
||||
aspect-ratio: 1;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.13);
|
||||
box-shadow: 0 1rem 2rem rgba(0, 0, 0, 0.2);
|
||||
color: #fff5dc;
|
||||
font-size: 3rem;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.game-detail-cover > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
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 {
|
||||
position: relative;
|
||||
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 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.game-detail-thumb__placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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-detail-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);
|
||||
font-weight: 400;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
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-detail-avatar {
|
||||
display: grid;
|
||||
width: 1.8rem;
|
||||
height: 1.8rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(199, 101, 61, 0.18);
|
||||
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-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-detail-tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
margin-top: 1.3rem;
|
||||
}
|
||||
|
||||
.game-detail-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-detail-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-detail-info-card strong {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.game-detail-info-card span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.28rem;
|
||||
color: var(--platform-text-soft);
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.game-detail-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;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.game-detail-hero {
|
||||
grid-template-columns: 9rem minmax(0, 1fr);
|
||||
gap: 1.15rem;
|
||||
}
|
||||
|
||||
.game-detail-hero .game-detail-cover {
|
||||
min-height: 9rem;
|
||||
}
|
||||
|
||||
.game-detail-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) {
|
||||
.game-detail-thumb {
|
||||
width: 4.6rem;
|
||||
}
|
||||
|
||||
.game-detail-hero {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.game-detail-hero .game-detail-cover {
|
||||
min-height: 13rem;
|
||||
margin-top: 1.2rem;
|
||||
}
|
||||
|
||||
.game-detail-hero h1 {
|
||||
margin-top: 1.4rem;
|
||||
}
|
||||
|
||||
.game-detail-side {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { GameDetailDisplay } from './index';
|
||||
|
||||
describe('GameDetailDisplay', () => {
|
||||
test('renders host-provided data and switches the hero through asset urls', async () => {
|
||||
render(
|
||||
<GameDetailDisplay
|
||||
game={{
|
||||
title: '星轨防线',
|
||||
summary: '守住最后一条航线。',
|
||||
description: '一款测试游戏。',
|
||||
category: '动作',
|
||||
tags: ['测试'],
|
||||
author: { name: '陶泥儿' },
|
||||
deviceSupport: { desktop: true, mobile: false, touch: false },
|
||||
playCount: 1200,
|
||||
}}
|
||||
coverObjectKey="cover/alpha.png"
|
||||
screenshotObjectKeys={['screenshot/one.png']}
|
||||
assetUrls={{
|
||||
'cover/alpha.png': 'https://assets.example.test/cover/alpha.png',
|
||||
'screenshot/one.png':
|
||||
'https://assets.example.test/screenshot/one.png',
|
||||
}}
|
||||
controlsLabel="键盘"
|
||||
versionLabel="v2"
|
||||
rating={<span>评分 9.1</span>}
|
||||
actions={<button type="button">试玩当前待审版本</button>}
|
||||
infoCards={[{ label: '发行包', value: '2.0 MiB' }]}
|
||||
footnoteText="游戏运行在独立的安全容器中。"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('heading', { name: '星轨防线' })).toBeTruthy();
|
||||
expect(screen.getByText('由 陶泥儿 制作')).toBeTruthy();
|
||||
expect(screen.getByText(/次游玩$/u)).toBeTruthy();
|
||||
expect(screen.getByText('评分 9.1')).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '试玩当前待审版本' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('键盘')).toBeTruthy();
|
||||
expect(screen.getByText('v2')).toBeTruthy();
|
||||
expect(screen.getByText('发行包').parentElement?.textContent).toBe(
|
||||
'发行包2.0 MiB',
|
||||
);
|
||||
expect(screen.getByText('游戏运行在独立的安全容器中。')).toBeTruthy();
|
||||
|
||||
const heroImage = () =>
|
||||
document.querySelector('.game-detail-cover img')?.getAttribute('src');
|
||||
expect(heroImage()).toBe('https://assets.example.test/cover/alpha.png');
|
||||
|
||||
const thumbs = screen.getAllByRole('button', { name: /(查看大图)/u });
|
||||
expect(thumbs).toHaveLength(2);
|
||||
fireEvent.click(thumbs[1]!);
|
||||
await waitFor(() =>
|
||||
expect(heroImage()).toBe(
|
||||
'https://assets.example.test/screenshot/one.png',
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
import './index.css';
|
||||
|
||||
import { Maximize2, Smartphone, Sparkles } from 'lucide-react';
|
||||
import { type ReactElement, type ReactNode, useState } from 'react';
|
||||
|
||||
/** 详情展示里素材出现的位置:hero 大图与缩略图条。 */
|
||||
export type GameDetailAssetRole = 'cover' | 'thumb';
|
||||
|
||||
export interface GameDetailAssetRenderInput {
|
||||
/** 素材 objectKey,宿主据此换签。 */
|
||||
objectKey: string;
|
||||
role: GameDetailAssetRole;
|
||||
/** 该素材当前是否为 hero 大图 / 已选中的缩略图。 */
|
||||
active: boolean;
|
||||
/** 素材名(封面、截图 1…),用于缩略图无障碍标签。 */
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface GameDetailShowcaseProps {
|
||||
/** 封面 objectKey;为空时只展示彩色占位。 */
|
||||
coverObjectKey?: string | null;
|
||||
/** 截图 objectKey(不含封面),最多展示 6 张。 */
|
||||
screenshotObjectKeys?: string[];
|
||||
/** 占位渐变的起始色,缺省用平台默认橙色。 */
|
||||
coverColor?: string;
|
||||
/** 占位图标,缺省 ✦。 */
|
||||
icon?: string;
|
||||
/** objectKey → 已换签的展示地址;缺项按占位处理。 */
|
||||
assetUrls?: Readonly<Record<string, string | null | undefined>>;
|
||||
/**
|
||||
* 覆盖默认的图片渲染:宿主可以用自己的取址钩子逐项渲染 `<img>`(后台则是已取到的地址)。
|
||||
* 返回空节点即保留占位视觉;返回的图片无需类名,样式由展示层提供。
|
||||
*/
|
||||
renderAsset?: (input: GameDetailAssetRenderInput) => ReactElement | null;
|
||||
}
|
||||
|
||||
export interface GameDetailDisplayInfoCard {
|
||||
/** 资料卡标题,如「发行包」。 */
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
}
|
||||
|
||||
export interface GameDetailDisplayProps extends GameDetailShowcaseProps {
|
||||
game: {
|
||||
title: string;
|
||||
summary: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
author: { name: string };
|
||||
deviceSupport: {
|
||||
desktop: boolean;
|
||||
mobile: boolean;
|
||||
touch: boolean;
|
||||
};
|
||||
/** 缺省时不显示游玩次数。 */
|
||||
playCount?: number;
|
||||
};
|
||||
/** 「游玩方式」文案;缺省「未标注操作方式」。 */
|
||||
controlsLabel?: string;
|
||||
/** 「当前版本」文案;缺省 —。 */
|
||||
versionLabel?: string;
|
||||
/** 追加到资料侧栏的卡片(宿主自定义信息)。 */
|
||||
infoCards?: GameDetailDisplayInfoCard[];
|
||||
/** hero 里的评分摘要(公开详情传评分,后台可省略)。 */
|
||||
rating?: ReactNode;
|
||||
/** hero 里的主行动作区(公开详情是「立即玩」,后台是「试玩待审版本」)。 */
|
||||
actions?: ReactNode;
|
||||
/** hero 与正文之间的内容(作者作品状态、后台版本与审核信息等)。 */
|
||||
children?: ReactNode;
|
||||
/** 正文之后的脚注文案,带全屏提示图标;缺省不渲染。 */
|
||||
footnoteText?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_COVER_COLOR = '#d77a51';
|
||||
const DEFAULT_COVER_ICON = '✦';
|
||||
const MAX_SCREENSHOTS = 6;
|
||||
|
||||
/**
|
||||
* 素材渲染:宿主给了 `renderAsset` 就交给宿主(公开详情按 objectKey 逐个换签),
|
||||
* 否则用宿主传入的 `assetUrls` 直接渲染 `<img>`;没有地址时返回空节点,由展示层占位兜底。
|
||||
*/
|
||||
function GameDetailAsset({
|
||||
objectKey,
|
||||
role,
|
||||
active,
|
||||
label,
|
||||
assetUrls,
|
||||
renderAsset,
|
||||
}: GameDetailAssetRenderInput & {
|
||||
assetUrls?: GameDetailShowcaseProps['assetUrls'];
|
||||
renderAsset?: GameDetailShowcaseProps['renderAsset'];
|
||||
}): ReactElement | null {
|
||||
if (renderAsset) return renderAsset({ objectKey, role, active, label });
|
||||
const url = (assetUrls?.[objectKey] ?? '').trim();
|
||||
if (!url) return null;
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt=""
|
||||
loading={role === 'cover' ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 封面区:默认展示封面,存在截图时在下方给出可点击的缩略图条。
|
||||
*
|
||||
* 缩略图第一项固定是封面本身,方便从截图切回封面;只有封面时整条不渲染。
|
||||
* 素材地址由宿主提供(`assetUrls` 或 `renderAsset`),组件自身不取数。
|
||||
*/
|
||||
export function GameDetailShowcase({
|
||||
coverObjectKey,
|
||||
screenshotObjectKeys,
|
||||
coverColor,
|
||||
icon,
|
||||
assetUrls,
|
||||
renderAsset,
|
||||
}: GameDetailShowcaseProps) {
|
||||
const coverKey = (coverObjectKey ?? '').trim();
|
||||
const screenshotKeys = (screenshotObjectKeys ?? [])
|
||||
.map((key) => key.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, MAX_SCREENSHOTS);
|
||||
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">
|
||||
<div
|
||||
className="game-detail-cover"
|
||||
style={{
|
||||
background: `linear-gradient(145deg, ${coverColor ?? DEFAULT_COVER_COLOR}, #16151c)`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span className="game-detail-cover__glow" />
|
||||
<span className="game-detail-cover__icon">
|
||||
{icon ?? DEFAULT_COVER_ICON}
|
||||
</span>
|
||||
<span className="game-detail-cover__grid" />
|
||||
<GameDetailAsset
|
||||
objectKey={activeKey}
|
||||
role="cover"
|
||||
active
|
||||
label="封面"
|
||||
assetUrls={assetUrls}
|
||||
renderAsset={renderAsset}
|
||||
/>
|
||||
</div>
|
||||
{items.length > 1 ? (
|
||||
<div className="game-detail-thumbs" role="group" aria-label="游戏截图">
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className="game-detail-thumb"
|
||||
aria-pressed={index === activeIndex}
|
||||
aria-label={`${item.label}(查看大图)`}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
>
|
||||
<span className="game-detail-thumb__placeholder" />
|
||||
<GameDetailAsset
|
||||
objectKey={item.key}
|
||||
role="thumb"
|
||||
active={index === activeIndex}
|
||||
label={item.label}
|
||||
assetUrls={assetUrls}
|
||||
renderAsset={renderAsset}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 游戏详情展示:hero、封面区、正文与资料侧栏。
|
||||
*
|
||||
* 纯受控展示组件——游戏数据、素材地址、评分、主行动作与附加区块全部由宿主传入,
|
||||
* 组件不加载数据、不鉴权、不接管游玩或评价逻辑,公开详情与后台审核详情共用同一套视觉。
|
||||
*/
|
||||
export function GameDetailDisplay({
|
||||
game,
|
||||
coverObjectKey,
|
||||
screenshotObjectKeys,
|
||||
coverColor,
|
||||
icon,
|
||||
assetUrls,
|
||||
renderAsset,
|
||||
controlsLabel,
|
||||
versionLabel,
|
||||
infoCards,
|
||||
rating,
|
||||
actions,
|
||||
children,
|
||||
footnoteText,
|
||||
className,
|
||||
}: GameDetailDisplayProps) {
|
||||
const controls = (controlsLabel ?? '').trim();
|
||||
const version = (versionLabel ?? '').trim();
|
||||
/** 作者名由宿主解析(公开详情会按作者 ID 补查真实昵称);空名不渲染,避免出现「由 — 制作」。 */
|
||||
const authorName = (game.author.name ?? '').trim();
|
||||
return (
|
||||
<div
|
||||
className={['game-detail-display', className].filter(Boolean).join(' ')}
|
||||
>
|
||||
<section className="game-detail-hero">
|
||||
<GameDetailShowcase
|
||||
coverObjectKey={coverObjectKey}
|
||||
screenshotObjectKeys={screenshotObjectKeys}
|
||||
coverColor={coverColor}
|
||||
icon={icon}
|
||||
assetUrls={assetUrls}
|
||||
renderAsset={renderAsset}
|
||||
/>
|
||||
<div className="game-detail-hero__copy">
|
||||
<span className="game-detail-eyebrow">
|
||||
<Sparkles aria-hidden="true" /> {game.category}
|
||||
</span>
|
||||
<h1>{game.title}</h1>
|
||||
<p className="game-detail-hero__summary">{game.summary}</p>
|
||||
{rating ? (
|
||||
<div className="game-detail-hero__rating">{rating}</div>
|
||||
) : null}
|
||||
{authorName ? (
|
||||
<div className="game-detail-hero__author">
|
||||
<span className="game-detail-avatar" aria-hidden="true">
|
||||
{Array.from(authorName)[0]}
|
||||
</span>
|
||||
<span title={authorName}>由 {authorName} 制作</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="game-detail-hero__actions">
|
||||
{actions}
|
||||
{typeof game.playCount === 'number' ? (
|
||||
<span className="game-detail-hero__play-count">
|
||||
{game.playCount.toLocaleString()} 次游玩
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{children}
|
||||
<section className="game-detail-body">
|
||||
<div className="game-detail-main">
|
||||
<h2>关于这款游戏</h2>
|
||||
<p>{game.description}</p>
|
||||
<div className="game-detail-tag-list">
|
||||
{game.tags.map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<aside className="game-detail-side">
|
||||
<div className="game-detail-info-card">
|
||||
<strong>游玩方式</strong>
|
||||
<span>{controls || '未标注操作方式'}</span>
|
||||
</div>
|
||||
<div className="game-detail-info-card">
|
||||
<strong>设备支持</strong>
|
||||
<span>
|
||||
<Smartphone aria-hidden="true" />{' '}
|
||||
{game.deviceSupport.mobile ? '移动端' : ''}
|
||||
{game.deviceSupport.desktop ? ' · 桌面端' : ''}
|
||||
{game.deviceSupport.touch ? ' · 支持触屏' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="game-detail-info-card">
|
||||
<strong>当前版本</strong>
|
||||
<span>{version || '—'}</span>
|
||||
</div>
|
||||
{(infoCards ?? []).map((card) => (
|
||||
<div className="game-detail-info-card" key={card.label}>
|
||||
<strong>{card.label}</strong>
|
||||
<span>{card.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
</section>
|
||||
{footnoteText ? (
|
||||
<p className="game-detail-footnote">
|
||||
<Maximize2 aria-hidden="true" /> {footnoteText}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { formatAdminDate, formatAdminDateTime } from './AdminFormat';
|
||||
|
||||
describe('formatAdminDateTime', () => {
|
||||
test('把后端的 seconds.microsZ 口径当成同一时刻渲染', () => {
|
||||
// 游戏分发后台接口下发的是 shared-kernel `format_timestamp_micros` 的
|
||||
// `seconds.microsZ` 文本(非 ISO 8601),必须与等价 ISO 时刻渲染一致。
|
||||
const microsText = '1791008678.817257Z';
|
||||
const equivalentIso = new Date(1791008678 * 1000 + 817).toISOString();
|
||||
|
||||
expect(formatAdminDateTime(microsText)).toBe(
|
||||
formatAdminDateTime(equivalentIso),
|
||||
);
|
||||
expect(formatAdminDateTime(microsText)).not.toBe('-');
|
||||
});
|
||||
|
||||
test('微秒位数不足时按毫秒补零,而不是丢位', () => {
|
||||
expect(formatAdminDateTime('1791008678.8Z')).toBe(
|
||||
formatAdminDateTime(new Date(1791008678 * 1000 + 800).toISOString()),
|
||||
);
|
||||
});
|
||||
|
||||
test('ISO 字符串、epoch 毫秒与 Date 仍按原语义渲染', () => {
|
||||
const iso = '2026-09-20T08:00:00Z';
|
||||
expect(formatAdminDateTime(iso)).toBe(formatAdminDateTime(new Date(iso)));
|
||||
expect(formatAdminDateTime(Date.parse(iso))).toBe(
|
||||
formatAdminDateTime(new Date(iso)),
|
||||
);
|
||||
});
|
||||
|
||||
test('无法解析的值回落 fallback', () => {
|
||||
expect(formatAdminDateTime('not-a-time')).toBe('-');
|
||||
expect(formatAdminDateTime('')).toBe('-');
|
||||
expect(formatAdminDateTime(null)).toBe('-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatAdminDate', () => {
|
||||
test('同样接受 seconds.microsZ 口径', () => {
|
||||
expect(formatAdminDate('1791008678.817257Z')).toBe(
|
||||
formatAdminDate(new Date(1791008678 * 1000 + 817).toISOString()),
|
||||
);
|
||||
expect(formatAdminDate('1791008678.817257Z')).not.toBe('-');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,28 @@
|
||||
/**
|
||||
* 后台统一的时间展示:ISO 字符串 / epoch 毫秒 / Date → `zh-CN` 本地时间。
|
||||
* 部分后台接口下发的微秒时间戳是 `seconds.microsZ` 文本口径(见 shared-kernel 的
|
||||
* `format_timestamp_micros`,例如 `1791008678.817257Z`)。它不是 ISO 8601,
|
||||
* JS `new Date()` 会直接判成 `Invalid Date`,因此先归一成 ISO 字符串再解析。
|
||||
* 其它输入原样返回,交给 `new Date()` 处理。
|
||||
*/
|
||||
function normalizeAdminTimestamp(value: string | number): string | number {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
const micros = /^(\d+)\.(\d{1,9})Z$/.exec(value.trim());
|
||||
if (!micros) {
|
||||
return value;
|
||||
}
|
||||
const seconds = Number(micros[1]);
|
||||
const fraction = micros[2] ?? '';
|
||||
if (!Number.isSafeInteger(seconds)) {
|
||||
return value;
|
||||
}
|
||||
const millis = Number(fraction.padEnd(9, '0').slice(0, 3));
|
||||
return new Date(seconds * 1000 + millis).toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 后台统一的时间展示:ISO 字符串 / `seconds.microsZ` / epoch 毫秒 / Date → `zh-CN` 本地时间。
|
||||
* 解析不出来时回落到 `fallback`,避免页面上出现 `Invalid Date` 或裸 ISO 串。
|
||||
*/
|
||||
export function formatAdminDateTime(
|
||||
@@ -10,7 +33,8 @@ export function formatAdminDateTime(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
const date =
|
||||
value instanceof Date ? value : new Date(normalizeAdminTimestamp(value));
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -30,7 +54,7 @@ export function formatAdminDateTime(
|
||||
}
|
||||
}
|
||||
|
||||
/** 后台统一的日期展示(不含时分秒)。 */
|
||||
/** 后台统一的日期展示(不含时分秒):同样接受 `seconds.microsZ` 口径。 */
|
||||
export function formatAdminDate(
|
||||
value: string | number | Date | null | undefined,
|
||||
fallback = '-',
|
||||
@@ -39,7 +63,8 @@ export function formatAdminDate(
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
const date =
|
||||
value instanceof Date ? value : new Date(normalizeAdminTimestamp(value));
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@@ -191,6 +191,40 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/*
|
||||
* 审核动作行约定:审核页里「详情 / 通过 / 拒绝 / 安全下架」等并列动作统一排布,
|
||||
* 并统一按钮高度、圆角、字号与图文间距,保证同一行按钮视觉齐平。
|
||||
* 规则限定在本类内部,其它后台按钮(含作为图标按钮使用的 ghost)不受影响。
|
||||
*/
|
||||
.admin-review-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-review-actions .admin-primary-button,
|
||||
.admin-review-actions .admin-secondary-button,
|
||||
.admin-review-actions .admin-danger-button,
|
||||
.admin-review-actions .admin-ghost-button {
|
||||
gap: 8px;
|
||||
width: auto;
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 审核动作里的 ghost 承载「拒绝」等次要动作:保留浅暖底,只还原成常规按钮尺寸。 */
|
||||
.admin-review-actions .admin-ghost-button {
|
||||
color: #755a49;
|
||||
background: #f8efe7;
|
||||
}
|
||||
|
||||
.admin-alert {
|
||||
border: 1px solid #e2b9a4;
|
||||
border-radius: 8px;
|
||||
@@ -397,7 +431,8 @@
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
/* 状态标签是短徽标:必须单行,避免在窄列里被拆成每行一两个字。 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-status-ok {
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
export type {
|
||||
GameDetailAssetRenderInput,
|
||||
GameDetailAssetRole,
|
||||
GameDetailDisplayInfoCard,
|
||||
GameDetailDisplayProps,
|
||||
GameDetailShowcaseProps,
|
||||
} from './GameDetailDisplay';
|
||||
export { GameDetailDisplay, GameDetailShowcase } from './GameDetailDisplay';
|
||||
export { GameRatingSummaryText } from './GameRatingSummaryText';
|
||||
export type {
|
||||
PlatformActionButtonAlign,
|
||||
|
||||
@@ -959,6 +959,44 @@ export interface GameIterationVersion {
|
||||
editPrompt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AGC 本地发布绑定:记录该本地项目对应哪个平台账号 / 服务 origin 的哪个游戏与最近版本。
|
||||
*
|
||||
* `accountId` + `apiBaseUrl` 是隔离键:切换账号或服务环境后,同一份本地清单不得被当成
|
||||
* 另一个账号的作品来更新;缺失或与当前会话不匹配时按未发布处理。`revision` 是游戏公开
|
||||
* 修订号,更新送审时用作 `expectedPublicationRevision` CAS。
|
||||
*/
|
||||
export interface GameCreationAppPublicationBinding {
|
||||
accountId: string;
|
||||
apiBaseUrl: string;
|
||||
gameId: string;
|
||||
revision: number;
|
||||
latestVersionId?: string | null;
|
||||
latestVersionNumber?: number | null;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
/** 项目版本标签的建议默认值:没有任何内部版本记录时的首个版本。 */
|
||||
export const GAME_CREATION_APP_FIRST_PROJECT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* 发布面板展示的「项目版本」的唯一权威值:AGC 工程内部版本序数。
|
||||
*
|
||||
* 唯一来源是清单里的 `versions[]`(AGC 工程内部版本记录,即资源总览「项目版本」栏目里的
|
||||
* 版本卡):标签等于当前存活的内部版本条数,也就是最新内部版本卡的「版本 N」;没有版本
|
||||
* 记录时用首版 1。
|
||||
*
|
||||
* 这条值**只读**,且不由平台侧任何字段推导:不读发布绑定里的 `latestVersionNumber`,
|
||||
* 也不读 `publicationRevision`。平台 `versionNumber` 与本地标签是两套独立概念,并列展示、
|
||||
* 互不推导。清单里遗留的 `projectVersion` 只做兼容解析,不参与本函数。
|
||||
*/
|
||||
export function resolveGameCreationAppProjectVersion(
|
||||
manifest: Pick<GameCreationAppManifest, 'versions'>,
|
||||
): number {
|
||||
const count = manifest.versions?.length ?? 0;
|
||||
return count > 0 ? count : GAME_CREATION_APP_FIRST_PROJECT_VERSION;
|
||||
}
|
||||
|
||||
export interface GameCreationAppManifest {
|
||||
schemaVersion: string;
|
||||
projectId: string;
|
||||
@@ -971,6 +1009,15 @@ export interface GameCreationAppManifest {
|
||||
versions?: GameIterationVersion[];
|
||||
godotProjectRoot?: string | null;
|
||||
cocosProjectRoot?: string | null;
|
||||
/**
|
||||
* 遗留位:历史清单写过的用户发行版本标签,仅为兼容旧清单可解析而保留。
|
||||
*
|
||||
* 它**不再**是发行版本来源:读取方一律用 `resolveGameCreationAppProjectVersion` 按工程
|
||||
* 内部版本 `versions[]` 派生,原生也不再写入或回填这个字段(下一次落盘时会被抹掉)。
|
||||
*/
|
||||
projectVersion?: number | null;
|
||||
/** 平台发布绑定;缺失即该项目尚未发布过。 */
|
||||
publication?: GameCreationAppPublicationBinding | null;
|
||||
}
|
||||
|
||||
export interface GameCreationAgentToolCallTrace {
|
||||
|
||||
@@ -168,6 +168,14 @@ export type GameDistributionCreateGameRequest = {
|
||||
|
||||
export type GameDistributionCreateVersionRequest = {
|
||||
localProjectId?: string | null;
|
||||
/**
|
||||
* 用户可见的正整数版本标签(AGC 发布面板由工程内部版本序数派生后原样提交)。
|
||||
*
|
||||
* 传入时只要求 `>= 1`:同一 `gameId` 的同一个版本号可以反复提交,每次提交生成新的
|
||||
* `versionId`,允许重复标签与回退到更小的版本号,不与已有最大值比较。
|
||||
* 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。
|
||||
*/
|
||||
versionNumber?: number | null;
|
||||
packageSha256: string;
|
||||
packageBytes: number;
|
||||
packageFileCount: number;
|
||||
@@ -175,20 +183,43 @@ export type GameDistributionCreateVersionRequest = {
|
||||
gameMetadata: GameDistributionCreateGameRequest;
|
||||
};
|
||||
|
||||
/**
|
||||
* 作者编辑游戏级展示资料。
|
||||
*
|
||||
* 只覆盖游戏行上的展示字段;随版本冻结的包摘要与资料快照不受影响。
|
||||
* `expectedPublicationRevision` 是公开切换 CAS,并发变化返回 409。
|
||||
*/
|
||||
export type GameDistributionUpdateGameMetadataRequest = {
|
||||
expectedPublicationRevision: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
description?: string | null;
|
||||
category: GameDistributionCategory;
|
||||
tags?: string[];
|
||||
coverAssetId?: string | null;
|
||||
/** 截图素材 ID(最多 6 张,复用平台图片上传与归属校验)。 */
|
||||
screenshots?: string[];
|
||||
deviceSupport: GameDistributionDeviceSupport;
|
||||
inputModes: GameDistributionInputMode[];
|
||||
orientation: GameDistributionOrientation;
|
||||
};
|
||||
|
||||
export type GameDistributionPrivateVersion = {
|
||||
versionId: string;
|
||||
gameId: string;
|
||||
versionNumber: number;
|
||||
packageSha256: string;
|
||||
packageBytes: number;
|
||||
packageFileCount: number;
|
||||
status: GameDistributionVersionStatus;
|
||||
/** 游戏公开修订号;撤回与审核动作都必须回传当前值做 CAS。 */
|
||||
publicationRevision: number;
|
||||
reviewReason?: string | null;
|
||||
/** 审核通过后由服务端派生的公开入口,未公开版本为 null。 */
|
||||
entryUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 版本状态的下一步动作,由服务端派生;客户端只按它渲染主行动作,不自行推断状态。
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user