Merge remote-tracking branch 'origin/master' into feat/agc_add_on
Project CI / Repository checks (pull_request) Failing after 2m51s
Project CI / Native shell tests (pull_request) Failing after 5m0s
Project CI / Frontend tests (pull_request) Successful in 5m6s
Project CI / Backend tests (pull_request) Successful in 7m39s

# Conflicts:
#	docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
This commit is contained in:
2026-09-01 08:52:07 +00:00
1052 changed files with 28412 additions and 19835 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') {
+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,
+25 -22
View File
@@ -2,8 +2,13 @@
color: #3d1f10;
background: #f8efe7;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
@@ -62,10 +67,8 @@ button:disabled {
}
.admin-login-screen {
background:
linear-gradient(145deg, rgba(204, 117, 76, 0.14), transparent 36%),
linear-gradient(315deg, rgba(226, 171, 134, 0.16), transparent 34%),
#f8efe7;
background: linear-gradient(145deg, rgba(204, 117, 76, 0.14), transparent 36%),
linear-gradient(315deg, rgba(226, 171, 134, 0.16), transparent 34%), #f8efe7;
}
.admin-login-panel,
@@ -172,7 +175,7 @@ button:disabled {
background: transparent;
}
.admin-nav-button[data-active="true"] {
.admin-nav-button[data-active='true'] {
color: #8f3f27;
background: #f4e5d7;
}
@@ -341,7 +344,7 @@ button:disabled {
font-weight: 800;
}
.admin-dashboard-tabs button[data-active="true"] {
.admin-dashboard-tabs button[data-active='true'] {
color: #8f3f27;
background: #f4e5d7;
}
@@ -438,7 +441,7 @@ button:disabled {
min-height: 132px;
}
.admin-dashboard-metric-card[data-compact="true"] {
.admin-dashboard-metric-card[data-compact='true'] {
min-height: 112px;
}
@@ -615,7 +618,7 @@ button:disabled {
text-align: left;
}
.admin-account-list-items > button[data-active="true"] {
.admin-account-list-items > button[data-active='true'] {
border-color: #c87955;
background: #f9eee5;
}
@@ -696,7 +699,9 @@ button:disabled {
.admin-table-query-grid {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(160px, 1fr) minmax(96px, 0.45fr) auto;
grid-template-columns:
minmax(180px, 1fr) minmax(160px, 1fr) minmax(96px, 0.45fr)
auto;
gap: 12px;
align-items: end;
}
@@ -756,16 +761,15 @@ button:disabled {
}
.admin-mono-value {
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
}
.admin-table tbody tr[data-clickable="true"] {
.admin-table tbody tr[data-clickable='true'] {
cursor: pointer;
}
.admin-table tbody tr[data-clickable="true"]:hover {
.admin-table tbody tr[data-clickable='true']:hover {
background: #fff7f0;
}
@@ -1012,8 +1016,7 @@ button:disabled {
.admin-combobox-option span {
color: #8f3f27;
font-family:
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 12px;
font-weight: 700;
}
@@ -1880,7 +1883,7 @@ button:disabled {
text-transform: uppercase;
}
.admin-database-table tbody tr[data-clickable="true"] {
.admin-database-table tbody tr[data-clickable='true'] {
cursor: pointer;
transition: background-color 140ms ease;
}
@@ -1889,7 +1892,7 @@ button:disabled {
background: #fffbf7;
}
.admin-database-table tbody tr[data-clickable="true"]:hover {
.admin-database-table tbody tr[data-clickable='true']:hover {
background: #fff3e9;
}
@@ -1972,7 +1975,7 @@ button:disabled {
.admin-table-sort-button:hover,
.admin-table-sort-button:focus-visible,
.admin-table-sort-button[data-active="true"] {
.admin-table-sort-button[data-active='true'] {
color: #8f3f27;
outline: none;
}
@@ -2680,7 +2683,7 @@ button:disabled {
font-weight: 700;
}
.admin-segmented-control button[data-active="true"] {
.admin-segmented-control button[data-active='true'] {
color: #8f3f27;
background: #ffffff;
box-shadow: 0 2px 8px rgba(112, 57, 30, 0.08);
@@ -2895,7 +2898,7 @@ button:disabled {
font-weight: 700;
}
.admin-bottom-nav-button[data-active="true"] {
.admin-bottom-nav-button[data-active='true'] {
color: #8f3f27;
background: #f4e5d7;
}
+5 -5
View File
@@ -1,16 +1,16 @@
import {dirname, resolve} from 'node:path';
import {fileURLToPath} from 'node:url';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import react from '@vitejs/plugin-react';
import {defineConfig, loadEnv} from 'vite';
import { defineConfig, loadEnv } from 'vite';
const adminWebRoot = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(adminWebRoot, '../..');
export default defineConfig(({command, mode}) => {
export default defineConfig(({ command, mode }) => {
const repoEnv = loadEnv(mode, repoRoot, '');
const appEnv = loadEnv(mode, adminWebRoot, '');
const env = {...repoEnv, ...appEnv};
const env = { ...repoEnv, ...appEnv };
const apiTarget =
env.ADMIN_API_TARGET ||
env.GENARRATIVE_API_TARGET ||
@@ -3,8 +3,5 @@
"identifier": "events",
"description": "允许客户端窗口订阅并取消订阅 Rust Runtime 事件。",
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
"permissions": [
"core:event:allow-listen",
"core:event:allow-unlisten"
]
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
}
@@ -73,9 +73,7 @@
"roleOverlays": [
{
"agentId": "project-planning",
"sections": [
"projectPlanningRoleBrief"
]
"sections": ["projectPlanningRoleBrief"]
}
],
"providerFragments": {

Some files were not shown because too many files have changed in this diff Show More