e32823d389
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 6m35s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 5m10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m29s
Project CI / Frontend tests (pull_request) Failing after 55s
Project CI / Backend tests (pull_request) Successful in 8m13s
Project CI / AI game creator shell web tests (pull_request) Successful in 5m37s
Project CI / Repository checks (pull_request) Successful in 7m5s
Project CI / Native shell tests (pull_request) Successful in 9m2s
新增创作者公开主页、第四导航入口、详情作者跳转及关注按钮 实现关注与粉丝列表、取消后重新关注、回关及移除粉丝交互 新增私有关注关系表、类型契约、受信过程和站内查询写入接口 按作者过滤公开游戏并保持既有四十八项读取边界 复用共享用户行、关注按钮及游戏卡,接入移动布局和页面深链 补充定向测试、隔离数据库及浏览器验收脚本并归并工程验证文档
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
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;
|
|
};
|
|
/** 「游玩方式」文案;缺省「未标注操作方式」。 */
|
|
authorContent?: ReactNode;
|
|
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,
|
|
authorContent,
|
|
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}
|
|
{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' ? (
|
|
<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>
|
|
);
|
|
}
|