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/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx index 645127aa3..3c2c04cbd 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -1,14 +1,14 @@ /* @vitest-environment jsdom */ -import {render, screen, waitFor} from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import {beforeEach, expect, test, vi} from 'vitest'; +import { beforeEach, expect, test, vi } from 'vitest'; import { getAdminDatabaseTableRows, getAdminDatabaseTables, } from '../api/adminApiClient'; -import {AdminDatabaseTablesPage} from './AdminDatabaseTablesPage'; +import { AdminDatabaseTablesPage } from './AdminDatabaseTablesPage'; vi.mock('../api/adminApiClient', () => ({ formatAdminApiError: vi.fn((error: unknown) => @@ -36,12 +36,7 @@ beforeEach(() => { invite_code: 'INV-1001', inviter_user_id: 'u-a', }, - raw: [ - 'u-b', - 'u-a', - 'INV-1001', - '2026-05-02T00:00:00Z', - ], + raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'], }, { cells: { @@ -69,32 +64,37 @@ beforeEach(() => { test('后台表查询页支持宽表滚动容器和表头排序', async () => { const user = userEvent.setup(); - const {container} = render( + const { container } = render( , ); await screen.findByText('u-b'); + await screen.findByText('2026-05-02 08:00:00'); const tableWrap = container.querySelector('.admin-table-wrap'); expect(tableWrap?.querySelector('.admin-database-table')).not.toBeNull(); - expect(screen.getByRole('option', {name: '邀请关系(profile_referral_relation)'}).getAttribute('title')).toBe( - '原始表名:profile_referral_relation。邀请关系记录表。', - ); - expect(screen.getByText('已选表:邀请关系(profile_referral_relation)')).toBeTruthy(); - expect(screen.getByRole('heading', {name: '邀请关系'}).getAttribute('title')).toBe( - '原始表名:profile_referral_relation。邀请关系记录表。', - ); - expect(screen.getByRole('button', {name: '被邀请人ID'}).getAttribute('title')).toBe( - '原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。', - ); + expect( + screen + .getByRole('option', { name: '邀请关系(profile_referral_relation)' }) + .getAttribute('title'), + ).toBe('原始表名:profile_referral_relation。邀请关系记录表。'); + expect( + screen.getByText('已选表:邀请关系(profile_referral_relation)'), + ).toBeTruthy(); + expect( + screen.getByRole('heading', { name: '邀请关系' }).getAttribute('title'), + ).toBe('原始表名:profile_referral_relation。邀请关系记录表。'); + expect( + screen.getByRole('button', { name: '被邀请人ID' }).getAttribute('title'), + ).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。'); expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']); - await user.click(screen.getByRole('button', {name: '邀请人ID'})); + await user.click(screen.getByRole('button', { name: '邀请人ID' })); await waitFor(() => { expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']); }); - await user.click(screen.getByRole('button', {name: '邀请人ID'})); + await user.click(screen.getByRole('button', { name: '邀请人ID' })); await waitFor(() => { expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']); }); diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx index cd9d0ea80..88192e2b8 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.tsx @@ -7,7 +7,7 @@ import { Search, X, } from 'lucide-react'; -import {FormEvent, useEffect, useMemo, useState} from 'react'; +import { FormEvent, useEffect, useMemo, useState } from 'react'; import { getAdminDatabaseTableRows, @@ -17,7 +17,7 @@ import type { AdminDatabaseTableRowPayload, AdminDatabaseTableRowsResponse, } from '../api/adminApiTypes'; -import {handlePageError} from './pageUtils'; +import { handlePageError } from './pageUtils'; interface AdminDatabaseTablesPageProps { token: string; @@ -35,8 +35,11 @@ export function AdminDatabaseTablesPage({ const [search, setSearch] = useState(''); const [filters, setFilters] = useState(''); const [limit, setLimit] = useState('100'); - const [result, setResult] = useState(null); - const [detailRow, setDetailRow] = useState(null); + const [result, setResult] = useState( + null, + ); + const [detailRow, setDetailRow] = + useState(null); const [errorMessage, setErrorMessage] = useState(''); const [copyMessage, setCopyMessage] = useState(''); const [sortColumn, setSortColumn] = useState(''); @@ -90,7 +93,9 @@ export function AdminDatabaseTablesPage({ const tableOptions = useMemo(() => { const optionNames = - tableName && !tables.includes(tableName) ? [tableName, ...tables] : tables; + tableName && !tables.includes(tableName) + ? [tableName, ...tables] + : tables; return optionNames.map(getDatabaseTableHeader); }, [tableName, tables]); @@ -119,7 +124,7 @@ export function AdminDatabaseTablesPage({ } return [...rows] - .map((row, index) => ({index, row})) + .map((row, index) => ({ index, row })) .sort((left, right) => { const comparison = compareTableCellValues( left.row.cells[sortColumn], @@ -131,7 +136,7 @@ export function AdminDatabaseTablesPage({ } return left.index - right.index; }) - .map(({row}) => row); + .map(({ row }) => row); }, [result, sortColumn, sortDirection, visibleColumns]); async function loadTables() { @@ -168,11 +173,15 @@ export function AdminDatabaseTablesPage({ setIsLoadingRows(true); setErrorMessage(''); try { - const response = await getAdminDatabaseTableRows(token, normalizedTableName, { - search: querySearch, - filters: queryFilters, - limit: parseLimit(queryLimit), - }); + const response = await getAdminDatabaseTableRows( + token, + normalizedTableName, + { + search: querySearch, + filters: queryFilters, + limit: parseLimit(queryLimit), + }, + ); setResult(response); setCopyMessage(''); } catch (error: unknown) { @@ -199,7 +208,7 @@ export function AdminDatabaseTablesPage({ setSearch(''); setFilters(''); setLimit('100'); - void refreshRows(tableName, {search: '', filters: '', limit: '100'}); + void refreshRows(tableName, { search: '', filters: '', limit: '100' }); } function handleSortColumn(column: string) { @@ -219,7 +228,11 @@ export function AdminDatabaseTablesPage({ return; } - const copiedText = JSON.stringify(detailRow.raw ?? detailRow.cells, null, 2); + const copiedText = JSON.stringify( + detailRow.raw ?? detailRow.cells, + null, + 2, + ); try { await navigator.clipboard.writeText(copiedText); setCopyMessage('已复制 JSON'); @@ -265,7 +278,7 @@ export function AdminDatabaseTablesPage({ value={tableName} onChange={(event) => handleTableChange(event.target.value)} > - {tableOptions.map(({name, optionLabel, description}) => ( + {tableOptions.map(({ name, optionLabel, description }) => ( @@ -296,7 +309,11 @@ export function AdminDatabaseTablesPage({ onChange={(event) => setLimit(event.target.value)} /> - @@ -327,14 +344,16 @@ export function AdminDatabaseTablesPage({
-

{resultTableHeader.label}

+

+ {resultTableHeader.label} +

{result?.totalReturned ?? 0} 条
- {columnHeaders.map(({column, label, description}) => { + {columnHeaders.map(({ column, label, description }) => { const isSorted = sortColumn === column; return ( + )} @@ -419,7 +443,11 @@ export function AdminDatabaseTablesPage({ {detailRow ? (
-
+

行详情

@@ -440,7 +468,9 @@ export function AdminDatabaseTablesPage({
- {copyMessage ?
{copyMessage}
: null} + {copyMessage ? ( +
{copyMessage}
+ ) : null}
               {JSON.stringify(detailRow.raw ?? detailRow.cells, null, 2)}
             
@@ -457,7 +487,9 @@ function readHashTableName() { if (queryIndex < 0) { return ''; } - return new URLSearchParams(hash.slice(queryIndex + 1)).get('table')?.trim() ?? ''; + return ( + new URLSearchParams(hash.slice(queryIndex + 1)).get('table')?.trim() ?? '' + ); } function parseLimit(value: string) { @@ -501,7 +533,10 @@ function getDatabaseTableLabel(tableName: string) { } function getDatabaseTableDescription(tableName: string, label: string) { - return databaseTableDescriptionMap[tableName] ?? `当前 SpacetimeDB 中的 ${label} 表`; + return ( + databaseTableDescriptionMap[tableName] ?? + `当前 SpacetimeDB 中的 ${label} 表` + ); } function getDatabaseTableColumnHeader(tableName: string, column: string) { @@ -512,7 +547,7 @@ function getDatabaseTableColumnHeader(tableName: string, column: string) { normalizedColumn, label, ); - return {column: normalizedColumn, label, description}; + return { column: normalizedColumn, label, description }; } function getDatabaseTableColumnLabel(column: string) { @@ -541,8 +576,7 @@ function getDatabaseTableColumnDescription( ) { const exactDescription = databaseTableColumnDescriptionMap[column]; const description = - exactDescription ?? - `当前表 ${tableName || '未知'} 中的 ${label} 字段`; + exactDescription ?? `当前表 ${tableName || '未知'} 中的 ${label} 字段`; return `原始字段名:${column}。${description}。点击可按此列排序。`; } @@ -566,7 +600,9 @@ function compareTableCellValues( } if (left.kind !== right.kind) { - return direction * (getSortKindOrder(left.kind) - getSortKindOrder(right.kind)); + return ( + direction * (getSortKindOrder(left.kind) - getSortKindOrder(right.kind)) + ); } let comparison = 0; @@ -578,7 +614,10 @@ function compareTableCellValues( comparison = Number(left.value) - Number(getSortableBooleanValue(right)); break; case 'text': - comparison = tableSortCollator.compare(left.value, getSortableTextValue(right)); + comparison = tableSortCollator.compare( + left.value, + getSortableTextValue(right), + ); break; } @@ -587,26 +626,26 @@ function compareTableCellValues( function normalizeTableCellSortValue(value: unknown): SortableTableCellValue { if (value === null || typeof value === 'undefined' || value === '') { - return {kind: 'empty'}; + return { kind: 'empty' }; } if (typeof value === 'number' && Number.isFinite(value)) { - return {kind: 'number', value}; + return { kind: 'number', value }; } if (typeof value === 'boolean') { - return {kind: 'boolean', value}; + return { kind: 'boolean', value }; } if (typeof value === 'string') { const trimmed = value.trim(); if (!trimmed) { - return {kind: 'empty'}; + return { kind: 'empty' }; } - return {kind: 'text', value: trimmed}; + return { kind: 'text', value: trimmed }; } - return {kind: 'text', value: stringifyUnknownValue(value)}; + return { kind: 'text', value: stringifyUnknownValue(value) }; } function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) { @@ -614,13 +653,24 @@ function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) { return `${rowIndex}-${String(firstValue ?? '')}`; } -function formatCellValue(value: unknown): FormattedTableCellValue { +function formatCellValue(value: unknown, column = ''): FormattedTableCellValue { if (value === null || typeof value === 'undefined' || value === '') { - return {content: '-', fullText: '-'}; + return { content: '-', fullText: '-' }; } - if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { const text = String(value); - return {content: text, fullText: text}; + const readableTimestamp = formatReadableTimestampValue(value, column); + if (readableTimestamp) { + return { + content: readableTimestamp, + fullText: `${readableTimestamp}(原始值:${text})`, + }; + } + return { content: text, fullText: text }; } return { content: stringifyUnknownValue(value), @@ -628,6 +678,107 @@ function formatCellValue(value: unknown): FormattedTableCellValue { }; } +function formatReadableTimestampValue( + value: string | number | boolean, + column: string, +) { + if (typeof value === 'boolean' || !isTimestampColumn(column)) { + return ''; + } + + const timestampMs = parseTimestampMillis(value, column); + if (timestampMs === null) { + return ''; + } + + const date = new Date(timestampMs); + if (Number.isNaN(date.getTime())) { + return ''; + } + + return formatBeijingDateTime(date); +} + +function isTimestampColumn(column: string) { + const normalizedColumn = column.trim().toLowerCase(); + return ( + normalizedColumn.endsWith('_at') || + normalizedColumn.endsWith('_at_ms') || + normalizedColumn.endsWith('_at_micros') || + normalizedColumn.endsWith('_timestamp') || + normalizedColumn.endsWith('_timestamp_ms') || + normalizedColumn.endsWith('_timestamp_micros') + ); +} + +function parseTimestampMillis(value: string | number, column: string) { + if (typeof value === 'number') { + return parseNumericTimestampMillis(value, column); + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + const numericValue = Number(trimmed); + if (Number.isFinite(numericValue) && /^-?\d+(\.\d+)?$/.test(trimmed)) { + return parseNumericTimestampMillis(numericValue, column); + } + + const parsed = Date.parse(trimmed); + return Number.isNaN(parsed) ? null : parsed; +} + +function parseNumericTimestampMillis(value: number, column: string) { + if (!Number.isFinite(value) || value <= 0) { + return null; + } + + const normalizedColumn = column.trim().toLowerCase(); + if ( + normalizedColumn.endsWith('_at_ms') || + normalizedColumn.endsWith('_timestamp_ms') + ) { + return value; + } + if ( + normalizedColumn.endsWith('_at_micros') || + normalizedColumn.endsWith('_timestamp_micros') + ) { + return Math.floor(value / 1_000); + } + + if (value >= 1_000_000_000_000_000) { + return Math.floor(value / 1_000); + } + if (value >= 1_000_000_000_000) { + return value; + } + if (value >= 1_000_000_000) { + return value * 1_000; + } + + return null; +} + +function formatBeijingDateTime(date: Date) { + const parts = new Intl.DateTimeFormat('zh-CN', { + day: '2-digit', + hour: '2-digit', + hourCycle: 'h23', + minute: '2-digit', + month: '2-digit', + second: '2-digit', + timeZone: 'Asia/Shanghai', + year: 'numeric', + }).formatToParts(date); + const partMap = Object.fromEntries( + parts.map((part) => [part.type, part.value]), + ); + return `${partMap.year}-${partMap.month}-${partMap.day} ${partMap.hour}:${partMap.minute}:${partMap.second}`; +} + function stringifyPrettyUnknownValue(value: unknown) { try { const serialized = JSON.stringify(value, null, 2); @@ -662,10 +813,10 @@ function getSortKindOrder(kind: SortableTableCellValue['kind']): number { } type SortableTableCellValue = - | {kind: 'empty'} - | {kind: 'number'; value: number} - | {kind: 'boolean'; value: boolean} - | {kind: 'text'; value: string}; + | { kind: 'empty' } + | { kind: 'number'; value: number } + | { kind: 'boolean'; value: boolean } + | { kind: 'text'; value: string }; interface DatabaseTableHeader { name: string; @@ -1257,7 +1408,8 @@ const databaseTableLabelMap: Record = { }; const databaseTableDescriptionMap: Record = { - database_migration_operator: '管理数据库迁移导出、导入和增量导入权限的操作员表', + database_migration_operator: + '管理数据库迁移导出、导入和增量导入权限的操作员表', database_migration_import_chunk: '大迁移 JSON 分片导入的临时表', auth_store_snapshot: '旧认证仓储的整份 JSON 快照表', user_account: '用户账号主表', @@ -1347,23 +1499,25 @@ function getSortableBooleanValue(value: SortableTableCellValue) { } function getSortableTextValue(value: SortableTableCellValue) { - return isSortableTextValue(value) ? value.value : stringifyUnknownValue(value); + return isSortableTextValue(value) + ? value.value + : stringifyUnknownValue(value); } function isSortableNumberValue( value: SortableTableCellValue, -): value is Extract { +): value is Extract { return value.kind === 'number'; } function isSortableBooleanValue( value: SortableTableCellValue, -): value is Extract { +): value is Extract { return value.kind === 'boolean'; } function isSortableTextValue( value: SortableTableCellValue, -): value is Extract { +): value is Extract { return value.kind === 'text'; } 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 选择 +
+ + +
+
+ +
+ + +
+ + + +
+
setDetailRow(row)} > {visibleColumns.map((column) => { - const cellValue = formatCellValue(row.cells[column]); + const cellValue = formatCellValue( + row.cells[column], + column, + ); return ( - 暂无数据 + 暂无数据 +