-
+
+
+
+
+
+
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/components/Login.vue b/src/components/Login.vue
new file mode 100644
index 0000000..e536bc3
--- /dev/null
+++ b/src/components/Login.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
Sign in
+
Need an account?
+
+
+
+
+
+
+
+
+
+ Sign in
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/Me.vue b/src/components/Me.vue
new file mode 100644
index 0000000..0323d8d
--- /dev/null
+++ b/src/components/Me.vue
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/src/components/Navbar.vue b/src/components/Navbar.vue
new file mode 100644
index 0000000..44c6e73
--- /dev/null
+++ b/src/components/Navbar.vue
@@ -0,0 +1,38 @@
+
+
+ Conduit
+
+ Home
+ Sign in
+ Sign up
+
+
+ Home
+ New Article
+ Settings
+ {{ user?.username || "please login"}}
+
+
+
+
+
+
diff --git a/src/components/NewArticle.vue b/src/components/NewArticle.vue
new file mode 100644
index 0000000..94ed2bd
--- /dev/null
+++ b/src/components/NewArticle.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ removeTag(idx)"
+ >{{ tag }}
+
+
+
+
+ Publish Article
+
+
+
+
+
diff --git a/src/components/Register.vue b/src/components/Register.vue
new file mode 100644
index 0000000..195c553
--- /dev/null
+++ b/src/components/Register.vue
@@ -0,0 +1,100 @@
+
+
+
+
Sign up
+
Have an account?
+
+
+
+
+
+
+
+
+
+
+
+
+ Sign up
+
+
+
+
+
+
diff --git a/src/components/Setting.vue b/src/components/Setting.vue
new file mode 100644
index 0000000..3b6f6f0
--- /dev/null
+++ b/src/components/Setting.vue
@@ -0,0 +1,6 @@
+
+ in the setting
+
+
+
+
\ No newline at end of file
diff --git a/src/components/YourFeed.vue b/src/components/YourFeed.vue
new file mode 100644
index 0000000..8a5ad2c
--- /dev/null
+++ b/src/components/YourFeed.vue
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main.ts b/src/main.ts
index f2797ef..2d40178 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,13 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
-import router from './router/index.ts'
+import router from './router/index'
import './style.css'
import App from './App.vue'
+import * as ElementPlusIconsVue from "@element-plus/icons-vue";
+import { useAuthStore } from './store/authStore'
+import "element-plus/dist/index.css";
const app = createApp(App)
const pinia = createPinia()
-
+for (const [key, val] of Object.entries(ElementPlusIconsVue)) {
+ app.component(key, val)
+}
app.use(pinia)
-.use(router)
-.mount('#app')
+app.use(router)
+const auth = useAuthStore()
+// 这里需要等待bootstrap,防止“先渲染后回跳“的现象
+await auth.bootstrap().catch(() => {}) // 该方法只在此处调用一次,用于刷新整体登录状态
+app.mount('#app')
diff --git a/src/router/index.ts b/src/router/index.ts
index e935402..0bec5b8 100644
--- a/src/router/index.ts
+++ b/src/router/index.ts
@@ -1,15 +1,26 @@
-import {createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
-import HelloWorld from '../components/HelloWorld.vue'
+import { createRouter, createWebHistory } from 'vue-router'
+import { routes } from './routes.ts'
+import { useAuthStore } from '../store/authStore.ts'
-const routes: RouteRecordRaw[] = [
- {
- path: '/',
- name: 'Home',
- component: HelloWorld
- }
-]
const router = createRouter({
history: createWebHistory(),
routes
})
+
+// 全局路由守卫
+router.beforeEach(async to => {
+ // 只初始化一次,不会在每次路由切换的时候都调用 fetchMe
+ // 初始化的行为放在 main.ts 里面,只进行一次,这里只通过登录状态来判断逻辑
+ const auth = useAuthStore()
+ if (to.meta.authRequired && !auth.isLoggedIn) {
+ // 需要权限 && 未登录
+ return {name: 'Login', query: {redirect: to.fullPath}}
+ }
+ if (to.name === 'Login' && auth.isLoggedIn) {
+ // 如果目标地址为登录页 && 已经是登录状态,则直接去主页
+ return {name: 'GlobalFeed'}
+ }
+ return true
+})
+
export default router
\ No newline at end of file
diff --git a/src/router/routes.ts b/src/router/routes.ts
new file mode 100644
index 0000000..c1d0b76
--- /dev/null
+++ b/src/router/routes.ts
@@ -0,0 +1,90 @@
+import { type RouteRecordRaw } from 'vue-router';
+import Layout from '../components/Layout.vue';
+export const routes: RouteRecordRaw[] = [
+ {
+ path: "/",
+ component: Layout,
+ children: [
+ {
+ path: "",
+ component: () => import("../components/Home.vue"),
+ children: [
+ {
+ path: "",
+ redirect: { name: "GlobalFeed" },
+ },
+ {
+ path: "personal-feed",
+ name: "PersonalFeed",
+ component: () => import("../components/YourFeed.vue"),
+ meta: { authRequired: true },
+ },
+ {
+ path: "global-feed",
+ name: "GlobalFeed",
+ component: () => import("../components/GlobalFeed.vue"),
+ },
+ ],
+ },
+ {
+ path: "login",
+ name: "Login",
+ component: () => import("../components/Login.vue"),
+ },
+ {
+ path: "register",
+ name: "Register",
+ component: () => import("../components/Register.vue"),
+ },
+ {
+ path: "editor/:slug",
+ name: "EditArticle",
+ component: () => import("../components/NewArticle.vue"),
+ meta: { authRequired: true },
+ props: true,
+ },
+ {
+ path: "editor",
+ name: "NewArticle",
+ component: () => import("../components/NewArticle.vue"),
+ meta: { authRequired: true },
+ },
+ {
+ path: "settings",
+ name: "Settings",
+ component: () => import("../components/Setting.vue"),
+ meta: { authRequired: true },
+ },
+ {
+ path: "me",
+ name: "Me",
+ component: () => import("../components/Me.vue"),
+ meta: { authRequired: true },
+ },
+ {
+ path: "article/:slug",
+ name: "Article",
+ component: () => import("../components/Article.vue"),
+ props: true
+ },
+ { path: ":pathMatch(.*)*", redirect: { name: "GlobalFeed" } }
+ ],
+ },
+];
+
+
+/**
+ * 路由结构图
+/
+└── Layout.vue (全站外壳:Navbar + Footer)
+ ├── '' → Home.vue (容器:Hero + Tabs + Popular Tags +
)
+ │ ├── '' (redirect) → GlobalFeed
+ │ ├── personal-feed → YourFeed.vue (meta.authRequired = true)
+ │ └── global-feed → GlobalFeed.vue
+ │
+ ├── login → Login.vue
+ ├── register → Register.vue
+ ├── edit/:slug → NewArticle.vue (meta.authRequired = true)
+ ├── settings → Setting.vue (meta.authRequired = true)
+ └── me → Me.vue (meta.authRequired = true)
+ */
\ No newline at end of file
diff --git a/src/store/articleStore.ts b/src/store/articleStore.ts
new file mode 100644
index 0000000..1499e8a
--- /dev/null
+++ b/src/store/articleStore.ts
@@ -0,0 +1,197 @@
+import { defineStore } from 'pinia'
+import { getGlobalFeed, getFollowedFeed } from '../api/articles'
+import { reactive } from 'vue'
+import type { Article } from '../types/articles'
+import axios from 'axios'
+
+// 这是一个多列表容器
+interface ListState {
+ slugs: string[]; // 只存引用,对应的数据在 bySlug
+ total: number; // 总条数
+ error: string | null;
+ params: Record; // 当前这个列表的查询参数快照(tag/author/limit/offset…)
+ loading: boolean;
+ abort?: AbortController; // 取消在途请求(切换筛选/分页时用)
+ // store 内部的“开关”,保证你切换参数时不会让旧请求回写覆盖数据。// lastUpdated: number;
+}
+const normalizeError = (e: unknown) => {
+ // 这里是根据 API 文档中的错误数据的结构类型写的
+ return (e as any)?.message || (e as any)?.response?.data?.errors?.body[0] || 'Request failed!'
+}
+
+/**
+ *
+ * @param params
+ * @returns
+ * 把查询参数稳定序列化为 key。
+ */
+const keyOf = (params: Record) => {
+ const usp = new URLSearchParams()
+ const keys = Object.keys(params).sort()
+ for (const k of keys) {
+ const val = params[k]
+ if (val === null || val === undefined || val === '') continue
+ usp.append(k, String(val))
+ }
+ return usp.toString() || '__default__'
+}
+export const useArticleStore = defineStore('article', () => {
+ // 将每篇 article 缓存在这个字典里
+ const bySlug = reactive>({});
+
+ // 将 articles 使用 slug 进行映射
+ const writeEntities = (articles: Article[]) => {
+ for (const a of articles) {
+ bySlug[a.slug] = a;
+ }
+ };
+ const globalLists = reactive>({}); // key 为序列化之后的查询参数
+ const feedLists = reactive>({});
+
+ /**
+ *
+ * @param map
+ * @param params
+ * @returns
+ * 保证某个 key 的 ListState 存在(不存在就初始化),返回 { key, state }
+ */
+ const ensure = (
+ map: Record,
+ params: Record
+ ) => {
+ // 这个工具函数是确保在没有传 params 的时候给定一个默认的对象
+ // 这里的 map 即 globalLists / feedLists
+ const key = keyOf(params);
+ if (!map[key]) {
+ map[key] = { slugs: [], total: 0, loading: false, params: { ...params }, error: null};
+ }
+ return { key, state: map[key] };
+ };
+
+ /**
+ *
+ * @param params
+ * 这是覆盖式的,同名会覆盖
+ */
+ async function fetchGlobal(params: Record) {
+ const { key, state } = ensure(globalLists, params);
+ state.loading = true;
+ // 此处的 loading 是否为响应式的?
+ state.error = null
+
+ // 取消上一次的请求,绑定新的取消控制器
+ state.abort?.abort()
+ const ac = new AbortController()
+ state.abort = ac
+ try {
+ const res = await getGlobalFeed(params, {signal: ac.signal});
+ writeEntities(res.articles);
+ state.slugs = res.articles.map((a) => a.slug);
+ state.total = res.articlesCount;
+ return { key, ok: true };
+ } catch (e) {
+ if (axios.isAxiosError(e) && (e as any).code === "ERR_CANCELED") return
+ if ((e as any).name === 'AbortError') return
+ state.error = normalizeError(e)
+ return { key, ok: false };
+ } finally {
+ if (!ac.signal.aborted) {
+ state.loading = false;
+ }
+ }
+ }
+ async function fetchFeed(params: Record) {
+ const { key, state } = ensure(feedLists, params);
+ state.loading = true;
+ state.error = null;
+
+ // 取消旧的请求,添加新的取消控制器
+ state.abort?.abort();
+ const ac = new AbortController()
+ state.abort = ac
+ try {
+ const res = await getFollowedFeed(params, {signal: ac.signal});
+ writeEntities(res.articles);
+ state.slugs = res.articles.map((a) => a.slug);
+ state.total = res.articlesCount;
+ return { key, ok: true};
+ } catch (e) {
+ // 如果错误类型为 AbortError ,那么将会触发新的一次请求,没有必要处理这个错误
+ if (axios.isAxiosError(e) && e.code === "ERR_CANCELED") {
+ // axios 的取消,是 CanceledError,code 为 ERR_CANCELED
+ return
+ }
+ if ((e as any).name === 'AbortError') {
+ // 有的环境是这个错误
+ return
+ }
+ state.error = normalizeError(e);
+ return { key, ok: false};
+ } finally {
+ // 只有当"这一次"请求没有被取消,才把 loading 置为 false
+ if (!ac.signal.aborted) {
+ state.loading = false;
+ }
+ }
+ }
+ function selectGlobal(params: Record): ListState {
+ return ensure(globalLists, params).state
+ }
+
+
+ function selectFeed(params: Record): ListState {
+ return ensure(feedLists, params).state
+ }
+
+ /**
+ *
+ * @param slug
+ * @returns
+ * 从实体缓存拿文章。
+ */
+ function getArticle(slug: string) {
+ return bySlug[slug] || null;
+ }
+
+ async function appendGlobal(params: Record) {
+ const { key, state } = ensure(globalLists, params)
+ if (state.loading) return { key, ok: true } // 防止重复触发
+
+ state.loading = true
+ state.error = null
+ state.abort?.abort()
+ const ac = new AbortController()
+ state.abort = ac
+ try {
+ const res = await getGlobalFeed(params, {signal: ac.signal})
+ writeEntities(res.articles)
+ const more = res.articles.map(a => a.slug)
+ const set = new Set([...state.slugs, ...more])
+ state.slugs = Array.from(set)
+ state.total = res.articlesCount
+ return { key, ok: true }
+ } catch (e) {
+ if (axios.isAxiosError(e) && (e as any).code === "ERR_CANCELED") return;
+ if ((e as any).name === "AbortError") return;
+ state.error = normalizeError(e)
+ return { key, ok: false }
+ } finally {
+ if (!ac.signal.aborted) {
+ state.loading = false;
+ }
+ }
+ }
+ return {
+ // states
+ globalLists,
+ feedLists,
+ // actions
+ fetchGlobal,
+ fetchFeed,
+ appendGlobal,
+ // selectors
+ selectFeed,
+ selectGlobal,
+ getArticle,
+ };
+})
\ No newline at end of file
diff --git a/src/store/authStore.ts b/src/store/authStore.ts
new file mode 100644
index 0000000..1d5a855
--- /dev/null
+++ b/src/store/authStore.ts
@@ -0,0 +1,140 @@
+// authStore.ts
+import { ref, computed } from 'vue'
+import { defineStore } from 'pinia'
+import { getCurrentUser, getLogin, getRegister } from '../api/users'
+import { getToken, setToken, removeToken } from '../utils/token.ts'
+import type { User, RegisterUser } from '../types/users.ts'
+import axios from 'axios'
+
+export const useAuthStore = defineStore('auth', () => {
+ const token = ref(getToken());
+ const user = ref(null);
+ const isLoggedIn = computed(() => !!token.value);
+
+ // 会话级状态机
+ const status = ref<"idle" | "loading" | "authenticated">("idle");
+ /**
+ * 会话级别的状态(登录/认证流程) → 用 authStore.status 或它的衍生计算属性。
+ * 组件自身的业务 loading → 自己维护 ref。
+ */
+ const booted = ref(false);
+ // 登录:更新状态并在失败时抛出原始错误
+ async function login(payload: {
+ username: string;
+ password: string;
+ }): Promise {
+ status.value = "loading";
+ try {
+ const res = await getLogin(payload);
+ // http 层已做解包,直接从 data 中取 user
+ user.value = (res as any)?.user ?? null;
+ token.value = user.value?.token ?? null;
+ // 此处应该在确定有 token 的时候将 status 置为 authenticated
+ // 最初版本没要考虑到这一点,此为后续补充
+ if (token.value) {
+ setToken(token.value);
+ status.value = "authenticated";
+ } else {
+ status.value = "idle";
+ }
+ } catch (e) {
+ status.value = "idle";
+ throw e;
+ }
+ }
+ /**
+ * 在登录逻辑里面,为了防止用户狂按 登录 ,可以利用 status 的 loading 这个全局状态对按钮进行限制,这个应该放在组件里面操作
+ * 登录
+ */
+
+ // 注册
+ async function register(payload: {user: RegisterUser}): Promise {
+ status.value = 'loading'
+ try {
+ const res = await getRegister(payload)
+ user.value = res.user || null
+ token.value = user.value?.token ?? null
+ if (token.value) {
+ setToken(token.value)
+ status.value = 'authenticated'
+ } else {
+ status.value = 'idle'
+ }
+ } catch (e) {
+ status.value = 'idle'
+ throw e
+ }
+ }
+
+ // 拉取当前用户:401 时执行登出并抛出错误
+ async function fetchMe(): Promise {
+ if (!token.value) return;
+ try {
+ const res = await getCurrentUser();
+ user.value = (res as any)?.user ?? null;
+ if (user.value?.token) {
+ token.value = user.value.token;
+ setToken(user.value.token);
+ status.value = "authenticated";
+ } else {
+ token.value = null
+ removeToken()
+ status.value = 'idle'
+ }
+ } catch (e) {
+ // 这里只捕获 401 错误,其他错误在个子组件里面自行捕获处理
+ if (axios.isAxiosError(e) && e.response?.status === 401) {
+ // 这里的 401 属于 HTTP 层级的语义
+ logout();
+ }
+ throw e;
+ }
+ }
+
+ // 启动:有 token 则验证当前会话,失败时清理并将错误继续抛出
+ async function bootstrap(): Promise {
+ if (token.value) {
+ try {
+ await fetchMe();
+ } catch (e) {
+ logout();
+ throw e;
+ }
+ }
+ booted.value = true;
+ }
+
+ function logout(): void {
+ token.value = null;
+ user.value = null;
+ removeToken();
+ status.value = "idle";
+ }
+
+ return {
+ token,
+ user,
+ isLoggedIn,
+ status,
+ booted,
+ login,
+ fetchMe,
+ logout,
+ bootstrap,
+ register
+ };
+})
+/**
+ * booted 字段使用场景:
+ * 路由守卫:有些页面要等 booted 为 true 才能正确判断是否放行。
+ * 避免“闪屏”:比如刚启动时,虽然 status 可能还是 "idle",但实际上还没跑 fetchMe(),不能贸然认为“未登录”。
+ * 它是 一次性开关,初始化完成后固定为 true。
+ */
+
+/**
+ * booted / status是否有必要同时存在?
+ • 有必要。
+ • status 负责表达“会话当前状态”,但它没法告诉你“authStore 是否初始化过”。
+ • 举例:应用启动时,status 可能是 "idle",但这并不意味着用户一定没登录,有可能 token 在本地,要等 fetchMe() 校验才知道。
+ • booted 就解决了这个问题,它告诉你“认证初始化流程是否跑完”,保证路由守卫和 UI 能安全地用 status 去判断。
+ */
\ No newline at end of file
diff --git a/src/store/user.ts b/src/store/userStore.ts
similarity index 100%
rename from src/store/user.ts
rename to src/store/userStore.ts
diff --git a/src/style.css b/src/style.css
index 818f283..a461c50 100644
--- a/src/style.css
+++ b/src/style.css
@@ -1,80 +1 @@
-@import "tailwindcss";
-:root {
- font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
- line-height: 1.5;
- font-weight: 400;
-
- color-scheme: light dark;
- color: rgba(255, 255, 255, 0.87);
- background-color: #242424;
-
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-}
-
-a {
- font-weight: 500;
- color: #646cff;
- text-decoration: inherit;
-}
-a:hover {
- color: #535bf2;
-}
-
-body {
- margin: 0;
- display: flex;
- place-items: center;
- min-width: 320px;
- min-height: 100vh;
-}
-
-h1 {
- font-size: 3.2em;
- line-height: 1.1;
-}
-
-button {
- border-radius: 8px;
- border: 1px solid transparent;
- padding: 0.6em 1.2em;
- font-size: 1em;
- font-weight: 500;
- font-family: inherit;
- background-color: #1a1a1a;
- cursor: pointer;
- transition: border-color 0.25s;
-}
-button:hover {
- border-color: #646cff;
-}
-button:focus,
-button:focus-visible {
- outline: 4px auto -webkit-focus-ring-color;
-}
-
-.card {
- padding: 2em;
-}
-
-#app {
- max-width: 1280px;
- margin: 0 auto;
- padding: 2rem;
- text-align: center;
-}
-
-@media (prefers-color-scheme: light) {
- :root {
- color: #213547;
- background-color: #ffffff;
- }
- a:hover {
- color: #747bff;
- }
- button {
- background-color: #f9f9f9;
- }
-}
+@import "tailwindcss";
\ No newline at end of file
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..60300b0
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,13 @@
+export interface Article {
+ id: number;
+ author: {
+ username: string;
+ avatar: string;
+ };
+ createdAt: string;
+ title: string;
+ description: string;
+ tags: string[];
+ favoritesCount: number;
+ favorited: boolean;
+}
diff --git a/src/types/articles.ts b/src/types/articles.ts
new file mode 100644
index 0000000..ad5983b
--- /dev/null
+++ b/src/types/articles.ts
@@ -0,0 +1,21 @@
+interface Author {
+ username: string;
+ bio: string;
+ image: string;
+ following: boolean;
+}
+export interface Article {
+ slug: string;
+ title: string;
+ description: string;
+ tagList: string[];
+ createdAt: string; // ISO 时间字符串
+ updatedAt: string; // ISO 时间字符串
+ favorited: boolean;
+ favoritesCount: number;
+ author: Author;
+}
+export interface ArticleResponse {
+ articles: Article[];
+ articlesCount: number;
+}
\ No newline at end of file
diff --git a/src/types/users.ts b/src/types/users.ts
new file mode 100644
index 0000000..8216b6b
--- /dev/null
+++ b/src/types/users.ts
@@ -0,0 +1,13 @@
+// 当前的用户信息
+export interface User {
+ email: string;
+ token: string;
+ username: string;
+ bio: string;
+ image: string;
+}
+export interface RegisterUser {
+ username: string,
+ email: string,
+ password: string
+}
\ No newline at end of file
diff --git a/src/utils/http.ts b/src/utils/http.ts
new file mode 100644
index 0000000..ff3ed5a
--- /dev/null
+++ b/src/utils/http.ts
@@ -0,0 +1,46 @@
+import axios, { AxiosHeaders, type AxiosRequestConfig } from 'axios'
+import { isRef } from 'vue'
+import { useAuthStore } from '../store/authStore'
+
+// 为 axios 增加类型声明
+declare module 'axios' {
+ export interface AxiosRequestConfig {
+ // 在调用 axios interceptor 的时候,传入这个字段标识是否需要鉴权
+ AuthRequired?: boolean
+ }
+}
+
+/**
+ * Axios 的两个“层”
+ * HTTP 层:真实的 HTTP 状态码(response.status,如 200/401/500…)。
+ * 业务层:后端放在 响应体里 的自定义码(response.data.code,如 0/200/401/501…)。
+ */
+
+export const http = axios.create({
+ baseURL: import.meta.env.VITE_API_BASE_URL,
+ timeout: 15000
+})
+
+http.interceptors.request.use(config => {
+ if (config.AuthRequired) {
+ // 如果这个文件在 Pinia 初始化前被 import,会拿不到 store。所以不要在模块顶层使用 useAuthStore()
+ const { token: tokenSource } = useAuthStore()
+ const token = isRef(tokenSource) ? tokenSource.value : tokenSource
+ if (token) {
+ // 有 token ,则把 token 挂到 请求体的 header 上面
+ config.headers = new AxiosHeaders(config.headers)
+ config.headers.set( 'Authorization', `Token ${token}`)
+ }
+ }
+ delete config.AuthRequired
+ return config
+}, error => {
+ return Promise.reject(error)
+})
+
+http.interceptors.response.use(response => {
+ // HTTP 200
+ return response.data
+}, error => {
+ return Promise.reject(error)
+})
diff --git a/src/utils/token.ts b/src/utils/token.ts
new file mode 100644
index 0000000..ab9dee4
--- /dev/null
+++ b/src/utils/token.ts
@@ -0,0 +1,13 @@
+const KEY = 'userToken'
+
+export const setToken = (token: string): void => {
+ localStorage.setItem(KEY, token)
+}
+
+export const removeToken = (): void => {
+ localStorage.removeItem(KEY)
+}
+
+export const getToken = (): string | null => {
+ return localStorage.getItem(KEY)
+}
\ No newline at end of file
diff --git a/src/smoke.test.ts b/test/smoke.test.ts
similarity index 100%
rename from src/smoke.test.ts
rename to test/smoke.test.ts
diff --git a/test/user.test.ts b/test/user.test.ts
new file mode 100644
index 0000000..cb2ed12
--- /dev/null
+++ b/test/user.test.ts
@@ -0,0 +1,25 @@
+import axios from "axios"
+
+
+test('get the current user', async () => {
+ // Given
+ const currentUser = {
+ email: "xxx@gmail.com",
+ token: "TEST",
+ username: "xxx",
+ bio: "bio desc",
+ image: "img",
+ }
+
+ // When
+ const res = fetch('http://localhost:5173/user')
+
+ // Then
+ await expect((await res).json()).resolves.toEqual(currentUser);
+})
+interface User {
+ email: string;
+ password: string;
+ username: string;
+}
+
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 6bfd5d2..952937c 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -1,6 +1,7 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
+ "composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vitest/globals"],
diff --git a/tsconfig.json b/tsconfig.json
index 1ffef60..e4a145f 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,26 @@
{
- "files": [],
+ // "files": [],
+ // files:用于精确列出要编译的文件。你明确告诉 TypeScript 需要编译哪些文件。
+
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
- ]
+ ],
+
+ "include": ["src/**/*", "test/**/*"],
+ // include:用于指定文件夹或者通配符模式,表示哪些文件需要被 TypeScript 编译。
+
+ "compilerOptions": {
+ "moduleResolution": "node", // 使用 Node.js 模块解析
+ // 用于定义编译器如何处理你的代码。
+ "types": ["vitest/globals"],
+ // types 字段用来指定哪些全局类型声明应该被加载。
+ // 告知 TypeScript 使用 vitest 提供的全局类型定义,从而使得 describe、it、expect 等函数可以在测试文件中全局可用。
+ "target": "ES2015",
+ "lib": ["ES2015", "DOM"], // 引入 ES2015 和 DOM 库
+ "baseUrl": ".", // 很重要
+ "paths": {
+ "@/*": ["src/*"] // 让 @ 指向 src
+ }
+ }
}
diff --git a/tsconfig.node.json b/tsconfig.node.json
index f85a399..4b917c1 100644
--- a/tsconfig.node.json
+++ b/tsconfig.node.json
@@ -1,8 +1,9 @@
{
"compilerOptions": {
+ "composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
- "lib": ["ES2023"],
+ "lib": ["ES2023", "ES2015"],
"module": "ESNext",
"skipLibCheck": true,
diff --git a/vite.config.ts b/vite.config.ts
index 9f6a31d..8ee337f 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,13 +1,35 @@
import { defineConfig } from 'vitest/config'
import tailwindcss from "@tailwindcss/vite";
import vue from '@vitejs/plugin-vue'
+import Components from "unplugin-vue-components/vite";
+import { ElementPlusResolver } from "unplugin-vue-components/resolvers";
// https://vite.dev/config/
export default defineConfig({
- plugins: [vue(), tailwindcss()],
+ plugins: [
+ vue(),
+ tailwindcss(),
+ Components({
+ resolvers: [ElementPlusResolver()], // 自动导入 Element Plus 组件
+ }),
+ ],
test: {
+ setupFiles: "./vitest.setup.ts",
globals: true, // 允许 describe/it/expect 全局可用
- environment: "jsdom", // Vue 组件测试需要 DOM 环境
+ environment: "node", // Vue 组件测试需要 DOM 环境, mock 接口测试需要 node 环境
+ coverage: {
+ reporter: ["text", "json", "html"], // 配置代码覆盖率报告
+ },
+ include: ["test/**/*.test.ts"],
+ },
+ server: {
+ proxy: {
+ "/api": {
+ target: "https://api.realworld.show/api",
+ changeOrigin: true,
+ // rewrite: (path) => path.replace(/^\/api/, "/api"),
+ },
+ },
},
});
/**
diff --git a/vitest.setup.ts b/vitest.setup.ts
new file mode 100644
index 0000000..076d7fb
--- /dev/null
+++ b/vitest.setup.ts
@@ -0,0 +1,18 @@
+import { beforeAll, afterEach, afterAll } from "vitest"
+import { server } from "./mock/node.ts"
+
+beforeAll(() => {
+ // 启动 MSW 服务器并监听 HTTP 请求,在测试开始前,它会拦截所有网络请求
+ server.listen()
+ console.log('正在监听所有测试。。。')
+})
+
+afterEach(() => {
+ // 每次测试执行后重置所有已设置的请求处理器,以便不影响后续测试
+ server.resetHandlers()
+ console.log('确保每个测试后都重置 MSW 请求处理器,防止测试间有状态或处理器的干扰')
+})
+afterAll(() => {
+// 停止 MSW 服务器,释放资源,防止资源泄漏
+ server.close()
+})