Merge remote-tracking branch 'origin/master' into feat/error-report
Project CI / Repository checks (pull_request) Failing after 1m40s
Project CI / Frontend tests (pull_request) Failing after 2m37s
Project CI / Backend tests (pull_request) Failing after 5m16s
Project CI / Native shell tests (pull_request) Failing after 5m26s

# Conflicts:
#	apps/ai-game-creator-shell/src-tauri/src/main.rs
#	apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/merge.rs
#	apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/recognition.rs
#	apps/ai-game-creator-shell/src-tauri/src/ui_editor/commands/ui_design_suggestion.rs
#	docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md
This commit is contained in:
2026-09-02 11:09:42 +08:00
1143 changed files with 44030 additions and 20972 deletions
@@ -74,9 +74,9 @@ test('后台账号创建和更新同时携带 Tab 与独立操作权限', async
test('账号配置一次提交初始和每日免费泥点', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({configId: 'profile_wallet'}), {
new Response(JSON.stringify({ configId: 'profile_wallet' }), {
status: 200,
headers: {'content-type': 'application/json'},
headers: { 'content-type': 'application/json' },
}),
);
vi.stubGlobal('fetch', fetchMock);
@@ -90,7 +90,7 @@ test('账号配置一次提交初始和每日免费泥点', async () => {
'/admin/api/profile/wallet-config',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({Authorization: 'Bearer owner-token'}),
headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }),
body: JSON.stringify({
initialMudPoints: 100,
dailyFreePointsPerDay: 35,
@@ -1,8 +1,8 @@
/* @vitest-environment jsdom */
import {render, screen, waitFor} from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
import {
getAdminUserDetail,
@@ -13,7 +13,7 @@ import type {
AdminProfileWalletPayload,
AdminUserDetailResponse,
} from '../api/adminApiTypes';
import {AdminUserReferenceButton} from './AdminUserReferenceButton';
import { AdminUserReferenceButton } from './AdminUserReferenceButton';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
@@ -93,7 +93,7 @@ beforeEach(() => {
changed: true,
reconciledAtMicros: 1_720_000_000_000_000,
});
vi.mocked(updateAdminWalletRestriction).mockResolvedValue({wallet});
vi.mocked(updateAdminWalletRestriction).mockResolvedValue({ wallet });
});
test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退款限制', async () => {
@@ -109,10 +109,10 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
</div>,
);
const trigger = screen.getByRole('button', {name: '查看用户信息'});
const trigger = screen.getByRole('button', { name: '查看用户信息' });
await user.click(trigger);
expect(parentClick).not.toHaveBeenCalled();
expect(await screen.findByRole('dialog', {name: '用户详情'})).toBeTruthy();
expect(await screen.findByRole('dialog', { name: '用户详情' })).toBeTruthy();
expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', {
userId: 'user-1',
publicUserCode: undefined,
@@ -121,13 +121,15 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退
expect(screen.getAllByText('TN1001').length).toBeGreaterThanOrEqual(1);
expect(screen.getByText('138****5678')).toBeTruthy();
expect(screen.getByText('退款欠账限制')).toBeTruthy();
expect(screen.getByText('25', {selector: 'strong'})).toBeTruthy();
expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('历史花费')).toBeTruthy();
expect(screen.getByText('1234', {selector: 'strong'})).toBeTruthy();
expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy();
expect(screen.getByText('order-1')).toBeTruthy();
await user.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('dialog', {name: '用户详情'})).toBeNull());
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: '用户详情' })).toBeNull(),
);
await waitFor(() => expect(document.activeElement).toBe(trigger));
});
@@ -141,7 +143,7 @@ test('只有陶泥号时按 publicUserCode 查询用户', async () => {
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
await screen.findByText('陶泥用户');
expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', {
userId: undefined,
@@ -159,10 +161,10 @@ test('历史花费支持手动对账并用权威结果校准展示', async () =>
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
await screen.findByText('陶泥用户');
await user.click(screen.getByRole('button', {name: '手动对账历史花费'}));
await user.click(screen.getByRole('button', {name: '确认'}));
await user.click(screen.getByRole('button', { name: '手动对账历史花费' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(reconcileAdminUserConsumption).toHaveBeenCalledWith('admin-token', {
@@ -170,7 +172,7 @@ test('历史花费支持手动对账并用权威结果校准展示', async () =>
});
});
expect(await screen.findByText('对账完成,历史花费已校准')).toBeTruthy();
expect(screen.getByText('1300', {selector: 'strong'})).toBeTruthy();
expect(screen.getByText('1300', { selector: 'strong' })).toBeTruthy();
});
test('没有独立操作权限时不显示历史花费对账按钮', async () => {
@@ -187,10 +189,10 @@ test('没有独立操作权限时不显示历史花费对账按钮', async () =>
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
await screen.findByText('陶泥用户');
expect(screen.queryByRole('button', {name: '手动对账历史花费'})).toBeNull();
expect(screen.queryByRole('button', { name: '手动对账历史花费' })).toBeNull();
});
test('人工冻结和解除人工冻结分别提交原因且不解除退款欠账限制', async () => {
@@ -211,8 +213,8 @@ test('人工冻结和解除人工冻结分别提交原因且不解除退款欠
},
};
vi.mocked(updateAdminWalletRestriction)
.mockResolvedValueOnce({wallet: manuallyFrozenWallet})
.mockResolvedValueOnce({wallet: {...wallet, manualFrozen: false}});
.mockResolvedValueOnce({ wallet: manuallyFrozenWallet })
.mockResolvedValueOnce({ wallet: { ...wallet, manualFrozen: false } });
render(
<AdminUserReferenceButton
token="admin-token"
@@ -220,32 +222,48 @@ test('人工冻结和解除人工冻结分别提交原因且不解除退款欠
onUnauthorized={vi.fn()}
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
await screen.findByText('陶泥用户');
await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '异常登录');
await user.click(screen.getByRole('button', {name: '人工冻结钱包'}));
await user.click(screen.getByRole('button', {name: '确认'}));
await user.type(
screen.getByRole('textbox', { name: '人工冻结操作原因' }),
'异常登录',
);
await user.click(screen.getByRole('button', { name: '人工冻结钱包' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(1, 'admin-token', {
userId: 'user-1',
frozen: true,
reason: '异常登录',
});
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(
1,
'admin-token',
{
userId: 'user-1',
frozen: true,
reason: '异常登录',
},
);
});
expect(await screen.findByText(/后台负责人/)).toBeTruthy();
expect(screen.queryByText(/admin:root/)).toBeNull();
expect(await screen.findByText('解除人工冻结后,退款欠账限制仍会保留。')).toBeTruthy();
await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '核查完成');
await user.click(screen.getByRole('button', {name: '解除人工冻结'}));
await user.click(screen.getByRole('button', {name: '确认'}));
expect(
await screen.findByText('解除人工冻结后,退款欠账限制仍会保留。'),
).toBeTruthy();
await user.type(
screen.getByRole('textbox', { name: '人工冻结操作原因' }),
'核查完成',
);
await user.click(screen.getByRole('button', { name: '解除人工冻结' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(2, 'admin-token', {
userId: 'user-1',
frozen: false,
reason: '核查完成',
});
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(
2,
'admin-token',
{
userId: 'user-1',
frozen: false,
reason: '核查完成',
},
);
});
expect(screen.getByText('退款欠账限制')).toBeTruthy();
});
@@ -263,9 +281,9 @@ test('用户详情读取失败后可以重试', async () => {
/>,
);
await user.click(screen.getByRole('button', {name: '查看用户信息'}));
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
expect(await screen.findByText('读取失败')).toBeTruthy();
await user.click(screen.getByRole('button', {name: '重试'}));
await user.click(screen.getByRole('button', { name: '重试' }));
expect(await screen.findByText('陶泥用户')).toBeTruthy();
expect(getAdminUserDetail).toHaveBeenCalledTimes(2);
});
@@ -1,6 +1,6 @@
import {RefreshCcw, ShieldAlert, UserRound, X} from 'lucide-react';
import {useEffect, useRef, useState} from 'react';
import {createPortal} from 'react-dom';
import { RefreshCcw, ShieldAlert, UserRound, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
formatAdminApiError,
@@ -13,7 +13,7 @@ import type {
AdminProfileWalletPayload,
AdminUserDetailResponse,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from './useAdminWriteConfirm';
import { useAdminWriteConfirm } from './useAdminWriteConfirm';
interface AdminUserDetailDialogProps {
token: string;
@@ -35,11 +35,12 @@ export function AdminUserDetailDialog({
const [errorMessage, setErrorMessage] = useState('');
const [restrictionReason, setRestrictionReason] = useState('');
const [isSavingRestriction, setIsSavingRestriction] = useState(false);
const [isReconcilingConsumption, setIsReconcilingConsumption] = useState(false);
const [isReconcilingConsumption, setIsReconcilingConsumption] =
useState(false);
const [reconcileMessage, setReconcileMessage] = useState('');
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
const requestVersionRef = useRef(0);
const {confirmWrite, confirmDialog, isConfirming} = useAdminWriteConfirm();
const { confirmWrite, confirmDialog, isConfirming } = useAdminWriteConfirm();
useEffect(() => {
void loadDetail();
@@ -139,7 +140,7 @@ export function AdminUserDetailDialog({
reason,
});
setDetail((current) =>
current ? {...current, wallet: response.wallet} : current,
current ? { ...current, wallet: response.wallet } : current,
);
setRestrictionReason('');
} catch (error: unknown) {
@@ -219,7 +220,9 @@ export function AdminUserDetailDialog({
<div className="admin-panel-heading">
<div>
<h3 id="admin-user-detail-title">用户详情</h3>
<span>{detail?.publicUserCode || publicUserCode || userId || '-'}</span>
<span>
{detail?.publicUserCode || publicUserCode || userId || '-'}
</span>
</div>
<div className="admin-detail-actions">
<button
@@ -292,9 +295,14 @@ export function AdminUserDetailDialog({
</div>
{detail.wallet.manualRestriction ? (
<div className="admin-user-restriction-record">
<span>{detail.wallet.manualRestriction.reason || '未填写原因'}</span>
<span>
{detail.wallet.manualRestriction.reason || '未填写原因'}
</span>
<small>
{formatMicros(detail.wallet.manualRestriction.updatedAtMicros)} /{' '}
{formatMicros(
detail.wallet.manualRestriction.updatedAtMicros,
)}{' '}
/{' '}
{detail.wallet.manualRestriction.updatedByAdminDisplayName}
</small>
</div>
@@ -312,7 +320,9 @@ export function AdminUserDetailDialog({
aria-label="人工冻结操作原因"
disabled={isSavingRestriction || isReconcilingConsumption}
value={restrictionReason}
onChange={(event) => setRestrictionReason(event.target.value)}
onChange={(event) =>
setRestrictionReason(event.target.value)
}
/>
</label>
<button
@@ -362,7 +372,9 @@ export function AdminUserDetailDialog({
{detail.rechargeOrders.map((order) => (
<tr key={order.orderId}>
<td>
<span className="admin-mono-value">{order.orderId}</span>
<span className="admin-mono-value">
{order.orderId}
</span>
<small>{formatMicros(order.createdAtMicros)}</small>
</td>
<td>
@@ -393,12 +405,15 @@ export function AdminUserDetailDialog({
);
}
function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) {
function UserIdentityHeader({ detail }: { detail: AdminUserDetailResponse }) {
return (
<section className="admin-user-identity">
<div className="admin-user-avatar">
{detail.avatarUrl ? (
<img alt={`${detail.displayName || detail.publicUserCode}头像`} src={detail.avatarUrl} />
<img
alt={`${detail.displayName || detail.publicUserCode}头像`}
src={detail.avatarUrl}
/>
) : (
<UserRound size={30} aria-hidden="true" />
)}
@@ -423,7 +438,8 @@ function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) {
<div>
<dt>绑定状态</dt>
<dd>
{detail.bindingStatus || '-'} / 手机{detail.phoneBound ? '已绑定' : '未绑定'} / 微信
{detail.bindingStatus || '-'} / 手机
{detail.phoneBound ? '已绑定' : '未绑定'} / 微信
{detail.wechatBound ? '已绑定' : '未绑定'}
</dd>
</div>
@@ -477,7 +493,9 @@ function WalletSection({
</button>
) : null}
<div className="admin-tag-list">
{wallet.manualFrozen ? <span className="admin-tag">人工冻结</span> : null}
{wallet.manualFrozen ? (
<span className="admin-tag">人工冻结</span>
) : null}
{wallet.refundDebtFrozen ? (
<span className="admin-tag">退款欠账限制</span>
) : null}
@@ -1,7 +1,7 @@
import {UserRoundSearch} from 'lucide-react';
import {MouseEvent, useRef, useState} from 'react';
import { UserRoundSearch } from 'lucide-react';
import { MouseEvent, useRef, useState } from 'react';
import {AdminUserDetailDialog} from './AdminUserDetailDialog';
import { AdminUserDetailDialog } from './AdminUserDetailDialog';
interface AdminUserReferenceButtonProps {
token: string;
@@ -21,9 +21,9 @@ export function AdminUserReferenceButton({
const normalizedUserId = normalizeUserReference(userId);
const normalizedPublicUserCode = normalizeUserReference(publicUserCode);
const lookup = normalizedUserId
? {userId: normalizedUserId}
? { userId: normalizedUserId }
: normalizedPublicUserCode
? {publicUserCode: normalizedPublicUserCode}
? { publicUserCode: normalizedPublicUserCode }
: null;
if (!lookup) {
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
interface AdminWriteConfirmOptions {
action: string;
@@ -10,7 +10,9 @@ interface PendingConfirm extends AdminWriteConfirmOptions {
}
export function useAdminWriteConfirm() {
const [pendingConfirm, setPendingConfirm] = useState<PendingConfirm | null>(null);
const [pendingConfirm, setPendingConfirm] = useState<PendingConfirm | null>(
null,
);
const cancelButtonRef = useRef<HTMLButtonElement | null>(null);
const confirmWrite = useCallback((options: AdminWriteConfirmOptions) => {
@@ -19,7 +21,7 @@ export function useAdminWriteConfirm() {
if (current) {
current.resolve(false);
}
return {...options, resolve};
return { ...options, resolve };
});
});
}, []);
+3 -3
View File
@@ -1,9 +1,9 @@
import './styles/admin.css';
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import {AdminApp} from './app/AdminApp';
import { AdminApp } from './app/AdminApp';
const rootElement = document.getElementById('root');
+13 -11
View File
@@ -1,15 +1,15 @@
import {Plus, RefreshCcw, Save} from 'lucide-react';
import {type FormEvent, useEffect, useState} from 'react';
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';
import type { AdminAccountPayload } from '../api/adminApiTypes';
import { adminRoutes } from '../app/adminRoutes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminAccountsPageProps {
token: string;
@@ -34,7 +34,7 @@ export function AdminAccountsPage({
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshAccounts();
@@ -87,8 +87,8 @@ export function AdminAccountsPage({
checked
? assignableRoutes
.map((route) => route.id)
.filter((routeId) =>
routeId === permission || current.includes(routeId),
.filter(
(routeId) => routeId === permission || current.includes(routeId),
)
: current.filter((item) => item !== permission),
);
@@ -129,7 +129,7 @@ export function AdminAccountsPage({
const response = selectedAccountId
? await updateAdminAccount(token, selectedAccountId, {
displayName: normalizedDisplayName,
...(password ? {password} : {}),
...(password ? { password } : {}),
tabPermissions,
actionPermissions,
enabled,
@@ -205,7 +205,9 @@ export function AdminAccountsPage({
data-active={account.accountId === selectedAccountId}
disabled={account.accountRole === 'owner'}
key={account.accountId}
title={account.accountRole === 'owner' ? 'owner' : account.username}
title={
account.accountRole === 'owner' ? 'owner' : account.username
}
type="button"
onClick={() => {
if (account.accountRole === 'member') {
@@ -8,6 +8,7 @@ import {
getAdminDatabaseTableRows,
getAdminDatabaseTables,
} from '../api/adminApiClient';
import type { AdminDatabaseTableRowsResponse } from '../api/adminApiTypes';
import {
AdminDatabaseTablesPage,
resolveAdminDatabaseUserReference,
@@ -23,7 +24,10 @@ vi.mock('../api/adminApiClient', () => ({
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({ userId, publicUserCode }: {
AdminUserReferenceButton: ({
userId,
publicUserCode,
}: {
userId?: string;
publicUserCode?: string;
}) => (
@@ -68,6 +72,7 @@ const referralRows = [
];
beforeEach(() => {
vi.clearAllMocks();
window.location.hash = '#tables?table=profile_referral_relation';
vi.mocked(getAdminDatabaseTables).mockResolvedValue({
fetchErrors: [],
@@ -124,7 +129,10 @@ test('后台表查询页通过页面级固定栏翻页并提示扫描结果可
),
).toBeTruthy();
await user.type(screen.getByRole('textbox', { name: '关键词' }), '未执行条件');
await user.type(
screen.getByRole('textbox', { name: '关键词' }),
'未执行条件',
);
await user.click(screen.getByRole('button', { name: '下一页' }));
await waitFor(() => {
@@ -141,6 +149,53 @@ test('后台表查询页通过页面级固定栏翻页并提示扫描结果可
});
});
test('后台表查询页不会让旧表请求覆盖新表结果', async () => {
const user = userEvent.setup();
let resolveFirstRequest!: (response: AdminDatabaseTableRowsResponse) => void;
const firstRequest = new Promise<AdminDatabaseTableRowsResponse>(
(resolve) => {
resolveFirstRequest = resolve;
},
);
const oldTableResponse = {
columns: ['id'],
limit: 100,
page: 1,
rows: [{ cells: { id: 'old-row' }, raw: ['old-row'] }],
scannedCount: 1,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 1,
totalReturned: 1,
} satisfies AdminDatabaseTableRowsResponse;
const newTableResponse = {
...oldTableResponse,
rows: [{ cells: { id: 'new-row' }, raw: ['new-row'] }],
tableName: 'profile_wallet',
} satisfies AdminDatabaseTableRowsResponse;
vi.mocked(getAdminDatabaseTables).mockResolvedValueOnce({
fetchErrors: [],
tables: ['profile_referral_relation', 'profile_wallet'],
});
vi.mocked(getAdminDatabaseTableRows)
.mockImplementationOnce(() => firstRequest)
.mockResolvedValueOnce(newTableResponse);
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const tableSelect = await screen.findByRole('combobox');
await user.selectOptions(tableSelect, 'profile_wallet');
expect(await screen.findByText('new-row')).toBeTruthy();
resolveFirstRequest(oldTableResponse);
await waitFor(() => {
expect(screen.queryByText('old-row')).toBeNull();
});
expect(screen.getByText('new-row')).toBeTruthy();
});
test('后台表查询页把表头排序交给后端并从第一页展示排序结果', async () => {
const user = userEvent.setup();
const { container } = render(
@@ -165,7 +220,9 @@ test('后台表查询页把表头排序交给后端并从第一页展示排序
).toBe('原始表名:profile_referral_relation。邀请关系记录表。');
expect(
screen.getByRole('button', { name: '被邀请人ID' }).getAttribute('title'),
).toBe('原始字段名:invitee_user_id。被邀请人的用户标识。点击可按此列排序。');
).toBe(
'原始字段名:invitee_user_id。被邀请人的用户标识。点击列名可在正序、倒序和不排序之间循环切换。 当前状态:不排序。',
);
expect(readFirstColumnValues(container)).toEqual(['u-b', 'u-a', 'u-c']);
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
@@ -219,6 +276,88 @@ test('后台表查询页把表头排序交给后端并从第一页展示排序
);
expect(readFirstColumnValues(container)).toEqual(['u-a', 'u-b', 'u-c']);
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['invitee_user_id', 'inviter_user_id', 'invite_code', 'bound_at'],
limit: 100,
page: 1,
rows: referralRows,
scannedCount: 3,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 3,
totalReturned: 3,
});
await user.click(screen.getByRole('button', { name: '邀请人ID' }));
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
page: 1,
sortColumn: undefined,
sortDirection: undefined,
}),
);
expect(
screen
.getAllByRole('columnheader', { name: '邀请人ID' })
.filter((header) => header.textContent?.trim() === '邀请人ID')
.some((header) => header.getAttribute('aria-sort') === 'none'),
).toBe(true);
});
});
test('后台表查询页行详情将对象值以语法高亮 JSON 预览', async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['id', 'metadata'],
limit: 100,
page: 1,
rows: [
{
cells: {
id: 'row-1',
metadata: { enabled: true, count: 2, label: 'demo' },
},
raw: ['row-1', { enabled: true, count: 2, label: 'demo' }],
},
],
scannedCount: 1,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 1,
totalReturned: 1,
});
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('row-1');
expect(screen.queryByRole('button', { name: /^详情$/ })).toBeNull();
await user.click(screen.getByRole('row', { name: /row-1/ }));
const jsonPreview = document.querySelector('pre.admin-json-preview');
expect(jsonPreview).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-key')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-boolean')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-number')).toBeTruthy();
expect(jsonPreview?.querySelector('.admin-json-token-string')).toBeTruthy();
expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(2);
await user.click(screen.getByRole('button', { name: '复制元数据' }));
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith(
JSON.stringify({ enabled: true, count: 2, label: 'demo' }, null, 2),
);
});
expect(screen.getByRole('status').textContent).toBe('已复制 元数据');
});
test('数据库用户字段显示查看按钮且点击不会打开行详情', async () => {
@@ -227,11 +366,28 @@ test('数据库用户字段显示查看按钮且点击不会打开行详情', as
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const userButton = await screen.findByRole('button', { name: '查看用户 u-b' });
const userButton = await screen.findByRole('button', {
name: '查看用户 u-b',
});
await user.click(userButton);
expect(screen.queryByRole('dialog')).toBeNull();
});
test('数据库用户查看按钮上的键盘操作不会同时打开行详情', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const userButton = await screen.findByRole('button', {
name: '查看用户 u-b',
});
userButton.focus();
await user.keyboard('{Enter}');
expect(screen.queryByRole('dialog')).toBeNull();
});
test('数据库用户字段识别会排除后台操作者与合成邀请码字段', () => {
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'owner_user_id', 'u-1'),
@@ -250,13 +406,254 @@ test('数据库用户字段识别会排除后台操作者与合成邀请码字
resolveAdminDatabaseUserReference('audit_log', 'admin_user_id', 'u-1'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'user_id', 'admin:root'),
resolveAdminDatabaseUserReference(
'profile_wallet',
'user_id',
'admin:root',
),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_invite_code', 'user_id', 'u-1'),
).toBeNull();
});
test('后台表查询页字段条件构建后透传 filters 并支持列表值', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByRole('button', { name: '添加条件' }));
expect(screen.getAllByRole('columnheader', { name: '字段' })).toHaveLength(1);
expect(screen.getAllByRole('columnheader', { name: '条件' })).toHaveLength(1);
expect(screen.getAllByRole('columnheader', { name: '值' })).toHaveLength(1);
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 1 字段' }),
'invite_code',
);
await user.type(
screen.getByRole('textbox', { name: '条件 1 值' }),
'INV-1001',
);
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 字段' }),
'inviter_user_id',
);
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 运算符' }),
'in',
);
expect(screen.getByLabelText('条件 2 值列表').className).toContain(
'admin-database-filter-list-editor',
);
await user.type(
screen.getByRole('textbox', { name: '条件 2 值输入' }),
'u-a,b',
);
await user.click(screen.getByRole('button', { name: '添加条件 2 的值' }));
await user.type(
screen.getByRole('textbox', { name: '条件 2 值输入' }),
'u-c',
);
await user.click(screen.getByRole('button', { name: '添加条件 2 的值' }));
expect(
screen.getByText('u-a,b').closest('.admin-database-filter-values-row'),
).toBeTruthy();
expect(
screen
.getAllByText('u-c')
.some((element) => element.closest('.admin-database-filter-values-row')),
).toBe(true);
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invite_code', op: 'eq', value: 'INV-1001' },
{
column: 'inviter_user_id',
op: 'in',
value: ['u-a,b', 'u-c'],
},
]),
page: 1,
}),
);
});
});
test('后台表查询页可以勾选或取消勾选条件来切换筛选组合', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 1 字段' }),
'invite_code',
);
await user.type(
screen.getByRole('textbox', { name: '条件 1 值' }),
'INV-1001',
);
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.selectOptions(
screen.getByRole('combobox', { name: '条件 2 字段' }),
'inviter_user_id',
);
await user.type(screen.getByRole('textbox', { name: '条件 2 值' }), 'u-a');
await user.click(screen.getByRole('checkbox', { name: '启用条件 2' }));
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invite_code', op: 'eq', value: 'INV-1001' },
]),
page: 1,
}),
);
});
});
test('后台表查询页会提示未完成的启用条件而不是静默忽略', async () => {
const user = userEvent.setup();
const rowsRequest = vi.mocked(getAdminDatabaseTableRows);
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
rowsRequest.mockClear();
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.click(getFormSubmitQueryButton());
expect(await screen.findByText('条件 1 未完成,请补充后再查询')).toBeTruthy();
expect(rowsRequest).not.toHaveBeenCalled();
});
test('后台表查询页顶部查询按钮会提示未完成的启用条件而不是静默忽略', async () => {
const user = userEvent.setup();
const rowsRequest = vi.mocked(getAdminDatabaseTableRows);
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
rowsRequest.mockClear();
await user.click(screen.getByRole('button', { name: '添加条件' }));
await user.click(getHeadingQueryButton());
expect(await screen.findByText('条件 1 未完成,请补充后再查询')).toBeTruthy();
expect(rowsRequest).not.toHaveBeenCalled();
});
test('后台表查询页列头筛选按钮会带字段添加条件', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(
screen.getByRole('button', { name: '按被邀请人ID添加条件' }),
);
const columnSelect = screen.getByRole('combobox', {
name: '条件 1 字段',
}) as HTMLSelectElement;
expect(columnSelect.value).toBe('invitee_user_id');
await user.type(screen.getByRole('textbox', { name: '条件 1 值' }), 'u-b');
await user.click(getFormSubmitQueryButton());
await waitFor(() => {
expect(getAdminDatabaseTableRows).toHaveBeenLastCalledWith(
'admin-token',
'profile_referral_relation',
expect.objectContaining({
filters: JSON.stringify([
{ column: 'invitee_user_id', op: 'eq', value: 'u-b' },
]),
page: 1,
}),
);
});
});
test('后台表查询页行详情仅保留复制操作', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('u-b');
await user.click(screen.getByText('INV-1001').closest('tr')!);
expect(await screen.findByRole('dialog')).toBeTruthy();
expect(screen.queryByRole('button', { name: '按邀请码筛选' })).toBeNull();
expect(screen.getAllByRole('button', { name: /^复制/ })).toHaveLength(4);
});
test('后台表查询页空字段详情复制会明确提示无法复制空值', async () => {
const user = userEvent.setup();
const writeText = vi.fn();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
vi.mocked(getAdminDatabaseTableRows).mockResolvedValueOnce({
columns: ['id', 'deleted_at'],
limit: 100,
page: 1,
rows: [
{
cells: {
id: 'row-null',
deleted_at: null,
},
raw: ['row-null', null],
},
],
scannedCount: 1,
scanLimit: 50000,
scanLimitReached: false,
tableName: 'profile_referral_relation',
totalMatched: 1,
totalReturned: 1,
});
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByText('row-null');
await user.click(screen.getByText('row-null').closest('tr')!);
await user.click(screen.getByRole('button', { name: '复制删除时间' }));
expect(screen.getByRole('alert').textContent).toBe('该字段为空,无法复制');
expect(writeText).not.toHaveBeenCalled();
});
function getFormSubmitQueryButton() {
const buttons = screen.getAllByRole('button', { name: '查询' });
return buttons[buttons.length - 1]!;
}
function getHeadingQueryButton() {
const buttons = screen.getAllByRole('button', { name: '查询' });
return buttons[0]!;
}
function readFirstColumnValues(container: HTMLElement) {
return Array.from(container.querySelectorAll('tbody tr')).map(
(row) => row.querySelector('td')?.textContent?.trim() ?? '',
File diff suppressed because it is too large Load Diff
+12 -13
View File
@@ -1,14 +1,14 @@
import {Plus, Send, Trash2} from 'lucide-react';
import {FormEvent, useMemo, useState} from 'react';
import { Plus, Send, Trash2 } from 'lucide-react';
import { FormEvent, useMemo, useState } from 'react';
import {debugAdminHttp} from '../api/adminApiClient';
import { debugAdminHttp } from '../api/adminApiClient';
import type {
AdminDebugHeaderInput,
AdminDebugHttpMethod,
AdminDebugHttpResponse,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {formatUnknownJson, handlePageError} from './pageUtils';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { formatUnknownJson, handlePageError } from './pageUtils';
interface AdminDebugHttpPageProps {
token: string;
@@ -34,7 +34,7 @@ export function AdminDebugHttpPage({
const [result, setResult] = useState<AdminDebugHttpResponse | null>(null);
const [errorMessage, setErrorMessage] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
const jsonPreview = useMemo(
() => formatUnknownJson(result?.bodyJson),
@@ -117,10 +117,7 @@ export function AdminDebugHttpPage({
className="admin-ghost-button"
type="button"
onClick={() =>
setHeaders((current) => [
...current,
{name: '', value: ''},
])
setHeaders((current) => [...current, { name: '', value: '' }])
}
>
<Plus size={16} aria-hidden="true" />
@@ -135,7 +132,7 @@ export function AdminDebugHttpPage({
setHeaders((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? {...item, name: event.target.value}
? { ...item, name: event.target.value }
: item,
),
)
@@ -147,7 +144,7 @@ export function AdminDebugHttpPage({
setHeaders((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? {...item, value: event.target.value}
? { ...item, value: event.target.value }
: item,
),
)
@@ -198,7 +195,9 @@ export function AdminDebugHttpPage({
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3>结果</h3>
<span>{result ? `${result.status} ${result.statusText}` : '-'}</span>
<span>
{result ? `${result.status} ${result.statusText}` : '-'}
</span>
</div>
{result ? (
<>
@@ -642,9 +642,7 @@ test('后台素材查询将中间产物按真实类型逐行收纳在最终产
expect(within(sourceRow!).getByText('角色生图')).toBeTruthy();
expect(within(sourceRow!).getByText('12 泥点')).toBeTruthy();
expect(within(postprocessRow!).getByText('角色抠图')).toBeTruthy();
expect(
within(postprocessRow!).getByText('本阶段不额外扣费'),
).toBeTruthy();
expect(within(postprocessRow!).getByText('本阶段不额外扣费')).toBeTruthy();
fireEvent.click(within(sourceRow!).getByRole('button', { name: '详情' }));
const detail = await screen.findByRole('dialog', { name: '素材详情' });
@@ -1189,9 +1187,7 @@ test('后台素材查询在现有预览弹窗播放完整角色动作序列', as
});
});
const callsBeforeRetry = vi.mocked(getAdminAssetReadUrl).mock.calls.length;
await user.click(
within(dialog).getByRole('button', { name: '重试失败帧' }),
);
await user.click(within(dialog).getByRole('button', { name: '重试失败帧' }));
await waitFor(() => {
expect(vi.mocked(getAdminAssetReadUrl).mock.calls.length).toBeGreaterThan(
callsBeforeRetry,
@@ -1472,7 +1468,9 @@ test('后台素材详情分别展示真实操作和归组 Task ID', async () =>
fireEvent.click(await screen.findByRole('button', { name: '详情' }));
const dialog = screen.getByRole('dialog', { name: '素材详情' });
expect(within(dialog).getByText('editor-atlas-split-operation-1')).toBeTruthy();
expect(
within(dialog).getByText('editor-atlas-split-operation-1'),
).toBeTruthy();
expect(within(dialog).getByText('vector-engine-source-task-1')).toBeTruthy();
});
@@ -1,15 +1,15 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
import {
getAdminEditorGenerationPricing,
upsertAdminEditorGenerationPricing,
} from '../api/adminApiClient';
import type {EditorGenerationPricingConfigPayload} from '../api/adminApiTypes';
import {AdminEditorGenerationPricingPage} from './AdminEditorGenerationPricingPage';
import type { EditorGenerationPricingConfigPayload } from '../api/adminApiTypes';
import { AdminEditorGenerationPricingPage } from './AdminEditorGenerationPricingPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
@@ -24,22 +24,22 @@ const pricing: EditorGenerationPricingConfigPayload = {
models: {
'gemini-3.1-flash-image-preview': {
unit: 'perGeneration',
prices: {'0.5K': 8, '1K': 12, '2K': 24},
prices: { '0.5K': 8, '1K': 12, '2K': 24 },
},
'gpt-image-2': {
unit: 'perGeneration',
prices: {'1K': 3, '2K': 5},
prices: { '1K': 3, '2K': 5 },
},
'seedance2.0-fast': {
unit: 'perSecond',
prices: {'480p': 10, '720p': 20, '1080p': 40},
prices: { '480p': 10, '720p': 20, '1080p': 40 },
},
'seedance2.0': {
unit: 'perSecond',
prices: {'480p': 12, '720p': 24, '1080p': 48},
prices: { '480p': 12, '720p': 24, '1080p': 48 },
},
'audio1.0': {unit: 'perGeneration', price: 5},
'chirp-v5': {unit: 'perGeneration', price: 12},
'audio1.0': { unit: 'perGeneration', price: 5 },
'chirp-v5': { unit: 'perGeneration', price: 12 },
},
};
@@ -52,7 +52,7 @@ beforeEach(() => {
...pricing.models,
'gpt-image-2': {
unit: 'perGeneration',
prices: {'1K': 20, '2K': 58},
prices: { '1K': 20, '2K': 58 },
},
},
});
@@ -70,9 +70,9 @@ test('模型定价后台按模型展示单位并保存尺寸定价', async () =>
expect((await screen.findAllByText('按次')).length).toBeGreaterThan(0);
expect(screen.getAllByText('按秒').length).toBeGreaterThan(0);
const gptImage2kInput = screen.getByLabelText('gpt-image-2 2K');
fireEvent.change(gptImage2kInput, {target: {value: '58'}});
await user.click(screen.getByRole('button', {name: '保存定价'}));
await user.click(screen.getByRole('button', {name: '确认'}));
fireEvent.change(gptImage2kInput, { target: { value: '58' } });
await user.click(screen.getByRole('button', { name: '保存定价' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() => {
expect(upsertAdminEditorGenerationPricing).toHaveBeenCalledWith(
@@ -1,5 +1,5 @@
import {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import { RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import {
getAdminEditorGenerationPricing,
@@ -10,8 +10,8 @@ import type {
EditorGenerationPricingConfigPayload,
EditorGenerationPricingUnitPayload,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminEditorGenerationPricingPageProps {
token: string;
@@ -36,7 +36,7 @@ export function AdminEditorGenerationPricingPage({
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshPricing();
@@ -156,7 +156,11 @@ export function AdminEditorGenerationPricingPage({
)}
</div>
<button className="admin-primary-button" disabled={isSaving} type="submit">
<button
className="admin-primary-button"
disabled={isSaving}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存定价'}</span>
</button>
@@ -196,7 +200,9 @@ function renderModelPricingCard({
step={1}
type="number"
value={price}
onChange={(event) => onTierChange(model, tier, event.target.value)}
onChange={(event) =>
onTierChange(model, tier, event.target.value)
}
/>
</label>
))}
@@ -1,10 +1,10 @@
/* @vitest-environment jsdom */
import {render, screen} from '@testing-library/react';
import {beforeEach, expect, test, vi} from 'vitest';
import { render, screen } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import {listProfileInviteCodes} from '../api/adminApiClient';
import {AdminInviteCodePage} from './AdminInviteCodePage';
import { listProfileInviteCodes } from '../api/adminApiClient';
import { AdminInviteCodePage } from './AdminInviteCodePage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
@@ -1,5 +1,5 @@
import {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import { RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import {
listProfileInviteCodes,
@@ -10,8 +10,8 @@ import type {
ProfileCodeOperationAdminResponse,
ProfileInviteCodeAdminResponse,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminInviteCodePageProps {
token: string;
@@ -30,10 +30,12 @@ export function AdminInviteCodePage({
const [errorMessage, setErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [entries, setEntries] = useState<ProfileInviteCodeAdminResponse[]>([]);
const [operations, setOperations] = useState<ProfileCodeOperationAdminResponse[]>([]);
const [operations, setOperations] = useState<
ProfileCodeOperationAdminResponse[]
>([]);
const [isSaving, setIsSaving] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshInviteCodes();
@@ -239,7 +241,9 @@ export function AdminInviteCodePage({
<TagList tags={metadataUserTags(entry.metadata)} />
</td>
<td>
<span className={`admin-status ${inviteValidityClass(entry)}`}>
<span
className={`admin-status ${inviteValidityClass(entry)}`}
>
{inviteValidityLabel(entry)}
</span>
<small>{formatValidityWindow(entry)}</small>
@@ -296,7 +300,7 @@ export function AdminInviteCodePage({
);
}
function TagList({tags}: {tags: string[]}) {
function TagList({ tags }: { tags: string[] }) {
if (!tags.length) {
return <span className="admin-muted-text">-</span>;
}
@@ -318,14 +322,18 @@ function metadataUserTags(metadata: Record<string, unknown>) {
return [];
}
return parseUserTags(raw.filter((value): value is string => typeof value === 'string').join('、'));
return parseUserTags(
raw
.filter((value): value is string => typeof value === 'string')
.join('、'),
);
}
function withMetadataUserTags(
metadata: Record<string, unknown>,
tags: string[],
): Record<string, unknown> {
const next = {...metadata};
const next = { ...metadata };
delete next.user_tags;
if (tags.length) {
next.userTags = tags;
@@ -443,7 +451,7 @@ function formatDateTime(value: string) {
if (!Number.isFinite(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {hour12: false});
return date.toLocaleString('zh-CN', { hour12: false });
}
function operationActionLabel(action: string) {
+4 -4
View File
@@ -1,14 +1,14 @@
import {LockKeyhole, ShieldCheck} from 'lucide-react';
import {FormEvent, useState} from 'react';
import { LockKeyhole, ShieldCheck } from 'lucide-react';
import { FormEvent, useState } from 'react';
import {formatAdminApiError} from '../api/adminApiClient';
import { formatAdminApiError } from '../api/adminApiClient';
interface AdminLoginPageProps {
notice: string;
onLogin: (username: string, password: string) => Promise<void>;
}
export function AdminLoginPage({notice, onLogin}: AdminLoginPageProps) {
export function AdminLoginPage({ notice, onLogin }: AdminLoginPageProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [errorMessage, setErrorMessage] = useState('');
+11 -6
View File
@@ -1,12 +1,12 @@
import {RefreshCw} from 'lucide-react';
import {useCallback, useEffect, useState} from 'react';
import { RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {getAdminOverview} from '../api/adminApiClient';
import { getAdminOverview } from '../api/adminApiClient';
import type {
AdminDatabaseTableStatPayload,
AdminOverviewResponse,
} from '../api/adminApiTypes';
import {handlePageError} from './pageUtils';
import { handlePageError } from './pageUtils';
interface AdminOverviewPageProps {
token: string;
@@ -66,7 +66,12 @@ export function AdminOverviewPage({
<InfoPanel
title="服务"
rows={[
['监听', overview ? `${overview.service.bindHost}:${overview.service.bindPort}` : '-'],
[
'监听',
overview
? `${overview.service.bindHost}:${overview.service.bindPort}`
: '-',
],
['JWT issuer', overview?.service.jwtIssuer ?? '-'],
['后台', overview?.service.adminEnabled ? '已启用' : '未启用'],
]}
@@ -152,7 +157,7 @@ function InfoPanel({
);
}
function TableStatRow({stat}: {stat: AdminDatabaseTableStatPayload}) {
function TableStatRow({ stat }: { stat: AdminDatabaseTableStatPayload }) {
return (
<tr>
<td>
@@ -1,36 +1,60 @@
/* @vitest-environment jsdom */
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {beforeEach, expect, test, vi} from 'vitest';
import { beforeEach, expect, test, vi } from 'vitest';
import {getProfileWalletConfig, upsertProfileWalletConfig} from '../api/adminApiClient';
import type {ProfileWalletConfigAdminResponse} from '../api/adminApiTypes';
import {AdminProfileWalletConfigPage} from './AdminProfileWalletConfigPage';
import {
getProfileWalletConfig,
upsertProfileWalletConfig,
} from '../api/adminApiClient';
import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes';
import { AdminProfileWalletConfigPage } from './AdminProfileWalletConfigPage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) => error instanceof Error ? error.message : '请求失败'),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
getProfileWalletConfig: vi.fn(),
isAdminApiError: vi.fn(() => false),
upsertProfileWalletConfig: vi.fn(),
}));
const configResponse: ProfileWalletConfigAdminResponse = {
configId: 'profile_wallet', initialMudPoints: 100, dailyFreePointsPerDay: 20,
createdBy: 'owner-1', createdByDisplayName: '管理员',
createdAt: '2026-07-31T01:00:00Z', updatedBy: 'owner-1',
updatedByDisplayName: '管理员', updatedAt: '2026-07-31T01:00:00Z',
configId: 'profile_wallet',
initialMudPoints: 100,
dailyFreePointsPerDay: 20,
createdBy: 'owner-1',
createdByDisplayName: '管理员',
createdAt: '2026-07-31T01:00:00Z',
updatedBy: 'owner-1',
updatedByDisplayName: '管理员',
updatedAt: '2026-07-31T01:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getProfileWalletConfig).mockResolvedValue(configResponse);
vi.mocked(upsertProfileWalletConfig).mockResolvedValue({...configResponse, initialMudPoints: 120, dailyFreePointsPerDay: 35});
vi.mocked(upsertProfileWalletConfig).mockResolvedValue({
...configResponse,
initialMudPoints: 120,
dailyFreePointsPerDay: 35,
});
});
test('账号配置页加载并展示每日免费泥点', async () => {
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={vi.fn()} />);
expect((await screen.findByLabelText('每日免费泥点数') as HTMLInputElement).value).toBe('20');
render(
<AdminProfileWalletConfigPage
token="admin-token"
result={configResponse}
onUnauthorized={vi.fn()}
onResultChange={vi.fn()}
/>,
);
expect(
((await screen.findByLabelText('每日免费泥点数')) as HTMLInputElement)
.value,
).toBe('20');
expect(getProfileWalletConfig).toHaveBeenCalledWith('admin-token');
expect(screen.getByText('每日免费泥点')).toBeTruthy();
});
@@ -38,21 +62,52 @@ test('账号配置页加载并展示每日免费泥点', async () => {
test('账号配置页一次保存初始和每日免费泥点', async () => {
const user = userEvent.setup();
const onResultChange = vi.fn();
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={onResultChange} />);
render(
<AdminProfileWalletConfigPage
token="admin-token"
result={configResponse}
onUnauthorized={vi.fn()}
onResultChange={onResultChange}
/>,
);
await screen.findByLabelText('每日免费泥点数');
fireEvent.change(screen.getByLabelText('账号初始泥点数'), {target: {value: '120'}});
fireEvent.change(screen.getByLabelText('每日免费泥点数'), {target: {value: '35'}});
await user.click(screen.getByRole('button', {name: '保存'}));
fireEvent.change(screen.getByLabelText('账号初始泥点数'), {
target: { value: '120' },
});
fireEvent.change(screen.getByLabelText('每日免费泥点数'), {
target: { value: '35' },
});
await user.click(screen.getByRole('button', { name: '保存' }));
expect(screen.getByText('初始 120 泥点,每日免费 35 泥点')).toBeTruthy();
await user.click(screen.getByRole('button', {name: '确认'}));
await waitFor(() => expect(upsertProfileWalletConfig).toHaveBeenCalledWith('admin-token', {initialMudPoints: 120, dailyFreePointsPerDay: 35}));
expect(onResultChange).toHaveBeenLastCalledWith(expect.objectContaining({initialMudPoints: 120, dailyFreePointsPerDay: 35}));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(upsertProfileWalletConfig).toHaveBeenCalledWith('admin-token', {
initialMudPoints: 120,
dailyFreePointsPerDay: 35,
}),
);
expect(onResultChange).toHaveBeenLastCalledWith(
expect.objectContaining({
initialMudPoints: 120,
dailyFreePointsPerDay: 35,
}),
);
});
test('账号配置页拒绝非正整数每日免费额度', async () => {
render(<AdminProfileWalletConfigPage token="admin-token" result={configResponse} onUnauthorized={vi.fn()} onResultChange={vi.fn()} />);
render(
<AdminProfileWalletConfigPage
token="admin-token"
result={configResponse}
onUnauthorized={vi.fn()}
onResultChange={vi.fn()}
/>,
);
const input = await screen.findByLabelText('每日免费泥点数');
fireEvent.change(input, {target: {value: '1.5'}});
expect((screen.getByRole('button', {name: '保存'}) as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(input, { target: { value: '1.5' } });
expect(
(screen.getByRole('button', { name: '保存' }) as HTMLButtonElement)
.disabled,
).toBe(true);
expect(upsertProfileWalletConfig).not.toHaveBeenCalled();
});
@@ -1,13 +1,13 @@
import {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import { RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import {
getProfileWalletConfig,
upsertProfileWalletConfig,
} from '../api/adminApiClient';
import type {ProfileWalletConfigAdminResponse} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
import type { ProfileWalletConfigAdminResponse } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminProfileWalletConfigPageProps {
token: string;
@@ -28,7 +28,7 @@ export function AdminProfileWalletConfigPage({
const [isSaving, setIsSaving] = useState(false);
const [loadErrorMessage, setLoadErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshConfig();
@@ -1,5 +1,5 @@
import {RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import { RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import {
listProfileRechargeProducts,
@@ -10,8 +10,8 @@ import type {
ProfileRechargeProductConfigAdminResponse,
ProfileRechargeProductKind,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminRechargeProductPageProps {
token: string;
@@ -20,20 +20,24 @@ interface AdminRechargeProductPageProps {
onResultChange: (result: ProfileRechargeProductConfigAdminResponse) => void;
}
const productKinds: Array<{value: ProfileRechargeProductKind; label: string}> = [
{value: 'points', label: '泥点'},
{value: 'membership', label: '会员'},
const productKinds: Array<{
value: ProfileRechargeProductKind;
label: string;
}> = [
{ value: 'points', label: '泥点' },
{ value: 'membership', label: '会员' },
];
const membershipTiers: Array<{value: ProfileMembershipTier; label: string}> = [
{value: 'starter', label: 'Starter'},
{value: 'basic', label: 'Basic'},
{value: 'pro', label: 'Pro'},
{value: 'ultimate', label: 'Ultimate'},
{value: 'month', label: '月卡'},
{value: 'season', label: '季卡'},
{value: 'year', label: '年卡'},
];
const membershipTiers: Array<{ value: ProfileMembershipTier; label: string }> =
[
{ value: 'starter', label: 'Starter' },
{ value: 'basic', label: 'Basic' },
{ value: 'pro', label: 'Pro' },
{ value: 'ultimate', label: 'Ultimate' },
{ value: 'month', label: '月卡' },
{ value: 'season', label: '季卡' },
{ value: 'year', label: '年卡' },
];
export function AdminRechargeProductPage({
token,
@@ -64,7 +68,7 @@ export function AdminRechargeProductPage({
const [isSaving, setIsSaving] = useState(false);
const [listErrorMessage, setListErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshProducts();
@@ -111,21 +115,31 @@ export function AdminRechargeProductPage({
title: title.trim(),
priceCents: parsePositiveInteger(priceCents),
kind,
pointsAmount: kind === 'points' ? parsePositiveInteger(pointsAmount) : 0,
bonusPoints: kind === 'points' ? parseNonNegativeInteger(bonusPoints) : 0,
pointsAmount:
kind === 'points' ? parsePositiveInteger(pointsAmount) : 0,
bonusPoints:
kind === 'points' ? parseNonNegativeInteger(bonusPoints) : 0,
durationDays:
kind === 'membership' ? parsePositiveInteger(durationDays) : 0,
badgeLabel: kind === 'points' ? badgeLabel.trim() : '',
description: description.trim(),
tier: kind === 'membership' ? tier : 'normal',
membershipPeriodPoints:
kind === 'membership' ? parsePositiveInteger(membershipPeriodPoints) : 0,
kind === 'membership'
? parsePositiveInteger(membershipPeriodPoints)
: 0,
membershipPeriodDays:
kind === 'membership' ? parsePositiveInteger(membershipPeriodDays) : 0,
kind === 'membership'
? parsePositiveInteger(membershipPeriodDays)
: 0,
membershipQueueLimit:
kind === 'membership' ? parseNonNegativeInteger(membershipQueueLimit) : 0,
kind === 'membership'
? parseNonNegativeInteger(membershipQueueLimit)
: 0,
membershipDiscountBps:
kind === 'membership' ? parseNonNegativeInteger(membershipDiscountBps) : 0,
kind === 'membership'
? parseNonNegativeInteger(membershipDiscountBps)
: 0,
enabled,
sortOrder: parseInteger(sortOrder),
});
@@ -141,7 +155,9 @@ export function AdminRechargeProductPage({
function upsertEntry(next: ProfileRechargeProductConfigAdminResponse) {
setEntries((current) => {
const rest = current.filter((entry) => entry.productId !== next.productId);
const rest = current.filter(
(entry) => entry.productId !== next.productId,
);
return sortProducts([...rest, next]);
});
}
@@ -230,7 +246,9 @@ export function AdminRechargeProductPage({
setTier(tier === 'normal' ? 'starter' : tier);
setDurationDays(durationDays === '0' ? '30' : durationDays);
setMembershipPeriodDays(
membershipPeriodDays === '0' ? '30' : membershipPeriodDays,
membershipPeriodDays === '0'
? '30'
: membershipPeriodDays,
);
}
}}
@@ -515,7 +533,9 @@ function formatTier(tier: ProfileMembershipTier) {
return '普通';
}
function formatProductContent(entry: ProfileRechargeProductConfigAdminResponse) {
function formatProductContent(
entry: ProfileRechargeProductConfigAdminResponse,
) {
if (entry.kind === 'points') {
return `${entry.pointsAmount}+${entry.bonusPoints}`;
}
@@ -1,5 +1,5 @@
import {PowerOff, RefreshCcw, Save} from 'lucide-react';
import {FormEvent, useEffect, useState} from 'react';
import { PowerOff, RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
import {
disableProfileRedeemCode,
@@ -11,18 +11,18 @@ import type {
ProfileRedeemCodeAdminResponse,
ProfileRedeemCodeMode,
} from '../api/adminApiTypes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError, splitLines} from './pageUtils';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError, splitLines } from './pageUtils';
interface AdminRedeemCodePageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const redeemModes: Array<{value: ProfileRedeemCodeMode; label: string}> = [
{value: 'public', label: '公共码'},
{value: 'unique', label: '唯一码'},
{value: 'private', label: '私有码'},
const redeemModes: Array<{ value: ProfileRedeemCodeMode; label: string }> = [
{ value: 'public', label: '公共码' },
{ value: 'unique', label: '唯一码' },
{ value: 'private', label: '私有码' },
];
export function AdminRedeemCodePage({
@@ -43,11 +43,13 @@ export function AdminRedeemCodePage({
const [disableErrorMessage, setDisableErrorMessage] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [entries, setEntries] = useState<ProfileRedeemCodeAdminResponse[]>([]);
const [operations, setOperations] = useState<ProfileCodeOperationAdminResponse[]>([]);
const [operations, setOperations] = useState<
ProfileCodeOperationAdminResponse[]
>([]);
const [isSaving, setIsSaving] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshRedeemCodes();
@@ -447,7 +449,7 @@ function formatDateTime(value: string) {
if (!Number.isFinite(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {hour12: false});
return date.toLocaleString('zh-CN', { hour12: false });
}
function validateValidityWindow(startsAt: string, expiresAt: string) {
@@ -494,10 +496,18 @@ function redeemValidityLabel(entry: ProfileRedeemCodeAdminResponse) {
const now = Date.now();
const startsAtTime = entry.startsAt ? Date.parse(entry.startsAt) : null;
const expiresAtTime = entry.expiresAt ? Date.parse(entry.expiresAt) : null;
if (startsAtTime !== null && Number.isFinite(startsAtTime) && now < startsAtTime) {
if (
startsAtTime !== null &&
Number.isFinite(startsAtTime) &&
now < startsAtTime
) {
return '未生效';
}
if (expiresAtTime !== null && Number.isFinite(expiresAtTime) && now >= expiresAtTime) {
if (
expiresAtTime !== null &&
Number.isFinite(expiresAtTime) &&
now >= expiresAtTime
) {
return '已过期';
}
if (entry.startsAt || entry.expiresAt) {
@@ -1,14 +1,14 @@
import {Eye, EyeOff, RefreshCcw} from 'lucide-react';
import {useEffect, useMemo, useState} from 'react';
import { Eye, EyeOff, RefreshCcw } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
listAdminWorkVisibility,
updateAdminWorkVisibility,
} from '../api/adminApiClient';
import type {AdminWorkVisibilityEntryPayload} from '../api/adminApiTypes';
import {AdminUserReferenceButton} from '../components/AdminUserReferenceButton';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
import type { AdminWorkVisibilityEntryPayload } from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminWorkVisibilityPageProps {
token: string;
@@ -37,7 +37,7 @@ export function AdminWorkVisibilityPage({
const [isLoading, setIsLoading] = useState(false);
const [savingKey, setSavingKey] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshEntries();
@@ -81,7 +81,8 @@ export function AdminWorkVisibilityPage({
async function handleToggle(entry: AdminWorkVisibilityEntryPayload) {
const nextVisible = !entry.visible;
const target = entry.title.trim() || entry.publicWorkCode || entry.profileId;
const target =
entry.title.trim() || entry.publicWorkCode || entry.profileId;
const confirmed = await confirmWrite({
action: nextVisible ? '显示作品' : '隐藏作品',
target,
@@ -110,7 +111,9 @@ export function AdminWorkVisibilityPage({
function upsertEntry(next: AdminWorkVisibilityEntryPayload) {
setEntries((current) =>
sortEntries([
...current.filter((entry) => buildEntryKey(entry) !== buildEntryKey(next)),
...current.filter(
(entry) => buildEntryKey(entry) !== buildEntryKey(next),
),
next,
]),
);
@@ -276,5 +279,5 @@ function formatMicros(value: number) {
if (!Number.isFinite(date.getTime())) {
return '-';
}
return date.toLocaleString('zh-CN', {hour12: false});
return date.toLocaleString('zh-CN', { hour12: false });
}
+1 -1
View File
@@ -1,4 +1,4 @@
import {formatAdminApiError, isAdminApiError} from '../api/adminApiClient';
import { formatAdminApiError, isAdminApiError } from '../api/adminApiClient';
export function handlePageError(
error: unknown,
File diff suppressed because it is too large Load Diff
+34 -1
View File
@@ -21,7 +21,9 @@ describe('admin shell scrolling contract', () => {
});
test('mobile navigation stays in one horizontally scrollable row', () => {
const mobileStyles = stylesheet.slice(stylesheet.indexOf('@media (max-width: 980px)'));
const mobileStyles = stylesheet.slice(
stylesheet.indexOf('@media (max-width: 980px)'),
);
expect(mobileStyles).toContain('.admin-bottom-nav {');
expect(mobileStyles).toContain('display: flex');
expect(mobileStyles).toContain('overflow-x: auto');
@@ -30,6 +32,37 @@ describe('admin shell scrolling contract', () => {
);
expect(mobileStyles).toContain('flex: 0 0 76px');
});
test('database preview headers stay on one line and expose ellipsis', () => {
const sortButton = ruleFor('.admin-table-sort-button');
const sortLabel = ruleFor('.admin-table-sort-button span');
expect(sortButton).toContain('white-space: nowrap');
expect(sortButton).toContain('overflow: hidden');
expect(sortLabel).toContain('text-overflow: ellipsis');
expect(sortLabel).toContain('white-space: nowrap');
});
test('copy feedback uses an auto-dismissing toast surface', () => {
const toast = ruleFor('.admin-toast');
expect(toast).toContain('position: fixed');
expect(toast).toContain('z-index: 120');
expect(toast).toContain('pointer-events: none');
expect(toast).toContain('animation: admin-toast-in');
expect(stylesheet).toContain(".admin-toast[data-tone='error']");
});
test('detail JSON keeps only a slim scrollbar thumb', () => {
const json = ruleFor('.admin-json-preview');
expect(json).toContain('scrollbar-width: thin');
expect(json).toContain('scrollbar-color: #d1b09a transparent');
expect(stylesheet).toContain(
'.admin-json-preview::-webkit-scrollbar-track',
);
expect(stylesheet).toContain('background: transparent');
expect(stylesheet).toContain(
'.admin-json-preview::-webkit-scrollbar-thumb',
);
});
});
function ruleFor(selector: string) {