Browse Source

feat(auth): 实现用户认证功能及持久化存储

- 添加pinia-plugin-persistedstate插件实现状态持久化
- 创建auth store管理token状态和认证逻辑
- 实现长按头像弹出登录对话框功能
- 新增LoginAlert组件处理用户认证流程
- 重构axios拦截器逻辑,移除路由跳转
- 调整输入框样式,增加密码输入特效
Sakulin 2 months ago
parent
commit
4b403a5e72
10 changed files with 309 additions and 85 deletions
  1. 1 0
      package.json
  2. 22 2
      src/App.vue
  3. 10 1
      src/assets/icons.ts
  4. 61 0
      src/assets/main.css
  5. 135 0
      src/layouts/LoginAlert.vue
  6. 13 4
      src/main.ts
  7. 45 0
      src/stores/auth.ts
  8. 0 12
      src/stores/counter.ts
  9. 21 38
      src/utils/axios.ts
  10. 1 28
      src/views/PostEditView.vue

+ 1 - 0
package.json

@@ -17,6 +17,7 @@
     "axios": "^1.9.0",
     "highlightjs": "^9.16.2",
     "pinia": "^3.0.1",
+    "pinia-plugin-persistedstate": "^4.3.0",
     "prismjs": "^1.30.0",
     "vite-plugin-prismjs": "^0.0.11",
     "vue": "^3.5.13",

+ 22 - 2
src/App.vue

@@ -1,9 +1,28 @@
 <script setup lang="ts">
-
 import { RouterView } from 'vue-router';
 
 import SideMenu from './layouts/BlogMenu.vue';
 import Footer from './layouts/BlogFooter.vue';
+import { ref } from 'vue';
+import LoginAlert from '@/layouts/LoginAlert.vue'
+
+const displayLoginAlert = ref(false);
+
+let pressTimer: number | null = null;
+const LONG_PRESS_DURATION = 1000;
+
+const avatarMouseDownHandler = () => {
+  pressTimer = window.setTimeout(() => {
+    displayLoginAlert.value = true;
+  }, LONG_PRESS_DURATION);
+};
+
+const avatarMouseUpHandler = () => {
+  if (pressTimer) {
+    window.clearTimeout(pressTimer);
+    pressTimer = null;
+  }
+};
 
 </script>
 
@@ -11,7 +30,7 @@ import Footer from './layouts/BlogFooter.vue';
   <div class="container">
     <div class="aside">
       <div class="avatar-box">
-        <img src="./assets/headset.jpg" alt="avatar" class="avatar" />
+        <img src="./assets/headset.jpg" alt="avatar" class="avatar" @mousedown.prevent="avatarMouseDownHandler" @mouseup.prevent="avatarMouseUpHandler"/>
         <div class="info">
           <span class="blogger">枫叶秋林</span>
           <span class="desc">枫林天天忙,不想写代码</span>
@@ -23,6 +42,7 @@ import Footer from './layouts/BlogFooter.vue';
       <RouterView />
       <Footer />
     </div>
+    <LoginAlert v-model="displayLoginAlert"/>
   </div>
 </template>
 

+ 10 - 1
src/assets/icons.ts

@@ -36,4 +36,13 @@ export const archiveIcon: Icon = {
         <path d="M511.488 995.328a128.654222 128.654222 0 0 1-57.116444-13.112889L70.769778 791.808a126.833778 126.833778 0 0 1-70.769778-113.777778V311.608889a126.179556 126.179556 0 0 1 15.36-60.103111V248.604444c1.479111-2.901333 3.356444-5.603556 5.518222-8.021333a127.630222 127.630222 0 0 1 49.891556-42.325333L454.371556 13.368889a128.739556 128.739556 0 0 1 112.981333 0l383.601778 190.407111a126.862222 126.862222 0 0 1 72.049777 113.379556v360.874666a126.805333 126.805333 0 0 1-70.769777 115.939556L568.604444 984.32c-17.92 7.964444-37.461333 11.747556-57.116444 11.008z m42.638222-470.897778v370.204445l360.192-178.545778c14.449778-7.253333 23.552-21.987556 23.438222-38.087111v-335.928889L554.097778 524.430222zM85.248 330.666667v347.335111a42.268444 42.268444 0 0 0 23.438222 38.087111l360.192 178.545778V523.576889L85.248 330.666667zM135.537778 260.835556l375.950222 189.952 137.671111-65.564445L286.435556 188.074667 135.537778 260.864z m245.105778-118.471112l363.576888 197.973334 150.897778-71.480889-365.283555-180.224a42.922667 42.922667 0 0 0-37.518223 0l-111.672888 53.731555z"/>
     </svg>
     `
-}
+}
+
+export const accountIcon: Icon = {
+    template: `
+    <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
+      <path d="M502.178909 38.632727c-131.072 0-237.521455 104.168727-237.521454 232.727273s106.402909 232.727273 237.521454 232.727273c131.165091 0 237.568-104.168727 237.568-232.727273s-106.309818-232.727273-237.568-232.727273z m0 0c-131.072 0-237.521455 104.168727-237.521454 232.727273s106.402909 232.727273 237.521454 232.727273c131.165091 0 237.568-104.168727 237.568-232.727273s-106.309818-232.727273-237.568-232.727273zM413.184 581.678545c-169.472 0-306.874182 134.609455-306.874182 300.590546v19.316364c0 67.909818 137.402182 67.956364 306.874182 67.956363h197.957818c169.425455 0 306.781091-2.513455 306.781091-67.956363v-19.316364c0-165.981091-137.355636-300.590545-306.781091-300.590546H413.184z m0 0"/>
+    </svg>
+    `
+}
+

+ 61 - 0
src/assets/main.css

@@ -268,3 +268,64 @@ a {
   }
 
 }
+
+.p-input {
+  border: none;
+  font-size: 20px;
+  margin-top: 20px;
+  margin-bottom: 10px;
+  padding-bottom: 8px;
+  background: linear-gradient(to right, var(--text-color), var(--text-color)) no-repeat left bottom;
+  background-size: 10% 2px;
+  color: var(--text-color);
+  transition: all 0.3s;
+
+  font-weight: bold;
+}
+
+.p-input::placeholder {
+  color: var(--muted-text-color);
+  letter-spacing: 0;
+}
+
+.p-input:focus {
+  outline: none;
+  background-size: 100% 2px !important;
+  text-shadow: var(--secondary-text-color) 0 0 v-bind(titleShadowRadius);
+}
+
+.p-input:hover {
+  background-size: 50% 2px;
+  text-shadow: var(--secondary-text-color) 0 0 v-bind(titleShadowRadius);
+}
+
+.p-input[type="password"] {
+  letter-spacing: 5px;
+  font-size: 20px;
+}
+
+.p-input-danger {
+  background: linear-gradient(to right, var(--danger-color), var(--danger-color)) no-repeat left bottom;
+  background-size: 10% 2px;
+}
+
+.p-input-danger::placeholder {
+  color: var(--danger-color);
+  letter-spacing: 0;
+}
+
+.p-button {
+  padding: 8px 12px;
+  cursor: pointer;
+  background: linear-gradient(to right, var(--text-color), var(--text-color)) no-repeat left bottom;
+  color: var(--text-color);
+  background-size: 100% 2px;
+  font-weight: bold;
+  transition: all .3s;
+}
+
+
+.p-button:hover {
+  background-size: 100% 100%;
+  color: var(--background-color);
+}

+ 135 - 0
src/layouts/LoginAlert.vue

@@ -0,0 +1,135 @@
+
+
+<script lang="ts" setup>
+import { defineProps, defineEmits, watch, ref } from 'vue'
+import { accountIcon } from '@/assets/icons.ts'
+import { useTokenStore } from '@/stores/auth.ts'
+
+const props = defineProps<{
+  modelValue: boolean;
+}>();
+
+const emits = defineEmits<{
+  (e: 'update:modelValue', value: boolean): void;
+}>();
+
+const isVisible = ref(false);
+const displayStyle = ref({
+  display: "none"
+});
+
+watch(() => props.modelValue, (newValue, oldValue) => {
+  oldValue = Boolean(oldValue);
+  if (oldValue && !newValue) {
+    isVisible.value = false;
+    setTimeout(() => {
+      displayStyle.value = { display: "none" };
+    }, 300);
+  } else if (!oldValue && newValue) {
+    displayStyle.value = { display: "flex" };
+    setTimeout(() => {
+      isVisible.value = true;
+    }, 20);
+  }
+}, {immediate: true});
+
+const closeDialog = () => {
+  emits('update:modelValue', false);
+};
+
+const psw = ref("");
+
+const tokenStore = useTokenStore();
+
+const placeholder = ref("PASS TOKEN");
+const failed = ref(false);
+
+const handleLogin = () => {
+  tokenStore.pushPassToken(psw.value).then(res => {
+    if (!res) {
+      psw.value = "";
+      placeholder.value = "WRONG TOKEN";
+      failed.value = true;
+    } else {
+      closeDialog();
+    }
+  });
+}
+</script>
+
+<template>
+  <div :class="isVisible ? ['visible'] : []" class="overlay" @mousedown="closeDialog" :style="displayStyle">
+    <div class="dialog" @mousedown.stop>
+      <div class="dialog-header">
+        <div class="close-btn" @click="closeDialog"/>
+      </div>
+      <div class="dialog-content" style="display: flex; flex-direction: column; align-items: center; justify-content: center">
+        <div style="width: 32px; height: 32px; fill: var(--text-color)" v-html="accountIcon.template"/>
+        <input :placeholder="placeholder" v-model="psw" class="p-input" :class="failed ? ['p-input-danger'] : []" type="password">
+      </div>
+      <div class="dialog-footer">
+        <div class="p-button" @click="handleLogin">LOGIN</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+
+.overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  opacity: 0;
+  background-color: rgba(0, 0, 0, 0.5);
+  justify-content: center;
+  align-items: center;
+  z-index: 1000;
+  transition: all .3s;
+}
+
+.visible {
+  opacity: 100%;
+}
+
+.dialog {
+  background-color: var(--secondary-background-color);
+  border-radius: 12px;
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+}
+
+.dialog-header {
+  padding: 10px;
+  display: flex;
+  justify-content: end;
+}
+
+.close-btn {
+  width: 12px;
+  height: 12px;
+  border-radius: 6px;
+  background: var(--danger-color);
+  cursor: pointer;
+  transition: box-shadow .3s;
+}
+
+.close-btn:hover {
+  box-shadow: var(--danger-color) 0 0 12px;
+}
+
+.dialog-content {
+  padding: 10px;
+  width: 480px;
+  height: 100px;
+}
+
+.dialog-footer {
+  width: calc(100% - 48px);
+  padding: 12px 24px;
+  display: flex;
+  justify-content: end;
+}
+
+</style>

+ 13 - 4
src/main.ts

@@ -17,13 +17,22 @@ import './assets/vuepressdark.css'
 
 import Prism from 'prismjs'
 
+import piniaPluginPersistence from "pinia-plugin-persistedstate"
+import { useTokenStore } from '@/stores/auth.ts'
+
 VMdEditor.use(githubTheme, { Prism })
 
-const app = createApp(App)
+const app = createApp(App);
+app.use(router);
+
+app.use(VMdEditor);
+
+const pinia = createPinia();
+
+pinia.use(piniaPluginPersistence);
 
-app.use(VMdEditor)
+app.use(pinia);
 
-app.use(createPinia())
-app.use(router)
+useTokenStore().checkToken()
 
 app.mount('#app')

+ 45 - 0
src/stores/auth.ts

@@ -0,0 +1,45 @@
+import { defineStore } from 'pinia'
+import { api } from '@/utils/axios.ts'
+
+export const useTokenStore = defineStore(
+  'token',
+  {
+    state: () => ({
+      token: "",
+      available: false
+    }),
+    actions: {
+      async pushPassToken(psw: string) {
+        const result = await api.auth(psw);
+        if (result.code == 200) {
+          this.token = result.token;
+          this.available = true;
+          return true;
+        } else if (result.code == 401) {
+          this.available = false;
+          this.token = '';
+          return false
+        }
+        return false;
+      },
+      async checkToken() {
+        console.log(this.token)
+        if (this.token) {
+          const result = await api.check(this.token);
+          if (result.code == 200) {
+            this.available = true;
+            return true
+          } else if (result.code == 401) {
+            this.available = false;
+            this.token = '';
+            return false
+          }
+        }
+        return false;
+      }
+    },
+    persist: {
+      pick: ["token"]
+    }
+  }
+);

+ 0 - 12
src/stores/counter.ts

@@ -1,12 +0,0 @@
-import { ref, computed } from 'vue'
-import { defineStore } from 'pinia'
-
-export const useCounterStore = defineStore('counter', () => {
-  const count = ref(0)
-  const doubleCount = computed(() => count.value * 2)
-  function increment() {
-    count.value++
-  }
-
-  return { count, doubleCount, increment }
-})

+ 21 - 38
src/utils/axios.ts

@@ -10,7 +10,6 @@ import type {
   UpdateSuccess,
 } from '@/models'
 import axios from 'axios'
-import { router } from '@/router'
 
 let token = ''
 
@@ -19,52 +18,36 @@ const baseServer = axios.create({
   headers: {
     Authorization: `Bearer ${token}`,
   },
-})
-//请求拦截器
+});
+
 baseServer.interceptors.request.use((config) => {
+  if (token)
+    config.headers["Authorization"] = `Bearer ${token}`;
   return config;
 });
-//响应拦截器
-baseServer.interceptors.response.use(
-  (response) => {
-    return response;
-  },
-  (error) => {
-    const { status, data, statusCode } = error.response;
-    switch (status) {
-      case 400:
-        //判断data.message 为数组
-        if (Array.isArray(data.message)) {
-          return;
-        }
-        console.log(data.message);
-        break;
-      case 401:
-        console.log("token失效,请重新登录");
-        window.localStorage.removeItem("token");
-        router.push("/auth?type=login");
-        break;
-      case 403:
-        console.log("没有权限,请联系管理员");
-        break;
-      case 404:
-        console.log("请求资源不存在");
-        break;
-      default:
-        console.log(data.message);
-    }
-    if (statusCode === 400) {
 
-    }
-    return Promise.reject(error);
-  }
-);
+baseServer.interceptors.response.use((response) => {
+  if (response?.data?.code == 401) token = '';
+  return response;
+});
 
 export const api = {
   auth(password: string) {
     return (baseServer.post('/auth/', { password }).then(res => res.data) as Promise<AuthSuccess | AuthFailed>).then(
       (data) => {
-        if (data.code == 200) token = data.token
+        if (data.code == 200) token = data.token;
+        return data
+      },
+    )
+  },
+  check(_token: string) {
+    return (baseServer.post('/auth/check/', null, {
+      headers: {
+        Authorization: `Bearer ${_token}`
+      }
+    }).then(res => res.data) as Promise<AuthSuccess | AuthFailed>).then(
+      (data) => {
+        if (data.code == 200) token = _token;
         return data
       },
     )

+ 1 - 28
src/views/PostEditView.vue

@@ -1,5 +1,5 @@
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { ref } from 'vue'
 import BetterSelect from '@/components/BetterSelect.vue'
 
 const editorConfig = {
@@ -20,8 +20,6 @@ print('Hello, World!')
 
 `)
 
-const titleShadowRadius = computed(() => (title.value.trim().length > 0 ? '8px' : ''))
-
 const selected = ref('')
 
 const options = [
@@ -55,31 +53,6 @@ const options = [
 </template>
 
 <style scoped>
-.p-input {
-  width: 100%;
-  border: none;
-  font-size: 20px;
-  margin-top: 20px;
-  margin-bottom: 10px;
-  padding-bottom: 8px;
-  background: linear-gradient(to right, var(--text-color), var(--text-color)) no-repeat left bottom;
-  background-size: 0 2px;
-  color: var(--text-color);
-  transition: all 0.3s;
-
-  font-weight: bold;
-}
-
-.p-input::placeholder {
-  color: var(--muted-text-color);
-}
-
-
-.p-input:focus {
-  outline: none;
-  background-size: 100% 2px;
-  text-shadow: var(--secondary-text-color) 0 0 v-bind(titleShadowRadius);
-}
 
 .p-select {
   width: 64px;