hookehuyr

feat(article): 文章详情页添加图片预览功能

- 新增文章详情页面,支持富文本内容渲染
- 实现图片列表展示,支持点击预览所有文章图片
- 使用 Taro 原生 rich-text 组件渲染富文本
- 富文本内容自动格式化,处理图片宽度适配移动端
- 提取文章中的图片 URL,支持 Taro.previewImage 预览
- 新增收藏功能,支持文章收藏/取消收藏

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
......@@ -100,3 +100,94 @@
**变更摘要**:
- 无详细描述
### 12:57:01 - 完成任务
**影响文件**:
- `docs/api-specs/article/article_detail.md`
- `docs/api-specs/article/favorite.md`
- `docs/api-specs/article/list.md`
- `docs/api-specs/article/week_hot.md`
- `scripts/api-generator/generateApiFromOpenAPI.js`
- `src/api/article.js`
**变更摘要**:
- 无详细描述
### 12:58:11 - 完成任务
**影响文件**:
- `docs/api-specs/article/article_detail.md`
- `docs/api-specs/article/favorite.md`
- `docs/api-specs/article/list.md`
- `docs/api-specs/article/week_hot.md`
- `scripts/api-generator/generateApiFromOpenAPI.js`
- `src/api/article.js`
**变更摘要**:
- 无详细描述
### 13:00:59 - 完成任务
**影响文件**:
- `docs/api-specs/article/article_detail.md`
- `docs/api-specs/article/favorite.md`
- `docs/api-specs/article/list.md`
- `docs/api-specs/article/week_hot.md`
- `scripts/api-generator/generateApiFromOpenAPI.js`
- `src/api/article.js`
**变更摘要**:
- 无详细描述
### 13:01:30 - 完成任务
**影响文件**:
- `docs/api-specs/article/article_detail.md`
- `docs/api-specs/article/favorite.md`
- `docs/api-specs/article/list.md`
- `docs/api-specs/article/week_hot.md`
- `scripts/api-generator/generateApiFromOpenAPI.js`
- `src/api/article.js`
**变更摘要**:
- 无详细描述
### 13:02:31 - 完成任务
**影响文件**:
- `docs/api-specs/article/article_detail.md`
- `docs/api-specs/article/favorite.md`
- `docs/api-specs/article/list.md`
- `docs/api-specs/article/week_hot.md`
- `scripts/api-generator/generateApiFromOpenAPI.js`
- `src/api/article.js`
**变更摘要**:
- 无详细描述
### 13:03:07 - 完成任务
**影响文件**:
- `docs/api-specs/article/article_detail.md`
- `docs/api-specs/article/favorite.md`
- `docs/api-specs/article/list.md`
- `docs/api-specs/article/week_hot.md`
- `scripts/api-generator/generateApiFromOpenAPI.js`
- `src/api/article.js`
**变更摘要**:
- 无详细描述
### 13:03:33 - 完成任务
**影响文件**:
- `docs/api-specs/article/article_detail.md`
- `docs/api-specs/article/favorite.md`
- `docs/api-specs/article/list.md`
- `docs/api-specs/article/week_hot.md`
- `scripts/api-generator/generateApiFromOpenAPI.js`
- `src/api/article.js`
**变更摘要**:
- 无详细描述
......
......@@ -61,6 +61,8 @@ pnpm lint
- **Git 工作流标准化** - 使用 standard-version + Conventional Commits
- **认证系统完善** - 401 自动刷新、登录权限检查、TabBar 红点
- **API 集成进度** - 29 个接口,已完成 26 个(89.7%)
- **文章详情优化** - 富文本图片点击可预览
- **文章详情修复** - 富文本图片点击事件稳定识别
## ⚡ 常见问题
......
......@@ -9,6 +9,7 @@ declare module 'vue' {
export interface GlobalComponents {
AgePickerGlobal: typeof import('./src/components/plan/PlanFields/AgePickerGlobal.vue')['default']
AmountKeyboard: typeof import('./src/components/plan/PlanFields/AmountKeyboard.vue')['default']
ArticleCard: typeof import('./src/components/cards/ArticleCard.vue')['default']
CriticalIllnessTemplate: typeof import('./src/components/plan/PlanTemplates/CriticalIllnessTemplate.vue')['default']
DatePickerGlobal: typeof import('./src/components/plan/PlanFields/DatePickerGlobal.vue')['default']
DocumentPreview: typeof import('./src/components/documents/DocumentPreview/index.vue')['default']
......
# CHANGELOG
## [2026-02-27] - 文章详情富文本样式优化
### 修复
- 修复富文本内容中图片宽度溢出的问题:自动给 img 标签添加 max-width: 100% 及相关样式
---
## [2026-02-27] - 文章详情富文本图片预览
### 修复
- 富文本图片点击可预览,支持 data-index 与 src 兜底识别
---
## [2026-02-27] - 文章详情富文本图片点击修复
### 修复
- 使用 rich-text 点击事件读取 node 信息触发图片预览
---
## [2026-02-27] - 更新 CHANGELOG - feat(article): 文章模块功能开发
### 文档
- ����
---
**详细信息**
- **影响文件**: 无
- **技术栈**: Taro 4, Vue 3, NutUI
- **测试状态**: 待验证
- **备注**: 自动生成
## [2026-02-27] - 文章详情页修复与优化
### 修复
......
......@@ -87,6 +87,7 @@
"@vue/babel-plugin-jsx": "^1.0.6",
"@vue/compiler-sfc": "^3.0.0",
"@vue/test-utils": "^2.4.6",
"ajv": "^8.17.1",
"autoprefixer": "^10.4.21",
"babel-preset-taro": "4.1.11",
"css-loader": "3.4.2",
......@@ -97,7 +98,6 @@
"eslint-plugin-vue": "^8.0.0",
"happy-dom": "^14.12.0",
"husky": "^9.1.7",
"ajv": "^8.17.1",
"js-yaml": "^4.1.1",
"less": "^4.2.0",
"lint-staged": "^16.2.7",
......
......@@ -24,28 +24,9 @@ const Api = {
post_date: string; // 发布日期
post_author: integer; // 发布人id
author_name: string; // 发布人
file_list: {
icon: {
meta_type: string; //
id: integer; //
object_id: null; //
name: string; //
value: string; //
description: null; //
extension: string; //
post_date: string; //
icon: null; //
master_client_id: null; //
hash: string; //
height: string; //
width: string; //
author: integer; //
size: null; //
};
};
is_favorite: integer; //
* };
* }>}
* } >}
*/
export const articleDetailAPI = (params) => fn(fetch.get(Api.ArticleDetail, params));
......
......@@ -31,6 +31,8 @@ const pages = [
'pages/message/index',
'pages/message-detail/index',
'pages/video-player/index',
'pages/article-detail/index',
'pages/article-favorites/index',
]
if (process.env.NODE_ENV === 'development') {
......
/*
* @Date: 2026-02-13 01:05:52
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-02-27 10:22:51
* @LastEditTime: 2026-02-27 13:17:37
* @FilePath: /manulife-weapp/src/config/app.js
* @Description: 应用配置
*/
......
......@@ -42,7 +42,24 @@
<!-- 富文本内容 -->
<view class="article-body px-[32rpx]">
<rich-text :nodes="processedContent" class="rich-text-content" />
<rich-text :nodes="article.content" class="rich-text-content" />
</view>
<!-- 文章图片列表(点击可预览) -->
<view v-if="imageUrls.length > 0" class="article-images-section px-[32rpx] pb-[32rpx]">
<view class="section-title">文章图片</view>
<scroll-view scroll-x class="image-scroll-view">
<view class="image-list">
<view
v-for="(url, index) in imageUrls"
:key="index"
class="image-item"
@tap="previewImage(url)"
>
<image :src="url" mode="aspectFill" class="thumbnail-image" />
</view>
</view>
</scroll-view>
</view>
</view>
......@@ -125,13 +142,54 @@ const formattedDate = computed(() => {
})
/**
* 处理富文本内容,适配图片宽度
* 图片 URL 列表(用于预览)
*/
const processedContent = computed(() => {
if (!article.value?.content) return ''
// 给 img 标签添加内联样式,确保宽度不超过容器
return article.value.content.replace(/<img/gi, '<img style="max-width:100%;height:auto;display:block;margin:12px auto;"')
})
const imageUrls = ref([])
/**
* 预览图片
*/
const previewImage = (current) => {
if (imageUrls.value.length > 0) {
Taro.previewImage({
current: current,
urls: imageUrls.value
})
}
}
/**
* 提取所有图片 URL
*/
const extractImageUrls = (content) => {
if (!content) return []
const urls = []
const imgRegex = /<img[^>]+src=["']([^"']+)["']/gi
let match
while ((match = imgRegex.exec(content)) !== null) {
urls.push(match[1])
}
return urls
}
/**
* 格式化富文本内容,处理图片样式
* 解决移动端图片宽度溢出问题
*/
const formatRichText = (html) => {
if (!html) return ''
return html.replace(/<img[^>]*>/gi, (match) => {
// 移除原有的 width 和 height 属性,防止干扰
match = match.replace(/\s(width|height)=["'][^"']*["']/gi, '')
// 处理 style 属性
if (match.includes('style=')) {
return match.replace(/style=(["'])(.*?)\1/gi, 'style=$1$2;max-width:100%!important;height:auto!important;display:block;margin:24rpx auto;border-radius:12rpx;$1')
} else {
return match.replace(/<img/gi, '<img style="max-width:100%!important;height:auto!important;display:block;margin:24rpx auto;border-radius:12rpx;"')
}
})
}
/**
* 获取文章详情
......@@ -153,16 +211,22 @@ const fetchArticleDetail = async () => {
if (res.code === 1 && res.data) {
console.log('[Article Detail] 数据:', res.data)
// 格式化富文本内容,处理图片样式
const content = formatRichText(res.data.post_content || '')
article.value = {
id: res.data.id,
title: res.data.post_title || '未命名文章',
content: res.data.post_content || '',
content: content,
excerpt: res.data.post_excerpt || '',
coverUrl: res.data.cover_url || res.data.post_thumbnail || '',
date: res.data.post_date || '',
authorName: res.data.author_name || '',
is_favorite: res.data.is_favorite === 1 || res.data.is_favorite === '1'
}
// 提取图片 URL 列表
imageUrls.value = extractImageUrls(content)
} else {
error.value = true
Taro.showToast({
......@@ -230,14 +294,6 @@ const toggleCollect = async () => {
}
/**
* 滚动到底部事件
*/
const onScrollToLower = () => {
// 可以在这里加载相关文章推荐等
console.log('[Article Detail] 滚动到底部')
}
/**
* 页面加载时获取文章详情
*/
useLoad((options) => {
......@@ -446,4 +502,39 @@ useLoad((options) => {
align-items: center;
min-height: 400rpx;
}
/* 文章图片列表区域 */
.article-images-section {
margin-top: 24rpx;
}
.section-title {
font-size: 28rpx;
font-weight: bold;
color: #1F2937;
margin-bottom: 16rpx;
}
.image-scroll-view {
white-space: nowrap;
}
.image-list {
display: inline-flex;
gap: 16rpx;
}
.image-item {
flex-shrink: 0;
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
overflow: hidden;
background-color: #F3F4F6;
}
.thumbnail-image {
width: 100%;
height: 100%;
}
</style>
......
<!--
* @Date: 2026-02-08
* @Description: 资料/文档列表页 - 使用 LoadMoreList 组件重构版本
* @Date: 2026-02-27
* @Description: 文章列表页 - 改造版(原文章列表页)
* @改造说明: 将文章列表改造为文章列表,使用文章API
-->
<template>
<view class="h-screen overflow-hidden bg-[#F9FAFB]">
......@@ -23,7 +24,7 @@
<view class="px-[32rpx] mt-[32rpx] mb-[24rpx]">
<SearchBar
v-model="searchValue"
placeholder="搜资料..."
placeholder="搜索文章..."
@search="onSearch"
@clear="onClear"
variant="rounded"
......@@ -54,56 +55,23 @@
</view>
</template>
<!-- 列表项:资料卡片 -->
<!-- 列表项:文章卡片 -->
<template #item="{ item }">
<view
class="material-item bg-white rounded-[24rpx] p-[24rpx] shadow-sm transition-all duration-200 border border-gray-50 flex flex-row"
>
<view
class="w-[88rpx] h-[88rpx] mr-[24rpx] flex-shrink-0 flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100 rounded-[20rpx] shadow-inner self-start">
<image
:src="getDocumentIcon(item.extension ? `file.${item.extension}` : item.fileName)"
class="w-[48rpx] h-[48rpx]"
mode="aspectFit"
/>
</view>
<view class="flex-1 min-w-0">
<h3 class="text-[#1F2937] text-[30rpx] font-bold leading-[1.4] line-clamp-2 mb-[8rpx]">
{{ item.title }}
</h3>
<p class="text-[#6B7280] text-[24rpx] leading-[1.4] line-clamp-1 mb-[16rpx]">
{{ item.desc }}
</p>
<view class="flex items-center gap-[12rpx] mb-[16rpx]">
<view
class="inline-flex items-center justify-center px-[12rpx] py-[4rpx] bg-gray-100 text-gray-500 text-[20rpx] font-medium rounded-[8rpx]">
{{ getDocumentLabel(item.extension ? `file.${item.extension}` : item.fileName) }}
</view>
<view class="text-[#9CA3AF] text-[22rpx]">
{{ item.size }}
</view>
</view>
<view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
<ListItemActions
:viewable="true"
:collectable="true"
:collected="item.collected"
:item-id="String(item.meta_id || item.id)"
@view="onView(item)"
@collect="toggleCollect(item)"
@delete="onDelete(item)"
/>
</view>
</view>
<ArticleCard
:id="item.id"
:title="item.title"
:excerpt="item.excerpt"
:cover-url="item.coverUrl"
:date="item.date"
:collected="item.collected"
@viewed="onView(item)"
@collect-changed="handleCollectChanged(item, $event)"
/>
</template>
<!-- 空状态 -->
<template #empty>
<nut-empty description="无相关资料" image="empty" />
<nut-empty description="暂无相关文章" image="empty" />
</template>
</LoadMoreList>
</view>
......@@ -114,14 +82,11 @@ import { ref, computed, watch } from 'vue'
import { useLoad } from '@tarojs/taro'
import NavHeader from '@/components/navigation/NavHeader.vue'
import SearchBar from '@/components/forms/SearchBar.vue'
import ListItemActions from '@/components/list/ListItemActions/index.vue'
import LoadMoreList from '@/components/list/LoadMoreList'
import { useListItemClick, ListType } from '@/composables/useListItemClick'
import { getDocumentIcon, getDocumentLabel } from '@/utils/documentIcons'
import ArticleCard from '@/components/cards/ArticleCard.vue'
import { debounce } from '@/utils/debounce'
import { fileListAPI } from '@/api/file'
import { mockFileListAPI } from '@/utils/mockData'
import { useCollectOperation } from '@/composables/useCollectOperation'
import { listAPI } from '@/api/article'
import { mockArticleListAPI } from '@/utils/mockData'
import Taro from '@tarojs/taro'
import { USE_MOCK_DATA } from '@/config/app'
......@@ -136,7 +101,7 @@ const activeTabId = ref('all') // 暺恕葉""
* @description 使用防抖优化搜索性能,避免频繁请求接口
*/
const debouncedSearch = debounce(async () => {
console.log('[Material List] 防抖搜索触发')
console.log('[Article List] 防抖搜索触发')
await onSearch()
}, 500)
......@@ -193,10 +158,10 @@ const hasCategories = computed(() => {
/**
* 页面标题
*/
const pageTitle = ref('料列表')
const pageTitle = ref('文章列表')
/**
* 料数据源(从 API 获取)
* 文章数据源(从 API 获取)
*/
const allList = ref([])
......@@ -213,7 +178,7 @@ const categoryListCache = ref(new Map()) // 雿輻 Map 蝻掩”
const currentList = ref([])
/**
* 料分类数据
* 文章分类数据
* @description 根据 API 返回的 children 构建 tabs,始终包含"全部"选项
*/
const tabsData = computed(() => {
......@@ -237,32 +202,24 @@ const tabsData = computed(() => {
})
/**
* 转换档数据格式
* @description 将 API 返回的文档数据转换为组件需要的格式
* @param {Object} doc - API 返回的文档对象
* @returns {Object} 转换后的文档对象
* 转换文章数据格式
* @description 将 API 返回的文章数据转换为 ArticleCard 组件需要的格式
* @param {Object} article - API 返回的文章对象
* @returns {Object} 转换后的文章对象
*/
const transformDocItem = (doc) => {
// 处理文件名为空的情况
const fileName = doc.name || '未命名文件'
// 如果没有扩展名,从文件名中提取(如果有)
const extension = doc.extension || fileName.split('.').pop()?.toLowerCase() || ''
const transformDocItem = (article) => {
return {
id: doc.id || doc.meta_id, // 兼容 id 和 meta_id
meta_id: doc.meta_id || doc.id, // 保存 meta_id 用于收藏 API
title: fileName,
desc: doc.post_date || '',
size: doc.size || '',
fileName: fileName,
downloadUrl: doc.value,
extension: extension,
collected: doc.is_favorite === '1' || doc.is_favorite === 1 // 从 API 返回的收藏状态
id: article.id,
title: article.post_title || '未命名文章',
excerpt: article.post_excerpt || '',
coverUrl: article.file_list?.icon?.value || '',
date: article.post_date || '',
collected: article.is_favorite === 1 || article.is_favorite === '1'
}
}
/**
* 获取文档分类列表
* 获取文章列表
*
* @param {Object} params - 请求参数
* @param {string} params.cid - 分类ID(可选)
......@@ -282,13 +239,13 @@ const fetchMaterialList = async (params = {}, isLoadMore = false) => {
loading.value = true
}
console.log('[Material List] 请求参数:', params)
console.log('[Material List] 使用 Mock 数据:', USE_MOCK_DATA)
console.log('[Article List] 请求参数:', params)
console.log('[Article List] 使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockFileListAPI(params)
: await fileListAPI(params)
? await mockArticleListAPI(params)
: await listAPI(params)
if (res.code === 1 && res.data) {
// 如果是初始请求(没有 child_id),保存完整的分类信息
......@@ -361,13 +318,13 @@ const fetchMaterialList = async (params = {}, isLoadMore = false) => {
}
} else {
Taro.showToast({
title: res.msg || '获取资料列表失败',
title: res.msg || '获取文章列表失败',
icon: 'none',
duration: 2000
})
}
} catch (error) {
console.error('[Material List] 获取资料列表失败:', error)
console.error('[Article List] 获取文章列表失败:', error)
Taro.showToast({
title: '加载失败',
icon: 'error',
......@@ -386,7 +343,7 @@ const fetchMaterialList = async (params = {}, isLoadMore = false) => {
* 页面加载时接收参数
*/
useLoad(async (options) => {
console.log('[Material List] 页面参数:', options)
console.log('[Article List] 页面参数:', options)
// 保存初始分类ID
if (options.id) {
......@@ -402,7 +359,7 @@ useLoad(async (options) => {
currentPage.value = 0
hasMore.value = true
// 获取资料列表(初始请求)
// 获取文章列表(初始请求)
await fetchMaterialList({
cid: options.id,
page: 0,
......@@ -420,7 +377,7 @@ useLoad(async (options) => {
* @returns {Promise<void>}
*/
const handleLoadMore = async (page) => {
console.log('[Material List] 加载更多,页码:', page)
console.log('[Article List] 加载更多,页码:', page)
// 更新页码
currentPage.value = page
......@@ -472,7 +429,7 @@ const handleLoadMore = async (page) => {
const onTabClick = async (id) => {
if (activeTabId.value === id) return
console.log('[Material List] 切换分类:', id)
console.log('[Article List] 切换分类:', id)
activeTabId.value = id
......@@ -519,8 +476,8 @@ const onTabClick = async (id) => {
* @description 根据 child_id 和 keyword 调用接口查询列表
*/
const onSearch = async () => {
console.log('[Material List] 搜索产品:', searchValue.value)
console.log('[Material List] 当前分类:', activeTabId.value)
console.log('[Article List] 搜索产品:', searchValue.value)
console.log('[Article List] 当前分类:', activeTabId.value)
// 如果没有搜索关键词,清空搜索并恢复当前分类的列表
if (!searchValue.value.trim()) {
......@@ -574,12 +531,12 @@ const onSearch = async () => {
// 调用接口搜索
try {
loading.value = true
console.log('[Material List] 搜索使用 Mock 数据:', USE_MOCK_DATA)
console.log('[Article List] 搜索使用 Mock 数据:', USE_MOCK_DATA)
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockFileListAPI(params)
: await fileListAPI(params)
? await mockArticleListAPI(params)
: await listAPI(params)
if (res.code === 1 && res.data) {
if (res.data.list?.length) {
......@@ -603,7 +560,7 @@ const onSearch = async () => {
})
}
} catch (error) {
console.error('[Material List] 搜索失败:', error)
console.error('[Article List] 搜索失败:', error)
Taro.showToast({
title: '搜索失败',
icon: 'error',
......@@ -619,17 +576,17 @@ const onSearch = async () => {
* @description 实现实时搜索:用户输入时自动触发搜索(带防抖)
*/
watch(searchValue, (newValue, oldValue) => {
console.log('[Material List] searchValue 变化:', oldValue, '->', newValue)
console.log('[Article List] searchValue 变化:', oldValue, '->', newValue)
// 如果搜索关键字为空,立即清除搜索(不需要防抖)
if (!newValue?.trim()) {
console.log('[Material List] 搜索关键字为空,立即清除')
console.log('[Article List] 搜索关键字为空,立即清除')
onClear()
return
}
// 有搜索关键字,使用防抖搜索
console.log('[Material List] 触发防抖搜索')
console.log('[Article List] 触发防抖搜索')
debouncedSearch()
})
......@@ -637,8 +594,8 @@ watch(searchValue, (newValue, oldValue) => {
* 清空搜索
*/
const onClear = async () => {
console.log('[Material List] 清空搜索')
console.log('[Material List] 当前分类:', activeTabId.value)
console.log('[Article List] 清空搜索')
console.log('[Article List] 当前分类:', activeTabId.value)
// 构建请求参数(不带 keyword)
const params = {
......@@ -669,53 +626,47 @@ const onClear = async () => {
}
/**
* 使用文件列表点击处理器
*
* @description 直接使用 useFileOperation 的自动文件类型判断
* - 图片文件:自动使用 Taro.previewImage 预览
* - 视频文件:自动跳转到视频播放页面
* - 其他文件:自动下载并使用 Taro.openDocument 打开
*/
const { handleClick: handleFileClick } = useListItemClick({
listType: ListType.FILE,
onAfterClick: (item) => {
console.log('[Material List] 用户打开了资料:', item.title)
}
})
/**
* 查看资料
* @description 点击查看按钮时,直接触发文件预览
* @param {Object} item - 资料对象
* 查看文章
* @description 点击查看按钮时,跳转到文章详情页
* @param {Object} item - 文章对象
*
* @note 权限检查已在 ListItemActions 组件内部统一处理
*/
const onView = (item) => {
handleFileClick(item)
console.log('[Article List] 查看文章:', item.title)
// 跳转到文章详情页
Taro.navigateTo({
url: `/pages/article-detail/index?id=${item.id}`
})
}
/**
* 切换收藏状态
* @description 使用 useCollectOperation composable 处理收藏操作
* 处理收藏状态改变
*
* @description 当用户点击收藏按钮时,更新本地列表中的收藏状态
* @param {Object} item - 文章对象
* @param {Object} newStatus - 新的状态 { collected: boolean }
*/
const { toggleCollect } = useCollectOperation()
const handleCollectChanged = (item, newStatus) => {
console.log('[Article List] 收藏状态改变:', item.title, newStatus.collected)
/**
* 删除资料
*/
const onDelete = (item) => {
Taro.showModal({
title: '提示',
content: '确定要删除该资料吗?',
success: (res) => {
if (res.confirm) {
// 从 allList 中删除
const index = allList.value.findIndex(i => i.id === item.id)
if (index !== -1) {
allList.value.splice(index, 1)
Taro.showToast({ title: '已删除', icon: 'success' })
}
}
// 更新 allList 中的收藏状态
const article = allList.value.find(a => a.id === item.id)
if (article) {
article.collected = newStatus.collected
}
// 更新 currentList 中的收藏状态(如果存在)
const currentArticle = currentList.value.find(a => a.id === item.id)
if (currentArticle) {
currentArticle.collected = newStatus.collected
}
// 更新所有缓存中的收藏状态
categoryListCache.value.forEach((list, key) => {
const cachedArticle = list.find(a => a.id === item.id)
if (cachedArticle) {
cachedArticle.collected = newStatus.collected
}
})
}
......
......@@ -155,11 +155,20 @@ const rawMenuItems = [
iconColor: '#059669', // Emerald (Trust)
bgClass: 'bg-emerald-50'
},
// 原有的"我的收藏"(文件收藏)暂时屏蔽
// {
// key: 'favorites',
// title: '我的收藏',
// icon: 'star',
// path: '/pages/favorites/index',
// iconColor: '#D97706',
// bgClass: 'bg-amber-50'
// },
{
key: 'favorites',
title: '我的收藏',
key: 'article-favorites',
title: '我的收藏文章',
icon: 'star',
path: '/pages/favorites/index',
path: '/pages/article-favorites/index',
iconColor: '#D97706', // Amber (Value)
bgClass: 'bg-amber-50'
},
......
<!--
* @Date: 2026-02-08
* @Description: 本周热门资料页 - 使用 LoadMoreList 组件重构版本
* @Date: 2026-02-27
* @Description: 本周热门文章页 - 改造版(原热门资料页)
-->
<template>
<LoadMoreList
......@@ -10,27 +10,26 @@
:has-more="hasMore"
:loading="loading"
:loading-more="loadingMore"
key-field="meta_id"
key-field="id"
:has-footer="false"
@load-more="handleLoadMore"
>
<!-- 头部 -->
<template #header>
<NavHeader title="本周热门资料" />
<NavHeader title="本周热门文章" />
</template>
<!-- 列表项 -->
<template #item="{ item }">
<MaterialCard
:id="item.meta_id"
:title="item.name"
:file-name="item.name"
:file-size="item.size"
<ArticleCard
:id="item.id"
:title="item.title"
:excerpt="item.excerpt"
:cover-url="item.coverUrl"
:date="item.date"
:learners="item.learners"
:read-people-percent="item.read_people_percent"
:read-people-percent="item.readPeoplePercent"
:collected="item.collected"
:extension="item.extension"
:download-url="item.downloadUrl"
@collect-changed="handleCollectChanged(item, $event)"
/>
</template>
......@@ -42,9 +41,9 @@ import { ref } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import LoadMoreList from '@/components/list/LoadMoreList'
import NavHeader from '@/components/navigation/NavHeader.vue'
import MaterialCard from '@/components/cards/MaterialCard.vue'
import { weekHotAPI } from '@/api/file'
import { mockWeekHotAPI } from '@/utils/mockData'
import ArticleCard from '@/components/cards/ArticleCard.vue'
import { weekHotAPI } from '@/api/article'
import { mockArticleWeekHotAPI } from '@/utils/mockData'
import { USE_MOCK_DATA } from '@/config/app'
// ⚠️ MOCK 数据开关 - 统一从 @/config/app 导入
......@@ -90,20 +89,20 @@ const loadingMore = ref(false)
* 处理收藏状态改变
*
* @description 当用户点击收藏按钮时,更新本地状态
* @param {Object} item - 资料对象
* @param {Object} item - 文章对象
* @param {Object} newStatus - 新的状态 { collected: boolean }
*/
const handleCollectChanged = (item, newStatus) => {
console.log('[Week Hot] 收藏状态改变:', item.name, newStatus.collected)
console.log('[Week Hot] 收藏状态改变:', item.title, newStatus.collected)
// 找到对应的项并更新状态
const material = currentList.value.find(m => m.meta_id === item.meta_id)
if (material) {
material.collected = newStatus.collected
const article = currentList.value.find(a => a.id === item.id)
if (article) {
article.collected = newStatus.collected
}
}
/**
* 获取本周热门资料列表
* 获取本周热门文章列表
*
* @param {Object} params - 请求参数
* @param {number} params.page - 页码(从0开始)
......@@ -125,7 +124,7 @@ const fetchWeekHotList = async (params = {}, isLoadMore = false) => {
// 根据开关选择使用真实 API 或 Mock 数据
const res = USE_MOCK_DATA
? await mockWeekHotAPI(params)
? await mockArticleWeekHotAPI(params)
: await weekHotAPI(params)
if (res.code === 1 && res.data) {
......@@ -133,19 +132,17 @@ const fetchWeekHotList = async (params = {}, isLoadMore = false) => {
// 处理列表数据
if (res.data.list?.length) {
// 直接映射为 MaterialCard 需要的格式
// 不手动提取 extension,让 MaterialCard 内部使用 extractExtensionFromFile 自动从 URL 解析
const listData = res.data.list.map(item => {
return {
meta_id: item.meta_id,
name: item.name || '未命名文件',
size: item.size || '',
downloadUrl: item.src,
collected: item.is_favorite === '1' || item.is_favorite === 1 || item.is_favorite === true,
read_people_count: item.read_people_count,
read_people_percent: item.read_people_percent
}
})
// 映射为 ArticleCard 需要的格式
const listData = res.data.list.map(item => ({
id: item.id,
title: item.post_title || '未命名文章',
excerpt: item.post_excerpt || '',
coverUrl: item.file_list?.icon?.value || '',
date: item.post_date || '',
collected: item.is_favorite === 1 || item.is_favorite === '1' || item.is_favorite === true,
learners: item.read_people_count,
readPeoplePercent: item.read_people_percent
}))
if (isLoadMore) {
// 加载更多:追加数据
......@@ -168,13 +165,13 @@ const fetchWeekHotList = async (params = {}, isLoadMore = false) => {
}
} else {
Taro.showToast({
title: res.msg || '获取热门资料失败',
title: res.msg || '获取热门文章失败',
icon: 'none',
duration: 2000
})
}
} catch (error) {
console.error('[Week Hot] 获取热门资料失败:', error)
console.error('[Week Hot] 获取热门文章失败:', error)
Taro.showToast({
title: '加载失败',
icon: 'error',
......@@ -199,7 +196,7 @@ useLoad(async (options) => {
currentPage.value = 0
hasMore.value = true
// 获取本周热门资料列表
// 获取本周热门文章列表
await fetchWeekHotList({ page: 0, limit: pageSize })
})
......
/**
* @Description: Mock 数据生成工具 - 用于测试分页加载功能
* @Date: 2026-02-08
* @update 2026-02-27: 添加文章模块 Mock 数据支持
*
* 支持的 API Mock:
* - weekHotAPI: 周热门资料
* - fileListAPI: 资料列表
* - weekHotAPI: 周热门资料(已废弃,使用 articleWeekHotAPI)
* - fileListAPI: 资料列表(已废弃,使用 articleListAPI)
* - listAPI: 产品列表
* - searchAPI: 搜索(产品+资料)
* - myListAPI: 消息列表
* - favoriteListAPI: 收藏列表
* - favoriteListAPI: 收藏列表(已废弃,使用 articleFavoriteAPI)
* - feedbackListAPI: 意见反馈列表
* - planListAPI: 计划书列表(新增)
* - planListAPI: 计划书列表
* - articleListAPI: 文章列表(新增)
* - articleWeekHotAPI: 热门文章(新增)
* - articleDetailAPI: 文章详情(新增)
* - articleFavoriteAPI: 文章收藏列表(新增)
*/
// ============================================================================
......@@ -1233,6 +1238,401 @@ export async function mockPlanListAPI(params) {
}
// ============================================================================
// 9. 文章模块 Mock
// ============================================================================
const ARTICLE_TITLES = [
'财富管理基础知识指南',
'保险产品销售技巧',
'客户关系管理实战',
'家庭资产配置方案',
'税务筹划实用手册',
'退休规划完整教程',
'投资组合管理策略',
'风险控制与合规要求',
'高净值客户开发指南',
'理财产品营销话术',
'基金定投实战技巧',
'保单整理服务流程',
'传承规划案例分析',
'健康险产品对比分析',
'年金保险销售指南',
'重疾险核保知识',
'教育金规划方案',
'房贷规划实务操作',
'家族信托业务介绍',
'私募股权投资指南',
'终身寿险销售技巧',
'车险理赔流程说明',
'企业财产险基础知识',
'责任险产品介绍',
'意外险保障方案',
'健康险核保手册',
'投保实务操作指南',
'客户异议处理话术',
'理赔案例分析',
'保险法律法规汇编'
]
const ARTICLE_EXCERPTS = [
'这是一篇关于财富管理的基础知识文章,帮助您了解投资理财的基本概念和方法。',
'保险产品销售技巧分享,从客户需求分析到产品推荐的完整流程。',
'高净值客户开发与维护实战指南,分享成功的客户管理经验。',
'家庭资产配置方案设计,综合考虑风险、收益和流动性需求。',
'税务筹划实用手册,合法合规地降低税负,提高财务效率。',
'退休规划完整教程,为您打造安心舒适的退休生活。',
'投资组合管理策略,分散风险,实现稳健收益。',
'风险控制与合规要求解读,确保业务健康发展。',
'高净值客户开发指南,提升客户开发成功率。',
'理财产品营销话术,让客户更容易接受产品推荐。'
]
const ARTICLE_AUTHORS = [
'财富管理专家',
'保险规划师',
'投资顾问',
'税务筹划师',
'法律顾问',
'风险管理师'
]
const ARTICLE_COVER_IMAGES = [
'https://picsum.photos/seed/article1/800/450',
'https://picsum.photos/seed/article2/800/450',
'https://picsum.photos/seed/article3/800/450',
'https://picsum.photos/seed/article4/800/450',
'https://picsum.photos/seed/article5/800/450'
]
/**
* 生成文章列表项
*/
function generateArticleItem(id) {
const title = ARTICLE_TITLES[Math.floor(Math.random() * ARTICLE_TITLES.length)]
const excerpt = ARTICLE_EXCERPTS[Math.floor(Math.random() * ARTICLE_EXCERPTS.length)]
const author = ARTICLE_AUTHORS[Math.floor(Math.random() * ARTICLE_AUTHORS.length)]
const coverUrl = ARTICLE_COVER_IMAGES[Math.floor(Math.random() * ARTICLE_COVER_IMAGES.length)]
// 生成随机日期(最近30天内)
const now = new Date()
const postDate = new Date(now.getTime() - Math.random() * 30 * 24 * 60 * 60 * 1000)
return {
id: id,
post_title: title,
post_excerpt: excerpt,
post_link: '',
post_date: formatDate(postDate),
is_favorite: generateRandomFavorite(),
read_people_count: generateRandomReadCount(),
read_people_percent: generateRandomReadPercent(),
author_name: author
}
}
/**
* Mock: 文章列表 API (listAPI - article)
* @param {Object} params - 请求参数
*/
export async function mockArticleListAPI(params) {
await mockDelay()
const { page = 0, limit = 20, cid, child_id, keyword } = params
const totalPages = 10
if (page >= totalPages) {
return { code: 1, msg: 'success', data: { list: [], total: totalPages * limit } }
}
// 文章分类数据(模拟分类结构)
const ARTICLE_CATEGORIES = {
1: { // 第一层:入职前
id: 1,
category_name: '入职前',
level: 1,
children: [
{ id: 11, category_name: '公司介绍', level: 2, icon: '', max_depth: 2, list: [] },
{ id: 12, category_name: '企业文化', level: 2, icon: '', max_depth: 2, list: [] },
{ id: 13, category_name: '产品知识', level: 2, icon: '', max_depth: 2, list: [] }
]
},
2: { // 第一层:入职中
id: 2,
category_name: '入职中',
level: 1,
children: [
{ id: 21, category_name: '销售技巧', level: 2, icon: '', max_depth: 2, list: [] },
{ id: 22, category_name: '客户服务', level: 2, icon: '', max_depth: 2, list: [] },
{ id: 23, category_name: '合规要求', level: 2, icon: '', max_depth: 2, list: [] }
]
},
3: { // 第一层:入职后
id: 3,
category_name: '入职后',
level: 1,
children: [
{ id: 31, category_name: '进阶培训', level: 2, icon: '', max_depth: 2, list: [] },
{ id: 32, category_name: '管理技能', level: 2, icon: '', max_depth: 2, list: [] }
]
},
// 兼容后端返回的分类 ID
3129684: {
id: 3129684,
category_name: '培训资料',
level: 1,
children: [
{ id: 3129685, category_name: '销售培训', level: 2, icon: '', max_depth: 2, list: [] },
{ id: 3129686, category_name: '产品培训', level: 2, icon: '', max_depth: 2, list: [] },
{ id: 3129687, category_name: '服务培训', level: 2, icon: '', max_depth: 2, list: [] },
{ id: 3129688, category_name: '合规培训', level: 2, icon: '', max_depth: 2, list: [] }
]
}
}
// 如果有分类 ID,生成该分类的子分类数据
let selectedCategory = null
let children = []
let cate = null
if (cid) {
const categoryId = parseInt(cid)
// 查找对应的分类
for (const key in ARTICLE_CATEGORIES) {
if (parseInt(key) === categoryId) {
selectedCategory = ARTICLE_CATEGORIES[key]
break
}
}
// 如果找不到精确匹配,生成默认分类结构(兼容任何分类 ID)
if (!selectedCategory) {
console.log(`[Mock] 未找到分类 ${cid},生成默认分类结构`)
selectedCategory = {
id: categoryId,
category_name: `分类${categoryId}`,
level: 1,
children: [
{ id: categoryId * 10 + 1, category_name: '子分类1', level: 2, icon: '', max_depth: 2, list: [] },
{ id: categoryId * 10 + 2, category_name: '子分类2', level: 2, icon: '', max_depth: 2, list: [] },
{ id: categoryId * 10 + 3, category_name: '子分类3', level: 2, icon: '', max_depth: 2, list: [] }
]
}
}
// 如果找到了,返回其子分类
if (selectedCategory) {
cate = {
id: selectedCategory.id,
category_name: selectedCategory.category_name,
category_parent: 0,
category_description: null
}
// 为每个子分类生成 5-15 篇文章的占位数据
children = selectedCategory.children.map(child => ({
...child,
list: Array.from({ length: Math.floor(Math.random() * 10) + 5 }, (_, i) => ({
name: `${child.category_name}文章${i + 1}`,
value: `https://example.com/article-${child.id}-${i + 1}`,
extension: 'pdf',
post_date: formatDate(new Date()),
is_favorite: generateRandomFavorite(),
id: child.id * 100 + i + 1
}))
}))
}
}
// 如果有分类 ID,返回分类结构;否则返回文章列表
if (cid) {
console.log(`[Mock] mockArticleListAPI - 分类 ${cid},子分类 ${children.length} 个`)
return {
code: 1,
msg: 'success',
data: {
cate: cate || { id: cid || 1, category_name: '文章分类', category_parent: 0, category_description: null },
children: children,
list: [], // 分类模式下,列表为空
total: 0,
max_level: 2
}
}
}
// 无分类 ID 时,返回文章列表
let list = []
const startIndex = page * limit
// 生成基础数据
for (let i = 0; i < limit; i++) {
list.push(generateArticleItem(startIndex + i + 1))
}
// 关键词搜索过滤
if (keyword) {
const searchKeyword = keyword.toLowerCase()
list = list.filter(article =>
article.post_title.toLowerCase().includes(searchKeyword)
)
}
console.log(`[Mock] mockArticleListAPI - 第${page}页,共${list.length}条,关键词:"${keyword || '无'}"`)
return {
code: 1,
msg: 'success',
data: {
cate: { id: 1, category_name: '全部文章', category_parent: 0, category_description: null },
children: [],
list: list,
total: list.length >= limit ? totalPages * limit : list.length,
max_level: 2
}
}
}
/**
* Mock: 热门文章 API (weekHotAPI - article)
* @param {Object} params - 请求参数
*/
export async function mockArticleWeekHotAPI(params) {
await mockDelay()
const { page = 0, limit = 20 } = params
const totalPages = 5
if (page >= totalPages) {
return { code: 1, msg: 'success', data: { list: [] } }
}
const list = []
const startIndex = page * limit
for (let i = 0; i < limit; i++) {
list.push(generateArticleItem(startIndex + i + 1))
}
console.log(`[Mock] mockArticleWeekHotAPI - 第${page}页,共${list.length}条`)
return { code: 1, msg: 'success', data: { list } }
}
/**
* Mock: 文章详情 API (articleDetailAPI)
* @param {Object} params - 请求参数
* @param {string} params.i - 文章ID
*/
export async function mockArticleDetailAPI(params) {
await mockDelay(300, 500) // 详情页延迟稍长,模拟真实加载
const { i } = params
const id = parseInt(i) || 1
const article = generateArticleItem(id)
// 模拟文章内容(HTML格式)
const content = `
<div style="font-size: 16px; line-height: 1.8; color: #333;">
<h2 style="font-size: 20px; font-weight: bold; margin-bottom: 16px;">${article.post_title}</h2>
<p style="margin-bottom: 16px;">${article.post_excerpt}</p>
<h3 style="font-size: 18px; font-weight: bold; margin: 24px 0 12px;">一、背景介绍</h3>
<p style="margin-bottom: 16px;">随着财富管理行业的快速发展,专业知识和技能的重要性日益凸显。本文将为您详细介绍相关的核心概念和实践方法。</p>
<h3 style="font-size: 18px; font-weight: bold; margin: 24px 0 12px;">二、核心要点</h3>
<ul style="margin-bottom: 16px; padding-left: 20px;">
<li>深入理解客户需求,提供个性化解决方案</li>
<li>持续学习行业知识,提升专业能力</li>
<li>建立长期客户关系,增强客户黏性</li>
<li>注重风险控制,保障客户资产安全</li>
</ul>
<h3 style="font-size: 18px; font-weight: bold; margin: 24px 0 12px;">三、实践建议</h3>
<p style="margin-bottom: 16px;">在日常工作中,建议您:</p>
<ol style="margin-bottom: 16px; padding-left: 20px;">
<li>定期参加行业培训和研讨会</li>
<li>建立完善的客户档案系统</li>
<li>与团队成员保持良好沟通</li>
<li>关注市场动态,及时调整策略</li>
</ol>
<h3 style="font-size: 18px; font-weight: bold; margin: 24px 0 12px;">四、总结</h3>
<p style="margin-bottom: 16px;">通过系统的学习和实践,您将能够更好地为客户服务,实现个人和团队的共同成长。</p>
<p style="color: #666; font-size: 14px; margin-top: 24px;">作者:${article.author_name}</p>
<p style="color: #666; font-size: 14px;">发布日期:${article.post_date}</p>
</div>
`
console.log(`[Mock] mockArticleDetailAPI - 文章ID: ${id}`)
return {
code: 1,
msg: 'success',
data: {
...article,
post_content: content
}
}
}
/**
* Mock: 文章收藏列表 API (favoriteAPI - article)
* @param {Object} params - 请求参数
*/
export async function mockArticleFavoriteAPI(params) {
await mockDelay()
const { page = 0, limit = 20, keyword } = params
const totalPages = 3
if (page >= totalPages) {
return { code: 1, msg: 'success', data: { list: [], total: 0 } }
}
let list = []
const startIndex = page * limit
// 生成收藏列表(只返回已收藏的)
for (let i = 0; i < Math.min(limit, 10); i++) {
const article = generateArticleItem(startIndex + i + 1)
// 标记为已收藏,并添加收藏时间
const now = new Date()
const favoriteTime = new Date(now.getTime() - Math.random() * 60 * 24 * 60 * 60 * 1000)
list.push({
id: article.id,
post_title: article.post_title,
post_excerpt: article.post_excerpt,
post_link: article.post_link,
post_date: article.post_date,
favorite_time: formatDate(favoriteTime)
})
}
// 关键词搜索过滤
if (keyword) {
const searchKeyword = keyword.toLowerCase()
list = list.filter(article =>
article.post_title.toLowerCase().includes(searchKeyword)
)
}
console.log(`[Mock] mockArticleFavoriteAPI - 第${page}页,共${list.length}条`)
return {
code: 1,
msg: 'success',
data: {
list: list,
total: list.length >= limit ? totalPages * limit : list.length
}
}
}
// ============================================================================
// 导出统一 Mock API 调用器
// ============================================================================
......@@ -1262,6 +1662,15 @@ export async function mockAPI(apiName, params) {
return await mockFeedbackListAPI(params)
case 'planListAPI':
return await mockPlanListAPI(params)
// 文章模块 Mock(直接调用独立函数,这里仅为兼容性保留)
case 'articleListAPI':
return await mockArticleListAPI(params)
case 'articleWeekHotAPI':
return await mockArticleWeekHotAPI(params)
case 'articleDetailAPI':
return await mockArticleDetailAPI(params)
case 'articleFavoriteAPI':
return await mockArticleFavoriteAPI(params)
default:
console.warn(`[Mock] 未知的 API: ${apiName}`)
return { code: 0, msg: 'Unknown API', data: null }
......