合并主分支最新改动

同步 master 的最新功能与修复
保留 AI 游戏创作客户端分支现有实现

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	docs/project-memory/shared-memory/pitfalls.md
#	scripts/dev.mjs
#	server-rs/crates/api-server/src/editor_screen_background_decision.rs
#	server-rs/crates/api-server/src/modules/admin.rs
#	src/components/rpg-entry/RpgEntryHomeView.tsx
This commit is contained in:
AIGameCreator App
2026-07-14 13:05:45 +08:00
384 changed files with 44028 additions and 5120 deletions
+5 -5
View File
@@ -1,6 +1,6 @@
---
name: spacetimedb-cli
description: SpacetimeDB 2.5 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification.
description: SpacetimeDB 2.6 CLI reference for Genarrative. Use for spacetime build, publish, generate, call, sql, logs, server management, local dev, explicit server targeting, version checks, and remote runtime verification.
---
# SpacetimeDB CLI
@@ -61,7 +61,7 @@ spacetime describe my-db table users --server http://127.0.0.1:3101 --json
# Reducer/procedure calls. Arguments are positional JSON values.
spacetime call --server http://127.0.0.1:3101 my-db my_reducer '"value"' '123'
# 2.5 accepts hex strings for Identity arguments without full JSON tuple syntax.
# 2.5+ accepts hex strings for Identity arguments without full JSON tuple syntax.
spacetime call --server http://127.0.0.1:3101 my-db reducer_needing_identity 0xabc123...
# Subscribe from CLI
@@ -102,7 +102,7 @@ curl -fsS http://127.0.0.1:3101/v1/ping
| Flag | Description |
|------|-------------|
| `--server`, `-s` | Target server nickname, host, or URL |
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.5 prefer scoped values |
| `--yes`, `-y` | Non-interactive prompt skipping; in 2.6 use scoped values |
| `--delete-data`, `-c` | Publish data policy: `always`, `on-conflict`, or `never` |
| `--module-path`, `-p` | Module project path |
| `--bin-path`, `-b` | Publish/generate from compiled wasm |
@@ -146,6 +146,6 @@ pid="$(systemctl show spacetimedb.service -p MainPID --value)"
## Notes
- Procedure calls are stable in 2.5; module HTTP handlers/webhooks, unstable view features, and RLS remain behind unstable gates per release notes.
- 2.5 fixes `publish --delete-data` config fallback so `spacetime.json` can provide the database name.
- Procedure calls remain stable in 2.6; module HTTP handlers/webhooks and RLS capabilities still require their documented gates.
- 2.5 fixed `publish --delete-data` config fallback; 2.6 keeps that behavior and improves CLI binary distribution.
- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on CLI defaults.
+9 -8
View File
@@ -1,6 +1,6 @@
---
name: spacetimedb-concepts
description: Understand SpacetimeDB 2.5 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features.
description: Understand SpacetimeDB 2.6 architecture, reducer/procedure/table/view semantics, schema evolution, subscriptions, identity, and Genarrative-specific backend boundaries. Use when designing or reviewing SpacetimeDB-backed features.
---
# SpacetimeDB Core Concepts
@@ -20,7 +20,7 @@ SpacetimeDB is a relational database that also executes application logic in upl
1. **Reducers are transactional**: they do not return data to callers. Read through subscriptions, read models, views, or BFF endpoints.
2. **Reducers are deterministic**: no filesystem, network, wall-clock, or external RNG. Use `ctx.timestamp`, `ctx.rng()` / `ctx.random()`, and tables.
3. **Procedures are stable in 2.5**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
3. **Procedures are stable in 2.6**: they can use explicit transactions and outgoing HTTP via `ctx.http`.
4. **Identity comes from context**: use `ctx.sender()` or language equivalent for authorization. Never trust identity passed as an argument.
5. **Auto-increment IDs are not ordering guarantees**: gaps are normal. Use timestamps or explicit sequence columns for ordering.
6. **Schema changes need migration discipline**: existing Genarrative table fields must be appended with defaults; update migration code, table catalog, generated bindings, and run `npm run check:spacetime-schema`.
@@ -44,25 +44,25 @@ Reducers are deterministic transactional functions. They are the primary client-
## Procedures
Procedures are stable in 2.5. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
Procedures are stable in 2.6. They can be scheduled, can open explicit transactions with `with_tx` / `try_with_tx`, and can use outgoing HTTP (`ctx.http`).
Genarrative default: keep external provider protocols in `platform-*` and orchestration in `api-server` unless a task explicitly moves a workflow into a module procedure.
Module HTTP handlers/webhooks, unstable view features, and RLS `client_visibility_filter` remain gated behind unstable according to the 2.5 release notes.
Module HTTP handlers/webhooks and RLS `client_visibility_filter` remain subject to their documented gates in 2.6.
## Views
Views expose computed read-only data. In 2.4.1 Rust and TypeScript gained primary key support for procedural views; in 2.5 C# gained the same. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction.
Views expose computed read-only data. SpacetimeDB 2.6 supports primary keys on procedural views in Rust, TypeScript, and C#. Clients can receive `OnUpdate` events when subscribed to such views with primary keys. Ensure the view never returns duplicate primary keys, because that can fail view refresh and roll back the triggering transaction.
## Event Tables
Event tables broadcast reducer/procedure-specific facts to subscribers and must be subscribed explicitly. They are excluded from `subscribe_to_all_tables()`.
2.5 adds broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
2.6 supports broader layout-altering automigrations for event tables, including column removal, reordering, and type changes that regular tables reject. This relaxed migration behavior is for event-only tables, not persistent tables.
Event-table primary keys and constraints are transaction-scoped. They can reject duplicate event rows within one transaction, but event rows are not retained in client cache, so clients observe event tables through insert callbacks only. Do not design Genarrative event tables around `OnUpdate` / `on_update` / `onUpdate`; use a persistent table or a primary-keyed procedural view when update callbacks are required.
Official 2.4.1/2.5 release notes document primary-key-backed update callbacks for procedural views, not event tables.
Official 2.4.1 through 2.6 release notes document primary-key-backed update callbacks for procedural views, not event tables.
## Subscriptions
@@ -78,7 +78,7 @@ Best practices:
- Avoid overlapping queries that duplicate row delivery.
- Use indexes for subscribed filters.
## 2.2.0 to 2.5.0 Delta
## 2.2.0 to 2.6.0 Delta
Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
@@ -87,6 +87,7 @@ Genarrative introduced SpacetimeDB around 2.2.0. Important changes since then:
- **2.4.0**: unstable module HTTP handlers/webhooks, faster synchronous WASM reducer runtime, commitlog resume truncation fix for silent data loss risk, better commitlog decode context, V8 heap metrics for procedure workers, JS execution-time billing regression reverted.
- **2.4.1**: Rust and TypeScript procedural views can declare primary keys, enabling `OnUpdate` events for subscribed views; fixed index schema from ST tables.
- **2.5.0**: procedures are stable, C# procedural views gain primary keys, event tables allow broader layout-altering automigrations, BTreeSet storage makes row insertion deterministic and avoids accidentally quadratic bulk insert behavior, `wasm_memory_bytes` billing metric semantics changed, template version constraints unified, `publish --delete-data` config fallback fixed, CLI `call` accepts hex Identity arguments.
- **2.6.0**: procedural-view primary keys are available across Rust, TypeScript, and C#, commitlog gains `max_segment_size` / `write_buffer_size` / `preallocate_segments`, the default write buffer increases for throughput, event-table automigrations improve, and CLI binary distribution expands.
## Debugging Checklist
+5 -5
View File
@@ -1,6 +1,6 @@
---
name: spacetimedb-rust
description: Develop SpacetimeDB 2.5 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic.
description: Develop SpacetimeDB 2.6 server modules in Rust for Genarrative. Use when writing or reviewing tables, reducers, procedures, views, migrations, row mappers, schema changes, and module logic.
---
# SpacetimeDB Rust Module Development
@@ -28,7 +28,7 @@ ctx.db.player.find(id) // Use ctx.db.player().id().find(&id)
ctx.sender // Use ctx.sender()
ctx.db.user().name().update(..) // Update by primary key only
spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures in 2.5
spacetimedb = { version = "...", features = ["unstable"] } // Not needed for procedures since 2.5
```
## Required Patterns
@@ -181,11 +181,11 @@ fn deal_damage(ctx: &ReducerContext, target: Identity, amount: u32) {
Event tables must be subscribed explicitly and are excluded from `subscribe_to_all_tables()`.
In 2.5, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables.
In 2.6, event tables support broader layout-altering automigrations than regular tables, including column removal, reordering, and type changes. This relaxed migration policy does not apply to persistent tables.
Event-table primary keys and constraints are enforced only within the current transaction. They do not make event rows persistent, and client SDKs expose event tables as insert-only event streams. Do not rely on `OnUpdate` / `on_update` / `onUpdate` for event tables; use a persistent table or a primary-keyed procedural view when update callbacks are required.
Official 2.4.1/2.5 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
Official 2.4.1 through 2.6 release notes tie primary-key-backed update callbacks to procedural views, not event tables.
## Views
@@ -228,7 +228,7 @@ For scheduled reducers, check `ctx.sender_auth().is_internal()` when the reducer
## Procedures
Procedures are stable in 2.5 and no longer require the `unstable` feature.
Procedures remain stable in 2.6 and no longer require the `unstable` feature.
```rust
use spacetimedb::{procedure, ProcedureContext};
+2
View File
@@ -104,6 +104,8 @@ WECHAT_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/sns/oauth2/access_token"
WECHAT_USER_INFO_ENDPOINT="https://api.weixin.qq.com/sns/userinfo"
WECHAT_JS_CODE_SESSION_ENDPOINT="https://api.weixin.qq.com/sns/jscode2session"
WECHAT_STABLE_ACCESS_TOKEN_ENDPOINT="https://api.weixin.qq.com/cgi-bin/stable_token"
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_QUERY_ORDER_ENDPOINT="https://api.weixin.qq.com/xpay/query_order"
WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_NOTIFY_PROVIDE_GOODS_ENDPOINT="https://api.weixin.qq.com/xpay/notify_provide_goods"
WECHAT_PHONE_NUMBER_ENDPOINT="https://api.weixin.qq.com/wxa/business/getuserphonenumber"
WECHAT_STATE_TTL_MINUTES="15"
WECHAT_MOCK_USER_ID="wx-mock-user"
+30 -2
View File
@@ -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<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
token,
});
}
export function upsertAdminFeatureGateConfig(
token: string,
payload: AdminUpsertFeatureGateConfigRequest,
) {
return request<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
method: 'PUT',
token,
body: payload,
});
}
export function getAdminCreationEntryConfig(token: string) {
return request<AdminCreationEntryConfigResponse>(
'/admin/api/creation-entry/config',
@@ -323,9 +342,13 @@ export function updateAdminWorkVisibility(
);
}
export function getAdminAssetReadUrl(query: AdminAssetReadUrlQuery) {
export function getAdminAssetReadUrl(
token: string,
query: AdminAssetReadUrlQuery,
) {
return request<AdminAssetReadUrlResponse>(
`/api/assets/read-url${buildAssetReadUrlQuery(query)}`,
`/admin/api/assets/read-url${buildAssetReadUrlQuery(query)}`,
{ token },
);
}
@@ -741,6 +764,11 @@ function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(query.limit));
}
if (typeof query.page === 'number' && Number.isFinite(query.page)) {
params.set('page', String(query.page));
}
appendQueryParam(params, 'sortColumn', query.sortColumn);
appendQueryParam(params, 'sortDirection', query.sortDirection);
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
+49 -1
View File
@@ -150,8 +150,11 @@ export interface AdminDatabaseTableListResponse {
export interface AdminDatabaseTableRowsQuery {
limit?: number;
page?: number;
search?: string;
filters?: string;
sortColumn?: string;
sortDirection?: 'asc' | 'desc';
}
export interface AdminDatabaseTableRowPayload {
@@ -165,6 +168,11 @@ export interface AdminDatabaseTableRowsResponse {
rows: AdminDatabaseTableRowPayload[];
totalReturned: number;
limit: number;
page: number;
totalMatched: number;
scannedCount: number;
scanLimit: number;
scanLimitReached: boolean;
}
export interface AdminDatabaseTableStatPayload {
@@ -199,7 +207,15 @@ export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
export type ProfileTaskCycle = 'daily';
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
export type ProfileRechargeProductKind = 'points' | 'membership';
export type ProfileMembershipTier = 'normal' | 'month' | 'season' | 'year';
export type ProfileMembershipTier =
| 'normal'
| 'month'
| 'season'
| 'year'
| 'starter'
| 'basic'
| 'pro'
| 'ultimate';
export interface AdminTrackingEventListQuery {
eventKey?: string;
@@ -212,6 +228,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[];
@@ -517,6 +553,8 @@ export interface AdminUpsertProfileRedeemCodeRequest {
enabled: boolean;
allowedUserIds: string[];
allowedPublicUserCodes: string[];
startsAt?: string | null;
expiresAt?: string | null;
}
export interface AdminUpsertProfileInviteCodeRequest {
@@ -558,6 +596,10 @@ export interface AdminUpsertProfileRechargeProductRequest {
badgeLabel?: string | null;
description?: string | null;
tier: ProfileMembershipTier;
membershipPeriodPoints: number;
membershipPeriodDays: number;
membershipQueueLimit: number;
membershipDiscountBps: number;
enabled: boolean;
sortOrder: number;
}
@@ -574,6 +616,8 @@ export interface ProfileRedeemCodeAdminResponse {
globalUsedCount: number;
enabled: boolean;
allowedUserIds: string[];
startsAt?: string | null;
expiresAt?: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
@@ -641,6 +685,10 @@ export interface ProfileRechargeProductConfigAdminResponse {
badgeLabel: string;
description: string;
tier: ProfileMembershipTier;
membershipPeriodPoints: number;
membershipPeriodDays: number;
membershipQueueLimit: number;
membershipDiscountBps: number;
enabled: boolean;
sortOrder: number;
createdBy: string;
+7
View File
@@ -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' ? (
<AdminGrayReleaseConfigPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'redeem' ? (
<AdminRedeemCodePage
token={token}
+2
View File
@@ -7,6 +7,7 @@ import {
LogOut,
Megaphone,
Eye,
GitBranch,
Images,
Star,
WalletCards,
@@ -38,6 +39,7 @@ const routeIcons = {
tables: Database,
debug: Bug,
tracking: Table2,
'gray-release': GitBranch,
redeem: TicketPercent,
invite: TicketCheck,
'profile-wallet': WalletCards,
@@ -40,6 +40,16 @@ test('后台模型定价路由可通过导航和 hash 访问', () => {
);
});
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',
+2
View File
@@ -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'},
@@ -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) =>
@@ -19,6 +19,36 @@ vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(() => false),
}));
const referralRows = [
{
cells: {
bound_at: '2026-05-02T00:00:00Z',
invitee_user_id: 'u-b',
invite_code: 'INV-1001',
inviter_user_id: 'u-a',
},
raw: ['u-b', 'u-a', 'INV-1001', '2026-05-02T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-01T00:00:00Z',
invitee_user_id: 'u-a',
invite_code: 'INV-1002',
inviter_user_id: 'u-c',
},
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-03T00:00:00Z',
invitee_user_id: 'u-c',
invite_code: 'INV-1003',
inviter_user_id: 'u-a',
},
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
},
];
beforeEach(() => {
window.location.hash = '#tables?table=profile_referral_relation';
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
@@ -28,74 +58,147 @@ beforeEach(() => {
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
rows: [
{
cells: {
bound_at: '2026-05-02T00:00:00Z',
invitee_user_id: 'u-b',
invite_code: 'INV-1001',
inviter_user_id: 'u-a',
},
raw: [
'u-b',
'u-a',
'INV-1001',
'2026-05-02T00:00:00Z',
],
},
{
cells: {
bound_at: '2026-05-01T00:00:00Z',
invitee_user_id: 'u-a',
invite_code: 'INV-1002',
inviter_user_id: 'u-c',
},
raw: ['u-a', 'u-c', 'INV-1002', '2026-05-01T00:00:00Z'],
},
{
cells: {
bound_at: '2026-05-03T00:00:00Z',
invitee_user_id: 'u-c',
invite_code: 'INV-1003',
inviter_user_id: 'u-a',
},
raw: ['u-c', 'u-a', 'INV-1003', '2026-05-03T00:00:00Z'],
},
],
rows: referralRows,
page: 1,
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
});
test('后台表查询页支持宽表滚动容器和表头排序', async () => {
test('后台表查询页通过页面级固定栏翻页并提示扫描结果可能不完整', async () => {
const user = userEvent.setup();
const {container} = render(
vi.mocked(getAdminDatabaseTableRows).mockResolvedValue({
columns: ['invitee_user_id'],
limit: 100,
page: 1,
rows: [
{
cells: { invitee_user_id: 'u-b' },
raw: ['u-b'],
},
],
scannedCount: 50000,
scanLimit: 50000,
scanLimitReached: true,
tableName: 'profile_referral_relation',
totalMatched: 250,
totalReturned: 1,
});
const { container } = render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
expect(await screen.findByText('第 1 / 3 页,共 250 条')).toBeTruthy();
const pagination = screen.getByRole('navigation', { name: '表查询分页' });
expect(pagination.classList.contains('admin-database-pagination')).toBe(true);
expect(pagination.closest('.admin-panel')).toBeNull();
expect(
container.querySelector('.admin-database-tables-page')?.lastElementChild,
).toBe(pagination);
expect(
screen.getByText(
'当前表超过 50000 条扫描与浏览上限,本次已扫描 50000 条;当前分页结果和匹配总数可能不完整。',
),
).toBeTruthy();
await user.type(screen.getByRole('textbox', { name: '关键词' }), '未执行条件');
await user.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: '',
limit: 100,
page: 2,
search: '',
}),
);
});
});
test('后台表查询页把表头排序交给后端并从第一页展示排序结果', async () => {
const user = userEvent.setup();
const { container } = render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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'}));
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: [referralRows[0]!, referralRows[2]!, referralRows[1]!],
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
page: 1,
sortColumn: 'inviter_user_id',
sortDirection: 'asc',
}),
);
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-c', 'u-a']);
});
await user.click(screen.getByRole('button', {name: '邀请人ID'}));
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: [referralRows[1]!, referralRows[0]!, referralRows[2]!],
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
page: 1,
sortColumn: 'inviter_user_id',
sortDirection: 'desc',
}),
);
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
});
});
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@ import {
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import {
@@ -127,12 +128,115 @@ test('后台素材查询缩略图使用 objectKey 换签后展示', async () =>
'https://signed.example.com/generated-character-drafts/editor/spec.png',
);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith({
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor/spec.png',
expireSeconds: 300,
});
});
test('后台素材查询将无 objectKey 的绝对 OSS 图片地址换签后展示', async () => {
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
{
...generatedAsset,
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(
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const image = await screen.findByRole('img', { name: '素材:角色形象 1' });
await waitFor(() => {
expect(image.getAttribute('src')).toBe(
'https://signed.example.com/absolute.png',
);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
legacyPublicPath: '/generated-character-drafts/editor/absolute.png',
expireSeconds: 300,
});
});
test('后台素材查询点击图片缩略图可打开放大预览', async () => {
const user = userEvent.setup();
render(
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await user.click(
await screen.findByRole('button', { name: '素材:角色形象 1' }),
);
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
const image = within(dialog).getByRole('img', {
name: '图片预览:角色形象 1',
});
await waitFor(() => {
expect(image.getAttribute('src')).toBe(
'https://signed.example.com/generated-character-drafts/editor/spec.png',
);
});
});
test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () => {
const user = userEvent.setup();
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
{
...generatedAsset,
assetId: 'asset-character-animation-1',
label: '角色动作首帧',
imageSrc: '/generated-animations/editor/source-1/task-1/frame00.png',
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
assetKind: 'character-animation',
thumbnailSrc:
'/generated-animations/editor/source-1/task-1/frame00.png',
},
],
nextCursor: null,
});
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
read: {
objectKey: 'generated-animations/editor/source-1/task-1/frame00.png',
signedUrl: 'https://signed.example.com/character-animation-frame00.png',
expiresAt: '2026-07-04T11:00:00Z',
},
});
render(
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await user.click(
await screen.findByRole('button', { name: '素材:角色动作首帧' }),
);
const dialog = await screen.findByRole('dialog', { name: '素材预览' });
const image = within(dialog).getByRole('img', {
name: '图片预览:角色动作首帧',
});
await waitFor(() => {
expect(image.getAttribute('src')).toBe(
'https://signed.example.com/character-animation-frame00.png',
);
});
expect(within(dialog).queryByLabelText('视频预览:角色动作首帧')).toBeNull();
});
test('后台素材查询音频素材使用统一封面缩略图', async () => {
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
@@ -159,6 +263,194 @@ test('后台素材查询音频素材使用统一封面缩略图', async () => {
expect(getAdminAssetReadUrl).not.toHaveBeenCalled();
});
test('后台素材查询点击音频缩略图后签名音频并展示播放器', async () => {
const user = userEvent.setup();
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
{
...generatedAsset,
assetId: 'asset-audio-1',
label: '胜利音效',
imageSrc: '/generated-editor-audios/sfx.mp3',
objectKey: 'generated-editor-audios/sfx.mp3',
assetKind: 'sound-effect',
},
],
nextCursor: null,
});
vi.mocked(getAdminAssetReadUrl).mockResolvedValue({
read: {
objectKey: 'generated-editor-audios/sfx.mp3',
signedUrl: 'https://signed.example.com/generated-editor-audios/sfx.mp3',
expiresAt: '2026-07-04T11:00:00Z',
},
});
render(
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await user.click(
await screen.findByRole('button', { name: '素材:胜利音效' }),
);
const player = await screen.findByLabelText('音频预览:胜利音效');
await waitFor(() => {
expect(player.getAttribute('src')).toBe(
'https://signed.example.com/generated-editor-audios/sfx.mp3',
);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-editor-audios/sfx.mp3',
expireSeconds: 300,
});
});
test('后台素材查询视频缩略图使用封面并在预览中播放视频', async () => {
const user = userEvent.setup();
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
{
...generatedAsset,
assetId: 'asset-video-1',
label: '生成视频 1',
imageSrc: '/generated-editor-videos/task-1/preview.mp4',
objectKey:
'generated-character-drafts/editor-videos/task-1/preview.mp4',
assetKind: 'editor_video',
thumbnailSrc: '/generated-editor-videos/task-1/cover.png',
},
],
nextCursor: null,
});
vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => {
if ('legacyPublicPath' in request) {
return {
read: {
objectKey: 'generated-editor-videos/task-1/cover.png',
signedUrl: 'https://signed.example.com/video-cover.png',
expiresAt: '2026-07-04T11:00:00Z',
},
};
}
const objectKey = request.objectKey ?? '';
return {
read: {
objectKey,
signedUrl: objectKey.endsWith('.mp4')
? 'https://signed.example.com/video-preview.mp4'
: 'https://signed.example.com/video-cover-by-key.png',
expiresAt: '2026-07-04T11:00:00Z',
},
};
});
render(
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const image = await screen.findByRole('img', { name: '素材:生成视频 1' });
await waitFor(() => {
expect(image.getAttribute('src')).toBe(
'https://signed.example.com/video-cover.png',
);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
legacyPublicPath: '/generated-editor-videos/task-1/cover.png',
expireSeconds: 300,
});
expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor-videos/task-1/preview.mp4',
expireSeconds: 300,
});
vi.mocked(getAdminAssetReadUrl).mockClear();
await user.click(screen.getByRole('button', { name: '素材:生成视频 1' }));
const video = await screen.findByLabelText('视频预览:生成视频 1');
await waitFor(() => {
expect(video.getAttribute('src')).toBe(
'https://signed.example.com/video-preview.mp4',
);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor-videos/task-1/preview.mp4',
expireSeconds: 300,
});
});
test('后台素材查询将无 objectKey 的绝对 OSS 视频和封面分别换签', async () => {
const user = userEvent.setup();
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
{
...generatedAsset,
assetId: 'asset-video-2',
label: '生成视频 2',
imageSrc:
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-editor-videos/task-2/preview.mp4?versionId=video',
objectKey: null,
assetKind: 'editor_video',
thumbnailSrc:
'https://genarrative.oss-cn-shanghai.aliyuncs.com/generated-editor-videos/task-2/cover.png?versionId=poster',
},
],
nextCursor: null,
});
vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => {
const legacyPublicPath = request.legacyPublicPath ?? '';
return {
read: {
objectKey: legacyPublicPath.replace(/^\//u, ''),
signedUrl: legacyPublicPath.endsWith('/cover.png')
? 'https://signed.example.com/video-2-cover.png'
: 'https://signed.example.com/video-2-preview.mp4',
expiresAt: '2026-07-04T11:00:00Z',
},
};
});
render(
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const thumbnail = await screen.findByRole('img', {
name: '素材:生成视频 2',
});
await waitFor(() => {
expect(thumbnail.getAttribute('src')).toBe(
'https://signed.example.com/video-2-cover.png',
);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
legacyPublicPath: '/generated-editor-videos/task-2/cover.png',
expireSeconds: 300,
});
vi.mocked(getAdminAssetReadUrl).mockClear();
await user.click(screen.getByRole('button', { name: '素材:生成视频 2' }));
const video = await screen.findByLabelText('视频预览:生成视频 2');
await waitFor(() => {
expect(video.getAttribute('src')).toBe(
'https://signed.example.com/video-2-preview.mp4',
);
expect(video.getAttribute('poster')).toBe(
'https://signed.example.com/video-2-cover.png',
);
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
legacyPublicPath: '/generated-editor-videos/task-2/preview.mp4',
expireSeconds: 300,
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
legacyPublicPath: '/generated-editor-videos/task-2/cover.png',
expireSeconds: 300,
});
});
test('后台素材查询格式化微秒时间文本', async () => {
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
@@ -19,7 +19,7 @@ interface AdminEditorAssetQueryPageProps {
}
const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
export function AdminEditorAssetQueryPage({
token,
@@ -36,6 +36,8 @@ export function AdminEditorAssetQueryPage({
const [errorMessage, setErrorMessage] = useState('');
const [detailEntry, setDetailEntry] =
useState<AdminEditorAssetPayload | null>(null);
const [previewEntry, setPreviewEntry] =
useState<AdminEditorAssetPayload | null>(null);
const [promptPreview, setPromptPreview] = useState<{
title: string;
prompt: string;
@@ -168,11 +170,11 @@ export function AdminEditorAssetQueryPage({
<td>
<button
className="admin-asset-query-thumb-button"
title="查看详情"
title="预览素材"
type="button"
onClick={() => setDetailEntry(entry)}
onClick={() => setPreviewEntry(entry)}
>
<AdminAssetThumbnail entry={entry} />
<AdminAssetThumbnail entry={entry} token={token} />
</button>
<small>{entry.label || '-'}</small>
</td>
@@ -233,7 +235,9 @@ export function AdminEditorAssetQueryPage({
{detailEntry ? (
<AdminAssetDetailDialog
entry={detailEntry}
token={token}
onClose={() => setDetailEntry(null)}
onPreview={(entry) => setPreviewEntry(entry)}
onPromptPreview={(entry, prompt) =>
setPromptPreview({
title: entry.label || entry.assetId,
@@ -243,6 +247,14 @@ export function AdminEditorAssetQueryPage({
/>
) : null}
{previewEntry ? (
<AdminAssetPreviewDialog
entry={previewEntry}
token={token}
onClose={() => setPreviewEntry(null)}
/>
) : null}
{promptPreview ? (
<div className="admin-confirm-backdrop" role="presentation">
<section
@@ -275,11 +287,18 @@ export function AdminEditorAssetQueryPage({
);
}
function AdminAssetThumbnail({ entry }: { entry: AdminEditorAssetPayload }) {
const isAudio = isAdminAudioAsset(entry);
const imageSrc = useAdminResolvedAssetImageSrc(
isAudio ? AUDIO_ASSET_COVER_SRC : entry.thumbnailSrc || entry.imageSrc,
isAudio ? null : entry.objectKey,
function AdminAssetThumbnail({
entry,
token,
}: {
entry: AdminEditorAssetPayload;
token: string;
}) {
const thumbnailSource = resolveAdminAssetThumbnailSource(entry);
const imageSrc = useAdminResolvedAssetUrl(
token,
thumbnailSource.src,
thumbnailSource.objectKey,
);
const alt = `素材:${entry.label || entry.assetId}`;
@@ -290,23 +309,89 @@ function AdminAssetThumbnail({ entry }: { entry: AdminEditorAssetPayload }) {
);
}
function isAdminAudioAsset(entry: AdminEditorAssetPayload) {
function resolveAdminAssetThumbnailSource(entry: AdminEditorAssetPayload) {
const mediaKind = resolveAdminAssetMediaKind(entry);
if (mediaKind === 'audio') {
return { src: AUDIO_ASSET_COVER_SRC, objectKey: null };
}
if (mediaKind === 'video') {
return { src: entry.thumbnailSrc || '', objectKey: null };
}
return {
src: entry.thumbnailSrc || entry.imageSrc,
objectKey: entry.objectKey,
};
}
type AdminAssetMediaKind = 'image' | 'audio' | 'video';
function resolveAdminAssetMediaKind(
entry: AdminEditorAssetPayload,
): AdminAssetMediaKind {
const pathMediaKind =
resolveAdminAssetMediaKindFromPath(entry.imageSrc) ??
resolveAdminAssetMediaKindFromPath(entry.objectKey ?? '');
if (pathMediaKind) {
return pathMediaKind;
}
const assetKind = entry.assetKind?.trim() ?? '';
return (
if (
assetKind === 'sound-effect' ||
assetKind === 'background-music' ||
assetKind === 'editor_uploaded_audio' ||
/\.(?:mp3|wav|m4a|aac|ogg)(?:$|[?#])/iu.test(entry.imageSrc.trim())
);
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 AdminAssetDetailDialog({
entry,
token,
onClose,
onPreview,
onPromptPreview,
}: {
entry: AdminEditorAssetPayload;
token: string;
onClose: () => void;
onPreview: (entry: AdminEditorAssetPayload) => void;
onPromptPreview: (entry: AdminEditorAssetPayload, prompt: string) => void;
}) {
const promptText = entry.prompt || entry.actualPrompt || '';
@@ -332,7 +417,14 @@ function AdminAssetDetailDialog({
</button>
</div>
<div className="admin-asset-query-detail-layout">
<AdminAssetThumbnail entry={entry} />
<button
className="admin-asset-query-thumb-button admin-asset-query-detail-thumb-button"
title="预览素材"
type="button"
onClick={() => onPreview(entry)}
>
<AdminAssetThumbnail entry={entry} token={token} />
</button>
<dl className="admin-info-list admin-detail-list">
<AdminInfoItem label="作者">
{authorDisplayName(entry)}
@@ -385,6 +477,114 @@ function AdminAssetDetailDialog({
);
}
function AdminAssetPreviewDialog({
entry,
token,
onClose,
}: {
entry: AdminEditorAssetPayload;
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>
<AdminAssetPreviewMedia entry={entry} token={token} />
</section>
</div>
);
}
function AdminAssetPreviewMedia({
entry,
token,
}: {
entry: AdminEditorAssetPayload;
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 AdminInfoItem({
label,
children,
@@ -400,14 +600,18 @@ function AdminInfoItem({
);
}
function useAdminResolvedAssetImageSrc(
function useAdminResolvedAssetUrl(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
const normalizedLegacyPublicPath = isGeneratedLegacyPath(normalizedImageSrc)
? normalizedImageSrc
: resolveAdminGeneratedLegacyPathFromUrl(normalizedImageSrc);
const shouldResolve =
Boolean(normalizedObjectKey) || isGeneratedLegacyPath(normalizedImageSrc);
Boolean(normalizedObjectKey) || Boolean(normalizedLegacyPublicPath);
const [resolvedImageSrc, setResolvedImageSrc] = useState(
shouldResolve ? '' : normalizedImageSrc,
);
@@ -426,13 +630,14 @@ function useAdminResolvedAssetImageSrc(
setResolvedImageSrc('');
void getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedImageSrc,
legacyPublicPath: normalizedLegacyPublicPath,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
},
)
@@ -451,7 +656,13 @@ function useAdminResolvedAssetImageSrc(
return () => {
cancelled = true;
};
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve]);
}, [
normalizedImageSrc,
normalizedLegacyPublicPath,
normalizedObjectKey,
shouldResolve,
token,
]);
return resolvedImageSrc;
}
@@ -464,6 +675,22 @@ 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() : '';
@@ -167,6 +167,10 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
submittedBefore: null,
limit: 80,
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor/spec.png',
expireSeconds: 300,
});
});
test('后台精选审核格式化微秒时间并显示素材名', async () => {
@@ -25,7 +25,7 @@ interface AdminEditorShowcaseReviewPageProps {
}
const ADMIN_SHOWCASE_READ_EXPIRE_SECONDS = 300;
const AUDIO_ASSET_COVER_SRC = '/creation-home/audio-asset-cover.png';
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: '角色' },
@@ -345,7 +345,7 @@ export function AdminEditorShowcaseReviewPage({
type="button"
onClick={() => setDetailEntry(entry)}
>
<AdminShowcaseThumbnail entry={entry} />
<AdminShowcaseThumbnail entry={entry} token={token} />
</button>
<small>{entry.label || '-'}</small>
</td>
@@ -597,6 +597,7 @@ export function AdminEditorShowcaseReviewPage({
{detailEntry ? (
<AdminShowcaseDetailDialog
entry={detailEntry}
token={token}
onClose={() => setDetailEntry(null)}
onPromptPreview={(entry, prompt) =>
setPromptPreview({
@@ -641,11 +642,14 @@ 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,
);
@@ -670,10 +674,12 @@ function isAdminShowcaseAudioAsset(entry: AdminEditorShowcaseAssetPayload) {
function AdminShowcaseDetailDialog({
entry,
token,
onClose,
onPromptPreview,
}: {
entry: AdminEditorShowcaseAssetPayload;
token: string;
onClose: () => void;
onPromptPreview: (
entry: AdminEditorShowcaseAssetPayload,
@@ -703,7 +709,7 @@ function AdminShowcaseDetailDialog({
</button>
</div>
<div className="admin-asset-query-detail-layout">
<AdminShowcaseThumbnail entry={entry} />
<AdminShowcaseThumbnail entry={entry} token={token} />
<dl className="admin-info-list admin-detail-list">
<AdminInfoItem label="作者">
{authorDisplayName(entry)}
@@ -786,6 +792,7 @@ function AdminInfoItem({
}
function useAdminResolvedAssetImageSrc(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
) {
@@ -811,6 +818,7 @@ function useAdminResolvedAssetImageSrc(
setResolvedImageSrc('');
void getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
@@ -836,7 +844,7 @@ function useAdminResolvedAssetImageSrc(
return () => {
cancelled = true;
};
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve]);
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve, token]);
return resolvedImageSrc;
}
@@ -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(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(
<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(
'拼图创作入口灰度',
);
});
test('灰度发布页可通过功能入口生成画布 Agent 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 前缀'), [
'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(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
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(<AdminGrayReleaseConfigPage token="" onUnauthorized={vi.fn()} />);
expect(getAdminCreationEntryConfig).not.toHaveBeenCalled();
expect(getAdminFeatureGateConfig).not.toHaveBeenCalled();
expect(upsertAdminFeatureGateConfig).not.toHaveBeenCalled();
});
File diff suppressed because it is too large Load Diff
@@ -26,6 +26,10 @@ const productKinds: Array<{value: ProfileRechargeProductKind; label: string}> =
];
const membershipTiers: Array<{value: ProfileMembershipTier; label: string}> = [
{value: 'starter', label: 'Starter'},
{value: 'basic', label: 'Basic'},
{value: 'pro', label: 'Pro'},
{value: 'ultimate', label: 'Ultimate'},
{value: 'month', label: '月卡'},
{value: 'season', label: '季卡'},
{value: 'year', label: '年卡'},
@@ -45,11 +49,15 @@ export function AdminRechargeProductPage({
const [priceCents, setPriceCents] = useState('600');
const [kind, setKind] = useState<ProfileRechargeProductKind>('points');
const [pointsAmount, setPointsAmount] = useState('60');
const [bonusPoints, setBonusPoints] = useState('60');
const [bonusPoints, setBonusPoints] = useState('0');
const [durationDays, setDurationDays] = useState('0');
const [badgeLabel, setBadgeLabel] = useState('首充双倍');
const [description, setDescription] = useState('首充送60泥点');
const [badgeLabel, setBadgeLabel] = useState('');
const [description, setDescription] = useState('60泥点');
const [tier, setTier] = useState<ProfileMembershipTier>('normal');
const [membershipPeriodPoints, setMembershipPeriodPoints] = useState('0');
const [membershipPeriodDays, setMembershipPeriodDays] = useState('0');
const [membershipQueueLimit, setMembershipQueueLimit] = useState('0');
const [membershipDiscountBps, setMembershipDiscountBps] = useState('0');
const [enabled, setEnabled] = useState(true);
const [sortOrder, setSortOrder] = useState('0');
const [isLoading, setIsLoading] = useState(false);
@@ -110,6 +118,14 @@ export function AdminRechargeProductPage({
badgeLabel: kind === 'points' ? badgeLabel.trim() : '',
description: description.trim(),
tier: kind === 'membership' ? tier : 'normal',
membershipPeriodPoints:
kind === 'membership' ? parsePositiveInteger(membershipPeriodPoints) : 0,
membershipPeriodDays:
kind === 'membership' ? parsePositiveInteger(membershipPeriodDays) : 0,
membershipQueueLimit:
kind === 'membership' ? parseNonNegativeInteger(membershipQueueLimit) : 0,
membershipDiscountBps:
kind === 'membership' ? parseNonNegativeInteger(membershipDiscountBps) : 0,
enabled,
sortOrder: parseInteger(sortOrder),
});
@@ -141,6 +157,10 @@ export function AdminRechargeProductPage({
setBadgeLabel(entry.badgeLabel);
setDescription(entry.description);
setTier(entry.tier);
setMembershipPeriodPoints(String(entry.membershipPeriodPoints));
setMembershipPeriodDays(String(entry.membershipPeriodDays));
setMembershipQueueLimit(String(entry.membershipQueueLimit));
setMembershipDiscountBps(String(entry.membershipDiscountBps));
setEnabled(entry.enabled);
setSortOrder(String(entry.sortOrder));
}
@@ -200,10 +220,18 @@ export function AdminRechargeProductPage({
if (item.value === 'points') {
setTier('normal');
setDurationDays('0');
setMembershipPeriodPoints('0');
setMembershipPeriodDays('0');
setMembershipQueueLimit('0');
setMembershipDiscountBps('0');
} else {
setBonusPoints('0');
setPointsAmount('0');
setTier(tier === 'normal' ? 'month' : tier);
setTier(tier === 'normal' ? 'starter' : tier);
setDurationDays(durationDays === '0' ? '30' : durationDays);
setMembershipPeriodDays(
membershipPeriodDays === '0' ? '30' : membershipPeriodDays,
);
}
}}
>
@@ -256,32 +284,86 @@ export function AdminRechargeProductPage({
</label>
</div>
) : (
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<select
value={tier}
onChange={(event) =>
setTier(event.target.value as ProfileMembershipTier)
}
>
{membershipTiers.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={durationDays}
onChange={(event) => setDurationDays(event.target.value)}
/>
</label>
<div className="admin-stack">
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<select
value={tier}
onChange={(event) =>
setTier(event.target.value as ProfileMembershipTier)
}
>
{membershipTiers.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={durationDays}
onChange={(event) => setDurationDays(event.target.value)}
/>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={membershipPeriodPoints}
onChange={(event) =>
setMembershipPeriodPoints(event.target.value)
}
/>
</label>
<label className="admin-field">
<span></span>
<input
min={1}
step={1}
type="number"
value={membershipPeriodDays}
onChange={(event) =>
setMembershipPeriodDays(event.target.value)
}
/>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
min={0}
step={1}
type="number"
value={membershipQueueLimit}
onChange={(event) =>
setMembershipQueueLimit(event.target.value)
}
/>
</label>
<label className="admin-field">
<span> bps</span>
<input
min={0}
step={1}
type="number"
value={membershipDiscountBps}
onChange={(event) =>
setMembershipDiscountBps(event.target.value)
}
/>
</label>
</div>
</div>
)}
@@ -409,6 +491,18 @@ function formatProductKind(kind: ProfileRechargeProductKind) {
}
function formatTier(tier: ProfileMembershipTier) {
if (tier === 'starter') {
return 'Starter';
}
if (tier === 'basic') {
return 'Basic';
}
if (tier === 'pro') {
return 'Pro';
}
if (tier === 'ultimate') {
return 'Ultimate';
}
if (tier === 'month') {
return '月卡';
}
@@ -425,7 +519,7 @@ function formatProductContent(entry: ProfileRechargeProductConfigAdminResponse)
if (entry.kind === 'points') {
return `${entry.pointsAmount}+${entry.bonusPoints}`;
}
return `${formatTier(entry.tier)} ${entry.durationDays}`;
return `${formatTier(entry.tier)} ${entry.durationDays} · 每${entry.membershipPeriodDays}${entry.membershipPeriodPoints}泥点`;
}
function formatPrice(priceCents: number) {
@@ -0,0 +1,166 @@
/* @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 {
disableProfileRedeemCode,
listProfileRedeemCodes,
upsertProfileRedeemCode,
} from '../api/adminApiClient';
import type { ProfileRedeemCodeAdminResponse } from '../api/adminApiTypes';
import { AdminRedeemCodePage } from './AdminRedeemCodePage';
vi.mock('../api/adminApiClient', () => ({
disableProfileRedeemCode: vi.fn(),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
isAdminApiError: vi.fn(() => false),
listProfileRedeemCodes: vi.fn(),
upsertProfileRedeemCode: vi.fn(),
}));
const baseEntry: ProfileRedeemCodeAdminResponse = {
code: 'LONG-LIVED',
mode: 'public',
rewardPoints: 100,
maxUses: 1,
globalUsedCount: 0,
enabled: true,
allowedUserIds: [],
startsAt: null,
expiresAt: null,
createdBy: 'admin',
createdAt: '2026-07-13T01:00:00Z',
updatedAt: '2026-07-13T01:00:00Z',
};
const entries: ProfileRedeemCodeAdminResponse[] = [
baseEntry,
{
...baseEntry,
code: 'PENDING',
startsAt: '2999-01-01T00:00:00Z',
},
{
...baseEntry,
code: 'EXPIRED',
expiresAt: '2000-01-01T00:00:00Z',
},
{
...baseEntry,
code: 'ACTIVE',
startsAt: '2000-01-01T00:00:00Z',
expiresAt: '2999-01-01T00:00:00Z',
},
{
...baseEntry,
code: 'DISABLED',
enabled: false,
startsAt: '2000-01-01T00:00:00Z',
expiresAt: '2999-01-01T00:00:00Z',
},
];
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listProfileRedeemCodes).mockResolvedValue({
entries,
operations: [],
});
vi.mocked(upsertProfileRedeemCode).mockResolvedValue(baseEntry);
vi.mocked(disableProfileRedeemCode).mockResolvedValue({
...baseEntry,
enabled: false,
});
});
test('兑换码列表展示生效状态与日期范围', async () => {
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
expect((await rowForCode('LONG-LIVED')).textContent).toContain('长期有效');
expect((await rowForCode('LONG-LIVED')).textContent).toContain('立即 / 长期');
expect((await rowForCode('PENDING')).textContent).toContain('未生效');
expect((await rowForCode('EXPIRED')).textContent).toContain('已过期');
expect((await rowForCode('ACTIVE')).textContent).toContain('有效');
expect((await rowForCode('DISABLED')).textContent).toContain('停用');
});
test('点击兑换码回填本地日期输入', async () => {
const user = userEvent.setup();
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
await user.click(await screen.findByRole('button', { name: 'ACTIVE' }));
expect((screen.getByLabelText('开始时间') as HTMLInputElement).value).toBe(
toLocalInputValue('2000-01-01T00:00:00Z'),
);
expect((screen.getByLabelText('截止时间') as HTMLInputElement).value).toBe(
toLocalInputValue('2999-01-01T00:00:00Z'),
);
});
test('兑换码拒绝截止时间不晚于开始时间的配置', async () => {
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByRole('button', { name: 'LONG-LIVED' });
fireEvent.change(screen.getByLabelText('Code'), {
target: { value: 'INVALID' },
});
fireEvent.change(screen.getByLabelText('开始时间'), {
target: { value: '2026-07-13T10:00' },
});
fireEvent.change(screen.getByLabelText('截止时间'), {
target: { value: '2026-07-13T10:00' },
});
expect(screen.getByText('截止时间必须晚于开始时间')).toBeTruthy();
expect(
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(upsertProfileRedeemCode).not.toHaveBeenCalled();
});
test('兑换码保存时把本地时间转换为 ISO 并保留空边界', async () => {
const user = userEvent.setup();
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByRole('button', { name: 'LONG-LIVED' });
fireEvent.change(screen.getByLabelText('Code'), {
target: { value: 'WINDOWED' },
});
fireEvent.change(screen.getByLabelText('开始时间'), {
target: { value: '2026-07-13T10:30' },
});
await user.click(screen.getByRole('button', { name: '保存' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(upsertProfileRedeemCode).toHaveBeenCalledWith(
'admin-token',
expect.objectContaining({
code: 'WINDOWED',
startsAt: new Date('2026-07-13T10:30').toISOString(),
expiresAt: null,
}),
);
});
});
async function rowForCode(code: string) {
const button = await screen.findByRole('button', { name: code });
const row = button.closest('tr');
if (!row) {
throw new Error(`未找到兑换码 ${code} 所在行`);
}
return row;
}
function toLocalInputValue(value: string) {
const date = new Date(value);
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
}
@@ -34,6 +34,8 @@ export function AdminRedeemCodePage({
const [rewardPoints, setRewardPoints] = useState('100');
const [maxUses, setMaxUses] = useState('1');
const [enabled, setEnabled] = useState(true);
const [startsAt, setStartsAt] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [allowedUserIds, setAllowedUserIds] = useState('');
const [allowedPublicUserCodes, setAllowedPublicUserCodes] = useState('');
const [disableCode, setDisableCode] = useState('');
@@ -73,6 +75,12 @@ export function AdminRedeemCodePage({
}
setErrorMessage('');
const validityError = validateValidityWindow(startsAt, expiresAt);
if (validityError) {
setErrorMessage(validityError);
return;
}
const confirmed = await confirmWrite({
action: '保存兑换码',
target: code.trim(),
@@ -92,6 +100,8 @@ export function AdminRedeemCodePage({
allowedUserIds: mode === 'private' ? splitLines(allowedUserIds) : [],
allowedPublicUserCodes:
mode === 'private' ? splitLines(allowedPublicUserCodes) : [],
startsAt: startsAt ? toIsoDateTime(startsAt) : null,
expiresAt: expiresAt ? toIsoDateTime(expiresAt) : null,
});
fillForm(response);
await refreshRedeemCodes();
@@ -137,11 +147,15 @@ export function AdminRedeemCodePage({
setRewardPoints(String(entry.rewardPoints));
setMaxUses(String(entry.maxUses));
setEnabled(entry.enabled);
setStartsAt(toDateTimeLocalValue(entry.startsAt));
setExpiresAt(toDateTimeLocalValue(entry.expiresAt));
setAllowedUserIds(entry.allowedUserIds.join('\n'));
setAllowedPublicUserCodes('');
setDisableCode(entry.code);
}
const validityError = validateValidityWindow(startsAt, expiresAt);
return (
<section className="admin-page">
<div className="admin-page-heading">
@@ -222,6 +236,25 @@ export function AdminRedeemCodePage({
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
type="datetime-local"
value={startsAt}
onChange={(event) => setStartsAt(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
type="datetime-local"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
/>
</label>
</div>
{mode === 'private' ? (
<div className="admin-form-row">
<label className="admin-field">
@@ -250,6 +283,11 @@ export function AdminRedeemCodePage({
{errorMessage}
</div>
) : null}
{validityError && validityError !== errorMessage ? (
<div className="admin-alert" role="status">
{validityError}
</div>
) : null}
<button
className="admin-primary-button"
@@ -257,7 +295,8 @@ export function AdminRedeemCodePage({
isSaving ||
!code.trim() ||
!parsePositiveInteger(rewardPoints) ||
!parsePositiveInteger(maxUses)
!parsePositiveInteger(maxUses) ||
Boolean(validityError)
}
type="submit"
>
@@ -280,6 +319,7 @@ export function AdminRedeemCodePage({
<th>Code</th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
@@ -296,7 +336,16 @@ export function AdminRedeemCodePage({
<small>{redeemModeLabel(entry.mode)}</small>
</td>
<td>{entry.rewardPoints}</td>
<td>{entry.enabled ? '启用' : '停用'}</td>
<td>
<span
className={`admin-status ${redeemValidityClass(entry)}`}
>
{redeemValidityLabel(entry)}
</span>
</td>
<td>
<small>{formatValidityWindow(entry)}</small>
</td>
</tr>
))}
</tbody>
@@ -400,3 +449,76 @@ function formatDateTime(value: string) {
}
return date.toLocaleString('zh-CN', {hour12: false});
}
function validateValidityWindow(startsAt: string, expiresAt: string) {
if (!startsAt || !expiresAt) {
return '';
}
const startsAtTime = Date.parse(toIsoDateTime(startsAt));
const expiresAtTime = Date.parse(toIsoDateTime(expiresAt));
if (!Number.isFinite(startsAtTime) || !Number.isFinite(expiresAtTime)) {
return '有效期时间无效';
}
return startsAtTime < expiresAtTime ? '' : '截止时间必须晚于开始时间';
}
function toIsoDateTime(value: string) {
const time = Date.parse(value);
if (!Number.isFinite(time)) {
throw new Error('有效期时间无效');
}
return new Date(time).toISOString();
}
function toDateTimeLocalValue(value?: string | null) {
if (!value) {
return '';
}
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
return '';
}
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
}
function redeemValidityLabel(entry: ProfileRedeemCodeAdminResponse) {
if (!entry.enabled) {
return '停用';
}
const now = Date.now();
const startsAtTime = entry.startsAt ? Date.parse(entry.startsAt) : null;
const expiresAtTime = entry.expiresAt ? Date.parse(entry.expiresAt) : null;
if (startsAtTime !== null && Number.isFinite(startsAtTime) && now < startsAtTime) {
return '未生效';
}
if (expiresAtTime !== null && Number.isFinite(expiresAtTime) && now >= expiresAtTime) {
return '已过期';
}
if (entry.startsAt || entry.expiresAt) {
return '有效';
}
return '长期有效';
}
function redeemValidityClass(entry: ProfileRedeemCodeAdminResponse) {
const label = redeemValidityLabel(entry);
if (label === '停用' || label === '已过期') {
return 'admin-status-error';
}
if (label === '未生效') {
return 'admin-status-pending';
}
return 'admin-status-ok';
}
function formatValidityWindow(entry: ProfileRedeemCodeAdminResponse) {
const startsAt = entry.startsAt ? formatDateTime(entry.startsAt) : '立即';
const expiresAt = entry.expiresAt ? formatDateTime(entry.expiresAt) : '长期';
return `${startsAt} / ${expiresAt}`;
}
+149 -8
View File
@@ -121,17 +121,21 @@ button:disabled {
.admin-shell {
display: grid;
height: 100dvh;
min-height: 100dvh;
grid-template-columns: 232px minmax(0, 1fr);
overflow: hidden;
}
.admin-sidebar {
display: flex;
min-height: 0;
flex-direction: column;
gap: 24px;
border-right: 1px solid #e1ccbb;
background: #ffffff;
padding: 22px 18px;
overflow-y: auto;
}
.admin-brand strong {
@@ -176,7 +180,9 @@ button:disabled {
.admin-main {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: 64px minmax(0, 1fr);
overflow: hidden;
}
.admin-topbar {
@@ -205,6 +211,7 @@ button:disabled {
.admin-content {
min-width: 0;
min-height: 0;
padding: 24px;
overflow: auto;
}
@@ -394,7 +401,7 @@ button:disabled {
.admin-dashboard-bars {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(18px, 1fr);
grid-auto-columns: minmax(36px, 1fr);
align-items: end;
gap: 8px;
min-height: 196px;
@@ -404,7 +411,7 @@ button:disabled {
.admin-dashboard-bar-item {
display: grid;
min-width: 18px;
min-width: 36px;
gap: 7px;
align-items: end;
justify-items: center;
@@ -413,7 +420,8 @@ button:disabled {
.admin-dashboard-bar-track {
position: relative;
display: flex;
width: 100%;
width: 18px;
max-width: 100%;
min-width: 18px;
height: 160px;
align-items: flex-end;
@@ -430,10 +438,12 @@ button:disabled {
}
.admin-dashboard-bar-item small {
max-width: 48px;
width: 100%;
max-width: 44px;
overflow: hidden;
color: #8f7868;
font-size: 11px;
font-variant-numeric: tabular-nums;
font-weight: 700;
text-align: center;
text-overflow: ellipsis;
@@ -516,6 +526,12 @@ button:disabled {
align-items: end;
}
.admin-gate-key-selectors {
display: grid;
grid-template-columns: minmax(150px, 0.42fr) minmax(0, 1fr);
gap: 10px;
}
.admin-filter-grid {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr)) auto;
@@ -572,6 +588,7 @@ button:disabled {
border: 0;
background: transparent;
padding: 0;
cursor: zoom-in;
}
.admin-asset-query-thumb {
@@ -1110,17 +1127,20 @@ button:disabled {
}
.admin-asset-query-detail-dialog,
.admin-asset-query-prompt-dialog {
.admin-asset-query-prompt-dialog,
.admin-asset-query-preview-dialog {
width: min(100%, 860px);
}
.admin-asset-query-prompt-dialog .admin-panel-heading > div,
.admin-asset-query-detail-dialog .admin-panel-heading > div {
.admin-asset-query-detail-dialog .admin-panel-heading > div,
.admin-asset-query-preview-dialog .admin-panel-heading > div {
min-width: 0;
}
.admin-asset-query-prompt-dialog .admin-panel-heading span,
.admin-asset-query-detail-dialog .admin-panel-heading span {
.admin-asset-query-detail-dialog .admin-panel-heading span,
.admin-asset-query-preview-dialog .admin-panel-heading span {
display: block;
max-width: 100%;
margin-top: 4px;
@@ -1140,11 +1160,53 @@ button:disabled {
align-items: start;
}
.admin-asset-query-detail-layout > .admin-asset-query-thumb {
.admin-asset-query-detail-thumb-button .admin-asset-query-thumb {
width: 220px;
height: 220px;
}
.admin-asset-query-preview-dialog {
width: min(100%, 960px);
}
.admin-asset-query-preview-media {
display: block;
max-width: 100%;
max-height: min(72dvh, 680px);
margin: 0 auto;
border: 1px solid #eaded2;
border-radius: 8px;
background: #fffdf9;
object-fit: contain;
}
.admin-asset-query-preview-audio {
display: grid;
justify-items: center;
gap: 16px;
padding: 18px 0;
}
.admin-asset-query-preview-cover {
width: min(240px, 64vw);
aspect-ratio: 1;
border: 1px solid #eaded2;
border-radius: 8px;
background: #fffdf9;
object-fit: contain;
}
.admin-asset-query-preview-player {
width: min(100%, 620px);
}
.admin-asset-query-preview-placeholder {
min-height: 220px;
border: 1px dashed #dfc8b7;
border-radius: 8px;
background: #fffdf9;
}
.admin-database-table {
width: max-content;
min-width: 100%;
@@ -1165,6 +1227,30 @@ button:disabled {
max-width: 112px;
}
.admin-database-tables-page {
padding-bottom: calc(var(--admin-database-pagination-height, 68px) + 16px);
}
.admin-database-pagination {
position: fixed;
right: 0;
bottom: 0;
left: 232px;
z-index: 18;
justify-content: space-between;
border-top: 1px solid #eaded2;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 -8px 24px rgba(112, 57, 30, 0.08);
padding: 12px 24px calc(12px + env(safe-area-inset-bottom));
backdrop-filter: blur(10px);
}
.admin-database-pagination-info {
color: #755a49;
font-size: 13px;
font-weight: 700;
}
.admin-table-sort-button {
display: inline-flex;
align-items: center;
@@ -1515,6 +1601,10 @@ button:disabled {
max-width: none;
}
.admin-gate-key-selectors {
grid-template-columns: 1fr;
}
.admin-bottom-nav {
position: fixed;
right: 0;
@@ -1529,6 +1619,20 @@ button:disabled {
backdrop-filter: blur(10px);
}
.admin-database-pagination {
right: 0;
bottom: var(--admin-bottom-nav-height, 64px);
left: 0;
padding: 10px 14px;
}
.admin-database-tables-page {
padding-bottom: calc(
var(--admin-bottom-nav-height, 64px) +
var(--admin-database-pagination-height, 58px) + 16px
);
}
.admin-bottom-nav-button {
display: grid;
gap: 4px;
@@ -1545,6 +1649,18 @@ button:disabled {
}
}
@media (max-width: 360px) {
.admin-database-pagination .admin-secondary-button {
width: 38px;
min-width: 38px;
padding: 0;
}
.admin-database-pagination .admin-secondary-button span {
display: none;
}
}
@media (max-width: 560px) {
.admin-login-panel,
.admin-panel {
@@ -1601,4 +1717,29 @@ button:disabled {
.admin-icon-button span {
display: none;
}
.admin-database-pagination,
.admin-database-pagination .admin-action-row {
flex-wrap: nowrap;
}
.admin-database-pagination {
gap: 8px;
padding: 10px;
}
.admin-database-pagination .admin-action-row {
gap: 6px;
}
.admin-database-pagination .admin-secondary-button {
gap: 4px;
min-height: 38px;
padding: 0 8px;
}
.admin-database-pagination-info {
font-size: 12px;
white-space: nowrap;
}
}
+33
View File
@@ -0,0 +1,33 @@
import fs from 'node:fs';
import path from 'node:path';
import { describe, expect, test } from 'vitest';
const stylesheet = fs.readFileSync(
path.resolve(process.cwd(), 'apps/admin-web/src/styles/admin.css'),
'utf8',
);
describe('admin shell scrolling contract', () => {
test('desktop shell keeps sidebar separate from the scrollable content', () => {
expect(ruleFor('.admin-shell')).toContain('\n height: 100dvh;');
expect(ruleFor('.admin-shell')).toContain('overflow: hidden');
expect(ruleFor('.admin-sidebar')).toContain('min-height: 0');
expect(ruleFor('.admin-sidebar')).toContain('overflow-y: auto');
expect(ruleFor('.admin-main')).toContain('min-height: 0');
expect(ruleFor('.admin-main')).toContain('overflow: hidden');
expect(ruleFor('.admin-content')).toContain('min-height: 0');
expect(ruleFor('.admin-content')).toContain('overflow: auto');
});
});
function ruleFor(selector: string) {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = stylesheet.match(
new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`),
);
if (!match) {
throw new Error(`Missing CSS rule for ${selector}`);
}
return match[1];
}
+2 -1
View File
@@ -7,7 +7,7 @@ import {defineConfig, loadEnv} from 'vite';
const adminWebRoot = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(adminWebRoot, '../..');
export default defineConfig(({mode}) => {
export default defineConfig(({command, mode}) => {
const repoEnv = loadEnv(mode, repoRoot, '');
const appEnv = loadEnv(mode, adminWebRoot, '');
const env = {...repoEnv, ...appEnv};
@@ -27,6 +27,7 @@ export default defineConfig(({mode}) => {
return {
root: adminWebRoot,
publicDir: command === 'serve' ? resolve(repoRoot, 'public') : false,
envDir: repoRoot,
base,
plugins: [react()],
+2 -2
View File
@@ -57,7 +57,7 @@ Linux Docker Engine 若要从宿主机 CLI 连到容器内服务,直接用 `ht
## 构建工具链
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.4.1` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
`api-server` 容器镜像只构建 Linux release API 二进制,不构建 `spacetime-module`。当前 `api-server -> spacetime-client -> spacetimedb-sdk 2.6.0` 依赖链要求 Rust 1.93,因此 `deploy/container/api-server.Dockerfile` 的 Rust builder 固定为 `rust:1.93-bookworm`。镜像构建阶段会同时复制 `public/`,用于满足 API 二进制里 `include_bytes!` 引用的内置素材;不要把 `public/generated-*` 放入镜像上下文。如果本机 Docker Hub 拉取失败,可以先在本机准备同名本地 builder 镜像,但不要把临时 bootstrap 容器或私有 registry 凭据写入仓库。
## 启动与验证
@@ -127,7 +127,7 @@ npm run container:worker-smoke -- status
npm run container:worker-smoke -- smoke --force
```
`container:worker-smoke` 默认会把本机 `spacetime` 2.4.1 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测默认使用 `clockworklabs/spacetime:v2.4.1`。如果 Docker build 阶段在容器内拉取 crates.io 依赖不稳定,可让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入临时 smoke 镜像。该模式默认使用 `rust:1.93-bookworm` 作为 builder、Debian bookworm smoke runtime 承载构建产物;需要换 builder 镜像时设置 `GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE`,需要换运行时基础镜像时设置 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE`
`container:worker-smoke` 默认会把本机 `spacetime` 2.6.0 CLI 打成轻量 SpacetimeDB 镜像,避免首次 smoke 必须拉取官方大镜像;普通 `npm run container:*` 压测默认使用 `clockworklabs/spacetime:v2.6.0`。如果 Docker build 阶段在容器内拉取 crates.io 依赖不稳定,可让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入临时 smoke 镜像。该模式默认使用 `rust:1.93-bookworm` 作为 builder、Debian bookworm smoke runtime 承载构建产物;需要换 builder 镜像时设置 `GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE`,需要换运行时基础镜像时设置 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE`
```bash
npm run container:worker-smoke -- smoke --local-binary

Some files were not shown because too many files have changed in this diff Show More