fix(studio): 补齐用户端登录入口
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:
lili 2026-06-28 09:29:07 -07:00
parent 9e0c223b19
commit acd7f040a5
10 changed files with 437 additions and 18 deletions

View File

@ -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');
}
}

View File

@ -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,
});
});
}); });

View File

@ -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);

View 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 />;
}

View File

@ -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,22 +19,31 @@ import HandoffLandingPage from '@/features/handoff/pages/HandoffLandingPage';
* AppLayout * AppLayout
*/ */
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />,
},
{ {
path: '/', path: '/',
element: <AppLayout />, element: <AuthGuard />,
children: [ children: [
{ index: true, element: <WorkListPage /> }, {
{ path: 'workspace/:workId', element: <WorkspacePage /> }, element: <AppLayout />,
{ path: 'works/:workId/editor/:chapterId', element: <EditorPage /> }, children: [
{ path: 'knowledge', element: <KnowledgePage /> }, { index: true, element: <WorkListPage /> },
{ path: 'knowledge/:workId', element: <KnowledgePage /> }, { path: 'workspace/:workId', element: <WorkspacePage /> },
{ path: 'agents', element: <AgentPage /> }, { path: 'works/:workId/editor/:chapterId', element: <EditorPage /> },
{ path: 'market', element: <MarketPage /> }, { path: 'knowledge', element: <KnowledgePage /> },
{ path: 'market/assets/:assetId', element: <MarketAssetDetailPage /> }, { path: 'knowledge/:workId', element: <KnowledgePage /> },
{ path: 'market/publish', element: <MarketPublishPage /> }, { path: 'agents', element: <AgentPage /> },
{ path: 'market/appeals', element: <MarketAppealsPage /> }, { path: 'market', element: <MarketPage /> },
{ path: 'account', element: <AccountPage /> }, { path: 'market/assets/:assetId', element: <MarketAssetDetailPage /> },
{ path: 'handoff/land/:targetOwner', element: <HandoffLandingPage /> }, { path: 'market/publish', element: <MarketPublishPage /> },
{ path: 'market/appeals', element: <MarketAppealsPage /> },
{ path: 'account', element: <AccountPage /> },
{ path: 'handoff/land/:targetOwner', element: <HandoffLandingPage /> },
],
},
], ],
}, },
]); ]);

View File

@ -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>
); );
} }

View 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');
}

View 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();
});
});

View 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>
);
}

View File

@ -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,