909f3d5fa2
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
- 客户端:新增 listThemes(游标分页,读 nextCursor)与 getTheme(404 由页面渲染成降级态);getGame 的返回类型增量 themes(匿名也返回;无主题时是空数组而不是缺键) - 共创 Tab:广场页新增「游戏 / 共创」页签,复用既有 game-category-tabs 结构与类名,切到 /games/themes - 主题列表页 GameThemesPage:卡片展示 name / summary / badge / memberCount;只有 nextCursor 非空才渲染「加载更多」并在点击时追加;空态与失败重试照既有写法 - 主题页 GameThemePage:标题 / 简介 / 角标 + 成员根清单;**逐根内嵌渲染作品树**——直接复用族谱页(GameLineagePage 新增 embedded 变体,只隐藏页面外壳:返回按钮 / 页头 / 脚注),树查询、可见性过滤、稳定排序与截断全部走 M3 那一份实现,不新建第二套树查询或树渲染(每根一次 /lineage 的 N+1 是技术方案 §3.10.4 已接受的取舍);已发布但可见成员为空 → 明确空态而不是错误;主题不存在 / 未发布 → 错误态 - 详情页:共创信息卡渲染所属主题角标(点击进主题页),第 N 代作品同样可见(服务端按根反查);无主题时不渲染空容器 - 路由登记:新增 /games/themes 与 /games/theme(详情用 ?id=),四处同批登记(STAGE_ROUTE_ENTRIES + SelectionStage 类型 + 壳层 lazy/回调/渲染分支 + 页面标题),并同步三份 nginx SPA allowlist 与 Pingora MAIN_SPA_PATHS:check:nginx-spa-routes(20 SPA routes / 3 模板)与 check:pingora-route-parity 均绿 - 测试:主题列表三例(渲染与卡片跳转 / 有游标时的加载更多与追加 / 空态与失败重试)、主题页三例(逐根两棵树且复用 lineage 查询 + 全屏入口参数 / 空成员空态 / 404 错误态)、详情页主题入口两例(渲染并可跳转 / 无主题不渲染容器)、路由与标题登记断言、壳层三例(页签切换 / 列表进主题页 / 主题页按 ?id= 渲染并复用族谱查询)
734 lines
24 KiB
TypeScript
734 lines
24 KiB
TypeScript
import type {
|
||
GameDistributionAuthor,
|
||
GameDistributionCancelVersionResponse,
|
||
GameDistributionCategory,
|
||
GameDistributionCreateGameRequest,
|
||
GameDistributionCreateVersionRequest,
|
||
GameDistributionDerivedResponse,
|
||
GameDistributionDeviceSupport,
|
||
GameDistributionForkAuthorization,
|
||
GameDistributionGame,
|
||
GameDistributionLineageResponse,
|
||
GameDistributionMyReviewResponse,
|
||
GameDistributionRecoveryAction,
|
||
GameDistributionReviewsResponse,
|
||
GameDistributionSaveReviewRequest,
|
||
GameDistributionSaveReviewResponse,
|
||
GameDistributionSetForkAuthorizationRequest,
|
||
GameDistributionUpdateGameMetadataRequest,
|
||
GameDistributionVersionDetail,
|
||
GameDistributionVersionStatus as GameDistributionVersionStatusValue,
|
||
GameDistributionVersionSummary,
|
||
} from '../../packages/shared/src/contracts/gameDistribution';
|
||
import {
|
||
type ApiRequestOptions,
|
||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||
requestJson as requestApiJson,
|
||
} from './apiClient';
|
||
import { getGamePlayClientId } from './gamePlayClientId';
|
||
|
||
export type {
|
||
GameDistributionAuthor,
|
||
GameDistributionDerivedResponse,
|
||
GameDistributionDeviceSupport,
|
||
GameDistributionForkAuthorization,
|
||
GameDistributionGame,
|
||
GameDistributionInputMode,
|
||
GameDistributionLineage,
|
||
GameDistributionLineageNode,
|
||
GameDistributionLineageResponse,
|
||
GameDistributionRatingSummary,
|
||
GameDistributionRecoveryAction,
|
||
GameDistributionReview,
|
||
GameDistributionReviewsResponse,
|
||
GameDistributionVersionDetail,
|
||
GameDistributionVersionSummary,
|
||
} from '../../packages/shared/src/contracts/gameDistribution';
|
||
|
||
export type GameCategory = GameDistributionCategory;
|
||
export type GameDeviceSupport = GameDistributionDeviceSupport;
|
||
export type GameAuthor = GameDistributionAuthor;
|
||
export type GameVersionSummary = GameDistributionVersionSummary;
|
||
|
||
export type GameListQuery = {
|
||
authorId?: string;
|
||
keyword?: string;
|
||
category?: GameCategory | '全部';
|
||
};
|
||
|
||
export type GameVersionCreateResult = {
|
||
gameId: string;
|
||
versionId: string;
|
||
versionNumber: number;
|
||
status: 'awaiting_upload';
|
||
};
|
||
|
||
const API_BASE = '/api/game-distribution';
|
||
|
||
function normalizeGameList(value: unknown): GameDistributionGame[] {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.filter((item): item is GameDistributionGame => {
|
||
if (!item || typeof item !== 'object') return false;
|
||
const game = item as Partial<GameDistributionGame>;
|
||
return Boolean(game.id && game.title && game.currentVersion);
|
||
});
|
||
}
|
||
|
||
const PUBLIC_GAME_REQUEST_OPTIONS: ApiRequestOptions = {
|
||
skipAuth: true,
|
||
skipRefresh: true,
|
||
notifyAuthStateChange: false,
|
||
clearAuthOnUnauthorized: false,
|
||
};
|
||
|
||
/**
|
||
* 游玩上报是后台尽力而为的请求:带上已登录 bearer(后端据此按 userId 去重),但即使遇到 401
|
||
* 也不刷新会话、不改动全局登录态——直接复用后台请求已有的同一份鉴权降级策略。
|
||
*/
|
||
|
||
async function requestJson<T>(
|
||
path: string,
|
||
init: RequestInit,
|
||
fallbackMessage: string,
|
||
options?: ApiRequestOptions,
|
||
) {
|
||
return requestApiJson<T>(
|
||
`${API_BASE}${path}`,
|
||
{
|
||
...init,
|
||
headers: {
|
||
Accept: 'application/json',
|
||
...(init.body instanceof FormData ||
|
||
(typeof Blob !== 'undefined' && init.body instanceof Blob) ||
|
||
init.body instanceof ArrayBuffer
|
||
? {}
|
||
: init.body
|
||
? { 'Content-Type': 'application/json' }
|
||
: {}),
|
||
...init.headers,
|
||
},
|
||
},
|
||
fallbackMessage,
|
||
options,
|
||
);
|
||
}
|
||
|
||
export async function listGames(query: GameListQuery = {}) {
|
||
const params = new URLSearchParams();
|
||
if (query.authorId !== undefined) params.set('authorId', query.authorId);
|
||
if (query.keyword?.trim()) params.set('keyword', query.keyword.trim());
|
||
if (query.category && query.category !== '全部')
|
||
params.set('category', query.category);
|
||
const search = params.toString();
|
||
const payload = await requestJson<unknown>(
|
||
`/games${search ? `?${search}` : ''}`,
|
||
{ method: 'GET' },
|
||
'读取游戏目录失败',
|
||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||
);
|
||
return normalizeGameList(
|
||
Array.isArray(payload) ? payload : (payload as { games?: unknown[] }).games,
|
||
);
|
||
}
|
||
|
||
export type GameDistributionMyGame = GameDistributionGame & {
|
||
latestVersion: GameDistributionVersionStatusEntry | null;
|
||
versions: GameDistributionVersionStatusEntry[];
|
||
};
|
||
|
||
export type GameDistributionVersionStatusEntry = {
|
||
versionId: string;
|
||
gameId: string;
|
||
versionNumber: number;
|
||
packageSha256: string;
|
||
packageBytes: number;
|
||
status: GameDistributionVersionStatusValue;
|
||
publicationRevision: number;
|
||
reviewReason?: string | null;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
/**
|
||
* 版本私有的发行入口;只有已公开版本有值,作者详情用它判定当前是否能在线游玩。
|
||
*/
|
||
entryUrl?: string | null;
|
||
/**
|
||
* 服务端派生的下一步动作。作者列表投影目前不带这一项(只有版本回读带),
|
||
* 缺失时前端只展示状态、不自行推断动作。
|
||
*/
|
||
recoveryAction?: GameDistributionRecoveryAction | null;
|
||
};
|
||
|
||
/**
|
||
* 作者自有游戏条目一定是「公开投影 + 私有版本状态」的形状;服务端缺字段时按空值兜底,
|
||
* 避免页面对半成品响应直接崩掉。
|
||
*/
|
||
function normalizeOwnerGameEntry(
|
||
value: unknown,
|
||
): GameDistributionMyGame | null {
|
||
if (!value || typeof value !== 'object') return null;
|
||
const entry = value as Partial<GameDistributionMyGame>;
|
||
if (!entry.id) return null;
|
||
const versions = Array.isArray(entry.versions)
|
||
? entry.versions.filter(
|
||
(item): item is GameDistributionVersionStatusEntry =>
|
||
Boolean(item && typeof item === 'object' && item.versionId),
|
||
)
|
||
: [];
|
||
return {
|
||
...(entry as GameDistributionMyGame),
|
||
latestVersion: entry.latestVersion ?? null,
|
||
versions,
|
||
};
|
||
}
|
||
|
||
export async function listMyGames() {
|
||
const payload = await requestJson<{ games?: unknown[] }>(
|
||
'/my-games',
|
||
{ method: 'GET' },
|
||
'读取我的游戏失败',
|
||
);
|
||
const games = Array.isArray(payload?.games) ? payload.games : [];
|
||
return games
|
||
.map(normalizeOwnerGameEntry)
|
||
.filter((game): game is GameDistributionMyGame => game !== null);
|
||
}
|
||
|
||
/**
|
||
* 作者读取自己名下单个作品:审核中 / 被驳回 / 已下架 / 已撤回的作品都能打开,
|
||
* 公开详情只服务已公开投影(作者点自己的未公开作品会拿到 404)。
|
||
*/
|
||
export async function getMyGame(
|
||
gameId: string,
|
||
): Promise<GameDistributionMyGame | null> {
|
||
const normalizedGameId = gameId.trim();
|
||
if (!normalizedGameId) return null;
|
||
const payload = await requestJson<{ game?: unknown }>(
|
||
`/my-games/${encodeURIComponent(normalizedGameId)}`,
|
||
{ method: 'GET' },
|
||
'读取作品详情失败',
|
||
);
|
||
return normalizeOwnerGameEntry(payload?.game);
|
||
}
|
||
|
||
/**
|
||
* 保存作品资料的成功响应:`game` 是公开投影形状(不含版本列表),
|
||
* `replayed` 表示服务端按同一个 Idempotency-Key 重放了这次写入。
|
||
*/
|
||
export type GameDistributionUpdateMyGameResponse = {
|
||
game: GameDistributionGame;
|
||
replayed: boolean;
|
||
};
|
||
|
||
/**
|
||
* 保存作品展示资料;包内容不在这里改动,仍随版本冻结。
|
||
*
|
||
* 服务端复用创建游戏的资料校验(含"必须有封面、截图必须是本人素材"),
|
||
* 因此编辑也必须带上当前封面/截图的素材 ID,否则资料不完整或会把线上截图清空。
|
||
*/
|
||
export async function updateMyGame(
|
||
gameId: string,
|
||
payload: GameDistributionUpdateGameMetadataRequest,
|
||
idempotencyKey: string,
|
||
) {
|
||
const normalizedGameId = gameId.trim();
|
||
const normalizedKey = idempotencyKey.trim();
|
||
if (!normalizedGameId) throw new Error('缺少作品编号');
|
||
if (!normalizedKey) throw new Error('保存作品资料缺少幂等键');
|
||
return requestJson<GameDistributionUpdateMyGameResponse>(
|
||
`/my-games/${encodeURIComponent(normalizedGameId)}`,
|
||
{
|
||
method: 'PATCH',
|
||
headers: { 'Idempotency-Key': normalizedKey },
|
||
body: JSON.stringify(payload),
|
||
},
|
||
'保存作品资料失败',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 软删除作品:成功后该作品从作者列表与公开投影里消失,历史版本包留在回收窗口内。
|
||
*
|
||
* 与其它写动作一致要带 `Idempotency-Key`;CAS 修订号走查询串(删除本身没有请求体),
|
||
* 并发下架/审核后过期由服务端拒绝。
|
||
*/
|
||
export async function deleteMyGame(
|
||
gameId: string,
|
||
expectedPublicationRevision: number,
|
||
idempotencyKey: string,
|
||
) {
|
||
const normalizedGameId = gameId.trim();
|
||
const normalizedKey = idempotencyKey.trim();
|
||
if (!normalizedGameId) throw new Error('缺少作品编号');
|
||
if (!normalizedKey) throw new Error('删除作品缺少幂等键');
|
||
const params = new URLSearchParams({
|
||
expectedPublicationRevision: String(expectedPublicationRevision),
|
||
});
|
||
return requestJson<{ game?: GameDistributionGame; replayed?: boolean }>(
|
||
`/my-games/${encodeURIComponent(normalizedGameId)}?${params.toString()}`,
|
||
{
|
||
method: 'DELETE',
|
||
headers: { 'Idempotency-Key': normalizedKey },
|
||
},
|
||
'删除作品失败',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 补读版本回读里的 `recoveryAction`。
|
||
*
|
||
* 作者列表投影不带这一步动作,只为「还需要作者动作」的最新版本各回读一次;单个版本
|
||
* 回读失败只丢失该版本的下一步提示,不影响列表与其它作品。
|
||
*/
|
||
export async function loadVersionRecoveryActions(
|
||
versionIds: readonly string[],
|
||
): Promise<Record<string, GameDistributionRecoveryAction>> {
|
||
const uniqueVersionIds = [
|
||
...new Set(versionIds.map((versionId) => versionId.trim()).filter(Boolean)),
|
||
];
|
||
const details = await Promise.allSettled(
|
||
uniqueVersionIds.map((versionId) => getGameVersion(versionId)),
|
||
);
|
||
const actions: Record<string, GameDistributionRecoveryAction> = {};
|
||
uniqueVersionIds.forEach((versionId, index) => {
|
||
const detail = details[index];
|
||
if (detail?.status === 'fulfilled') {
|
||
actions[versionId] = detail.value.version.recoveryAction;
|
||
}
|
||
});
|
||
return actions;
|
||
}
|
||
|
||
export async function unpublishGame(
|
||
gameId: string,
|
||
expectedPublicationRevision: number,
|
||
idempotencyKey: string,
|
||
) {
|
||
const normalizedGameId = gameId.trim();
|
||
const normalizedKey = idempotencyKey.trim();
|
||
if (!normalizedGameId) throw new Error('缺少游戏编号');
|
||
if (!normalizedKey) throw new Error('缺少幂等键');
|
||
return requestJson<{ game: { id: string; status: string } }>(
|
||
`/games/${encodeURIComponent(normalizedGameId)}/unpublish`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': normalizedKey },
|
||
body: JSON.stringify({ expectedPublicationRevision }),
|
||
},
|
||
'下架游戏失败',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 提升作品共创授权(只升不降)。`expectedForkAuthorization` 为 CAS 期望值,
|
||
* 服务端发现档位冲突或降级时返回 409,错误信息由 `requestJson` 透传。
|
||
*/
|
||
export async function updateGameForkAuthorization(
|
||
gameId: string,
|
||
forkAuthorization: GameDistributionForkAuthorization,
|
||
expectedForkAuthorization: GameDistributionForkAuthorization,
|
||
idempotencyKey: string,
|
||
) {
|
||
const normalizedGameId = gameId.trim();
|
||
const normalizedKey = idempotencyKey.trim();
|
||
if (!normalizedGameId) throw new Error('缺少游戏编号');
|
||
if (!normalizedKey) throw new Error('设置共创授权缺少幂等键');
|
||
const payload: GameDistributionSetForkAuthorizationRequest = {
|
||
expectedForkAuthorization,
|
||
forkAuthorization,
|
||
};
|
||
return requestJson<{
|
||
ok?: boolean;
|
||
replayed?: boolean;
|
||
game: {
|
||
id: string;
|
||
forkAuthorization?: GameDistributionForkAuthorization;
|
||
};
|
||
}>(
|
||
`/games/${encodeURIComponent(normalizedGameId)}/fork-authorization`,
|
||
{
|
||
method: 'PUT',
|
||
headers: { 'Idempotency-Key': normalizedKey },
|
||
body: JSON.stringify(payload),
|
||
},
|
||
'设置共创授权失败',
|
||
);
|
||
}
|
||
|
||
export async function getGame(gameId: string) {
|
||
const normalizedId = gameId.trim();
|
||
if (!normalizedId) return null;
|
||
return requestJson<GameDistributionGameWithCollection>(
|
||
`/games/${encodeURIComponent(normalizedId)}`,
|
||
{ method: 'GET' },
|
||
'读取游戏详情失败',
|
||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 公开详情在**已认证**时多带 `collected`;匿名请求**完全不返回该键**。
|
||
*
|
||
* 所以类型里它是可选的,读侧必须把「缺字段」与 `false` 区分开:缺字段 = 不可判定
|
||
* (未登录,或带过期 token 被服务端按匿名处理),不能渲染成「未收藏」。
|
||
*/
|
||
export type GameDistributionGameWithCollection = GameDistributionGame & {
|
||
collected?: boolean;
|
||
/**
|
||
* 该作品所属的公开共创主题(先取作品的根再按根反查,因此**第 N 代作品也会有值**)。
|
||
* 匿名也返回:没有所属主题时是 `[]` 而不是缺键。
|
||
*/
|
||
themes?: GameDistributionThemeReference[];
|
||
};
|
||
|
||
/** 作品详情里的主题入口:只够渲染一个跳转(简介与成员数在主题页取)。 */
|
||
export type GameDistributionThemeReference = {
|
||
themeId: string;
|
||
name: string;
|
||
badge: string;
|
||
};
|
||
|
||
/** 共创主题摘要(公开列表条目)。 */
|
||
export type GameDistributionThemeSummary = {
|
||
themeId: string;
|
||
name: string;
|
||
summary: string;
|
||
badge: string;
|
||
/** 当前**可见**成员数(与主题详情 `roots` 长度同口径,不含草稿/已下架成员)。 */
|
||
memberCount: number;
|
||
};
|
||
|
||
/** 主题详情:摘要 + 成员根清单(逐条是公开目录同一份投影)。 */
|
||
export type GameDistributionThemeDetail = GameDistributionThemeSummary & {
|
||
roots: GameDistributionGame[];
|
||
};
|
||
|
||
function normalizeThemeList(value: unknown): GameDistributionThemeSummary[] {
|
||
if (!Array.isArray(value)) return [];
|
||
return value.flatMap((item) => {
|
||
if (!item || typeof item !== 'object') return [];
|
||
const theme = item as Partial<GameDistributionThemeSummary>;
|
||
const themeId = typeof theme.themeId === 'string' ? theme.themeId.trim() : '';
|
||
const name = typeof theme.name === 'string' ? theme.name.trim() : '';
|
||
if (!themeId || !name) return [];
|
||
return [
|
||
{
|
||
themeId,
|
||
name,
|
||
summary: typeof theme.summary === 'string' ? theme.summary : '',
|
||
badge: typeof theme.badge === 'string' ? theme.badge : '',
|
||
memberCount:
|
||
typeof theme.memberCount === 'number' && theme.memberCount >= 0
|
||
? theme.memberCount
|
||
: 0,
|
||
},
|
||
];
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 共创主题列表(匿名可读)。分页与 `/my-collections` 同口径:服务端默认 20 / 上限 50,
|
||
* `nextCursor` 是真实游标(末页为 `null`),客户端只在非空时渲染「加载更多」。
|
||
*/
|
||
export async function listThemes(cursor?: string) {
|
||
const params = new URLSearchParams();
|
||
const normalizedCursor = cursor?.trim();
|
||
if (normalizedCursor) params.set('cursor', normalizedCursor);
|
||
const query = params.toString();
|
||
const payload = await requestJson<{
|
||
themes?: unknown;
|
||
nextCursor?: string | null;
|
||
}>(
|
||
`/themes${query ? `?${query}` : ''}`,
|
||
{ method: 'GET', cache: 'no-store' },
|
||
'读取共创主题失败',
|
||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||
);
|
||
return {
|
||
themes: normalizeThemeList(payload.themes),
|
||
nextCursor: payload.nextCursor ?? null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 共创主题详情(匿名可读)。主题不存在 / 未发布一律 404(服务端不返回空壳),
|
||
* 这里按失败抛出,由主题页渲染成「主题不存在或尚未发布」的降级态。
|
||
*/
|
||
export async function getTheme(
|
||
themeId: string,
|
||
): Promise<GameDistributionThemeDetail | null> {
|
||
const normalizedThemeId = themeId.trim();
|
||
if (!normalizedThemeId) return null;
|
||
const payload = await requestJson<{
|
||
themeId?: string;
|
||
name?: string;
|
||
summary?: string;
|
||
badge?: string;
|
||
memberCount?: number;
|
||
roots?: unknown;
|
||
}>(
|
||
`/themes/${encodeURIComponent(normalizedThemeId)}`,
|
||
{ method: 'GET', cache: 'no-store' },
|
||
'读取共创主题失败',
|
||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||
);
|
||
return {
|
||
themeId: payload.themeId ?? normalizedThemeId,
|
||
name: payload.name ?? '',
|
||
summary: payload.summary ?? '',
|
||
badge: payload.badge ?? '',
|
||
memberCount: payload.memberCount ?? 0,
|
||
roots: normalizeGameList(payload.roots),
|
||
};
|
||
}
|
||
|
||
/** 收藏切换的响应;DELETE 不带 `replayed`。 */
|
||
export type GameDistributionCollectionState = {
|
||
collected: boolean;
|
||
replayed?: boolean;
|
||
};
|
||
|
||
/**
|
||
* 收藏 / 取消收藏。收藏走 PUT(幂等键由调用方生成,重复提交返回同一结果);
|
||
* 取消走 DELETE(确定性主键,重复调用结果相同,因此不需要幂等键)。
|
||
*/
|
||
export async function setGameCollection(
|
||
gameId: string,
|
||
collected: boolean,
|
||
idempotencyKey: string,
|
||
) {
|
||
const normalizedId = gameId.trim();
|
||
if (!normalizedId) throw new Error('收藏缺少作品编号');
|
||
if (!collected) {
|
||
return requestJson<GameDistributionCollectionState>(
|
||
`/games/${encodeURIComponent(normalizedId)}/collection`,
|
||
{ method: 'DELETE' },
|
||
'取消收藏失败',
|
||
);
|
||
}
|
||
const normalizedKey = idempotencyKey.trim();
|
||
if (!normalizedKey) throw new Error('收藏缺少幂等键');
|
||
return requestJson<GameDistributionCollectionState>(
|
||
`/games/${encodeURIComponent(normalizedId)}/collection`,
|
||
{
|
||
method: 'PUT',
|
||
headers: { 'Idempotency-Key': normalizedKey },
|
||
},
|
||
'收藏失败',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 「我的收藏」:只返回当前公开可读的收藏作品(下架/软删除的作品只是不出现,行不删除),
|
||
* 条目形状与公开目录逐字一致,因此可以复用同一套卡片组件渲染。
|
||
*
|
||
* `nextCursor` 服务端当前恒为 `null`(真分页是已知留白);这里按「将来会有 cursor」的形态
|
||
* 读出来并原样交给调用方,调用方只在非空时渲染「加载更多」。
|
||
*/
|
||
export async function listMyCollections() {
|
||
const payload = await requestJson<{
|
||
games?: unknown;
|
||
nextCursor?: string | null;
|
||
}>('/my-collections', { method: 'GET', cache: 'no-store' }, '读取我的收藏失败');
|
||
return {
|
||
games: normalizeGameList(payload.games),
|
||
nextCursor: payload.nextCursor ?? null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 读取以某作品的根为顶的创作族谱树。公开只读接口,匿名可读;
|
||
* 作品不存在或已软删除时服务端返回 404,这里按失败抛出,由页面走既有错误态。
|
||
*/
|
||
export async function getGameLineage(gameId: string) {
|
||
const normalizedId = gameId.trim();
|
||
if (!normalizedId) return null;
|
||
return requestJson<GameDistributionLineageResponse>(
|
||
`/games/${encodeURIComponent(normalizedId)}/lineage`,
|
||
{ method: 'GET', cache: 'no-store' },
|
||
'读取创作族谱失败',
|
||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 读取某作品的直接衍生作品(「被改编」列表)。只含对外可见的直接子代,
|
||
* 与公开详情里 `forkCount` 的口径一致。
|
||
*/
|
||
export async function getDerivedGames(gameId: string) {
|
||
const normalizedId = gameId.trim();
|
||
if (!normalizedId) return null;
|
||
return requestJson<GameDistributionDerivedResponse>(
|
||
`/games/${encodeURIComponent(normalizedId)}/derived`,
|
||
{ method: 'GET', cache: 'no-store' },
|
||
'读取衍生作品失败',
|
||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 上报一次「开始游戏」。调用方按 fire-and-forget 处理:失败只静默忽略,绝不阻断进入游戏。
|
||
*/
|
||
export function recordGamePlay(
|
||
gameId: string,
|
||
): Promise<{ recorded?: boolean }> {
|
||
const normalizedId = gameId.trim();
|
||
if (!normalizedId) return Promise.resolve({ recorded: false });
|
||
return requestJson<{ recorded?: boolean }>(
|
||
`/games/${encodeURIComponent(normalizedId)}/plays`,
|
||
{
|
||
method: 'POST',
|
||
cache: 'no-store',
|
||
body: JSON.stringify({ clientId: getGamePlayClientId() }),
|
||
},
|
||
'游玩上报失败',
|
||
BACKGROUND_AUTH_REQUEST_OPTIONS,
|
||
);
|
||
}
|
||
|
||
export function listGameReviews(gameId: string, page = 1) {
|
||
return requestJson<GameDistributionReviewsResponse>(
|
||
`/games/${encodeURIComponent(gameId)}/reviews?page=${page}&pageSize=20`,
|
||
{ method: 'GET', cache: 'no-store' },
|
||
'读取游戏评价失败',
|
||
PUBLIC_GAME_REQUEST_OPTIONS,
|
||
);
|
||
}
|
||
|
||
export function getMyGameReview(gameId: string) {
|
||
return requestJson<GameDistributionMyReviewResponse>(
|
||
`/games/${encodeURIComponent(gameId)}/my-review`,
|
||
{ method: 'GET', cache: 'no-store' },
|
||
'读取我的评价失败',
|
||
);
|
||
}
|
||
|
||
export function saveMyGameReview(
|
||
gameId: string,
|
||
payload: GameDistributionSaveReviewRequest,
|
||
) {
|
||
return requestJson<GameDistributionSaveReviewResponse>(
|
||
`/games/${encodeURIComponent(gameId)}/my-review`,
|
||
{ method: 'PUT', cache: 'no-store', body: JSON.stringify(payload) },
|
||
'保存评价失败',
|
||
);
|
||
}
|
||
|
||
export async function createGame(
|
||
payload: GameDistributionCreateGameRequest,
|
||
idempotencyKey: string,
|
||
) {
|
||
const normalizedKey = idempotencyKey.trim();
|
||
if (!normalizedKey) throw new Error('创建游戏缺少幂等键');
|
||
return requestJson<GameDistributionGame>(
|
||
'/games',
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': normalizedKey },
|
||
body: JSON.stringify(payload),
|
||
},
|
||
'创建游戏失败',
|
||
);
|
||
}
|
||
|
||
export async function createGameVersion(
|
||
gameId: string,
|
||
payload: GameDistributionCreateVersionRequest,
|
||
idempotencyKey: string,
|
||
) {
|
||
const normalizedGameId = gameId.trim();
|
||
const normalizedIdempotencyKey = idempotencyKey.trim();
|
||
if (!normalizedGameId || !normalizedIdempotencyKey) {
|
||
throw new Error('游戏版本创建缺少必要标识');
|
||
}
|
||
return requestJson<GameVersionCreateResult>(
|
||
`/games/${encodeURIComponent(normalizedGameId)}/versions`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': normalizedIdempotencyKey },
|
||
body: JSON.stringify(payload),
|
||
},
|
||
'创建游戏版本失败',
|
||
);
|
||
}
|
||
|
||
/** 回读单个版本状态、驳回理由与服务端给出的恢复动作。 */
|
||
export async function getGameVersion(versionId: string) {
|
||
const normalizedVersionId = versionId.trim();
|
||
if (!normalizedVersionId) throw new Error('缺少版本编号');
|
||
return requestJson<GameDistributionVersionDetail>(
|
||
`/versions/${encodeURIComponent(normalizedVersionId)}`,
|
||
{ method: 'GET' },
|
||
'读取版本状态失败',
|
||
);
|
||
}
|
||
|
||
/** 撤回尚未公开的版本;幂等键由调用方生成,重复提交返回同一结果。 */
|
||
export async function cancelGameVersion(
|
||
versionId: string,
|
||
expectedPublicationRevision: number,
|
||
idempotencyKey: string,
|
||
reason?: string,
|
||
) {
|
||
const normalizedVersionId = versionId.trim();
|
||
const normalizedKey = idempotencyKey.trim();
|
||
if (!normalizedVersionId) throw new Error('撤回版本缺少版本编号');
|
||
if (!normalizedKey) throw new Error('撤回版本缺少幂等键');
|
||
const normalizedReason = reason?.trim();
|
||
return requestJson<GameDistributionCancelVersionResponse>(
|
||
`/versions/${encodeURIComponent(normalizedVersionId)}/cancel`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': normalizedKey },
|
||
body: JSON.stringify({
|
||
expectedPublicationRevision,
|
||
...(normalizedReason ? { reason: normalizedReason } : {}),
|
||
}),
|
||
},
|
||
'撤回版本失败',
|
||
);
|
||
}
|
||
|
||
export async function submitGameVersion(
|
||
versionId: string,
|
||
expectedPublicationRevision: number,
|
||
idempotencyKey: string,
|
||
) {
|
||
const normalizedVersionId = versionId.trim();
|
||
const normalizedKey = idempotencyKey.trim();
|
||
if (!normalizedVersionId) throw new Error('提交审核缺少版本 ID');
|
||
if (!normalizedKey) throw new Error('提交审核缺少幂等键');
|
||
return requestJson<{ version?: { status?: string } }>(
|
||
`/versions/${encodeURIComponent(normalizedVersionId)}/submit`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'Idempotency-Key': normalizedKey },
|
||
body: JSON.stringify({ expectedPublicationRevision }),
|
||
},
|
||
'提交审核失败',
|
||
);
|
||
}
|
||
|
||
export async function uploadGamePackage(
|
||
versionId: string,
|
||
packageBody: Blob | ArrayBuffer,
|
||
idempotencyKey: string,
|
||
) {
|
||
const normalizedVersionId = versionId.trim();
|
||
const normalizedIdempotencyKey = idempotencyKey.trim();
|
||
if (!normalizedVersionId || !normalizedIdempotencyKey) {
|
||
throw new Error('游戏发行包上传缺少必要标识');
|
||
}
|
||
return requestJson<{ versionId: string; status: 'uploaded' }>(
|
||
`/versions/${encodeURIComponent(normalizedVersionId)}/package`,
|
||
{
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/zip',
|
||
'Idempotency-Key': normalizedIdempotencyKey,
|
||
},
|
||
body: packageBody,
|
||
},
|
||
'上传游戏发行包失败',
|
||
);
|
||
}
|