Merge remote-tracking branch 'refs/remotes/origin/codex/ai-game-creator-app' into ai-game-creator-app-home-sidebar

# Conflicts:
#	apps/ai-game-creator-shell/src-tauri/Cargo.lock
#	apps/ai-game-creator-shell/src-tauri/Cargo.toml
#	apps/ai-game-creator-shell/src-tauri/src/main.rs
#	apps/ai-game-creator-shell/src/App.tsx
#	apps/ai-game-creator-shell/tests/appSurface.test.ts
#	docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
This commit is contained in:
2026-07-16 19:06:03 +08:00
589 changed files with 246178 additions and 8206 deletions
+31 -6
View File
@@ -14,20 +14,40 @@ if (hookInput && !isGitCommitCommand(extractShellCommand(hookInput))) {
}
const validationSteps = [
{
label: 'Rust format check',
command: npmCommand,
args:
process.platform === 'win32'
? ['/d', '/s', '/c', 'npm run check:rustfmt']
: ['run', 'check:rustfmt'],
},
{
label: 'TypeScript typecheck',
command: npmCommand,
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run typecheck'] : ['run', 'typecheck'],
args:
process.platform === 'win32'
? ['/d', '/s', '/c', 'npm run typecheck']
: ['run', 'typecheck'],
},
{
label: 'Admin web typecheck',
command: npmCommand,
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run admin-web:typecheck'] : ['run', 'admin-web:typecheck'],
args:
process.platform === 'win32'
? ['/d', '/s', '/c', 'npm run admin-web:typecheck']
: ['run', 'admin-web:typecheck'],
},
{
label: 'Rust api-server compile check',
command: 'cargo',
args: ['check', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'],
args: [
'check',
'-p',
'api-server',
'--manifest-path',
'server-rs/Cargo.toml',
],
},
];
@@ -66,7 +86,9 @@ function runStep(step) {
}
if (result.error) {
console.error(`[codex-hook] ${step.label} 启动失败:${result.error.message}`);
console.error(
`[codex-hook] ${step.label} 启动失败:${result.error.message}`,
);
return { ok: false, status: 1 };
}
@@ -104,12 +126,15 @@ function extractShellCommand(input) {
input?.command,
];
const command = candidates.find(value => typeof value === 'string' && value.trim().length > 0);
const command = candidates.find(
(value) => typeof value === 'string' && value.trim().length > 0,
);
if (command) {
return command;
}
const shellCommand = input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd;
const shellCommand =
input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd;
if (Array.isArray(shellCommand)) {
return shellCommand.join(' ');
}
+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"
@@ -0,0 +1,174 @@
import { afterEach, expect, test, vi } from 'vitest';
import {
createAdminAccount,
executeAdminRechargeRefund,
getAdminUserDetail,
listAdminRechargeOrders,
resolveAdminRechargeRefundManualReview,
updateAdminAccount,
} from './adminApiClient';
afterEach(() => {
vi.unstubAllGlobals();
});
test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({account: {accountId: 'member-1'}}), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await createAdminAccount('owner-token', {
username: 'operator',
displayName: '运营',
password: 'secret123',
tabPermissions: ['dashboard', 'tracking'],
enabled: true,
});
await updateAdminAccount('owner-token', 'member/1', {
displayName: '运营二组',
tabPermissions: ['tracking'],
enabled: false,
});
expect(fetchMock.mock.calls[0]?.[0]).toBe('/admin/api/accounts');
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({Authorization: 'Bearer owner-token'}),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1');
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({
displayName: '运营二组',
tabPermissions: ['tracking'],
enabled: false,
}),
}),
);
});
test('充值订单查询按后台契约序列化筛选参数', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ entries: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
await listAdminRechargeOrders('token-1', {
orderId: 'order 1',
userId: 'user-1',
providerTransactionId: 'wx-1',
paymentChannel: 'wechat_native',
status: 'paid',
createdAfter: '2026-07-01T00:00:00.000Z',
createdBefore: '2026-07-13T23:59:00.000Z',
limit: 50,
});
const requestUrl = String(fetchMock.mock.calls[0]?.[0]);
const parsed = new URL(requestUrl, 'http://admin.local');
expect(parsed.pathname).toBe('/admin/api/profile/recharge-orders');
expect(Object.fromEntries(parsed.searchParams)).toEqual({
orderId: 'order 1',
providerTransactionId: 'wx-1',
userId: 'user-1',
paymentChannel: 'wechat_native',
status: 'paid',
createdAfter: '2026-07-01T00:00:00.000Z',
createdBefore: '2026-07-13T23:59:00.000Z',
limit: '50',
});
});
test('用户详情只发送实际提供的用户定位字段', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ userId: 'user-1' }), { status: 200 }),
);
vi.stubGlobal('fetch', fetchMock);
await getAdminUserDetail('token-1', { publicUserCode: 'TN1001' });
const requestUrl = String(fetchMock.mock.calls[0]?.[0]);
const parsed = new URL(requestUrl, 'http://admin.local');
expect(parsed.pathname).toBe('/admin/api/profile/users/detail');
expect(Object.fromEntries(parsed.searchParams)).toEqual({
publicUserCode: 'TN1001',
});
});
test('退款执行使用独立 execute 管理员路由', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
await executeAdminRechargeRefund('token-1', {
orderId: 'order-1',
refundAmountCents: 300,
requestId: 'request-1',
reason: '用户申请',
});
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
'/admin/api/profile/recharge-refunds/execute',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
orderId: 'order-1',
refundAmountCents: 300,
requestId: 'request-1',
reason: '用户申请',
}),
}),
);
});
test('退款人工复核使用独立 resolve 管理员路由', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
await resolveAdminRechargeRefundManualReview('token-1', {
outRefundNo: 'refund-1',
reason: '已核对微信商户平台原始账单',
expectedErrorCode: 'provider_transaction_id_mismatch',
});
expect(String(fetchMock.mock.calls[0]?.[0])).toBe(
'/admin/api/profile/recharge-refunds/manual-review/resolve',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
outRefundNo: 'refund-1',
reason: '已核对微信商户平台原始账单',
expectedErrorCode: 'provider_transaction_id_mismatch',
}),
}),
);
});
+174 -3
View File
@@ -1,4 +1,7 @@
import type {
AdminAccountListResponse,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
AdminUpsertCreationEntryEventBannersRequest,
AdminUpsertCreationEntryTypeConfigRequest,
AdminCreationEntryConfigResponse,
@@ -22,16 +25,28 @@ import type {
AdminEditorShowcaseListQuery,
AdminEditorShowcaseListResponse,
AdminEditorShowcaseReviewRequest,
AdminFeatureGateConfigResponse,
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminRechargeOrderListQuery,
AdminRechargeOrderListResponse,
AdminRechargeRefundActionResponse,
AdminRechargeRefundExecuteRequest,
AdminRechargeRefundManualReviewResolveRequest,
AdminRechargeRefundPreviewRequest,
AdminRechargeRefundPreviewResponse,
AdminRechargeRefundRegisterRequest,
AdminTrackingEventListQuery,
AdminTrackingEventKeyListResponse,
AdminTrackingEventListResponse,
AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse,
AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
AdminUpsertProfileInviteCodeRequest,
AdminUpsertProfileRechargeProductRequest,
AdminUpsertProfileRedeemCodeRequest,
@@ -39,6 +54,10 @@ import type {
AdminUpsertProfileWalletConfigRequest,
AdminUpsertPublicWorkInteractionConfigRequest,
AdminWorkVisibilityListResponse,
AdminUserDetailQuery,
AdminUserDetailResponse,
AdminWalletRestrictionRequest,
AdminWalletRestrictionResponse,
ApiErrorEnvelope,
ApiMeta,
ApiSuccessEnvelope,
@@ -174,6 +193,32 @@ export function getAdminMe(token: string) {
return request<AdminMeResponse>('/admin/api/me', { token });
}
export function listAdminAccounts(token: string) {
return request<AdminAccountListResponse>('/admin/api/accounts', {token});
}
export function createAdminAccount(
token: string,
payload: AdminCreateAccountRequest,
) {
return request<AdminCreateAccountResponse>('/admin/api/accounts', {
method: 'POST',
token,
body: payload,
});
}
export function updateAdminAccount(
token: string,
accountId: string,
payload: AdminUpdateAccountRequest,
) {
return request<AdminUpdateAccountResponse>(
`/admin/api/accounts/${encodeURIComponent(accountId)}`,
{method: 'PUT', token, body: payload},
);
}
export function getAdminOverview(token: string) {
return request<AdminOverviewResponse>('/admin/api/overview', { token });
}
@@ -230,6 +275,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,19 +385,24 @@ 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 },
);
}
export function listAdminEditorAssets(
token: string,
query: AdminEditorAssetListQuery = {},
signal?: AbortSignal,
) {
return request<AdminEditorAssetListResponse>(
`/admin/api/editor-assets${buildEditorAssetListQuery(query)}`,
{ token },
{ token, signal },
);
}
@@ -557,6 +624,76 @@ export function upsertProfileRechargeProduct(
);
}
export function listAdminRechargeOrders(
token: string,
query: AdminRechargeOrderListQuery = {},
) {
return request<AdminRechargeOrderListResponse>(
`/admin/api/profile/recharge-orders${buildAdminRechargeOrderListQuery(query)}`,
{token},
);
}
export function getAdminUserDetail(
token: string,
query: AdminUserDetailQuery,
) {
return request<AdminUserDetailResponse>(
`/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`,
{token},
);
}
export function previewAdminRechargeRefund(
token: string,
payload: AdminRechargeRefundPreviewRequest,
) {
return request<AdminRechargeRefundPreviewResponse>(
'/admin/api/profile/recharge-refunds/preview',
{method: 'POST', token, body: payload},
);
}
export function executeAdminRechargeRefund(
token: string,
payload: AdminRechargeRefundExecuteRequest,
) {
return request<AdminRechargeRefundActionResponse>(
'/admin/api/profile/recharge-refunds/execute',
{method: 'POST', token, body: payload},
);
}
export function registerAdminRechargeRefund(
token: string,
payload: AdminRechargeRefundRegisterRequest,
) {
return request<AdminRechargeRefundActionResponse>(
'/admin/api/profile/recharge-refunds/register',
{method: 'POST', token, body: payload},
);
}
export function resolveAdminRechargeRefundManualReview(
token: string,
payload: AdminRechargeRefundManualReviewResolveRequest,
) {
return request<AdminRechargeRefundActionResponse>(
'/admin/api/profile/recharge-refunds/manual-review/resolve',
{method: 'POST', token, body: payload},
);
}
export function updateAdminWalletRestriction(
token: string,
payload: AdminWalletRestrictionRequest,
) {
return request<AdminWalletRestrictionResponse>(
'/admin/api/profile/wallet-restriction',
{method: 'POST', token, body: payload},
);
}
function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
@@ -724,6 +861,35 @@ function buildQueryString(query: AdminTrackingEventListQuery) {
return queryString ? `?${queryString}` : '';
}
function buildAdminRechargeOrderListQuery(query: AdminRechargeOrderListQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'orderId', query.orderId);
appendQueryParam(
params,
'providerTransactionId',
query.providerTransactionId,
);
appendQueryParam(params, 'userId', query.userId);
appendQueryParam(params, 'publicUserCode', query.publicUserCode);
appendQueryParam(params, 'paymentChannel', query.paymentChannel);
appendQueryParam(params, 'status', query.status);
appendQueryParam(params, 'createdAfter', query.createdAfter);
appendQueryParam(params, 'createdBefore', query.createdBefore);
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
params.set('limit', String(query.limit));
}
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function buildAdminUserDetailQuery(query: AdminUserDetailQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'userId', query.userId);
appendQueryParam(params, 'publicUserCode', query.publicUserCode);
const queryString = params.toString();
return queryString ? `?${queryString}` : '';
}
function buildDashboardQuery(query: AdminDashboardQuery) {
const params = new URLSearchParams();
appendQueryParam(params, 'granularity', query.granularity);
@@ -741,6 +907,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}` : '';
}
+304 -1
View File
@@ -36,10 +36,53 @@ export interface AdminSessionPayload {
username: string;
displayName: string;
roles: string[];
accountRole: 'owner' | 'member';
tabPermissions: string[];
issuedAt: string;
expiresAt: string;
}
export interface AdminAccountPayload {
accountId: string;
username: string;
displayName: string;
accountRole: 'owner' | 'member';
tabPermissions: string[];
enabled: boolean;
tokenVersion: number;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
export interface AdminAccountListResponse {
accounts: AdminAccountPayload[];
}
export interface AdminCreateAccountRequest {
username: string;
displayName: string;
password: string;
tabPermissions: string[];
enabled: boolean;
}
export interface AdminCreateAccountResponse {
account: AdminAccountPayload;
}
export interface AdminUpdateAccountRequest {
displayName: string;
password?: string;
tabPermissions: string[];
enabled: boolean;
}
export interface AdminUpdateAccountResponse {
account: AdminAccountPayload;
}
export interface AdminLoginResponse {
token: string;
admin: AdminSessionPayload;
@@ -85,6 +128,9 @@ export interface AdminDashboardMetricsPayload {
consumedMudPoints: number;
totalRegisteredUsers: number;
newRegisteredUsers: number;
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
day1Retention: AdminDashboardRetentionMetricPayload;
day7Retention: AdminDashboardRetentionMetricPayload;
visitUsers: number;
totalVisitUsers: number;
visitCount: number;
@@ -92,6 +138,18 @@ export interface AdminDashboardMetricsPayload {
currentUsers: number;
}
export interface AdminDashboardPaymentConversionPayload {
paidUsers: number;
newRegisteredUsers: number;
rateBasisPoints: number;
}
export interface AdminDashboardRetentionMetricPayload {
eligibleUsers: number;
retainedUsers: number;
rateBasisPoints: number;
}
export interface AdminDashboardChartPayload {
id: string;
title: string;
@@ -150,8 +208,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 +226,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 +265,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 +286,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[];
@@ -373,6 +467,7 @@ export interface AdminEditorAssetPayload {
model?: string | null;
provider?: string | null;
taskId?: string | null;
groupTaskId?: string | null;
assetKind?: string | null;
generationInputs?: Record<string, unknown> | null;
sourceResourceId?: string | null;
@@ -380,6 +475,10 @@ export interface AdminEditorAssetPayload {
generationCostMudPoints: number;
createdAt: string;
updatedAt: string;
generator: string;
taskGenerator: string;
taskCostMudPoints: number;
children: AdminEditorAssetPayload[];
}
export interface AdminEditorAssetListResponse {
@@ -517,6 +616,8 @@ export interface AdminUpsertProfileRedeemCodeRequest {
enabled: boolean;
allowedUserIds: string[];
allowedPublicUserCodes: string[];
startsAt?: string | null;
expiresAt?: string | null;
}
export interface AdminUpsertProfileInviteCodeRequest {
@@ -558,6 +659,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 +679,8 @@ export interface ProfileRedeemCodeAdminResponse {
globalUsedCount: number;
enabled: boolean;
allowedUserIds: string[];
startsAt?: string | null;
expiresAt?: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
@@ -585,6 +692,7 @@ export interface ProfileCodeOperationAdminResponse {
code: string;
action: 'create' | 'update' | 'disable' | string;
operatorUserId: string;
operatorDisplayName: string;
createdAt: string;
}
@@ -621,8 +729,10 @@ export interface ProfileTaskConfigAdminResponse {
enabled: boolean;
sortOrder: number;
createdBy: string;
createdByDisplayName: string;
createdAt: string;
updatedBy: string;
updatedByDisplayName: string;
updatedAt: string;
}
@@ -641,6 +751,10 @@ export interface ProfileRechargeProductConfigAdminResponse {
badgeLabel: string;
description: string;
tier: ProfileMembershipTier;
membershipPeriodPoints: number;
membershipPeriodDays: number;
membershipQueueLimit: number;
membershipDiscountBps: number;
enabled: boolean;
sortOrder: number;
createdBy: string;
@@ -657,8 +771,10 @@ export interface ProfileWalletConfigAdminResponse {
configId: string;
initialMudPoints: number;
createdBy: string;
createdByDisplayName: string;
createdAt: string;
updatedBy: string;
updatedByDisplayName: string;
updatedAt: string;
}
@@ -690,3 +806,190 @@ export interface AdminTrackingEventKeyPayload {
export interface AdminTrackingEventKeyListResponse {
eventKeys: AdminTrackingEventKeyPayload[];
}
export interface AdminRechargeOrderListQuery {
orderId?: string;
providerTransactionId?: string;
userId?: string;
publicUserCode?: string;
paymentChannel?: string;
status?: string;
createdAfter?: string;
createdBefore?: string;
limit?: number;
}
export interface AdminUserSummaryPayload {
userId: string;
publicUserCode: string;
displayName: string;
avatarUrl?: string | null;
}
export interface AdminWalletManualRestrictionPayload {
frozen: boolean;
reason: string;
createdByAdminUserId: string;
createdByAdminDisplayName: string;
createdAtMicros: number;
updatedByAdminUserId: string;
updatedByAdminDisplayName: string;
updatedAtMicros: number;
}
export interface AdminProfileWalletPayload {
userId: string;
totalBalance: number;
spendableBalance: number;
dailyFreePoints: number;
membershipLimitedPoints: number;
permanentPoints: number;
heldPoints: number;
refundDebtPoints: number;
manualFrozen: boolean;
refundDebtFrozen: boolean;
walletFrozen: boolean;
manualRestriction?: AdminWalletManualRestrictionPayload | null;
}
export interface AdminRechargeRefundPayload {
outRefundNo: string;
providerRefundId: string;
providerTransactionId: string;
providerStatus: string;
totalCents: number;
refundCents: number;
payerRefundCents: number;
successAtMicros?: number | null;
firstObservedAtMicros: number;
updatedAtMicros: number;
observationSource: string;
targetRecoveryPoints: number;
recoveredPoints: number;
unrecoveredPoints: number;
recoveryStatus: string;
lastErrorCode?: string | null;
manualReviewResolvedByAdminUserId?: string | null;
manualReviewResolutionReason?: string | null;
manualReviewResolvedAtMicros?: number | null;
manualReviewResolvedErrorCode?: string | null;
}
export interface AdminRechargeRefundHoldPayload {
outRefundNo: string;
refundCents: number;
heldPoints: number;
status: string;
adminUserId: string;
reason: string;
createdAtMicros: number;
updatedAtMicros: number;
}
export interface AdminRechargeOrderEntryPayload {
orderId: string;
userId: string;
user?: AdminUserSummaryPayload | null;
productId: string;
productTitle: string;
productKind: string;
amountCents: number;
status: string;
paymentChannel: string;
paidAtMicros?: number | null;
providerTransactionId?: string | null;
createdAtMicros: number;
pointsDelta: number;
cumulativeSuccessRefundCents: number;
targetRecoveryPoints: number;
recoveredPoints: number;
unrecoveredPoints: number;
recoveryStatus?: string | null;
wallet: AdminProfileWalletPayload;
refunds: AdminRechargeRefundPayload[];
activeHold?: AdminRechargeRefundHoldPayload | null;
remainingRefundableCents: number;
refundEligible: boolean;
refundBlockReasonCode?: string | null;
}
export interface AdminRechargeOrderListResponse {
entries: AdminRechargeOrderEntryPayload[];
}
export interface AdminUserDetailQuery {
userId?: string;
publicUserCode?: string;
}
export interface AdminUserDetailResponse {
userId: string;
publicUserCode: string;
displayName: string;
avatarUrl?: string | null;
phoneNumberMasked?: string | null;
loginMethod: string;
bindingStatus: string;
phoneBound: boolean;
wechatBound: boolean;
wallet: AdminProfileWalletPayload;
rechargeOrders: AdminRechargeOrderEntryPayload[];
}
export interface AdminRechargeRefundPreviewRequest {
orderId: string;
refundAmountCents: number;
}
export interface AdminRechargeRefundExecuteRequest {
orderId: string;
refundAmountCents: number;
requestId: string;
reason?: string | null;
}
export interface AdminRechargeRefundRegisterRequest {
outRefundNo: string;
}
export interface AdminRechargeRefundManualReviewResolveRequest {
outRefundNo: string;
reason: string;
expectedErrorCode: string;
}
export interface AdminWalletRestrictionRequest {
userId: string;
frozen: boolean;
reason: string;
}
export interface AdminWechatPaymentCheckPayload {
verified: boolean;
tradeState: string;
transactionId?: string | null;
amountTotalCents?: number | null;
knownRefundsRefreshed: number;
}
export interface AdminRechargeRefundPreviewResponse {
order: AdminRechargeOrderEntryPayload;
paymentCheck: AdminWechatPaymentCheckPayload;
refundAmountCents: number;
incrementalRecoveryPoints: number;
remainingRefundableCents: number;
canSubmit: boolean;
blockReasonCode?: string | null;
}
export interface AdminRechargeRefundActionResponse {
outRefundNo: string;
providerStatus: string;
resultCode: string;
providerStatusUnknown: boolean;
order: AdminRechargeOrderEntryPayload;
}
export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
+78 -19
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useState} from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {
formatAdminApiError,
@@ -18,6 +18,7 @@ import {
setStoredAdminToken,
} from '../auth/adminAuthStore';
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
import {AdminAccountsPage} from '../pages/AdminAccountsPage';
import {AdminDashboardPage} from '../pages/AdminDashboardPage';
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
@@ -26,16 +27,23 @@ 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';
import {AdminRechargeOrderPage} from '../pages/AdminRechargeOrderPage';
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
import {AdminWorkVisibilityPage} from '../pages/AdminWorkVisibilityPage';
import {AdminShell} from './AdminShell';
import type {AdminRouteId} from './adminRoutes';
import {resolveAdminRoute, routeHash} from './adminRoutes';
import {
getAccessibleAdminRoutes,
resolveAccessibleAdminRoute,
resolveAdminRoute,
routeHash,
} from './adminRoutes';
type SessionStatus = 'checking' | 'guest' | 'authenticated';
@@ -53,6 +61,13 @@ export function AdminApp() {
useState<ProfileWalletConfigAdminResponse | null>(null);
const [rechargeProductResult, setRechargeProductResult] =
useState<ProfileRechargeProductConfigAdminResponse | null>(null);
const accessibleRoutes = useMemo(
() => (admin ? getAccessibleAdminRoutes(admin) : []),
[admin],
);
const activeRouteId = accessibleRoutes.some((route) => route.id === routeId)
? routeId
: null;
const clearSession = useCallback((message = '') => {
clearStoredAdminToken();
@@ -105,6 +120,26 @@ export function AdminApp() {
};
}, []);
useEffect(() => {
if (status !== 'authenticated' || !admin) {
return;
}
const nextRouteId = resolveAccessibleAdminRoute(
window.location.hash,
accessibleRoutes,
);
if (!nextRouteId) {
return;
}
setRouteId(nextRouteId);
const nextHash = routeHash(nextRouteId);
if (window.location.hash !== nextHash) {
window.history.replaceState(null, '', nextHash);
}
}, [accessibleRoutes, admin, routeId, status]);
useEffect(() => {
const handleHashChange = () => {
setRouteId(resolveAdminRoute(window.location.hash));
@@ -161,63 +196,75 @@ export function AdminApp() {
return (
<AdminShell
admin={admin}
routeId={routeId}
routeId={activeRouteId}
routes={accessibleRoutes}
onLogout={handleLogout}
onRouteChange={handleRouteChange}
>
{routeId === 'dashboard' ? (
{activeRouteId === null ? (
<section className="admin-panel admin-zero-permission-state">
<h2>访</h2>
</section>
) : null}
{activeRouteId === 'dashboard' ? (
<AdminDashboardPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'overview' ? (
{activeRouteId === 'overview' ? (
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'tables' ? (
{activeRouteId === 'tables' ? (
<AdminDatabaseTablesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'debug' ? (
{activeRouteId === 'debug' ? (
<AdminDebugHttpPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'tracking' ? (
{activeRouteId === 'tracking' ? (
<AdminTrackingEventsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'redeem' ? (
{activeRouteId === 'gray-release' ? (
<AdminGrayReleaseConfigPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'redeem' ? (
<AdminRedeemCodePage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'invite' ? (
{activeRouteId === 'invite' ? (
<AdminInviteCodePage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'creation-announcement' ? (
{activeRouteId === 'creation-announcement' ? (
<AdminCreationEntrySwitchPage
mode="announcements"
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'creation-entry' ? (
{activeRouteId === 'creation-entry' ? (
<AdminCreationEntrySwitchPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'work-visibility' ? (
{activeRouteId === 'work-visibility' ? (
<AdminWorkVisibilityPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'tasks' ? (
{activeRouteId === 'tasks' ? (
<AdminTaskConfigPage
result={taskConfigResult}
token={token}
@@ -225,7 +272,7 @@ export function AdminApp() {
onResultChange={setTaskConfigResult}
/>
) : null}
{routeId === 'profile-wallet' ? (
{activeRouteId === 'profile-wallet' ? (
<AdminProfileWalletConfigPage
result={profileWalletConfigResult}
token={token}
@@ -233,7 +280,7 @@ export function AdminApp() {
onResultChange={setProfileWalletConfigResult}
/>
) : null}
{routeId === 'recharge-products' ? (
{activeRouteId === 'recharge-products' ? (
<AdminRechargeProductPage
result={rechargeProductResult}
token={token}
@@ -241,24 +288,36 @@ export function AdminApp() {
onResultChange={setRechargeProductResult}
/>
) : null}
{routeId === 'editor-generation-pricing' ? (
{activeRouteId === 'recharge-orders' ? (
<AdminRechargeOrderPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-generation-pricing' ? (
<AdminEditorGenerationPricingPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-showcase' ? (
{activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-assets' ? (
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'accounts' ? (
<AdminAccountsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
</AdminShell>
);
}
+13 -6
View File
@@ -7,6 +7,7 @@ import {
LogOut,
Megaphone,
Eye,
GitBranch,
Images,
Star,
WalletCards,
@@ -17,16 +18,18 @@ import {
Table2,
TicketCheck,
TicketPercent,
ReceiptText,
Users,
} from 'lucide-react';
import type {ReactNode} from 'react';
import type {AdminSessionPayload} from '../api/adminApiTypes';
import type {AdminRouteId} from './adminRoutes';
import {adminRoutes} from './adminRoutes';
import type {AdminRouteDefinition, AdminRouteId} from './adminRoutes';
interface AdminShellProps {
admin: AdminSessionPayload;
routeId: AdminRouteId;
routeId: AdminRouteId | null;
routes: AdminRouteDefinition[];
children: ReactNode;
onRouteChange: (routeId: AdminRouteId) => void;
onLogout: () => void;
@@ -38,22 +41,26 @@ const routeIcons = {
tables: Database,
debug: Bug,
tracking: Table2,
'gray-release': GitBranch,
redeem: TicketPercent,
invite: TicketCheck,
'profile-wallet': WalletCards,
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'editor-assets': Images,
'creation-announcement': Megaphone,
'creation-entry': SlidersHorizontal,
'work-visibility': Eye,
accounts: Users,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
admin,
routeId,
routes,
children,
onRouteChange,
onLogout,
@@ -72,7 +79,7 @@ export function AdminShell({
</div>
<nav className="admin-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
{routes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
@@ -95,7 +102,7 @@ export function AdminShell({
<header className="admin-topbar">
<div className="admin-user">
<span>{admin.displayName || admin.username}</span>
<small>{admin.roles.join(' / ')}</small>
<small>{admin.accountRole === 'owner' ? 'owner' : 'member'}</small>
</div>
<button
className="admin-icon-button"
@@ -112,7 +119,7 @@ export function AdminShell({
</div>
<nav className="admin-bottom-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
{routes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
+60 -1
View File
@@ -1,6 +1,12 @@
import {expect, test} from 'vitest';
import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes';
import {
adminRoutes,
getAccessibleAdminRoutes,
resolveAccessibleAdminRoute,
resolveAdminRoute,
routeHash,
} from './adminRoutes';
test('后台默认进入 Dashboard', () => {
expect(adminRoutes[0]).toEqual({
@@ -40,6 +46,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',
@@ -59,3 +75,46 @@ test('后台精选审核路由可通过导航和 hash 访问', () => {
expect(resolveAdminRoute('#editor-showcase')).toBe('editor-showcase');
expect(routeHash('editor-showcase')).toBe('#editor-showcase');
});
test('后台充值管理路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'recharge-orders',
label: '充值管理',
hash: '#recharge-orders',
});
expect(resolveAdminRoute('#recharge-orders')).toBe('recharge-orders');
expect(routeHash('recharge-orders')).toBe('#recharge-orders');
});
test('owner 可访问全部业务 Tab 和账号管理', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'owner',
tabPermissions: [],
});
expect(routes).toEqual(adminRoutes);
expect(routes.at(-1)).toMatchObject({id: 'accounts', ownerOnly: true});
});
test('member 只访问已分配 Tab 且无权 hash 回落到第一项', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking', 'recharge-orders'],
});
expect(routes.map((route) => route.id)).toEqual([
'tracking',
'recharge-orders',
]);
expect(resolveAccessibleAdminRoute('#accounts', routes)).toBe('tracking');
expect(resolveAccessibleAdminRoute('#recharge-orders', routes)).toBe(
'recharge-orders',
);
});
test('零权限 member 不回落到 Dashboard', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: [],
});
expect(routes).toEqual([]);
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
});
+40 -1
View File
@@ -5,23 +5,29 @@ export type AdminRouteId =
| 'tables'
| 'debug'
| 'tracking'
| 'gray-release'
| 'redeem'
| 'invite'
| 'profile-wallet'
| 'tasks'
| 'recharge-products'
| 'recharge-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'editor-assets'
| 'creation-announcement'
| 'creation-entry'
| 'work-visibility';
| 'work-visibility'
| 'accounts';
export type AdminTabPermission = Exclude<AdminRouteId, 'accounts'>;
/** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */
export interface AdminRouteDefinition {
id: AdminRouteId;
label: string;
hash: string;
ownerOnly?: boolean;
}
export const adminRoutes: AdminRouteDefinition[] = [
@@ -30,19 +36,52 @@ 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'},
{id: 'tasks', label: '任务配置', hash: '#tasks'},
{id: 'recharge-products', label: '充值商品', hash: '#recharge-products'},
{id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders'},
{id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'},
{id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase'},
{id: 'editor-assets', label: '素材查询', hash: '#editor-assets'},
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
{id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true},
];
export interface AdminRouteAccess {
accountRole: 'owner' | 'member';
tabPermissions: string[];
}
export function getAccessibleAdminRoutes(
admin: AdminRouteAccess,
): AdminRouteDefinition[] {
if (admin.accountRole === 'owner') {
return adminRoutes;
}
const permissions = new Set(admin.tabPermissions);
return adminRoutes.filter(
(route) => !route.ownerOnly && permissions.has(route.id),
);
}
export function resolveAccessibleAdminRoute(
hash: string,
routes: AdminRouteDefinition[],
): AdminRouteId | null {
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
return (
routes.find((route) => route.hash === normalizedHash)?.id ??
routes[0]?.id ??
null
);
}
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到 Dashboard。 */
export function resolveAdminRoute(hash: string): AdminRouteId {
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
@@ -0,0 +1,214 @@
/* @vitest-environment jsdom */
import {render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import {
getAdminUserDetail,
updateAdminWalletRestriction,
} from '../api/adminApiClient';
import type {
AdminProfileWalletPayload,
AdminUserDetailResponse,
} from '../api/adminApiTypes';
import {AdminUserReferenceButton} from './AdminUserReferenceButton';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
getAdminUserDetail: vi.fn(),
isAdminApiError: vi.fn(() => false),
updateAdminWalletRestriction: vi.fn(),
}));
const wallet: AdminProfileWalletPayload = {
userId: 'user-1',
totalBalance: 96,
spendableBalance: 40,
dailyFreePoints: 6,
membershipLimitedPoints: 20,
permanentPoints: 70,
heldPoints: 5,
refundDebtPoints: 25,
manualFrozen: false,
refundDebtFrozen: true,
walletFrozen: true,
manualRestriction: null,
};
const detail: AdminUserDetailResponse = {
userId: 'user-1',
publicUserCode: 'TN1001',
displayName: '陶泥用户',
avatarUrl: 'https://example.com/avatar.png',
phoneNumberMasked: '138****5678',
loginMethod: 'phone',
bindingStatus: 'bound',
phoneBound: true,
wechatBound: true,
wallet,
rechargeOrders: [
{
orderId: 'order-1',
userId: 'user-1',
user: null,
productId: 'points_60',
productTitle: '60泥点',
productKind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
paidAtMicros: 1_720_000_000_000_000,
providerTransactionId: 'wx-1',
createdAtMicros: 1_720_000_000_000_000,
pointsDelta: 60,
cumulativeSuccessRefundCents: 300,
targetRecoveryPoints: 30,
recoveredPoints: 5,
unrecoveredPoints: 25,
recoveryStatus: 'shortfall',
wallet,
refunds: [],
activeHold: null,
remainingRefundableCents: 300,
refundEligible: false,
refundBlockReasonCode: 'refund_reconciliation_pending',
},
],
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getAdminUserDetail).mockResolvedValue(detail);
vi.mocked(updateAdminWalletRestriction).mockResolvedValue({wallet});
});
test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退款限制', async () => {
const user = userEvent.setup();
const parentClick = vi.fn();
render(
<div onClick={parentClick}>
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>
</div>,
);
const trigger = screen.getByRole('button', {name: '查看用户信息'});
await user.click(trigger);
expect(parentClick).not.toHaveBeenCalled();
expect(await screen.findByRole('dialog', {name: '用户详情'})).toBeTruthy();
expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', {
userId: 'user-1',
publicUserCode: undefined,
});
expect(screen.getByText('陶泥用户')).toBeTruthy();
expect(screen.getAllByText('TN1001').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('138****5678')).toBeTruthy();
expect(screen.getByText('退款欠账限制')).toBeTruthy();
expect(screen.getByText('25', {selector: 'strong'})).toBeTruthy();
expect(screen.getByText('order-1')).toBeTruthy();
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('dialog', {name: '用户详情'})).toBeNull());
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
test('只有陶泥号时按 publicUserCode 查询用户', async () => {
const user = userEvent.setup();
render(
<AdminUserReferenceButton
token="admin-token"
publicUserCode="TN1001"
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
await screen.findByText('陶泥用户');
expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', {
userId: undefined,
publicUserCode: 'TN1001',
});
});
test('人工冻结和解除人工冻结分别提交原因且不解除退款欠账限制', async () => {
const user = userEvent.setup();
const manuallyFrozenWallet: AdminProfileWalletPayload = {
...wallet,
manualFrozen: true,
walletFrozen: true,
manualRestriction: {
frozen: true,
reason: '风险核查',
createdByAdminUserId: 'admin:root',
createdByAdminDisplayName: '后台负责人',
createdAtMicros: 1_720_000_000_000_000,
updatedByAdminUserId: 'admin:root',
updatedByAdminDisplayName: '后台负责人',
updatedAtMicros: 1_720_000_000_000_000,
},
};
vi.mocked(updateAdminWalletRestriction)
.mockResolvedValueOnce({wallet: manuallyFrozenWallet})
.mockResolvedValueOnce({wallet: {...wallet, manualFrozen: false}});
render(
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
await screen.findByText('陶泥用户');
await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '异常登录');
await user.click(screen.getByRole('button', {name: '人工冻结钱包'}));
await user.click(screen.getByRole('button', {name: '确认'}));
await waitFor(() => {
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(1, 'admin-token', {
userId: 'user-1',
frozen: true,
reason: '异常登录',
});
});
expect(await screen.findByText(/后台负责人/)).toBeTruthy();
expect(screen.queryByText(/admin:root/)).toBeNull();
expect(await screen.findByText('解除人工冻结后,退款欠账限制仍会保留。')).toBeTruthy();
await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '核查完成');
await user.click(screen.getByRole('button', {name: '解除人工冻结'}));
await user.click(screen.getByRole('button', {name: '确认'}));
await waitFor(() => {
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(2, 'admin-token', {
userId: 'user-1',
frozen: false,
reason: '核查完成',
});
});
expect(screen.getByText('退款欠账限制')).toBeTruthy();
});
test('用户详情读取失败后可以重试', async () => {
const user = userEvent.setup();
vi.mocked(getAdminUserDetail)
.mockRejectedValueOnce(new Error('读取失败'))
.mockResolvedValueOnce(detail);
render(
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
expect(await screen.findByText('读取失败')).toBeTruthy();
await user.click(screen.getByRole('button', {name: '重试'}));
expect(await screen.findByText('陶泥用户')).toBeTruthy();
expect(getAdminUserDetail).toHaveBeenCalledTimes(2);
});
@@ -0,0 +1,427 @@
import {RefreshCcw, ShieldAlert, UserRound, X} from 'lucide-react';
import {useEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import {
formatAdminApiError,
getAdminUserDetail,
isAdminApiError,
updateAdminWalletRestriction,
} from '../api/adminApiClient';
import type {
AdminProfileWalletPayload,
AdminUserDetailResponse,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from './useAdminWriteConfirm';
interface AdminUserDetailDialogProps {
token: string;
userId?: string | null;
publicUserCode?: string | null;
onClose: () => void;
onUnauthorized: (message?: string) => void;
}
export function AdminUserDetailDialog({
token,
userId,
publicUserCode,
onClose,
onUnauthorized,
}: AdminUserDetailDialogProps) {
const [detail, setDetail] = useState<AdminUserDetailResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [restrictionReason, setRestrictionReason] = useState('');
const [isSavingRestriction, setIsSavingRestriction] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
const requestVersionRef = useRef(0);
const {confirmWrite, confirmDialog, isConfirming} = useAdminWriteConfirm();
useEffect(() => {
void loadDetail();
return () => {
requestVersionRef.current += 1;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, userId, publicUserCode]);
useEffect(() => {
closeButtonRef.current?.focus();
}, []);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !isSavingRestriction && !isConfirming) {
event.preventDefault();
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [isConfirming, isSavingRestriction, onClose]);
async function loadDetail() {
const requestVersion = requestVersionRef.current + 1;
requestVersionRef.current = requestVersion;
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminUserDetail(token, {
userId: userId?.trim() || undefined,
publicUserCode: userId?.trim()
? undefined
: publicUserCode?.trim() || undefined,
});
if (requestVersionRef.current === requestVersion) {
setDetail(response);
}
} catch (error: unknown) {
if (requestVersionRef.current !== requestVersion) {
return;
}
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
return;
}
setErrorMessage(formatAdminApiError(error));
} finally {
if (requestVersionRef.current === requestVersion) {
setIsLoading(false);
}
}
}
async function handleRestrictionChange() {
if (!detail || isSavingRestriction) {
return;
}
const reason = restrictionReason.trim();
if (!reason) {
setErrorMessage('请填写人工冻结操作原因');
return;
}
const nextFrozen = !detail.wallet.manualFrozen;
const action = nextFrozen ? '人工冻结钱包' : '解除人工冻结';
const confirmed = await confirmWrite({
action,
target: `${detail.displayName || detail.publicUserCode} / ${detail.userId}`,
});
if (!confirmed) {
return;
}
setIsSavingRestriction(true);
setErrorMessage('');
try {
const response = await updateAdminWalletRestriction(token, {
userId: detail.userId,
frozen: nextFrozen,
reason,
});
setDetail((current) =>
current ? {...current, wallet: response.wallet} : current,
);
setRestrictionReason('');
} catch (error: unknown) {
if (isAdminApiError(error) && error.status === 401) {
onUnauthorized('登录状态已失效');
} else {
setErrorMessage(formatAdminApiError(error));
}
} finally {
setIsSavingRestriction(false);
}
}
if (typeof document === 'undefined') {
return null;
}
return createPortal(
<div
aria-modal="true"
className="admin-confirm-backdrop admin-user-detail-backdrop"
role="dialog"
aria-labelledby="admin-user-detail-title"
onMouseDown={(event) => {
if (
event.target === event.currentTarget &&
!isSavingRestriction &&
!isConfirming
) {
onClose();
}
}}
>
<section className="admin-detail-panel admin-user-detail-panel">
<div className="admin-panel-heading">
<div>
<h3 id="admin-user-detail-title"></h3>
<span>{detail?.publicUserCode || publicUserCode || userId || '-'}</span>
</div>
<div className="admin-detail-actions">
<button
aria-label="刷新用户信息"
className="admin-ghost-button"
disabled={isLoading}
title="刷新"
type="button"
onClick={() => void loadDetail()}
>
<RefreshCcw size={17} aria-hidden="true" />
</button>
<button
ref={closeButtonRef}
aria-label="关闭用户详情"
className="admin-ghost-button"
disabled={isSavingRestriction}
title="关闭"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</button>
</div>
</div>
{isLoading ? (
<div className="admin-user-detail-loading" role="status">
<div className="admin-loading-mark" />
<span></span>
</div>
) : errorMessage && !detail ? (
<div className="admin-user-detail-error">
<div className="admin-alert" role="status">
{errorMessage}
</div>
<button
className="admin-secondary-button"
type="button"
onClick={() => void loadDetail()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span></span>
</button>
</div>
) : detail ? (
<>
<UserIdentityHeader detail={detail} />
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<WalletSection wallet={detail.wallet} />
<section className="admin-user-restriction-section">
<div className="admin-panel-heading">
<h3></h3>
<span>
{detail.wallet.manualFrozen ? '当前已冻结' : '当前未冻结'}
</span>
</div>
{detail.wallet.manualRestriction ? (
<div className="admin-user-restriction-record">
<span>{detail.wallet.manualRestriction.reason || '未填写原因'}</span>
<small>
{formatMicros(detail.wallet.manualRestriction.updatedAtMicros)} /{' '}
{detail.wallet.manualRestriction.updatedByAdminDisplayName}
</small>
</div>
) : null}
{detail.wallet.manualFrozen && detail.wallet.refundDebtFrozen ? (
<div className="admin-alert admin-alert-warning" role="status">
<ShieldAlert size={17} aria-hidden="true" />
<span>退</span>
</div>
) : null}
<div className="admin-user-restriction-actions">
<label className="admin-field admin-field-fill">
<span></span>
<input
aria-label="人工冻结操作原因"
disabled={isSavingRestriction}
value={restrictionReason}
onChange={(event) => setRestrictionReason(event.target.value)}
/>
</label>
<button
className={
detail.wallet.manualFrozen
? 'admin-secondary-button'
: 'admin-danger-button'
}
disabled={isSavingRestriction || !restrictionReason.trim()}
type="button"
onClick={() => void handleRestrictionChange()}
>
<ShieldAlert size={17} aria-hidden="true" />
<span>
{isSavingRestriction
? '处理中'
: detail.wallet.manualFrozen
? '解除人工冻结'
: '人工冻结钱包'}
</span>
</button>
</div>
</section>
<section className="admin-user-recharge-section">
<div className="admin-panel-heading">
<h3></h3>
<span>{detail.rechargeOrders.length} </span>
</div>
{detail.rechargeOrders.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-user-recharge-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>退</th>
<th></th>
</tr>
</thead>
<tbody>
{detail.rechargeOrders.map((order) => (
<tr key={order.orderId}>
<td>
<span className="admin-mono-value">{order.orderId}</span>
<small>{formatMicros(order.createdAtMicros)}</small>
</td>
<td>
{order.productTitle || order.productId}
<small> {order.pointsDelta} </small>
</td>
<td>{formatMoney(order.amountCents)}</td>
<td>
{formatMoney(order.cumulativeSuccessRefundCents)}
<small> {order.unrecoveredPoints} </small>
</td>
<td>{formatOrderStatus(order.status)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</>
) : null}
</section>
{confirmDialog}
</div>,
document.body,
);
}
function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) {
return (
<section className="admin-user-identity">
<div className="admin-user-avatar">
{detail.avatarUrl ? (
<img alt={`${detail.displayName || detail.publicUserCode}头像`} src={detail.avatarUrl} />
) : (
<UserRound size={30} aria-hidden="true" />
)}
</div>
<div className="admin-user-identity-primary">
<strong>{detail.displayName || '未设置昵称'}</strong>
<span>{detail.publicUserCode || '未分配陶泥号'}</span>
</div>
<dl className="admin-info-list admin-user-identity-list">
<div>
<dt> ID</dt>
<dd>{detail.userId}</dd>
</div>
<div>
<dt></dt>
<dd>{detail.phoneNumberMasked || '未绑定'}</dd>
</div>
<div>
<dt></dt>
<dd>{detail.loginMethod || '-'}</dd>
</div>
<div>
<dt></dt>
<dd>
{detail.bindingStatus || '-'} / {detail.phoneBound ? '已绑定' : '未绑定'} /
{detail.wechatBound ? '已绑定' : '未绑定'}
</dd>
</div>
</dl>
</section>
);
}
function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
const metrics = [
['总余额', wallet.totalBalance],
['可消费', wallet.spendableBalance],
['永久泥点', wallet.permanentPoints],
['每日免费', wallet.dailyFreePoints],
['会员限时', wallet.membershipLimitedPoints],
['退款占用', wallet.heldPoints],
['退款欠账', wallet.refundDebtPoints],
] as const;
return (
<section className="admin-user-wallet-section">
<div className="admin-panel-heading">
<h3></h3>
<div className="admin-tag-list">
{wallet.manualFrozen ? <span className="admin-tag"></span> : null}
{wallet.refundDebtFrozen ? (
<span className="admin-tag">退</span>
) : null}
{!wallet.walletFrozen ? <span className="admin-status admin-status-ok"></span> : null}
</div>
</div>
<div className="admin-user-wallet-grid">
{metrics.map(([label, value]) => (
<div className="admin-recharge-metric" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</section>
);
}
function formatMoney(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
function formatMicros(value: number) {
if (!Number.isFinite(value) || value <= 0) {
return '-';
}
return new Date(Math.floor(value / 1000)).toLocaleString('zh-CN', {
hour12: false,
});
}
function formatOrderStatus(status: string) {
const labels: Record<string, string> = {
pending: '待支付',
paid: '已支付',
refunded: '已退款',
closed: '已关闭',
};
return labels[status.toLowerCase()] ?? status;
}
@@ -0,0 +1,73 @@
import {UserRoundSearch} from 'lucide-react';
import {MouseEvent, useRef, useState} from 'react';
import {AdminUserDetailDialog} from './AdminUserDetailDialog';
interface AdminUserReferenceButtonProps {
token: string;
userId?: string | null;
publicUserCode?: string | null;
onUnauthorized: (message?: string) => void;
}
export function AdminUserReferenceButton({
token,
userId,
publicUserCode,
onUnauthorized,
}: AdminUserReferenceButtonProps) {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const normalizedUserId = normalizeUserReference(userId);
const normalizedPublicUserCode = normalizeUserReference(publicUserCode);
const lookup = normalizedUserId
? {userId: normalizedUserId}
: normalizedPublicUserCode
? {publicUserCode: normalizedPublicUserCode}
: null;
if (!lookup) {
return null;
}
function openDialog(event: MouseEvent<HTMLButtonElement>) {
event.stopPropagation();
setOpen(true);
}
function closeDialog() {
setOpen(false);
window.requestAnimationFrame(() => triggerRef.current?.focus());
}
return (
<>
<button
ref={triggerRef}
aria-label="查看用户信息"
className="admin-ghost-button admin-user-reference-button"
title="查看用户信息"
type="button"
onClick={openDialog}
>
<UserRoundSearch size={16} aria-hidden="true" />
</button>
{open ? (
<AdminUserDetailDialog
token={token}
{...lookup}
onClose={closeDialog}
onUnauthorized={onUnauthorized}
/>
) : null}
</>
);
}
function normalizeUserReference(value?: string | null) {
const normalized = value?.trim() ?? '';
if (!normalized || normalized.toLowerCase().startsWith('admin:')) {
return '';
}
return normalized;
}
@@ -101,5 +101,9 @@ export function useAdminWriteConfirm() {
</div>
) : null;
return {confirmWrite, confirmDialog};
return {
confirmWrite,
confirmDialog,
isConfirming: pendingConfirm !== null,
};
}
@@ -0,0 +1,305 @@
import {Plus, RefreshCcw, Save} from 'lucide-react';
import {type FormEvent, useEffect, useState} from 'react';
import {
createAdminAccount,
listAdminAccounts,
updateAdminAccount,
} from '../api/adminApiClient';
import type {AdminAccountPayload} from '../api/adminApiTypes';
import {adminRoutes} from '../app/adminRoutes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminAccountsPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const assignableRoutes = adminRoutes.filter((route) => !route.ownerOnly);
export function AdminAccountsPage({
token,
onUnauthorized,
}: AdminAccountsPageProps) {
const [accounts, setAccounts] = useState<AdminAccountPayload[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState('');
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [enabled, setEnabled] = useState(true);
const [tabPermissions, setTabPermissions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshAccounts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshAccounts() {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminAccounts(token);
setAccounts(response.accounts);
const selected = response.accounts.find(
(account) => account.accountId === selectedAccountId,
);
if (selected) {
fillForm(selected);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}
function startCreate() {
setSelectedAccountId('');
setUsername('');
setDisplayName('');
setPassword('');
setEnabled(true);
setTabPermissions([]);
setErrorMessage('');
}
function fillForm(account: AdminAccountPayload) {
setSelectedAccountId(account.accountId);
setUsername(account.username);
setDisplayName(account.displayName);
setPassword('');
setEnabled(account.enabled);
setTabPermissions(account.tabPermissions);
setErrorMessage('');
}
function togglePermission(permission: string, checked: boolean) {
setTabPermissions((current) =>
checked
? assignableRoutes
.map((route) => route.id)
.filter((routeId) =>
routeId === permission || current.includes(routeId),
)
: current.filter((item) => item !== permission),
);
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
const normalizedUsername = username.trim();
const normalizedDisplayName = displayName.trim();
if (!selectedAccountId && !normalizedUsername) {
setErrorMessage('请输入用户名');
return;
}
if (!normalizedDisplayName) {
setErrorMessage('请输入显示名称');
return;
}
if (!selectedAccountId && !password) {
setErrorMessage('请输入密码');
return;
}
const confirmed = await confirmWrite({
action: selectedAccountId ? '更新后台账号' : '创建后台账号',
target: normalizedUsername,
});
if (!confirmed) {
return;
}
setIsSaving(true);
setErrorMessage('');
try {
const response = selectedAccountId
? await updateAdminAccount(token, selectedAccountId, {
displayName: normalizedDisplayName,
...(password ? {password} : {}),
tabPermissions,
enabled,
})
: await createAdminAccount(token, {
username: normalizedUsername,
displayName: normalizedDisplayName,
password,
tabPermissions,
enabled,
});
setAccounts((current) => {
const rest = current.filter(
(account) => account.accountId !== response.account.accountId,
);
return [...rest, response.account].sort((left, right) =>
left.username.localeCompare(right.username),
);
});
fillForm(response.account);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<div className="admin-action-row">
<button
className="admin-secondary-button"
type="button"
onClick={startCreate}
>
<Plus size={17} aria-hidden="true" />
<span></span>
</button>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshAccounts}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-accounts-layout">
<section className="admin-panel admin-account-list">
<div className="admin-panel-heading">
<h3></h3>
<span>{accounts.length}</span>
</div>
{accounts.length ? (
<div className="admin-account-list-items">
{accounts.map((account) => (
<button
data-active={account.accountId === selectedAccountId}
disabled={account.accountRole === 'owner'}
key={account.accountId}
title={account.accountRole === 'owner' ? 'owner' : account.username}
type="button"
onClick={() => {
if (account.accountRole === 'member') {
fillForm(account);
}
}}
>
<span>
<strong>{account.displayName || account.username}</strong>
<small>{account.username}</small>
</span>
<small>
{account.accountRole === 'owner'
? 'owner'
: account.enabled
? '启用'
: '停用'}
</small>
</button>
))}
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无成员账号'}
</div>
)}
</section>
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-panel-heading">
<h3>{selectedAccountId ? '编辑账号' : '添加账号'}</h3>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
disabled={Boolean(selectedAccountId)}
autoComplete="off"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span>{selectedAccountId ? '新密码' : '密码'}</span>
<input
autoComplete="new-password"
placeholder={selectedAccountId ? '不修改' : ''}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<fieldset className="admin-permission-fieldset">
<legend>Tab 访</legend>
<div className="admin-permission-grid">
{assignableRoutes.map((route) => (
<label key={route.id}>
<input
checked={tabPermissions.includes(route.id)}
type="checkbox"
onChange={(event) =>
togglePermission(route.id, event.target.checked)
}
/>
<span>{route.label}</span>
</label>
))}
</div>
</fieldset>
<button
className="admin-primary-button"
disabled={isSaving}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
</div>
{confirmDialog}
</section>
);
}
@@ -1,6 +1,12 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
@@ -29,6 +35,21 @@ const dashboardResponse: AdminDashboardResponse = {
consumedMudPoints: 88,
totalRegisteredUsers: 1200,
newRegisteredUsers: 16,
newUserPaymentConversion: {
paidUsers: 5,
newRegisteredUsers: 16,
rateBasisPoints: 3125,
},
day1Retention: {
eligibleUsers: 12,
retainedUsers: 3,
rateBasisPoints: 2500,
},
day7Retention: {
eligibleUsers: 0,
retainedUsers: 0,
rateBasisPoints: 0,
},
visitUsers: 34,
totalVisitUsers: 456,
visitCount: 98,
@@ -41,7 +62,10 @@ const dashboardResponse: AdminDashboardResponse = {
title: '生产素材',
unit: '个',
total: 12,
buckets: [{ key: '2026-06-23', label: '2026-06-23', value: 12 }],
buckets: [
{ key: '2026-06-23', label: '2026-06-23', value: 12 },
{ key: '2026-06-24', label: '2026-06-24', value: 0 },
],
},
],
operations: {
@@ -82,8 +106,34 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
expect(screen.getByText('本日生产素材数')).toBeTruthy();
expect(screen.getByText('总注册用户')).toBeTruthy();
expect(screen.getByText('本日新增用户数')).toBeTruthy();
expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy();
expect(screen.getByText('新增用户转化与留存')).toBeTruthy();
const paymentRateCard = screen
.getByText('本日新增用户付费率')
.closest('article');
expect(paymentRateCard).toBeTruthy();
expect(
within(paymentRateCard as HTMLElement).getByText('31.25%'),
).toBeTruthy();
expect(
within(paymentRateCard as HTMLElement).getByText('付费人数 / 新增人数'),
).toBeTruthy();
expect(
within(paymentRateCard as HTMLElement).getByText('5 / 16 人'),
).toBeTruthy();
expect(screen.getByText('次日留存')).toBeTruthy();
expect(screen.getByText('25%')).toBeTruthy();
expect(screen.getByText('3 / 12 人')).toBeTruthy();
const day7Card = screen.getByText('七日留存').closest('article');
expect(day7Card).toBeTruthy();
expect(within(day7Card as HTMLElement).getByText('-')).toBeTruthy();
expect(within(day7Card as HTMLElement).getByText('0 / 0 人')).toBeTruthy();
expect(screen.getByText('近 5 分钟活跃用户')).toBeTruthy();
expect(screen.getByText('生产素材')).toBeTruthy();
expect(
screen
.getByTitle('2026-06-24: 0 个')
.firstElementChild?.getAttribute('style'),
).toContain('height: 0%');
expect(screen.queryByRole('button', { name: '本时段' })).toBeNull();
await user.click(screen.getByRole('button', { name: '运营汇总' }));
@@ -108,7 +158,7 @@ test('Dashboard 默认日期使用北京时间', async () => {
});
});
test('Dashboard 选择本周时按今天填充整周范围', async () => {
test('Dashboard 选择本周时按北京时间今天截断未来日期', async () => {
const user = setupUser();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
@@ -123,12 +173,12 @@ test('Dashboard 选择本周时按今天填充整周范围', async () => {
granularity: 'period',
anchor: undefined,
startDate: '2026-06-22',
endDate: '2026-06-28',
endDate: '2026-06-27',
});
});
});
test('Dashboard 选择本月时按今天填充整月范围', async () => {
test('Dashboard 选择本月时按北京时间今天截断未来日期', async () => {
const user = setupUser();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
@@ -143,7 +193,7 @@ test('Dashboard 选择本月时按今天填充整月范围', async () => {
granularity: 'period',
anchor: undefined,
startDate: '2026-06-01',
endDate: '2026-06-30',
endDate: '2026-06-27',
});
});
});
@@ -186,8 +236,136 @@ test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
});
});
expect(screen.getByText('本时段新增用户数')).toBeTruthy();
expect(screen.getByText('本时段新增用户付费率')).toBeTruthy();
});
test('Dashboard 新增用户付费率分母为零时显示横线', async () => {
vi.mocked(getAdminDashboard).mockResolvedValue({
...dashboardResponse,
metrics: {
...dashboardResponse.metrics,
newRegisteredUsers: 0,
newUserPaymentConversion: {
paidUsers: 0,
newRegisteredUsers: 0,
rateBasisPoints: 0,
},
},
});
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
const paymentRateCard = (
await screen.findByText('本日新增用户付费率')
).closest('article');
expect(paymentRateCard).toBeTruthy();
expect(within(paymentRateCard as HTMLElement).getByText('-')).toBeTruthy();
expect(
within(paymentRateCard as HTMLElement).getByText('0 / 0 人'),
).toBeTruthy();
});
test('Dashboard 手动选择日期时不允许查询北京时间今天之后', async () => {
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('本日生产素材数');
const endDateInput = screen.getByLabelText('终止日期') as HTMLInputElement;
expect(endDateInput.max).toBe('2026-06-27');
fireEvent.change(endDateInput, { target: { value: '2026-07-12' } });
await waitFor(() => {
expect(endDateInput.value).toBe('2026-06-27');
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
granularity: 'period',
anchor: undefined,
startDate: '2026-06-27',
endDate: '2026-06-27',
});
});
});
test('Dashboard 四张趋势图共享横向日期窗口', async () => {
const baseChart = dashboardResponse.charts[0]!;
vi.mocked(getAdminDashboard).mockResolvedValue({
...dashboardResponse,
charts: [
baseChart,
{
...baseChart,
id: 'consumed-mud-points',
title: '消耗泥点',
unit: '泥点',
},
],
});
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
const chartRegions = await screen.findAllByRole('region', {
name: /趋势图$/,
});
const [sourceChart, targetChart] = chartRegions;
if (!sourceChart || !targetChart) {
throw new Error('趋势图未完整渲染');
}
setScrollableDimensions(sourceChart, {
clientWidth: 100,
scrollWidth: 500,
scrollLeft: 200,
});
setScrollableDimensions(targetChart, {
clientWidth: 100,
scrollWidth: 500,
scrollLeft: 0,
});
fireEvent.scroll(sourceChart);
await waitFor(() => {
expect(targetChart.scrollLeft).toBe(200);
});
});
test('Dashboard 访问人数趋势明确区分每日桶与时段去重总数', async () => {
const baseChart = dashboardResponse.charts[0]!;
vi.mocked(getAdminDashboard).mockResolvedValue({
...dashboardResponse,
charts: [
{
...baseChart,
id: 'visit-users',
title: '每日访问人数',
unit: '人',
total: 34,
},
],
});
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
expect(await screen.findByText('每日访问人数')).toBeTruthy();
expect(screen.getByText('时段去重 34 人')).toBeTruthy();
});
function setupUser() {
return userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
}
function setScrollableDimensions(
element: HTMLElement,
dimensions: {
clientWidth: number;
scrollWidth: number;
scrollLeft: number;
},
) {
Object.defineProperties(element, {
clientWidth: { configurable: true, value: dimensions.clientWidth },
scrollWidth: { configurable: true, value: dimensions.scrollWidth },
scrollLeft: {
configurable: true,
value: dimensions.scrollLeft,
writable: true,
},
});
}
+158 -31
View File
@@ -1,5 +1,5 @@
import { RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAdminDashboard } from '../api/adminApiClient';
import type {
@@ -42,24 +42,29 @@ export function AdminDashboardPage({
const [activeTab, setActiveTab] = useState<AdminDashboardTab>('metrics');
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [chartScrollLeft, setChartScrollLeft] = useState(0);
const today = formatBeijingDateInput(new Date());
const loadDashboard = useCallback(async (range = dateRange) => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminDashboard(token, {
granularity: 'period',
anchor: undefined,
startDate: range.startDate,
endDate: range.endDate,
});
setDashboard(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [dateRange, onUnauthorized, token]);
const loadDashboard = useCallback(
async (range = dateRange) => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminDashboard(token, {
granularity: 'period',
anchor: undefined,
startDate: range.startDate,
endDate: range.endDate,
});
setDashboard(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
},
[dateRange, onUnauthorized, token],
);
useEffect(() => {
void loadDashboard();
@@ -75,6 +80,10 @@ export function AdminDashboardPage({
return () => window.clearInterval(timer);
}, [loadDashboard]);
useEffect(() => {
setChartScrollLeft(0);
}, [dashboard?.range.periodEndDate, dashboard?.range.periodStartDate]);
const metrics = dashboard?.metrics;
const totalMetricCards = useMemo(
() => [
@@ -98,7 +107,7 @@ export function AdminDashboardPage({
},
{
id: 'current-users',
label: '当前使用人数(五分钟统计一次)',
label: '近 5 分钟活跃用户',
value: metrics?.currentUsers ?? 0,
unit: '人',
},
@@ -154,6 +163,7 @@ export function AdminDashboardPage({
<span></span>
<input
type="date"
max={today}
value={dateRange.startDate}
onChange={(event) =>
handleDateRangeChange('startDate', event.target.value)
@@ -164,6 +174,7 @@ export function AdminDashboardPage({
<span></span>
<input
type="date"
max={today}
value={dateRange.endDate}
onChange={(event) =>
handleDateRangeChange('endDate', event.target.value)
@@ -278,9 +289,51 @@ export function AdminDashboardPage({
</div>
</section>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{dashboard?.range.periodLabel ?? '-'}</span>
</div>
<div className="admin-dashboard-retention-grid">
<RateCard
label={`${rangePrefix(granularity)}新增用户付费率`}
numeratorLabel="付费人数"
denominatorLabel="新增人数"
numerator={metrics?.newUserPaymentConversion?.paidUsers}
denominator={
metrics?.newUserPaymentConversion?.newRegisteredUsers
}
rateBasisPoints={
metrics?.newUserPaymentConversion?.rateBasisPoints
}
/>
<RateCard
label="次日留存"
numeratorLabel="留存人数"
denominatorLabel="可观察新增人数"
numerator={metrics?.day1Retention.retainedUsers}
denominator={metrics?.day1Retention.eligibleUsers}
rateBasisPoints={metrics?.day1Retention.rateBasisPoints}
/>
<RateCard
label="七日留存"
numeratorLabel="留存人数"
denominatorLabel="可观察新增人数"
numerator={metrics?.day7Retention.retainedUsers}
denominator={metrics?.day7Retention.eligibleUsers}
rateBasisPoints={metrics?.day7Retention.rateBasisPoints}
/>
</div>
</section>
<div className="admin-dashboard-chart-grid">
{(dashboard?.charts ?? []).map((chart) => (
<ChartPanel key={chart.id} chart={chart} />
<ChartPanel
key={chart.id}
chart={chart}
scrollLeft={chartScrollLeft}
onScrollLeftChange={setChartScrollLeft}
/>
))}
{dashboard && dashboard.charts.length === 0 ? (
<div className="admin-empty-state"></div>
@@ -347,9 +400,10 @@ export function AdminDashboardPage({
if (!parseDateValueAsUtc(value)) {
return;
}
const clampedValue = value > today ? today : value;
setGranularity('period');
setDateRange((current) =>
normalizeDateRange({ ...current, [field]: value }),
normalizeDateRange({ ...current, [field]: clampedValue }),
);
}
}
@@ -377,17 +431,85 @@ function MetricCard({
);
}
function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
function RateCard({
label,
numeratorLabel,
denominatorLabel,
numerator,
denominator,
rateBasisPoints,
}: {
label: string;
numeratorLabel: string;
denominatorLabel: string;
numerator?: number;
denominator?: number;
rateBasisPoints?: number;
}) {
const hasDenominator = Boolean(denominator);
return (
<article className="admin-dashboard-retention-card">
<span>{label}</span>
<strong>
{hasDenominator && rateBasisPoints !== undefined
? formatRateBasisPoints(rateBasisPoints)
: '-'}
</strong>
<div>
<small>
{numeratorLabel} / {denominatorLabel}
</small>
<b>
{numerator !== undefined && denominator !== undefined
? `${formatNumber(numerator)} / ${formatNumber(denominator)}`
: '-'}
</b>
</div>
</article>
);
}
function ChartPanel({
chart,
scrollLeft,
onScrollLeftChange,
}: {
chart: AdminDashboardChartPayload;
scrollLeft: number;
onScrollLeftChange: (scrollLeft: number) => void;
}) {
const barsRef = useRef<HTMLDivElement>(null);
const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value));
useEffect(() => {
const element = barsRef.current;
if (!element) {
return;
}
if (Math.abs(element.scrollLeft - scrollLeft) > 1) {
element.scrollLeft = scrollLeft;
}
}, [chart.buckets.length, scrollLeft]);
return (
<section className="admin-panel admin-dashboard-chart-card">
<div className="admin-panel-heading">
<h3>{chart.title}</h3>
<span>
{chart.id === 'visit-users' ? '时段去重 ' : ''}
{formatNumber(chart.total)} {chart.unit}
</span>
</div>
<div className="admin-dashboard-bars">
<div
ref={barsRef}
className="admin-dashboard-bars"
role="region"
aria-label={`${chart.title}趋势图`}
tabIndex={0}
onScroll={(event) => {
onScrollLeftChange(event.currentTarget.scrollLeft);
}}
>
{chart.buckets.map((bucket) => (
<div className="admin-dashboard-bar-item" key={bucket.key}>
<div
@@ -396,7 +518,10 @@ function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
>
<span
style={{
height: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
height:
bucket.value === 0
? '0%'
: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
}}
/>
</div>
@@ -450,6 +575,13 @@ function formatNumber(value: number) {
return new Intl.NumberFormat('zh-CN').format(value);
}
function formatRateBasisPoints(value: number) {
return new Intl.NumberFormat('zh-CN', {
style: 'percent',
maximumFractionDigits: 2,
}).format(value / 10_000);
}
function formatBeijingDateInput(date: Date) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
@@ -498,9 +630,7 @@ function buildWeekDateRange(dateValue: string) {
const weekday = date.getUTCDay() || 7;
const monday = new Date(date);
monday.setUTCDate(date.getUTCDate() - weekday + 1);
const sunday = new Date(monday);
sunday.setUTCDate(monday.getUTCDate() + 6);
return { startDate: formatUtcDate(monday), endDate: formatUtcDate(sunday) };
return { startDate: formatUtcDate(monday), endDate: dateValue };
}
function buildMonthDateRange(dateValue: string) {
@@ -512,12 +642,9 @@ function buildMonthDateRange(dateValue: string) {
const firstDate = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1),
);
const lastDate = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
);
return {
startDate: formatUtcDate(firstDate),
endDate: formatUtcDate(lastDate),
endDate: dateValue,
};
}
@@ -1,14 +1,17 @@
/* @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,
resolveAdminDatabaseUserReference,
} from './AdminDatabaseTablesPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
@@ -19,6 +22,51 @@ vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(() => false),
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({ userId, publicUserCode }: {
userId?: string;
publicUserCode?: string;
}) => (
<button
aria-label={`查看用户 ${userId || publicUserCode}`}
data-public-user-code={publicUserCode}
data-user-id={userId}
type="button"
onClick={(event) => event.stopPropagation()}
/>
),
}));
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,78 +76,187 @@ 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']);
});
});
test('数据库用户字段显示查看按钮且点击不会打开行详情', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const userButton = await screen.findByRole('button', { name: '查看用户 u-b' });
await user.click(userButton);
expect(screen.queryByRole('dialog')).toBeNull();
});
test('数据库用户字段识别会排除后台操作者与合成邀请码字段', () => {
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'owner_user_id', 'u-1'),
).toEqual({ userId: 'u-1' });
expect(
resolveAdminDatabaseUserReference(
'auth_store_projection',
'public_user_code',
'TN1001',
),
).toEqual({ publicUserCode: 'TN1001' });
expect(
resolveAdminDatabaseUserReference('audit_log', 'operator_user_id', 'u-1'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('audit_log', 'admin_user_id', 'u-1'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'user_id', 'admin:root'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_invite_code', 'user_id', 'u-1'),
).toBeNull();
});
function readFirstColumnValues(container: HTMLElement) {
return Array.from(container.querySelectorAll('tbody tr')).map(
(row) => row.querySelector('td')?.textContent?.trim() ?? '',
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -31,6 +31,20 @@ vi.mock('../api/adminApiClient', () => ({
upsertAdminEditorShowcaseCampaign: vi.fn(),
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({ userId, publicUserCode }: {
userId?: string;
publicUserCode?: string | null;
}) => (
<button
aria-label="查看精选作者"
data-public-user-code={publicUserCode ?? undefined}
data-user-id={userId}
type="button"
/>
),
}));
const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = {
showcaseId: 'showcase-1',
assetId: 'asset-1',
@@ -156,6 +170,9 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
expect(await screen.findByText('作者昵称')).toBeTruthy();
expect(screen.getByText('SY-00000042')).toBeTruthy();
const userButton = screen.getByRole('button', { name: '查看精选作者' });
expect(userButton.getAttribute('data-user-id')).toBe('user-1');
expect(userButton.getAttribute('data-public-user-code')).toBe('SY-00000042');
expect(screen.getAllByText('待审核').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('12 泥点')).toBeTruthy();
expect(await screen.findByDisplayValue('活动卡')).toBeTruthy();
@@ -167,6 +184,10 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
submittedBefore: null,
limit: 80,
});
expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', {
objectKey: 'generated-character-drafts/editor/spec.png',
expireSeconds: 300,
});
});
test('后台精选审核格式化微秒时间并显示素材名', async () => {
@@ -17,6 +17,7 @@ import type {
AdminEditorShowcaseCampaignPayload,
AdminEditorShowcaseListQuery,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
interface AdminEditorShowcaseReviewPageProps {
@@ -25,7 +26,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,14 +346,24 @@ export function AdminEditorShowcaseReviewPage({
type="button"
onClick={() => setDetailEntry(entry)}
>
<AdminShowcaseThumbnail entry={entry} />
<AdminShowcaseThumbnail entry={entry} token={token} />
</button>
<small>{entry.label || '-'}</small>
</td>
<td>{formatDateTime(entry.submittedAt)}</td>
<td>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td>
{entry.reviewStatus === 'approved' ? (
@@ -597,6 +608,8 @@ export function AdminEditorShowcaseReviewPage({
{detailEntry ? (
<AdminShowcaseDetailDialog
entry={detailEntry}
token={token}
onUnauthorized={onUnauthorized}
onClose={() => setDetailEntry(null)}
onPromptPreview={(entry, prompt) =>
setPromptPreview({
@@ -641,11 +654,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,11 +686,15 @@ function isAdminShowcaseAudioAsset(entry: AdminEditorShowcaseAssetPayload) {
function AdminShowcaseDetailDialog({
entry,
token,
onClose,
onPromptPreview,
onUnauthorized,
}: {
entry: AdminEditorShowcaseAssetPayload;
token: string;
onClose: () => void;
onUnauthorized: (message?: string) => void;
onPromptPreview: (
entry: AdminEditorShowcaseAssetPayload,
prompt: string,
@@ -703,11 +723,21 @@ 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)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</AdminInfoItem>
<AdminInfoItem label="用户 ID">{entry.ownerUserId}</AdminInfoItem>
<AdminInfoItem label="素材 ID">{entry.assetId}</AdminInfoItem>
@@ -786,6 +816,7 @@ function AdminInfoItem({
}
function useAdminResolvedAssetImageSrc(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
) {
@@ -811,6 +842,7 @@ function useAdminResolvedAssetImageSrc(
setResolvedImageSrc('');
void getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
@@ -836,7 +868,7 @@ function useAdminResolvedAssetImageSrc(
return () => {
cancelled = true;
};
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve]);
}, [normalizedImageSrc, normalizedObjectKey, shouldResolve, token]);
return resolvedImageSrc;
}

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