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:
@@ -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(' ');
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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}` : '';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user