fix(studio): 补齐用户端登录入口
Some checks failed
Backend Maven CI / backend-local (push) Has been cancelled
Some checks failed
Backend Maven CI / backend-local (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9e0c223b19
commit
acd7f040a5
@ -23,6 +23,15 @@ export const ACCESS_TOKEN_STORAGE_KEYS = [
|
||||
'muse_access_token',
|
||||
] as const;
|
||||
|
||||
/** 登录成功后前端需要持久化的最小认证信息。 */
|
||||
export interface AuthSession {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
tenantId?: string;
|
||||
userId?: string | number;
|
||||
expiresTime?: string;
|
||||
}
|
||||
|
||||
/** 安全读取浏览器 storage,兼容隐私模式或测试环境中 storage 访问抛错的场景。 */
|
||||
function getBrowserStorage(name: 'localStorage' | 'sessionStorage'): Storage | null {
|
||||
try {
|
||||
@ -93,3 +102,42 @@ export function readTenantId(): string {
|
||||
}
|
||||
return '1';
|
||||
}
|
||||
|
||||
/** 写入登录态。统一入口能避免登录页、测试脚本和 SSE/HTTP 客户端使用不同 storage 键名导致漂移。 */
|
||||
export function saveAuthSession(session: AuthSession): void {
|
||||
const storage = getBrowserStorage('localStorage');
|
||||
if (!storage) return;
|
||||
|
||||
const accessToken = normalizeBearerToken(session.accessToken);
|
||||
if (!accessToken) return;
|
||||
|
||||
storage.setItem('accessToken', accessToken);
|
||||
if (session.refreshToken) {
|
||||
storage.setItem('refreshToken', session.refreshToken);
|
||||
}
|
||||
storage.setItem('tenantId', session.tenantId?.trim() || '1');
|
||||
if (session.userId !== undefined) {
|
||||
storage.setItem('museUserId', String(session.userId));
|
||||
}
|
||||
if (session.expiresTime) {
|
||||
storage.setItem('museTokenExpiresTime', session.expiresTime);
|
||||
}
|
||||
}
|
||||
|
||||
/** 清除所有已知认证键。401、退出登录和手动切换账号都必须走这里,避免残留旧 token。 */
|
||||
export function clearAuthSession(): void {
|
||||
for (const storage of [getBrowserStorage('localStorage'), getBrowserStorage('sessionStorage')]) {
|
||||
if (!storage) continue;
|
||||
for (const key of ACCESS_TOKEN_STORAGE_KEYS) {
|
||||
storage.removeItem(key);
|
||||
}
|
||||
for (const key of TENANT_ID_STORAGE_KEYS) {
|
||||
storage.removeItem(key);
|
||||
}
|
||||
storage.removeItem('refreshToken');
|
||||
storage.removeItem('refresh_token');
|
||||
storage.removeItem('museRefreshToken');
|
||||
storage.removeItem('museUserId');
|
||||
storage.removeItem('museTokenExpiresTime');
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import { api } from './client';
|
||||
describe('api client contract behavior', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('treats successful empty 200 responses as success instead of network errors', async () => {
|
||||
@ -38,4 +40,38 @@ describe('api client contract behavior', () => {
|
||||
expectedRevision: 7,
|
||||
}));
|
||||
});
|
||||
|
||||
it('clears local auth and redirects to login when backend returns business 401', async () => {
|
||||
localStorage.setItem('accessToken', 'expired-token');
|
||||
localStorage.setItem('tenantId', '1');
|
||||
const assignSpy = vi.fn();
|
||||
const originalLocation = window.location;
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: {
|
||||
pathname: '/workspace/work1',
|
||||
search: '',
|
||||
hash: '',
|
||||
assign: assignSpy,
|
||||
},
|
||||
});
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
|
||||
Response.json({
|
||||
code: 401,
|
||||
msg: '账号未登录',
|
||||
data: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(api.get('/works')).rejects.toThrow('账号未登录');
|
||||
|
||||
expect(localStorage.getItem('accessToken')).toBeNull();
|
||||
expect(localStorage.getItem('tenantId')).toBeNull();
|
||||
expect(assignSpy).toHaveBeenCalledWith('/login?redirect=%2Fworkspace%2Fwork1');
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: originalLocation,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { components } from '@/types/openapi';
|
||||
import { readAccessToken, readTenantId } from '@/api/auth';
|
||||
import { clearAuthSession, readAccessToken, readTenantId } from '@/api/auth';
|
||||
|
||||
// 后端 API 前缀路径
|
||||
const BASE_URL = '/app-api/muse';
|
||||
@ -31,6 +31,21 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断业务错误是否代表登录态失效;后端常以 HTTP 200 + body.code=401 返回“账号未登录”。 */
|
||||
function isUnauthorizedCode(code: unknown): boolean {
|
||||
return String(code) === '401';
|
||||
}
|
||||
|
||||
/** 登录态失效时清理本地凭证并跳转登录页,避免业务页继续发起一串无效请求。 */
|
||||
function redirectToLoginIfNeeded(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (window.location.pathname === '/login') return;
|
||||
|
||||
const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
clearAuthSession();
|
||||
window.location.assign(`/login?redirect=${encodeURIComponent(redirect)}`);
|
||||
}
|
||||
|
||||
export interface BinaryDownloadResponse {
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
@ -143,6 +158,9 @@ export async function request<T>(
|
||||
|
||||
// 业务层非 0 错误代表接口执行异常,抛出封装后的 ApiError
|
||||
if (body.code !== 0) {
|
||||
if (isUnauthorizedCode(body.code)) {
|
||||
redirectToLoginIfNeeded();
|
||||
}
|
||||
throw new ApiError(String(body.code), body.msg, response.status);
|
||||
}
|
||||
|
||||
@ -167,6 +185,9 @@ export async function downloadBinary(path: string): Promise<BinaryDownloadRespon
|
||||
throw new ApiError('JSON_PARSE_ERROR', '反序列化服务器 JSON 数据失败', response.status);
|
||||
}
|
||||
if (body.code !== 0) {
|
||||
if (isUnauthorizedCode(body.code)) {
|
||||
redirectToLoginIfNeeded();
|
||||
}
|
||||
throw new ApiError(String(body.code), body.msg, response.status);
|
||||
}
|
||||
throw new ApiError('UNEXPECTED_JSON_RESPONSE', '下载接口未返回文件流', response.status);
|
||||
|
||||
15
muse-studio/src/app/routes/AuthGuard.tsx
Normal file
15
muse-studio/src/app/routes/AuthGuard.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { readAccessToken } from '@/api/auth';
|
||||
|
||||
/** 业务路由登录守卫:前端只做体验层拦截,真实权限仍由后端 Bearer Token 校验。 */
|
||||
export default function AuthGuard() {
|
||||
const location = useLocation();
|
||||
const accessToken = readAccessToken();
|
||||
|
||||
if (!accessToken) {
|
||||
const redirect = `${location.pathname}${location.search}${location.hash}`;
|
||||
return <Navigate to={`/login?redirect=${encodeURIComponent(redirect)}`} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { createBrowserRouter } from 'react-router-dom';
|
||||
import AuthGuard from '@/app/routes/AuthGuard';
|
||||
import AppLayout from '@/components/layout/AppLayout';
|
||||
import WorkspacePage from '@/pages/WorkspacePage';
|
||||
import WorkListPage from '@/features/editor/components/WorkListPage';
|
||||
@ -10,6 +11,7 @@ import MarketAssetDetailPage from '@/pages/MarketAssetDetailPage';
|
||||
import MarketPublishPage from '@/pages/MarketPublishPage';
|
||||
import MarketAppealsPage from '@/pages/MarketAppealsPage';
|
||||
import AccountPage from '@/pages/AccountPage';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import HandoffLandingPage from '@/features/handoff/pages/HandoffLandingPage';
|
||||
|
||||
/**
|
||||
@ -17,8 +19,15 @@ import HandoffLandingPage from '@/features/handoff/pages/HandoffLandingPage';
|
||||
* 统一在 AppLayout 下渲染各功能模块页面
|
||||
*/
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/login',
|
||||
element: <LoginPage />,
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
element: <AuthGuard />,
|
||||
children: [
|
||||
{
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <WorkListPage /> },
|
||||
@ -35,4 +44,6 @@ export const router = createBrowserRouter([
|
||||
{ path: 'handoff/land/:targetOwner', element: <HandoffLandingPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { logoutLocally } from '@/features/auth/useAuth';
|
||||
|
||||
/** 导航栏单项接口定义 */
|
||||
interface NavItem {
|
||||
@ -56,10 +57,14 @@ export default function Sidebar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 底部设置按钮占位 */}
|
||||
<div className="w-8 h-8 rounded-full bg-gray-100 flex items-center justify-center text-xs text-gray-400 border border-gray-200 cursor-pointer hover:bg-gray-200 transition-colors">
|
||||
⚙️
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logoutLocally}
|
||||
title="退出登录"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border border-gray-200 bg-gray-100 text-xs font-bold text-gray-500 transition-colors hover:bg-rose-50 hover:text-rose-600"
|
||||
>
|
||||
退
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
43
muse-studio/src/features/auth/useAuth.ts
Normal file
43
muse-studio/src/features/auth/useAuth.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/api/client';
|
||||
import { clearAuthSession, saveAuthSession } from '@/api/auth';
|
||||
|
||||
/** App 端手机号密码登录入参;后端契约为 /app-api/member/auth/login。 */
|
||||
export interface LoginInput {
|
||||
mobile: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** 后端登录响应最小字段。 */
|
||||
export interface LoginResponse {
|
||||
userId: number;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresTime?: string;
|
||||
}
|
||||
|
||||
/** 调用真实后端登录接口,成功后统一写入 HTTP/SSE 共享的登录态。 */
|
||||
export function useLogin() {
|
||||
return useMutation({
|
||||
mutationFn: (input: LoginInput) => api.post<LoginResponse>('/app-api/member/auth/login', input),
|
||||
onSuccess: (session) => {
|
||||
const authSession = {
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
tenantId: '1',
|
||||
userId: session.userId,
|
||||
};
|
||||
saveAuthSession(
|
||||
session.expiresTime
|
||||
? { ...authSession, expiresTime: session.expiresTime }
|
||||
: authSession,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 本地退出登录:RC 阶段优先清理前端凭证,服务端 token 回收失败不阻塞用户切换账号。 */
|
||||
export function logoutLocally(): void {
|
||||
clearAuthSession();
|
||||
window.location.assign('/login');
|
||||
}
|
||||
76
muse-studio/src/pages/LoginPage.test.tsx
Normal file
76
muse-studio/src/pages/LoginPage.test.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import AuthGuard from '@/app/routes/AuthGuard';
|
||||
import LoginPage from '@/pages/LoginPage';
|
||||
import { server } from '@/test/setup';
|
||||
|
||||
function renderWithRouter(initialPath: string) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const router = createMemoryRouter(
|
||||
[
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{
|
||||
path: '/',
|
||||
element: <AuthGuard />,
|
||||
children: [{ index: true, element: <div>业务首页</div> }],
|
||||
},
|
||||
],
|
||||
{ initialEntries: [initialPath] },
|
||||
);
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
describe('LoginPage', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('未登录访问业务路由时应跳转登录页', async () => {
|
||||
const router = renderWithRouter('/');
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '用户登录' })).toBeInTheDocument();
|
||||
expect(router.state.location.pathname).toBe('/login');
|
||||
expect(router.state.location.search).toContain('redirect=');
|
||||
});
|
||||
|
||||
it('登录成功后应写入 token 并回到原始业务路由', async () => {
|
||||
server.use(
|
||||
http.post('/app-api/member/auth/login', () =>
|
||||
HttpResponse.json({
|
||||
code: 0,
|
||||
msg: 'success',
|
||||
data: {
|
||||
userId: 1001,
|
||||
accessToken: 'real-access-token',
|
||||
refreshToken: 'real-refresh-token',
|
||||
expiresTime: '2026-06-28T23:59:59',
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
const router = renderWithRouter('/login?redirect=%2F');
|
||||
|
||||
fireEvent.change(screen.getByLabelText('手机号'), { target: { value: '15601691388' } });
|
||||
fireEvent.change(screen.getByLabelText('密码'), { target: { value: 'admin123' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/'));
|
||||
expect(localStorage.getItem('accessToken')).toBe('real-access-token');
|
||||
expect(localStorage.getItem('refreshToken')).toBe('real-refresh-token');
|
||||
expect(localStorage.getItem('tenantId')).toBe('1');
|
||||
expect(await screen.findByText('业务首页')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
126
muse-studio/src/pages/LoginPage.tsx
Normal file
126
muse-studio/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ApiError } from '@/api/client';
|
||||
import { readAccessToken } from '@/api/auth';
|
||||
import { useLogin } from '@/features/auth/useAuth';
|
||||
|
||||
/** 只允许站内跳转,避免登录后被 redirect 参数带到外部地址。 */
|
||||
function normalizeRedirect(rawRedirect: string | null): string {
|
||||
if (!rawRedirect || !rawRedirect.startsWith('/') || rawRedirect.startsWith('//')) {
|
||||
return '/';
|
||||
}
|
||||
return rawRedirect;
|
||||
}
|
||||
|
||||
/** 用户端登录页:接真实 /member/auth/login,成功后进入原始目标路由。 */
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const redirectTo = useMemo(() => normalizeRedirect(searchParams.get('redirect')), [searchParams]);
|
||||
const [mobile, setMobile] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const login = useLogin();
|
||||
|
||||
if (readAccessToken()) {
|
||||
return <Navigate to={redirectTo} replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setErrorMessage(null);
|
||||
login.mutate(
|
||||
{ mobile: mobile.trim(), password },
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate(redirectTo, { replace: true, state: { from: location.pathname } });
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error instanceof ApiError ? error.message : '登录失败,请稍后重试';
|
||||
setErrorMessage(message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-950 text-slate-100">
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-6 py-10">
|
||||
<section className="grid w-full overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="flex min-h-[520px] flex-col justify-between bg-[radial-gradient(circle_at_20%_20%,rgba(14,165,233,0.24),transparent_32%),linear-gradient(135deg,#0f172a,#111827_55%,#14532d)] p-8">
|
||||
<div className="inline-flex h-11 w-11 items-center justify-center rounded-lg bg-white text-lg font-black text-slate-950">
|
||||
M
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.24em] text-emerald-200">Muse Studio</p>
|
||||
<h1 className="mt-4 max-w-xl text-4xl font-black leading-tight text-white">
|
||||
登录后进入创作工作台
|
||||
</h1>
|
||||
<p className="mt-4 max-w-lg text-sm leading-6 text-slate-200">
|
||||
作品、知识库、智能体和市场资产都按账户隔离;未登录请求会被后端拒绝。
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-slate-300">
|
||||
登录凭证只保存在当前浏览器本地存储,用于向 Muse 后端注入 Bearer Token 和租户头。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center bg-white p-8 text-slate-900">
|
||||
<form onSubmit={handleSubmit} className="w-full max-w-sm space-y-5" noValidate>
|
||||
<div>
|
||||
<h2 className="text-2xl font-extrabold text-slate-950">用户登录</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">使用会员手机号和密码登录 Studio。</p>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-semibold text-slate-700">手机号</span>
|
||||
<input
|
||||
value={mobile}
|
||||
onChange={(event) => setMobile(event.target.value)}
|
||||
className="mt-2 h-11 w-full rounded-lg border border-slate-300 px-3 text-sm outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
|
||||
inputMode="tel"
|
||||
autoComplete="tel"
|
||||
placeholder="15601691388"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-sm font-semibold text-slate-700">密码</span>
|
||||
<input
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="mt-2 h-11 w-full rounded-lg border border-slate-300 px-3 text-sm outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={login.isPending}
|
||||
className="inline-flex h-11 w-full items-center justify-center rounded-lg bg-emerald-600 px-4 text-sm font-bold text-white transition hover:bg-emerald-700 disabled:cursor-not-allowed disabled:bg-slate-300"
|
||||
>
|
||||
{login.isPending ? '登录中...' : '登录'}
|
||||
</button>
|
||||
|
||||
<p className="text-xs leading-5 text-slate-500">
|
||||
如果测试账号不可用,需要先在后台或数据库中创建会员账号;前端不会伪造登录态。
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -16,6 +16,44 @@ import { marketHandlers } from '../api/mocks/handlers/market';
|
||||
import { accountHandlers } from '../api/mocks/handlers/account';
|
||||
import { afterAll, afterEach, beforeAll } from 'vitest';
|
||||
|
||||
/** 创建最小 Storage Mock,规避当前 Node/Vitest 环境中 localStorage 不可用的问题。 */
|
||||
function createStorageMock(): Storage {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return values.size;
|
||||
},
|
||||
clear: () => {
|
||||
values.clear();
|
||||
},
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => Array.from(values.keys())[index] ?? null,
|
||||
removeItem: (key) => {
|
||||
values.delete(key);
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.localStorage) {
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: createStorageMock(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!globalThis.sessionStorage) {
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: createStorageMock(),
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 聚合所有子功能模块的 Mock 规则
|
||||
export const server = setupServer(
|
||||
...contentHandlers,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user