Compare commits
2 Commits
ada02e9135
...
46d98ab11e
| Author | SHA1 | Date | |
|---|---|---|---|
| 46d98ab11e | |||
| ffa86f8d55 |
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "6.3.4",
|
"@ant-design/icons": "6.3.4",
|
||||||
|
"@atlas/contracts": "workspace:*",
|
||||||
"antd": "6.6.2",
|
"antd": "6.6.2",
|
||||||
"axios": "1.20.0",
|
"axios": "1.20.0",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ConfigProvider, App as AntdApp } from 'antd';
|
|||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import { router } from './router';
|
import { router } from './router';
|
||||||
|
import { SessionProvider } from '@/services/session';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -20,7 +21,9 @@ function App() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AntdApp>
|
<AntdApp>
|
||||||
|
<SessionProvider>
|
||||||
<RouterProvider router={router} />
|
<RouterProvider router={router} />
|
||||||
|
</SessionProvider>
|
||||||
</AntdApp>
|
</AntdApp>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { Spin } from 'antd';
|
||||||
|
|
||||||
|
/** 应用启动会话恢复期间的全屏占位,避免守卫闪烁跳转 */
|
||||||
|
const SessionSplash = () => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'var(--bg-color)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SessionSplash;
|
||||||
@@ -8,19 +8,9 @@
|
|||||||
<<<<<<< HEAD
|
<<<<<<< HEAD
|
||||||
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||||
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
|
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
|
||||||
=======
|
======= radial-gradient(1000px 500px at 15% -10%, rgba(139, 92, 246, 0.14), transparent 60%),
|
||||||
radial-gradient(
|
radial-gradient(900px 480px at 95% 110%, rgba(109, 40, 217, 0.1), transparent 55%),
|
||||||
1000px 500px at 15% -10%,
|
>>>>>>> feat-0.2.0 var(--bg-color);
|
||||||
rgba(139, 92, 246, 0.14),
|
|
||||||
transparent 60%
|
|
||||||
),
|
|
||||||
radial-gradient(
|
|
||||||
900px 480px at 95% 110%,
|
|
||||||
rgba(109, 40, 217, 0.1),
|
|
||||||
transparent 55%
|
|
||||||
),
|
|
||||||
>>>>>>> feat-0.2.0
|
|
||||||
var(--bg-color);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-panel {
|
.auth-panel {
|
||||||
|
|||||||
@@ -96,6 +96,30 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-user {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-user-workspace {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-user-email {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
.collapse-trigger,
|
.collapse-trigger,
|
||||||
.mobile-menu-trigger {
|
.mobile-menu-trigger {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Breadcrumb, Drawer, Layout } from 'antd';
|
import { Breadcrumb, Button, Drawer, Layout } from 'antd';
|
||||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
|
||||||
import SidebarMenu from './SidebarMenu';
|
import SidebarMenu from './SidebarMenu';
|
||||||
import { getBreadcrumbItems } from './menuConfig';
|
import { getBreadcrumbItems } from './menuConfig';
|
||||||
|
import { useSession } from '@/services/session';
|
||||||
import './MainLayout.css';
|
import './MainLayout.css';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
@@ -12,7 +13,10 @@ const MainLayout = () => {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [loggingOut, setLoggingOut] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { session, logout } = useSession();
|
||||||
|
|
||||||
const breadcrumbItems = useMemo(
|
const breadcrumbItems = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -27,6 +31,16 @@ const MainLayout = () => {
|
|||||||
[location.pathname]
|
[location.pathname]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
setLoggingOut(true);
|
||||||
|
try {
|
||||||
|
await logout();
|
||||||
|
} finally {
|
||||||
|
setLoggingOut(false);
|
||||||
|
navigate('/login', { replace: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
setIsMobile(window.innerWidth < 768);
|
setIsMobile(window.innerWidth < 768);
|
||||||
@@ -108,6 +122,19 @@ const MainLayout = () => {
|
|||||||
items={breadcrumbItems}
|
items={breadcrumbItems}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{session && (
|
||||||
|
<div className="header-right">
|
||||||
|
<div className="header-user">
|
||||||
|
<span className="header-user-workspace">
|
||||||
|
{session.workspace.name}
|
||||||
|
</span>
|
||||||
|
<span className="header-user-email">{session.user.email}</span>
|
||||||
|
</div>
|
||||||
|
<Button size="small" loading={loggingOut} onClick={handleLogout}>
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Header>
|
</Header>
|
||||||
|
|
||||||
<Content className="main-content">
|
<Content className="main-content">
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { App as AntdApp, Button, Form, Input } from 'antd';
|
import { App as AntdApp, Button, Form, Input } from 'antd';
|
||||||
import { LockOutlined, MailOutlined } from '@ant-design/icons';
|
import { LockOutlined, MailOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import AuthLayout from '@/layouts/AuthLayout';
|
import AuthLayout from '@/layouts/AuthLayout';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||||
import { authApi } from '@/services/auth';
|
import { authApi } from '@/services/auth';
|
||||||
import { authToken } from '@/services/authToken';
|
import { useSession } from '@/services/session';
|
||||||
import { emailFormatRule } from '@/utils/validators';
|
import { emailFormatRule } from '@/utils/validators';
|
||||||
|
|
||||||
interface LoginFormValues {
|
interface LoginFormValues {
|
||||||
@@ -17,7 +17,10 @@ interface LoginFormValues {
|
|||||||
/** 登录页,契约见 docs/api/auth.md §3 */
|
/** 登录页,契约见 docs/api/auth.md §3 */
|
||||||
const LoginPage = () => {
|
const LoginPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { message } = AntdApp.useApp();
|
const { message } = AntdApp.useApp();
|
||||||
|
const { applyAuthSession } = useSession();
|
||||||
|
const [form] = Form.useForm<LoginFormValues>();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleFinish = async (values: LoginFormValues) => {
|
const handleFinish = async (values: LoginFormValues) => {
|
||||||
@@ -27,13 +30,28 @@ const LoginPage = () => {
|
|||||||
email: values.email.trim(),
|
email: values.email.trim(),
|
||||||
password: values.password,
|
password: values.password,
|
||||||
});
|
});
|
||||||
authToken.set(session.access_token);
|
applyAuthSession(session);
|
||||||
message.success('登录成功');
|
message.success('登录成功');
|
||||||
navigate('/online-products', { replace: true });
|
// 守卫记住的来源路径优先,默认进入在线商品首页
|
||||||
|
const from = (location.state as { from?: string } | null)?.from;
|
||||||
|
navigate(from && from.startsWith('/') ? from : '/online-products', {
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误
|
// 契约约定登录失败统一返回 401 INVALID_CREDENTIALS,不区分邮箱不存在或密码错误
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
message.error('邮箱或密码不正确');
|
message.error('邮箱或密码不正确');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 422 VALIDATION_FAILED 等带字段明细的错误,逐项落到对应表单项
|
||||||
|
const details = apiErrorDetails(error);
|
||||||
|
if (details.length > 0) {
|
||||||
|
form.setFields(
|
||||||
|
details.map((item) => ({
|
||||||
|
name: item.field as keyof LoginFormValues,
|
||||||
|
errors: [item.message],
|
||||||
|
}))
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
message.error(apiErrorMessage(error));
|
message.error(apiErrorMessage(error));
|
||||||
}
|
}
|
||||||
@@ -52,7 +70,12 @@ const LoginPage = () => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Form<LoginFormValues> layout="vertical" requiredMark={false} onFinish={handleFinish}>
|
<Form<LoginFormValues>
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
requiredMark={false}
|
||||||
|
onFinish={handleFinish}
|
||||||
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="email"
|
name="email"
|
||||||
label="邮箱"
|
label="邮箱"
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { App as AntdApp, Button, Form, Input } from 'antd';
|
|||||||
import { LockOutlined, MailOutlined, UserOutlined } from '@ant-design/icons';
|
import { LockOutlined, MailOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import AuthLayout from '@/layouts/AuthLayout';
|
import AuthLayout from '@/layouts/AuthLayout';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
|
||||||
import { authApi } from '@/services/auth';
|
import { authApi } from '@/services/auth';
|
||||||
import { authToken } from '@/services/authToken';
|
import { useSession } from '@/services/session';
|
||||||
import { emailFormatRule } from '@/utils/validators';
|
import { emailFormatRule } from '@/utils/validators';
|
||||||
|
|
||||||
interface RegisterFormValues {
|
interface RegisterFormValues {
|
||||||
@@ -20,6 +20,7 @@ interface RegisterFormValues {
|
|||||||
const RegisterPage = () => {
|
const RegisterPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { message } = AntdApp.useApp();
|
const { message } = AntdApp.useApp();
|
||||||
|
const { applyAuthSession } = useSession();
|
||||||
const [form] = Form.useForm<RegisterFormValues>();
|
const [form] = Form.useForm<RegisterFormValues>();
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
@@ -33,13 +34,24 @@ const RegisterPage = () => {
|
|||||||
display_name: values.display_name?.trim() || undefined,
|
display_name: values.display_name?.trim() || undefined,
|
||||||
});
|
});
|
||||||
// 注册成功即在同一事务创建用户、个人工作空间和登录会话
|
// 注册成功即在同一事务创建用户、个人工作空间和登录会话
|
||||||
authToken.set(session.access_token);
|
applyAuthSession(session);
|
||||||
message.success('注册成功,已进入你的工作空间');
|
message.success('注册成功,已进入你的工作空间');
|
||||||
navigate('/online-products', { replace: true });
|
navigate('/online-products', { replace: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示
|
// 重复邮箱返回 409 EMAIL_ALREADY_REGISTERED,落到邮箱字段上提示
|
||||||
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
if (axios.isAxiosError(error) && error.response?.status === 409) {
|
||||||
form.setFields([{ name: 'email', errors: ['该邮箱已注册,请直接登录'] }]);
|
form.setFields([{ name: 'email', errors: ['该邮箱已注册,请直接登录'] }]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 422 VALIDATION_FAILED 等带字段明细的错误,逐项落到对应表单项
|
||||||
|
const details = apiErrorDetails(error);
|
||||||
|
if (details.length > 0) {
|
||||||
|
form.setFields(
|
||||||
|
details.map((item) => ({
|
||||||
|
name: item.field as keyof RegisterFormValues,
|
||||||
|
errors: [item.message],
|
||||||
|
}))
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
message.error(apiErrorMessage(error));
|
message.error(apiErrorMessage(error));
|
||||||
}
|
}
|
||||||
@@ -91,7 +103,7 @@ const RegisterPage = () => {
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name="password"
|
name="password"
|
||||||
label="密码"
|
label="密码"
|
||||||
extra="长度与复杂度规则以服务端安全校验为准"
|
extra="至少 12 个字符;长度与复杂度以服务端安全校验为准"
|
||||||
rules={[{ required: true, message: '请输入密码' }]}
|
rules={[{ required: true, message: '请输入密码' }]}
|
||||||
>
|
>
|
||||||
<Input.Password
|
<Input.Password
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
import type { ReactNode } from 'react';
|
||||||
|
import { Navigate, useLocation, createBrowserRouter } from 'react-router-dom';
|
||||||
import MainLayout from '@/layouts/MainLayout';
|
import MainLayout from '@/layouts/MainLayout';
|
||||||
|
import SessionSplash from '@/components/SessionSplash';
|
||||||
import CollectionPage from '@/pages/collection/CollectionPage';
|
import CollectionPage from '@/pages/collection/CollectionPage';
|
||||||
import LoginPage from '@/pages/auth/LoginPage';
|
import LoginPage from '@/pages/auth/LoginPage';
|
||||||
import OnlineProductsPage from '@/pages/online-products/OnlineProductsPage';
|
import OnlineProductsPage from '@/pages/online-products/OnlineProductsPage';
|
||||||
@@ -8,11 +10,40 @@ import ProductEditPage from '@/pages/products/ProductEditPage';
|
|||||||
import RegisterPage from '@/pages/auth/RegisterPage';
|
import RegisterPage from '@/pages/auth/RegisterPage';
|
||||||
import ShopsPage from '@/pages/shops/ShopsPage';
|
import ShopsPage from '@/pages/shops/ShopsPage';
|
||||||
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
import WatermarksPage from '@/pages/watermarks/WatermarksPage';
|
||||||
|
import { useSession } from '@/services/session';
|
||||||
|
|
||||||
|
/** 主布局守卫:会话恢复中显示启动屏;未认证跳登录页并记住来源路径 */
|
||||||
|
function RequireSession({ children }: { children: ReactNode }) {
|
||||||
|
const { status } = useSession();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (status === 'restoring') {
|
||||||
|
return <SessionSplash />;
|
||||||
|
}
|
||||||
|
if (status === 'anonymous') {
|
||||||
|
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 认证页互斥:已登录访问 /login、/register 时回到主布局 */
|
||||||
|
function RedirectIfAuthenticated({ children }: { children: ReactNode }) {
|
||||||
|
const { status } = useSession();
|
||||||
|
|
||||||
|
if (status === 'authenticated') {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
element: <MainLayout />,
|
element: (
|
||||||
|
<RequireSession>
|
||||||
|
<MainLayout />
|
||||||
|
</RequireSession>
|
||||||
|
),
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="/online-products" replace /> },
|
{ index: true, element: <Navigate to="/online-products" replace /> },
|
||||||
{ path: 'online-products', element: <OnlineProductsPage /> },
|
{ path: 'online-products', element: <OnlineProductsPage /> },
|
||||||
@@ -24,8 +55,21 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'products/:id', element: <ProductEditPage /> },
|
{ path: 'products/:id', element: <ProductEditPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// 认证页在主布局之外;API 侧会话体系实现后再加路由守卫
|
{
|
||||||
{ path: 'login', element: <LoginPage /> },
|
path: 'login',
|
||||||
{ path: 'register', element: <RegisterPage /> },
|
element: (
|
||||||
|
<RedirectIfAuthenticated>
|
||||||
|
<LoginPage />
|
||||||
|
</RedirectIfAuthenticated>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'register',
|
||||||
|
element: (
|
||||||
|
<RedirectIfAuthenticated>
|
||||||
|
<RegisterPage />
|
||||||
|
</RedirectIfAuthenticated>
|
||||||
|
),
|
||||||
|
},
|
||||||
{ path: '*', element: <Navigate to="/" replace /> },
|
{ path: '*', element: <Navigate to="/" replace /> },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
import type {
|
||||||
|
AxiosInstance,
|
||||||
|
AxiosRequestConfig,
|
||||||
|
AxiosResponse,
|
||||||
|
InternalAxiosRequestConfig,
|
||||||
|
} from 'axios';
|
||||||
import { envConfig } from '@/config/env';
|
import { envConfig } from '@/config/env';
|
||||||
import { authToken } from '@/services/authToken';
|
import { authToken } from '@/services/authToken';
|
||||||
|
|
||||||
@@ -34,10 +39,11 @@ let refreshTask: Promise<string | null> | null = null;
|
|||||||
/** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */
|
/** 并发 401 只触发一次刷新请求;刷新依赖 HttpOnly refresh cookie */
|
||||||
function refreshAccessToken(): Promise<string | null> {
|
function refreshAccessToken(): Promise<string | null> {
|
||||||
refreshTask ??= apiClient
|
refreshTask ??= apiClient
|
||||||
.post<{ access_token: string }>('/auth/refresh')
|
.post('/auth/refresh')
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
authToken.set(res.data.access_token);
|
const token = unwrapEnvelope<{ access_token: string }>(res).access_token;
|
||||||
return res.data.access_token;
|
authToken.set(token);
|
||||||
|
return token;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
authToken.clear();
|
authToken.clear();
|
||||||
@@ -72,18 +78,31 @@ apiClient.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一解包强制响应信封:成功为 { data: T },204 No Content 无响应体
|
||||||
|
* (docs/api/conventions.md §3.1)。服务层泛型 T 指业务负载本身;
|
||||||
|
* 未来列表接口({ data, meta })在此扩展专门的解包方法以保留 meta。
|
||||||
|
*/
|
||||||
|
function unwrapEnvelope<T>(response: AxiosResponse): T {
|
||||||
|
const body = response.data as { data?: unknown } | undefined;
|
||||||
|
if (body === null || body === undefined) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return body.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
get: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||||
apiClient.get<T>(url, config).then((res) => res.data),
|
apiClient.get<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
||||||
|
|
||||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||||
apiClient.post<T>(url, data, config).then((res) => res.data),
|
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||||
|
|
||||||
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||||
apiClient.patch<T>(url, data, config).then((res) => res.data),
|
apiClient.patch<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||||
|
|
||||||
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
delete: <T = unknown>(url: string, config?: AxiosRequestConfig) =>
|
||||||
apiClient.delete<T>(url, config).then((res) => res.data),
|
apiClient.delete<T>(url, config).then((res) => unwrapEnvelope<T>(res)),
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */
|
/** 统一错误格式为 { error: { code, message, request_id } }(docs/api/conventions.md),提取可展示信息 */
|
||||||
@@ -99,4 +118,25 @@ export function apiErrorMessage(error: unknown): string {
|
|||||||
return error instanceof Error ? error.message : String(error);
|
return error instanceof Error ? error.message : String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiErrorDetail {
|
||||||
|
field: string;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取错误信封中的字段级明细 details: [{ field, code, message }](docs/api/conventions.md §4) */
|
||||||
|
export function apiErrorDetails(error: unknown): ApiErrorDetail[] {
|
||||||
|
if (!axios.isAxiosError(error)) return [];
|
||||||
|
const details = (error.response?.data as { error?: { details?: unknown } } | undefined)?.error
|
||||||
|
?.details;
|
||||||
|
if (!Array.isArray(details)) return [];
|
||||||
|
return details.filter(
|
||||||
|
(item): item is ApiErrorDetail =>
|
||||||
|
!!item &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
typeof (item as ApiErrorDetail).field === 'string' &&
|
||||||
|
typeof (item as ApiErrorDetail).message === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default apiClient;
|
export default apiClient;
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
|
import type { AuthSessionResponse, LoginRequest, RegisterRequest } from '@atlas/contracts';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import type { AuthSession, LoginPayload, MeResult, RegisterPayload } from '@/types/auth';
|
import type { MeResult, RefreshResult } from '@/types/auth';
|
||||||
|
|
||||||
/** 账户与会话接口,契约见 docs/api/auth.md */
|
/** 账户与会话接口,契约见 docs/api/auth.md;类型来自 @atlas/contracts(me/refresh 暂为本地临时类型) */
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
register: (payload: RegisterPayload) => api.post<AuthSession>('/auth/register', payload),
|
register: (payload: RegisterRequest) =>
|
||||||
|
api.post<AuthSessionResponse>('/auth/register', payload),
|
||||||
|
|
||||||
login: (payload: LoginPayload) => api.post<AuthSession>('/auth/login', payload),
|
login: (payload: LoginRequest) => api.post<AuthSessionResponse>('/auth/login', payload),
|
||||||
|
|
||||||
|
refresh: () => api.post<RefreshResult>('/auth/refresh'),
|
||||||
|
|
||||||
logout: () => api.post<void>('/auth/logout'),
|
logout: () => api.post<void>('/auth/logout'),
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import axios from 'axios';
|
||||||
|
import type { AuthSessionResponse } from '@atlas/contracts';
|
||||||
|
import { authApi } from '@/services/auth';
|
||||||
|
import { authToken } from '@/services/authToken';
|
||||||
|
import type { MeResult } from '@/types/auth';
|
||||||
|
|
||||||
|
export type SessionStatus = 'restoring' | 'authenticated' | 'anonymous';
|
||||||
|
|
||||||
|
export interface SessionState {
|
||||||
|
user: MeResult['user'];
|
||||||
|
workspace: MeResult['workspace'];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionContextValue {
|
||||||
|
status: SessionStatus;
|
||||||
|
session: SessionState | null;
|
||||||
|
/** 登录 / 注册成功后写入会话 */
|
||||||
|
applyAuthSession: (payload: AuthSessionResponse) => void;
|
||||||
|
/** 撤销服务端会话并清除本地状态;网络失败也保证本地登出 */
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionContext = createContext<SessionContextValue | null>(null);
|
||||||
|
|
||||||
|
// 模块级去重:StrictMode 双挂载或并发恢复时只发一次 refresh(refresh 会轮换会话,重复调用会互相失效)
|
||||||
|
let restoreTask: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/** 会话上下文:应用启动时经 HttpOnly refresh cookie 恢复会话,登录/注册/登出统一在此维护状态 */
|
||||||
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<SessionStatus>('restoring');
|
||||||
|
const [session, setSession] = useState<SessionState | null>(null);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
mountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
restoreTask ??= (async () => {
|
||||||
|
try {
|
||||||
|
const refreshed = await authApi.refresh();
|
||||||
|
authToken.set(refreshed.access_token);
|
||||||
|
const me = await authApi.me();
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
setSession({ user: me.user, workspace: me.workspace });
|
||||||
|
setStatus('authenticated');
|
||||||
|
} catch {
|
||||||
|
if (!mountedRef.current) return;
|
||||||
|
authToken.clear();
|
||||||
|
setStatus('anonymous');
|
||||||
|
} finally {
|
||||||
|
restoreTask = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
void restoreTask;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyAuthSession = useCallback((payload: AuthSessionResponse) => {
|
||||||
|
authToken.set(payload.access_token);
|
||||||
|
setSession({ user: payload.user, workspace: payload.workspace });
|
||||||
|
setStatus('authenticated');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await authApi.logout();
|
||||||
|
} catch (error) {
|
||||||
|
// access token 已过期时先刷新一次再重试,确保服务端会话被撤销;
|
||||||
|
// 仍失败则仅做本地登出,等待服务端会话自然过期
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 401) {
|
||||||
|
const refreshed = await authApi.refresh().catch(() => null);
|
||||||
|
if (refreshed) {
|
||||||
|
authToken.set(refreshed.access_token);
|
||||||
|
await authApi.logout().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
authToken.clear();
|
||||||
|
setSession(null);
|
||||||
|
setStatus('anonymous');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ status, session, applyAuthSession, logout }),
|
||||||
|
[status, session, applyAuthSession, logout]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSession(): SessionContextValue {
|
||||||
|
const context = useContext(SessionContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useSession 必须在 SessionProvider 内使用');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -1,40 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* 认证相关类型,形状对齐 docs/api/auth.md。
|
* 临时类型:@atlas/contracts 目前只提供认证请求与会话 schema,
|
||||||
* 后续 @atlas/contracts 补充认证 schema 后可迁移为 zod 推导类型。
|
* refresh / me 的响应 schema 尚未收录(契约缺口已按协作约定第 3 节提报后端 AI)。
|
||||||
|
* 形状对齐 docs/api/auth.md §4/§5;contracts 补齐后删除本文件并改用共享类型。
|
||||||
*/
|
*/
|
||||||
|
import type { AuthUser, Workspace } from '@atlas/contracts';
|
||||||
|
|
||||||
export interface AuthUser {
|
/** POST /api/auth/refresh 成功响应(信封 { data } 已在 api 层解包) */
|
||||||
id: string;
|
export interface RefreshResult {
|
||||||
email: string;
|
|
||||||
display_name: string | null;
|
|
||||||
email_verified_at: string | null;
|
|
||||||
created_at?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthWorkspace {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注册 / 登录成功返回的会话(access token 短期有效,refresh token 在 HttpOnly cookie 中) */
|
|
||||||
export interface AuthSession {
|
|
||||||
access_token: string;
|
access_token: string;
|
||||||
access_token_expires_in: number;
|
access_token_expires_in: number;
|
||||||
user: AuthUser;
|
|
||||||
workspace: AuthWorkspace;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterPayload {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
password_confirmation: string;
|
|
||||||
display_name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginPayload {
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformSummary {
|
export interface PlatformSummary {
|
||||||
@@ -43,10 +17,10 @@ export interface PlatformSummary {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/auth/me 的返回;default_store 摘要字段以后续门店接口文档为准 */
|
/** GET /api/auth/me 成功响应;default_store 摘要字段以后续门店接口文档为准 */
|
||||||
export interface MeResult {
|
export interface MeResult {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
workspace: AuthWorkspace;
|
workspace: Workspace;
|
||||||
platforms: PlatformSummary[];
|
platforms: PlatformSummary[];
|
||||||
suggested_context: {
|
suggested_context: {
|
||||||
platform_id: string;
|
platform_id: string;
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
# Vitest 入门:快速理解并上手前端自动化测试
|
||||||
|
|
||||||
|
> 面向 Atlas Seller Kit 的学习文档。本文解释 Vitest 是什么、为什么前端项目选它,以及如何在 `apps/web` 中安装、配置并写出第一批测试:断言、生命周期、mock、React 组件测试与覆盖率。读完即可在项目里独立写单测。
|
||||||
|
|
||||||
|
## 1. 一句话理解 Vitest
|
||||||
|
|
||||||
|
**Vitest = 测试运行器 + 断言库 + mock 工具,三合一,且构建在 Vite 之上。**
|
||||||
|
|
||||||
|
一个测试文件只涉及三个 API,10 秒即可看懂结构:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('被测对象', () => {
|
||||||
|
it('某个行为', () => {
|
||||||
|
expect(1 + 1).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- `describe`:把一组相关测试分组;
|
||||||
|
- `it`(别名 `test`):一条测试用例,失败即该用例不通过;
|
||||||
|
- `expect(实际值).断言(期望值)`:断言,全部断言通过用例才算通过。
|
||||||
|
|
||||||
|
运行器负责找到所有 `*.test.ts` 文件、执行、输出报告;断言库就是 `expect` 那一族方法;mock 工具是 `vi.*`,用来"替换掉"你不想在测试里真实执行的依赖(网络请求、定时器、localStorage 等)。
|
||||||
|
|
||||||
|
## 2. 为什么选 Vitest 而不是 Jest
|
||||||
|
|
||||||
|
Vitest 的 API 有意做成和 Jest 兼容(`describe/it/expect/vi`),从 Jest 迁移基本只改配置。差别在于底座:
|
||||||
|
|
||||||
|
| 维度 | Vitest | Jest |
|
||||||
|
|---|---|---|
|
||||||
|
| 构建/转换 | 复用 Vite 管线 | 自带转换器(需要额外配置 TS/JSX/别名) |
|
||||||
|
| `@` 别名、插件 | `vite.config.ts` 里已有的直接继承 | 要在 `jest.config` 里再抄一遍 |
|
||||||
|
| ESM / TS | 开箱即用,无需 `transform` 配置 | ESM 支持至今仍偏繁琐 |
|
||||||
|
| 冷启动与增量速度 | 快(与 dev server 同源,watch 模式只重跑受影响文件) | 相对慢 |
|
||||||
|
| 生态 | 与 Vite 生态(本项目技术栈)天然一致 | 生态最大,但多面向 webpack 时代 |
|
||||||
|
|
||||||
|
对本项目还有一条硬约束:`apps/web` 用的是 **Vite 8**,需要 **Vitest 4.1+**(Vite 8 支持自 4.1 引入;4.1 起 Vitest 直接使用项目里已安装的 Vite,版本天然对齐)。安装时不用刻意锁旧版,装最新稳定版即可。
|
||||||
|
|
||||||
|
## 3. 在 apps/web 中安装与配置
|
||||||
|
|
||||||
|
### 3.1 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
# 必装
|
||||||
|
pnpm add -D vitest
|
||||||
|
# 组件测试三件套(jsdom 提供 DOM 环境,后文 §8)
|
||||||
|
pnpm add -D jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
|
||||||
|
# 覆盖率(后文 §9)
|
||||||
|
pnpm add -D @vitest/coverage-v8
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 配置:直接写在 vite.config.ts 里
|
||||||
|
|
||||||
|
Vitest 默认读取 `vite.config.ts`——这意味着 `@` 别名、React 插件**零配置直接继承**,这是选它的最大红利。只需加一个 `test` 字段:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
/// <reference types="vitest/config" />
|
||||||
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
/* …原有配置不动… */
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
// Web 项目统一用 jsdom,纯逻辑测试在 jsdom 里跑也完全没问题
|
||||||
|
environment: 'jsdom',
|
||||||
|
// 每次测试前加载的公共准备代码(jest-dom 扩展、polyfill),见 §8
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
coverage: {
|
||||||
|
include: ['src/**'],
|
||||||
|
reporter: ['text', 'html'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
顶部那行 `/// <reference types="vitest/config" />` 是给 TypeScript 的类型提示,让 `test` 字段获得补全和类型检查,不影响运行。
|
||||||
|
|
||||||
|
> ⚠️ 不要另建 `vitest.config.ts`:一旦它存在,`vite.config.ts` 会被**整体忽略(不合并)**,`@` 别名就会在测试里失效。本项目统一用 vite.config.ts 的 `test` 字段。
|
||||||
|
|
||||||
|
### 3.3 添加 npm scripts
|
||||||
|
|
||||||
|
`apps/web/package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `pnpm test`:watch 模式,改代码自动重跑相关测试,适合开发时一直开着;
|
||||||
|
- `pnpm test:run`:跑一遍就退出,适合 CI 和提交前检查。
|
||||||
|
|
||||||
|
monorepo 里从仓库根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @atlas/web test:run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 写第一个测试
|
||||||
|
|
||||||
|
### 4.1 文件放哪、叫什么
|
||||||
|
|
||||||
|
Vitest 默认扫描两类文件:
|
||||||
|
|
||||||
|
- 与被测文件同目录的 `*.test.ts(x)` / `*.spec.ts(x)`,如 `src/utils/format.test.ts`;
|
||||||
|
- `__tests__/` 目录下的测试文件。
|
||||||
|
|
||||||
|
**本项目约定:与被测文件同目录同名加 `.test`**,如 `api.ts` 的测试叫 `api.test.ts`。就近放置,改哪个文件一眼就能看到它的测试。
|
||||||
|
|
||||||
|
### 4.2 用项目里的真实函数练手
|
||||||
|
|
||||||
|
`src/services/api.ts` 的 `apiErrorMessage` 是纯函数、无副作用,非常适合第一个测试(`src/services/api.test.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { apiErrorMessage } from '@/services/api';
|
||||||
|
|
||||||
|
describe('apiErrorMessage', () => {
|
||||||
|
it('Error 对象返回其 message', () => {
|
||||||
|
expect(apiErrorMessage(new Error('token 过期'))).toBe('token 过期');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('字符串原样返回', () => {
|
||||||
|
expect(apiErrorMessage('boom')).toBe('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('HTTP 错误优先展示后端 error.message', () => {
|
||||||
|
const error = new AxiosError(
|
||||||
|
'Bad Request',
|
||||||
|
'ERR_BAD_REQUEST',
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
{ status: 400, data: { error: { message: '用户名已存在' } } } as never
|
||||||
|
);
|
||||||
|
expect(apiErrorMessage(error)).toBe('用户名已存在');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无响应体时展示状态码', () => {
|
||||||
|
const error = new AxiosError('Bad Request', 'ERR_BAD_REQUEST', undefined, undefined, {
|
||||||
|
status: 400,
|
||||||
|
} as never);
|
||||||
|
expect(apiErrorMessage(error)).toBe('请求失败(HTTP 400)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
运行 `pnpm test:run`,看到绿色 ✓ 即通过。故意把某个期望值改错再跑一次,观察失败报告——它会把实际值和期望值的差异打印出来,这是 Vitest 体验最好的部分之一。
|
||||||
|
|
||||||
|
## 5. 常用断言速查
|
||||||
|
|
||||||
|
| 断言 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `toBe(x)` | 严格相等(`===`,适合原始值、同一引用) |
|
||||||
|
| `toEqual(x)` | 深度相等(对象/数组逐字段比较) |
|
||||||
|
| `toStrictEqual(x)` | 深度相等 + 检查 class 与 `undefined` 字段 |
|
||||||
|
| `toBeTruthy()` / `toBeFalsy()` | 真 / 假 |
|
||||||
|
| `toBeNull()` / `toBeUndefined()` / `toBeDefined()` | null / undefined / 非 undefined |
|
||||||
|
| `toContain(x)` | 包含(数组元素或字符串子串) |
|
||||||
|
| `toHaveLength(n)` | `.length` 等于 n |
|
||||||
|
| `toMatch(regex)` / `toMatchObject(obj)` | 匹配正则 / 部分字段匹配 |
|
||||||
|
| `toThrow(msg?)` | 抛出异常 |
|
||||||
|
| `toHaveBeenCalledWith(...)` | mock 函数以指定参数被调用(§7) |
|
||||||
|
|
||||||
|
两个惯用法:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// 异步断言:await + resolves/rejects
|
||||||
|
await expect(authApi.login(payload)).rejects.toThrow();
|
||||||
|
|
||||||
|
// 取反:任意断言前加 .not
|
||||||
|
expect(list).not.toContain('deleted');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 生命周期与批量用例
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { beforeEach, describe, it } from 'vitest';
|
||||||
|
|
||||||
|
describe('购物车', () => {
|
||||||
|
// 每条 it 前执行:适合"重置状态",保证用例互不影响
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 同族还有 afterAll / beforeAll / afterEach,作用域限定在当前 describe 内
|
||||||
|
it('加入商品', () => { /* … */ });
|
||||||
|
it('移除商品', () => { /* … */ });
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
同一段逻辑测多组输入用 `it.each`,避免复制粘贴:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
it.each([
|
||||||
|
['admin', true],
|
||||||
|
['', false],
|
||||||
|
[null, false],
|
||||||
|
])('isValidRole(%s) → %s', (role, expected) => {
|
||||||
|
expect(isValidRole(role)).toBe(expected);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Mock:隔离依赖的三种姿势
|
||||||
|
|
||||||
|
测试的核心纪律是**只测当前单元的逻辑,把外部依赖换成假的**。`vi` 提供三个层级的工具:
|
||||||
|
|
||||||
|
### 7.1 `vi.fn()`:造一个可断言的假函数
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
handle({ onSuccess });
|
||||||
|
expect(onSuccess).toHaveBeenCalledOnce();
|
||||||
|
expect(onSuccess).toHaveBeenCalledWith(expect.objectContaining({ id: 'p1' }));
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 `vi.mock()`:整个模块替换掉(最常用)
|
||||||
|
|
||||||
|
测试 `authApi` 时不应真的发 HTTP 请求,把 `@/services/api` 整体换掉:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { authApi } from '@/services/auth';
|
||||||
|
import { api } from '@/services/api';
|
||||||
|
|
||||||
|
// 工厂函数返回"假模块":auth.ts 只导入了 api,替换它即可
|
||||||
|
vi.mock('@/services/api', () => ({
|
||||||
|
api: { post: vi.fn(), get: vi.fn(), patch: vi.fn(), delete: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedPost = vi.mocked(api.post);
|
||||||
|
|
||||||
|
describe('authApi', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks(); // 每条用例前清空调用记录,防止互相污染
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logout 调用 POST /auth/logout', async () => {
|
||||||
|
mockedPost.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
await authApi.logout();
|
||||||
|
|
||||||
|
expect(api.post).toHaveBeenCalledWith('/auth/logout');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
两个关键机制:
|
||||||
|
|
||||||
|
- **提升(hoisting)**:`vi.mock` 调用会被提升到文件最顶部,先于任何 import 生效,所以"先 import 后 mock"的写法是安全的;但代价是**工厂函数里不能引用外部变量**(会报错),确有需要用 `vi.hoisted()` 声明。
|
||||||
|
- `vi.mocked(api.post)` 只是给类型加了 mock 能力,指向的就是假模块里的那个 `vi.fn()`。
|
||||||
|
|
||||||
|
### 7.3 `vi.spyOn`:只监听(或临时替换)对象上的某个方法
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
// …执行会打日志的代码…
|
||||||
|
expect(spy).toHaveBeenCalled();
|
||||||
|
spy.mockRestore(); // 还原,避免影响其他用例
|
||||||
|
```
|
||||||
|
|
||||||
|
其他高频工具一行记:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
vi.useFakeTimers(); // 冻结时间,setTimeout/Date 可控(记得配 vi.useRealTimers() 还原)
|
||||||
|
vi.stubGlobal('fetch', vi.fn()); // 替换全局对象(fetch、localStorage 等)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 测试 React 组件
|
||||||
|
|
||||||
|
组件测试用 Testing Library:**像用户一样操作页面(渲染→点击→断言看到的内容),而不是断言内部状态**。它在 jsdom 里渲染真实 React 组件(React 19 需要 `@testing-library/react` ≥ 16,安装最新版即可)。
|
||||||
|
|
||||||
|
公共准备代码 `src/test/setup.ts`(对应 §3.2 的 `setupFiles`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import '@testing-library/jest-dom/vitest'; // 提供 toBeInTheDocument 等DOM断言
|
||||||
|
|
||||||
|
// antd 依赖 matchMedia,jsdom 未实现,需打桩
|
||||||
|
window.matchMedia ??= ((query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
})) as typeof window.matchMedia;
|
||||||
|
```
|
||||||
|
|
||||||
|
一个组件测试的样子:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
function ConfirmButton({ onConfirm }: { onConfirm: () => void }) {
|
||||||
|
return <button onClick={onConfirm}>确认删除</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ConfirmButton', () => {
|
||||||
|
it('点击后触发 onConfirm', async () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
render(<ConfirmButton onConfirm={onConfirm} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||||||
|
|
||||||
|
expect(onConfirm).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
要点:
|
||||||
|
|
||||||
|
- `render` 渲染组件,`screen.getByRole/getByText/getByLabelText` 按用户视角查询元素(查不到会抛错,用 `queryBy*` 断言"不存在");
|
||||||
|
- `userEvent` 模拟真实交互(点击、输入),必须是 `await`;
|
||||||
|
- 测页面级组件(如 `LoginPage`)时,若内部用到 Router/Session 上下文,需要先包一层 Provider 或 mock 对应模块——这也是组件测试的主要成本所在,建议**只对关键交互路径写组件测试**。
|
||||||
|
|
||||||
|
## 9. 日常运行与覆盖率
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm test # watch 模式(开发时开着)
|
||||||
|
pnpm test:run # 单次运行
|
||||||
|
pnpm test:run src/services # 只跑路径匹配的文件
|
||||||
|
pnpm test:run -t 'logout' # 只跑名字含 logout 的用例
|
||||||
|
pnpm test:run --related # 只测本次 git 变更涉及的文件(提交前自查很好用)
|
||||||
|
pnpm test:coverage # 覆盖率报告(终端表格 + html/coverage/index.html)
|
||||||
|
pnpm test -- --ui # 浏览器图形界面(需 pnpm add -D @vitest/ui)
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖率数字只是参考:`utils`、`services` 这类逻辑层应追求高覆盖;UI 组件靠少量关键交互测试兜底即可,不必为数字凑行数。
|
||||||
|
|
||||||
|
## 10. 在本项目的落地建议
|
||||||
|
|
||||||
|
1. **优先测两层**:`src/utils/` 纯函数(成本最低、收益最高);`src/services/` 服务层——mock 掉 `@/services/api` 后,401 刷新重放、信封解包(`unwrapEnvelope`)、`apiErrorMessage` 这些核心逻辑都能稳定验证。
|
||||||
|
2. **组件测试抓大放小**:登录/注册表单校验与提交、路由跳转等关键路径值得写;纯展示组件不强制。
|
||||||
|
3. **会话相关逻辑先打桩再测**:`api.ts` 刷新失败会执行 `window.location.assign('/login')`,jsdom 里 location 不可随意改写,测试时用 `vi.stubGlobal` 或把跳转逻辑抽成可注入的函数。
|
||||||
|
4. **接入流程**:本地开发开 `pnpm test`;提交前 `pnpm test:run --related`;CI 里跑 `test:coverage`。后续可在 `turbo.json` 中加 `test` 任务,实现根目录 `turbo run test` 一键跑全部 workspace。
|
||||||
|
5. **与 typecheck 分工**:`tsc` 管"类型对不对",Vitest 管"行为对不对",两者互补,提交前都跑。
|
||||||
|
|
||||||
|
## 11. 常见坑速查
|
||||||
|
|
||||||
|
| 症状 | 原因与解法 |
|
||||||
|
|---|---|
|
||||||
|
| 测试里 `@/...` 导入报错"找不到模块" | 建了 `vitest.config.ts` 导致 `vite.config.ts` 被整体忽略。删掉它,统一写在 `vite.config.ts` 的 `test` 字段(见 §3.2) |
|
||||||
|
| `vi.mock` 工厂里引用了外部变量报错 | `vi.mock` 会被提升到文件顶部,工厂不能闭包引用外部变量;需要共享值时用 `vi.hoisted()` |
|
||||||
|
| antd 组件测试报 `matchMedia is not a function` | jsdom 未实现该 API,在 `src/test/setup.ts` 里打桩(见 §8) |
|
||||||
|
| `toBeInTheDocument` 类型报错 | 缺 `import '@testing-library/jest-dom/vitest'`,确认 setup 文件已配置 |
|
||||||
|
| 测 `setTimeout`/节流时用例"卡住"或时序不稳 | 用 `vi.useFakeTimers()` 冻结时间并手动 `vi.advanceTimersByTime()` |
|
||||||
|
| 覆盖率命令报错找不到 provider | 覆盖率是可选依赖:`pnpm add -D @vitest/coverage-v8` |
|
||||||
|
| 想让 `describe/it/expect` 免导入直接用 | 配置 `test.globals: true`,并在 tsconfig 的 `compilerOptions.types` 加 `"vitest/globals"`。本项目建议保持显式导入(不折腾 tsconfig,IDE 跳转也更准) |
|
||||||
|
|
||||||
|
## 12. 参考资料
|
||||||
|
|
||||||
|
- [Vitest 官方文档](https://vitest.dev/guide/)(Getting Started / Guide / API 三栏足够日常使用)
|
||||||
|
- [Vitest 4.1 发布公告](https://vitest.dev/blog/vitest-4-1.html)(Vite 8 支持与版本对齐说明)
|
||||||
|
- [Testing Library 官方文档](https://testing-library.com/docs/react-testing-library/intro/)(查询优先级与 userEvent 用法)
|
||||||
|
- 本项目接口契约:`docs/api/auth.md`、`docs/api/conventions.md`
|
||||||
Generated
+1935
-486
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user