合并 master 最新更新
Project CI / Frontend tests (push) Failing after 24s
Project CI / Repository checks (push) Failing after 43s
Project CI / Backend tests (push) Successful in 3m5s
Project CI / Native shell tests (push) Failing after 6m49s

合入 master 的 BgFilter、CI、运维与现役平台改造。

保留 AI 游戏创作 Runtime、独立锁文件与原生壳验证链路。

修复共享充值账单组件、LLM 网关与退役 Agent 兼容边界。

同步冲突文档、锁文件和开发脚本。
This commit is contained in:
AIGameCreator App
2026-07-27 18:28:26 +08:00
1304 changed files with 170414 additions and 36096 deletions
+141 -16
View File
@@ -3,10 +3,13 @@ import { afterEach, expect, test, vi } from 'vitest';
import {
createAdminAccount,
executeAdminRechargeRefund,
getAdminFeatureGateConfig,
getAdminUserDetail,
listAdminRechargeOrders,
resolveAdminRechargeRefundManualReview,
updateAdminAccount,
uploadAdminEditorShowcaseCampaignImage,
upsertAdminFeatureGateConfig,
} from './adminApiClient';
afterEach(() => {
@@ -16,7 +19,7 @@ afterEach(() => {
test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({account: {accountId: 'member-1'}}), {
new Response(JSON.stringify({ account: { accountId: 'member-1' } }), {
status: 200,
}),
),
@@ -40,7 +43,7 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({Authorization: 'Bearer owner-token'}),
headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1');
@@ -56,6 +59,132 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () =>
);
});
test('灰度配置读写只使用通用 feature-gates 管理接口', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ gates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await getAdminFeatureGateConfig('gray-token');
await upsertAdminFeatureGateConfig('gray-token', {
gateKey: 'image-editor:agent-sidebar',
enabled: true,
rolloutPercent: 25,
allowUserIds: ['user-1'],
allowUserTags: ['beta'],
denyUserIds: ['blocked-1'],
description: '画布 Agent 入口灰度',
});
expect(fetchMock.mock.calls[0]?.[0]).toBe('/admin/api/feature-gates');
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/feature-gates');
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'PUT',
headers: expect.objectContaining({ Authorization: 'Bearer gray-token' }),
body: JSON.stringify({
gateKey: 'image-editor:agent-sidebar',
enabled: true,
rolloutPercent: 25,
allowUserIds: ['user-1'],
allowUserTags: ['beta'],
denyUserIds: ['blocked-1'],
description: '画布 Agent 入口灰度',
}),
}),
);
});
test('活动卡图片上传成功后先确认正式私有对象再返回图片引用', async () => {
const closeBitmap = vi.fn();
vi.stubGlobal(
'createImageBitmap',
vi
.fn()
.mockResolvedValue({ width: 1024, height: 1536, close: closeBitmap }),
);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(
JSON.stringify({
upload: {
bucket: 'genarrative-release',
host: 'https://genarrative-release.oss.example.com',
objectKey:
'generated-character-drafts/editor/showcase-campaign/current/card.png',
legacyPublicPath:
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
contentType: 'image/png',
formFields: { key: 'campaign-key', policy: 'signed-policy' },
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
)
.mockResolvedValueOnce(new Response('', { status: 200 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({ assetObject: { assetObjectId: 'assetobj-1' } }),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
),
);
vi.stubGlobal('fetch', fetchMock);
const file = new File(['image-bytes'], 'card.png', { type: 'image/png' });
const uploaded = await uploadAdminEditorShowcaseCampaignImage(
'admin-token',
file,
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/editor-showcase/campaign/image-upload-ticket',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'https://genarrative-release.oss.example.com',
);
expect(fetchMock.mock.calls[2]?.[0]).toBe(
'/admin/api/editor-showcase/campaign/image-upload-confirm',
);
expect(fetchMock.mock.calls[2]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ Authorization: 'Bearer admin-token' }),
body: JSON.stringify({
bucket: 'genarrative-release',
objectKey:
'generated-character-drafts/editor/showcase-campaign/current/card.png',
contentType: 'image/png',
contentLength: file.size,
}),
}),
);
expect(uploaded).toEqual({
imageSrc:
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
imageObjectKey:
'generated-character-drafts/editor/showcase-campaign/current/card.png',
imageWidth: 1024,
imageHeight: 1536,
legacyPublicPath:
'/generated-character-drafts/editor/showcase-campaign/current/card.png',
});
expect(closeBitmap).toHaveBeenCalledOnce();
});
test('充值订单查询按后台契约序列化筛选参数', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ entries: [] }), {
@@ -110,13 +239,11 @@ test('用户详情只发送实际提供的用户定位字段', async () => {
});
test('退款执行使用独立 execute 管理员路由', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
await executeAdminRechargeRefund('token-1', {
@@ -143,13 +270,11 @@ test('退款执行使用独立 execute 管理员路由', async () => {
});
test('退款人工复核使用独立 resolve 管理员路由', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
await resolveAdminRechargeRefundManualReview('token-1', {
+36 -103
View File
@@ -1,10 +1,10 @@
import type {
AdminAccountListResponse,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse,
AdminCreationEntryConfigResponse,
AdminDashboardQuery,
AdminDashboardResponse,
AdminDatabaseTableListResponse,
@@ -40,11 +40,7 @@ import type {
AdminTrackingEventListResponse,
AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertCreationEntryEventBannersRequest,
AdminUpsertCreationEntryTypeConfigRequest,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
AdminUpsertProfileInviteCodeRequest,
@@ -52,12 +48,10 @@ import type {
AdminUpsertProfileRedeemCodeRequest,
AdminUpsertProfileTaskConfigRequest,
AdminUpsertProfileWalletConfigRequest,
AdminUpsertPublicWorkInteractionConfigRequest,
AdminUserDetailQuery,
AdminUserDetailResponse,
AdminWalletRestrictionRequest,
AdminWalletRestrictionResponse,
AdminWorkVisibilityListResponse,
ApiErrorEnvelope,
ApiMeta,
ApiSuccessEnvelope,
@@ -194,7 +188,7 @@ export function getAdminMe(token: string) {
}
export function listAdminAccounts(token: string) {
return request<AdminAccountListResponse>('/admin/api/accounts', {token});
return request<AdminAccountListResponse>('/admin/api/accounts', { token });
}
export function createAdminAccount(
@@ -215,7 +209,7 @@ export function updateAdminAccount(
) {
return request<AdminUpdateAccountResponse>(
`/admin/api/accounts/${encodeURIComponent(accountId)}`,
{method: 'PUT', token, body: payload},
{ method: 'PUT', token, body: payload },
);
}
@@ -292,57 +286,6 @@ export function upsertAdminFeatureGateConfig(
});
}
export function getAdminCreationEntryConfig(token: string) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config',
{ token },
);
}
export function upsertAdminCreationEntryConfig(
token: string,
payload: AdminUpsertCreationEntryTypeConfigRequest,
) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config',
{
method: 'POST',
token,
body: payload,
},
);
}
/** 保存创作入口公告表单序列化后的后端传输字段。 */
export function upsertAdminCreationEntryBanners(
token: string,
payload: AdminUpsertCreationEntryEventBannersRequest,
) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config/banners',
{
method: 'POST',
token,
body: payload,
},
);
}
/** 保存公开作品详情页点赞 / 改造能力配置。 */
export function upsertAdminPublicWorkInteractions(
token: string,
payload: AdminUpsertPublicWorkInteractionConfigRequest,
) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config/interactions',
{
method: 'POST',
token,
body: payload,
},
);
}
export function getAdminEditorGenerationPricing(token: string) {
return request<EditorGenerationPricingConfigPayload>(
'/admin/api/editor-generation-pricing',
@@ -364,27 +307,6 @@ export function upsertAdminEditorGenerationPricing(
);
}
export function listAdminWorkVisibility(token: string) {
return request<AdminWorkVisibilityListResponse>(
'/admin/api/works/visibility',
{ token },
);
}
export function updateAdminWorkVisibility(
token: string,
payload: AdminUpdateWorkVisibilityRequest,
) {
return request<AdminUpdateWorkVisibilityResponse>(
'/admin/api/works/visibility',
{
method: 'POST',
token,
body: payload,
},
);
}
export function getAdminAssetReadUrl(
token: string,
query: AdminAssetReadUrlQuery,
@@ -471,20 +393,34 @@ export async function uploadAdminEditorShowcaseCampaignImage(
): Promise<AdminUploadedEditorShowcaseCampaignImage> {
const contentType = resolveAdminImageContentType(file);
const dimensions = await readAdminImageFileDimensions(file);
const response = await request<AdminCreateEditorShowcaseCampaignImageUploadTicketResponse>(
'/admin/api/editor-showcase/campaign/image-upload-ticket',
const response =
await request<AdminCreateEditorShowcaseCampaignImageUploadTicketResponse>(
'/admin/api/editor-showcase/campaign/image-upload-ticket',
{
method: 'POST',
token,
body: {
fileName: file.name.trim() || 'showcase-campaign.png',
contentType,
contentLength: file.size,
} satisfies AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
},
);
await postAdminDirectUploadFile(response.upload, file);
const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, '');
await request<unknown>(
'/admin/api/editor-showcase/campaign/image-upload-confirm',
{
method: 'POST',
token,
body: {
fileName: file.name.trim() || 'showcase-campaign.png',
bucket: response.upload.bucket,
objectKey,
contentType,
contentLength: file.size,
} satisfies AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
} satisfies AdminConfirmEditorShowcaseCampaignImageUploadRequest,
},
);
await postAdminDirectUploadFile(response.upload, file);
const objectKey = response.upload.objectKey.trim().replace(/^\/+/u, '');
return {
imageSrc: objectKey ? `/${objectKey}` : response.upload.legacyPublicPath,
imageObjectKey: objectKey,
@@ -630,17 +566,14 @@ export function listAdminRechargeOrders(
) {
return request<AdminRechargeOrderListResponse>(
`/admin/api/profile/recharge-orders${buildAdminRechargeOrderListQuery(query)}`,
{token},
{ token },
);
}
export function getAdminUserDetail(
token: string,
query: AdminUserDetailQuery,
) {
export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) {
return request<AdminUserDetailResponse>(
`/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`,
{token},
{ token },
);
}
@@ -650,7 +583,7 @@ export function previewAdminRechargeRefund(
) {
return request<AdminRechargeRefundPreviewResponse>(
'/admin/api/profile/recharge-refunds/preview',
{method: 'POST', token, body: payload},
{ method: 'POST', token, body: payload },
);
}
@@ -660,7 +593,7 @@ export function executeAdminRechargeRefund(
) {
return request<AdminRechargeRefundActionResponse>(
'/admin/api/profile/recharge-refunds/execute',
{method: 'POST', token, body: payload},
{ method: 'POST', token, body: payload },
);
}
@@ -670,7 +603,7 @@ export function registerAdminRechargeRefund(
) {
return request<AdminRechargeRefundActionResponse>(
'/admin/api/profile/recharge-refunds/register',
{method: 'POST', token, body: payload},
{ method: 'POST', token, body: payload },
);
}
@@ -680,7 +613,7 @@ export function resolveAdminRechargeRefundManualReview(
) {
return request<AdminRechargeRefundActionResponse>(
'/admin/api/profile/recharge-refunds/manual-review/resolve',
{method: 'POST', token, body: payload},
{ method: 'POST', token, body: payload },
);
}
@@ -690,7 +623,7 @@ export function updateAdminWalletRestriction(
) {
return request<AdminWalletRestrictionResponse>(
'/admin/api/profile/wallet-restriction',
{method: 'POST', token, body: payload},
{ method: 'POST', token, body: payload },
);
}
@@ -710,10 +643,7 @@ function buildAssetReadUrlQuery(query: AdminAssetReadUrlQuery) {
if (objectKey) {
params.set('objectKey', objectKey);
} else if (legacyPublicPath) {
params.set(
'legacyPublicPath',
`/${legacyPublicPath.replace(/^\/+/u, '')}`,
);
params.set('legacyPublicPath', `/${legacyPublicPath.replace(/^\/+/u, '')}`);
}
if (
typeof query.expireSeconds === 'number' &&
@@ -731,7 +661,10 @@ function resolveAdminImageContentType(file: File) {
if (declaredType.startsWith('image/')) {
return declaredType;
}
const extension = file.name.trim().toLowerCase().match(/\.([a-z0-9]+)$/u)?.[1];
const extension = file.name
.trim()
.toLowerCase()
.match(/\.([a-z0-9]+)$/u)?.[1];
if (extension === 'jpg' || extension === 'jpeg') {
return 'image/jpeg';
}
+8 -121
View File
@@ -306,78 +306,6 @@ export type AdminUpsertFeatureGateConfigRequest = Omit<
'updatedAt'
>;
/** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */
export interface AdminCreationEntryConfigResponse {
entries: AdminCreationEntryTypeConfigPayload[];
eventBanners: AdminCreationEntryEventBannerPayload[];
publicWorkInteractions: PublicWorkInteractionConfigPayload[];
}
/** 后台创作入口公告位配置项;旧结构化 banner 字段仅保留兼容。 */
export interface AdminCreationEntryEventBannerPayload {
title: string;
description: string;
coverImageSrc: string;
prizePoolMudPoints: number;
startsAtText: string;
endsAtText: string;
renderMode: 'structured' | 'html';
htmlCode?: string | null;
}
/** 后台单个创作模板入口配置,公告不再绑定在某一个入口上。 */
export interface AdminCreationEntryTypeConfigPayload {
id: string;
title: string;
subtitle: string;
badge: string;
imageSrc: string;
visible: boolean;
open: boolean;
sortOrder: number;
categoryId: string;
categoryLabel: string;
categorySortOrder: number;
updatedAtMicros: number;
unifiedCreationSpec?: UnifiedCreationSpecPayload | null;
}
/** 后台保存创作模板入口开关与统一创作契约的请求体。 */
export interface AdminUpsertCreationEntryTypeConfigRequest {
id: string;
title: string;
subtitle: string;
badge: string;
imageSrc: string;
visible: boolean;
open: boolean;
sortOrder: number;
categoryId: string;
categoryLabel: string;
categorySortOrder: number;
unifiedCreationSpec?: UnifiedCreationSpecPayload | null;
}
/** 后台保存创作入口公告表单序列化结果的请求体。 */
export interface AdminUpsertCreationEntryEventBannersRequest {
/** 传输字段沿用后端契约,内容由后台表单生成。 */
eventBannersJson: string;
}
/** 后台公开作品详情页互动能力配置项。 */
export interface PublicWorkInteractionConfigPayload {
sourceType: string;
likeEnabled: boolean;
remixEnabled: boolean;
likeDisabledMessage: string;
remixDisabledMessage: string;
}
/** 后台保存公开作品点赞 / 改造能力配置请求体。 */
export interface AdminUpsertPublicWorkInteractionConfigRequest {
publicWorkInteractions: PublicWorkInteractionConfigPayload[];
}
/** 图片画布生成模型泥点定价配置。 */
export type EditorGenerationPricingUnitPayload = 'perGeneration' | 'perSecond';
@@ -391,55 +319,6 @@ export interface EditorGenerationPricingConfigPayload {
models: Record<string, EditorGenerationModelPricingPayload>;
}
/** 后台统一创作工作台契约表单的传输结构。 */
export interface UnifiedCreationSpecPayload {
playId: string;
title: string;
mudPointCost: number;
workspaceStage: string;
generationStage: string;
resultStage: string;
fields: UnifiedCreationFieldPayload[];
}
/** 后台统一创作字段契约,保存前会校验字段类型和必填标记。 */
export interface UnifiedCreationFieldPayload {
id: string;
kind: 'text' | 'select' | 'image' | 'audio';
label: string;
required: boolean;
}
export interface AdminWorkVisibilityEntryPayload {
sourceType: string;
workId: string;
profileId: string;
sourceSessionId?: string | null;
publicWorkCode: string;
ownerUserId: string;
authorDisplayName: string;
title: string;
subtitle: string;
coverImageSrc?: string | null;
visible: boolean;
publishedAtMicros?: number | null;
updatedAtMicros: number;
}
export interface AdminWorkVisibilityListResponse {
entries: AdminWorkVisibilityEntryPayload[];
}
export interface AdminUpdateWorkVisibilityRequest {
sourceType: string;
profileId: string;
visible: boolean;
}
export interface AdminUpdateWorkVisibilityResponse {
entry: AdminWorkVisibilityEntryPayload;
}
export interface AdminEditorAssetListQuery {
cursor?: string | null;
ownerUserId?: string | null;
@@ -513,6 +392,7 @@ export interface AdminEditorShowcaseAssetPayload {
taskId?: string | null;
assetKind?: string | null;
generationInputs?: Record<string, unknown> | null;
thumbnailSrc?: string | null;
generationCostMudPoints: number;
refundMudPoints: number;
reviewStatus: 'pending' | 'approved' | 'rejected' | string;
@@ -600,6 +480,13 @@ export interface AdminCreateEditorShowcaseCampaignImageUploadTicketResponse {
upload: AdminDirectUploadTicketPayload;
}
export interface AdminConfirmEditorShowcaseCampaignImageUploadRequest {
bucket: string;
objectKey: string;
contentType: string;
contentLength: number;
}
export interface AdminUploadedEditorShowcaseCampaignImage {
imageSrc: string;
imageObjectKey: string;
+35 -56
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
formatAdminApiError,
@@ -17,33 +17,31 @@ import {
getStoredAdminToken,
setStoredAdminToken,
} from '../auth/adminAuthStore';
import {AdminAccountsPage} from '../pages/AdminAccountsPage';
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
import {AdminDashboardPage} from '../pages/AdminDashboardPage';
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
import {AdminEditorAssetQueryPage} from '../pages/AdminEditorAssetQueryPage';
import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage';
import {AdminEditorShowcaseReviewPage} from '../pages/AdminEditorShowcaseReviewPage';
import {AdminGrayReleaseConfigPage} from '../pages/AdminGrayReleaseConfigPage';
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
import {AdminLoginPage} from '../pages/AdminLoginPage';
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage';
import {AdminRechargeOrderPage} from '../pages/AdminRechargeOrderPage';
import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
import {AdminWorkVisibilityPage} from '../pages/AdminWorkVisibilityPage';
import type {AdminRouteId} from './adminRoutes';
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
import { AdminRechargeProductPage } from '../pages/AdminRechargeProductPage';
import { AdminRedeemCodePage } from '../pages/AdminRedeemCodePage';
import { AdminTaskConfigPage } from '../pages/AdminTaskConfigPage';
import { AdminTrackingEventsPage } from '../pages/AdminTrackingEventsPage';
import type { AdminRouteId } from './adminRoutes';
import {
getAccessibleAdminRoutes,
resolveAccessibleAdminRoute,
resolveAdminRoute,
routeHash,
} from './adminRoutes';
import {AdminShell} from './AdminShell';
import { AdminShell } from './AdminShell';
type SessionStatus = 'checking' | 'guest' | 'authenticated';
@@ -157,17 +155,20 @@ export function AdminApp() {
}
}, []);
const handleLogin = useCallback(async (username: string, password: string) => {
const response = await loginAdmin(username, password);
setStoredAdminToken(response.token);
setToken(response.token);
setAdmin(response.admin);
setTaskConfigResult(null);
setProfileWalletConfigResult(null);
setRechargeProductResult(null);
setLoginNotice('');
setStatus('authenticated');
}, []);
const handleLogin = useCallback(
async (username: string, password: string) => {
const response = await loginAdmin(username, password);
setStoredAdminToken(response.token);
setToken(response.token);
setAdmin(response.admin);
setTaskConfigResult(null);
setProfileWalletConfigResult(null);
setRechargeProductResult(null);
setLoginNotice('');
setStatus('authenticated');
},
[],
);
const handleUnauthorized = useCallback(
(message = '登录状态已失效') => {
@@ -245,25 +246,6 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'creation-announcement' ? (
<AdminCreationEntrySwitchPage
mode="announcements"
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'creation-entry' ? (
<AdminCreationEntrySwitchPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'work-visibility' ? (
<AdminWorkVisibilityPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'tasks' ? (
<AdminTaskConfigPage
result={taskConfigResult}
@@ -313,10 +295,7 @@ export function AdminApp() {
/>
) : null}
{activeRouteId === 'accounts' ? (
<AdminAccountsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
<AdminAccountsPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
</AdminShell>
);
+3 -9
View File
@@ -4,16 +4,13 @@ import {
Bug,
Coins,
Database,
Eye,
GitBranch,
Images,
LayoutDashboard,
ListChecks,
LogOut,
Megaphone,
ReceiptText,
ShieldCheck,
SlidersHorizontal,
Star,
Table2,
TicketCheck,
@@ -21,10 +18,10 @@ import {
Users,
WalletCards,
} from 'lucide-react';
import type {ReactNode} from 'react';
import type { ReactNode } from 'react';
import type {AdminSessionPayload} from '../api/adminApiTypes';
import type {AdminRouteDefinition, AdminRouteId} from './adminRoutes';
import type { AdminSessionPayload } from '../api/adminApiTypes';
import type { AdminRouteDefinition, AdminRouteId } from './adminRoutes';
interface AdminShellProps {
admin: AdminSessionPayload;
@@ -51,9 +48,6 @@ const routeIcons = {
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'editor-assets': Images,
'creation-announcement': Megaphone,
'creation-entry': SlidersHorizontal,
'work-visibility': Eye,
accounts: Users,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
+19 -15
View File
@@ -1,4 +1,4 @@
import {expect, test} from 'vitest';
import { expect, test } from 'vitest';
import {
adminRoutes,
@@ -19,19 +19,6 @@ test('后台默认进入 Dashboard', () => {
expect(routeHash('dashboard')).toBe('#dashboard');
});
// 中文注释:后台入口公告必须作为独立导航存在,避免公告表单被误藏在入口开关页。
test('后台入口公告路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'creation-announcement',
label: '入口公告',
hash: '#creation-announcement',
});
expect(resolveAdminRoute('#creation-announcement')).toBe(
'creation-announcement',
);
expect(routeHash('creation-announcement')).toBe('#creation-announcement');
});
test('后台模型定价路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'editor-generation-pricing',
@@ -56,6 +43,12 @@ test('后台灰度发布路由可通过导航和 hash 访问', () => {
expect(routeHash('gray-release')).toBe('#gray-release');
});
test('后台不再暴露旧创作模板管理路由', () => {
expect(resolveAdminRoute('#creation-entry')).toBe('dashboard');
expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard');
expect(resolveAdminRoute('#work-visibility')).toBe('dashboard');
});
test('后台素材查询路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'editor-assets',
@@ -92,7 +85,7 @@ test('owner 可访问全部业务 Tab 和账号管理', () => {
tabPermissions: [],
});
expect(routes).toEqual(adminRoutes);
expect(routes.at(-1)).toMatchObject({id: 'accounts', ownerOnly: true});
expect(routes.at(-1)).toMatchObject({ id: 'accounts', ownerOnly: true });
});
test('member 只访问已分配 Tab 且无权 hash 回落到第一项', () => {
@@ -110,6 +103,17 @@ test('member 只访问已分配 Tab 且无权 hash 回落到第一项', () => {
);
});
test('member 可单独获得灰度发布 Tab 权限', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['gray-release'],
});
expect(routes.map((route) => route.id)).toEqual(['gray-release']);
expect(resolveAccessibleAdminRoute('#gray-release', routes)).toBe(
'gray-release',
);
});
test('零权限 member 不回落到 Dashboard', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
+21 -23
View File
@@ -1,4 +1,4 @@
/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */
/** 后台单页应用可导航的路由标识。 */
export type AdminRouteId =
| 'dashboard'
| 'overview'
@@ -15,9 +15,6 @@ export type AdminRouteId =
| 'editor-generation-pricing'
| 'editor-showcase'
| 'editor-assets'
| 'creation-announcement'
| 'creation-entry'
| 'work-visibility'
| 'accounts';
export type AdminTabPermission = Exclude<AdminRouteId, 'accounts'>;
@@ -31,25 +28,26 @@ export interface AdminRouteDefinition {
}
export const adminRoutes: AdminRouteDefinition[] = [
{id: 'dashboard', label: 'Dashboard', hash: '#dashboard'},
{id: 'overview', label: '服务总览', hash: '#overview'},
{id: 'tables', label: '表查询', hash: '#tables'},
{id: 'debug', label: 'API 调试', hash: '#debug'},
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
{id: 'gray-release', label: '灰度发布', hash: '#gray-release'},
{id: 'redeem', label: '兑换码', hash: '#redeem'},
{id: 'invite', label: '邀请码', hash: '#invite'},
{id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet'},
{id: 'tasks', label: '任务配置', hash: '#tasks'},
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
{id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders'},
{id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'},
{id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase'},
{id: 'editor-assets', label: '素材查询', hash: '#editor-assets'},
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
{id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true},
{ id: 'dashboard', label: 'Dashboard', hash: '#dashboard' },
{ id: 'overview', label: '服务总览', hash: '#overview' },
{ id: 'tables', label: '表查询', hash: '#tables' },
{ id: 'debug', label: 'API 调试', hash: '#debug' },
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
{ id: 'invite', label: '邀请码', hash: '#invite' },
{ id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet' },
{ id: 'tasks', label: '任务配置', hash: '#tasks' },
{ id: 'recharge-products', label: '充值商品', hash: '#recharge-products' },
{ id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders' },
{
id: 'editor-generation-pricing',
label: '模型定价',
hash: '#editor-generation-pricing',
},
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
];
export interface AdminRouteAccess {
@@ -0,0 +1,436 @@
import { X } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
import { getAdminAssetReadUrl, isAdminApiError } from '../api/adminApiClient';
const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40;
const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const;
const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px';
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
let adminAssetReadDispatchTail = Promise.resolve();
export interface AdminPreviewableEditorAsset {
assetId: string;
label: string;
imageSrc: string;
objectKey?: string | null;
assetKind?: string | null;
thumbnailSrc?: string | null;
}
export function AdminEditorAssetThumbnail({
entry,
token,
altPrefix = '素材',
}: {
entry: AdminPreviewableEditorAsset;
token: string;
altPrefix?: string;
}) {
const thumbnailSource = resolveAdminAssetThumbnailSource(entry);
const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility();
const imageSrc = useAdminResolvedAssetUrl(
token,
thumbnailSource.src,
thumbnailSource.objectKey,
shouldLoad,
);
const alt = `${altPrefix}:${entry.label || entry.assetId}`;
return imageSrc ? (
<img
ref={observeElement}
alt={alt}
className="admin-asset-query-thumb"
src={imageSrc}
/>
) : (
<div
ref={observeElement}
className="admin-asset-query-thumb admin-asset-query-thumb-placeholder"
/>
);
}
export function AdminEditorAssetPreviewDialog({
entry,
token,
onClose,
}: {
entry: AdminPreviewableEditorAsset;
token: string;
onClose: () => void;
}) {
return (
<div className="admin-confirm-backdrop" role="presentation">
<section
aria-label="素材预览"
className="admin-detail-panel admin-asset-query-preview-dialog"
role="dialog"
>
<div className="admin-panel-heading">
<div>
<h3>{entry.label || entry.assetId}</h3>
<span>{entry.assetId}</span>
</div>
<button
aria-label="关闭素材预览"
className="admin-ghost-button"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</button>
</div>
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
</section>
</div>
);
}
function AdminEditorAssetPreviewMedia({
entry,
token,
}: {
entry: AdminPreviewableEditorAsset;
token: string;
}) {
const mediaKind = resolveAdminAssetMediaKind(entry);
const isAudio = mediaKind === 'audio';
const isVideo = mediaKind === 'video';
const mediaSrc = useAdminResolvedAssetUrl(
token,
entry.imageSrc,
entry.objectKey,
);
const posterSrc = useAdminResolvedAssetUrl(
token,
isVideo ? (entry.thumbnailSrc ?? '') : '',
null,
);
const label = entry.label || entry.assetId;
if (isAudio) {
return (
<div className="admin-asset-query-preview-audio">
<img
alt={`音频封面:${label}`}
className="admin-asset-query-preview-cover"
src={AUDIO_ASSET_COVER_SRC}
/>
{mediaSrc ? (
<audio
aria-label={`音频预览:${label}`}
className="admin-asset-query-preview-player"
controls
preload="metadata"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
)}
</div>
);
}
if (isVideo) {
return mediaSrc ? (
<video
aria-label={`视频预览:${label}`}
className="admin-asset-query-preview-media"
controls
playsInline
poster={posterSrc || undefined}
preload="metadata"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
);
}
return mediaSrc ? (
<img
alt={`图片预览:${label}`}
className="admin-asset-query-preview-media"
src={mediaSrc}
/>
) : (
<div className="admin-asset-query-preview-placeholder" />
);
}
function resolveAdminAssetThumbnailSource(entry: AdminPreviewableEditorAsset) {
const mediaKind = resolveAdminAssetMediaKind(entry);
if (mediaKind === 'audio') {
return { src: AUDIO_ASSET_COVER_SRC, objectKey: null };
}
if (mediaKind === 'video') {
return { src: entry.thumbnailSrc || '', objectKey: null };
}
if (entry.thumbnailSrc?.trim()) {
return {
src: entry.thumbnailSrc,
objectKey: adminAssetPathsMatch(entry.thumbnailSrc, entry.imageSrc)
? entry.objectKey
: null,
};
}
return {
src: entry.imageSrc,
objectKey: entry.objectKey,
};
}
function useAdminAssetThumbnailVisibility() {
const [element, setElement] = useState<HTMLElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(false);
const observeElement = useCallback((nextElement: HTMLElement | null) => {
setElement(nextElement);
}, []);
useEffect(() => {
if (shouldLoad || !element) {
return;
}
if (typeof IntersectionObserver === 'undefined') {
setShouldLoad(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShouldLoad(true);
observer.disconnect();
}
},
{ rootMargin: ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN },
);
observer.observe(element);
return () => observer.disconnect();
}, [element, shouldLoad]);
return { observeElement, shouldLoad };
}
type AdminAssetMediaKind = 'image' | 'audio' | 'video';
function resolveAdminAssetMediaKind(
entry: AdminPreviewableEditorAsset,
): AdminAssetMediaKind {
const pathMediaKind =
resolveAdminAssetMediaKindFromPath(entry.imageSrc) ??
resolveAdminAssetMediaKindFromPath(entry.objectKey ?? '');
if (pathMediaKind) {
return pathMediaKind;
}
const assetKind = entry.assetKind?.trim() ?? '';
if (
assetKind === 'sound-effect' ||
assetKind === 'background-music' ||
assetKind === 'editor_uploaded_audio'
) {
return 'audio';
}
if (
assetKind === 'video' ||
assetKind === 'editor_video' ||
assetKind === 'editor-video' ||
assetKind === 'editor_uploaded_video'
) {
return 'video';
}
return 'image';
}
function resolveAdminAssetMediaKindFromPath(
value: string,
): AdminAssetMediaKind | null {
const normalizedValue = value.trim();
if (/^data:image\//iu.test(normalizedValue)) {
return 'image';
}
if (/^data:audio\//iu.test(normalizedValue)) {
return 'audio';
}
if (/^data:video\//iu.test(normalizedValue)) {
return 'video';
}
if (
/\.(?:avif|bmp|gif|jpe?g|png|svg|webp)(?:$|[?#])/iu.test(normalizedValue)
) {
return 'image';
}
if (/\.(?:aac|flac|m4a|mp3|ogg|opus|wav)(?:$|[?#])/iu.test(normalizedValue)) {
return 'audio';
}
if (/\.(?:m4v|mov|mp4|ogv|webm)(?:$|[?#])/iu.test(normalizedValue)) {
return 'video';
}
return null;
}
function useAdminResolvedAssetUrl(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
enabled = true,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
const normalizedLegacyPublicPath = isGeneratedLegacyPath(normalizedImageSrc)
? normalizedImageSrc
: resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc);
const shouldResolve =
Boolean(normalizedObjectKey) || Boolean(normalizedLegacyPublicPath);
const [resolvedImageSrc, setResolvedImageSrc] = useState(
shouldResolve ? '' : normalizedImageSrc,
);
useEffect(() => {
if (!normalizedImageSrc && !normalizedObjectKey) {
setResolvedImageSrc('');
return;
}
if (!shouldResolve) {
setResolvedImageSrc(normalizedImageSrc);
return;
}
if (!enabled) {
setResolvedImageSrc('');
return;
}
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryIndex = 0;
const dispatchController = new AbortController();
setResolvedImageSrc('');
const resolveReadUrl = async () => {
try {
await waitForAdminAssetReadDispatch(dispatchController.signal);
if (cancelled) {
return;
}
const response = await getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedLegacyPublicPath,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
},
);
if (!cancelled) {
setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response));
}
} catch (error: unknown) {
if (cancelled) {
return;
}
const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex];
if (
isAdminApiError(error) &&
error.status === 429 &&
typeof retryDelay === 'number'
) {
retryIndex += 1;
retryTimer = setTimeout(() => void resolveReadUrl(), retryDelay);
return;
}
setResolvedImageSrc('');
}
};
void resolveReadUrl();
return () => {
cancelled = true;
dispatchController.abort();
if (retryTimer !== null) {
clearTimeout(retryTimer);
}
};
}, [
enabled,
normalizedImageSrc,
normalizedLegacyPublicPath,
normalizedObjectKey,
shouldResolve,
token,
]);
return resolvedImageSrc;
}
async function waitForAdminAssetReadDispatch(signal: AbortSignal) {
const dispatch = adminAssetReadDispatchTail.then(
() => waitForAdminAssetReadDispatchSpacing(signal),
() => waitForAdminAssetReadDispatchSpacing(signal),
);
adminAssetReadDispatchTail = dispatch.catch(() => undefined);
await dispatch;
}
async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) {
if (signal.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', handleAbort);
resolve();
}, ADMIN_ASSET_READ_DISPATCH_SPACING_MS);
function handleAbort() {
clearTimeout(timer);
reject(new DOMException('The operation was aborted.', 'AbortError'));
}
signal.addEventListener('abort', handleAbort, { once: true });
});
}
function normalizeAdminObjectKey(value: string | null | undefined) {
return value?.trim().replace(/^\/+/u, '') ?? '';
}
function adminAssetPathsMatch(left: string, right: string) {
return (
left.trim().replace(/^\/+|[?#].*$/gu, '') ===
right.trim().replace(/^\/+|[?#].*$/gu, '')
);
}
function isGeneratedLegacyPath(value: string) {
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
}
function resolveAdminGeneratedLegacyPathFromUrl(value: string) {
try {
const parsedUrl = new URL(value);
if (
parsedUrl.protocol !== 'https:' ||
!/^[^.]+\.oss-[^.]+\.aliyuncs\.com$/iu.test(parsedUrl.hostname)
) {
return '';
}
const legacyPublicPath = decodeURIComponent(parsedUrl.pathname);
return isGeneratedLegacyPath(legacyPublicPath) ? legacyPublicPath : '';
} catch {
return '';
}
}
function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) {
const read = response.read ?? response;
return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
}
@@ -21,8 +21,8 @@ describe('admin tracking event definitions', () => {
expect(keys).toContain('auth_login_options_view');
expect(keys).toContain('task_center_view');
expect(keys).toContain('asset_upload_ticket_create');
expect(keys).toContain('creative_agent_route_success');
expect(keys).toContain('work_play_start');
expect(keys).not.toContain('creative_agent_route_success');
expect(keys).not.toContain('work_play_start');
});
test('任务配置候选只开放适合个人任务的事件', () => {
@@ -49,13 +49,15 @@ describe('admin tracking event definitions', () => {
]);
expect(options.find(({ key }) => key === 'work_play_start')?.title).toBe(
'作品开始游玩',
'work_play_start',
);
expect(options.find(({ key }) => key === 'unknown_event')?.title).toBe(
'未知事件',
);
expect(
filterAdminTrackingEventKeyOptions(options, '作品').map(({ key }) => key),
filterAdminTrackingEventKeyOptions(options, 'work_play').map(
({ key }) => key,
),
).toEqual(['work_play_start']);
});
@@ -65,7 +67,7 @@ describe('admin tracking event definitions', () => {
).toEqual(['asset_upload_ticket_create']);
expect(
filterAdminTrackingEventDefinitions('work_play').map(({ key }) => key),
).toEqual(['work_play_start']);
).toEqual([]);
expect(findAdminTrackingEventDefinition(' daily_login ')?.title).toBe(
'每日登录',
);
@@ -148,42 +148,6 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
scopeKind: 'user',
remark: '领取个人任务奖励成功后记录。',
},
{
key: 'save_archive_list_view',
title: '存档列表查看',
scopeKind: 'user',
remark: '读取个人存档列表成功后记录。',
},
{
key: 'save_archive_detail_view',
title: '存档详情查看',
scopeKind: 'user',
remark: '读取个人存档详情成功后记录。',
},
{
key: 'browse_history_view',
title: '浏览历史查看',
scopeKind: 'user',
remark: '读取浏览历史成功后记录。',
},
{
key: 'browse_history_record',
title: '浏览历史写入',
scopeKind: 'user',
remark: '记录浏览历史成功后记录。',
},
{
key: 'browse_history_clear',
title: '浏览历史清空',
scopeKind: 'user',
remark: '清空浏览历史成功后记录。',
},
{
key: 'play_stats_view',
title: '游玩统计查看',
scopeKind: 'user',
remark: '读取个人游玩统计成功后记录。',
},
{
key: 'profile_analytics_metric_view',
title: '个人指标查看',
@@ -352,62 +316,6 @@ export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
scopeKind: 'user',
remark: '更新运行设置成功后记录。',
},
{
key: 'runtime_snapshot_view',
title: '运行快照查看',
scopeKind: 'user',
remark: '读取运行快照成功后记录。',
},
{
key: 'runtime_snapshot_save',
title: '运行快照保存',
scopeKind: 'user',
remark: '保存运行快照成功后记录。',
},
{
key: 'runtime_snapshot_delete',
title: '运行快照删除',
scopeKind: 'user',
remark: '删除运行快照成功后记录。',
},
{
key: 'puzzle_route_success',
title: '拼图路由成功',
scopeKind: 'user',
remark: '拼图运行或创作接口成功响应后兜底记录;GET 入口可能按 site 统计。',
},
{
key: 'match3d_route_success',
title: '抓大鹅路由成功',
scopeKind: 'user',
remark:
'抓大鹅创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
},
{
key: 'square_hole_route_success',
title: '方洞路由成功',
scopeKind: 'user',
remark: '方洞创作或运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
},
{
key: 'custom_world_route_success',
title: '自定义世界路由成功',
scopeKind: 'user',
remark: '自定义世界运行接口成功响应后兜底记录;GET 入口可能按 site 统计。',
},
{
key: 'creative_agent_route_success',
title: '创意 Agent 路由成功',
scopeKind: 'user',
remark: '创意 Agent 接口成功响应后兜底记录;GET 入口可能按 site 统计。',
},
{
key: 'work_play_start',
title: '作品开始游玩',
scopeKind: 'work',
remark:
'拼图、抓大鹅、方洞、自定义世界、大鱼吃小鱼、Visual Novel 正式开始游玩时记录。',
},
];
export const adminProfileTaskTrackingEventDefinitions =
File diff suppressed because it is too large Load Diff
@@ -1,13 +1,14 @@
/* @vitest-environment jsdom */
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
getAdminAssetReadUrl,
@@ -23,6 +24,13 @@ import { AdminEditorShowcaseReviewPage } from './AdminEditorShowcaseReviewPage';
vi.mock('../api/adminApiClient', () => ({
getAdminAssetReadUrl: vi.fn(),
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
getAdminEditorShowcaseCampaign: vi.fn(),
listAdminEditorShowcaseAssets: vi.fn(),
reviewAdminEditorShowcaseAsset: vi.fn(),
@@ -31,8 +39,87 @@ vi.mock('../api/adminApiClient', () => ({
upsertAdminEditorShowcaseCampaign: vi.fn(),
}));
interface MockIntersectionObserverController {
enter: (target: Element) => void;
isObserved: (target: Element) => boolean;
}
function installIntersectionObserverMock(): MockIntersectionObserverController {
const observed = new Map<
Element,
{
callback: IntersectionObserverCallback;
observer: IntersectionObserver;
}
>();
class MockIntersectionObserver implements IntersectionObserver {
readonly root = null;
readonly rootMargin: string;
readonly thresholds = [0];
private readonly targets = new Set<Element>();
constructor(
private readonly callback: IntersectionObserverCallback,
options: IntersectionObserverInit = {},
) {
this.rootMargin = options.rootMargin ?? '0px';
}
observe(target: Element) {
this.targets.add(target);
observed.set(target, {
callback: this.callback,
observer: this as unknown as IntersectionObserver,
});
}
unobserve(target: Element) {
this.targets.delete(target);
observed.delete(target);
}
disconnect() {
this.targets.forEach((target) => observed.delete(target));
this.targets.clear();
}
takeRecords() {
return [];
}
}
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver);
return {
enter(target) {
const record = observed.get(target);
if (!record) {
throw new Error('目标精选缩略图尚未进入 IntersectionObserver');
}
act(() => {
record.callback(
[
{
isIntersecting: true,
target,
} as IntersectionObserverEntry,
],
record.observer,
);
});
},
isObserved(target) {
return observed.has(target);
},
};
}
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({ userId, publicUserCode }: {
AdminUserReferenceButton: ({
userId,
publicUserCode,
}: {
userId?: string;
publicUserCode?: string | null;
}) => (
@@ -160,6 +247,10 @@ beforeEach(() => {
});
});
afterEach(() => {
vi.unstubAllGlobals();
});
test('后台精选审核展示待审核素材和活动卡配置', async () => {
render(
<AdminEditorShowcaseReviewPage
@@ -184,10 +275,114 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
submittedBefore: null,
limit: 80,
});
await waitFor(() => {
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor/spec.png',
expireSeconds: 300,
});
});
});
test('后台精选审核缩略图进入视口后换签并可打开图片预览', async () => {
const observer = installIntersectionObserverMock();
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
expect(await screen.findByText('角色形象 1')).toBeTruthy();
const previewButton = screen.getByTitle('预览素材');
const thumbnail = previewButton.querySelector('.admin-asset-query-thumb');
expect(thumbnail).not.toBeNull();
await waitFor(() => expect(observer.isObserved(thumbnail!)).toBe(true));
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
observer.enter(thumbnail!);
const image = await screen.findByRole('img', {
name: '精选素材:角色形象 1',
});
expect(image.getAttribute('src')).toBe('https://signed.example.com/spec.png');
fireEvent.click(previewButton);
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
const previewImage = await within(dialog).findByRole('img', {
name: '图片预览:角色形象 1',
});
await waitFor(() => {
expect(previewImage.getAttribute('src')).toBe(
'https://signed.example.com/spec.png',
);
});
expect(screen.queryByRole('dialog', { name: '精选素材详情' })).toBeNull();
});
test('后台精选审核将无 objectKey 的绝对 OSS 图片地址换签后预览', async () => {
vi.mocked(listAdminEditorShowcaseAssets).mockResolvedValueOnce({
entries: [
{
...pendingShowcaseAsset,
imageSrc:
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-character-drafts/editor/absolute.png?x-oss-process=image/resize,w_320',
objectKey: null,
},
],
nextCursor: null,
});
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
read: {
objectKey: 'generated-character-drafts/editor/absolute.png',
signedUrl: 'https://signed.example.com/absolute.png',
expiresAt: '2026-07-04T11:00:00Z',
},
});
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
const image = await screen.findByRole('img', {
name: '精选素材:角色形象 1',
});
expect(image.getAttribute('src')).toBe(
'https://signed.example.com/absolute.png',
);
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor/spec.png',
legacyPublicPath: '/generated-character-drafts/editor/absolute.png',
expireSeconds: 300,
});
fireEvent.click(screen.getByTitle('预览素材'));
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
expect(
await within(dialog).findByRole('img', {
name: '图片预览:角色形象 1',
}),
).toHaveProperty('src', 'https://signed.example.com/absolute.png');
});
test('后台精选审核详情中的缩略图也可打开素材预览', async () => {
render(
<AdminEditorShowcaseReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
fireEvent.click(await screen.findByRole('button', { name: '详情' }));
const detail = await screen.findByRole('dialog', { name: '精选素材详情' });
fireEvent.click(within(detail).getByTitle('预览素材'));
const preview = await screen.findByRole('dialog', { name: '素材预览' });
expect(
await within(preview).findByRole('img', {
name: '图片预览:角色形象 1',
}),
).toBeTruthy();
});
test('后台精选审核格式化微秒时间并显示素材名', async () => {
@@ -2,9 +2,7 @@ import { Eye, FileText, RefreshCcw, Upload, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useEffect, useRef, useState } from 'react';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
import {
getAdminAssetReadUrl,
getAdminEditorShowcaseCampaign,
listAdminEditorShowcaseAssets,
reviewAdminEditorShowcaseAsset,
@@ -17,6 +15,10 @@ import type {
AdminEditorShowcaseCampaignPayload,
AdminEditorShowcaseListQuery,
} from '../api/adminApiTypes';
import {
AdminEditorAssetPreviewDialog,
AdminEditorAssetThumbnail,
} from '../components/AdminEditorAssetMedia';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
@@ -25,9 +27,6 @@ interface AdminEditorShowcaseReviewPageProps {
onUnauthorized: (message?: string) => void;
}
const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300;
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
const showcaseCategoryOptions = [
{ value: 'characters', label: '角色' },
{ value: 'ui', label: 'UI' },
@@ -58,6 +57,8 @@ export function AdminEditorShowcaseReviewPage({
const [reviewNotes, setReviewNotes] = useState<Record<string, string>>({});
const [detailEntry, setDetailEntry] =
useState<AdminEditorShowcaseAssetPayload | null>(null);
const [previewEntry, setPreviewEntry] =
useState<AdminEditorShowcaseAssetPayload | null>(null);
const [promptPreview, setPromptPreview] = useState<{
title: string;
prompt: string;
@@ -342,11 +343,15 @@ export function AdminEditorShowcaseReviewPage({
<td>
<button
className="admin-asset-query-thumb-button"
title="查看详情"
title="预览素材"
type="button"
onClick={() => setDetailEntry(entry)}
onClick={() => setPreviewEntry(entry)}
>
<AdminShowcaseThumbnail entry={entry} token={token} />
<AdminEditorAssetThumbnail
entry={entry}
token={token}
altPrefix="精选素材"
/>
</button>
<small>{entry.label || '-'}</small>
</td>
@@ -355,7 +360,9 @@ export function AdminEditorShowcaseReviewPage({
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
@@ -611,6 +618,7 @@ export function AdminEditorShowcaseReviewPage({
token={token}
onUnauthorized={onUnauthorized}
onClose={() => setDetailEntry(null)}
onPreview={(entry) => setPreviewEntry(entry)}
onPromptPreview={(entry, prompt) =>
setPromptPreview({
title: entry.label || entry.showcaseId,
@@ -620,6 +628,14 @@ export function AdminEditorShowcaseReviewPage({
/>
) : null}
{previewEntry ? (
<AdminEditorAssetPreviewDialog
entry={previewEntry}
token={token}
onClose={() => setPreviewEntry(null)}
/>
) : null}
{promptPreview ? (
<div className="admin-confirm-backdrop" role="presentation">
<section
@@ -652,48 +668,18 @@ export function AdminEditorShowcaseReviewPage({
);
}
function AdminShowcaseThumbnail({
entry,
token,
}: {
entry: AdminEditorShowcaseAssetPayload;
token: string;
}) {
const isAudio = isAdminShowcaseAudioAsset(entry);
const imageSrc = useAdminResolvedAssetImageSrc(
token,
isAudio ? AUDIO_ASSET_COVER_SRC : entry.imageSrc,
isAudio ? null : entry.objectKey,
);
const alt = `精选素材:${entry.label || entry.showcaseId}`;
return imageSrc ? (
<img alt={alt} className="admin-asset-query-thumb" src={imageSrc} />
) : (
<div className="admin-asset-query-thumb admin-asset-query-thumb-placeholder" />
);
}
function isAdminShowcaseAudioAsset(entry: AdminEditorShowcaseAssetPayload) {
const assetKind = entry.assetKind?.trim() ?? '';
return (
assetKind === 'sound-effect' ||
assetKind === 'background-music' ||
assetKind === 'editor_uploaded_audio' ||
/\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim())
);
}
function AdminShowcaseDetailDialog({
entry,
token,
onClose,
onPreview,
onPromptPreview,
onUnauthorized,
}: {
entry: AdminEditorShowcaseAssetPayload;
token: string;
onClose: () => void;
onPreview: (entry: AdminEditorShowcaseAssetPayload) => void;
onUnauthorized: (message?: string) => void;
onPromptPreview: (
entry: AdminEditorShowcaseAssetPayload,
@@ -723,7 +709,18 @@ function AdminShowcaseDetailDialog({
</button>
</div>
<div className="admin-asset-query-detail-layout">
<AdminShowcaseThumbnail entry={entry} token={token} />
<button
className="admin-asset-query-thumb-button admin-asset-query-detail-thumb-button"
title="预览素材"
type="button"
onClick={() => onPreview(entry)}
>
<AdminEditorAssetThumbnail
entry={entry}
token={token}
altPrefix="精选素材"
/>
</button>
<dl className="admin-info-list admin-detail-list">
<AdminInfoItem label="作者">
<div className="admin-inline-identity">
@@ -815,77 +812,6 @@ function AdminInfoItem({
);
}
function useAdminResolvedAssetImageSrc(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
const shouldResolve =
Boolean(normalizedObjectKey) || isGeneratedLegacyPath(normalizedImageSrc);
const [resolvedImageSrc, setResolvedImageSrc] = useState(
shouldResolve ? '' : normalizedImageSrc,
);
useEffect(() => {
if (!normalizedImageSrc && !normalizedObjectKey) {
setResolvedImageSrc('');
return;
}
if (!shouldResolve) {
setResolvedImageSrc(normalizedImageSrc);
return;
}
let cancelled = false;
setResolvedImageSrc('');
void getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_SHOWCASE_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedImageSrc,
expireSeconds: ADMIN_SHOWCASE_READ_EXPIRE_SECONDS,
},
)
.then(resolveAdminAssetReadSignedUrl)
.then((signedUrl) => {
if (!cancelled) {
setResolvedImageSrc(signedUrl);
}
})
.catch(() => {
if (!cancelled) {
setResolvedImageSrc('');
}
});
return () => {
cancelled = true;
};
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve, token]);
return resolvedImageSrc;
}
function normalizeAdminObjectKey(value: string | null | undefined) {
return value?.trim().replace(/^\/+/u, '') ?? '';
}
function isGeneratedLegacyPath(value: string) {
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
}
function resolveAdminAssetReadSignedUrl(response: AdminAssetReadUrlResponse) {
const read = response.read ?? response;
return typeof read.signedUrl === 'string' ? read.signedUrl.trim() : '';
}
function mergeShowcaseEntries(
current: AdminEditorShowcaseAssetPayload[],
incoming: AdminEditorShowcaseAssetPayload[],
@@ -5,21 +5,16 @@ import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import {
getAdminCreationEntryConfig,
getAdminFeatureGateConfig,
upsertAdminFeatureGateConfig,
} from '../api/adminApiClient';
import type {
AdminCreationEntryConfigResponse,
AdminFeatureGateConfigResponse,
} from '../api/adminApiTypes';
import type { AdminFeatureGateConfigResponse } from '../api/adminApiTypes';
import { AdminGrayReleaseConfigPage } from './AdminGrayReleaseConfigPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
getAdminCreationEntryConfig: vi.fn(),
getAdminFeatureGateConfig: vi.fn(),
isAdminApiError: vi.fn(() => false),
upsertAdminFeatureGateConfig: vi.fn(),
@@ -50,48 +45,8 @@ const configResponse: AdminFeatureGateConfigResponse = {
],
};
const creationEntryResponse: AdminCreationEntryConfigResponse = {
entries: [
{
id: 'puzzle',
title: '拼图',
subtitle: '',
badge: '',
imageSrc: '',
visible: true,
open: true,
sortOrder: 10,
categoryId: 'default',
categoryLabel: '默认',
categorySortOrder: 0,
updatedAtMicros: 0,
unifiedCreationSpec: null,
},
{
id: 'match3d',
title: '3D 消除',
subtitle: '',
badge: '',
imageSrc: '',
visible: true,
open: true,
sortOrder: 20,
categoryId: 'default',
categoryLabel: '默认',
categorySortOrder: 0,
updatedAtMicros: 0,
unifiedCreationSpec: null,
},
],
eventBanners: [],
publicWorkInteractions: [],
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAdminCreationEntryConfig).mockResolvedValue(
creationEntryResponse,
);
vi.mocked(getAdminFeatureGateConfig).mockResolvedValue(configResponse);
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValue(configResponse);
});
@@ -109,7 +64,6 @@ test('灰度发布页加载并展示 gate 列表', async () => {
).toBeTruthy();
expect(screen.getByText('25%')).toBeTruthy();
expect(getAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token');
expect(getAdminCreationEntryConfig).toHaveBeenCalledWith('admin-token');
});
test('灰度发布页可选择已有 gate 编辑', async () => {
@@ -152,12 +106,11 @@ test('灰度发布页选择新 target 时重置旧 gate 规则', async () => {
await screen.findByRole('button', { name: 'editor.new-toolbar' }),
);
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
'creation-entry',
'image-editor',
]);
await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['match3d']);
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
'creation-entry:match3d',
'image-editor:agent-sidebar',
);
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
false,
@@ -175,27 +128,7 @@ test('灰度发布页选择新 target 时重置旧 gate 规则', async () => {
(screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value,
).toBe('');
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
'3D 消除创作入口灰度',
);
});
test('灰度发布页可通过创作入口生成 Gate Key', async () => {
const user = userEvent.setup();
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByRole('button', { name: 'editor.new-toolbar' });
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
'creation-entry',
]);
await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['puzzle']);
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
'creation-entry:puzzle',
);
expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe(
'拼图创作入口灰度',
'画布 Agent 入口灰度',
);
});
@@ -281,7 +214,6 @@ test('灰度发布页保存时转换数组和百分比', async () => {
test('灰度发布页无 token 时不请求配置', () => {
render(<AdminGrayReleaseConfigPage token="" onUnauthorized={vi.fn()} />);
expect(getAdminCreationEntryConfig).not.toHaveBeenCalled();
expect(getAdminFeatureGateConfig).not.toHaveBeenCalled();
expect(upsertAdminFeatureGateConfig).not.toHaveBeenCalled();
});
@@ -1,13 +1,11 @@
import { Plus, RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import { type FormEvent, useEffect, useState } from 'react';
import {
getAdminCreationEntryConfig,
getAdminFeatureGateConfig,
upsertAdminFeatureGateConfig,
} from '../api/adminApiClient';
import type {
AdminCreationEntryTypeConfigPayload,
AdminFeatureGateConfigPayload,
AdminUpsertFeatureGateConfigRequest,
} from '../api/adminApiTypes';
@@ -28,7 +26,6 @@ interface GateTargetOption {
}
const GATE_PREFIX_LABELS: Record<string, string> = {
'creation-entry': '创作入口',
'image-editor': '画布',
};
@@ -47,9 +44,6 @@ export function AdminGrayReleaseConfigPage({
onUnauthorized,
}: AdminGrayReleaseConfigPageProps) {
const [gates, setGates] = useState<AdminFeatureGateConfigPayload[]>([]);
const [creationEntries, setCreationEntries] = useState<
AdminCreationEntryTypeConfigPayload[]
>([]);
const [selectedGateKey, setSelectedGateKey] = useState('');
const [gatePrefix, setGatePrefix] = useState('');
const [gateKey, setGateKey] = useState('');
@@ -74,7 +68,6 @@ export function AdminGrayReleaseConfigPage({
const requestToken = token.trim();
if (!requestToken) {
setGates([]);
setCreationEntries([]);
setListErrorMessage('');
setIsLoading(false);
return;
@@ -83,12 +76,8 @@ export function AdminGrayReleaseConfigPage({
setIsLoading(true);
setListErrorMessage('');
try {
const [featureGateResponse, creationEntryResponse] = await Promise.all([
getAdminFeatureGateConfig(requestToken),
getAdminCreationEntryConfig(requestToken),
]);
const featureGateResponse = await getAdminFeatureGateConfig(requestToken);
setGates(featureGateResponse.gates);
setCreationEntries(creationEntryResponse.entries);
const selectedGate = featureGateResponse.gates.find(
(gate) => gate.gateKey === selectedGateKey,
);
@@ -240,7 +229,7 @@ export function AdminGrayReleaseConfigPage({
const canSave =
gateKey.trim().length > 0 && isRolloutPercentInputValid(rolloutPercent);
const gateTargetOptions = buildGateTargetOptions(creationEntries);
const gateTargetOptions = FIXED_GATE_TARGETS;
const gatePrefixOptions = buildGatePrefixOptions(gateTargetOptions);
const gateTargetsForPrefix = gateTargetOptions.filter(
(option) => option.prefix === gatePrefix,
@@ -466,27 +455,6 @@ function isRolloutPercentInputValid(value: string) {
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100;
}
function creationEntryGateKey(entryId: string) {
return `creation-entry:${entryId.trim()}`;
}
function buildGateTargetOptions(
creationEntries: AdminCreationEntryTypeConfigPayload[],
): GateTargetOption[] {
return [
...creationEntries.map((entry) => ({
prefix: 'creation-entry',
suffix: entry.id,
key: creationEntryGateKey(entry.id),
label: entry.title.trim() || entry.id,
description: entry.title.trim()
? `${entry.title.trim()}创作入口灰度`
: '创作入口灰度',
})),
...FIXED_GATE_TARGETS,
];
}
function buildGatePrefixOptions(gateTargetOptions: GateTargetOption[]) {
const seen = new Set<string>();
return gateTargetOptions.flatMap((option) => {
+6 -1
View File
@@ -15,5 +15,10 @@
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
"include": ["src", "vite.config.ts"],
"exclude": [
"src/pages/AdminCreationEntrySwitchPage.tsx",
"src/pages/AdminCreationEntrySwitchPage.test.tsx",
"src/pages/AdminWorkVisibilityPage.tsx"
]
}
File diff suppressed because it is too large Load Diff