Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / AI game creator shell Rust crates (pull_request) Successful in 6m0s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m43s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m42s
Project CI / Frontend tests (pull_request) Failing after 1m16s
Project CI / Backend tests (pull_request) Successful in 8m51s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m34s
Project CI / Repository checks (pull_request) Successful in 5m34s
Project CI / Native shell tests (pull_request) Successful in 7m18s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-10-06 10:14:15 +08:00
131 changed files with 15731 additions and 5835 deletions
+9
View File
@@ -59,3 +59,12 @@ describe('activeAppTitle', () => {
});
});
});
test('创作者主页和关系列表使用各自标题', () => {
expect(resolveAppTitleForSelectionStage('creator-home')).toBe(
'创作者主页 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('creator-connections')).toBe(
'关注与粉丝 - 陶泥儿',
);
});
+2
View File
@@ -8,6 +8,8 @@ const APP_TITLE_BY_SELECTION_STAGE: Record<SelectionStage, string> = {
platform: APP_HOME_TITLE,
'creation-home': APP_HOME_TITLE,
project: '项目 - 陶泥儿',
'creator-home': '创作者主页 - 陶泥儿',
'creator-connections': '关注与粉丝 - 陶泥儿',
profile: '我的 - 陶泥儿',
'profile-payment': '支付服务 - 陶泥儿',
'payment-checkout': '扫码支付 - 陶泥儿',
+41
View File
@@ -0,0 +1,41 @@
import { beforeEach, expect, it, vi } from 'vitest';
import {
changeCreatorRelationship,
getCreator,
getCreatorConnections,
} from './creatorProfileClient';
const request = vi.hoisted(() => vi.fn());
vi.mock('./apiClient', () => ({ requestJson: request }));
beforeEach(() => request.mockReset());
it('公开主页请求独立于登录态', async () => {
await getCreator('a/b');
expect(request.mock.calls[0]?.[0]).toBe('/api/creators/a%2Fb');
expect(request.mock.calls[0]?.[3]).toEqual({
skipAuth: true,
skipRefresh: true,
});
});
it('列表游标编码,游客不附带凭证', async () => {
await getCreatorConnections('a', 'followers', 'x+y', false);
expect(request.mock.calls[0]?.[0]).toBe(
'/api/creators/a/followers?limit=20&cursor=x%2By',
);
expect(request.mock.calls[0]?.[3]).toEqual({
skipAuth: true,
skipRefresh: true,
});
});
it.each([
['follow', 'PUT', '/api/creators/b/follow'],
['unfollow', 'DELETE', '/api/creators/b/follow'],
['remove-follower', 'DELETE', '/api/creators/me/followers/b'],
] as const)('%s 不自动重放写请求', async (action, method, path) => {
await changeCreatorRelationship('b', action);
expect(request.mock.calls[0]?.[0]).toBe(path);
expect(request.mock.calls[0]?.[1]).toEqual({ method });
expect(request.mock.calls[0]?.[3]).toEqual({
skipRefresh: true,
retry: { maxRetries: 0 },
});
});
+61
View File
@@ -0,0 +1,61 @@
import type {
CreatorConnections,
CreatorProfile,
CreatorRelationshipResponse,
} from '../../packages/shared/src/contracts/creator';
import { requestJson as requestApiJson } from './apiClient';
export type CreatorConnectionKind = 'following' | 'followers';
export type CreatorAction = 'follow' | 'unfollow' | 'remove-follower';
export const creatorPath = (id: string) =>
`/creators?id=${encodeURIComponent(id)}`;
export const connectionsPath = (id: string, tab: CreatorConnectionKind) =>
`/creators/connections?id=${encodeURIComponent(id)}&tab=${tab}`;
const userPath = (id: string) => `/api/creators/${encodeURIComponent(id)}`;
export function getCreator(id: string) {
return requestApiJson<CreatorProfile>(
userPath(id),
{ method: 'GET' },
'读取创作者资料失败',
{ skipAuth: true, skipRefresh: true },
);
}
export function getCreatorRelationship(id: string) {
return requestApiJson<CreatorRelationshipResponse>(
`${userPath(id)}/relationship`,
{ method: 'GET' },
'读取关注状态失败',
);
}
export function getCreatorConnections(
id: string,
kind: CreatorConnectionKind,
cursor: string | null,
authenticated: boolean,
) {
const query = new URLSearchParams({ limit: '20' });
if (cursor) query.set('cursor', cursor);
return requestApiJson<CreatorConnections>(
`${userPath(id)}/${kind}?${query}`,
{ method: 'GET' },
'读取用户列表失败',
authenticated ? undefined : { skipAuth: true, skipRefresh: true },
);
}
export function changeCreatorRelationship(id: string, action: CreatorAction) {
const path =
action === 'remove-follower'
? `/api/creators/me/followers/${encodeURIComponent(id)}`
: `${userPath(id)}/follow`;
return requestApiJson<CreatorRelationshipResponse>(
path,
{ method: action === 'follow' ? 'PUT' : 'DELETE' },
'关注操作失败',
{
// 写入不自动重放,包括鉴权刷新后的重放;失败由业务层回读确认。
skipRefresh: true,
retry: { maxRetries: 0 },
},
);
}
@@ -19,6 +19,14 @@ import {
describe('gameDistributionClient', () => {
beforeEach(() => {
// Node 新版提供的原生 localStorage 不等于浏览器存储;测试使用独立内存存储。
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear(),
});
clearStoredAccessToken({ emit: false });
});
@@ -80,6 +88,25 @@ describe('gameDistributionClient', () => {
expect(games[0]?.id).toBe('game-1');
});
it('作者过滤由查询参数交给后端,显式空值不退回全站游戏', async () => {
const fetchMock = vi
.fn()
.mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ games: [] }), { status: 200 }),
),
);
vi.stubGlobal('fetch', fetchMock);
await listGames({ authorId: 'user/a' });
expect(fetchMock.mock.calls[0][0]).toBe(
'/api/game-distribution/games?authorId=user%2Fa',
);
await listGames({ authorId: '' });
expect(fetchMock.mock.calls[1][0]).toBe(
'/api/game-distribution/games?authorId=',
);
});
it('读取详情时拒绝空 id', async () => {
expect(await getGame('')).toBeNull();
});
@@ -180,6 +207,14 @@ describe('gameDistributionClient', () => {
describe('gameDistributionClient 作者接口', () => {
beforeEach(() => {
// Node 新版提供的原生 localStorage 不等于浏览器存储;测试使用独立内存存储。
const values = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
removeItem: (key: string) => values.delete(key),
clear: () => values.clear(),
});
clearStoredAccessToken({ emit: false });
});
+4 -1
View File
@@ -42,6 +42,7 @@ export type GameAuthor = GameDistributionAuthor;
export type GameVersionSummary = GameDistributionVersionSummary;
export type GameListQuery = {
authorId?: string;
keyword?: string;
category?: GameCategory | '全部';
};
@@ -105,11 +106,13 @@ async function requestJson<T>(
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${params.size ? `?${params.toString()}` : ''}`,
`/games${search ? `?${search}` : ''}`,
{ method: 'GET' },
'读取游戏目录失败',
PUBLIC_GAME_REQUEST_OPTIONS,