diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index a673e0e01..85f029e37 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -1,16 +1,61 @@ 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: [] }), { diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 89a9bf649..73540117a 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 }); } diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 31afc2d78..abe743139 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; @@ -629,6 +672,7 @@ export interface ProfileCodeOperationAdminResponse { code: string; action: 'create' | 'update' | 'disable' | string; operatorUserId: string; + operatorDisplayName: string; createdAt: string; } @@ -665,8 +709,10 @@ export interface ProfileTaskConfigAdminResponse { enabled: boolean; sortOrder: number; createdBy: string; + createdByDisplayName: string; createdAt: string; updatedBy: string; + updatedByDisplayName: string; updatedAt: string; } @@ -705,8 +751,10 @@ export interface ProfileWalletConfigAdminResponse { configId: string; initialMudPoints: number; createdBy: string; + createdByDisplayName: string; createdAt: string; updatedBy: string; + updatedByDisplayName: string; updatedAt: string; } @@ -762,8 +810,10 @@ export interface AdminWalletManualRestrictionPayload { frozen: boolean; reason: string; createdByAdminUserId: string; + createdByAdminDisplayName: string; createdAtMicros: number; updatedByAdminUserId: string; + updatedByAdminDisplayName: string; updatedAtMicros: number; } 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({