hookehuyr

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

添加登录页面和戒子详情页面,实现手机号验证码登录功能
在路由守卫中添加登录状态校验,未登录用户访问受保护路由将被重定向
更新API接口添加登录相关方法,移除旧的用户存储实现
在首页添加照片查询入口,根据登录状态跳转不同页面
...@@ -154,6 +154,22 @@ API 请求基于 Axios 封装,配置文件位于 `src/utils/request.js`,支 ...@@ -154,6 +154,22 @@ API 请求基于 Axios 封装,配置文件位于 `src/utils/request.js`,支
154 - **LoadingSpinner** - 加载动画组件 154 - **LoadingSpinner** - 加载动画组件
155 - **EmptyState** - 空状态组件 155 - **EmptyState** - 空状态组件
156 156
157 +## 登录模块(Mock)
158 +
159 +- 页面:`src/views/Login.vue`
160 +- 功能:手机号 + 验证码登录;发送验证码含 60 秒倒计时;登录成功写入 Pinia 并跳转首页。
161 +- 接口:
162 + - `send_sms_mock(phone)`:发送验证码,模拟成功返回(约 500ms)。
163 + - `login_mock(phone, code)`:验证码登录,返回 `token``userInfo`(约 600ms)。
164 +- 说明:暂不加样式,待设计稿出具后统一视觉优化。
165 +
166 +## 访问控制(登录态)
167 +
168 +- 入口逻辑:首页“照片查询和下载”入口点击时将根据登录态跳转。
169 + - 未登录:跳转 `#/login`
170 + - 已登录:跳转 `#/studentInfo`
171 +- 路由守卫:对 `#/studentInfo` 做登录校验,未登录访问将被重定向到登录页并附带原始目标路径,登录后可返回。
172 +
157 ## 开发规范 173 ## 开发规范
158 174
159 ### 代码风格 175 ### 代码风格
......
1 /* 1 /*
2 * @Date: 2022-06-17 14:54:29 2 * @Date: 2022-06-17 14:54:29
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2023-06-25 09:33:28 4 + * @LastEditTime: 2025-11-11 09:19:52
5 - * @FilePath: /huizhu/src/api/common.js 5 + * @FilePath: /stdj_h5/src/api/common.js
6 * @Description: 通用接口 6 * @Description: 通用接口
7 */ 7 */
8 import { fn, fetch, uploadFn } from '@/api/fn'; 8 import { fn, fetch, uploadFn } from '@/api/fn';
......
1 /* 1 /*
2 * @Date: 2022-05-18 22:56:08 2 * @Date: 2022-05-18 22:56:08
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-11-04 21:18:39 4 + * @LastEditTime: 2025-11-11 13:31:09
5 * @FilePath: /stdj_h5/src/api/fn.js 5 * @FilePath: /stdj_h5/src/api/fn.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
...@@ -22,7 +22,7 @@ export const fn = (api) => { ...@@ -22,7 +22,7 @@ export const fn = (api) => {
22 } else { 22 } else {
23 // tslint:disable-next-line: no-console 23 // tslint:disable-next-line: no-console
24 console.warn(res); 24 console.warn(res);
25 - if (!res.data.show) return false; 25 + // if (!res.data.show) return false;
26 showFailToast(res.data.msg); 26 showFailToast(res.data.msg);
27 return false; 27 return false;
28 } 28 }
......
...@@ -11,6 +11,7 @@ const Api = { ...@@ -11,6 +11,7 @@ const Api = {
11 GET_HOME: '/srv/?a=home', 11 GET_HOME: '/srv/?a=home',
12 GET_ARTICLE: '/srv/?a=get_article', 12 GET_ARTICLE: '/srv/?a=get_article',
13 GET_ARTICLE_FILE: '/srv/?a=get_article&t=file', 13 GET_ARTICLE_FILE: '/srv/?a=get_article&t=file',
14 + POST_LOGIN: '/srv/?a=login',
14 }; 15 };
15 16
16 /** 17 /**
...@@ -86,3 +87,16 @@ export const getImgStreamAPI = (params) => fn(fetch.get(Api.GET_ARTICLE_FILE, pa ...@@ -86,3 +87,16 @@ export const getImgStreamAPI = (params) => fn(fetch.get(Api.GET_ARTICLE_FILE, pa
86 * @property string data.file_list.photo.value 照片地址 87 * @property string data.file_list.photo.value 照片地址
87 */ 88 */
88 export const getArticleDetailAPI = (params) => fn(fetch.get(Api.GET_ARTICLE, params)); 89 export const getArticleDetailAPI = (params) => fn(fetch.get(Api.GET_ARTICLE, params));
90 +
91 +/**
92 + * @description: 登录接口
93 + * @param {String} phone 手机号
94 + * @param {String} code 验证码
95 + * @returns {Object} data
96 + * @property string data.token 登录凭证
97 + * @property object data.user 用户信息
98 + * @property string data.user.id 用户id
99 + * @property string data.user.nickname 昵称
100 + * @property string data.user.avatar 头像
101 + */
102 +export const loginAPI = (params) => fn(fetch.post(Api.POST_LOGIN, params));
......
1 /* 1 /*
2 * @Date: 2025-10-30 10:29:15 2 * @Date: 2025-10-30 10:29:15
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-11-04 20:52:25 4 + * @LastEditTime: 2025-11-11 12:56:04
5 * @FilePath: /stdj_h5/src/router/index.js 5 * @FilePath: /stdj_h5/src/router/index.js
6 * @Description: 文件描述 6 * @Description: 文件描述
7 */ 7 */
8 import { createRouter, createWebHashHistory } from 'vue-router' 8 import { createRouter, createWebHashHistory } from 'vue-router'
9 import { useLoadingStore } from '@/stores/loading.js' 9 import { useLoadingStore } from '@/stores/loading.js'
10 +import Cookies from 'js-cookie'
10 11
11 const routes = [ 12 const routes = [
12 { 13 {
...@@ -40,6 +41,16 @@ const routes = [ ...@@ -40,6 +41,16 @@ const routes = [
40 component: () => import('../views/Students.vue') 41 component: () => import('../views/Students.vue')
41 }, 42 },
42 { 43 {
44 + path: '/studentInfo',
45 + name: '戒子详情',
46 + component: () => import('../views/StudentInfo.vue')
47 + },
48 + {
49 + path: '/login',
50 + name: '登录',
51 + component: () => import('../views/Login.vue')
52 + },
53 + {
43 path: '/volunteers', 54 path: '/volunteers',
44 name: '义工', 55 name: '义工',
45 component: () => import('../views/Volunteers.vue') 56 component: () => import('../views/Volunteers.vue')
...@@ -86,7 +97,26 @@ router.beforeEach((to, from, next) => { ...@@ -86,7 +97,26 @@ router.beforeEach((to, from, next) => {
86 void e 97 void e
87 } 98 }
88 99
89 - // 这里可以添加权限验证逻辑 100 + /**
101 + * 访问控制:仅在进入 /studentInfo 时校验登录状态
102 + * 说明:优先读取 Cookie 中的 token;若不存在则回退读取本地存储 token,避免重复登录。
103 + */
104 + const has_token_cookie = !!Cookies.get('token-stdj')
105 + const is_login = has_token_cookie
106 +
107 + if (to.path === '/studentInfo' && !is_login) {
108 + // 关键:在重定向前关闭或重置上一跳的loading,避免计数残留
109 + try {
110 + const loading = useLoadingStore()
111 + loading.reset()
112 + } catch (e) {
113 + void e
114 + }
115 + next({ name: '登录', query: { redirect: to.fullPath } })
116 + return
117 + }
118 +
119 + // 其他页面不做登录校验
90 next() 120 next()
91 }) 121 })
92 122
......
1 -/*
2 - * @Date: 2025-10-30 10:30:17
3 - * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2025-10-30 10:30:26
5 - * @FilePath: /itomix/h5_vite_template/src/stores/user.js
6 - * @Description: 文件描述
7 - */
8 -import { defineStore } from 'pinia'
9 -import { userApi } from '@/api'
10 -
11 -export const useUserStore = defineStore('user', {
12 - state: () => ({
13 - userInfo: null,
14 - token: localStorage.getItem('token') || '',
15 - isLogin: false
16 - }),
17 -
18 - getters: {
19 - // 获取用户名
20 - username: (state) => state.userInfo?.username || '',
21 -
22 - // 获取用户头像
23 - avatar: (state) => state.userInfo?.avatar || '',
24 -
25 - // 是否已登录
26 - hasLogin: (state) => !!state.token && !!state.userInfo
27 - },
28 -
29 - actions: {
30 - // 设置 token
31 - setToken(token) {
32 - this.token = token
33 - localStorage.setItem('token', token)
34 - },
35 -
36 - // 清除 token
37 - clearToken() {
38 - this.token = ''
39 - localStorage.removeItem('token')
40 - },
41 -
42 - // 设置用户信息
43 - setUserInfo(userInfo) {
44 - this.userInfo = userInfo
45 - this.isLogin = true
46 - },
47 -
48 - // 清除用户信息
49 - clearUserInfo() {
50 - this.userInfo = null
51 - this.isLogin = false
52 - },
53 -
54 - // 登录
55 - async login(loginData) {
56 - const response = await userApi.login(loginData)
57 - const { token, userInfo } = response.data
58 -
59 - this.setToken(token)
60 - this.setUserInfo(userInfo)
61 -
62 - return response
63 - },
64 -
65 - // 获取用户信息
66 - async getUserInfo() {
67 - try {
68 - const response = await userApi.getUserInfo()
69 - this.setUserInfo(response.data)
70 - return response
71 - } catch (error) {
72 - // 如果获取用户信息失败,清除本地存储
73 - this.logout()
74 - throw error
75 - }
76 - },
77 -
78 - // 登出
79 - async logout() {
80 - try {
81 - await userApi.logout()
82 - } catch (error) {
83 - console.error('登出失败:', error)
84 - } finally {
85 - this.clearToken()
86 - this.clearUserInfo()
87 - }
88 - }
89 - }
90 -})
...\ No newline at end of file ...\ No newline at end of file
...@@ -121,6 +121,15 @@ ...@@ -121,6 +121,15 @@
121 </div> 121 </div>
122 </div> 122 </div>
123 123
124 + <div style="background: #E5DAC2; padding-bottom: 1rem;">
125 + <div class="photo-section" @click="handlePhotoClick">
126 + <div class="photo-content">
127 + <div class="photo-title">照片查询下载</div>
128 + <div class="more-button"><span class="more-text">身份验证</span></div>
129 + </div>
130 + </div>
131 + </div>
132 +
124 <!-- 相关新闻 --> 133 <!-- 相关新闻 -->
125 <div class="news-section"> 134 <div class="news-section">
126 <!-- 标题 --> 135 <!-- 标题 -->
...@@ -175,6 +184,7 @@ import VideoPlayer from '@/components/VideoPlayer.vue' ...@@ -175,6 +184,7 @@ import VideoPlayer from '@/components/VideoPlayer.vue'
175 import { useTitle } from '@vueuse/core'; 184 import { useTitle } from '@vueuse/core';
176 import { useRouter } from 'vue-router' 185 import { useRouter } from 'vue-router'
177 import { showToast } from 'vant' 186 import { showToast } from 'vant'
187 +import Cookies from 'js-cookie'
178 188
179 // 导入接口 189 // 导入接口
180 import { homePageAPI } from '@/api/index.js' 190 import { homePageAPI } from '@/api/index.js'
...@@ -637,6 +647,23 @@ const showSwipeHint = ref(true) ...@@ -637,6 +647,23 @@ const showSwipeHint = ref(true)
637 const dismissSwipeHint = () => { 647 const dismissSwipeHint = () => {
638 showSwipeHint.value = false 648 showSwipeHint.value = false
639 } 649 }
650 +
651 +/**
652 + * 处理照片入口点击
653 + * 说明:未登录跳转登录页;已登录进入戒子信息页面。
654 + * @returns {void}
655 + */
656 +// 登录状态获取
657 +const hasLogin = computed(() => Boolean(Cookies.get('token-stdj')))
658 +
659 +const handlePhotoClick = () => {
660 + // 判断是否已登录
661 + if (hasLogin.value) {
662 + router.push('/studentInfo')
663 + } else {
664 + router.push('/login')
665 + }
666 +}
640 </script> 667 </script>
641 668
642 <style scoped> 669 <style scoped>
...@@ -735,6 +762,50 @@ const dismissSwipeHint = () => { ...@@ -735,6 +762,50 @@ const dismissSwipeHint = () => {
735 background: #E5DAC2; 762 background: #E5DAC2;
736 } 763 }
737 764
765 +/* 照片查询下载模块样式 */
766 +.photo-section {
767 + margin: 0 1rem;
768 + height: 3.5rem;
769 + border-radius: 1rem;
770 + background-image: url('https://cdn.ipadbiz.cn/stdj/images/%E7%85%A7%E7%89%87%E4%B8%8B%E8%BD%BD@2x.png');
771 + background-size: cover;
772 + background-position: center;
773 + background-repeat: no-repeat;
774 + display: flex;
775 + align-items: center;
776 + justify-content: center;
777 + position: relative;
778 +}
779 +
780 +.photo-content {
781 + width: 100%;
782 + padding: 0 1.5rem;
783 + display: flex;
784 + align-items: center;
785 + justify-content: space-between;
786 +}
787 +
788 +.photo-title {
789 + color: #FFFFFF;
790 + font-size: 1rem; /* 约18px,符合移动端标题视觉 */
791 + font-weight: 600;
792 + text-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.35);
793 +}
794 +
795 +.photo-section .more-button {
796 + position: absolute;
797 + top: 50%;
798 + right: 1.5rem;
799 + left: auto;
800 + bottom: auto;
801 + height: 2rem; /* 复用查看更多按钮风格并调整高度 */
802 + transform: translateY(-50%);
803 +}
804 +
805 +.photo-section .more-text {
806 + font-size: 0.75rem;
807 +}
808 +
738 /* 通用标题样式 */ 809 /* 通用标题样式 */
739 .common-title { 810 .common-title {
740 text-align: center; 811 text-align: center;
......
1 +<!--
2 + * @Date: 2025-11-10 18:08:59
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2025-11-11 13:31:30
5 + * @FilePath: /stdj_h5/src/views/Login.vue
6 + * @Description: 登录页
7 +-->
8 +<template>
9 + <div class="login-page">
10 + <!-- 顶部LOGO标题 -->
11 + <div class="logo-title">
12 + <img class="logo-img" src="https://cdn.ipadbiz.cn/stdj/images/logo@2x.png" alt="Logo">
13 + </div>
14 +
15 + <!-- 戒子身份验证容器 -->
16 + <div class="auth-card">
17 + <div class="card-title">戒子身份验证</div>
18 +
19 + <!-- 登录表单:手机号 -->
20 + <div class="form-item">
21 + <div class="input-with-icon phone">
22 + <input class="input" type="tel" placeholder="请输入手机号" v-model="phone" maxlength="11" />
23 + </div>
24 + </div>
25 +
26 + <!-- 登录表单:验证码 + 获取验证码 -->
27 + <div class="form-item">
28 + <div class="code-row">
29 + <div class="input-with-icon code">
30 + <input class="input" type="tel" placeholder="请输入验证码" v-model="code" maxlength="6" />
31 + </div>
32 + <div class="btn send" :class="{ disabled: send_disabled }" @click="on_click_send_sms">
33 + <span v-if="countdown === 0">获取验证码</span>
34 + <span v-else>{{ countdown }}s后重试</span>
35 + </div>
36 + </div>
37 + </div>
38 +
39 + <!-- 立即验证按钮 -->
40 + <div class="form-item">
41 + <div class="btn primary" :class="{ disabled: login_disabled }" @click="on_click_login">立即验证</div>
42 + </div>
43 + </div>
44 + </div>
45 +</template>
46 +
47 +<script setup>
48 +import { ref, onUnmounted, computed } from 'vue'
49 +import { useRoute, useRouter } from 'vue-router'
50 +import { useTitle } from '@vueuse/core'
51 +import { smsAPI } from '@/api/common.js'
52 +import { loginAPI } from '@/api/index.js'
53 +import { showToast, showFailToast, showSuccessToast } from 'vant'
54 +import Cookies from 'js-cookie'
55 +
56 +useTitle('戒子身份验证')
57 +
58 +const route = useRoute()
59 +const router = useRouter()
60 +
61 +// 表单状态
62 +const phone = ref('')
63 +const code = ref('')
64 +const countdown = ref(0)
65 +const timer_id = ref(null)
66 +const sending = ref(false)
67 +const logging = ref(false)
68 +
69 +/**
70 + * 发送验证码按钮禁用条件
71 + * 说明:倒计时进行中或正在发送或手机号无效时不可操作
72 + */
73 +const send_disabled = computed(function () {
74 + return countdown.value > 0 || sending.value || !is_valid_phone(phone.value)
75 +})
76 +
77 +/**
78 + * 登录按钮禁用条件
79 + * 说明:登录进行中或手机号/验证码未通过校验时不可操作
80 + */
81 +const login_disabled = computed(function () {
82 + return (
83 + logging.value ||
84 + !is_valid_phone(phone.value) ||
85 + !/^\d{4,6}$/.test(String(code.value || '').trim())
86 + )
87 +})
88 +
89 +/**
90 + * 点击发送验证码包装函数
91 + * 说明:在禁用态下不触发真实发送逻辑
92 + * @returns {void}
93 + */
94 +const on_click_send_sms = function () {
95 + if (send_disabled.value) return
96 + on_send_sms()
97 +}
98 +
99 +/**
100 + * 点击登录包装函数
101 + * 说明:在禁用态下不触发真实登录逻辑
102 + * @returns {void}
103 + */
104 +const on_click_login = function () {
105 + if (login_disabled.value) return
106 + on_login()
107 +}
108 +
109 +/**
110 + * 校验手机号格式
111 + * 说明:仅支持中国大陆11位手机号校验
112 + * @param {string} v 手机号
113 + * @returns {boolean} 是否有效
114 + */
115 +const is_valid_phone = function (v) {
116 + return /^1\d{10}$/.test(String(v || '').trim())
117 +}
118 +
119 +/**
120 + * 启动60秒倒计时
121 + * 说明:发送验证码成功后启动,期间禁用发送按钮
122 + * @returns {void}
123 + */
124 +const start_countdown = function () {
125 + countdown.value = 60
126 + if (timer_id.value) {
127 + clearInterval(timer_id.value)
128 + timer_id.value = null
129 + }
130 + timer_id.value = setInterval(function () {
131 + countdown.value = countdown.value - 1
132 + if (countdown.value <= 0) {
133 + clearInterval(timer_id.value)
134 + timer_id.value = null
135 + countdown.value = 0
136 + }
137 + }, 1000)
138 +}
139 +
140 +/**
141 + * 发送验证码
142 + * 说明:调用接口,成功后开始倒计时
143 + * @returns {Promise<void>}
144 + */
145 +const on_send_sms = async function () {
146 + if (sending.value || countdown.value > 0) return
147 + if (!is_valid_phone(phone.value)) {
148 + showFailToast('请输入有效的手机号')
149 + return
150 + }
151 + try {
152 + sending.value = true
153 + const { code } = await smsAPI({ phone: phone.value })
154 + if (code) {
155 + showToast('验证码已发送')
156 + start_countdown()
157 + }
158 + } catch (e) {
159 + showFailToast('网络异常,请稍后重试')
160 + } finally {
161 + sending.value = false
162 + }
163 +}
164 +
165 +/**
166 + * 登录
167 + * @returns {Promise<void>}
168 + */
169 +const on_login = async function () {
170 + if (logging.value) return
171 + if (!is_valid_phone(phone.value)) {
172 + showFailToast('请输入有效的手机号')
173 + return
174 + }
175 + if (!/^\d{4,6}$/.test(String(code.value || '').trim())) {
176 + showFailToast('请输入4~6位数字验证码')
177 + return
178 + }
179 + try {
180 + logging.value = true
181 + const { code, data } = await loginAPI({ phone: phone.value, code: code.value })
182 + if (code) {
183 + // 登录成功后,将token存储到cookie中
184 + Cookies.set('token-stdj', data.token, { expires: 7 })
185 + showSuccessToast('登录成功')
186 + // 跳转戒子详情页
187 + router.replace({ path: route.query.redirect })
188 + }
189 + } catch (e) {
190 + showFailToast('网络异常,请稍后重试')
191 + } finally {
192 + logging.value = false
193 + }
194 +}
195 +
196 +// 组件卸载时清理计时器
197 +onUnmounted(function () {
198 + if (timer_id.value) {
199 + clearInterval(timer_id.value)
200 + timer_id.value = null
201 + }
202 +})
203 +</script>
204 +
205 +<style lang="less" scoped>
206 +// 页面背景与布局
207 +.login-page {
208 + min-height: 100vh;
209 + background-color: #FCF8F1; // 整个页面背景色
210 + display: flex;
211 + flex-direction: column;
212 + align-items: center;
213 + padding: 6rem 1rem;
214 + background-image: url('https://cdn.ipadbiz.cn/stdj/images/bg002@2x.png');
215 + background-size: cover;
216 + background-position: center;
217 +}
218 +
219 +// 顶部Logo标题
220 +.logo-title {
221 + margin-bottom: 2rem;
222 +}
223 +.logo-img {
224 + height: 5.5rem;
225 + object-fit: contain;
226 + margin-top: 1rem;
227 +}
228 +
229 +// 验证容器
230 +.auth-card {
231 + max-width: 26rem;
232 + background-color: rgba(174, 155, 99, 0.14); // 容器背景色
233 + border-radius: 0.75rem;
234 + padding: 1rem;
235 + box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.06);
236 +}
237 +.card-title {
238 + text-align: center;
239 + color: #432C0E;
240 + font-size: 1.125rem;
241 + font-weight: 700;
242 + margin-top: 0.75rem;
243 + margin-bottom: 1.25rem;
244 +}
245 +
246 +// 表单项布局
247 +.form-item {
248 + margin-top: 1.25rem;
249 + margin-bottom: 0.75rem;
250 +}
251 +.code-row {
252 + display: flex;
253 + gap: 0.5rem;
254 + align-items: center;
255 +}
256 +
257 +// 输入框:带左侧图标
258 +.input-with-icon {
259 + position: relative;
260 +}
261 +.input-with-icon::before {
262 + content: '';
263 + position: absolute;
264 + left: 0.75rem;
265 + top: 50%;
266 + width: 1rem;
267 + height: 1rem;
268 + background-size: contain;
269 + background-position: center;
270 + background-repeat: no-repeat;
271 + transform: translateY(-50%);
272 +}
273 +.input-with-icon.phone::before {
274 + background-image: url('https://cdn.ipadbiz.cn/stdj/images/%E6%89%8B%E6%9C%BA@2x.png');
275 +}
276 +.input-with-icon.code::before {
277 + background-image: url('https://cdn.ipadbiz.cn/stdj/images/%E9%AA%8C%E8%AF%81%E7%A0%81@2x-1.png');
278 +}
279 +
280 +.input {
281 + width: 100%;
282 + height: 2.5rem;
283 + border-radius: 0.5rem;
284 + border: none;
285 + background-color: #FFFFFF;
286 + padding: 0 0.75rem 0 2.25rem; // 为左侧图标预留空间
287 + box-shadow: inset 0 0 0 0.0625rem rgba(0, 0, 0, 0.08);
288 +}
289 +.input::placeholder {
290 + color: #999999;
291 +}
292 +
293 +// 按钮样式
294 +.btn {
295 + height: 2.5rem;
296 + padding: 0 0.75rem;
297 + border: none;
298 + border-radius: 0.5rem;
299 + background-color: #A67939; // 获取验证码与立即验证背景色
300 + color: #FFFFFF;
301 + font-weight: 600;
302 + text-align: center;
303 + line-height: 2.5rem;
304 +}
305 +.btn.send {
306 + white-space: nowrap;
307 +}
308 +.btn.primary {
309 + width: 100%;
310 +}
311 +.btn.disabled {
312 + // 不可操作态:明显的灰显与禁止样式
313 + opacity: 0.5;
314 + cursor: not-allowed;
315 + pointer-events: none;
316 + filter: grayscale(30%);
317 + box-shadow: none;
318 +}
319 +</style>
1 +<!--
2 + * @Date: 2025-11-10 18:12:23
3 + * @LastEditors: hookehuyr hookehuyr@gmail.com
4 + * @LastEditTime: 2025-11-11 13:04:06
5 + * @FilePath: /stdj_h5/src/views/StudentInfo.vue
6 + * @Description: 戒子详情页
7 +-->
8 +<template>
9 + <div class="masters-detail-container">
10 + <section class="single-list">
11 + <div class="item-card">
12 + <img :src="article_item.src" :alt="article_item.title" class="item-image" />
13 + <div class="item-content">
14 + <div class="item-role">{{ article_item.role }}</div>
15 + <div class="item-name" v-html="formatNameWithSuperscript(article_item.name)"></div>
16 + <div class="item-desc" v-html="article_item.desc"></div>
17 + </div>
18 + </div>
19 + </section>
20 + <!-- 标题图片 -->
21 + <div class="common-title">
22 + <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">
23 + </div>
24 + <!-- 下载提示:参考首页滑动提示样式 -->
25 + <div class="download-hint">
26 + <span class="download-icon">⬇</span>
27 + <span class="download-text">点击查看大图 长按图片下载</span>
28 + </div>
29 + <!-- 瀑布流内容 -->
30 + <div class="waterfall-content">
31 + <van-list
32 + v-model:loading="loading"
33 + :finished="finished"
34 + finished-text="没有更多了"
35 + @load="onLoad"
36 + >
37 + <div class="waterfall-container">
38 + <div class="waterfall-column" v-for="(column, index) in columns" :key="index">
39 + <div
40 + class="waterfall-item"
41 + v-for="item in column"
42 + :key="item.id"
43 + @click="onImageClick(item)"
44 + >
45 + <div class="image-wrapper">
46 + <img
47 + :src="item.src"
48 + :alt="item.title"
49 + :style="{ height: item.height + 'px' }"
50 + @load="onImageLoad"
51 + @error="onImageError"
52 + />
53 + <!-- <div class="image-overlay">
54 + <span class="image-title">{{ item.title }}</span>
55 + </div> -->
56 + </div>
57 + </div>
58 + </div>
59 + </div>
60 + </van-list>
61 + </div>
62 + </div>
63 +</template>
64 +
65 +<script setup>
66 +import { ref, onMounted } from 'vue'
67 +import { useTitle } from '@vueuse/core';
68 +import { useRoute, useRouter } from 'vue-router'
69 +import { showImagePreview } from 'vant'
70 +
71 +// 导入接口
72 +import { getArticleDetailAPI, getImgStreamAPI } from '@/api/index.js'
73 +
74 +useTitle('戒子详情')
75 +
76 +const route = useRoute()
77 +const router = useRouter()
78 +
79 +const article_item = ref({})
80 +
81 +/**
82 + * 为name字段的第一个文字添加上标效果
83 + * @param {string} name - 原始姓名
84 + * @returns {string} - 带上标的HTML字符串
85 + */
86 +const formatNameWithSuperscript = (name) => {
87 + if (!name || name.length === 0) return name
88 +
89 + const firstChar = name.charAt(0)
90 + const restChars = name.slice(1)
91 +
92 + return `<sup style="font-size: 0.6rem;">上</sup>${firstChar}<sup style="font-size: 0.6rem;">下</sup>${restChars}`
93 +}
94 +
95 +/**
96 + * 加载文章详情
97 + */
98 +const loadArticleDetail = async () => {
99 + try {
100 + // const articleId = route.params.id
101 + const articleId = '3662153'
102 + const { code, data } = await getArticleDetailAPI({ i: articleId })
103 + if (code) {
104 + // 遍历data对象,将每个元素转换为新的对象格式
105 + article_item.value = {
106 + id: data.id,
107 + role: data.post_excerpt,
108 + name: data.post_title,
109 + src: data?.file_list?.photo?.value,
110 + desc: data.post_content
111 + }
112 + }
113 + } catch (error) {
114 + console.error('加载文章详情失败:', error)
115 + }
116 +}
117 +
118 +// const i = ref(router.currentRoute.value.query.i)
119 +const i = ref('3680502')
120 +
121 +// 响应式数据
122 +const loading = ref(false)
123 +const finished = ref(false)
124 +const currentPage = ref(0) // API使用0作为起始页码
125 +const pageSize = 10
126 +const allImages = ref([])
127 +const columns = reactive([[], []])
128 +
129 +// 加载数据
130 +const onLoad = async () => {
131 + loading.value = true
132 +
133 + try {
134 + // 调用正式接口
135 + const { code, data } = await getImgStreamAPI({
136 + i: i.value,
137 + page: currentPage.value,
138 + limit: pageSize
139 + })
140 +
141 + if (code) {
142 + const newData = data.list.map(item => ({
143 + id: item.id,
144 + src: item.value, // 修改为src,用于ImagePreview
145 + title: item.name,
146 + description: item.description,
147 + date: item.post_date,
148 + height: Math.floor(Math.random() * 200) + 200 // 随机高度用于瀑布流布局
149 + }))
150 +
151 + if (newData.length === 0) {
152 + finished.value = true
153 + } else {
154 + allImages.value.push(...newData)
155 + distributeImages(newData)
156 + currentPage.value++
157 + }
158 + } else {
159 + finished.value = true
160 + }
161 + } catch (error) {
162 + console.error('加载数据失败:', error)
163 + finished.value = true
164 + } finally {
165 + loading.value = false
166 + }
167 +}
168 +
169 +// 分配图片到两列
170 +const distributeImages = (images) => {
171 + images.forEach(image => {
172 + // 计算两列的当前高度
173 + const leftHeight = columns[0].reduce((sum, item) => sum + item.height + 20, 0)
174 + const rightHeight = columns[1].reduce((sum, item) => sum + item.height + 20, 0)
175 +
176 + // 将图片添加到高度较小的列
177 + if (leftHeight <= rightHeight) {
178 + columns[0].push(image)
179 + } else {
180 + columns[1].push(image)
181 + }
182 + })
183 +}
184 +
185 +/**
186 + * 显示图片预览(仅单张)
187 + * @param {Object} item - 当前点击的图片对象
188 + */
189 +// 图片点击事件 - 仅预览当前单张图片
190 +const onImageClick = (item) => {
191 + console.log('点击图片:', item)
192 +
193 + // 获取当前点击图片在所有图片中的索引
194 + const currentIndex = allImages.value.findIndex(img => img.id === item.id)
195 +
196 + // 提取所有图片的src用于预览
197 + const images = allImages.value.map(img => img.src)
198 +
199 + // 仅预览当前单张图片,禁用索引与循环
200 + showImagePreview({
201 + images: [images[currentIndex]],
202 + startPosition: 0,
203 + showIndex: false,
204 + loop: false,
205 + closeable: true
206 + })
207 +}
208 +
209 +// 图片加载成功
210 +const onImageLoad = (event) => {
211 + console.log('图片加载成功:', event.target.src)
212 +}
213 +
214 +// 图片加载失败
215 +const onImageError = (event) => {
216 + console.error('图片加载失败:', event.target.src)
217 + // 可以设置默认图片
218 + event.target.src = 'https://via.placeholder.com/300x400?text=加载失败'
219 +}
220 +
221 +onMounted(() => {
222 + loadArticleDetail()
223 + // 初始加载第一页数据
224 + onLoad()
225 +})
226 +</script>
227 +
228 +
229 +<style scoped>
230 +/* 通用标题样式 */
231 +.common-title {
232 + text-align: center;
233 + margin-bottom: 2rem;
234 + position: relative;
235 + z-index: 3;
236 + margin-top: 2rem;
237 +}
238 +
239 +.common-title img {
240 + max-width: 10rem;
241 + height: auto;
242 +}
243 +
244 +/* 下载提示样式(参考首页 swipe-hint) */
245 +.download-hint {
246 + display: flex;
247 + align-items: center;
248 + justify-content: center;
249 + gap: 0.35rem;
250 + margin: -1rem auto 1.25rem;
251 + color: #985122;
252 + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.08);
253 + font-weight: 700;
254 +}
255 +.download-icon {
256 + font-size: 0.875rem;
257 + line-height: 1;
258 + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
259 +}
260 +.download-text {
261 + font-size: 0.875rem;
262 + font-weight: 700;
263 + line-height: 1;
264 + letter-spacing: 0.02rem;
265 +}
266 +
267 +@media (max-width: 30rem) {
268 + .download-hint {
269 + margin-bottom: 1.25rem;
270 + }
271 + .download-icon,
272 + .download-text {
273 + font-size: 0.8rem;
274 + }
275 +}
276 +
277 +.masters-detail-container {
278 + padding: 1.5rem;
279 + background: #F2EBDB;
280 + min-height: 100vh;
281 + /* 背景至少覆盖整个视口高度 */
282 + width: 100%;
283 + box-sizing: border-box;
284 +}
285 +
286 +.single-list {
287 + display: flex;
288 + flex-direction: column;
289 + gap: 1rem;
290 + margin-bottom: 1rem;
291 +}
292 +
293 +.item-card {
294 + position: relative;
295 + padding: 0.5rem;
296 + background: #F2EBDB;
297 + border: 2px solid #6B4102;
298 + overflow: hidden;
299 + transition: transform 0.2s ease;
300 +}
301 +
302 +.item-image {
303 + width: 100%;
304 + height: auto;
305 + display: block;
306 +}
307 +
308 +.item-content {
309 + padding: 0.85rem;
310 + text-align: center;
311 + color: #6B4102;
312 + background: #FCF8F1;
313 +}
314 +
315 +.item-role {
316 + font-size: 0.75rem;
317 + opacity: 0.95;
318 +}
319 +
320 +.item-name {
321 + font-size: 1.25rem;
322 + font-weight: 600;
323 + margin-top: 0.25rem;
324 +}
325 +
326 +.item-desc {
327 + margin-top: 0.5rem;
328 +}
329 +
330 +.header {
331 + position: sticky;
332 + top: 0;
333 + z-index: 100;
334 + background-color: #fff;
335 +}
336 +
337 +.waterfall-content {
338 + padding: 0;
339 +}
340 +
341 +.waterfall-container {
342 + display: flex;
343 + gap: 0.75rem;
344 + align-items: flex-start;
345 +}
346 +
347 +.waterfall-column {
348 + flex: 1;
349 + display: flex;
350 + flex-direction: column;
351 + gap: 0.75rem;
352 +}
353 +
354 +.waterfall-item {
355 + background-color: #fff;
356 + border-radius: 0.5rem;
357 + overflow: hidden;
358 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
359 + transition: transform 0.2s ease, box-shadow 0.2s ease;
360 + cursor: pointer;
361 +}
362 +
363 +.waterfall-item:hover {
364 + transform: translateY(-2px);
365 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
366 +}
367 +
368 +.image-wrapper {
369 + position: relative;
370 + overflow: hidden;
371 +}
372 +
373 +.image-wrapper img {
374 + width: 100%;
375 + display: block;
376 + object-fit: cover;
377 + transition: transform 0.3s ease;
378 +}
379 +
380 +.waterfall-item:hover .image-wrapper img {
381 + transform: scale(1.05);
382 +}
383 +
384 +.image-overlay {
385 + position: absolute;
386 + bottom: 0;
387 + left: 0;
388 + right: 0;
389 + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
390 + padding: 1rem 0.75rem 0.75rem;
391 + transform: translateY(100%);
392 + transition: transform 0.3s ease;
393 +}
394 +
395 +.waterfall-item:hover .image-overlay {
396 + transform: translateY(0);
397 +}
398 +
399 +.image-title {
400 + color: #fff;
401 + font-size: 0.875rem;
402 + font-weight: 500;
403 + line-height: 1.4;
404 +}
405 +
406 +/* 加载状态样式 */
407 +:deep(.van-list__loading) {
408 + padding: 1rem;
409 + text-align: center;
410 + color: #969799;
411 +}
412 +
413 +:deep(.van-list__finished-text) {
414 + padding: 1rem;
415 + text-align: center;
416 + color: #969799;
417 + font-size: 0.875rem;
418 +}
419 +
420 +/* 响应式设计 */
421 +@media (max-width: 480px) {
422 + .waterfall-content {
423 + padding: 0;
424 + }
425 +
426 + .waterfall-container {
427 + gap: 0.5rem;
428 + }
429 +
430 + .waterfall-column {
431 + gap: 0.5rem;
432 + }
433 +
434 + .image-title {
435 + font-size: 0.8125rem;
436 + }
437 +}
438 +
439 +/* 骨架屏效果 */
440 +.waterfall-item.loading {
441 + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
442 + background-size: 200% 100%;
443 + animation: loading 1.5s infinite;
444 +}
445 +
446 +@keyframes loading {
447 + 0% {
448 + background-position: 200% 0;
449 + }
450 + 100% {
451 + background-position: -200% 0;
452 + }
453 +}
454 +
455 +/* 遮罩层样式 */
456 +:deep(.van-overlay) {
457 + display: flex;
458 + align-items: center;
459 + justify-content: center;
460 + padding: 1rem;
461 +}
462 +
463 +.overlay-content {
464 + background-color: #fff;
465 + border-radius: 1rem;
466 + max-width: 90vw;
467 + max-height: 80vh;
468 + overflow: hidden;
469 + position: relative;
470 + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
471 +}
472 +
473 +.close-btn {
474 + position: absolute;
475 + top: 1rem;
476 + right: 1rem;
477 + width: 2.5rem;
478 + height: 2.5rem;
479 + background-color: rgba(0, 0, 0, 0.5);
480 + border-radius: 50%;
481 + display: flex;
482 + align-items: center;
483 + justify-content: center;
484 + cursor: pointer;
485 + z-index: 10;
486 + transition: background-color 0.2s ease;
487 +}
488 +
489 +.close-btn:hover {
490 + background-color: rgba(0, 0, 0, 0.7);
491 +}
492 +
493 +.overlay-image-wrapper {
494 + width: 100%;
495 + max-height: 60vh;
496 + overflow: hidden;
497 + display: flex;
498 + align-items: center;
499 + justify-content: center;
500 + background-color: #f5f5f5;
501 +}
502 +
503 +.overlay-image {
504 + width: 100%;
505 + height: auto;
506 + max-height: 60vh;
507 + object-fit: contain;
508 + display: block;
509 +}
510 +
511 +.overlay-description {
512 + padding: 1.5rem;
513 + background-color: #fff;
514 +}
515 +
516 +.overlay-title {
517 + font-size: 1.25rem;
518 + font-weight: 600;
519 + color: #333;
520 + margin: 0 0 1rem 0;
521 + line-height: 1.4;
522 +}
523 +
524 +.overlay-text {
525 + font-size: 1rem;
526 + color: #666;
527 + line-height: 1.6;
528 + margin: 0;
529 + white-space: pre-wrap;
530 +}
531 +
532 +/* 移动端适配 */
533 +@media (max-width: 480px) {
534 + .overlay-content {
535 + max-width: 95vw;
536 + max-height: 85vh;
537 + border-radius: 0.75rem;
538 + }
539 +
540 + .close-btn {
541 + top: 0.75rem;
542 + right: 0.75rem;
543 + width: 2rem;
544 + height: 2rem;
545 + }
546 +
547 + .overlay-description {
548 + padding: 1rem;
549 + }
550 +
551 + .overlay-title {
552 + font-size: 1.125rem;
553 + }
554 +
555 + .overlay-text {
556 + font-size: 0.875rem;
557 + }
558 +}
559 +</style>