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',
|
'muse_access_token',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** 登录成功后前端需要持久化的最小认证信息。 */
|
||||||
|
export interface AuthSession {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
userId?: string | number;
|
||||||
|
expiresTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 安全读取浏览器 storage,兼容隐私模式或测试环境中 storage 访问抛错的场景。 */
|
/** 安全读取浏览器 storage,兼容隐私模式或测试环境中 storage 访问抛错的场景。 */
|
||||||
function getBrowserStorage(name: 'localStorage' | 'sessionStorage'): Storage | null {
|
function getBrowserStorage(name: 'localStorage' | 'sessionStorage'): Storage | null {
|
||||||
try {
|
try {
|
||||||
@ -93,3 +102,42 @@ export function readTenantId(): string {
|
|||||||
}
|
}
|
||||||
return '1';
|
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', () => {
|
describe('api client contract behavior', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats successful empty 200 responses as success instead of network errors', async () => {
|
it('treats successful empty 200 responses as success instead of network errors', async () => {
|
||||||
@ -38,4 +40,38 @@ describe('api client contract behavior', () => {
|
|||||||
expectedRevision: 7,
|
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 type { components } from '@/types/openapi';
|
||||||
import { readAccessToken, readTenantId } from '@/api/auth';
|
import { clearAuthSession, readAccessToken, readTenantId } from '@/api/auth';
|
||||||
|
|
||||||
// 后端 API 前缀路径
|
// 后端 API 前缀路径
|
||||||
const BASE_URL = '/app-api/muse';
|
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 {
|
export interface BinaryDownloadResponse {
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@ -143,6 +158,9 @@ export async function request<T>(
|
|||||||
|
|
||||||
// 业务层非 0 错误代表接口执行异常,抛出封装后的 ApiError
|
// 业务层非 0 错误代表接口执行异常,抛出封装后的 ApiError
|
||||||
if (body.code !== 0) {
|
if (body.code !== 0) {
|
||||||
|
if (isUnauthorizedCode(body.code)) {
|
||||||
|
redirectToLoginIfNeeded();
|
||||||
|
}
|
||||||
throw new ApiError(String(body.code), body.msg, response.status);
|
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);
|
throw new ApiError('JSON_PARSE_ERROR', '反序列化服务器 JSON 数据失败', response.status);
|
||||||
}
|
}
|
||||||
if (body.code !== 0) {
|
if (body.code !== 0) {
|
||||||
|
if (isUnauthorizedCode(body.code)) {
|
||||||
|
redirectToLoginIfNeeded();
|
||||||
|
}
|
||||||
throw new ApiError(String(body.code), body.msg, response.status);
|
throw new ApiError(String(body.code), body.msg, response.status);
|
||||||
}
|
}
|
||||||
throw new ApiError('UNEXPECTED_JSON_RESPONSE', '下载接口未返回文件流', 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 { createBrowserRouter } from 'react-router-dom';
|
||||||
|
import AuthGuard from '@/app/routes/AuthGuard';
|
||||||
import AppLayout from '@/components/layout/AppLayout';
|
import AppLayout from '@/components/layout/AppLayout';
|
||||||
import WorkspacePage from '@/pages/WorkspacePage';
|
import WorkspacePage from '@/pages/WorkspacePage';
|
||||||
import WorkListPage from '@/features/editor/components/WorkListPage';
|
import WorkListPage from '@/features/editor/components/WorkListPage';
|
||||||
@ -10,6 +11,7 @@ import MarketAssetDetailPage from '@/pages/MarketAssetDetailPage';
|
|||||||
import MarketPublishPage from '@/pages/MarketPublishPage';
|
import MarketPublishPage from '@/pages/MarketPublishPage';
|
||||||
import MarketAppealsPage from '@/pages/MarketAppealsPage';
|
import MarketAppealsPage from '@/pages/MarketAppealsPage';
|
||||||
import AccountPage from '@/pages/AccountPage';
|
import AccountPage from '@/pages/AccountPage';
|
||||||
|
import LoginPage from '@/pages/LoginPage';
|
||||||
import HandoffLandingPage from '@/features/handoff/pages/HandoffLandingPage';
|
import HandoffLandingPage from '@/features/handoff/pages/HandoffLandingPage';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,8 +19,15 @@ import HandoffLandingPage from '@/features/handoff/pages/HandoffLandingPage';
|
|||||||
* 统一在 AppLayout 下渲染各功能模块页面
|
* 统一在 AppLayout 下渲染各功能模块页面
|
||||||
*/
|
*/
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
element: <LoginPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
element: <AuthGuard />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
element: <AppLayout />,
|
element: <AppLayout />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <WorkListPage /> },
|
{ index: true, element: <WorkListPage /> },
|
||||||
@ -35,4 +44,6 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'handoff/land/:targetOwner', element: <HandoffLandingPage /> },
|
{ path: 'handoff/land/:targetOwner', element: <HandoffLandingPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import { logoutLocally } from '@/features/auth/useAuth';
|
||||||
|
|
||||||
/** 导航栏单项接口定义 */
|
/** 导航栏单项接口定义 */
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@ -56,10 +57,14 @@ export default function Sidebar() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底部设置按钮占位 */}
|
<button
|
||||||
<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">
|
type="button"
|
||||||
⚙️
|
onClick={logoutLocally}
|
||||||
</div>
|
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>
|
</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 { accountHandlers } from '../api/mocks/handlers/account';
|
||||||
import { afterAll, afterEach, beforeAll } from 'vitest';
|
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 规则
|
// 聚合所有子功能模块的 Mock 规则
|
||||||
export const server = setupServer(
|
export const server = setupServer(
|
||||||
...contentHandlers,
|
...contentHandlers,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user