diff --git a/.codex/hooks/pre-submit-compile-check.mjs b/.codex/hooks/pre-submit-compile-check.mjs index 97a5b305a..13f62b64b 100644 --- a/.codex/hooks/pre-submit-compile-check.mjs +++ b/.codex/hooks/pre-submit-compile-check.mjs @@ -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(' '); } diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index b2127d332..85f029e37 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -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', }), }), ); diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 89a9bf649..8b1f4eb25 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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('/admin/api/me', { token }); } +export function listAdminAccounts(token: string) { + return request('/admin/api/accounts', {token}); +} + +export function createAdminAccount( + token: string, + payload: AdminCreateAccountRequest, +) { + return request('/admin/api/accounts', { + method: 'POST', + token, + body: payload, + }); +} + +export function updateAdminAccount( + token: string, + accountId: string, + payload: AdminUpdateAccountRequest, +) { + return request( + `/admin/api/accounts/${encodeURIComponent(accountId)}`, + {method: 'PUT', token, body: payload}, + ); +} + export function getAdminOverview(token: string) { return request('/admin/api/overview', { token }); } @@ -367,10 +398,11 @@ export function getAdminAssetReadUrl( export function listAdminEditorAssets( token: string, query: AdminEditorAssetListQuery = {}, + signal?: AbortSignal, ) { return request( `/admin/api/editor-assets${buildEditorAssetListQuery(query)}`, - { token }, + { token, signal }, ); } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 1afe7f04f..2aff05de0 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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 | 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 { diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 9a596528c..2e58ac6f8 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -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(null); const [rechargeProductResult, setRechargeProductResult] = useState(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 ( - {routeId === 'dashboard' ? ( + {activeRouteId === null ? ( +
+

暂无访问权限

+
+ ) : null} + {activeRouteId === 'dashboard' ? ( ) : null} - {routeId === 'overview' ? ( + {activeRouteId === 'overview' ? ( ) : null} - {routeId === 'tables' ? ( + {activeRouteId === 'tables' ? ( ) : null} - {routeId === 'debug' ? ( + {activeRouteId === 'debug' ? ( ) : null} - {routeId === 'tracking' ? ( + {activeRouteId === 'tracking' ? ( ) : null} - {routeId === 'gray-release' ? ( + {activeRouteId === 'gray-release' ? ( ) : null} - {routeId === 'redeem' ? ( + {activeRouteId === 'redeem' ? ( ) : null} - {routeId === 'invite' ? ( + {activeRouteId === 'invite' ? ( ) : null} - {routeId === 'creation-announcement' ? ( + {activeRouteId === 'creation-announcement' ? ( ) : null} - {routeId === 'creation-entry' ? ( + {activeRouteId === 'creation-entry' ? ( ) : null} - {routeId === 'work-visibility' ? ( + {activeRouteId === 'work-visibility' ? ( ) : null} - {routeId === 'tasks' ? ( + {activeRouteId === 'tasks' ? ( ) : null} - {routeId === 'profile-wallet' ? ( + {activeRouteId === 'profile-wallet' ? ( ) : null} - {routeId === 'recharge-products' ? ( + {activeRouteId === 'recharge-products' ? ( ) : null} - {routeId === 'recharge-orders' ? ( + {activeRouteId === 'recharge-orders' ? ( ) : null} - {routeId === 'editor-generation-pricing' ? ( + {activeRouteId === 'editor-generation-pricing' ? ( ) : null} - {routeId === 'editor-showcase' ? ( + {activeRouteId === 'editor-showcase' ? ( ) : null} - {routeId === 'editor-assets' ? ( + {activeRouteId === 'editor-assets' ? ( ) : null} + {activeRouteId === 'accounts' ? ( + + ) : null}
); } diff --git a/apps/admin-web/src/app/AdminShell.tsx b/apps/admin-web/src/app/AdminShell.tsx index 557b1f42a..12c050fab 100644 --- a/apps/admin-web/src/app/AdminShell.tsx +++ b/apps/admin-web/src/app/AdminShell.tsx @@ -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; export function AdminShell({ admin, routeId, + routes, children, onRouteChange, onLogout, @@ -76,7 +79,7 @@ export function AdminShell({