hookehuyr

feat(登录): 实现登录模块及访问控制功能

添加登录页面和戒子详情页面,实现手机号验证码登录功能
在路由守卫中添加登录状态校验,未登录用户访问受保护路由将被重定向
更新API接口添加登录相关方法,移除旧的用户存储实现
在首页添加照片查询入口,根据登录状态跳转不同页面
......@@ -154,6 +154,22 @@ API 请求基于 Axios 封装,配置文件位于 `src/utils/request.js`,支
- **LoadingSpinner** - 加载动画组件
- **EmptyState** - 空状态组件
## 登录模块(Mock)
- 页面:`src/views/Login.vue`
- 功能:手机号 + 验证码登录;发送验证码含 60 秒倒计时;登录成功写入 Pinia 并跳转首页。
- 接口:
- `send_sms_mock(phone)`:发送验证码,模拟成功返回(约 500ms)。
- `login_mock(phone, code)`:验证码登录,返回 `token``userInfo`(约 600ms)。
- 说明:暂不加样式,待设计稿出具后统一视觉优化。
## 访问控制(登录态)
- 入口逻辑:首页“照片查询和下载”入口点击时将根据登录态跳转。
- 未登录:跳转 `#/login`
- 已登录:跳转 `#/studentInfo`
- 路由守卫:对 `#/studentInfo` 做登录校验,未登录访问将被重定向到登录页并附带原始目标路径,登录后可返回。
## 开发规范
### 代码风格
......
/*
* @Date: 2022-06-17 14:54:29
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2023-06-25 09:33:28
* @FilePath: /huizhu/src/api/common.js
* @LastEditTime: 2025-11-11 09:19:52
* @FilePath: /stdj_h5/src/api/common.js
* @Description: 通用接口
*/
import { fn, fetch, uploadFn } from '@/api/fn';
......
/*
* @Date: 2022-05-18 22:56:08
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-04 21:18:39
* @LastEditTime: 2025-11-11 13:31:09
* @FilePath: /stdj_h5/src/api/fn.js
* @Description: 文件描述
*/
......@@ -22,7 +22,7 @@ export const fn = (api) => {
} else {
// tslint:disable-next-line: no-console
console.warn(res);
if (!res.data.show) return false;
// if (!res.data.show) return false;
showFailToast(res.data.msg);
return false;
}
......
......@@ -11,6 +11,7 @@ const Api = {
GET_HOME: '/srv/?a=home',
GET_ARTICLE: '/srv/?a=get_article',
GET_ARTICLE_FILE: '/srv/?a=get_article&t=file',
POST_LOGIN: '/srv/?a=login',
};
/**
......@@ -86,3 +87,16 @@ export const getImgStreamAPI = (params) => fn(fetch.get(Api.GET_ARTICLE_FILE, pa
* @property string data.file_list.photo.value 照片地址
*/
export const getArticleDetailAPI = (params) => fn(fetch.get(Api.GET_ARTICLE, params));
/**
* @description: 登录接口
* @param {String} phone 手机号
* @param {String} code 验证码
* @returns {Object} data
* @property string data.token 登录凭证
* @property object data.user 用户信息
* @property string data.user.id 用户id
* @property string data.user.nickname 昵称
* @property string data.user.avatar 头像
*/
export const loginAPI = (params) => fn(fetch.post(Api.POST_LOGIN, params));
......
/*
* @Date: 2025-10-30 10:29:15
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-04 20:52:25
* @LastEditTime: 2025-11-11 12:56:04
* @FilePath: /stdj_h5/src/router/index.js
* @Description: 文件描述
*/
import { createRouter, createWebHashHistory } from 'vue-router'
import { useLoadingStore } from '@/stores/loading.js'
import Cookies from 'js-cookie'
const routes = [
{
......@@ -40,6 +41,16 @@ const routes = [
component: () => import('../views/Students.vue')
},
{
path: '/studentInfo',
name: '戒子详情',
component: () => import('../views/StudentInfo.vue')
},
{
path: '/login',
name: '登录',
component: () => import('../views/Login.vue')
},
{
path: '/volunteers',
name: '义工',
component: () => import('../views/Volunteers.vue')
......@@ -86,7 +97,26 @@ router.beforeEach((to, from, next) => {
void e
}
// 这里可以添加权限验证逻辑
/**
* 访问控制:仅在进入 /studentInfo 时校验登录状态
* 说明:优先读取 Cookie 中的 token;若不存在则回退读取本地存储 token,避免重复登录。
*/
const has_token_cookie = !!Cookies.get('token-stdj')
const is_login = has_token_cookie
if (to.path === '/studentInfo' && !is_login) {
// 关键:在重定向前关闭或重置上一跳的loading,避免计数残留
try {
const loading = useLoadingStore()
loading.reset()
} catch (e) {
void e
}
next({ name: '登录', query: { redirect: to.fullPath } })
return
}
// 其他页面不做登录校验
next()
})
......
/*
* @Date: 2025-10-30 10:30:17
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-10-30 10:30:26
* @FilePath: /itomix/h5_vite_template/src/stores/user.js
* @Description: 文件描述
*/
import { defineStore } from 'pinia'
import { userApi } from '@/api'
export const useUserStore = defineStore('user', {
state: () => ({
userInfo: null,
token: localStorage.getItem('token') || '',
isLogin: false
}),
getters: {
// 获取用户名
username: (state) => state.userInfo?.username || '',
// 获取用户头像
avatar: (state) => state.userInfo?.avatar || '',
// 是否已登录
hasLogin: (state) => !!state.token && !!state.userInfo
},
actions: {
// 设置 token
setToken(token) {
this.token = token
localStorage.setItem('token', token)
},
// 清除 token
clearToken() {
this.token = ''
localStorage.removeItem('token')
},
// 设置用户信息
setUserInfo(userInfo) {
this.userInfo = userInfo
this.isLogin = true
},
// 清除用户信息
clearUserInfo() {
this.userInfo = null
this.isLogin = false
},
// 登录
async login(loginData) {
const response = await userApi.login(loginData)
const { token, userInfo } = response.data
this.setToken(token)
this.setUserInfo(userInfo)
return response
},
// 获取用户信息
async getUserInfo() {
try {
const response = await userApi.getUserInfo()
this.setUserInfo(response.data)
return response
} catch (error) {
// 如果获取用户信息失败,清除本地存储
this.logout()
throw error
}
},
// 登出
async logout() {
try {
await userApi.logout()
} catch (error) {
console.error('登出失败:', error)
} finally {
this.clearToken()
this.clearUserInfo()
}
}
}
})
\ No newline at end of file
......@@ -121,6 +121,15 @@
</div>
</div>
<div style="background: #E5DAC2; padding-bottom: 1rem;">
<div class="photo-section" @click="handlePhotoClick">
<div class="photo-content">
<div class="photo-title">照片查询下载</div>
<div class="more-button"><span class="more-text">身份验证</span></div>
</div>
</div>
</div>
<!-- 相关新闻 -->
<div class="news-section">
<!-- 标题 -->
......@@ -175,6 +184,7 @@ import VideoPlayer from '@/components/VideoPlayer.vue'
import { useTitle } from '@vueuse/core';
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import Cookies from 'js-cookie'
// 导入接口
import { homePageAPI } from '@/api/index.js'
......@@ -637,6 +647,23 @@ const showSwipeHint = ref(true)
const dismissSwipeHint = () => {
showSwipeHint.value = false
}
/**
* 处理照片入口点击
* 说明:未登录跳转登录页;已登录进入戒子信息页面。
* @returns {void}
*/
// 登录状态获取
const hasLogin = computed(() => Boolean(Cookies.get('token-stdj')))
const handlePhotoClick = () => {
// 判断是否已登录
if (hasLogin.value) {
router.push('/studentInfo')
} else {
router.push('/login')
}
}
</script>
<style scoped>
......@@ -735,6 +762,50 @@ const dismissSwipeHint = () => {
background: #E5DAC2;
}
/* 照片查询下载模块样式 */
.photo-section {
margin: 0 1rem;
height: 3.5rem;
border-radius: 1rem;
background-image: url('https://cdn.ipadbiz.cn/stdj/images/%E7%85%A7%E7%89%87%E4%B8%8B%E8%BD%BD@2x.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.photo-content {
width: 100%;
padding: 0 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.photo-title {
color: #FFFFFF;
font-size: 1rem; /* 约18px,符合移动端标题视觉 */
font-weight: 600;
text-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.35);
}
.photo-section .more-button {
position: absolute;
top: 50%;
right: 1.5rem;
left: auto;
bottom: auto;
height: 2rem; /* 复用查看更多按钮风格并调整高度 */
transform: translateY(-50%);
}
.photo-section .more-text {
font-size: 0.75rem;
}
/* 通用标题样式 */
.common-title {
text-align: center;
......
<!--
* @Date: 2025-11-10 18:08:59
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-11 13:31:30
* @FilePath: /stdj_h5/src/views/Login.vue
* @Description: 登录页
-->
<template>
<div class="login-page">
<!-- 顶部LOGO标题 -->
<div class="logo-title">
<img class="logo-img" src="https://cdn.ipadbiz.cn/stdj/images/logo@2x.png" alt="Logo">
</div>
<!-- 戒子身份验证容器 -->
<div class="auth-card">
<div class="card-title">戒子身份验证</div>
<!-- 登录表单:手机号 -->
<div class="form-item">
<div class="input-with-icon phone">
<input class="input" type="tel" placeholder="请输入手机号" v-model="phone" maxlength="11" />
</div>
</div>
<!-- 登录表单:验证码 + 获取验证码 -->
<div class="form-item">
<div class="code-row">
<div class="input-with-icon code">
<input class="input" type="tel" placeholder="请输入验证码" v-model="code" maxlength="6" />
</div>
<div class="btn send" :class="{ disabled: send_disabled }" @click="on_click_send_sms">
<span v-if="countdown === 0">获取验证码</span>
<span v-else>{{ countdown }}s后重试</span>
</div>
</div>
</div>
<!-- 立即验证按钮 -->
<div class="form-item">
<div class="btn primary" :class="{ disabled: login_disabled }" @click="on_click_login">立即验证</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTitle } from '@vueuse/core'
import { smsAPI } from '@/api/common.js'
import { loginAPI } from '@/api/index.js'
import { showToast, showFailToast, showSuccessToast } from 'vant'
import Cookies from 'js-cookie'
useTitle('戒子身份验证')
const route = useRoute()
const router = useRouter()
// 表单状态
const phone = ref('')
const code = ref('')
const countdown = ref(0)
const timer_id = ref(null)
const sending = ref(false)
const logging = ref(false)
/**
* 发送验证码按钮禁用条件
* 说明:倒计时进行中或正在发送或手机号无效时不可操作
*/
const send_disabled = computed(function () {
return countdown.value > 0 || sending.value || !is_valid_phone(phone.value)
})
/**
* 登录按钮禁用条件
* 说明:登录进行中或手机号/验证码未通过校验时不可操作
*/
const login_disabled = computed(function () {
return (
logging.value ||
!is_valid_phone(phone.value) ||
!/^\d{4,6}$/.test(String(code.value || '').trim())
)
})
/**
* 点击发送验证码包装函数
* 说明:在禁用态下不触发真实发送逻辑
* @returns {void}
*/
const on_click_send_sms = function () {
if (send_disabled.value) return
on_send_sms()
}
/**
* 点击登录包装函数
* 说明:在禁用态下不触发真实登录逻辑
* @returns {void}
*/
const on_click_login = function () {
if (login_disabled.value) return
on_login()
}
/**
* 校验手机号格式
* 说明:仅支持中国大陆11位手机号校验
* @param {string} v 手机号
* @returns {boolean} 是否有效
*/
const is_valid_phone = function (v) {
return /^1\d{10}$/.test(String(v || '').trim())
}
/**
* 启动60秒倒计时
* 说明:发送验证码成功后启动,期间禁用发送按钮
* @returns {void}
*/
const start_countdown = function () {
countdown.value = 60
if (timer_id.value) {
clearInterval(timer_id.value)
timer_id.value = null
}
timer_id.value = setInterval(function () {
countdown.value = countdown.value - 1
if (countdown.value <= 0) {
clearInterval(timer_id.value)
timer_id.value = null
countdown.value = 0
}
}, 1000)
}
/**
* 发送验证码
* 说明:调用接口,成功后开始倒计时
* @returns {Promise<void>}
*/
const on_send_sms = async function () {
if (sending.value || countdown.value > 0) return
if (!is_valid_phone(phone.value)) {
showFailToast('请输入有效的手机号')
return
}
try {
sending.value = true
const { code } = await smsAPI({ phone: phone.value })
if (code) {
showToast('验证码已发送')
start_countdown()
}
} catch (e) {
showFailToast('网络异常,请稍后重试')
} finally {
sending.value = false
}
}
/**
* 登录
* @returns {Promise<void>}
*/
const on_login = async function () {
if (logging.value) return
if (!is_valid_phone(phone.value)) {
showFailToast('请输入有效的手机号')
return
}
if (!/^\d{4,6}$/.test(String(code.value || '').trim())) {
showFailToast('请输入4~6位数字验证码')
return
}
try {
logging.value = true
const { code, data } = await loginAPI({ phone: phone.value, code: code.value })
if (code) {
// 登录成功后,将token存储到cookie中
Cookies.set('token-stdj', data.token, { expires: 7 })
showSuccessToast('登录成功')
// 跳转戒子详情页
router.replace({ path: route.query.redirect })
}
} catch (e) {
showFailToast('网络异常,请稍后重试')
} finally {
logging.value = false
}
}
// 组件卸载时清理计时器
onUnmounted(function () {
if (timer_id.value) {
clearInterval(timer_id.value)
timer_id.value = null
}
})
</script>
<style lang="less" scoped>
// 页面背景与布局
.login-page {
min-height: 100vh;
background-color: #FCF8F1; // 整个页面背景色
display: flex;
flex-direction: column;
align-items: center;
padding: 6rem 1rem;
background-image: url('https://cdn.ipadbiz.cn/stdj/images/bg002@2x.png');
background-size: cover;
background-position: center;
}
// 顶部Logo标题
.logo-title {
margin-bottom: 2rem;
}
.logo-img {
height: 5.5rem;
object-fit: contain;
margin-top: 1rem;
}
// 验证容器
.auth-card {
max-width: 26rem;
background-color: rgba(174, 155, 99, 0.14); // 容器背景色
border-radius: 0.75rem;
padding: 1rem;
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.06);
}
.card-title {
text-align: center;
color: #432C0E;
font-size: 1.125rem;
font-weight: 700;
margin-top: 0.75rem;
margin-bottom: 1.25rem;
}
// 表单项布局
.form-item {
margin-top: 1.25rem;
margin-bottom: 0.75rem;
}
.code-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
// 输入框:带左侧图标
.input-with-icon {
position: relative;
}
.input-with-icon::before {
content: '';
position: absolute;
left: 0.75rem;
top: 50%;
width: 1rem;
height: 1rem;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
transform: translateY(-50%);
}
.input-with-icon.phone::before {
background-image: url('https://cdn.ipadbiz.cn/stdj/images/%E6%89%8B%E6%9C%BA@2x.png');
}
.input-with-icon.code::before {
background-image: url('https://cdn.ipadbiz.cn/stdj/images/%E9%AA%8C%E8%AF%81%E7%A0%81@2x-1.png');
}
.input {
width: 100%;
height: 2.5rem;
border-radius: 0.5rem;
border: none;
background-color: #FFFFFF;
padding: 0 0.75rem 0 2.25rem; // 为左侧图标预留空间
box-shadow: inset 0 0 0 0.0625rem rgba(0, 0, 0, 0.08);
}
.input::placeholder {
color: #999999;
}
// 按钮样式
.btn {
height: 2.5rem;
padding: 0 0.75rem;
border: none;
border-radius: 0.5rem;
background-color: #A67939; // 获取验证码与立即验证背景色
color: #FFFFFF;
font-weight: 600;
text-align: center;
line-height: 2.5rem;
}
.btn.send {
white-space: nowrap;
}
.btn.primary {
width: 100%;
}
.btn.disabled {
// 不可操作态:明显的灰显与禁止样式
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
filter: grayscale(30%);
box-shadow: none;
}
</style>
This diff is collapsed. Click to expand it.