hookehuyr

feat(yinlishi): 新增引礼师列表与详情页面功能

- 新增引礼师列表页 `/yinlishi` 和详情页 `/yinlishi/:id` 及对应路由配置
- 新增 `getArticleListAPI` 接口用于加载引礼师分类数据
- 更新首页导航逻辑,添加引礼师栏目的跳转
- 在 Masters.vue 中添加注释说明引礼师展示位置已迁移至独立页面
- 新增项目开发文档 AGENTS.md
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) => {
......
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>
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>