合并 master 最新改动

同步 origin/master 至 b303605bf
保留项目总控 Runtime 决策并合入后台账号与画布更新
沿用当前 platform-llm LlmRunRequest 接口解决背景决策冲突
This commit is contained in:
AIGameCreator App
2026-07-16 17:07:13 +08:00
187 changed files with 12640 additions and 1572 deletions
+31 -6
View File
@@ -14,20 +14,40 @@ if (hookInput && !isGitCommitCommand(extractShellCommand(hookInput))) {
}
const validationSteps = [
{
label: 'Rust format check',
command: npmCommand,
args:
process.platform === 'win32'
? ['/d', '/s', '/c', 'npm run check:rustfmt']
: ['run', 'check:rustfmt'],
},
{
label: 'TypeScript typecheck',
command: npmCommand,
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run typecheck'] : ['run', 'typecheck'],
args:
process.platform === 'win32'
? ['/d', '/s', '/c', 'npm run typecheck']
: ['run', 'typecheck'],
},
{
label: 'Admin web typecheck',
command: npmCommand,
args: process.platform === 'win32' ? ['/d', '/s', '/c', 'npm run admin-web:typecheck'] : ['run', 'admin-web:typecheck'],
args:
process.platform === 'win32'
? ['/d', '/s', '/c', 'npm run admin-web:typecheck']
: ['run', 'admin-web:typecheck'],
},
{
label: 'Rust api-server compile check',
command: 'cargo',
args: ['check', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'],
args: [
'check',
'-p',
'api-server',
'--manifest-path',
'server-rs/Cargo.toml',
],
},
];
@@ -66,7 +86,9 @@ function runStep(step) {
}
if (result.error) {
console.error(`[codex-hook] ${step.label} 启动失败:${result.error.message}`);
console.error(
`[codex-hook] ${step.label} 启动失败:${result.error.message}`,
);
return { ok: false, status: 1 };
}
@@ -104,12 +126,15 @@ function extractShellCommand(input) {
input?.command,
];
const command = candidates.find(value => typeof value === 'string' && value.trim().length > 0);
const command = candidates.find(
(value) => typeof value === 'string' && value.trim().length > 0,
);
if (command) {
return command;
}
const shellCommand = input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd;
const shellCommand =
input?.tool_input?.cmd ?? input?.toolInput?.cmd ?? input?.arguments?.cmd;
if (Array.isArray(shellCommand)) {
return shellCommand.join(' ');
}
+70 -13
View File
@@ -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',
}),
}),
);
+33 -1
View File
@@ -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 },
);
}
+74
View File
@@ -36,10 +36,53 @@ export interface AdminSessionPayload {
username: string;
displayName: string;
roles: string[];
accountRole: 'owner' | 'member';
tabPermissions: string[];
issuedAt: string;
expiresAt: string;
}
export interface AdminAccountPayload {
accountId: string;
username: string;
displayName: string;
accountRole: 'owner' | 'member';
tabPermissions: string[];
enabled: boolean;
tokenVersion: number;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
export interface AdminAccountListResponse {
accounts: AdminAccountPayload[];
}
export interface AdminCreateAccountRequest {
username: string;
displayName: string;
password: string;
tabPermissions: string[];
enabled: boolean;
}
export interface AdminCreateAccountResponse {
account: AdminAccountPayload;
}
export interface AdminUpdateAccountRequest {
displayName: string;
password?: string;
tabPermissions: string[];
enabled: boolean;
}
export interface AdminUpdateAccountResponse {
account: AdminAccountPayload;
}
export interface AdminLoginResponse {
token: string;
admin: AdminSessionPayload;
@@ -85,6 +128,9 @@ export interface AdminDashboardMetricsPayload {
consumedMudPoints: number;
totalRegisteredUsers: number;
newRegisteredUsers: number;
newUserPaymentConversion: AdminDashboardPaymentConversionPayload;
day1Retention: AdminDashboardRetentionMetricPayload;
day7Retention: AdminDashboardRetentionMetricPayload;
visitUsers: number;
totalVisitUsers: number;
visitCount: number;
@@ -92,6 +138,18 @@ export interface AdminDashboardMetricsPayload {
currentUsers: number;
}
export interface AdminDashboardPaymentConversionPayload {
paidUsers: number;
newRegisteredUsers: number;
rateBasisPoints: number;
}
export interface AdminDashboardRetentionMetricPayload {
eligibleUsers: number;
retainedUsers: number;
rateBasisPoints: number;
}
export interface AdminDashboardChartPayload {
id: string;
title: string;
@@ -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 {
+66 -21
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useState} from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {
formatAdminApiError,
@@ -18,6 +18,7 @@ import {
setStoredAdminToken,
} from '../auth/adminAuthStore';
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
import {AdminAccountsPage} from '../pages/AdminAccountsPage';
import {AdminDashboardPage} from '../pages/AdminDashboardPage';
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
@@ -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>
);
}
+9 -6
View File
@@ -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
+40 -1
View File
@@ -1,6 +1,12 @@
import {expect, test} from 'vitest';
import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes';
import {
adminRoutes,
getAccessibleAdminRoutes,
resolveAccessibleAdminRoute,
resolveAdminRoute,
routeHash,
} from './adminRoutes';
test('后台默认进入 Dashboard', () => {
expect(adminRoutes[0]).toEqual({
@@ -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();
});
+36 -1
View File
@@ -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,
},
});
}
+158 -31
View File
@@ -1,5 +1,5 @@
import { RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAdminDashboard } from '../api/adminApiClient';
import type {
@@ -42,24 +42,29 @@ export function AdminDashboardPage({
const [activeTab, setActiveTab] = useState<AdminDashboardTab>('metrics');
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [chartScrollLeft, setChartScrollLeft] = useState(0);
const today = formatBeijingDateInput(new Date());
const loadDashboard = useCallback(async (range = dateRange) => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminDashboard(token, {
granularity: 'period',
anchor: undefined,
startDate: range.startDate,
endDate: range.endDate,
});
setDashboard(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [dateRange, onUnauthorized, token]);
const loadDashboard = useCallback(
async (range = dateRange) => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminDashboard(token, {
granularity: 'period',
anchor: undefined,
startDate: range.startDate,
endDate: range.endDate,
});
setDashboard(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
},
[dateRange, onUnauthorized, token],
);
useEffect(() => {
void loadDashboard();
@@ -75,6 +80,10 @@ export function AdminDashboardPage({
return () => window.clearInterval(timer);
}, [loadDashboard]);
useEffect(() => {
setChartScrollLeft(0);
}, [dashboard?.range.periodEndDate, dashboard?.range.periodStartDate]);
const metrics = dashboard?.metrics;
const totalMetricCards = useMemo(
() => [
@@ -98,7 +107,7 @@ export function AdminDashboardPage({
},
{
id: 'current-users',
label: '当前使用人数(五分钟统计一次)',
label: '近 5 分钟活跃用户',
value: metrics?.currentUsers ?? 0,
unit: '人',
},
@@ -154,6 +163,7 @@ export function AdminDashboardPage({
<span></span>
<input
type="date"
max={today}
value={dateRange.startDate}
onChange={(event) =>
handleDateRangeChange('startDate', event.target.value)
@@ -164,6 +174,7 @@ export function AdminDashboardPage({
<span></span>
<input
type="date"
max={today}
value={dateRange.endDate}
onChange={(event) =>
handleDateRangeChange('endDate', event.target.value)
@@ -278,9 +289,51 @@ export function AdminDashboardPage({
</div>
</section>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{dashboard?.range.periodLabel ?? '-'}</span>
</div>
<div className="admin-dashboard-retention-grid">
<RateCard
label={`${rangePrefix(granularity)}新增用户付费率`}
numeratorLabel="付费人数"
denominatorLabel="新增人数"
numerator={metrics?.newUserPaymentConversion?.paidUsers}
denominator={
metrics?.newUserPaymentConversion?.newRegisteredUsers
}
rateBasisPoints={
metrics?.newUserPaymentConversion?.rateBasisPoints
}
/>
<RateCard
label="次日留存"
numeratorLabel="留存人数"
denominatorLabel="可观察新增人数"
numerator={metrics?.day1Retention.retainedUsers}
denominator={metrics?.day1Retention.eligibleUsers}
rateBasisPoints={metrics?.day1Retention.rateBasisPoints}
/>
<RateCard
label="七日留存"
numeratorLabel="留存人数"
denominatorLabel="可观察新增人数"
numerator={metrics?.day7Retention.retainedUsers}
denominator={metrics?.day7Retention.eligibleUsers}
rateBasisPoints={metrics?.day7Retention.rateBasisPoints}
/>
</div>
</section>
<div className="admin-dashboard-chart-grid">
{(dashboard?.charts ?? []).map((chart) => (
<ChartPanel key={chart.id} chart={chart} />
<ChartPanel
key={chart.id}
chart={chart}
scrollLeft={chartScrollLeft}
onScrollLeftChange={setChartScrollLeft}
/>
))}
{dashboard && dashboard.charts.length === 0 ? (
<div className="admin-empty-state"></div>
@@ -347,9 +400,10 @@ export function AdminDashboardPage({
if (!parseDateValueAsUtc(value)) {
return;
}
const clampedValue = value > today ? today : value;
setGranularity('period');
setDateRange((current) =>
normalizeDateRange({ ...current, [field]: value }),
normalizeDateRange({ ...current, [field]: clampedValue }),
);
}
}
@@ -377,17 +431,85 @@ function MetricCard({
);
}
function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
function RateCard({
label,
numeratorLabel,
denominatorLabel,
numerator,
denominator,
rateBasisPoints,
}: {
label: string;
numeratorLabel: string;
denominatorLabel: string;
numerator?: number;
denominator?: number;
rateBasisPoints?: number;
}) {
const hasDenominator = Boolean(denominator);
return (
<article className="admin-dashboard-retention-card">
<span>{label}</span>
<strong>
{hasDenominator && rateBasisPoints !== undefined
? formatRateBasisPoints(rateBasisPoints)
: '-'}
</strong>
<div>
<small>
{numeratorLabel} / {denominatorLabel}
</small>
<b>
{numerator !== undefined && denominator !== undefined
? `${formatNumber(numerator)} / ${formatNumber(denominator)}`
: '-'}
</b>
</div>
</article>
);
}
function ChartPanel({
chart,
scrollLeft,
onScrollLeftChange,
}: {
chart: AdminDashboardChartPayload;
scrollLeft: number;
onScrollLeftChange: (scrollLeft: number) => void;
}) {
const barsRef = useRef<HTMLDivElement>(null);
const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value));
useEffect(() => {
const element = barsRef.current;
if (!element) {
return;
}
if (Math.abs(element.scrollLeft - scrollLeft) > 1) {
element.scrollLeft = scrollLeft;
}
}, [chart.buckets.length, scrollLeft]);
return (
<section className="admin-panel admin-dashboard-chart-card">
<div className="admin-panel-heading">
<h3>{chart.title}</h3>
<span>
{chart.id === 'visit-users' ? '时段去重 ' : ''}
{formatNumber(chart.total)} {chart.unit}
</span>
</div>
<div className="admin-dashboard-bars">
<div
ref={barsRef}
className="admin-dashboard-bars"
role="region"
aria-label={`${chart.title}趋势图`}
tabIndex={0}
onScroll={(event) => {
onScrollLeftChange(event.currentTarget.scrollLeft);
}}
>
{chart.buckets.map((bucket) => (
<div className="admin-dashboard-bar-item" key={bucket.key}>
<div
@@ -396,7 +518,10 @@ function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
>
<span
style={{
height: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
height:
bucket.value === 0
? '0%'
: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
}}
/>
</div>
@@ -450,6 +575,13 @@ function formatNumber(value: number) {
return new Intl.NumberFormat('zh-CN').format(value);
}
function formatRateBasisPoints(value: number) {
return new Intl.NumberFormat('zh-CN', {
style: 'percent',
maximumFractionDigits: 2,
}).format(value / 10_000);
}
function formatBeijingDateInput(date: Date) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
@@ -498,9 +630,7 @@ function buildWeekDateRange(dateValue: string) {
const weekday = date.getUTCDay() || 7;
const monday = new Date(date);
monday.setUTCDate(date.getUTCDate() - weekday + 1);
const sunday = new Date(monday);
sunday.setUTCDate(monday.getUTCDate() + 6);
return { startDate: formatUtcDate(monday), endDate: formatUtcDate(sunday) };
return { startDate: formatUtcDate(monday), endDate: dateValue };
}
function buildMonthDateRange(dateValue: string) {
@@ -512,12 +642,9 @@ function buildMonthDateRange(dateValue: string) {
const firstDate = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1),
);
const lastDate = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
);
return {
startDate: formatUtcDate(firstDate),
endDate: formatUtcDate(lastDate),
endDate: dateValue,
};
}
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>
+276 -8
View File
@@ -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;
}
+1
View File
@@ -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