合并 master 最新改动
同步 origin/master 至 b303605bf
保留项目总控 Runtime 决策并合入后台账号与画布更新
沿用当前 platform-llm LlmRunRequest 接口解决背景决策冲突
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,21 +1,66 @@
|
||||
import {afterEach, expect, test, vi} from 'vitest';
|
||||
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: []}), {
|
||||
new Response(JSON.stringify({ entries: [] }), {
|
||||
status: 200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
@@ -47,12 +92,14 @@ test('充值订单查询按后台契约序列化筛选参数', async () => {
|
||||
});
|
||||
|
||||
test('用户详情只发送实际提供的用户定位字段', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({userId: 'user-1'}), {status: 200}),
|
||||
);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ userId: 'user-1' }), { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await getAdminUserDetail('token-1', {publicUserCode: 'TN1001'});
|
||||
await getAdminUserDetail('token-1', { publicUserCode: 'TN1001' });
|
||||
|
||||
const requestUrl = String(fetchMock.mock.calls[0]?.[0]);
|
||||
const parsed = new URL(requestUrl, 'http://admin.local');
|
||||
@@ -63,9 +110,13 @@ test('用户详情只发送实际提供的用户定位字段', async () => {
|
||||
});
|
||||
|
||||
test('退款执行使用独立 execute 管理员路由', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({outRefundNo: 'refund-1'}), {status: 200}),
|
||||
);
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ outRefundNo: 'refund-1' }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await executeAdminRechargeRefund('token-1', {
|
||||
@@ -92,14 +143,19 @@ test('退款执行使用独立 execute 管理员路由', async () => {
|
||||
});
|
||||
|
||||
test('退款人工复核使用独立 resolve 管理员路由', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({outRefundNo: 'refund-1'}), {status: 200}),
|
||||
);
|
||||
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(
|
||||
@@ -111,6 +167,7 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => {
|
||||
body: JSON.stringify({
|
||||
outRefundNo: 'refund-1',
|
||||
reason: '已核对微信商户平台原始账单',
|
||||
expectedErrorCode: 'provider_transaction_id_mismatch',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type {
|
||||
AdminAccountListResponse,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminCreationEntryConfigResponse,
|
||||
@@ -39,6 +42,8 @@ import type {
|
||||
AdminTrackingEventListResponse,
|
||||
AdminUpdateWorkVisibilityRequest,
|
||||
AdminUpdateWorkVisibilityResponse,
|
||||
AdminUpdateAccountRequest,
|
||||
AdminUpdateAccountResponse,
|
||||
AdminUploadedEditorShowcaseCampaignImage,
|
||||
AdminUpsertEditorShowcaseCampaignRequest,
|
||||
AdminUpsertFeatureGateConfigRequest,
|
||||
@@ -188,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 });
|
||||
}
|
||||
@@ -367,10 +398,11 @@ export function getAdminAssetReadUrl(
|
||||
export function listAdminEditorAssets(
|
||||
token: string,
|
||||
query: AdminEditorAssetListQuery = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return request<AdminEditorAssetListResponse>(
|
||||
`/admin/api/editor-assets${buildEditorAssetListQuery(query)}`,
|
||||
{ token },
|
||||
{ token, signal },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -409,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;
|
||||
@@ -416,6 +475,10 @@ export interface AdminEditorAssetPayload {
|
||||
generationCostMudPoints: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
generator: string;
|
||||
taskGenerator: string;
|
||||
taskCostMudPoints: number;
|
||||
children: AdminEditorAssetPayload[];
|
||||
}
|
||||
|
||||
export interface AdminEditorAssetListResponse {
|
||||
@@ -629,6 +692,7 @@ export interface ProfileCodeOperationAdminResponse {
|
||||
code: string;
|
||||
action: 'create' | 'update' | 'disable' | string;
|
||||
operatorUserId: string;
|
||||
operatorDisplayName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -665,8 +729,10 @@ export interface ProfileTaskConfigAdminResponse {
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdBy: string;
|
||||
createdByDisplayName: string;
|
||||
createdAt: string;
|
||||
updatedBy: string;
|
||||
updatedByDisplayName: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -705,8 +771,10 @@ export interface ProfileWalletConfigAdminResponse {
|
||||
configId: string;
|
||||
initialMudPoints: number;
|
||||
createdBy: string;
|
||||
createdByDisplayName: string;
|
||||
createdAt: string;
|
||||
updatedBy: string;
|
||||
updatedByDisplayName: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -762,8 +830,10 @@ export interface AdminWalletManualRestrictionPayload {
|
||||
frozen: boolean;
|
||||
reason: string;
|
||||
createdByAdminUserId: string;
|
||||
createdByAdminDisplayName: string;
|
||||
createdAtMicros: number;
|
||||
updatedByAdminUserId: string;
|
||||
updatedByAdminDisplayName: string;
|
||||
updatedAtMicros: number;
|
||||
}
|
||||
|
||||
@@ -785,7 +855,9 @@ export interface AdminProfileWalletPayload {
|
||||
export interface AdminRechargeRefundPayload {
|
||||
outRefundNo: string;
|
||||
providerRefundId: string;
|
||||
providerTransactionId: string;
|
||||
providerStatus: string;
|
||||
totalCents: number;
|
||||
refundCents: number;
|
||||
payerRefundCents: number;
|
||||
successAtMicros?: number | null;
|
||||
@@ -800,6 +872,7 @@ export interface AdminRechargeRefundPayload {
|
||||
manualReviewResolvedByAdminUserId?: string | null;
|
||||
manualReviewResolutionReason?: string | null;
|
||||
manualReviewResolvedAtMicros?: number | null;
|
||||
manualReviewResolvedErrorCode?: string | null;
|
||||
}
|
||||
|
||||
export interface AdminRechargeRefundHoldPayload {
|
||||
@@ -882,6 +955,7 @@ export interface AdminRechargeRefundRegisterRequest {
|
||||
export interface AdminRechargeRefundManualReviewResolveRequest {
|
||||
outRefundNo: string;
|
||||
reason: string;
|
||||
expectedErrorCode: string;
|
||||
}
|
||||
|
||||
export interface AdminWalletRestrictionRequest {
|
||||
|
||||
@@ -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';
|
||||
@@ -37,7 +38,12 @@ 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';
|
||||
|
||||
@@ -55,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();
|
||||
@@ -107,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));
|
||||
@@ -163,69 +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 === 'gray-release' ? (
|
||||
{activeRouteId === 'gray-release' ? (
|
||||
<AdminGrayReleaseConfigPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'redeem' ? (
|
||||
{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}
|
||||
@@ -233,7 +272,7 @@ export function AdminApp() {
|
||||
onResultChange={setTaskConfigResult}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'profile-wallet' ? (
|
||||
{activeRouteId === 'profile-wallet' ? (
|
||||
<AdminProfileWalletConfigPage
|
||||
result={profileWalletConfigResult}
|
||||
token={token}
|
||||
@@ -241,7 +280,7 @@ export function AdminApp() {
|
||||
onResultChange={setProfileWalletConfigResult}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'recharge-products' ? (
|
||||
{activeRouteId === 'recharge-products' ? (
|
||||
<AdminRechargeProductPage
|
||||
result={rechargeProductResult}
|
||||
token={token}
|
||||
@@ -249,30 +288,36 @@ export function AdminApp() {
|
||||
onResultChange={setRechargeProductResult}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'recharge-orders' ? (
|
||||
{activeRouteId === 'recharge-orders' ? (
|
||||
<AdminRechargeOrderPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'editor-generation-pricing' ? (
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,16 +19,17 @@ import {
|
||||
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;
|
||||
@@ -53,11 +54,13 @@ const routeIcons = {
|
||||
'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,
|
||||
@@ -76,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
|
||||
@@ -99,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"
|
||||
@@ -116,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({
|
||||
@@ -79,3 +85,36 @@ test('后台充值管理路由可通过导航和 hash 访问', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -17,13 +17,17 @@ export type AdminRouteId =
|
||||
| '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[] = [
|
||||
@@ -45,8 +49,39 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{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] ?? '';
|
||||
|
||||
@@ -146,8 +146,10 @@ test('人工冻结和解除人工冻结分别提交原因且不解除退款欠
|
||||
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,
|
||||
},
|
||||
};
|
||||
@@ -174,6 +176,8 @@ test('人工冻结和解除人工冻结分别提交原因且不解除退款欠
|
||||
reason: '异常登录',
|
||||
});
|
||||
});
|
||||
expect(await screen.findByText(/后台负责人/)).toBeTruthy();
|
||||
expect(screen.queryByText(/admin:root/)).toBeNull();
|
||||
|
||||
expect(await screen.findByText('解除人工冻结后,退款欠账限制仍会保留。')).toBeTruthy();
|
||||
await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '核查完成');
|
||||
|
||||
@@ -236,7 +236,7 @@ export function AdminUserDetailDialog({
|
||||
<span>{detail.wallet.manualRestriction.reason || '未填写原因'}</span>
|
||||
<small>
|
||||
{formatMicros(detail.wallet.manualRestriction.updatedAtMicros)} /{' '}
|
||||
{detail.wallet.manualRestriction.updatedByAdminUserId}
|
||||
{detail.wallet.manualRestriction.updatedByAdminDisplayName}
|
||||
</small>
|
||||
</div>
|
||||
) : 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,41 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {render, screen} from '@testing-library/react';
|
||||
import {beforeEach, expect, test, vi} from 'vitest';
|
||||
|
||||
import {listProfileInviteCodes} from '../api/adminApiClient';
|
||||
import {AdminInviteCodePage} from './AdminInviteCodePage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
listProfileInviteCodes: vi.fn(),
|
||||
upsertProfileInviteCode: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listProfileInviteCodes).mockResolvedValue({
|
||||
entries: [],
|
||||
operations: [
|
||||
{
|
||||
operationId: 'operation-1',
|
||||
codeKind: 'invite',
|
||||
code: 'TEAM',
|
||||
action: 'create',
|
||||
operatorUserId: 'admin-account-internal-1',
|
||||
operatorDisplayName: '邀请运营',
|
||||
createdAt: '2026-07-14T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('操作记录只展示管理员显示名称', async () => {
|
||||
render(<AdminInviteCodePage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('邀请运营')).toBeTruthy();
|
||||
expect(screen.queryByText('admin-account-internal-1')).toBeNull();
|
||||
});
|
||||
@@ -278,7 +278,7 @@ export function AdminInviteCodePage({
|
||||
<tr key={operation.operationId}>
|
||||
<td>{operationActionLabel(operation.action)}</td>
|
||||
<td>{operation.code}</td>
|
||||
<td>{operation.operatorUserId}</td>
|
||||
<td>{operation.operatorDisplayName}</td>
|
||||
<td>{formatDateTime(operation.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -149,7 +149,7 @@ export function AdminProfileWalletConfigPage({
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新人</dt>
|
||||
<dd>{result.updatedBy || '-'}</dd>
|
||||
<dd>{result.updatedByDisplayName || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新</dt>
|
||||
|
||||
@@ -274,7 +274,7 @@ test('已登记的部分退款允许在 REFUND 支付状态下继续退剩余额
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('网络结果不明后重新预检会复用稳定 requestId 且双击只提交一次', async () => {
|
||||
test('网络结果不明后当前弹窗直接复用稳定 requestId', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(executeAdminRechargeRefund)
|
||||
.mockRejectedValueOnce(new Error('network disconnected'))
|
||||
@@ -284,21 +284,26 @@ test('网络结果不明后重新预检会复用稳定 requestId 且双击只提
|
||||
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
||||
await user.type(screen.getByLabelText('退款原因'), '用户申请');
|
||||
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
||||
await user.dblClick(screen.getByRole('button', { name: '确认退款' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认退款' }));
|
||||
|
||||
expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy();
|
||||
expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(1);
|
||||
const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock
|
||||
.calls[0]?.[1].requestId;
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
||||
await user.dblClick(screen.getByRole('button', { name: '确认退款' }));
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: '核验支付账单' })
|
||||
.hasAttribute('disabled'),
|
||||
).toBe(true);
|
||||
await user.click(screen.getByRole('button', { name: '继续核对退款' }));
|
||||
await waitFor(() =>
|
||||
expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(
|
||||
vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1].requestId,
|
||||
).toBe(firstRequestId);
|
||||
expect(previewAdminRechargeRefund).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('刷新后根据 active hold 恢复原退款请求并复用 requestId', async () => {
|
||||
@@ -350,6 +355,56 @@ test('刷新后根据 active hold 恢复原退款请求并复用 requestId', asy
|
||||
expect(previewAdminRechargeRefund).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('超长退款原因按服务端上限持久化并可在刷新后恢复', async () => {
|
||||
const user = userEvent.setup();
|
||||
const normalizedReason = '退'.repeat(80);
|
||||
vi.mocked(executeAdminRechargeRefund)
|
||||
.mockRejectedValueOnce(new Error('network disconnected'))
|
||||
.mockResolvedValueOnce(actionFor(baseOrder));
|
||||
const firstRender = renderPage();
|
||||
await openPartialRefund(user);
|
||||
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
||||
await user.type(screen.getByLabelText('退款原因'), `${normalizedReason}多`);
|
||||
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
||||
await user.click(screen.getByRole('button', { name: '确认退款' }));
|
||||
|
||||
expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy();
|
||||
const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock
|
||||
.calls[0]?.[1].requestId;
|
||||
expect(vi.mocked(executeAdminRechargeRefund).mock.calls[0]?.[1].reason).toBe(
|
||||
normalizedReason,
|
||||
);
|
||||
firstRender.unmount();
|
||||
|
||||
const activeHoldOrder = orderWithActiveHold();
|
||||
activeHoldOrder.activeHold = {
|
||||
...activeHoldOrder.activeHold!,
|
||||
reason: normalizedReason,
|
||||
};
|
||||
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
|
||||
entries: [activeHoldOrder],
|
||||
});
|
||||
renderPage();
|
||||
await user.click(await screen.findByRole('button', { name: '核对退款' }));
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '退款处理' })).toBeTruthy();
|
||||
expect(screen.getByLabelText('退款原因')).toHaveProperty(
|
||||
'value',
|
||||
normalizedReason,
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '继续核对退款' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
reason: normalizedReason,
|
||||
requestId: firstRequestId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('过期退款请求上下文不会恢复 active hold,只开放安全查单', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce(
|
||||
@@ -469,7 +524,16 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])(
|
||||
entries: [
|
||||
{
|
||||
...baseOrder,
|
||||
refunds: [manualReviewRefund({ lastErrorCode })],
|
||||
refunds: [
|
||||
manualReviewRefund({
|
||||
lastErrorCode,
|
||||
providerTransactionId:
|
||||
lastErrorCode === 'provider_transaction_id_mismatch'
|
||||
? 'wx-transaction-other'
|
||||
: 'wx-transaction-1',
|
||||
totalCents: lastErrorCode === 'order_total_mismatch' ? 700 : 600,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -480,6 +544,29 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])(
|
||||
const submitButton = within(dialog).getByRole('button', {
|
||||
name: '确认并追回',
|
||||
});
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
lastErrorCode === 'provider_transaction_id_mismatch'
|
||||
? '微信交易单号不一致'
|
||||
: '订单总额不一致',
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByText('本地微信交易单号').parentElement?.textContent,
|
||||
).toContain('wx-transaction-1');
|
||||
expect(
|
||||
within(dialog).getByText('退款微信交易单号').parentElement?.textContent,
|
||||
).toContain(
|
||||
lastErrorCode === 'provider_transaction_id_mismatch'
|
||||
? 'wx-transaction-other'
|
||||
: 'wx-transaction-1',
|
||||
);
|
||||
expect(
|
||||
within(dialog).getByText('本地订单总额').parentElement?.textContent,
|
||||
).toContain('¥6.00');
|
||||
expect(
|
||||
within(dialog).getByText('退款订单总额').parentElement?.textContent,
|
||||
).toContain(lastErrorCode === 'order_total_mismatch' ? '¥7.00' : '¥6.00');
|
||||
expect(submitButton.hasAttribute('disabled')).toBe(true);
|
||||
await user.type(
|
||||
within(dialog).getByRole('textbox', { name: '人工复核原因' }),
|
||||
@@ -493,6 +580,7 @@ test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])(
|
||||
{
|
||||
outRefundNo: 'refund-manual-1',
|
||||
reason: '已核对微信商户平台原始账单',
|
||||
expectedErrorCode: lastErrorCode,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -511,7 +599,12 @@ test('非白名单、已处理和会员退款不显示人工复核按钮', async
|
||||
...baseOrder,
|
||||
orderId: 'order-resolved',
|
||||
refunds: [
|
||||
manualReviewRefund({ manualReviewResolvedAtMicros: 1_720_000 }),
|
||||
manualReviewRefund({
|
||||
manualReviewResolvedAtMicros: 1_720_000,
|
||||
manualReviewResolvedByAdminUserId: 'admin-1',
|
||||
manualReviewResolutionReason: '已核对商户平台',
|
||||
manualReviewResolvedErrorCode: 'provider_transaction_id_mismatch',
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -526,6 +619,10 @@ test('非白名单、已处理和会员退款不显示人工复核按钮', async
|
||||
|
||||
await screen.findByText('order-membership');
|
||||
expect(screen.queryByRole('button', { name: '人工复核' })).toBeNull();
|
||||
expect(screen.getByText(/复核 微信交易单号不一致/).textContent).toContain(
|
||||
'admin-1',
|
||||
);
|
||||
expect(screen.getByText(/已核对商户平台/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test('支付侧已退款但泥点不足时持续展示异常欠账与消费限制', async () => {
|
||||
@@ -645,7 +742,9 @@ function manualReviewRefund(
|
||||
return {
|
||||
outRefundNo: 'refund-manual-1',
|
||||
providerRefundId: 'provider-refund-1',
|
||||
providerTransactionId: 'wx-transaction-other',
|
||||
providerStatus: 'SUCCESS',
|
||||
totalCents: 600,
|
||||
refundCents: 300,
|
||||
payerRefundCents: 300,
|
||||
successAtMicros: 1_720_000_000_000_000,
|
||||
@@ -660,6 +759,7 @@ function manualReviewRefund(
|
||||
manualReviewResolvedByAdminUserId: null,
|
||||
manualReviewResolutionReason: null,
|
||||
manualReviewResolvedAtMicros: null,
|
||||
manualReviewResolvedErrorCode: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ interface PersistedRefundRequestContext {
|
||||
}
|
||||
|
||||
interface ManualReviewTarget {
|
||||
orderId: string;
|
||||
order: AdminRechargeOrderEntryPayload;
|
||||
refund: AdminRechargeRefundPayload;
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ export function AdminRechargeOrderPage({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const reason = refundReason.trim();
|
||||
const reason = normalizeRefundReason(refundReason);
|
||||
if (!reason) {
|
||||
setRefundError('请填写退款原因');
|
||||
return;
|
||||
@@ -345,6 +345,7 @@ export function AdminRechargeOrderPage({
|
||||
if (!isAdminApiError(error)) {
|
||||
setProviderStatusUnknown(true);
|
||||
setPreview(null);
|
||||
setResumableRefundRequest(requestContext);
|
||||
setRefundError(unknownProviderStatusMessage);
|
||||
} else {
|
||||
removePersistedRefundRequestContext(token, refundOrder.orderId);
|
||||
@@ -402,7 +403,7 @@ export function AdminRechargeOrderPage({
|
||||
order: AdminRechargeOrderEntryPayload,
|
||||
refund: AdminRechargeRefundPayload,
|
||||
) {
|
||||
setManualReviewTarget({ orderId: order.orderId, refund });
|
||||
setManualReviewTarget({ order, refund });
|
||||
setManualReviewReason('');
|
||||
setManualReviewError('');
|
||||
}
|
||||
@@ -410,15 +411,23 @@ export function AdminRechargeOrderPage({
|
||||
async function handleResolveManualReview(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const reason = manualReviewReason.trim();
|
||||
if (!manualReviewTarget || manualReviewLockRef.current || !reason) {
|
||||
const expectedErrorCode = manualReviewTarget?.refund.lastErrorCode?.trim();
|
||||
if (
|
||||
!manualReviewTarget ||
|
||||
manualReviewLockRef.current ||
|
||||
!reason ||
|
||||
!expectedErrorCode
|
||||
) {
|
||||
if (!reason) {
|
||||
setManualReviewError('请填写人工复核原因');
|
||||
} else if (!expectedErrorCode) {
|
||||
setManualReviewError('退款人工复核状态已变化,请刷新后重试');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirmWrite({
|
||||
action: '确认微信退款并追回泥点',
|
||||
target: `${manualReviewTarget.orderId} / ${manualReviewTarget.refund.outRefundNo}`,
|
||||
target: `${manualReviewTarget.order.orderId} / ${manualReviewTarget.refund.outRefundNo}`,
|
||||
});
|
||||
if (!confirmed || manualReviewLockRef.current) {
|
||||
return;
|
||||
@@ -431,6 +440,7 @@ export function AdminRechargeOrderPage({
|
||||
const response = await resolveAdminRechargeRefundManualReview(token, {
|
||||
outRefundNo: manualReviewTarget.refund.outRefundNo,
|
||||
reason,
|
||||
expectedErrorCode,
|
||||
});
|
||||
setLastAction(response);
|
||||
upsertOrder(response.order);
|
||||
@@ -772,6 +782,7 @@ export function AdminRechargeOrderPage({
|
||||
<input
|
||||
aria-label="退款原因"
|
||||
disabled={isSubmitting || Boolean(resumableRefundRequest)}
|
||||
maxLength={80}
|
||||
value={refundReason}
|
||||
onChange={(event) => setRefundReason(event.target.value)}
|
||||
/>
|
||||
@@ -966,6 +977,36 @@ export function AdminRechargeOrderPage({
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<dl className="admin-info-list" aria-label="退款冲突核对信息">
|
||||
<div>
|
||||
<dt>冲突类型</dt>
|
||||
<dd>
|
||||
{formatManualReviewErrorCode(
|
||||
manualReviewTarget.refund.lastErrorCode,
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>本地微信交易单号</dt>
|
||||
<dd>{manualReviewTarget.order.providerTransactionId || '-'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>退款微信交易单号</dt>
|
||||
<dd>{manualReviewTarget.refund.providerTransactionId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>本地订单总额</dt>
|
||||
<dd>{formatMoney(manualReviewTarget.order.amountCents)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>退款订单总额</dt>
|
||||
<dd>{formatMoney(manualReviewTarget.refund.totalCents)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>本次退款金额</dt>
|
||||
<dd>{formatMoney(manualReviewTarget.refund.refundCents)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<label className="admin-field">
|
||||
<span>人工复核原因</span>
|
||||
<textarea
|
||||
@@ -1047,6 +1088,7 @@ function RechargeOrderRow({
|
||||
}) {
|
||||
const userName = order.user?.displayName || '未读取用户资料';
|
||||
const manualReviewRefund = findResolvableManualReviewRefund(order);
|
||||
const resolvedManualReviewRefund = findResolvedManualReviewRefund(order);
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
@@ -1088,6 +1130,25 @@ function RechargeOrderRow({
|
||||
{order.activeHold ? (
|
||||
<small>占用 {order.activeHold.heldPoints} 泥点</small>
|
||||
) : null}
|
||||
{resolvedManualReviewRefund ? (
|
||||
<>
|
||||
<small>
|
||||
复核{' '}
|
||||
{formatManualReviewErrorCode(
|
||||
resolvedManualReviewRefund.manualReviewResolvedErrorCode,
|
||||
)}{' '}
|
||||
/{' '}
|
||||
{resolvedManualReviewRefund.manualReviewResolvedByAdminUserId ||
|
||||
'-'}
|
||||
</small>
|
||||
<small>
|
||||
{resolvedManualReviewRefund.manualReviewResolutionReason || '-'} /{' '}
|
||||
{formatMicros(
|
||||
resolvedManualReviewRefund.manualReviewResolvedAtMicros ?? 0,
|
||||
)}
|
||||
</small>
|
||||
</>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<span>可消费 {order.wallet.spendableBalance}</span>
|
||||
@@ -1321,6 +1382,19 @@ function formatRefundBlockReason(code?: string | null) {
|
||||
return labels[code.toLowerCase()] ?? `当前不可退款:${code}`;
|
||||
}
|
||||
|
||||
function formatManualReviewErrorCode(code?: string | null) {
|
||||
const normalized = code?.trim().toLowerCase();
|
||||
const labels: Record<string, string> = {
|
||||
provider_transaction_id_mismatch: '微信交易单号不一致',
|
||||
order_total_mismatch: '订单总额不一致',
|
||||
};
|
||||
return normalized ? (labels[normalized] ?? normalized) : '-';
|
||||
}
|
||||
|
||||
function normalizeRefundReason(value: string) {
|
||||
return Array.from(value.trim()).slice(0, 80).join('');
|
||||
}
|
||||
|
||||
function datetimeLocalToRfc3339(value: string) {
|
||||
const normalized = value.trim();
|
||||
if (!normalized) {
|
||||
@@ -1369,6 +1443,16 @@ function findResolvableManualReviewRefund(
|
||||
);
|
||||
}
|
||||
|
||||
function findResolvedManualReviewRefund(order: AdminRechargeOrderEntryPayload) {
|
||||
return order.refunds.find(
|
||||
(refund) =>
|
||||
Boolean(refund.manualReviewResolvedErrorCode) &&
|
||||
Boolean(refund.manualReviewResolvedByAdminUserId) &&
|
||||
Boolean(refund.manualReviewResolutionReason) &&
|
||||
Boolean(refund.manualReviewResolvedAtMicros),
|
||||
);
|
||||
}
|
||||
|
||||
function createRequestId() {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
|
||||
@@ -68,7 +68,17 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listProfileRedeemCodes).mockResolvedValue({
|
||||
entries,
|
||||
operations: [],
|
||||
operations: [
|
||||
{
|
||||
operationId: 'operation-1',
|
||||
codeKind: 'redeem',
|
||||
code: 'LONG-LIVED',
|
||||
action: 'create',
|
||||
operatorUserId: 'admin-account-internal-1',
|
||||
operatorDisplayName: '兑换码运营',
|
||||
createdAt: '2026-07-13T01:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(upsertProfileRedeemCode).mockResolvedValue(baseEntry);
|
||||
vi.mocked(disableProfileRedeemCode).mockResolvedValue({
|
||||
@@ -77,6 +87,13 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
|
||||
test('操作记录只展示管理员显示名称', async () => {
|
||||
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('兑换码运营')).toBeTruthy();
|
||||
expect(screen.queryByText('admin-account-internal-1')).toBeNull();
|
||||
});
|
||||
|
||||
test('兑换码列表展示生效状态与日期范围', async () => {
|
||||
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -402,7 +402,7 @@ export function AdminRedeemCodePage({
|
||||
<tr key={operation.operationId}>
|
||||
<td>{operationActionLabel(operation.action)}</td>
|
||||
<td>{operation.code}</td>
|
||||
<td>{operation.operatorUserId}</td>
|
||||
<td>{operation.operatorDisplayName}</td>
|
||||
<td>{formatDateTime(operation.createdAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -553,7 +553,7 @@ export function AdminTaskConfigPage({
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新人</dt>
|
||||
<dd>{result.updatedBy}</dd>
|
||||
<dd>{result.updatedByDisplayName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新</dt>
|
||||
|
||||
@@ -216,6 +216,17 @@ button:disabled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-zero-permission-state {
|
||||
min-height: 180px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.admin-zero-permission-state h2 {
|
||||
margin: 0;
|
||||
color: #6f5848;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.admin-page {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
@@ -350,6 +361,63 @@ button:disabled {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 112px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 8px 16px;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card + .admin-dashboard-retention-card {
|
||||
border-left: 1px solid #ead8ca;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > span {
|
||||
color: #8f7868;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > strong {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 2;
|
||||
color: #8f3f27;
|
||||
font-size: 30px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > div {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card small {
|
||||
min-width: 0;
|
||||
color: #a38f80;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card b {
|
||||
flex: 0 0 auto;
|
||||
color: #755a49;
|
||||
font-size: 13px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-dashboard-chart-grid,
|
||||
.admin-dashboard-operations {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -406,7 +474,10 @@ button:disabled {
|
||||
gap: 8px;
|
||||
min-height: 196px;
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-inline: contain;
|
||||
padding: 4px 2px 0;
|
||||
scrollbar-color: #bdaea3 #f4e5d7;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-item {
|
||||
@@ -513,6 +584,90 @@ button:disabled {
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
|
||||
}
|
||||
|
||||
.admin-accounts-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 0.42fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-account-list {
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.admin-account-list-items {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-account-list-items > button {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 58px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
border: 1px solid #eaded2;
|
||||
border-radius: 8px;
|
||||
color: #755a49;
|
||||
background: #fffdf9;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-account-list-items > button[data-active="true"] {
|
||||
border-color: #c87955;
|
||||
background: #f9eee5;
|
||||
}
|
||||
|
||||
.admin-account-list-items > button > span {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-account-list-items strong,
|
||||
.admin-account-list-items small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-account-list-items small {
|
||||
color: #8f7868;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-permission-fieldset {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
border: 1px solid #e1ccbb;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.admin-permission-fieldset legend {
|
||||
color: #6f5848;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.admin-permission-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px 14px;
|
||||
}
|
||||
|
||||
.admin-permission-grid label {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #5f4738;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-stack,
|
||||
.admin-form {
|
||||
display: grid;
|
||||
@@ -566,7 +721,17 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-inline-identity > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-inline-identity small {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-database-user-cell,
|
||||
@@ -655,6 +820,55 @@ button:disabled {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-asset-query-resource-cell {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-asset-query-resource-cell > div:last-child small {
|
||||
display: block;
|
||||
max-width: 92px;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-asset-query-expand-button {
|
||||
display: inline-flex;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #78523e;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-asset-query-expand-spacer,
|
||||
.admin-asset-query-child-branch {
|
||||
display: block;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.admin-asset-query-child-row {
|
||||
background: #fffaf5;
|
||||
}
|
||||
|
||||
.admin-asset-query-resource-cell-child {
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.admin-asset-query-child-branch {
|
||||
height: 34px;
|
||||
border-bottom: 1px solid #d8c3b3;
|
||||
border-left: 1px solid #d8c3b3;
|
||||
}
|
||||
|
||||
.admin-asset-query-prompt-text {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
@@ -1117,7 +1331,7 @@ button:disabled {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-info-list div {
|
||||
.admin-info-list > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(90px, 0.34fr) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
@@ -1307,29 +1521,30 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-asset-query-table {
|
||||
min-width: 1080px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(1),
|
||||
.admin-asset-query-table td:nth-child(1) {
|
||||
width: 10%;
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(2),
|
||||
.admin-asset-query-table td:nth-child(2),
|
||||
.admin-asset-query-table th:nth-child(3),
|
||||
.admin-asset-query-table td:nth-child(3) {
|
||||
width: 14%;
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(4),
|
||||
.admin-asset-query-table td:nth-child(4) {
|
||||
width: 34%;
|
||||
width: 27%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(5),
|
||||
.admin-asset-query-table td:nth-child(5) {
|
||||
width: 8%;
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(6),
|
||||
@@ -1337,6 +1552,11 @@ button:disabled {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-asset-query-table th:nth-child(7),
|
||||
.admin-asset-query-table td:nth-child(7) {
|
||||
width: 10%;
|
||||
}
|
||||
|
||||
.admin-showcase-review-table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
@@ -1861,6 +2081,7 @@ button:disabled {
|
||||
.admin-dashboard-operations,
|
||||
.admin-two-column,
|
||||
.admin-two-column-wide,
|
||||
.admin-accounts-layout,
|
||||
.admin-pricing-grid,
|
||||
.admin-form-row,
|
||||
.admin-filter-grid,
|
||||
@@ -1870,6 +2091,10 @@ button:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-permission-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-heading {
|
||||
display: grid;
|
||||
}
|
||||
@@ -1890,10 +2115,19 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
.admin-dashboard-operation-grid,
|
||||
.admin-dashboard-retention-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card:nth-child(odd) {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card:nth-child(n + 3) {
|
||||
border-top: 1px solid #ead8ca;
|
||||
}
|
||||
|
||||
.admin-field-compact {
|
||||
max-width: none;
|
||||
}
|
||||
@@ -1965,6 +2199,10 @@ button:disabled {
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.admin-permission-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-login-panel,
|
||||
.admin-panel {
|
||||
padding: 16px;
|
||||
@@ -2022,11 +2260,19 @@ button:disabled {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.admin-info-list div {
|
||||
.admin-info-list > div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-asset-query-detail-thumb-button {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -2035,8 +2281,30 @@ button:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card + .admin-dashboard-retention-card {
|
||||
border-top: 1px solid #ead8ca;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > strong {
|
||||
grid-row: auto;
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.admin-dashboard-retention-card > div {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
.admin-dashboard-operation-grid,
|
||||
.admin-dashboard-retention-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ AUTH_REFRESH_COOKIE_SECURE=false
|
||||
GENARRATIVE_SPACETIME_SERVER_URL=http://spacetimedb:3101
|
||||
GENARRATIVE_SPACETIME_DATABASE=genarrative-loadtest
|
||||
GENARRATIVE_SPACETIME_TOKEN=
|
||||
# HTTP 角色使用 8 条无 read-model 订阅的调用连接,并额外创建 1 条共享缓存读连接。
|
||||
GENARRATIVE_SPACETIME_POOL_SIZE=8
|
||||
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user