Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m20s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m3s
Project CI / Frontend tests (pull_request) Successful in 2m54s
Project CI / Backend tests (pull_request) Successful in 7m36s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m26s
Project CI / Repository checks (pull_request) Successful in 6m25s
Project CI / Native shell tests (pull_request) Successful in 9m34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m20s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m3s
Project CI / Frontend tests (pull_request) Successful in 2m54s
Project CI / Backend tests (pull_request) Successful in 7m36s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m26s
Project CI / Repository checks (pull_request) Successful in 6m25s
Project CI / Native shell tests (pull_request) Successful in 9m34s
# Conflicts: # deploy/container/nginx.conf # deploy/nginx/genarrative-dev-http.conf # deploy/nginx/genarrative.conf # server-rs/crates/api-server/src/modules/game_distribution.rs # src/components/game-distribution/GameDetailPage.tsx # src/components/game-distribution/GameDistributionPages.test.tsx # src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CreatorFollowButton, CreatorUserRow } from './index';
|
||||
afterEach(cleanup);
|
||||
it('无链接的资料行正常展示资料和独立操作,不产生导航焦点', () => {
|
||||
const follow = vi.fn();
|
||||
render(
|
||||
<CreatorUserRow
|
||||
name="当前作者"
|
||||
subtitle="ID 10001"
|
||||
actions={<CreatorFollowButton followed={false} onClick={follow} />}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText('当前作者').closest('a, button, [tabindex]'),
|
||||
).toBeNull();
|
||||
expect(screen.getByText('ID 10001')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关注' }));
|
||||
expect(follow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('用户链接与关注按钮互相独立,长昵称完整且不嵌套交互元素', () => {
|
||||
const navigate = vi.fn(),
|
||||
follow = vi.fn();
|
||||
const name = '很长的创作者名字'.repeat(10);
|
||||
render(
|
||||
<CreatorUserRow
|
||||
name={name}
|
||||
href="/creators?id=b"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigate();
|
||||
}}
|
||||
actions={<CreatorFollowButton followed={false} onClick={follow} />}
|
||||
/>,
|
||||
);
|
||||
const link = screen.getByRole('link', { name });
|
||||
expect(link.querySelector('button')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '关注' }));
|
||||
expect(follow).toHaveBeenCalledTimes(1);
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
fireEvent.click(link);
|
||||
expect(navigate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it('写入过程中按钮不可重复触发', () => {
|
||||
const click = vi.fn();
|
||||
render(<CreatorFollowButton followed={false} pending onClick={click} />);
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
expect(click).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('已关注状态文案仍明确提供取消关注的可访问名称', () => {
|
||||
const click = vi.fn();
|
||||
render(
|
||||
<CreatorFollowButton followed followedLabel="已关注" onClick={click} />,
|
||||
);
|
||||
const button = screen.getByRole('button', { name: '取消关注' });
|
||||
expect(button.textContent).toBe('已关注');
|
||||
fireEvent.click(button);
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import './style.css';
|
||||
|
||||
import type { MouseEventHandler, ReactNode } from 'react';
|
||||
|
||||
export function CreatorFollowButton({
|
||||
followed,
|
||||
pending = false,
|
||||
disabled = false,
|
||||
followLabel = '关注',
|
||||
followedLabel = '取消关注',
|
||||
onClick,
|
||||
}: {
|
||||
followed: boolean;
|
||||
pending?: boolean;
|
||||
disabled?: boolean;
|
||||
followLabel?: string;
|
||||
followedLabel?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`creator-follow ${followed ? 'creator-follow--active' : ''}`}
|
||||
disabled={disabled || pending}
|
||||
aria-busy={pending}
|
||||
aria-label={followed && !pending ? '取消关注' : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
{pending ? '处理中…' : followed ? followedLabel : followLabel}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
export function CreatorUserRow({
|
||||
name,
|
||||
avatarUrl,
|
||||
subtitle,
|
||||
href,
|
||||
onClick,
|
||||
actions,
|
||||
}: {
|
||||
name: string;
|
||||
avatarUrl?: string | null;
|
||||
subtitle?: string;
|
||||
href?: string;
|
||||
onClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const content = (
|
||||
<>
|
||||
<span className="creator-avatar" aria-hidden="true">
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" />
|
||||
) : (
|
||||
Array.from(name)[0] || '创'
|
||||
)}
|
||||
</span>
|
||||
<span className="creator-user-copy">
|
||||
<strong>{name}</strong>
|
||||
{subtitle && <small>{subtitle}</small>}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div className="creator-user-row">
|
||||
{href ? (
|
||||
<a
|
||||
className="creator-user-link"
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
aria-label={name ? undefined : '查看创作者主页'}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<div className="creator-user-link">{content}</div>
|
||||
)}
|
||||
{actions && <div className="creator-user-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
.creator-user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.creator-user-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
}
|
||||
.creator-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--platform-panel-fill, #eee);
|
||||
border: 1px solid var(--platform-surface-border, #ccc);
|
||||
}
|
||||
.creator-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.creator-user-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.creator-user-copy small {
|
||||
color: var(--platform-text-soft);
|
||||
}
|
||||
.creator-user-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.creator-follow {
|
||||
min-height: 44px;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--platform-surface-border, #ccc);
|
||||
border-radius: 999px;
|
||||
background: var(--platform-panel-fill, #fff);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.creator-follow:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.creator-follow:focus-visible,
|
||||
.creator-user-link:focus-visible {
|
||||
outline: 2px solid #b85d3d;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
.creator-follow--active {
|
||||
color: var(--platform-text-soft);
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.creator-user-row > .creator-user-link {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.creator-user-row > .creator-user-actions {
|
||||
margin-left: 60px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import './style.css';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { GameDistributionGame } from '../../contracts/gameDistribution';
|
||||
import { GameRatingSummaryText } from '../GameRatingSummaryText';
|
||||
export function GameCard({
|
||||
game,
|
||||
cover,
|
||||
onOpen,
|
||||
}: {
|
||||
game: GameDistributionGame;
|
||||
cover: ReactNode;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button type="button" className="game-card" onClick={onOpen}>
|
||||
{cover}
|
||||
<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__rating">
|
||||
<GameRatingSummaryText summary={game.ratingSummary} />
|
||||
</span>
|
||||
<span className="game-card__meta">
|
||||
<span>{game.category}</span>
|
||||
<span>·</span>
|
||||
<span>{game.author.name}</span>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.game-card {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 1.45rem;
|
||||
padding: 0;
|
||||
background: var(--platform-panel-fill);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
box-shadow: var(--platform-panel-shadow);
|
||||
transition: 180ms ease;
|
||||
}
|
||||
.game-card:hover {
|
||||
border-color: rgba(199, 101, 61, 0.38);
|
||||
box-shadow: 0 1rem 2.4rem rgba(104, 54, 36, 0.14);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.game-card__body {
|
||||
display: block;
|
||||
padding: 0.95rem 1rem 1.1rem;
|
||||
}
|
||||
.game-card__title-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.game-card__title-row strong {
|
||||
overflow: hidden;
|
||||
font-size: 1rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.game-card__play-count {
|
||||
flex: none;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.66rem;
|
||||
}
|
||||
.game-card__summary {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
margin-top: 0.42rem;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.game-card__rating {
|
||||
display: block;
|
||||
margin-top: 0.42rem;
|
||||
color: var(--platform-text-base);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-card__meta {
|
||||
display: flex;
|
||||
gap: 0.38rem;
|
||||
margin-top: 0.85rem;
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
@@ -56,6 +56,8 @@ export interface GameDetailDisplayProps extends GameDetailShowcaseProps {
|
||||
/** 缺省时不显示游玩次数。 */
|
||||
playCount?: number;
|
||||
};
|
||||
/** 替换默认作者行;缺省时按 game.author.name 展示。 */
|
||||
authorContent?: ReactNode;
|
||||
/** 「游玩方式」文案;缺省「未标注操作方式」。 */
|
||||
controlsLabel?: string;
|
||||
/** 「当前版本」文案;缺省 —。 */
|
||||
@@ -199,6 +201,7 @@ export function GameDetailDisplay({
|
||||
icon,
|
||||
assetUrls,
|
||||
renderAsset,
|
||||
authorContent,
|
||||
controlsLabel,
|
||||
versionLabel,
|
||||
infoCards,
|
||||
@@ -234,14 +237,15 @@ export function GameDetailDisplay({
|
||||
{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}
|
||||
{authorContent ??
|
||||
(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' ? (
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformGameLoadingSurface } from './PlatformGameLoadingSurface';
|
||||
import {
|
||||
PLATFORM_GAME_LOADING_ASSET_PHASE_MS,
|
||||
PLATFORM_GAME_LOADING_ENGINE_PHASE_MS,
|
||||
PLATFORM_GAME_LOADING_SLOW_PHASE_MS,
|
||||
resolvePlatformGameLoadingMessage,
|
||||
} from './platformGameLoadingSurfaceModel';
|
||||
|
||||
function readMessage() {
|
||||
return screen
|
||||
.getByRole('status')
|
||||
.querySelector('.platform-game-loading__message')?.textContent;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('PlatformGameLoadingSurface', () => {
|
||||
test('阶段文案随等待时长推进,慢加载才追加提示行', () => {
|
||||
vi.useFakeTimers();
|
||||
render(<PlatformGameLoadingSurface title="星尘防线" />);
|
||||
|
||||
const surface = screen.getByRole('status');
|
||||
expect(surface.getAttribute('aria-busy')).toBe('true');
|
||||
expect(surface.getAttribute('aria-label')).toContain('星尘防线');
|
||||
expect(readMessage()).toBe(resolvePlatformGameLoadingMessage(0));
|
||||
expect(surface.querySelector('.platform-game-loading__hint')).toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_ASSET_PHASE_MS);
|
||||
});
|
||||
expect(readMessage()).toBe(
|
||||
resolvePlatformGameLoadingMessage(PLATFORM_GAME_LOADING_ASSET_PHASE_MS),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('status').querySelector('.platform-game-loading__hint'),
|
||||
).toBeNull();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(
|
||||
PLATFORM_GAME_LOADING_ENGINE_PHASE_MS -
|
||||
PLATFORM_GAME_LOADING_ASSET_PHASE_MS,
|
||||
);
|
||||
});
|
||||
expect(readMessage()).toBe(
|
||||
resolvePlatformGameLoadingMessage(PLATFORM_GAME_LOADING_ENGINE_PHASE_MS),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(
|
||||
PLATFORM_GAME_LOADING_SLOW_PHASE_MS -
|
||||
PLATFORM_GAME_LOADING_ENGINE_PHASE_MS,
|
||||
);
|
||||
});
|
||||
expect(
|
||||
screen.getByRole('status').querySelector('.platform-game-loading__hint')
|
||||
?.textContent,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('宿主文案与提示覆盖默认阶段文案,封面按宿主给的内容渲染', () => {
|
||||
render(
|
||||
<PlatformGameLoadingSurface
|
||||
title="待审版本试玩"
|
||||
tone="panel"
|
||||
message="正在创建试玩会话…"
|
||||
hint="会话过期后请重新创建"
|
||||
cover={<img alt="封面" src="data:image/gif;base64,R0lGOD" />}
|
||||
/>,
|
||||
);
|
||||
|
||||
const surface = screen.getByRole('status');
|
||||
expect(surface.className).toContain('platform-game-loading--panel');
|
||||
expect(readMessage()).toBe('正在创建试玩会话…');
|
||||
expect(
|
||||
surface.querySelector('.platform-game-loading__hint')?.textContent,
|
||||
).toBe('会话过期后请重新创建');
|
||||
expect(screen.getByRole('img', { name: '封面' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('宿主判定停止推进时不再声明 busy,并停掉进度动画', () => {
|
||||
render(<PlatformGameLoadingSurface title="待审版本试玩" stalled />);
|
||||
|
||||
const surface = screen.getByRole('status');
|
||||
expect(surface.getAttribute('aria-busy')).toBe('false');
|
||||
expect(surface.className).toContain('platform-game-loading--stalled');
|
||||
});
|
||||
|
||||
test('没有封面时渲染图标占位而不是空白区块', () => {
|
||||
const { container } = render(
|
||||
<PlatformGameLoadingSurface title="加载中" tone="player" />,
|
||||
);
|
||||
|
||||
const surface = container.querySelector('.platform-game-loading');
|
||||
expect(surface?.className).toContain('platform-game-loading--player');
|
||||
expect(
|
||||
surface?.querySelector('.platform-game-loading__cover svg'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Gamepad2 } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
PLATFORM_GAME_LOADING_SLOW_PHASE_MS,
|
||||
resolvePlatformGameLoadingMessage,
|
||||
} from './platformGameLoadingSurfaceModel';
|
||||
|
||||
export type PlatformGameLoadingSurfaceTone = 'player' | 'panel';
|
||||
|
||||
export type PlatformGameLoadingSurfaceProps = {
|
||||
/** 游戏标题:加载面用来说明正在加载哪一个游戏。 */
|
||||
title: string;
|
||||
/**
|
||||
* 封面内容由宿主提供:网页端与后台端各自持有素材换签逻辑,共享组件只负责摆放,
|
||||
* 缺省时退化为平台图标占位。
|
||||
*/
|
||||
cover?: ReactNode | null;
|
||||
/** `player` 用于暗色播放器壳,`panel` 用于浅色后台面板。 */
|
||||
tone?: PlatformGameLoadingSurfaceTone;
|
||||
/** 覆盖默认阶段文案(例如宿主自己知道正在创建试玩会话)。 */
|
||||
message?: string;
|
||||
/** 追加一行提示(例如失败的兜底说明)。 */
|
||||
hint?: string;
|
||||
/**
|
||||
* 宿主已判定这次等待不再推进(例如已经给出超时结论):停掉进度动画并把
|
||||
* `aria-busy` 置回 `false`,避免「一边转圈一边说超时」。
|
||||
*/
|
||||
stalled?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台通用「游戏加载面」。
|
||||
*
|
||||
* 发行包(Phaser/Vite 产物)首屏要下完整个引擎与资源,网络稍慢就是好几秒;这段时间
|
||||
* 网页游玩页与后台审核试玩都必须给出**看得见的**加载反馈,而不是近黑面板上的一行小字
|
||||
* 或一片空白。阶段文案由组件自己按时长推进,宿主只负责在真正进入游戏后把它换掉。
|
||||
*
|
||||
* 组件按绝对定位铺满**最近的定位祖先**:宿主必须给它一个 `position: relative` 的容器
|
||||
* (网页游玩页是 `.game-player-shell`,后台试玩是 `.admin-game-review-preview-shell`)。
|
||||
*/
|
||||
export function PlatformGameLoadingSurface({
|
||||
title,
|
||||
cover = null,
|
||||
tone = 'panel',
|
||||
message,
|
||||
hint,
|
||||
stalled = false,
|
||||
className,
|
||||
}: PlatformGameLoadingSurfaceProps) {
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const startedAt = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedMs(Date.now() - startedAt);
|
||||
}, 500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const statusMessage = message ?? resolvePlatformGameLoadingMessage(elapsedMs);
|
||||
const slowHint =
|
||||
!hint && elapsedMs >= PLATFORM_GAME_LOADING_SLOW_PHASE_MS
|
||||
? '加载时间比平时更久,仍在继续…'
|
||||
: hint;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-busy={stalled ? 'false' : 'true'}
|
||||
aria-label={`${title} ${statusMessage}`}
|
||||
className={[
|
||||
'platform-game-loading',
|
||||
`platform-game-loading--${tone}`,
|
||||
stalled ? 'platform-game-loading--stalled' : null,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className="platform-game-loading__cover">
|
||||
{cover ?? <Gamepad2 aria-hidden="true" />}
|
||||
</div>
|
||||
<div className="platform-game-loading__copy">
|
||||
<strong className="platform-game-loading__title">{title}</strong>
|
||||
<p className="platform-game-loading__message">{statusMessage}</p>
|
||||
<div className="platform-game-loading__track" aria-hidden="true">
|
||||
<span className="platform-game-loading__fill" />
|
||||
</div>
|
||||
{slowHint ? (
|
||||
<p className="platform-game-loading__hint">{slowHint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,18 @@ export type {
|
||||
PlatformFilterToolbarTabItem,
|
||||
} from './PlatformFilterToolbar';
|
||||
export { PlatformFilterToolbar } from './PlatformFilterToolbar';
|
||||
export type {
|
||||
PlatformGameLoadingSurfaceProps,
|
||||
PlatformGameLoadingSurfaceTone,
|
||||
} from './PlatformGameLoadingSurface';
|
||||
export { PlatformGameLoadingSurface } from './PlatformGameLoadingSurface';
|
||||
export {
|
||||
PLATFORM_GAME_LOADING_ASSET_PHASE_MS,
|
||||
PLATFORM_GAME_LOADING_ENGINE_PHASE_MS,
|
||||
PLATFORM_GAME_LOADING_SLOW_PHASE_MS,
|
||||
PLATFORM_GAME_LOADING_TIMEOUT_MS,
|
||||
resolvePlatformGameLoadingMessage,
|
||||
} from './platformGameLoadingSurfaceModel';
|
||||
export type {
|
||||
PlatformIconBadgeProps,
|
||||
PlatformIconBadgeShape,
|
||||
@@ -247,3 +259,5 @@ export {
|
||||
// 后台管理端 chrome:页面壳、面板、按钮、提示、表格与表单外壳。
|
||||
// 只承载表现与交互,路由、鉴权和后台接口仍留在 apps/admin-web。
|
||||
export * from './admin';
|
||||
export { CreatorFollowButton, CreatorUserRow } from './CreatorControls';
|
||||
export { GameCard } from './GameCard';
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/** 阶段文案切换点:先「准备」,再「资源」,最后「引擎」。 */
|
||||
export const PLATFORM_GAME_LOADING_ASSET_PHASE_MS = 1_500;
|
||||
export const PLATFORM_GAME_LOADING_ENGINE_PHASE_MS = 6_000;
|
||||
/** 超过这个时长仍未进入游戏,补一行「比平时久」的状态,避免用户以为卡死。 */
|
||||
export const PLATFORM_GAME_LOADING_SLOW_PHASE_MS = 12_000;
|
||||
/**
|
||||
* 发行包首屏加载上限。
|
||||
*
|
||||
* 网页游玩页与后台审核试玩共用同一口径:超过上限就给出显式超时与重试,而不是让
|
||||
* 用户对着加载面无限期等待。
|
||||
*/
|
||||
export const PLATFORM_GAME_LOADING_TIMEOUT_MS = 20_000;
|
||||
|
||||
const DEFAULT_COPY_BY_PHASE: Record<'prepare' | 'assets' | 'engine', string> = {
|
||||
prepare: '正在准备游戏…',
|
||||
assets: '正在加载游戏资源…',
|
||||
engine: '正在启动游戏引擎…',
|
||||
};
|
||||
|
||||
/** 按已等待时长给出阶段文案;与 `PlatformGameLoadingSurface` 内的计时同源。 */
|
||||
export function resolvePlatformGameLoadingMessage(elapsedMs: number) {
|
||||
if (elapsedMs >= PLATFORM_GAME_LOADING_ENGINE_PHASE_MS) {
|
||||
return DEFAULT_COPY_BY_PHASE.engine;
|
||||
}
|
||||
if (elapsedMs >= PLATFORM_GAME_LOADING_ASSET_PHASE_MS) {
|
||||
return DEFAULT_COPY_BY_PHASE.assets;
|
||||
}
|
||||
return DEFAULT_COPY_BY_PHASE.prepare;
|
||||
}
|
||||
@@ -1398,7 +1398,8 @@ textarea.genarrative-ui-text-field__control {
|
||||
.genarrative-ui-switch,
|
||||
.genarrative-ui-progress__fill,
|
||||
.genarrative-ui-spinner,
|
||||
.genarrative-ui-skeleton {
|
||||
.genarrative-ui-skeleton,
|
||||
.platform-game-loading__fill {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
@@ -1541,3 +1542,181 @@ textarea.genarrative-ui-text-field__control {
|
||||
)::after {
|
||||
content: attr(aria-label);
|
||||
}
|
||||
|
||||
/*
|
||||
* 平台「游戏加载面」:发行包(Phaser/Vite 产物)首屏要下完整个引擎与资源,网络稍慢就是
|
||||
* 好几秒;网页游玩页与后台审核试玩都必须在这段时间给出看得见的加载反馈,而不是近黑面板
|
||||
* 上的一行小字或一片空白。
|
||||
*
|
||||
* 样式放在这里而不是任一宿主的业务样式表:后台 `admin-web` 只引入本文件与 `theme.css`,
|
||||
* 没有 Tailwind,因此共享组件不能只靠工具类撑起外观。
|
||||
*/
|
||||
.platform-game-loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: clamp(1rem, 3vw, 2.25rem);
|
||||
padding: clamp(1rem, 4vw, 2.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.platform-game-loading--player {
|
||||
background: linear-gradient(135deg, #1e1820, #302329);
|
||||
color: #fff8f0;
|
||||
}
|
||||
|
||||
.platform-game-loading--panel {
|
||||
background: var(--platform-panel-fill-soft, rgba(255, 254, 252, 0.96));
|
||||
color: var(--platform-text-base, #6f5848);
|
||||
}
|
||||
|
||||
.platform-game-loading__cover {
|
||||
display: grid;
|
||||
aspect-ratio: 4 / 3;
|
||||
width: clamp(6.5rem, 18vw, 11rem);
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 1.1rem;
|
||||
}
|
||||
|
||||
.platform-game-loading--player .platform-game-loading__cover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.platform-game-loading--panel .platform-game-loading__cover {
|
||||
background: rgba(199, 101, 61, 0.1);
|
||||
box-shadow: inset 0 0 0 1px var(--platform-surface-border, rgba(226, 203, 184, 0.88));
|
||||
}
|
||||
|
||||
.platform-game-loading__cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/*
|
||||
* 宿主自己的封面组件(例如游玩页的 `.game-cover`)带自己的最小高度与内边距;加载面只借
|
||||
* 它的图,盒子尺寸一律由加载面决定,否则封面会把封面槽撑破。
|
||||
*/
|
||||
.platform-game-loading__cover > * {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.platform-game-loading__cover svg {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.platform-game-loading__copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
max-width: 26rem;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.platform-game-loading__title {
|
||||
overflow: hidden;
|
||||
font-size: clamp(1.05rem, 2.2vw, 1.5rem);
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.platform-game-loading__message {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.platform-game-loading--player .platform-game-loading__message {
|
||||
color: rgba(255, 241, 227, 0.78);
|
||||
}
|
||||
|
||||
.platform-game-loading--panel .platform-game-loading__message {
|
||||
color: var(--platform-text-soft, #988476);
|
||||
}
|
||||
|
||||
.platform-game-loading__track {
|
||||
position: relative;
|
||||
height: 0.35rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.platform-game-loading--player .platform-game-loading__track {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.platform-game-loading--panel .platform-game-loading__track {
|
||||
background: var(--platform-track-fill, rgba(226, 203, 184, 0.45));
|
||||
}
|
||||
|
||||
.platform-game-loading__fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 38%;
|
||||
border-radius: inherit;
|
||||
animation: platform-game-loading-sweep 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.platform-game-loading--player .platform-game-loading__fill {
|
||||
background: linear-gradient(90deg, rgba(255, 196, 150, 0.4), #ffb27a);
|
||||
}
|
||||
|
||||
.platform-game-loading--panel .platform-game-loading__fill {
|
||||
background: var(--platform-button-primary-fill, #c7653d);
|
||||
}
|
||||
|
||||
/* 宿主已判定这次等待不再推进(例如已经给出超时结论):进度条停下并压暗,
|
||||
避免出现「一边转圈一边说超时」的自相矛盾。 */
|
||||
.platform-game-loading--stalled .platform-game-loading__fill {
|
||||
animation: none;
|
||||
transform: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.platform-game-loading__hint {
|
||||
margin: 0;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.platform-game-loading--player .platform-game-loading__hint {
|
||||
color: rgba(255, 241, 227, 0.6);
|
||||
}
|
||||
|
||||
.platform-game-loading--panel .platform-game-loading__hint {
|
||||
color: var(--platform-text-muted, #a38f80);
|
||||
}
|
||||
|
||||
@keyframes platform-game-loading-sweep {
|
||||
from {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(280%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.platform-game-loading {
|
||||
align-content: safe center;
|
||||
text-align: center;
|
||||
}
|
||||
.platform-game-loading__copy {
|
||||
max-width: 100%;
|
||||
}
|
||||
.platform-game-loading__track {
|
||||
margin: 0 auto;
|
||||
width: min(14rem, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export interface CreatorUser {
|
||||
id: string;
|
||||
publicUserCode: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface CreatorRelationship {
|
||||
isSelf: boolean;
|
||||
isFollowing: boolean;
|
||||
isFollowedBy: boolean;
|
||||
}
|
||||
|
||||
export interface CreatorProfile {
|
||||
user: CreatorUser;
|
||||
followingCount: number;
|
||||
followerCount: number;
|
||||
}
|
||||
|
||||
export interface CreatorConnection {
|
||||
user: CreatorUser;
|
||||
/** 列表方向的关系建立时间,RFC 3339。 */
|
||||
followedAt: string;
|
||||
/** 仅本人访问自己的列表时返回关系。 */
|
||||
relationship: CreatorRelationship | null;
|
||||
}
|
||||
|
||||
export interface CreatorConnections {
|
||||
items: CreatorConnection[];
|
||||
nextCursor: string | null;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CreatorRelationshipResponse {
|
||||
userId: string;
|
||||
relationship: CreatorRelationship;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export type * from './creator';
|
||||
export * from './editorAgent';
|
||||
export type * from './editorAudio';
|
||||
export * from './gameCreationApp';
|
||||
|
||||
Reference in New Issue
Block a user