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

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-10-05 10:50:30 +08:00
165 changed files with 13827 additions and 1670 deletions
@@ -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;
}
+36 -1
View File
@@ -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 {
+8
View File
@@ -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;
};
/**
* 版本状态的下一步动作,由服务端派生;客户端只按它渲染主行动作,不自行推断状态。
*/