This commit is contained in:
Zyronon
2025-11-10 16:57:28 +00:00
parent 4f2ba22447
commit 882d80f6d4
12 changed files with 1538 additions and 42 deletions

95
src/apis/auth.ts Normal file
View File

@@ -0,0 +1,95 @@
import http from '@/utils/http.ts'
// 用户登录接口
export interface LoginParams {
email?: string
phone?: string
password?: string
code?: string
type: 'email' | 'phone' | 'wechat'
}
export interface LoginResponse {
token: string
user: {
id: string
email?: string
phone?: string
nickname?: string
avatar?: string
}
}
// 用户注册接口
export interface RegisterParams {
email?: string
phone: string
password: string
code: string
nickname?: string
}
export interface RegisterResponse {
token: string
user: {
id: string
email?: string
phone: string
nickname?: string
avatar?: string
}
}
// 发送验证码接口
export interface SendCodeParams {
email?: string
phone: string
type: 'login' | 'register' | 'reset_password'
}
// 重置密码接口
export interface ResetPasswordParams {
email?: string
phone: string
code: string
newPassword: string
}
// 微信登录接口
export interface WechatLoginParams {
code: string
state?: string
}
// API 函数定义
export function login(params: LoginParams) {
return http<LoginResponse>('auth/login', params, null, 'post')
}
export function register(params: RegisterParams) {
return http<RegisterResponse>('auth/register', params, null, 'post')
}
export function sendCode(params: SendCodeParams) {
return http<boolean>('auth/sendCode', params, null, 'post')
}
export function resetPassword(params: ResetPasswordParams) {
return http<boolean>('auth/resetPassword', params, null, 'post')
}
export function wechatLogin(params: WechatLoginParams) {
return http<LoginResponse>('auth/wechatLogin', params, null, 'post')
}
export function logout() {
return http<boolean>('auth/logout', null, null, 'post')
}
export function refreshToken() {
return http<{ token: string }>('auth/refreshToken', null, null, 'post')
}
// 获取用户信息
export function getUserInfo() {
return http<LoginResponse['user']>('auth/userInfo', null, null, 'get')
}

View File

@@ -59,3 +59,6 @@ export function uploadImportData(data,onUploadProgress) {
onUploadProgress
})
}
// 导出认证相关API
export * from './auth'

View File

@@ -219,6 +219,9 @@ a {
text-decoration: none;
}
.cp{
@apply cursor-pointer;
}
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {

View File

@@ -21,6 +21,11 @@ const props = defineProps({
default: false,
},
maxLength: Number,
size: {
type: String,
default: 'normal',
validator: (value: string) => ['normal', 'large'].includes(value)
},
});
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'validation']);
@@ -96,7 +101,7 @@ const vFocus = {
<template>
<div class="base-input2"
ref="inputEl"
:class="{ 'is-disabled': disabled, 'has-error': errorMsg,focus }">
:class="{ 'is-disabled': disabled, 'has-error': errorMsg,focus, [`base-input2--${size}`]: true }">
<slot name="subfix"></slot>
<input
v-bind="attrs"
@@ -134,6 +139,26 @@ const vFocus = {
align-items: center;
background: var(--color-input-bg);
// normal size (default)
&--normal {
padding: .2rem .3rem;
.inner {
height: 1.5rem;
font-size: 1rem;
}
}
// large size
&--large {
padding: .6rem .8rem;
.inner {
height: 2rem;
font-size: 1.125rem;
}
}
&.is-disabled {
opacity: 0.6;
}

52
src/config/auth.ts Normal file
View File

@@ -0,0 +1,52 @@
// 微信登录配置
export const WECHAT_CONFIG = {
// 微信开放平台AppID需要在微信开放平台申请
appId: 'your_wechat_app_id',
// 微信授权回调地址
redirectUri: `${window.location.origin}/wechat/callback`,
// 授权作用域
scope: 'snsapi_userinfo',
// 授权状态参数
state: 'wechat_login'
}
// 获取微信授权URL
export function getWechatAuthUrl(state?: string): string {
const {appId, redirectUri, scope} = WECHAT_CONFIG
const authState = state || Math.random().toString(36).substr(2, 15)
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scope}&state=${authState}#wechat_redirect`
}
// 手机号验证配置
export const PHONE_CONFIG = {
// 验证码长度
codeLength: 6,
// 验证码发送间隔(秒)
sendInterval: 60,
// 手机号正则表达式(中国大陆)
phoneRegex: /^1[3-9]\d{9}$/
}
// 邮箱配置
export const EMAIL_CONFIG = {
// 邮箱正则表达式
emailRegex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
// 邮箱验证码长度
codeLength: 6
}
// 密码配置
export const PASSWORD_CONFIG = {
// 密码最小长度
minLength: 6,
// 密码最大长度
maxLength: 20
}

View File

@@ -8,6 +8,7 @@ import VueVirtualScroller from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import './types/global.d.ts'
import loadingDirective from './directives/loading.tsx'
import { useAuthStore } from './stores/auth.ts'
const pinia = createPinia()
@@ -22,7 +23,11 @@ app.directive('opacity', (el, binding) => {
})
app.directive('loading', loadingDirective)
app.mount('#app')
// 初始化认证状态
const authStore = useAuthStore()
authStore.initAuth().then(() => {
app.mount('#app')
})
// 注册Service Worker(pwa支持)
if ('serviceWorker' in navigator) {

View File

@@ -31,6 +31,7 @@ import Textarea from "@/components/base/Textarea.vue";
import SettingItem from "@/pages/setting/SettingItem.vue";
import { get, set } from "idb-keyval";
import { useRuntimeStore } from "@/stores/runtime.ts";
import { useAuthStore } from "@/stores/auth.ts";
const emit = defineEmits<{
toggleDisabledDialogEscKey: [val: boolean]
@@ -40,6 +41,7 @@ const tabIndex = $ref(0)
const settingStore = useSettingStore()
const runtimeStore = useRuntimeStore()
const store = useBaseStore()
const authStore = useAuthStore()
//@ts-ignore
const gitLastCommitHash = ref(LATEST_COMMIT_HASH);
@@ -773,6 +775,29 @@ function importOldData() {
<div v-if="tabIndex === 6" class="center flex-col">
<h1>Type Words</h1>
<!-- 用户信息部分 -->
<div v-if="authStore.isLoggedIn && authStore.user" class="user-info-section mb-6">
<div class="user-avatar mb-4">
<img v-if="authStore.user.avatar" :src="authStore.user.avatar" alt="头像" class="avatar-img" />
<div v-else class="avatar-placeholder">
{{ authStore.user.nickname?.charAt(0) || 'U' }}
</div>
</div>
<h3 class="mb-2">{{ authStore.user.nickname || '用户' }}</h3>
<p v-if="authStore.user.email" class="text-sm color-gray mb-1">{{ authStore.user.email }}</p>
<p v-if="authStore.user.phone" class="text-sm color-gray">{{ authStore.user.phone }}</p>
<BaseButton
@click="authStore.logout"
type="info"
class="mt-4"
:loading="authStore.isLoading"
>
退出登录
</BaseButton>
</div>
<p class="w-100 text-xl">
感谢使用本项目本项目是开源项目如果觉得有帮助请在 GitHub 点个 Star您的支持是我持续改进的动力
</p>
@@ -803,6 +828,75 @@ function importOldData() {
margin-bottom: 1rem;
}
// 用户信息样式
.user-info-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
border: 1px solid var(--color-input-border);
border-radius: 8px;
background: var(--color-bg);
width: 100%;
max-width: 400px;
.user-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
border: 3px solid var(--color-select-bg);
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
font-weight: bold;
}
}
h3 {
margin: 0;
color: var(--color-font-1);
}
.text-sm {
font-size: 0.9rem;
margin: 0.25rem 0;
}
.color-gray {
color: #666;
}
.mb-1 {
margin-bottom: 0.25rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-4 {
margin-bottom: 1rem;
}
.mt-4 {
margin-top: 1rem;
}
}
.setting {
@apply text-lg;
display: flex;

View File

@@ -1,29 +1,139 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useAuthStore } from "@/stores/auth.ts";
import { useRouter } from "vue-router";
import BaseButton from "@/components/BaseButton.vue";
import Toast from "@/components/base/toast/Toast.ts";
import { onMounted } from "vue";
import { IS_LOGIN } from "@/config/env.ts";
import router from "@/router.ts";
const authStore = useAuthStore()
const router = useRouter()
// 页面状态
const isLoading = ref(false)
// 退出登录
const handleLogout = async () => {
await authStore.logout()
}
// 跳转到设置页面
const goToSettings = () => {
router.push('/setting')
}
onMounted(() => {
if (!IS_LOGIN) {
// 如果用户未登录,跳转到登录页
if (!authStore.isLoggedIn) {
router.push({path: "/login"});
return
}
// 获取用户信息
if (!authStore.user) {
authStore.fetchUserInfo()
}
router.push({path: "/login"});
})
</script>
<template>
<div class="flex flex-col justify-between min-h-screen">
<div class="center flex-col gap-8">
onMounted(() => {
if (!IS_LOGIN) {
router.push({path: "/login"});
}
})
<div class="user-center">
<div class="user-header">
<div class="avatar">
<img v-if="authStore.user?.avatar" :src="authStore.user.avatar" alt="头像" />
<div v-else class="avatar-placeholder">
{{ authStore.user?.nickname?.charAt(0) || 'U' }}
</div>
</div>
<div class="user-info">
<h2>{{ authStore.user?.nickname || '用户' }}</h2>
<p v-if="authStore.user?.email">{{ authStore.user.email }}</p>
<p v-if="authStore.user?.phone">{{ authStore.user.phone }}</p>
</div>
</div>
<div class="user-actions">
<BaseButton @click="goToSettings" class="w-full mb-4" size="large">
系统设置
</BaseButton>
<BaseButton
@click="handleLogout"
type="info"
class="w-full"
size="large"
:loading="isLoading"
>
退出登录
</BaseButton>
</div>
</div>
</template>
<style scoped lang="scss">
.user-center {
max-width: 600px;
margin: 0 auto;
padding: 2rem;
}
.user-header {
display: flex;
align-items: center;
gap: 1.5rem;
padding: 2rem 0;
border-bottom: 1px solid #eee;
margin-bottom: 2rem;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
border: 3px solid var(--color-select-bg);
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
font-weight: bold;
}
.user-info {
flex: 1;
h2 {
margin: 0 0 0.5rem 0;
color: #333;
font-size: 1.5rem;
}
p {
margin: 0.25rem 0;
color: #666;
font-size: 0.9rem;
}
}
.user-actions {
display: flex;
flex-direction: column;
gap: 1rem;
.mb-4 {
margin-bottom: 1rem;
}
}
</style>

View File

@@ -1,11 +1,271 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import BaseInput from "@/components/base/BaseInput.vue";
import BaseButton from "@/components/BaseButton.vue";
import { APP_NAME } from "@/config/env.ts";
import { uploadImportData } from "@/apis";
import {APP_NAME} from "@/config/env.ts";
import {useAuthStore} from "@/stores/auth.ts";
import {sendCode, register as registerApi, resetPassword, uploadImportData} from "@/apis";
import {validateLoginForm, validateRegisterForm, validateResetPasswordForm} from "@/utils/validation.ts";
import Toast from "@/components/base/toast/Toast.ts";
import { getWechatAuthUrl, PHONE_CONFIG } from "@/config/auth.ts";
function sync() {
// 状态管理
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
// 页面状态
let currentMode = $ref<'login' | 'register' | 'forgot'>('login')
let loginType = $ref<'code' | 'password'>('code') // 默认验证码登录
let isLoading = $ref(false)
let isSendingCode = $ref(false)
let codeCountdown = $ref(0)
let showWechatQR = $ref(false)
let wechatQRUrl = $ref('')
// 表单数据
const loginForm = $ref({
account: '', // 支持邮箱或手机号
password: ''
})
const phoneLoginForm = $ref({
phone: '',
code: ''
})
const registerForm = $ref({
phone: '',
password: '',
confirmPassword: '',
code: ''
})
const forgotForm = $ref({
phone: '',
email: '',
code: '',
newPassword: '',
confirmPassword: ''
})
// 错误信息
const loginErrors = ref<Record<string, string>>({})
const registerErrors = ref<Record<string, string>>({})
const forgotErrors = ref<Record<string, string>>({})
// 发送验证码
async function sendVerificationCode(phone: string, type: 'login' | 'register' | 'reset_password') {
if (!phone) {
Toast.error('请输入手机号')
return
}
if (!PHONE_CONFIG.phoneRegex.test(phone)) {
Toast.error('请输入有效的手机号')
return
}
try {
isSendingCode = true
const response = await sendCode({ phone, type })
if (response.success) {
Toast.success('验证码已发送')
codeCountdown = PHONE_CONFIG.sendInterval
const timer = setInterval(() => {
codeCountdown--
if (codeCountdown <= 0) {
clearInterval(timer)
}
}, 1000)
} else {
Toast.error(response.msg || '发送失败')
}
} catch (error) {
console.error('Send code error:', error)
Toast.error('发送验证码失败')
} finally {
isSendingCode = false
}
}
// 账号密码登录
async function handleLogin() {
// 判断是邮箱还是手机号
const isEmail = loginForm.account.includes('@')
const validation = validateLoginForm({
email: isEmail ? loginForm.account : undefined,
phone: !isEmail ? loginForm.account : undefined,
password: loginForm.password,
code: undefined,
type: 'email'
})
if (!validation.isValid) {
Object.assign(loginErrors, validation.errors)
return
}
isLoading = true
try {
await authStore.login({
email: isEmail ? loginForm.account : undefined,
phone: !isEmail ? loginForm.account : undefined,
password: loginForm.password,
type: 'email'
})
} finally {
isLoading = false
}
}
// 手机号验证码登录
async function handlePhoneCodeLogin() {
const validation = validateLoginForm({
phone: phoneLoginForm.phone,
code: phoneLoginForm.code,
password: undefined,
type: 'phone'
})
if (!validation.isValid) {
Object.assign(loginErrors, validation.errors)
return
}
isLoading = true
try {
await authStore.login({
phone: phoneLoginForm.phone,
code: phoneLoginForm.code,
type: 'phone'
})
} finally {
isLoading = false
}
}
// 注册
async function handleRegister() {
if (registerForm.password !== registerForm.confirmPassword) {
Toast.error('两次密码输入不一致')
return
}
const validation = validateRegisterForm({
phone: registerForm.phone,
password: registerForm.password,
code: registerForm.code,
nickname: undefined,
email: undefined
})
if (!validation.isValid) {
Object.assign(registerErrors, validation.errors)
return
}
isLoading = true
try {
await authStore.register({
phone: registerForm.phone,
password: registerForm.password,
code: registerForm.code,
nickname: undefined,
email: undefined
})
} finally {
isLoading = false
}
}
// 忘记密码
async function handleForgotPassword() {
if (forgotForm.newPassword !== forgotForm.confirmPassword) {
Toast.error('两次密码输入不一致')
return
}
const validation = validateResetPasswordForm({
phone: forgotForm.phone,
email: forgotForm.email,
code: forgotForm.code,
newPassword: forgotForm.newPassword
})
if (!validation.isValid) {
Object.assign(forgotErrors, validation.errors)
return
}
isLoading = true
try {
const response = await authStore.resetPassword({
phone: forgotForm.phone,
email: forgotForm.email,
code: forgotForm.code,
newPassword: forgotForm.newPassword
})
if (response.success) {
Toast.success('密码重置成功,请重新登录')
switchMode('login')
} else {
Toast.error(response.msg || '重置失败')
}
} finally {
isLoading = false
}
}
// 微信登录 - 显示二维码
async function handleWechatLogin() {
try {
showWechatQR = true
// 这里应该调用后端获取二维码
// const response = await getWechatQR()
// wechatQRUrl = response.qrUrl
// 暂时使用占位二维码
wechatQRUrl = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2Y1ZjVmNSIvPgogIDx0ZXh0IHg9IjUwJSIgeT0iNTAlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OTk5OSI+55So5o6l566h55CG6L295Lit6K+B77yBPC90ZXh0Pgo8L3N2Zz4K'
// 模拟轮询检查扫码状态
const checkInterval = setInterval(async () => {
// 这里应该轮询后端检查扫码状态
// const result = await checkWechatLoginStatus()
// if (result.success) {
// clearInterval(checkInterval)
// showWechatQR = false
// // 登录成功处理
// }
}, 2000)
// 5分钟后自动关闭二维码
setTimeout(() => {
clearInterval(checkInterval)
showWechatQR = false
Toast.info('二维码已过期,请重新获取')
}, 300000)
} catch (error) {
console.error('Wechat login error:', error)
Toast.error('微信登录失败')
}
}
// 切换模式
function switchMode(mode: 'login' | 'register' | 'forgot') {
currentMode = mode
clearErrors()
}
// 清除错误
function clearErrors() {
loginErrors.value = {}
registerErrors.value = {}
forgotErrors.value = {}
}
async function handleAudioChange(e) {
@@ -30,37 +290,530 @@ async function s() {
if (res.progress >= 100) clearInterval(timer);
}, 1000);
}
// 初始化页面
onMounted(() => {
// 检查是否有重定向地址
const redirect = route.query.redirect as string
if (redirect) {
// 如果有重定向地址,可以显示提示信息
Toast.info('请先登录后再访问该页面')
}
})
</script>
<template>
<div class="center h-screen">
<div class=" flex flex-col gap-6 w-100">
<div class="login-container">
<!-- 左侧登录区域 -->
<div class="login-section">
<h1 class="mb-0 text-align-center">{{ APP_NAME }}</h1>
<div class="flex center">
<span class="shrink-0">账户</span>
<BaseInput type="text"/>
</div>
<div class="flex center">
<span class="shrink-0">密码</span>
<BaseInput type="password"/>
</div>
<BaseButton class="w-full">登录</BaseButton>
<BaseButton class="w-full" @click="sync">同步</BaseButton>
<div class="upload relative">
<BaseButton>上传</BaseButton>
<input type="file"
accept=".zip,.json"
@change="handleAudioChange"
class="w-full h-full absolute left-0 top-0 opacity-0"/>
<span class="agreement-text">继续操作即表示你同意我们的 <a href="" class="color-link">用户协议</a> 并确认已了解 <a href="" class="color-link">隐私政策</a></span>
<!-- 登录选项 -->
<div v-if="currentMode === 'login' && !showWechatQR" class="login-options">
<!-- Tab切换 -->
<div class="login-tabs">
<div
class="tab-item"
:class="{ active: loginType === 'code' }"
@click="loginType = 'code'"
>
验证码登录
</div>
<div
class="tab-item"
:class="{ active: loginType === 'password' }"
@click="loginType = 'password'"
>
密码登录
</div>
</div>
<!-- 验证码登录表单 -->
<template v-if="loginType === 'code'">
<div class="form-group">
<BaseInput
v-model="phoneLoginForm.phone"
type="tel"
size="large"
placeholder="请输入手机号"
:class="{ 'has-error': loginErrors.phone }"
/>
<div v-if="loginErrors.phone" class="error-text">{{ loginErrors.phone }}</div>
</div>
<div class="form-group flex gap-2">
<div class="flex-1">
<BaseInput
v-model="phoneLoginForm.code"
type="text"
size="large"
placeholder="请输入验证码"
:class="{ 'has-error': loginErrors.code }"
/>
<div v-if="loginErrors.code" class="error-text">{{ loginErrors.code }}</div>
</div>
<BaseButton
@click="sendVerificationCode(phoneLoginForm.phone, 'login')"
:disabled="isSendingCode || codeCountdown > 0"
type="info"
size="large"
class="send-code-btn"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : (isSendingCode ? '发送中' : '获取验证码') }}
</BaseButton>
</div>
<BaseButton
class="w-full"
size="large"
:loading="isLoading"
@click="handlePhoneCodeLogin"
>
登录
</BaseButton>
</template>
<!-- 密码登录表单 -->
<template v-else>
<div class="form-group">
<BaseInput
v-model="loginForm.account"
type="text"
size="large"
placeholder="请输入邮箱或手机号"
:class="{ 'has-error': loginErrors.email || loginErrors.phone }"
/>
<div v-if="loginErrors.email" class="error-text">{{ loginErrors.email }}</div>
<div v-if="loginErrors.phone" class="error-text">{{ loginErrors.phone }}</div>
</div>
<div class="form-group">
<BaseInput
v-model="loginForm.password"
type="password"
size="large"
placeholder="请输入密码"
:class="{ 'has-error': loginErrors.password }"
/>
<div v-if="loginErrors.password" class="error-text">{{ loginErrors.password }}</div>
</div>
<BaseButton
class="w-full"
size="large"
:loading="isLoading"
@click="handleLogin"
>
登录
</BaseButton>
</template>
<!-- 底部操作链接 -->
<div class="bottom-actions">
<div class="color-link cp" @click="currentMode = 'register'">注册账号</div>
<div class="color-link cp" @click="currentMode = 'forgot'">忘记密码?</div>
</div>
</div>
<div class="w-full flex justify-end gap-4">
<div>注册</div>
<div>忘记密码</div>
<!-- 注册模式 -->
<template v-else-if="currentMode === 'register'" class="register-form">
<h2 class="form-title">注册新账号</h2>
<div class="form-group">
<BaseInput
v-model="registerForm.phone"
type="tel"
size="large"
placeholder="请输入手机号"
:class="{ 'has-error': registerErrors.phone }"
/>
<div v-if="registerErrors.phone" class="error-text">{{ registerErrors.phone }}</div>
</div>
<div class="form-group flex gap-2">
<div class="flex-1">
<BaseInput
v-model="registerForm.code"
type="text"
size="large"
placeholder="请输入验证码"
:class="{ 'has-error': registerErrors.code }"
/>
<div v-if="registerErrors.code" class="error-text">{{ registerErrors.code }}</div>
</div>
<BaseButton
@click="sendVerificationCode(registerForm.phone, 'register')"
:disabled="isSendingCode || codeCountdown > 0"
type="info"
size="large"
class="send-code-btn"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : (isSendingCode ? '发送中' : '获取验证码') }}
</BaseButton>
</div>
<div class="form-group">
<BaseInput
v-model="registerForm.password"
type="password"
size="large"
placeholder="请设置密码6-20位"
:class="{ 'has-error': registerErrors.password }"
/>
<div v-if="registerErrors.password" class="error-text">{{ registerErrors.password }}</div>
</div>
<div class="form-group">
<BaseInput
v-model="registerForm.confirmPassword"
type="password"
size="large"
placeholder="请再次输入密码"
/>
</div>
<div class="form-actions">
<BaseButton
class="w-full"
size="large"
:loading="isLoading"
@click="handleRegister"
>
注册
</BaseButton>
<div class="back-link">
<div class="color-link cp" @click="switchMode('login')">返回登录</div>
</div>
</div>
</template>
<!-- 忘记密码模式 -->
<template v-else-if="currentMode === 'forgot'" class="forgot-form">
<h2 class="form-title">重置密码</h2>
<div class="form-group">
<BaseInput
v-model="forgotForm.phone"
type="tel"
size="large"
placeholder="请输入手机号"
:class="{ 'has-error': forgotErrors.phone }"
/>
<div v-if="forgotErrors.phone" class="error-text">{{ forgotErrors.phone }}</div>
</div>
<div class="form-group flex gap-2">
<div class="flex-1">
<BaseInput
v-model="forgotForm.code"
type="text"
size="large"
placeholder="请输入验证码"
:class="{ 'has-error': forgotErrors.code }"
/>
<div v-if="forgotErrors.code" class="error-text">{{ forgotErrors.code }}</div>
</div>
<BaseButton
@click="sendVerificationCode(forgotForm.phone, 'reset_password')"
:disabled="isSendingCode || codeCountdown > 0"
type="info"
size="large"
class="send-code-btn"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : (isSendingCode ? '发送中' : '获取验证码') }}
</BaseButton>
</div>
<div class="form-group">
<BaseInput
v-model="forgotForm.newPassword"
type="password"
size="large"
placeholder="请输入新密码6-20位"
:class="{ 'has-error': forgotErrors.newPassword }"
/>
<div v-if="forgotErrors.newPassword" class="error-text">{{ forgotErrors.newPassword }}</div>
</div>
<div class="form-group">
<BaseInput
v-model="forgotForm.confirmPassword"
type="password"
size="large"
placeholder="请再次输入新密码"
/>
</div>
<div class="form-group">
<BaseInput
v-model="forgotForm.email"
type="email"
size="large"
placeholder="请输入邮箱地址(选填)"
:class="{ 'has-error': forgotErrors.email }"
/>
<div v-if="forgotErrors.email" class="error-text">{{ forgotErrors.email }}</div>
</div>
<div class="form-actions">
<BaseButton
class="w-full"
size="large"
:loading="isLoading"
@click="handleForgotPassword"
>
重置密码
</BaseButton>
<div class="back-link">
<div class="color-link cp" @click="switchMode('login')">返回登录</div>
</div>
</div>
</template>
</div>
<!-- 右侧微信二维码 -->
<div class="qr-section">
<div class="qr-container">
<div class="qr-header">
<h3>微信扫码登录</h3>
<div v-if="showWechatQR" class="close-btn" @click="showWechatQR = false">
<IconFluentDismiss12Regular />
</div>
</div>
<div class="qr-content">
<img
v-if="showWechatQR"
:src="wechatQRUrl"
alt="微信登录二维码"
class="qr-image active"
/>
<img
v-else
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2Y1ZjVmNSIvPgogIDx0ZXh0IHg9IjUwJSIgeT0iNTAlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OTk5OSI+55So5o6l566h55CG6L295Lit6K+B77yBPC90ZXh0Pgo8L3N2Zz4K"
alt="微信登录二维码"
class="qr-image"
/>
<p class="qr-tip" v-if="!showWechatQR">请使用微信扫描二维码登录</p>
<p class="qr-tip active" v-else>正在扫码请稍候...</p>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.item {
border-radius: 2rem;
border: 1px solid #ccc;
padding: 0.6rem 1rem;
background: white;
display: flex;
align-items: center;
position: relative;
cursor: pointer;
transition: all 0.3s;
svg {
font-size: 1.6rem;
}
div {
font-size: 1rem;
width: 100%;
position: absolute;
left: 0;
text-align: center;
}
&:hover {
border-color: var(--color-select-bg);
background: #f9f9f9;
}
}
.line-wrap {
display: flex;
align-items: center;
gap: 1rem;
color: #999;
font-size: 0.9rem;
.line {
flex: 1;
height: 1px;
background: #cfcfcf;
border: none;
}
}
// 表单组样式
.form-group {
margin-bottom: 1rem;
position: relative;
&.flex {
display: flex;
align-items: flex-end;
gap: 0.5rem;
}
}
// Tab切换样式
.login-tabs {
display: flex;
margin-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.tab-item {
flex: 1;
padding: 1rem 1rem;
text-align: center;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.3s;
color: #666;
font-size: 0.95rem;
&:hover {
color: var(--color-select-bg);
}
&.active {
color: var(--color-select-bg);
border-bottom: 2px solid var(--color-select-bg);
font-weight: 500;
}
}
.tab-item {
flex: 1;
padding: 0.8rem 1rem;
text-align: center;
cursor: pointer;
border-bottom: 3px solid transparent;
transition: all 0.3s;
color: #666;
font-size: 0.95rem;
&:hover {
color: var(--color-select-bg);
}
&.active {
color: var(--color-select-bg);
border-bottom: 3px solid var(--color-select-bg);
font-weight: 500;
}
}
// 验证码按钮样式
.send-code-btn {
min-width: 100px;
white-space: nowrap;
flex-shrink: 0;
}
// 错误文本样式
.error-text {
color: #f56c6c;
font-size: 0.85rem;
margin-top: 0.25rem;
padding-left: 0.5rem;
line-height: 1.2;
}
// 链接样式
.color-link {
color: var(--color-select-bg);
text-decoration: none;
transition: opacity 0.3s;
&:hover {
opacity: 0.8;
}
}
.cp {
cursor: pointer;
}
// Input错误状态覆盖
:deep(.base-input2.has-error) {
border-color: #f56c6c;
}
// 微信二维码样式
.wechat-qr-container {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 1.5rem;
background: white;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
.qr-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
h3 {
margin: 0;
font-size: 1.1rem;
color: #333;
}
.close-btn {
cursor: pointer;
color: #999;
transition: color 0.3s;
&:hover {
color: #666;
}
}
}
.qr-content {
display: flex;
flex-direction: column;
align-items: center;
.qr-image {
width: 200px;
height: 200px;
border: 1px solid #e4e7ed;
border-radius: 4px;
}
.qr-tip {
margin-top: 1rem;
color: #666;
font-size: 0.9rem;
margin: 0;
}
}
}
// 登录容器布局 - 居中显示
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 2rem;
gap: 3rem;
}
.login-section {
flex: 1;
max-width: 500px;
}
.qr-section {
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -12,6 +12,8 @@ import BookList from "@/pages/article/BookList.vue";
import Setting from "@/pages/setting/Setting.vue";
import Login from "@/pages/user/login.vue";
import User from "@/pages/user/index.vue";
import WechatCallback from "@/pages/user/wechat-callback.vue";
import { useAuthStore } from "@/stores/auth.ts";
export const routes: RouteRecordRaw[] = [
{
@@ -39,7 +41,7 @@ export const routes: RouteRecordRaw[] = [
},
{path: '/batch-edit-article', component: () => import("@/pages/article/BatchEditArticlePage.vue")},
{path: '/test', component: () => import("@/pages/test/test.vue")},
{path: '/:pathMatch(.*)*', redirect: '/word'},
{path: '/:pathMatch(.*)*', redirect: '/words'},
]
const router = VueRouter.createRouter({
@@ -56,7 +58,27 @@ const router = VueRouter.createRouter({
},
})
router.beforeEach((to: any, from: any) => {
// 路由守卫
router.beforeEach(async (to: any, from: any) => {
const authStore = useAuthStore()
// 公共路由,不需要登录验证
const publicRoutes = ['/login', '/wechat/callback']
// 如果目标路由是公共路由,直接放行
if (publicRoutes.includes(to.path)) {
return true
}
// 如果用户未登录,跳转到登录页
if (!authStore.isLoggedIn) {
// 尝试初始化认证状态
const isInitialized = await authStore.initAuth()
if (!isInitialized) {
return { path: '/login', query: { redirect: to.fullPath } }
}
}
return true
// console.log('beforeEach-to',to.path)
// console.log('beforeEach-from',from.path)

168
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,168 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login as loginApi, register as registerApi, logout as logoutApi, getUserInfo, resetPassword as resetPasswordApi, type LoginParams, type RegisterParams } from '@/apis/auth'
import Toast from '@/components/base/toast/Toast.ts'
import router from '@/router.ts'
export interface User {
id: string
email?: string
phone?: string
nickname?: string
avatar?: string
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(localStorage.getItem('token') || '')
const user = ref<User | null>(null)
const isLoading = ref(false)
const isLoggedIn = computed(() => !!token.value)
// 设置token
const setToken = (newToken: string) => {
token.value = newToken
localStorage.setItem('token', newToken)
}
// 清除token
const clearToken = () => {
token.value = ''
localStorage.removeItem('token')
user.value = null
}
// 设置用户信息
const setUser = (userInfo: User) => {
user.value = userInfo
}
// 登录
const login = async (params: LoginParams) => {
try {
isLoading.value = true
const response = await loginApi(params)
if (response.success && response.data) {
setToken(response.data.token)
setUser(response.data.user)
Toast.success('登录成功')
// 跳转到首页或用户中心
router.push('/')
return true
} else {
Toast.error(response.msg || '登录失败')
return false
}
} catch (error) {
console.error('Login error:', error)
Toast.error('登录失败,请重试')
return false
} finally {
isLoading.value = false
}
}
// 登出
const logout = async () => {
try {
await logoutApi()
} catch (error) {
console.error('Logout error:', error)
} finally {
clearToken()
Toast.success('已退出登录')
router.push('/login')
}
}
// 获取用户信息
const fetchUserInfo = async () => {
try {
const response = await getUserInfo()
if (response.success && response.data) {
setUser(response.data)
return true
}
return false
} catch (error) {
console.error('Get user info error:', error)
return false
}
}
// 注册
const register = async (params: RegisterParams) => {
try {
isLoading.value = true
const response = await registerApi(params)
if (response.success && response.data) {
setToken(response.data.token)
setUser(response.data.user)
Toast.success('注册成功')
// 跳转到首页或用户中心
router.push('/')
return true
} else {
Toast.error(response.msg || '注册失败')
return false
}
} catch (error) {
console.error('Register error:', error)
Toast.error('注册失败,请重试')
return false
} finally {
isLoading.value = false
}
}
// 重置密码
const resetPassword = async (params: { email?: string; phone: string; code: string; newPassword: string }) => {
try {
isLoading.value = true
const response = await resetPasswordApi(params)
if (response.success) {
Toast.success('密码重置成功')
return { success: true, msg: '密码重置成功' }
} else {
return { success: false, msg: response.msg || '重置失败' }
}
} catch (error) {
console.error('Reset password error:', error)
return { success: false, msg: '重置密码失败,请重试' }
} finally {
isLoading.value = false
}
}
// 初始化用户状态
const initAuth = async () => {
if (token.value) {
const success = await fetchUserInfo()
if (!success) {
clearToken()
}
return success
}
return false
}
return {
token,
user,
isLoading,
isLoggedIn,
setToken,
clearToken,
setUser,
login,
register,
resetPassword,
logout,
fetchUserInfo,
initAuth
}
})

166
src/utils/validation.ts Normal file
View File

@@ -0,0 +1,166 @@
// 邮箱验证
export const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
// 手机号验证(中国大陆)
export const validatePhone = (phone: string): boolean => {
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(phone)
}
// 密码验证
export const validatePassword = (password: string): { isValid: boolean; message?: string } => {
if (!password) {
return { isValid: false, message: '密码不能为空' }
}
if (password.length < 6) {
return { isValid: false, message: '密码长度不能少于6位' }
}
if (password.length > 20) {
return { isValid: false, message: '密码长度不能超过20位' }
}
return { isValid: true }
}
// 验证码验证
export const validateCode = (code: string): boolean => {
return /^\d{6}$/.test(code)
}
// 用户名验证
export const validateNickname = (nickname: string): { isValid: boolean; message?: string } => {
if (!nickname) {
return { isValid: true } // 昵称可以为空
}
if (nickname.length < 2) {
return { isValid: false, message: '昵称长度不能少于2位' }
}
if (nickname.length > 20) {
return { isValid: false, message: '昵称长度不能超过20位' }
}
return { isValid: true }
}
// 综合验证函数
export const validateLoginForm = (data: {
email?: string
phone?: string
password: string
code?: string
type: 'email' | 'phone' | 'wechat'
}): { isValid: boolean; errors: Record<string, string> } => {
const errors: Record<string, string> = {}
if (data.type === 'email') {
if (!data.email) {
errors.email = '邮箱不能为空'
} else if (!validateEmail(data.email)) {
errors.email = '请输入有效的邮箱地址'
}
}
if (data.type === 'phone') {
if (!data.phone) {
errors.phone = '手机号不能为空'
} else if (!validatePhone(data.phone)) {
errors.phone = '请输入有效的手机号'
}
if (!data.code) {
errors.code = '验证码不能为空'
} else if (!validateCode(data.code)) {
errors.code = '请输入6位数字验证码'
}
}
if (data.type !== 'phone' && !data.password) {
errors.password = '密码不能为空'
} else if (data.type !== 'phone' && data.password) {
const passwordValidation = validatePassword(data.password)
if (!passwordValidation.isValid) {
errors.password = passwordValidation.message
}
}
return {
isValid: Object.keys(errors).length === 0,
errors
}
}
export const validateRegisterForm = (data: {
email?: string
phone: string
password: string
code: string
nickname?: string
}): { isValid: boolean; errors: Record<string, string> } => {
const errors: Record<string, string> = {}
if (!data.phone) {
errors.phone = '手机号不能为空'
} else if (!validatePhone(data.phone)) {
errors.phone = '请输入有效的手机号'
}
if (!data.code) {
errors.code = '验证码不能为空'
} else if (!validateCode(data.code)) {
errors.code = '请输入6位数字验证码'
}
const passwordValidation = validatePassword(data.password)
if (!passwordValidation.isValid) {
errors.password = passwordValidation.message
}
const nicknameValidation = validateNickname(data.nickname || '')
if (!nicknameValidation.isValid) {
errors.nickname = nicknameValidation.message
}
if (data.email && !validateEmail(data.email)) {
errors.email = '请输入有效的邮箱地址'
}
return {
isValid: Object.keys(errors).length === 0,
errors
}
}
export const validateResetPasswordForm = (data: {
email?: string
phone: string
code: string
newPassword: string
}): { isValid: boolean; errors: Record<string, string> } => {
const errors: Record<string, string> = {}
if (!data.phone) {
errors.phone = '手机号不能为空'
} else if (!validatePhone(data.phone)) {
errors.phone = '请输入有效的手机号'
}
if (!data.code) {
errors.code = '验证码不能为空'
} else if (!validateCode(data.code)) {
errors.code = '请输入6位数字验证码'
}
const passwordValidation = validatePassword(data.newPassword)
if (!passwordValidation.isValid) {
errors.newPassword = passwordValidation.message
}
if (data.email && !validateEmail(data.email)) {
errors.email = '请输入有效的邮箱地址'
}
return {
isValid: Object.keys(errors).length === 0,
errors
}
}