博客项目 laravel vue mysql 第二章 登录功能

前言

前面章节没看过的朋友请先从第一章开始看 第一章。这章主要写登录相关功能。我的构思是这样的。单独账号admin,不需要注册和权限管理,只实现最简单的登录。

后端

数据库

编辑.env文件

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=blog-api
DB_USERNAME=root
DB_PASSWORD=root

如果laravel本身自带user表就看下面,如果没有执行生成命令

php artisan make:migration create_users_table

我是自带的,就在原来的上面修改了。
编辑迁移文件database/migrations/2014_10_12_000000_create_users_table.php

// 只修改原来的up方法
public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id(); // 主键,自增ID
            $table->string('name'); // 用户名,用于登录
            $table->string('nickname')->nullable(); // 昵称,用于显示 允许为null
            $table->string('email')->unique(); // 邮箱,唯一,用于登录或通知
            $table->string('password'); // 密码,使用 bcrypt 加密存储
            $table->string('avatar')->nullable(); // 头像路径
            $table->timestamps(); // 创建时间和更新时间
        });
    }

运行迁移

php artisan migrate

新建种子文件

php artisan make:seeder UserSeeder

编辑database/seeders/UserSeeder.php

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;

public function run()
    {
        // 插入管理员账号
        DB::table('users')->insert([
            'name' => 'admin',
            'nickname' => '管理员',
            'email' => 'admin@example.com',
            'password' => Hash::make('123456'), // 使用 bcrypt 加密密码
            'avatar' => 'default-avatar.jpg', // 默认头像路径,根据实际情况设置
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }

编辑文件database/seeders/DatabaseSeeder

public function run()
{
    $this->call([
        UserSeeder::class,
    ]);
}

运行

php artisan db:seed

到这一步的时候去查看数据库里user表是否生成数据,有的话继续,没有的话自己检查一下

API实现

创建模型

php artisan make:model User

编辑app/Models/User.php

/**
 * 可填充字段,允许批量赋值。
 */
protected $fillable = ['name', 'nickname', 'email', 'password', 'avatar'];
/**
 * 隐藏字段,在序列化时不会包含这些字段。
 */
protected $hidden = [
    'password',
    'remember_token',
];

创建控制器

php artisan make:controller AuthController

编辑.env文件

APP_URL=http://127.0.0.1:8000

编辑控制器app/Http/Controllers/AuthController.php

/**
     * 用户登录并生成认证令牌
     *
     * @param Request $request
     * @return JsonResponse
     * @throws ValidationException
     */
    public function login(Request $request): JsonResponse
    {

        // 验证请求数据
        $credentials = $request->validate([
            'name' => 'required|string',
            'password' => 'required|string',
        ]);

        // 尝试使用用户名查找用户
        $user = User::where('name', $credentials['name'])->first();

        // 验证用户存在
        if(!$user) {
            return response()->json([
                'message' => '用户名不存在'
            ], 401);
        }
        // 密码正确
        if(!Hash::check($credentials['password'], $user->password)){
            return response()->json([
                'message' => '密码错误'
            ],401);
        }

        // 删除旧令牌并创建新令牌
        $user->tokens()->delete();
        $token = $user->createToken('blog-token', ['*'])->plainTextToken;

        // 返回响应
        return response()->json([
            'data' => [
                'token' => $token,
                'user' => [
                    'id' => $user->id,
                    'name' => $user->name,
                    'nickname' => $user->nickname,
                    'email' => $user->email,
                    'avatar' => $user->avatar ? asset($user->avatar) : null,
                ],
            ],
            'message' => '登录成功',
        ], 200);
    }

返回信息,注意我的url是完整路径,这里童鞋可以自己改

{
    "data": {
        "token": "5|DvBZBXuNBsRLlBxpGvyAodgoRUpJTFNW3TxYlrER",
        "user": {
            "id": 1,
            "name": "admin",
            "nickname": "四季豆",
            "email": "506717715@qq.com",
            "avatar": "http://localhost:8000/default-avatar.jpg"
        }
    },
    "message": "登录成功"
}

编辑routes/api.php

use App\Http\Controllers\AuthController;

Route::post('/login', [AuthController::class, 'login']);

测试token接口

/*
|--------------------------------------------------------------------------
| 受保护接口(需要认证)
|--------------------------------------------------------------------------
*/
Route::middleware('auth:sanctum')->group(function () {
	  // 测试token接口
    Route::post('/test', function () {
        return response()->json([
            'message' => 'token is valid',
            'data' => [
                'name' => '四季豆',
                'url' => 'https://blog.youkuaiyun.com/php_ACDE?type=blog'
            ]
        ], 200);
    });
});

返回结果

{
    "message": "token is valid",
    "data": {
        "name": "四季豆",
        "url": "https://blog.youkuaiyun.com/php_ACDE?type=blog"
    }
}

到这这章后端结束,如果不能正确返回就检查错误或者私我

前端

修改src/views/Home.vue

<template>
    <Header />
    
    <h1>Home</h1>

    <Footer />
</template>

<script setup>
import Header from '@/components/layout/Header.vue'
import Footer from '@/components/layout/Footer.vue'
</script>

<style scoped>
</style>

创建文件src/components/layout/Header.vue

<template>
    <div class="header">
        <div class="container">
            <div class="logo">
                <router-link to="/">Green Beans</router-link>
            </div>
            <div class="nav" :class="{ active: isMenuOpen}">
                <router-link to="/">首页</router-link>
                <router-link target="_blank" to="/login">登录</router-link>
            </div>
            <div class="nav-icon" @click="toggleMenu">
                <span></span>
            </div>
        </div>
    </div>
</template>

<script setup>
import { ref } from 'vue';
const isMenuOpen = ref(false);
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value;
};
</script>

<style scoped lang="scss">
.header {
    background: #fff;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
    position: sticky;
    top: 0;
    z-index: 100;
    .container {
        display: flex;
        justify-content: space-between;
        align-items: center;
        height: 64px;
    }
}
    .logo a {
    font-size: 24px;
    font-weight: 700;
    color: #222;
    }
    .nav {
    display: flex;
    gap: 20px;
    a {
        color: #5c6b77;
        font-size: 16px;
        transition: color 0.3s;
        font-weight: 500;
    }
    }
    .nav a:hover,
    .nav a.router-link-exact-active {
    color: #2563eb;
    }
    .nav-icon {
    display: none;
    cursor: pointer;
    font-size: 24px;
    color: #334155;
    transition: color 0.2s ease;
    }
    .nav-icon:hover {
    color: #2563eb;
    }
    @media (max-width: 768px) {
    .header .container {
        position: relative;
    }
    .nav {
        display: none;
        flex-direction: column;
        position: absolute;
        top: 60px;
        right: 0;
        background-color: #fff;
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
        width: 100%;
        padding: 10px;
        border-radius: 0 0 16px 16px;
    }
    .nav-icon {
        display: block;
    }
    .nav.active {
        display: flex;
    }
}
</style>

创建文件src/components/layout/Footer.vue

<template>
    <div class="footer">
        <div class="container">
            <p>
                © {{ currentYear }} Green Beans. All rights reserved.
            </p>
        </div>
    </div>
</template>

<script setup>
import { computed } from 'vue';

const currentYear = computed(() => new Date().getFullYear());
</script>

<style scoped>
.footer {
    background-color: #fff;
    color: #888;
    text-align: center;
    padding: 18px 0 12px 0;
    width: 100%;
    font-size: 13px;
    z-index: 999;
    border-top: 1px solid #f0f1f3;
}
.footer p {
    margin: 0;
    line-height: 1.5;
}

</style>

编辑src/assets/base.scss

.container {
  width:100%;
  max-width: 1200px;
  margin: 0 auto;
  padding: 0 20px;
}

修改scr/router/index.js

import { createRouter, createWebHistory } from 'vue-router';

const routes = [
    {
        path: '/',
        name: 'Home',
        component: () => import('@/views/Home.vue'),
    },
    {
        path: '/login',
        name: 'Login',
        component: () => import('@/views/Login.vue'),
    },
    // 后台
    {
        path: '/admin',
        name: 'AdminLayout',
        component: () => import('@/components/layouts/AdminLayout.vue'),
    }
];

const router = createRouter({
    history: createWebHistory(),
    routes,
});

export default router;

创建src/views/Login.vue

<template>
    <h1>login</h1>
</template>

<script setup>

</script>

<style scoped>

</style>

到这里,你该去点击下顶部的登录,如果能正常跳转到login页,那么之前算是成功,否则就回去检查错误。

OK,继续!

创建src/api/admin.js

import apiClient from "@/utils/request";

// 认证相关api
export const authApi = {
    login(credentials){
        return apiClient.post('/login', credentials);
    },
    logout(){
        return apiClient.delete('/logout');
    },
    user(){
        return apiClient.get('/user')
    },
    nickname() {
        return apiClient.patch('nickname')
    }
}

创建src/stores/auth.js

import { defineStore } from 'pinia'
import { authApi } from '@/api/admin'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    user: JSON.parse(localStorage.getItem('user')) || null,
    token: localStorage.getItem('token') || null,
    loading: false
  }),
  getters: {
    isAuthenticated: (state) => !!state.token && !!state.user,
    userRole: (state) => state.user?.role || 'user'
  },
  actions: {
    async login(credentials) {
      this.loading = true
      try {
        const response = await authApi.login(credentials)
        const { token, user } = response.data.data
        
        this.token = token
        this.user = user
        
        localStorage.setItem('token', token)
        localStorage.setItem('user', JSON.stringify(user))
        
        return response
      } catch (error) {
        throw error
      } finally {
        this.loading = false
      }
    },
    
    async logout() {
      try {
        await authApi.logout()
      } catch (error) {
        console.error('登出API调用失败:', error)
      } finally {
        this.token = null
        this.user = null
        localStorage.removeItem('token')
        localStorage.removeItem('user')
      }
    },
    
    async fetchUserInfo() {
      if (!this.token) return
      
      try {
        // 这里可以添加获取用户信息的API调用
        // const response = await authApi.getUserInfo()
        // this.user = response.data
        // localStorage.setItem('user', JSON.stringify(response.data))
      } catch (error) {
        console.error('获取用户信息失败:', error)
        this.logout()
      }
    },
    
    updateUserInfo(userInfo) {
      this.user = { ...this.user, ...userInfo }
      localStorage.setItem('user', JSON.stringify(this.user))
    }
  }
})

编辑src/views/Login.vue

<template>
    <div class="login-container">
        <div class="login-card">
            <div class="widget-title">登陆</div>
            <el-form
                class="login-form"
                :model="form"
                ref="loginForm"
                :rules="rules"
                @submit.prevent="handleLogin"
            >
            <el-form-item prop="username">
                <el-input
                    v-model="form.username"
                    placeholder="请输入用户名"
                    prefix-icon="User"
                    clearable
                />
            </el-form-item>
            <el-form-item prop="password">
                <el-input
                    v-model="form.password"
                    type="password"
                    placeholder="请输入密码"
                    prefix-icon="Lock"
                    show-password
                    clearable
                />
            </el-form-item>
            <el-form-item>
                <el-button
                    type="primary"
                    native-type="submit"
                    :loading="loading"
                    class="login-button"
                >
                    登录
                </el-button>
            </el-form-item>
            <el-alert
                v-if="error"
                type="error"
                :title="error"
                show-icon
                :closable="false"
                class="error-alert"
            />
            </el-form>
        </div>
    </div>
</template>

<script setup>
import { ref } from 'vue';
import { useRouter  } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { ElMessage } from 'element-plus';

const form = ref({
    username: '',
    password: ''
})
const loading = ref(false);
const error = ref('');
const loginForm = ref(null);

const rules = ref({
    username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
    password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
})

const router = useRouter();
const authStore = useAuthStore();

const handleLogin = async () => {
    try {
        await loginForm.value.validate();
        loading.value = true;
        error.value = '';

        await authStore.login({
            name: form.value.username,
            password: form.value.password
        })
        
        ElMessage.success('登陆成功');
        router.push('/admin');
    } catch (err){
        console.error('登陆错误', err);
        if (err.response?.data?.message) {
            error.value = err.response.data.message;
        } else {
            error.value = '登录失败,请检查用户名或密码';
        }
        ElMessage.error(error.value);
    } finally {
        loading.value = false;
    }
};
</script>

<style lang="scss" scoped>
.login-container {
    background: #fafbfc;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 24px 16px;
}
.login-card {
    max-width: 360px;
    width: 100%;
    background: #fff;
    border-radius: 16px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
    padding: 32px 28px;
}
.widget-title {
    font-size: 24px;
    font-weight: 700;
    color: #222;
    margin-bottom: 24px;
    text-align: center;
}
.login-form {
    display: flex;
    flex-direction: column;
    gap: 18;
}

/* 输入框 */
:deep(.el-input__wrapper) {
  border-radius: 6px;
  border: 1px solid #e2e8f0;
  background: #fff;
  transition: border-color 0.2s ease;
}

:deep(.el-input__inner) {
  font-size: 15px; /* 主页字体 */
  color: #1e293b; /* 主页文字色 */
}

:deep(.el-input__wrapper.is-focus) {
  border-color: #2563eb; /* 主页主题色 */
  box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); /* 聚焦光晕 */
}

/* 按钮 */
:deep(.el-button) {
  background: #2563eb;
  border-radius: 6px;
  color: #fff;
  font-weight: 600;
  border: none;
  transition: background 0.2s;
}
:deep(.el-button):hover {
  background: #1d4ed8;
}

/* 错误提示 */
.error-alert {
  margin-top: 16px; /* 与主页 .error-alert 一致 */
}

/* 响应式 */
@media (max-width: 768px) {
  .login-container {
    padding: 16px; /* 主页移动端 */
  }
  .login-card {
    max-width: 90%; /* 适配小屏幕 */
    padding: 16px;
  }
}
</style>

创建src/components/layouts/AdminLayout.vue

<template>
    <div class="admin-layout">
        <h2 class="logo">博客后台</h2>
    </div>
</template>

<script setup>

</script>

<style lang="scss" scoped>

</style>

进入登陆页面,账号admin密码123456 登陆成功进入博客后台页面就算成功

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值