hookehuyr

feat(登录): 添加戒子身份验证功能及相关页面

实现戒子通过身份证号登录的功能,包括:
1. 新增登录页面LoginID.vue
2. 修改api接口路径和参数
3. 添加戒子信息查询接口
4. 创建戒子详情页StudentInfo.vue
5. 暂时禁用路由鉴权逻辑
/*
* @Date: 2023-08-24 09:42:27
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-04 21:18:22
* @LastEditTime: 2025-11-12 14:53:22
* @FilePath: /stdj_h5/src/api/index.js
* @Description: 文件描述
*/
......@@ -11,7 +11,8 @@ 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',
POST_LOGIN: '/srv/?a=user',
GET_USER_INFO: '/srv/?a=user',
};
/**
......@@ -90,13 +91,26 @@ export const getArticleDetailAPI = (params) => fn(fetch.get(Api.GET_ARTICLE, par
/**
* @description: 登录接口
* @param {String} phone 手机号
* @param {String} code 验证码
* @param {String} mobile 手机号
* @param {String} mobile_code 验证码
* @param {String} idcard 身份证号
* @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 头像
* @property string data.id 用户id(后续接口要转的值)
* @property string data.nickname 法名
*/
export const loginAPI = (params) => fn(fetch.post(Api.POST_LOGIN, params));
/**
* @description: 戒子信息查询
* @param {String} i 用户id
* @returns {Object} data
* @property string data.id 用户id
* @property string data.name 姓名
* @property string data.nickname 法名
* @property string data.courtesy_name 字号
* @property string data.introduction 简介
* @property string data.group_title 堂口
* @property string data.avatar[{name,url}] 证件照
* @property string data.photo[{name,url}] 其它照片
*/
export const getUserInfoAPI = (params) => fn(fetch.get(Api.GET_USER_INFO, params));
......
/*
* @Date: 2025-10-30 10:29:15
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-11 22:10:22
* @LastEditTime: 2025-11-12 10:06:07
* @FilePath: /stdj_h5/src/router/index.js
* @Description: 文件描述
*/
......@@ -51,6 +51,11 @@ const routes = [
component: () => import('../views/Login.vue')
},
{
path: '/jz_login',
name: '登录-戒子',
component: () => import('../views/LoginID.vue')
},
{
path: '/volunteers',
name: '义工',
component: () => import('../views/Volunteers.vue')
......@@ -101,24 +106,24 @@ router.beforeEach((to, from, next) => {
* 访问控制:除首页('/')与登录页外,其余页面均需登录
* 说明:优先读取 Cookie 中的 token;若不存在则回退读取本地存储 token,避免重复登录。
*/
const token_cookie = Cookies.get('token-stdj')
const is_login = !!(token_cookie)
// const token_cookie = Cookies.get('token-stdj')
// const is_login = !!(token_cookie)
// 白名单:无需登录校验的路径
const white_list = ['/', '/login']
const need_auth = !white_list.includes(to.path)
// // 白名单:无需登录校验的路径
// const white_list = ['/', '/login']
// const need_auth = !white_list.includes(to.path)
if (need_auth && !is_login) {
// 在重定向前关闭或重置上一跳的loading,避免计数残留
try {
const loading = useLoadingStore()
loading.reset()
} catch (e) {
void e
}
next({ name: '登录', query: { redirect: to.fullPath } })
return
}
// if (need_auth && !is_login) {
// // 在重定向前关闭或重置上一跳的loading,避免计数残留
// try {
// const loading = useLoadingStore()
// loading.reset()
// } catch (e) {
// void e
// }
// next({ name: '登录', query: { redirect: to.fullPath } })
// return
// }
next()
})
......
<!--
* @Date: 2025-11-10 18:08:59
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-12 14:51:18
* @FilePath: /stdj_h5/src/views/LoginID.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="text" placeholder="请输入证件号码" v-model="id_card" maxlength="18" />
</div>
</div>
<!-- 身份证验证按钮 -->
<div class="form-item">
<div class="btn primary" :class="{ disabled: id_login_disabled }" @click="on_click_login_by_id">立即查询</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useTitle } from '@vueuse/core'
import { loginAPI } from '@/api/index.js'
import { showFailToast, showSuccessToast } from 'vant'
import Cookies from 'js-cookie'
useTitle('戒子身份验证')
const route = useRoute()
const router = useRouter()
// 表单状态
const logging = ref(false)
const id_card = ref('')
/**
* 身份证登录按钮禁用条件
* 说明:登录进行中或输入内容为空时不可操作
*/
const id_login_disabled = computed(function () {
return (
logging.value ||
!is_not_empty(id_card.value)
)
})
/**
* 校验输入是否非空
* 说明:仅校验输入值是否为空字符串(去除首尾空格)
* @param {string} v 输入内容
* @returns {boolean} 是否非空
*/
const is_not_empty = function (v) {
return String(v || '').trim().length > 0
}
/**
* 身份证登录
* 说明:仅凭身份证号进行登录
* @returns {Promise<void>}
*/
const on_login_by_id = async function () {
if (logging.value) return
if (!is_not_empty(id_card.value)) {
showFailToast('请输入证件号码')
return
}
try {
logging.value = true
const { code, data } = await loginAPI({ idcard: id_card.value })
if (code) {
Cookies.set('token-stdj', data.id, { expires: 1 })
showSuccessToast('登录成功')
setTimeout(() => {
// 直接跳转到戒子信息页
router.replace({ path: '/studentInfo' })
}, 1000)
}
} catch (e) {
showFailToast('网络异常,请稍后重试')
} finally {
logging.value = false
}
}
/**
* 点击身份证登录包装函数
* 说明:在禁用态下不触发真实登录逻辑
* @returns {void}
*/
const on_click_login_by_id = function () {
if (id_login_disabled.value) return
on_login_by_id()
}
onMounted(() => {
// 初始化时,检查是否已登录(通过token判断)
const token = Cookies.get('token-stdj')
if (token) {
// 已登录,直接跳转到戒子信息页
router.replace({ path: '/studentInfo' })
}
})
</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 {
width: 100%;
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;
}
// 输入框:带左侧图标
.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/%E8%BA%AB%E4%BB%BD@2x.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.primary {
width: 100%;
}
.btn.disabled {
// 不可操作态:明显的灰显与禁止样式
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
filter: grayscale(30%);
box-shadow: none;
}
</style>
<!--
* @Date: 2025-11-10 18:12:23
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-11-11 13:04:06
* @LastEditTime: 2025-11-12 15:14:14
* @FilePath: /stdj_h5/src/views/StudentInfo.vue
* @Description: 戒子详情页
-->
......@@ -9,74 +9,61 @@
<div class="masters-detail-container">
<section class="single-list">
<div class="item-card">
<img :src="article_item.src" :alt="article_item.title" class="item-image" />
<img :src="student_info.src" :alt="student_info.title" class="item-image" />
<div class="item-content">
<div class="item-role">{{ article_item.role }}</div>
<div class="item-name" v-html="formatNameWithSuperscript(article_item.name)"></div>
<div class="item-desc" v-html="article_item.desc"></div>
<div class="item-role">{{ student_info.role }}</div>
<div class="item-name" v-html="formatNameWithSuperscript(student_info.name)"></div>
<div class="item-group-title">{{ student_info.group_title }}</div>
<div class="item-desc" v-html="student_info.desc"></div>
</div>
</div>
</section>
<!-- 标题图片 -->
<div class="common-title">
<img src="https://cdn.ipadbiz.cn/stdj/images/%E7%9B%B8%E5%85%B3%E7%85%A7%E7%89%87@2x.png" alt="相关照片" class="title-image">
<img src="https://cdn.ipadbiz.cn/stdj/images/%E7%9B%B8%E5%85%B3%E7%85%A7%E7%89%87@2x.png" alt="相关照片"
class="title-image">
</div>
<!-- 下载提示:参考首页滑动提示样式 -->
<div class="download-hint">
<div v-if="student_info?.photo?.length" class="download-hint">
<span class="download-icon">⬇</span>
<span class="download-text">点击查看大图 长按图片下载</span>
</div>
<!-- 瀑布流内容 -->
<div class="waterfall-content">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div class="waterfall-container">
<div class="waterfall-column" v-for="(column, index) in columns" :key="index">
<div
class="waterfall-item"
v-for="item in column"
:key="item.id"
@click="onImageClick(item)"
>
<div class="image-wrapper">
<img
:src="item.src"
:alt="item.title"
:style="{ height: item.height + 'px' }"
@load="onImageLoad"
@error="onImageError"
/>
<!-- <div class="image-overlay">
<span class="image-title">{{ item.title }}</span>
</div> -->
</div>
<div v-if="student_info?.photo?.length" class="waterfall-content">
<div class="waterfall-container">
<div class="waterfall-column" v-for="(column, index) in columns" :key="index">
<div class="waterfall-item" v-for="item in column" :key="item.id" @click="onImageClick(item)">
<div class="image-wrapper">
<img :src="item.src" :alt="item.title" :style="{ height: item.height + 'px' }" @load="onImageLoad"
@error="onImageError" />
</div>
</div>
</div>
</van-list>
</div>
</div>
<div v-else>
<div class="empty-message">暂无相关照片</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, reactive } from 'vue'
import { useTitle } from '@vueuse/core';
import { useRoute, useRouter } from 'vue-router'
import { showImagePreview } from 'vant'
import Cookies from 'js-cookie'
// 导入接口
import { getArticleDetailAPI, getImgStreamAPI } from '@/api/index.js'
import { getUserInfoAPI } from '@/api/index.js'
useTitle('戒子详情')
const route = useRoute()
const router = useRouter()
const article_item = ref({})
const info_id = ref('');
const student_info = ref({})
/**
* 为name字段的第一个文字添加上标效果
......@@ -93,21 +80,33 @@ const formatNameWithSuperscript = (name) => {
}
/**
* 加载文章详情
* 加载详情
*/
const loadArticleDetail = async () => {
const loadUserInfo = async () => {
try {
// const articleId = route.params.id
const articleId = '3662153'
const { code, data } = await getArticleDetailAPI({ i: articleId })
const { code, data } = await getUserInfoAPI({ i: info_id.value })
if (code) {
// 遍历data对象,将每个元素转换为新的对象格式
article_item.value = {
student_info.value = {
id: data.id,
role: data.post_excerpt,
name: data.post_title,
src: data?.file_list?.photo?.value,
desc: data.post_content
role: data.name,
name: data.nickname,
src: data?.avatar[0].url,
desc: data.introduction,
courtesy_name: data.courtesy_name,
group_title: data.group_title,
photo: data.photo,
}
// 处理图片数据
if (student_info.value.photo?.length) {
student_info.value.photo = student_info.value.photo.map(item => ({
id: item.id,
src: item.url,
title: item.name,
height: Math.floor(Math.random() * 200) + 200
}))
allImages.value = student_info.value.photo
distributeImages(student_info.value.photo)
}
}
} catch (error) {
......@@ -115,57 +114,10 @@ const loadArticleDetail = async () => {
}
}
// const i = ref(router.currentRoute.value.query.i)
const i = ref('3680502')
// 响应式数据
const loading = ref(false)
const finished = ref(false)
const currentPage = ref(0) // API使用0作为起始页码
const pageSize = 10
const allImages = ref([])
const columns = reactive([[], []])
// 加载数据
const onLoad = async () => {
loading.value = true
try {
// 调用正式接口
const { code, data } = await getImgStreamAPI({
i: i.value,
page: currentPage.value,
limit: pageSize
})
if (code) {
const newData = data.list.map(item => ({
id: item.id,
src: item.value, // 修改为src,用于ImagePreview
title: item.name,
description: item.description,
date: item.post_date,
height: Math.floor(Math.random() * 200) + 200 // 随机高度用于瀑布流布局
}))
if (newData.length === 0) {
finished.value = true
} else {
allImages.value.push(...newData)
distributeImages(newData)
currentPage.value++
}
} else {
finished.value = true
}
} catch (error) {
console.error('加载数据失败:', error)
finished.value = true
} finally {
loading.value = false
}
}
// 分配图片到两列
const distributeImages = (images) => {
images.forEach(image => {
......@@ -188,27 +140,18 @@ const distributeImages = (images) => {
*/
// 图片点击事件 - 仅预览当前单张图片
const onImageClick = (item) => {
console.log('点击图片:', item)
// 获取当前点击图片在所有图片中的索引
const currentIndex = allImages.value.findIndex(img => img.id === item.id)
// 提取所有图片的src用于预览
const images = allImages.value.map(img => img.src)
// 仅预览当前单张图片,禁用索引与循环
showImagePreview({
images: [images[currentIndex]],
startPosition: 0,
showIndex: false,
loop: false,
closeable: true
})
showImagePreview({
images: [item.src],
startPosition: 0,
showIndex: false,
loop: false,
closeable: true
})
}
// 图片加载成功
const onImageLoad = (event) => {
console.log('图片加载成功:', event.target.src)
// console.log('图片加载成功:', event.target.src)
}
// 图片加载失败
......@@ -219,13 +162,20 @@ const onImageError = (event) => {
}
onMounted(() => {
loadArticleDetail()
// 初始加载第一页数据
onLoad()
// 检查是否已登录(通过token判断)
const token = Cookies.get('token-stdj')
if (!token) {
// 未登录,跳转到登录页
router.replace({ path: '/jz_login' })
return
}
//
info_id.value = token
//
loadUserInfo()
})
</script>
<style scoped>
/* 通用标题样式 */
.common-title {
......@@ -241,6 +191,14 @@ onMounted(() => {
height: auto;
}
.empty-message {
text-align: center;
color: #999999;
font-size: 1rem;
margin-top: 2rem;
}
/* 下载提示样式(参考首页 swipe-hint) */
.download-hint {
display: flex;
......@@ -252,11 +210,13 @@ onMounted(() => {
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.08);
font-weight: 700;
}
.download-icon {
font-size: 0.875rem;
line-height: 1;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
}
.download-text {
font-size: 0.875rem;
font-weight: 700;
......@@ -268,6 +228,7 @@ onMounted(() => {
.download-hint {
margin-bottom: 1.25rem;
}
.download-icon,
.download-text {
font-size: 0.8rem;
......@@ -323,6 +284,12 @@ onMounted(() => {
margin-top: 0.25rem;
}
.item-group-title {
font-size: 1rem;
font-weight: 500;
margin-top: 0.25rem;
}
.item-desc {
margin-top: 0.5rem;
}
......@@ -447,6 +414,7 @@ onMounted(() => {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
......