diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 3ecc25e7c..e3087cfe3 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -22,6 +22,7 @@ import type { AdminEditorShowcaseListQuery, AdminEditorShowcaseListResponse, AdminEditorShowcaseReviewRequest, + AdminFeatureGateConfigResponse, AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, @@ -32,6 +33,7 @@ import type { AdminUpdateWorkVisibilityResponse, AdminUploadedEditorShowcaseCampaignImage, AdminUpsertEditorShowcaseCampaignRequest, + AdminUpsertFeatureGateConfigRequest, AdminUpsertProfileInviteCodeRequest, AdminUpsertProfileRechargeProductRequest, AdminUpsertProfileRedeemCodeRequest, @@ -230,6 +232,23 @@ export function listAdminTrackingEventKeys(token: string) { ); } +export function getAdminFeatureGateConfig(token: string) { + return request('/admin/api/feature-gates', { + token, + }); +} + +export function upsertAdminFeatureGateConfig( + token: string, + payload: AdminUpsertFeatureGateConfigRequest, +) { + return request('/admin/api/feature-gates', { + method: 'PUT', + token, + body: payload, + }); +} + export function getAdminCreationEntryConfig(token: string) { return request( '/admin/api/creation-entry/config', diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 48f1a7656..c2e594bf8 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -212,6 +212,26 @@ export interface AdminTrackingEventListQuery { exportAll?: boolean; } +export interface AdminFeatureGateConfigPayload { + gateKey: string; + enabled: boolean; + rolloutPercent: number; + allowUserIds: string[]; + allowUserTags: string[]; + denyUserIds: string[]; + description: string; + updatedAt: string; +} + +export interface AdminFeatureGateConfigResponse { + gates: AdminFeatureGateConfigPayload[]; +} + +export type AdminUpsertFeatureGateConfigRequest = Omit< + AdminFeatureGateConfigPayload, + 'updatedAt' +>; + /** 后台创作入口配置响应,同时包含模板入口和独立公告配置。 */ export interface AdminCreationEntryConfigResponse { entries: AdminCreationEntryTypeConfigPayload[]; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 01dd05e86..68c9cf799 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -26,6 +26,7 @@ import {AdminLoginPage} from '../pages/AdminLoginPage'; import {AdminEditorGenerationPricingPage} from '../pages/AdminEditorGenerationPricingPage'; import {AdminEditorAssetQueryPage} from '../pages/AdminEditorAssetQueryPage'; import {AdminEditorShowcaseReviewPage} from '../pages/AdminEditorShowcaseReviewPage'; +import {AdminGrayReleaseConfigPage} from '../pages/AdminGrayReleaseConfigPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage'; import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage'; import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage'; @@ -186,6 +187,12 @@ export function AdminApp() { onUnauthorized={handleUnauthorized} /> ) : null} + {routeId === 'gray-release' ? ( + + ) : null} {routeId === 'redeem' ? ( { ); }); +test('后台灰度发布路由可通过导航和 hash 访问', () => { + expect(adminRoutes).toContainEqual({ + id: 'gray-release', + label: '灰度发布', + hash: '#gray-release', + }); + expect(resolveAdminRoute('#gray-release')).toBe('gray-release'); + expect(routeHash('gray-release')).toBe('#gray-release'); +}); + test('后台素材查询路由可通过导航和 hash 访问', () => { expect(adminRoutes).toContainEqual({ id: 'editor-assets', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 3449f5fc1..569e5309d 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -5,6 +5,7 @@ export type AdminRouteId = | 'tables' | 'debug' | 'tracking' + | 'gray-release' | 'redeem' | 'invite' | 'profile-wallet' @@ -30,6 +31,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ {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'}, diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx new file mode 100644 index 000000000..e7f82b84e --- /dev/null +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx @@ -0,0 +1,287 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +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 { 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(), +})); + +const configResponse: AdminFeatureGateConfigResponse = { + gates: [ + { + gateKey: 'editor.new-toolbar', + enabled: true, + rolloutPercent: 25, + allowUserIds: ['user-1'], + allowUserTags: ['beta'], + denyUserIds: ['blocked-1'], + description: '新版编辑器工具条', + updatedAt: '2026-07-07T01:00:00Z', + }, + { + gateKey: 'image.generator.v2', + enabled: false, + rolloutPercent: 5, + allowUserIds: ['artist-1', 'artist-2'], + allowUserTags: ['internal', 'trial'], + denyUserIds: [], + description: '图片生成链路', + updatedAt: '2026-07-07T02:00:00Z', + }, + ], +}; + +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); +}); + +test('灰度发布页加载并展示 gate 列表', async () => { + render( + , + ); + + expect( + await screen.findByRole('button', { name: 'editor.new-toolbar' }), + ).toBeTruthy(); + expect( + screen.getByRole('button', { name: 'image.generator.v2' }), + ).toBeTruthy(); + expect(screen.getByText('25%')).toBeTruthy(); + expect(getAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token'); + expect(getAdminCreationEntryConfig).toHaveBeenCalledWith('admin-token'); +}); + +test('灰度发布页可选择已有 gate 编辑', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click( + await screen.findByRole('button', { name: 'image.generator.v2' }), + ); + + expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe( + 'image.generator.v2', + ); + expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe( + false, + ); + expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe( + '5', + ); + expect( + (screen.getByLabelText('允许用户 ID') as HTMLTextAreaElement).value, + ).toBe('artist-1\nartist-2'); + expect( + (screen.getByLabelText('允许用户标签') as HTMLTextAreaElement).value, + ).toBe('internal\ntrial'); + expect( + (screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value, + ).toBe(''); +}); + +test('灰度发布页选择新 target 时重置旧 gate 规则', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click( + await screen.findByRole('button', { name: 'editor.new-toolbar' }), + ); + await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [ + 'creation-entry', + ]); + await user.selectOptions(screen.getByLabelText('Gate Key 目标'), ['match3d']); + + expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe( + 'creation-entry:match3d', + ); + expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe( + false, + ); + expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe( + '0', + ); + expect( + (screen.getByLabelText('允许用户 ID') as HTMLTextAreaElement).value, + ).toBe(''); + expect( + (screen.getByLabelText('允许用户标签') as HTMLTextAreaElement).value, + ).toBe(''); + expect( + (screen.getByLabelText('拒绝用户 ID') as HTMLTextAreaElement).value, + ).toBe(''); + expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe( + '3D 消除创作入口灰度', + ); +}); + +test('灰度发布页可通过创作入口生成 Gate Key', async () => { + const user = userEvent.setup(); + render( + , + ); + + 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( + '拼图创作入口灰度', + ); +}); + +test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async () => { + const user = userEvent.setup(); + render( + , + ); + + await screen.findByRole('button', { name: 'editor.new-toolbar' }); + await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [ + 'image-editor', + ]); + + expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe( + 'image-editor:agent-sidebar', + ); + expect( + (screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value, + ).toBe('agent-sidebar'); + expect((screen.getByLabelText('描述') as HTMLTextAreaElement).value).toBe( + '画布 Agent 入口灰度', + ); +}); + +test('灰度发布页保存时转换数组和百分比', async () => { + const user = userEvent.setup(); + vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({ + gates: [ + ...configResponse.gates, + { + gateKey: 'homepage.feed-redesign', + enabled: true, + rolloutPercent: 42, + allowUserIds: ['user-a', 'user-b'], + allowUserTags: ['beta', 'staff'], + denyUserIds: ['blocked-a'], + description: '首页信息流', + updatedAt: '2026-07-07T03:00:00Z', + }, + ], + }); + render( + , + ); + + await screen.findByRole('button', { name: 'editor.new-toolbar' }); + fireEvent.change(screen.getByLabelText('Gate Key'), { + target: { value: 'homepage.feed-redesign' }, + }); + await user.click(screen.getByLabelText('启用')); + fireEvent.change(screen.getByLabelText('灰度比例'), { + target: { value: '42' }, + }); + fireEvent.change(screen.getByLabelText('允许用户 ID'), { + target: { value: ' user-a \n\n user-b ' }, + }); + fireEvent.change(screen.getByLabelText('允许用户标签'), { + target: { value: ' beta \n staff ' }, + }); + fireEvent.change(screen.getByLabelText('拒绝用户 ID'), { + target: { value: ' blocked-a \n ' }, + }); + fireEvent.change(screen.getByLabelText('描述'), { + target: { value: ' 首页信息流 ' }, + }); + await user.click(screen.getByRole('button', { name: '保存配置' })); + await user.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', { + gateKey: 'homepage.feed-redesign', + enabled: true, + rolloutPercent: 42, + allowUserIds: ['user-a', 'user-b'], + allowUserTags: ['beta', 'staff'], + denyUserIds: ['blocked-a'], + description: '首页信息流', + }); + }); +}); + +test('灰度发布页无 token 时不请求配置', () => { + render(); + + expect(getAdminCreationEntryConfig).not.toHaveBeenCalled(); + expect(getAdminFeatureGateConfig).not.toHaveBeenCalled(); + expect(upsertAdminFeatureGateConfig).not.toHaveBeenCalled(); +}); diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx new file mode 100644 index 000000000..38d0e1de6 --- /dev/null +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx @@ -0,0 +1,513 @@ +import { Plus, RefreshCcw, Save } from 'lucide-react'; +import { FormEvent, useEffect, useState } from 'react'; + +import { + getAdminCreationEntryConfig, + getAdminFeatureGateConfig, + upsertAdminFeatureGateConfig, +} from '../api/adminApiClient'; +import type { + AdminCreationEntryTypeConfigPayload, + AdminFeatureGateConfigPayload, + AdminUpsertFeatureGateConfigRequest, +} from '../api/adminApiTypes'; +import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm'; +import { handlePageError, splitLines } from './pageUtils'; + +interface AdminGrayReleaseConfigPageProps { + token: string; + onUnauthorized: (message?: string) => void; +} + +interface GateTargetOption { + prefix: string; + suffix: string; + key: string; + label: string; + description: string; +} + +const GATE_PREFIX_LABELS: Record = { + 'creation-entry': '创作入口', + 'image-editor': '画布', +}; + +const FIXED_GATE_TARGETS: GateTargetOption[] = [ + { + prefix: 'image-editor', + suffix: 'agent-sidebar', + key: 'image-editor:agent-sidebar', + label: 'Agent 侧边栏', + description: '画布 Agent 入口灰度', + }, +]; + +export function AdminGrayReleaseConfigPage({ + token, + onUnauthorized, +}: AdminGrayReleaseConfigPageProps) { + const [gates, setGates] = useState([]); + const [creationEntries, setCreationEntries] = useState< + AdminCreationEntryTypeConfigPayload[] + >([]); + const [selectedGateKey, setSelectedGateKey] = useState(''); + const [gatePrefix, setGatePrefix] = useState(''); + const [gateKey, setGateKey] = useState(''); + const [enabled, setEnabled] = useState(false); + const [rolloutPercent, setRolloutPercent] = useState('0'); + const [allowUserIds, setAllowUserIds] = useState(''); + const [allowUserTags, setAllowUserTags] = useState(''); + const [denyUserIds, setDenyUserIds] = useState(''); + const [description, setDescription] = useState(''); + const [listErrorMessage, setListErrorMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const { confirmWrite, confirmDialog } = useAdminWriteConfirm(); + + useEffect(() => { + void refreshFeatureGates(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token]); + + async function refreshFeatureGates() { + const requestToken = token.trim(); + if (!requestToken) { + setGates([]); + setCreationEntries([]); + setListErrorMessage(''); + setIsLoading(false); + return; + } + + setIsLoading(true); + setListErrorMessage(''); + try { + const [featureGateResponse, creationEntryResponse] = await Promise.all([ + getAdminFeatureGateConfig(requestToken), + getAdminCreationEntryConfig(requestToken), + ]); + setGates(featureGateResponse.gates); + setCreationEntries(creationEntryResponse.entries); + const selectedGate = featureGateResponse.gates.find( + (gate) => gate.gateKey === selectedGateKey, + ); + if (selectedGate) { + fillForm(selectedGate); + } + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setListErrorMessage); + } finally { + setIsLoading(false); + } + } + + async function handleSave(event: FormEvent) { + event.preventDefault(); + const requestToken = token.trim(); + if (isSaving || !requestToken) { + return; + } + + setErrorMessage(''); + const payload = buildPayload(); + const confirmed = await confirmWrite({ + action: '保存灰度配置', + target: payload.gateKey, + }); + if (!confirmed) { + return; + } + + setIsSaving(true); + try { + const response = await upsertAdminFeatureGateConfig( + requestToken, + payload, + ); + setGates(response.gates); + const savedGate = response.gates.find( + (gate) => gate.gateKey === payload.gateKey, + ); + if (savedGate) { + fillForm(savedGate); + } else { + setSelectedGateKey(payload.gateKey); + } + } catch (error: unknown) { + handlePageError(error, onUnauthorized, setErrorMessage); + } finally { + setIsSaving(false); + } + } + + function handleGatePrefixSelect( + nextPrefix: string, + gateTargetOptions: GateTargetOption[], + ) { + setGatePrefix(nextPrefix); + if (!nextPrefix) { + setSelectedGateKey(''); + setGateKey(''); + return; + } + + const nextOptions = gateTargetOptions.filter( + (option) => option.prefix === nextPrefix, + ); + const onlyOption = nextOptions[0]; + if (nextOptions.length === 1 && onlyOption) { + applyGateTarget(onlyOption); + return; + } + + setSelectedGateKey(''); + setGateKey(''); + } + + function handleGateTargetSelect( + suffix: string, + gateTargetOptions: GateTargetOption[], + ) { + if (!gatePrefix || !suffix) { + return; + } + + const option = gateTargetOptions.find( + (item) => item.prefix === gatePrefix && item.suffix === suffix, + ); + if (option) { + applyGateTarget(option); + } + } + + function applyGateTarget(option: GateTargetOption) { + const existingGate = gates.find((gate) => gate.gateKey === option.key); + if (existingGate) { + fillForm(existingGate); + return; + } + + setSelectedGateKey(''); + setGatePrefix(option.prefix); + setGateKey(option.key); + setEnabled(false); + setRolloutPercent('0'); + setAllowUserIds(''); + setAllowUserTags(''); + setDenyUserIds(''); + setDescription(option.description); + setErrorMessage(''); + } + + function buildPayload(): AdminUpsertFeatureGateConfigRequest { + return { + gateKey: gateKey.trim(), + enabled, + rolloutPercent: parseRolloutPercent(rolloutPercent), + allowUserIds: splitLines(allowUserIds), + allowUserTags: splitLines(allowUserTags), + denyUserIds: splitLines(denyUserIds), + description: description.trim(), + }; + } + + function fillForm(entry: AdminFeatureGateConfigPayload) { + setSelectedGateKey(entry.gateKey); + setGatePrefix(resolveGatePrefix(entry.gateKey)); + setGateKey(entry.gateKey); + setEnabled(entry.enabled); + setRolloutPercent(String(entry.rolloutPercent)); + setAllowUserIds(entry.allowUserIds.join('\n')); + setAllowUserTags(entry.allowUserTags.join('\n')); + setDenyUserIds(entry.denyUserIds.join('\n')); + setDescription(entry.description); + setErrorMessage(''); + } + + function resetForm() { + setSelectedGateKey(''); + setGatePrefix(''); + setGateKey(''); + setEnabled(false); + setRolloutPercent('0'); + setAllowUserIds(''); + setAllowUserTags(''); + setDenyUserIds(''); + setDescription(''); + setErrorMessage(''); + } + + const canSave = + gateKey.trim().length > 0 && isRolloutPercentInputValid(rolloutPercent); + const gateTargetOptions = buildGateTargetOptions(creationEntries); + const gatePrefixOptions = buildGatePrefixOptions(gateTargetOptions); + const gateTargetsForPrefix = gateTargetOptions.filter( + (option) => option.prefix === gatePrefix, + ); + const selectedGateTargetSuffix = + gateTargetsForPrefix.find((option) => option.key === gateKey)?.suffix ?? ''; + + return ( +
+
+
+

灰度发布

+

开关配置

+
+
+ + +
+
+ + {listErrorMessage ? ( +
+ {listErrorMessage} +
+ ) : null} + +
+
+
+ Gate Key 选择 +
+ + +
+
+ +
+ + +
+ + + +
+