hookehuyr

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

- 新增引礼师列表页 `/yinlishi` 和详情页 `/yinlishi/:id` 及对应路由配置
- 新增 `getArticleListAPI` 接口用于加载引礼师分类数据
- 更新首页导航逻辑,添加引礼师栏目的跳转
- 在 Masters.vue 中添加注释说明引礼师展示位置已迁移至独立页面
- 新增项目开发文档 AGENTS.md
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## 项目概述
这是一个基于 Vue 3 + Vite + Vant 4 的移动端 H5 项目(三坛大戒),用于展示佛教戒期相关信息。
## 常用命令
```bash
# 开发(pnpm/npm/yarn)
pnpm dev # 启动开发服务器(默认端口 5173)
pnpm build # 构建生产版本
pnpm lint # ESLint 检查并修复
pnpm serve # 预览构建产物
# 部署(依赖 qshell 和 ssh)
pnpm deploy # 执行完整部署流程:构建 -> 七牛上传 -> 服务器打包上传
bash scripts/deploy.sh # 直接执行部署脚本
```
## 项目架构
### 全局 Loading 机制
项目使用计数器方式管理全局加载状态,优化低网速体验:
- **状态管理**`src/stores/loading.js` - 使用 `pending_count` 记录进行中的请求数
- **拦截器集成**`src/utils/axios.js` - 请求开始 +1,响应成功 -1,错误时重置
- **UI 展示**`src/App.vue` - 全屏蒙版 + van-loading 组件
**特殊 API**:图片流媒体接口 `getImgStreamAPI` 配置了 `{ ignore_loading: true }`,不触发全局 loading。
**路由级加载**`src/router/index.js` 中路由守卫也会控制 loading,避免懒加载白屏。
### 登录态控制
- **Token 存储**:使用 `js-cookie` 存储 `token-stdj`
- **路由守卫**:除 `['/', '/jz_login']` 白名单外,所有页面需登录
- **未登录处理**:重定向到 `/jz_login` 并携带 `redirect` 参数
### API 层设计
- **API 定义**`src/api/index.js` - 统一定义接口路径和 JSDoc
- **请求封装**`src/api/fn.js` - 提供 `fn()` 处理响应、`fetch` 基于 axios
- **默认参数**:所有请求自动携带 `f: 'stdj'``timestamp`(GET)
**API 响应约定**
```javascript
// 成功响应格式(res.code === 1)
{ code: 1, data: {...}, msg: 'success' }
// 错误处理使用 showFailToast
```
### 目录结构
```
src/
├── api/ # API 接口定义
│ ├── index.js # 主接口(首页、文章、登录、用户信息)
│ ├── fn.js # 请求封装与错误处理
│ ├── common.js # 通用接口
│ └── wx/ # 微信相关(支付、配置等)
├── assets/ # 静态资源
├── components/ # 通用组件(VideoPlayer、LoadingSpinner、EmptyState)
├── router/ # Vue Router 配置(含登录守卫)
├── stores/ # Pinia 状态管理(loading store)
├── utils/ # 工具函数
│ ├── axios.js # Axios 拦截器(接入 loading 计数)
│ ├── request.js # Axios 实例导出
│ ├── upload.js # 上传工具
│ └── vconsole.js # 调试控制台
├── views/ # 页面组件
└── main.js # 入口文件
```
## 关键配置
### 移动端适配
- 使用 `postcss-px-to-viewport`,设计稿基准 **375px**
- 选择器添加 `ignore-` 前缀可跳过转换
### 路径别名
```javascript
@ src/
@components src/components/
@utils src/utils/
@api src/api/
@assets src/assets/
@views src/views/
@stores src/stores/
```
### 环境变量
- `.env` - 默认配置
- `.env.development` - 开发环境
- `.env.production` - 生产环境
**关键变量**
- `VITE_BASE` - 资源公共路径
- `VITE_PROXY_TARGET` - 代理目标服务器
- `VITE_PROXY_PREFIX` - 代理前缀(默认 `/srv/`
- `VITE_OUTDIR` - 构建输出目录(默认 `dist`
### 自动导入
- **Vue API**:通过 `unplugin-auto-import` 自动导入(见 `auto-imports.d.ts`
- **Vant 组件**:通过 `unplugin-vue-components` 按需导入(见 `components.d.ts`
## 部署说明
部署脚本 `scripts/deploy.sh` 执行以下流程:
1. **构建项目**`pnpm run build`
2. **上传静态资源到七牛云**`qshell qupload ~/.qshell/stdj_upload.conf`
3. **打包并上传到服务器**`scp -P 12101``zhsy@oa.jcedu.org:/home/www/f`
**依赖**:本机需安装 `qshell` 并配置 `~/.qshell/stdj_upload.conf`
## 开发注意事项
1. **新增 API**:在 `src/api/index.js` 定义接口,使用 `fn(fetch.get/post())` 封装
2. **跳过全局 Loading**:请求配置添加 `{ ignore_loading: true }`
3. **路由配置**:新页面如需登录,无需添加白名单(默认需登录);公开页面添加到 `white_list`
4. **组件命名**:文件名 kebab-case,组件名 PascalCase
......@@ -72,6 +72,20 @@ export const homePageAPI = (params) => fn(fetch.get(Api.GET_HOME, params));
export const getSSQZAPI = (params) => fn(fetch.get(Api.GET_ARTICLE, params));
/**
* @description: 文章列表数据(用于按分类加载引礼师等列表)
* @param {String} cid 分类id
* @param {String} [page] 页码,默认0
* @param {String} [limit] 条数,默认10
* @returns {Object} list
* @property integer list.id 文章id
* @property string list.post_title 标题/法名
* @property string list.post_date 发布时间
* @property string list.post_excerpt 简介/职位
* @property string list.photo 封面图/照片
*/
export const getArticleListAPI = (params) => fn(fetch.get(Api.GET_ARTICLE, params));
/**
* @description: 图片流媒体数据
* @param {String} i 分类id
* @param {String} page 页码,默认0
......
......@@ -31,6 +31,16 @@ const routes = [
component: () => import('../views/MastersDetail.vue')
},
{
path: '/yinlishi',
name: '引礼师',
component: () => import('../views/YinLiShi.vue')
},
{
path: '/yinlishi/:id',
name: '引礼师详情',
component: () => import('../views/YinLiShiDetail.vue')
},
{
path: '/news/:id',
name: 'NewsDetail',
component: () => import('../views/NewsDetail.vue')
......
......@@ -130,7 +130,7 @@
<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">
</div>
<!-- 个栏目 -->
<!-- 个栏目 -->
<div class="three-columns">
<!-- 临坛十师栏目 -->
<div class="column-item" v-for="(item, index) in mastersList" :key="index">
......@@ -521,6 +521,9 @@ const viewMoreByCategory = (item) => {
case 'STSSQZ': // 临坛十师
router.push(`/masters?pid=${item.id}`)
break
case 'STYLS': // 引礼师
router.push(`/yinlishi?cid=${item.id}`)
break
case 'STJZ': // 戒子
router.push(`${item.category_link}`)
break
......
......@@ -96,6 +96,8 @@ const singleItems = ref([])
const gridItems = ref([])
// 引礼师
// 说明:展示位置已迁移到独立的“引礼师”页面(/yinlishi)。
// 这里先保留旧字段结构与取值注释,避免后续回查旧接口映射时丢失上下文。
const gridItems2 = ref([])
const goDetail = (item) => {
......
<template>
<div class="yinlishi-container">
<section class="grid-two">
<div
class="item-card"
v-for="(item, index) in guideList"
:key="`${item.id}-${index}`"
@click="goDetail(item)"
>
<div class="item-image">
<img
:src="item.image"
:alt="`${item.role} ${item.name}`"
:aria-label="`${item.role}:${item.name}`"
loading="lazy"
/>
</div>
<div class="item-caption">
<div class="item-role">{{ item.role }}</div>
<div class="item-name">
<sup class="name-sup">上</sup>{{ item.name.charAt(0) }}<sup class="name-sup">下</sup>{{ item.name.slice(1) }}
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useTitle } from '@vueuse/core'
import { getArticleListAPI } from '@/api/index.js'
const router = useRouter()
useTitle('引礼师')
const cid = ref(router.currentRoute.value.query.cid || '')
const guideList = ref([])
/**
* 处理图片地址,避免空图导致布局塌陷
* @param {string} photo 原始图片地址
* @returns {string} 处理后的图片地址
*/
const processImageUrl = (photo) => {
if (!photo) {
return ''
}
return `${photo}?imageMogr2/thumbnail/400x/strip/quality/70`
}
/**
* 规范化引礼师列表项,统一页面渲染字段
* @param {object} item 接口返回项
* @returns {object} 页面展示项
*/
const processGuideItem = (item) => ({
id: item?.id || '',
role: item?.post_excerpt || '',
name: item?.post_title || '未知',
image: processImageUrl(item?.photo)
})
const goDetail = (item) => {
router.push(`/yinlishi/${item.id}`)
}
/**
* 加载引礼师文章列表
* @returns {Promise<void>}
*/
const loadGuideList = async () => {
try {
const { code, list } = await getArticleListAPI({
cid: cid.value,
page: 0,
limit: 50
})
if (!code || !Array.isArray(list)) {
console.error('Invalid guide list response')
guideList.value = []
return
}
guideList.value = list.map(processGuideItem)
} catch (error) {
console.error('Failed to fetch guide list:', error)
guideList.value = []
}
}
onMounted(() => {
loadGuideList()
})
</script>
<style scoped>
.yinlishi-container {
min-height: 100vh;
padding: 1rem;
background: #F2EBDB;
box-sizing: border-box;
}
.grid-two {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.item-card {
position: relative;
border: 1px solid rgba(107, 65, 2, 0.8);
overflow: hidden;
background: #FFFFFF;
transition: transform 0.2s ease;
}
.item-card:hover {
transform: translateY(-0.125rem);
}
.item-image {
width: 100%;
aspect-ratio: 3 / 4;
background: #f5f5f5;
}
.item-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.item-caption {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 0.75rem;
text-align: center;
color: #FFFFFF;
background: rgba(107, 65, 2, 0.8);
}
.item-role {
font-size: 0.75rem;
opacity: 0.95;
}
.item-name {
margin-top: 0.25rem;
font-size: 1.25rem;
font-weight: 600;
}
.name-sup {
font-size: 0.6em;
}
@media (max-width: 30rem) {
.yinlishi-container {
padding: 0.75rem;
}
.item-caption {
padding: 0.5rem;
}
}
</style>
<template>
<div class="yinlishi-detail-container">
<section class="single-list">
<div class="item-card">
<img :src="articleItem.src" :alt="articleItem.name || '引礼师详情'" class="item-image" />
<div class="item-content">
<div class="item-role">{{ articleItem.role }}</div>
<div class="item-name" v-html="formatNameWithSuperscript(articleItem.name)"></div>
<div class="item-desc" v-html="articleItem.desc"></div>
</div>
</div>
</section>
<div class="waterfall-content" v-if="columns[0].length || columns[1].length">
<div class="waterfall-container">
<div class="waterfall-column" v-for="(column, cidx) in columns" :key="cidx">
<div
class="waterfall-item"
v-for="item in column"
:key="item.id"
@click="onImageClick(item)"
>
<div class="image-wrapper">
<img
:src="item.src"
:alt="item.title"
:style="{ height: item.height + 'px' }"
@load="onImageLoad"
@error="onImageError"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { useTitle } from '@vueuse/core'
import { useRoute } from 'vue-router'
import { showImagePreview } from 'vant'
import { getArticleDetailAPI } from '@/api/index.js'
useTitle('引礼师详情')
const route = useRoute()
const articleItem = ref({})
const columns = reactive([[], []])
const allImages = ref([])
/**
* 为姓名的第一个字添加上标样式
* @param {string} name 原始姓名
* @returns {string} 带上标的 HTML
*/
const formatNameWithSuperscript = (name) => {
if (!name || name.length === 0) return name
const firstChar = name.charAt(0)
const restChars = name.slice(1)
return `<sup style="font-size: 0.6rem;">上</sup>${firstChar}<sup style="font-size: 0.6rem;">下</sup>${restChars}`
}
/**
* 分配图片到两列
* @param {Array} images 新增图片列表
* @returns {void}
*/
const distributeImages = (images) => {
images.forEach((image) => {
const leftHeight = columns[0].reduce((sum, item) => sum + item.height + 20, 0)
const rightHeight = columns[1].reduce((sum, item) => sum + item.height + 20, 0)
if (leftHeight <= rightHeight) {
columns[0].push(image)
return
}
columns[1].push(image)
})
}
/**
* 初始化瀑布流图片数据
* @param {Array} imgs 原始图片列表
* @returns {void}
*/
const initWaterfallImages = (imgs) => {
columns[0].splice(0, columns[0].length)
columns[1].splice(0, columns[1].length)
const list = Array.isArray(imgs) ? imgs : []
const mapped = list.map((item, idx) => {
const src = typeof item === 'string' ? item : (item?.value || item?.src || '')
return {
id: typeof item === 'object' && item?.id ? item.id : idx + 1,
src: src + ((item?.size || 0) > 20 * 1024 * 1024 ? '' : '?imageMogr2/thumbnail/400x/strip/quality/70'),
title: typeof item === 'object' && item?.name ? item.name : (`图片${idx + 1}`),
height: Math.floor(Math.random() * 200) + 200
}
}).filter((item) => String(item.src || '').trim().length > 0)
allImages.value = mapped
distributeImages(mapped)
}
/**
* 加载文章详情
* @returns {Promise<void>}
*/
const loadArticleDetail = async () => {
try {
const articleId = route.params.id
const { code, data } = await getArticleDetailAPI({ i: articleId })
if (!code) {
return
}
articleItem.value = {
id: data.id,
role: data.post_excerpt,
name: data.post_title,
src: `${data?.file_list?.photo?.value || ''}?imageMogr2/thumbnail/400x/strip/quality/70`,
desc: data.post_content,
imgs: data?.file_list?.img || []
}
initWaterfallImages(articleItem.value.imgs)
} catch (error) {
console.error('加载引礼师详情失败:', error)
}
}
/**
* 图片加载完成回调
* @returns {void}
*/
const onImageLoad = () => {
// 预留:如需按图片实际宽高调整瀑布流高度,可在这里扩展
}
/**
* 图片加载失败回调
* @param {Event} evt 图片加载事件
* @returns {void}
*/
const onImageError = (evt) => {
console.warn(`图片加载失败:${evt.target.src}`)
}
/**
* 点击预览大图
* @param {object} item 当前图片项
* @returns {void}
*/
const onImageClick = (item) => {
const currentIndex = allImages.value.findIndex((img) => img.id === item.id)
const images = allImages.value.map((img) => img.src.replace('?imageMogr2/thumbnail/400x/strip/quality/70', ''))
showImagePreview({
images,
startPosition: currentIndex >= 0 ? currentIndex : 0,
showIndex: true,
closeable: true
})
}
onMounted(() => {
loadArticleDetail()
})
</script>
<style lang="less" scoped>
.yinlishi-detail-container {
min-height: 100vh;
width: 100%;
padding: 1.5rem;
box-sizing: border-box;
background: #F2EBDB;
.waterfall-content {
margin-top: 1rem;
}
.waterfall-container {
display: flex;
gap: 0.5rem;
align-items: flex-start;
}
.waterfall-column {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.waterfall-item {
.image-wrapper {
width: 100%;
overflow: hidden;
border-radius: 0.5rem;
background: #FFFFFF;
box-shadow: inset 0 0 0 0.0625rem rgba(0, 0, 0, 0.08);
img {
width: 100%;
display: block;
object-fit: cover;
}
}
}
}
.single-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1rem;
}
.item-card {
overflow: hidden;
background: #FFFFFF;
border-radius: 0.75rem;
box-shadow: 0 0.25rem 1rem rgba(107, 65, 2, 0.08);
}
.item-image {
width: 100%;
display: block;
}
.item-content {
padding: 1rem;
}
.item-role {
color: #6B4102;
font-size: 0.875rem;
}
.item-name {
margin-top: 0.5rem;
color: #3D2A0B;
font-size: 1.75rem;
font-weight: 600;
}
.item-desc {
margin-top: 0.75rem;
color: #4F4F4F;
line-height: 1.7;
font-size: 0.9375rem;
}
@media (max-width: 30rem) {
.yinlishi-detail-container {
padding: 0.75rem;
}
.item-content {
padding: 0.875rem;
}
.item-name {
font-size: 1.5rem;
}
}
</style>