feat(登录): 实现登录模块及访问控制功能
添加登录页面和戒子详情页面,实现手机号验证码登录功能 在路由守卫中添加登录状态校验,未登录用户访问受保护路由将被重定向 更新API接口添加登录相关方法,移除旧的用户存储实现 在首页添加照片查询入口,根据登录状态跳转不同页面
Showing
9 changed files
with
1015 additions
and
96 deletions
| ... | @@ -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 | ... | ... |
src/stores/user.js
deleted
100644 → 0
| 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; | ... | ... |
src/views/Login.vue
0 → 100644
| 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> |
src/views/StudentInfo.vue
0 → 100644
| 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> |
-
Please register or login to post a comment