feat(yinlishi): 新增引礼师列表与详情页面功能
- 新增引礼师列表页 `/yinlishi` 和详情页 `/yinlishi/:id` 及对应路由配置 - 新增 `getArticleListAPI` 接口用于加载引礼师分类数据 - 更新首页导航逻辑,添加引礼师栏目的跳转 - 在 Masters.vue 中添加注释说明引礼师展示位置已迁移至独立页面 - 新增项目开发文档 AGENTS.md
Showing
7 changed files
with
612 additions
and
1 deletions
AGENTS.md
0 → 100644
| 1 | +# AGENTS.md | ||
| 2 | + | ||
| 3 | +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. | ||
| 4 | + | ||
| 5 | +## 项目概述 | ||
| 6 | + | ||
| 7 | +这是一个基于 Vue 3 + Vite + Vant 4 的移动端 H5 项目(三坛大戒),用于展示佛教戒期相关信息。 | ||
| 8 | + | ||
| 9 | +## 常用命令 | ||
| 10 | + | ||
| 11 | +```bash | ||
| 12 | +# 开发(pnpm/npm/yarn) | ||
| 13 | +pnpm dev # 启动开发服务器(默认端口 5173) | ||
| 14 | +pnpm build # 构建生产版本 | ||
| 15 | +pnpm lint # ESLint 检查并修复 | ||
| 16 | +pnpm serve # 预览构建产物 | ||
| 17 | + | ||
| 18 | +# 部署(依赖 qshell 和 ssh) | ||
| 19 | +pnpm deploy # 执行完整部署流程:构建 -> 七牛上传 -> 服务器打包上传 | ||
| 20 | +bash scripts/deploy.sh # 直接执行部署脚本 | ||
| 21 | +``` | ||
| 22 | + | ||
| 23 | +## 项目架构 | ||
| 24 | + | ||
| 25 | +### 全局 Loading 机制 | ||
| 26 | + | ||
| 27 | +项目使用计数器方式管理全局加载状态,优化低网速体验: | ||
| 28 | + | ||
| 29 | +- **状态管理**:`src/stores/loading.js` - 使用 `pending_count` 记录进行中的请求数 | ||
| 30 | +- **拦截器集成**:`src/utils/axios.js` - 请求开始 +1,响应成功 -1,错误时重置 | ||
| 31 | +- **UI 展示**:`src/App.vue` - 全屏蒙版 + van-loading 组件 | ||
| 32 | + | ||
| 33 | +**特殊 API**:图片流媒体接口 `getImgStreamAPI` 配置了 `{ ignore_loading: true }`,不触发全局 loading。 | ||
| 34 | + | ||
| 35 | +**路由级加载**:`src/router/index.js` 中路由守卫也会控制 loading,避免懒加载白屏。 | ||
| 36 | + | ||
| 37 | +### 登录态控制 | ||
| 38 | + | ||
| 39 | +- **Token 存储**:使用 `js-cookie` 存储 `token-stdj` | ||
| 40 | +- **路由守卫**:除 `['/', '/jz_login']` 白名单外,所有页面需登录 | ||
| 41 | +- **未登录处理**:重定向到 `/jz_login` 并携带 `redirect` 参数 | ||
| 42 | + | ||
| 43 | +### API 层设计 | ||
| 44 | + | ||
| 45 | +- **API 定义**:`src/api/index.js` - 统一定义接口路径和 JSDoc | ||
| 46 | +- **请求封装**:`src/api/fn.js` - 提供 `fn()` 处理响应、`fetch` 基于 axios | ||
| 47 | +- **默认参数**:所有请求自动携带 `f: 'stdj'` 和 `timestamp`(GET) | ||
| 48 | + | ||
| 49 | +**API 响应约定**: | ||
| 50 | +```javascript | ||
| 51 | +// 成功响应格式(res.code === 1) | ||
| 52 | +{ code: 1, data: {...}, msg: 'success' } | ||
| 53 | + | ||
| 54 | +// 错误处理使用 showFailToast | ||
| 55 | +``` | ||
| 56 | + | ||
| 57 | +### 目录结构 | ||
| 58 | + | ||
| 59 | +``` | ||
| 60 | +src/ | ||
| 61 | +├── api/ # API 接口定义 | ||
| 62 | +│ ├── index.js # 主接口(首页、文章、登录、用户信息) | ||
| 63 | +│ ├── fn.js # 请求封装与错误处理 | ||
| 64 | +│ ├── common.js # 通用接口 | ||
| 65 | +│ └── wx/ # 微信相关(支付、配置等) | ||
| 66 | +├── assets/ # 静态资源 | ||
| 67 | +├── components/ # 通用组件(VideoPlayer、LoadingSpinner、EmptyState) | ||
| 68 | +├── router/ # Vue Router 配置(含登录守卫) | ||
| 69 | +├── stores/ # Pinia 状态管理(loading store) | ||
| 70 | +├── utils/ # 工具函数 | ||
| 71 | +│ ├── axios.js # Axios 拦截器(接入 loading 计数) | ||
| 72 | +│ ├── request.js # Axios 实例导出 | ||
| 73 | +│ ├── upload.js # 上传工具 | ||
| 74 | +│ └── vconsole.js # 调试控制台 | ||
| 75 | +├── views/ # 页面组件 | ||
| 76 | +└── main.js # 入口文件 | ||
| 77 | +``` | ||
| 78 | + | ||
| 79 | +## 关键配置 | ||
| 80 | + | ||
| 81 | +### 移动端适配 | ||
| 82 | + | ||
| 83 | +- 使用 `postcss-px-to-viewport`,设计稿基准 **375px** | ||
| 84 | +- 选择器添加 `ignore-` 前缀可跳过转换 | ||
| 85 | + | ||
| 86 | +### 路径别名 | ||
| 87 | + | ||
| 88 | +```javascript | ||
| 89 | +@ → src/ | ||
| 90 | +@components → src/components/ | ||
| 91 | +@utils → src/utils/ | ||
| 92 | +@api → src/api/ | ||
| 93 | +@assets → src/assets/ | ||
| 94 | +@views → src/views/ | ||
| 95 | +@stores → src/stores/ | ||
| 96 | +``` | ||
| 97 | + | ||
| 98 | +### 环境变量 | ||
| 99 | + | ||
| 100 | +- `.env` - 默认配置 | ||
| 101 | +- `.env.development` - 开发环境 | ||
| 102 | +- `.env.production` - 生产环境 | ||
| 103 | + | ||
| 104 | +**关键变量**: | ||
| 105 | +- `VITE_BASE` - 资源公共路径 | ||
| 106 | +- `VITE_PROXY_TARGET` - 代理目标服务器 | ||
| 107 | +- `VITE_PROXY_PREFIX` - 代理前缀(默认 `/srv/`) | ||
| 108 | +- `VITE_OUTDIR` - 构建输出目录(默认 `dist`) | ||
| 109 | + | ||
| 110 | +### 自动导入 | ||
| 111 | + | ||
| 112 | +- **Vue API**:通过 `unplugin-auto-import` 自动导入(见 `auto-imports.d.ts`) | ||
| 113 | +- **Vant 组件**:通过 `unplugin-vue-components` 按需导入(见 `components.d.ts`) | ||
| 114 | + | ||
| 115 | +## 部署说明 | ||
| 116 | + | ||
| 117 | +部署脚本 `scripts/deploy.sh` 执行以下流程: | ||
| 118 | + | ||
| 119 | +1. **构建项目**(`pnpm run build`) | ||
| 120 | +2. **上传静态资源到七牛云**(`qshell qupload ~/.qshell/stdj_upload.conf`) | ||
| 121 | +3. **打包并上传到服务器**(`scp -P 12101` 到 `zhsy@oa.jcedu.org:/home/www/f`) | ||
| 122 | + | ||
| 123 | +**依赖**:本机需安装 `qshell` 并配置 `~/.qshell/stdj_upload.conf`。 | ||
| 124 | + | ||
| 125 | +## 开发注意事项 | ||
| 126 | + | ||
| 127 | +1. **新增 API**:在 `src/api/index.js` 定义接口,使用 `fn(fetch.get/post())` 封装 | ||
| 128 | +2. **跳过全局 Loading**:请求配置添加 `{ ignore_loading: true }` | ||
| 129 | +3. **路由配置**:新页面如需登录,无需添加白名单(默认需登录);公开页面添加到 `white_list` | ||
| 130 | +4. **组件命名**:文件名 kebab-case,组件名 PascalCase |
| ... | @@ -72,6 +72,20 @@ export const homePageAPI = (params) => fn(fetch.get(Api.GET_HOME, params)); | ... | @@ -72,6 +72,20 @@ export const homePageAPI = (params) => fn(fetch.get(Api.GET_HOME, params)); |
| 72 | export const getSSQZAPI = (params) => fn(fetch.get(Api.GET_ARTICLE, params)); | 72 | export const getSSQZAPI = (params) => fn(fetch.get(Api.GET_ARTICLE, params)); |
| 73 | 73 | ||
| 74 | /** | 74 | /** |
| 75 | + * @description: 文章列表数据(用于按分类加载引礼师等列表) | ||
| 76 | + * @param {String} cid 分类id | ||
| 77 | + * @param {String} [page] 页码,默认0 | ||
| 78 | + * @param {String} [limit] 条数,默认10 | ||
| 79 | + * @returns {Object} list | ||
| 80 | + * @property integer list.id 文章id | ||
| 81 | + * @property string list.post_title 标题/法名 | ||
| 82 | + * @property string list.post_date 发布时间 | ||
| 83 | + * @property string list.post_excerpt 简介/职位 | ||
| 84 | + * @property string list.photo 封面图/照片 | ||
| 85 | + */ | ||
| 86 | +export const getArticleListAPI = (params) => fn(fetch.get(Api.GET_ARTICLE, params)); | ||
| 87 | + | ||
| 88 | +/** | ||
| 75 | * @description: 图片流媒体数据 | 89 | * @description: 图片流媒体数据 |
| 76 | * @param {String} i 分类id | 90 | * @param {String} i 分类id |
| 77 | * @param {String} page 页码,默认0 | 91 | * @param {String} page 页码,默认0 | ... | ... |
| ... | @@ -31,6 +31,16 @@ const routes = [ | ... | @@ -31,6 +31,16 @@ const routes = [ |
| 31 | component: () => import('../views/MastersDetail.vue') | 31 | component: () => import('../views/MastersDetail.vue') |
| 32 | }, | 32 | }, |
| 33 | { | 33 | { |
| 34 | + path: '/yinlishi', | ||
| 35 | + name: '引礼师', | ||
| 36 | + component: () => import('../views/YinLiShi.vue') | ||
| 37 | + }, | ||
| 38 | + { | ||
| 39 | + path: '/yinlishi/:id', | ||
| 40 | + name: '引礼师详情', | ||
| 41 | + component: () => import('../views/YinLiShiDetail.vue') | ||
| 42 | + }, | ||
| 43 | + { | ||
| 34 | path: '/news/:id', | 44 | path: '/news/:id', |
| 35 | name: 'NewsDetail', | 45 | name: 'NewsDetail', |
| 36 | component: () => import('../views/NewsDetail.vue') | 46 | component: () => import('../views/NewsDetail.vue') | ... | ... |
| ... | @@ -130,7 +130,7 @@ | ... | @@ -130,7 +130,7 @@ |
| 130 | <img src="https://cdn.ipadbiz.cn/stdj/images/home/%E4%B8%B4%E5%9D%9B%E5%8D%81%E5%B8%88@2x.png" alt="临坛十师" class="title-image"> | 130 | <img src="https://cdn.ipadbiz.cn/stdj/images/home/%E4%B8%B4%E5%9D%9B%E5%8D%81%E5%B8%88@2x.png" alt="临坛十师" class="title-image"> |
| 131 | </div> | 131 | </div> |
| 132 | 132 | ||
| 133 | - <!-- 三个栏目 --> | 133 | + <!-- 多个栏目 --> |
| 134 | <div class="three-columns"> | 134 | <div class="three-columns"> |
| 135 | <!-- 临坛十师栏目 --> | 135 | <!-- 临坛十师栏目 --> |
| 136 | <div class="column-item" v-for="(item, index) in mastersList" :key="index"> | 136 | <div class="column-item" v-for="(item, index) in mastersList" :key="index"> |
| ... | @@ -521,6 +521,9 @@ const viewMoreByCategory = (item) => { | ... | @@ -521,6 +521,9 @@ const viewMoreByCategory = (item) => { |
| 521 | case 'STSSQZ': // 临坛十师 | 521 | case 'STSSQZ': // 临坛十师 |
| 522 | router.push(`/masters?pid=${item.id}`) | 522 | router.push(`/masters?pid=${item.id}`) |
| 523 | break | 523 | break |
| 524 | + case 'STYLS': // 引礼师 | ||
| 525 | + router.push(`/yinlishi?cid=${item.id}`) | ||
| 526 | + break | ||
| 524 | case 'STJZ': // 戒子 | 527 | case 'STJZ': // 戒子 |
| 525 | router.push(`${item.category_link}`) | 528 | router.push(`${item.category_link}`) |
| 526 | break | 529 | break | ... | ... |
| ... | @@ -96,6 +96,8 @@ const singleItems = ref([]) | ... | @@ -96,6 +96,8 @@ const singleItems = ref([]) |
| 96 | const gridItems = ref([]) | 96 | const gridItems = ref([]) |
| 97 | 97 | ||
| 98 | // 引礼师 | 98 | // 引礼师 |
| 99 | +// 说明:展示位置已迁移到独立的“引礼师”页面(/yinlishi)。 | ||
| 100 | +// 这里先保留旧字段结构与取值注释,避免后续回查旧接口映射时丢失上下文。 | ||
| 99 | const gridItems2 = ref([]) | 101 | const gridItems2 = ref([]) |
| 100 | 102 | ||
| 101 | const goDetail = (item) => { | 103 | const goDetail = (item) => { | ... | ... |
src/views/YinLiShi.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="yinlishi-container"> | ||
| 3 | + <section class="grid-two"> | ||
| 4 | + <div | ||
| 5 | + class="item-card" | ||
| 6 | + v-for="(item, index) in guideList" | ||
| 7 | + :key="`${item.id}-${index}`" | ||
| 8 | + @click="goDetail(item)" | ||
| 9 | + > | ||
| 10 | + <div class="item-image"> | ||
| 11 | + <img | ||
| 12 | + :src="item.image" | ||
| 13 | + :alt="`${item.role} ${item.name}`" | ||
| 14 | + :aria-label="`${item.role}:${item.name}`" | ||
| 15 | + loading="lazy" | ||
| 16 | + /> | ||
| 17 | + </div> | ||
| 18 | + <div class="item-caption"> | ||
| 19 | + <div class="item-role">{{ item.role }}</div> | ||
| 20 | + <div class="item-name"> | ||
| 21 | + <sup class="name-sup">上</sup>{{ item.name.charAt(0) }}<sup class="name-sup">下</sup>{{ item.name.slice(1) }} | ||
| 22 | + </div> | ||
| 23 | + </div> | ||
| 24 | + </div> | ||
| 25 | + </section> | ||
| 26 | + </div> | ||
| 27 | +</template> | ||
| 28 | + | ||
| 29 | +<script setup> | ||
| 30 | +import { ref, onMounted } from 'vue' | ||
| 31 | +import { useRouter } from 'vue-router' | ||
| 32 | +import { useTitle } from '@vueuse/core' | ||
| 33 | + | ||
| 34 | +import { getArticleListAPI } from '@/api/index.js' | ||
| 35 | + | ||
| 36 | +const router = useRouter() | ||
| 37 | + | ||
| 38 | +useTitle('引礼师') | ||
| 39 | + | ||
| 40 | +const cid = ref(router.currentRoute.value.query.cid || '') | ||
| 41 | +const guideList = ref([]) | ||
| 42 | + | ||
| 43 | +/** | ||
| 44 | + * 处理图片地址,避免空图导致布局塌陷 | ||
| 45 | + * @param {string} photo 原始图片地址 | ||
| 46 | + * @returns {string} 处理后的图片地址 | ||
| 47 | + */ | ||
| 48 | +const processImageUrl = (photo) => { | ||
| 49 | + if (!photo) { | ||
| 50 | + return '' | ||
| 51 | + } | ||
| 52 | + return `${photo}?imageMogr2/thumbnail/400x/strip/quality/70` | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +/** | ||
| 56 | + * 规范化引礼师列表项,统一页面渲染字段 | ||
| 57 | + * @param {object} item 接口返回项 | ||
| 58 | + * @returns {object} 页面展示项 | ||
| 59 | + */ | ||
| 60 | +const processGuideItem = (item) => ({ | ||
| 61 | + id: item?.id || '', | ||
| 62 | + role: item?.post_excerpt || '', | ||
| 63 | + name: item?.post_title || '未知', | ||
| 64 | + image: processImageUrl(item?.photo) | ||
| 65 | +}) | ||
| 66 | + | ||
| 67 | +const goDetail = (item) => { | ||
| 68 | + router.push(`/yinlishi/${item.id}`) | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +/** | ||
| 72 | + * 加载引礼师文章列表 | ||
| 73 | + * @returns {Promise<void>} | ||
| 74 | + */ | ||
| 75 | +const loadGuideList = async () => { | ||
| 76 | + try { | ||
| 77 | + const { code, list } = await getArticleListAPI({ | ||
| 78 | + cid: cid.value, | ||
| 79 | + page: 0, | ||
| 80 | + limit: 50 | ||
| 81 | + }) | ||
| 82 | + | ||
| 83 | + if (!code || !Array.isArray(list)) { | ||
| 84 | + console.error('Invalid guide list response') | ||
| 85 | + guideList.value = [] | ||
| 86 | + return | ||
| 87 | + } | ||
| 88 | + | ||
| 89 | + guideList.value = list.map(processGuideItem) | ||
| 90 | + } catch (error) { | ||
| 91 | + console.error('Failed to fetch guide list:', error) | ||
| 92 | + guideList.value = [] | ||
| 93 | + } | ||
| 94 | +} | ||
| 95 | + | ||
| 96 | +onMounted(() => { | ||
| 97 | + loadGuideList() | ||
| 98 | +}) | ||
| 99 | +</script> | ||
| 100 | + | ||
| 101 | +<style scoped> | ||
| 102 | +.yinlishi-container { | ||
| 103 | + min-height: 100vh; | ||
| 104 | + padding: 1rem; | ||
| 105 | + background: #F2EBDB; | ||
| 106 | + box-sizing: border-box; | ||
| 107 | +} | ||
| 108 | + | ||
| 109 | +.grid-two { | ||
| 110 | + display: grid; | ||
| 111 | + grid-template-columns: repeat(2, 1fr); | ||
| 112 | + gap: 0.75rem; | ||
| 113 | +} | ||
| 114 | + | ||
| 115 | +.item-card { | ||
| 116 | + position: relative; | ||
| 117 | + border: 1px solid rgba(107, 65, 2, 0.8); | ||
| 118 | + overflow: hidden; | ||
| 119 | + background: #FFFFFF; | ||
| 120 | + transition: transform 0.2s ease; | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +.item-card:hover { | ||
| 124 | + transform: translateY(-0.125rem); | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +.item-image { | ||
| 128 | + width: 100%; | ||
| 129 | + aspect-ratio: 3 / 4; | ||
| 130 | + background: #f5f5f5; | ||
| 131 | +} | ||
| 132 | + | ||
| 133 | +.item-image img { | ||
| 134 | + width: 100%; | ||
| 135 | + height: 100%; | ||
| 136 | + object-fit: cover; | ||
| 137 | + display: block; | ||
| 138 | +} | ||
| 139 | + | ||
| 140 | +.item-caption { | ||
| 141 | + position: absolute; | ||
| 142 | + left: 0; | ||
| 143 | + right: 0; | ||
| 144 | + bottom: 0; | ||
| 145 | + padding: 0.75rem; | ||
| 146 | + text-align: center; | ||
| 147 | + color: #FFFFFF; | ||
| 148 | + background: rgba(107, 65, 2, 0.8); | ||
| 149 | +} | ||
| 150 | + | ||
| 151 | +.item-role { | ||
| 152 | + font-size: 0.75rem; | ||
| 153 | + opacity: 0.95; | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +.item-name { | ||
| 157 | + margin-top: 0.25rem; | ||
| 158 | + font-size: 1.25rem; | ||
| 159 | + font-weight: 600; | ||
| 160 | +} | ||
| 161 | + | ||
| 162 | +.name-sup { | ||
| 163 | + font-size: 0.6em; | ||
| 164 | +} | ||
| 165 | + | ||
| 166 | +@media (max-width: 30rem) { | ||
| 167 | + .yinlishi-container { | ||
| 168 | + padding: 0.75rem; | ||
| 169 | + } | ||
| 170 | + | ||
| 171 | + .item-caption { | ||
| 172 | + padding: 0.5rem; | ||
| 173 | + } | ||
| 174 | +} | ||
| 175 | +</style> |
src/views/YinLiShiDetail.vue
0 → 100644
| 1 | +<template> | ||
| 2 | + <div class="yinlishi-detail-container"> | ||
| 3 | + <section class="single-list"> | ||
| 4 | + <div class="item-card"> | ||
| 5 | + <img :src="articleItem.src" :alt="articleItem.name || '引礼师详情'" class="item-image" /> | ||
| 6 | + <div class="item-content"> | ||
| 7 | + <div class="item-role">{{ articleItem.role }}</div> | ||
| 8 | + <div class="item-name" v-html="formatNameWithSuperscript(articleItem.name)"></div> | ||
| 9 | + <div class="item-desc" v-html="articleItem.desc"></div> | ||
| 10 | + </div> | ||
| 11 | + </div> | ||
| 12 | + </section> | ||
| 13 | + | ||
| 14 | + <div class="waterfall-content" v-if="columns[0].length || columns[1].length"> | ||
| 15 | + <div class="waterfall-container"> | ||
| 16 | + <div class="waterfall-column" v-for="(column, cidx) in columns" :key="cidx"> | ||
| 17 | + <div | ||
| 18 | + class="waterfall-item" | ||
| 19 | + v-for="item in column" | ||
| 20 | + :key="item.id" | ||
| 21 | + @click="onImageClick(item)" | ||
| 22 | + > | ||
| 23 | + <div class="image-wrapper"> | ||
| 24 | + <img | ||
| 25 | + :src="item.src" | ||
| 26 | + :alt="item.title" | ||
| 27 | + :style="{ height: item.height + 'px' }" | ||
| 28 | + @load="onImageLoad" | ||
| 29 | + @error="onImageError" | ||
| 30 | + /> | ||
| 31 | + </div> | ||
| 32 | + </div> | ||
| 33 | + </div> | ||
| 34 | + </div> | ||
| 35 | + </div> | ||
| 36 | + </div> | ||
| 37 | +</template> | ||
| 38 | + | ||
| 39 | +<script setup> | ||
| 40 | +import { ref, onMounted, reactive } from 'vue' | ||
| 41 | +import { useTitle } from '@vueuse/core' | ||
| 42 | +import { useRoute } from 'vue-router' | ||
| 43 | +import { showImagePreview } from 'vant' | ||
| 44 | + | ||
| 45 | +import { getArticleDetailAPI } from '@/api/index.js' | ||
| 46 | + | ||
| 47 | +useTitle('引礼师详情') | ||
| 48 | + | ||
| 49 | +const route = useRoute() | ||
| 50 | + | ||
| 51 | +const articleItem = ref({}) | ||
| 52 | +const columns = reactive([[], []]) | ||
| 53 | +const allImages = ref([]) | ||
| 54 | + | ||
| 55 | +/** | ||
| 56 | + * 为姓名的第一个字添加上标样式 | ||
| 57 | + * @param {string} name 原始姓名 | ||
| 58 | + * @returns {string} 带上标的 HTML | ||
| 59 | + */ | ||
| 60 | +const formatNameWithSuperscript = (name) => { | ||
| 61 | + if (!name || name.length === 0) return name | ||
| 62 | + | ||
| 63 | + const firstChar = name.charAt(0) | ||
| 64 | + const restChars = name.slice(1) | ||
| 65 | + | ||
| 66 | + return `<sup style="font-size: 0.6rem;">上</sup>${firstChar}<sup style="font-size: 0.6rem;">下</sup>${restChars}` | ||
| 67 | +} | ||
| 68 | + | ||
| 69 | +/** | ||
| 70 | + * 分配图片到两列 | ||
| 71 | + * @param {Array} images 新增图片列表 | ||
| 72 | + * @returns {void} | ||
| 73 | + */ | ||
| 74 | +const distributeImages = (images) => { | ||
| 75 | + images.forEach((image) => { | ||
| 76 | + const leftHeight = columns[0].reduce((sum, item) => sum + item.height + 20, 0) | ||
| 77 | + const rightHeight = columns[1].reduce((sum, item) => sum + item.height + 20, 0) | ||
| 78 | + | ||
| 79 | + if (leftHeight <= rightHeight) { | ||
| 80 | + columns[0].push(image) | ||
| 81 | + return | ||
| 82 | + } | ||
| 83 | + | ||
| 84 | + columns[1].push(image) | ||
| 85 | + }) | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +/** | ||
| 89 | + * 初始化瀑布流图片数据 | ||
| 90 | + * @param {Array} imgs 原始图片列表 | ||
| 91 | + * @returns {void} | ||
| 92 | + */ | ||
| 93 | +const initWaterfallImages = (imgs) => { | ||
| 94 | + columns[0].splice(0, columns[0].length) | ||
| 95 | + columns[1].splice(0, columns[1].length) | ||
| 96 | + | ||
| 97 | + const list = Array.isArray(imgs) ? imgs : [] | ||
| 98 | + const mapped = list.map((item, idx) => { | ||
| 99 | + const src = typeof item === 'string' ? item : (item?.value || item?.src || '') | ||
| 100 | + | ||
| 101 | + return { | ||
| 102 | + id: typeof item === 'object' && item?.id ? item.id : idx + 1, | ||
| 103 | + src: src + ((item?.size || 0) > 20 * 1024 * 1024 ? '' : '?imageMogr2/thumbnail/400x/strip/quality/70'), | ||
| 104 | + title: typeof item === 'object' && item?.name ? item.name : (`图片${idx + 1}`), | ||
| 105 | + height: Math.floor(Math.random() * 200) + 200 | ||
| 106 | + } | ||
| 107 | + }).filter((item) => String(item.src || '').trim().length > 0) | ||
| 108 | + | ||
| 109 | + allImages.value = mapped | ||
| 110 | + distributeImages(mapped) | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +/** | ||
| 114 | + * 加载文章详情 | ||
| 115 | + * @returns {Promise<void>} | ||
| 116 | + */ | ||
| 117 | +const loadArticleDetail = async () => { | ||
| 118 | + try { | ||
| 119 | + const articleId = route.params.id | ||
| 120 | + const { code, data } = await getArticleDetailAPI({ i: articleId }) | ||
| 121 | + | ||
| 122 | + if (!code) { | ||
| 123 | + return | ||
| 124 | + } | ||
| 125 | + | ||
| 126 | + articleItem.value = { | ||
| 127 | + id: data.id, | ||
| 128 | + role: data.post_excerpt, | ||
| 129 | + name: data.post_title, | ||
| 130 | + src: `${data?.file_list?.photo?.value || ''}?imageMogr2/thumbnail/400x/strip/quality/70`, | ||
| 131 | + desc: data.post_content, | ||
| 132 | + imgs: data?.file_list?.img || [] | ||
| 133 | + } | ||
| 134 | + | ||
| 135 | + initWaterfallImages(articleItem.value.imgs) | ||
| 136 | + } catch (error) { | ||
| 137 | + console.error('加载引礼师详情失败:', error) | ||
| 138 | + } | ||
| 139 | +} | ||
| 140 | + | ||
| 141 | +/** | ||
| 142 | + * 图片加载完成回调 | ||
| 143 | + * @returns {void} | ||
| 144 | + */ | ||
| 145 | +const onImageLoad = () => { | ||
| 146 | + // 预留:如需按图片实际宽高调整瀑布流高度,可在这里扩展 | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +/** | ||
| 150 | + * 图片加载失败回调 | ||
| 151 | + * @param {Event} evt 图片加载事件 | ||
| 152 | + * @returns {void} | ||
| 153 | + */ | ||
| 154 | +const onImageError = (evt) => { | ||
| 155 | + console.warn(`图片加载失败:${evt.target.src}`) | ||
| 156 | +} | ||
| 157 | + | ||
| 158 | +/** | ||
| 159 | + * 点击预览大图 | ||
| 160 | + * @param {object} item 当前图片项 | ||
| 161 | + * @returns {void} | ||
| 162 | + */ | ||
| 163 | +const onImageClick = (item) => { | ||
| 164 | + const currentIndex = allImages.value.findIndex((img) => img.id === item.id) | ||
| 165 | + const images = allImages.value.map((img) => img.src.replace('?imageMogr2/thumbnail/400x/strip/quality/70', '')) | ||
| 166 | + | ||
| 167 | + showImagePreview({ | ||
| 168 | + images, | ||
| 169 | + startPosition: currentIndex >= 0 ? currentIndex : 0, | ||
| 170 | + showIndex: true, | ||
| 171 | + closeable: true | ||
| 172 | + }) | ||
| 173 | +} | ||
| 174 | + | ||
| 175 | +onMounted(() => { | ||
| 176 | + loadArticleDetail() | ||
| 177 | +}) | ||
| 178 | +</script> | ||
| 179 | + | ||
| 180 | +<style lang="less" scoped> | ||
| 181 | +.yinlishi-detail-container { | ||
| 182 | + min-height: 100vh; | ||
| 183 | + width: 100%; | ||
| 184 | + padding: 1.5rem; | ||
| 185 | + box-sizing: border-box; | ||
| 186 | + background: #F2EBDB; | ||
| 187 | + | ||
| 188 | + .waterfall-content { | ||
| 189 | + margin-top: 1rem; | ||
| 190 | + } | ||
| 191 | + | ||
| 192 | + .waterfall-container { | ||
| 193 | + display: flex; | ||
| 194 | + gap: 0.5rem; | ||
| 195 | + align-items: flex-start; | ||
| 196 | + } | ||
| 197 | + | ||
| 198 | + .waterfall-column { | ||
| 199 | + flex: 1; | ||
| 200 | + display: flex; | ||
| 201 | + flex-direction: column; | ||
| 202 | + gap: 0.5rem; | ||
| 203 | + } | ||
| 204 | + | ||
| 205 | + .waterfall-item { | ||
| 206 | + .image-wrapper { | ||
| 207 | + width: 100%; | ||
| 208 | + overflow: hidden; | ||
| 209 | + border-radius: 0.5rem; | ||
| 210 | + background: #FFFFFF; | ||
| 211 | + box-shadow: inset 0 0 0 0.0625rem rgba(0, 0, 0, 0.08); | ||
| 212 | + | ||
| 213 | + img { | ||
| 214 | + width: 100%; | ||
| 215 | + display: block; | ||
| 216 | + object-fit: cover; | ||
| 217 | + } | ||
| 218 | + } | ||
| 219 | + } | ||
| 220 | +} | ||
| 221 | + | ||
| 222 | +.single-list { | ||
| 223 | + display: flex; | ||
| 224 | + flex-direction: column; | ||
| 225 | + gap: 1rem; | ||
| 226 | + margin-bottom: 1rem; | ||
| 227 | +} | ||
| 228 | + | ||
| 229 | +.item-card { | ||
| 230 | + overflow: hidden; | ||
| 231 | + background: #FFFFFF; | ||
| 232 | + border-radius: 0.75rem; | ||
| 233 | + box-shadow: 0 0.25rem 1rem rgba(107, 65, 2, 0.08); | ||
| 234 | +} | ||
| 235 | + | ||
| 236 | +.item-image { | ||
| 237 | + width: 100%; | ||
| 238 | + display: block; | ||
| 239 | +} | ||
| 240 | + | ||
| 241 | +.item-content { | ||
| 242 | + padding: 1rem; | ||
| 243 | +} | ||
| 244 | + | ||
| 245 | +.item-role { | ||
| 246 | + color: #6B4102; | ||
| 247 | + font-size: 0.875rem; | ||
| 248 | +} | ||
| 249 | + | ||
| 250 | +.item-name { | ||
| 251 | + margin-top: 0.5rem; | ||
| 252 | + color: #3D2A0B; | ||
| 253 | + font-size: 1.75rem; | ||
| 254 | + font-weight: 600; | ||
| 255 | +} | ||
| 256 | + | ||
| 257 | +.item-desc { | ||
| 258 | + margin-top: 0.75rem; | ||
| 259 | + color: #4F4F4F; | ||
| 260 | + line-height: 1.7; | ||
| 261 | + font-size: 0.9375rem; | ||
| 262 | +} | ||
| 263 | + | ||
| 264 | +@media (max-width: 30rem) { | ||
| 265 | + .yinlishi-detail-container { | ||
| 266 | + padding: 0.75rem; | ||
| 267 | + } | ||
| 268 | + | ||
| 269 | + .item-content { | ||
| 270 | + padding: 0.875rem; | ||
| 271 | + } | ||
| 272 | + | ||
| 273 | + .item-name { | ||
| 274 | + font-size: 1.5rem; | ||
| 275 | + } | ||
| 276 | +} | ||
| 277 | +</style> |
-
Please register or login to post a comment