合并 master 最新更新
合入 master 的 BgFilter、CI、运维与现役平台改造。 保留 AI 游戏创作 Runtime、独立锁文件与原生壳验证链路。 修复共享充值账单组件、LLM 网关与退役 Agent 兼容边界。 同步冲突文档、锁文件和开发脚本。
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
+38
-474
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user