hookehuyr

feat(plan): 添加消息详情计划书查看功能

- 新增 usePlanView composable 封装计划书查看逻辑
- 支持单文件直接预览,多文件显示选择弹框
- 预览成功后自动标记为已查看
- 消息列表第一页插入3条测试数据(1001/1002/1003)
- 更新测试文件地址,使用项目 CDN 资源

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
/**
* 计划书查看 Composable
*
* @description 封装计划书查看逻辑,支持:
* - 单文件直接预览
* - 多文件显示选择弹框
* - 预览成功后标记为已查看
* - 传入 proposal 数据自动处理状态和文件
*
* @example
* const { viewProposal } = usePlanView()
*
* // 方式1:传入完整的 proposal 对象(从消息详情 API 获取)
* viewProposal({
* id: 123,
* order_status: '7',
* proposal_files: [
* { file_name: '计划书.pdf', file_url: 'xxx', id: 1 }
* ]
* })
*
* // 方式2:传入已转换的 item(从计划书列表获取)
* viewProposal(planItem)
*
* @author Claude Code
* @version 1.0.0
*/
import { useFileOperation } from './useFileOperation'
import { viewAPI } from '@/api/plan'
import Taro from '@tarojs/taro'
/**
* 计划书查看 Hook
*
* @returns {Object} 包含 viewProposal 方法的对象
*/
export function usePlanView() {
const { viewFile } = useFileOperation()
/**
* 订单状态映射
*
* @param {string} orderStatus - API 返回的状态值
* @returns {string} 前端状态:'pending' | 'processing' | 'generated' | 'viewed'
*
* @description 状态值说明(根据API文档):
* - "3" = 待处理 (pending)
* - "5" = 处理中 (processing)
* - "7" = 已生成 (generated)
* - "9" = 已查看 (viewed)
*/
const mapOrderStatus = (orderStatus) => {
const statusMap = {
'3': 'pending', // 待处理
'5': 'processing', // 处理中
'7': 'generated', // 已生成
'9': 'viewed' // 已查看
}
return statusMap[orderStatus] || 'pending'
}
/**
* 获取状态文本
*
* @param {string} status - 前端状态值
* @returns {string} 状态文本
*/
const getStatusText = (status) => {
const textMap = {
'pending': '待处理',
'processing': '处理中',
'generated': '已生成',
'viewed': '已查看'
}
return textMap[status] || '待处理'
}
/**
* 查看计划书
*
* @param {Object} proposal - 计划书对象(支持两种格式)
* @param {number} proposal.id - 计划书 ID(必需)
* @param {string} proposal.order_status - 订单状态(API 格式:'3'|'5'|'7'|'9')
* @param {Array} proposal.proposal_files - 文件列表(API 格式)
* @param {string} proposal.status - 订单状态(前端格式,兼容列表数据)
* @param {Array} proposal.proposalFiles - 文件列表(兼容列表数据)
* @param {Object} callbacks - 回调函数
* @param {Function} callbacks.onViewSuccess - 查看成功后回调,参数为 proposalId
* @param {Function} callbacks.beforeView - 查看前回调,返回 false 可取消查看
* @returns {Promise<void>}
*/
const viewProposal = async (proposal, callbacks = {}) => {
const { beforeView, onViewSuccess } = callbacks
// 1. 状态检查 - 解析两种可能的状态字段
const status = proposal.status || mapOrderStatus(proposal.order_status)
if (status === 'pending' || status === 'processing') {
Taro.showToast({
title: '计划书尚未生成,请稍后',
icon: 'none'
})
return
}
// 2. 解析文件列表 - 支持两种可能的字段名
const proposalFiles = proposal.proposal_files || proposal.proposalFiles || []
if (!proposalFiles || proposalFiles.length === 0) {
Taro.showToast({
title: '暂无可查看的计划书',
icon: 'none'
})
return
}
// 3. 执行查看前回调
if (beforeView) {
const shouldContinue = await beforeView(proposal)
if (shouldContinue === false) return
}
/**
* 处理单个文件的查看
*
* @param {Object} file - 文件对象
* @param {string} file.file_url - 文件 URL
* @param {string} file.file_name - 文件名称
*/
const handleFileView = async (file) => {
try {
const previewSuccess = await viewFile({
downloadUrl: file.file_url,
fileName: file.file_name
})
if (!previewSuccess) return
// 4. 预览成功后标记为已查看
if (status !== 'viewed' && proposal.id) {
const viewRes = await viewAPI({ i: proposal.id })
if (viewRes.code === 1) {
Taro.showToast({
title: '已标记为查看',
icon: 'success',
duration: 1000
})
// 触发成功回调
if (onViewSuccess) {
onViewSuccess(proposal.id)
}
}
}
} catch (error) {
console.error('查看计划书文件失败:', error)
}
}
// 5. 单文件直接查看
if (proposalFiles.length === 1) {
await handleFileView(proposalFiles[0])
return
}
// 6. 多文件显示选择弹框
const fileList = proposalFiles.map((file, index) => ({
text: file.file_name || `计划书 ${index + 1}`,
file: file
}))
Taro.showActionSheet({
itemList: fileList.map(f => f.text),
success: async (res) => {
const selectedIndex = res.tapIndex
if (selectedIndex !== undefined && selectedIndex >= 0) {
const selectedFile = fileList[selectedIndex].file
await handleFileView(selectedFile)
}
}
})
}
return {
viewProposal,
mapOrderStatus,
getStatusText
}
}
<!--
* @Date:2026-02-03 21:26:58
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-02-12 21:09:32
* @LastEditTime: 2026-02-13
* @FilePath: /manulife-weapp/src/pages/message-detail/index.vue
* @Description: 消息详情页
* @Description: 消息详情页 - API 新增 title 字段,添加 Mock 支持,新增计划书查看功能
-->
<template>
<view class="min-h-screen bg-gray-50 pb-safe">
......@@ -12,7 +12,7 @@
<view v-if="detail" class="p-4">
<!-- 消息卡片 -->
<view class="bg-white rounded-lg p-5 shadow-sm">
<!-- 标题区域 (模拟) -->
<!-- 标题区域:API 返回 title 字段 -->
<view class="mb-3">
<text class="text-xl font-bold text-gray-900 leading-snug block">
{{ detail.title || '系统消息通知' }}
......@@ -33,6 +33,57 @@
<view class="rich-text-content">
<rich-text :nodes="formattedContent" />
</view>
<!-- 计划书卡片 -->
<view
v-if="detail.proposal"
class="mt-6 pt-6 border-t border-gray-200"
>
<!-- 卡片标题 -->
<view class="flex justify-between items-center mb-4">
<text class="text-base font-bold text-gray-900">关联计划书</text>
<!-- 状态标记 -->
<view
:class="[
'status-badge',
proposalStatus === 'generated' ? 'status-generated' : '',
proposalStatus === 'viewed' ? 'status-viewed' : ''
]"
>
{{ getProposalStatusText(proposalStatus) }}
</view>
</view>
<!-- 计划书信息 -->
<view class="bg-gray-50 rounded-lg p-4 mb-4">
<view class="flex justify-between text-sm mb-2">
<text class="text-gray-500">申请人</text>
<text class="text-gray-900 font-medium">{{ detail.proposal.customer_name || '-' }}</text>
</view>
<view class="flex justify-between text-sm mb-2">
<text class="text-gray-500">产品</text>
<text class="text-gray-900 font-medium">{{ detail.proposal.product_name || '-' }}</text>
</view>
<view class="flex justify-between text-sm">
<text class="text-gray-500">创建时间</text>
<text class="text-gray-900 font-medium">{{ detail.proposal.created_time || '-' }}</text>
</view>
</view>
<!-- 查看按钮 -->
<nut-button
v-if="canViewProposal"
type="primary"
color="#2563EB"
block
class="!rounded-lg !text-base"
@tap="handleViewProposal"
>
{{ proposalFiles.length > 1
? `查看计划书 (${proposalFiles.length}个文件)`
: '查看计划书' }}
</nut-button>
</view>
</view>
</view>
......@@ -52,12 +103,75 @@ import { useLoad } from '@tarojs/taro'
import NavHeader from '@/components/navigation/NavHeader.vue'
import { detailAPI } from '@/api/news'
import { useUserStore } from '@/stores/user'
import { mockDetailAPI } from '@/utils/mockData'
import { USE_MOCK_DATA } from '@/config/app'
import { usePlanView } from '@/composables/usePlanView'
const detail = ref(null)
const loading = ref(true)
// 使用计划书查看 Composable
const { viewProposal } = usePlanView()
/**
* 计划书状态
*
* @description 将 API 返回的 order_status 映射为前端状态
* @returns {string|null} 'pending' | 'processing' | 'generated' | 'viewed' | null
*/
const proposalStatus = computed(() => {
if (!detail.value?.proposal) return null
// 状态映射(与 plan/index.vue 保持一致)
const statusMap = {
'3': 'pending', // 待处理
'5': 'processing', // 处理中
'7': 'generated', // 已生成
'9': 'viewed' // 已查看
}
return statusMap[detail.value.proposal.order_status] || 'pending'
})
/**
* 计划书文件列表
*
* @description 从 proposal 对象中提取文件列表
* @returns {Array} 文件列表
*/
const proposalFiles = computed(() => {
return detail.value?.proposal?.proposal_files || []
})
/**
* @description 格式化富文本内容,处理图片宽度等问题
* 是否可以查看计划书
*
* @description 只有"已生成"和"已查看"状态才能查看
* @returns {boolean}
*/
const canViewProposal = computed(() => {
const status = proposalStatus.value
return status === 'generated' || status === 'viewed'
})
/**
* 获取状态文本
*
* @param {string} status - 前端状态值
* @returns {string} 状态中文文本
*/
const getProposalStatusText = (status) => {
const textMap = {
'pending': '待处理',
'processing': '处理中',
'generated': '已生成',
'viewed': '已查看'
}
return textMap[status] || '待处理'
}
/**
* 格式化富文本内容,处理图片宽度等问题
*/
const formattedContent = computed(() => {
if (!detail.value?.note) return ''
......@@ -72,21 +186,37 @@ const formattedContent = computed(() => {
})
/**
* @description 获取消息详情
* 查看计划书
*
* @description 调用 usePlanView 的 viewProposal 方法查看计划书
* 支持单文件直接预览,多文件显示选择弹框
*/
const handleViewProposal = () => {
viewProposal(detail.value.proposal, {
onViewSuccess: (proposalId) => {
// 查看成功后的回调
console.log('计划书已查看:', proposalId)
}
})
}
/**
* 获取消息详情
*
* @description 根据 ID 获取消息详情,API 已返回 title 字段
* @param {string|number} id 消息ID
*/
const fetchDetail = async (id) => {
loading.value = true
try {
const res = await detailAPI({ i: id })
// 根据环境选择使用 Mock 数据还是真实 API
const res = USE_MOCK_DATA
? await mockDetailAPI({ i: id })
: await detailAPI({ i: id })
if (res.code === 1) {
// 模拟标题数据,实际项目中应由后端返回
// 这里为了演示效果,如果后端没有返回 title,就模拟一个
const mockTitle = '关于系统维护升级的通知公告'
detail.value = {
...res.data,
title: res.data.title || mockTitle
}
// API 已返回 title 字段,直接使用后端数据
detail.value = res.data
// 查看消息后刷新用户信息,更新未读消息数
const userStore = useUserStore()
......@@ -128,4 +258,23 @@ useLoad((options) => {
margin-bottom: 10rpx;
}
}
/* 计划书状态标记样式 */
.status-badge {
padding: 4rpx 12rpx;
border-radius: 9999rpx;
font-size: 22rpx;
font-weight: 500;
white-space: nowrap;
}
.status-generated {
background-color: #D1FAE5;
color: #059669;
}
.status-viewed {
background-color: #E5E7EB;
color: #6B7280;
}
</style>
......
......@@ -67,11 +67,17 @@ function mockDelay(min = 100, max = 300) {
/**
* 真实的可预览测试文件地址
* 来源:GitHub 和其他公开的测试资源
*
* 来源说明:
* - 项目 CDN: cdn.ipadbiz.cn(项目自有 CDN,最稳定)
* - calibre-ebook.com: Calibre 官方测试文件
* - filesamples.com: 文件格式测试样本
* - Microsoft: 官方示例文件
*/
const TEST_FILES = {
// PDF 文档
// PDF 文档(优先使用项目 CDN)
pdf: [
'https://cdn.ipadbiz.cn/manulife/document/test.pdf', // 项目 CDN(最可靠)
'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
'https://www.africau.edu/images/default/sample.pdf'
],
......@@ -83,12 +89,14 @@ const TEST_FILES = {
// Excel 表格 (xlsx)
xlsx: [
'https://filesamples.com/samples/document/xlsx/sample1.xlsx', // filesamples 更稳定
'https://go.microsoft.com/fwlink/?LinkID=512104&clcid=0x0409'
],
// PPT 演示文稿 (pptx)
// PPT 演示文稿 (ppt/pptx)
pptx: [
'https://www.africau.edu/images/default/sample.pptx'
'https://www.africau.edu/images/default/sample.pptx',
'https://filesamples.com/samples/document/ppt/sample1.ppt'
],
// 图片
......@@ -617,20 +625,24 @@ const MESSAGE_TITLES = [
/**
* 生成消息列表项
*
* @description 按 API 规范生成消息 Mock 数据
* @param {number} id - 消息 ID
* @returns {Object} 消息对象
*/
function generateMessageItem(id) {
const title = MESSAGE_TITLES[Math.floor(Math.random() * MESSAGE_TITLES.length)]
const now = new Date()
const createDate = new Date(now.getTime() - Math.random() * 30 * 24 * 60 * 60 * 1000)
const isUnread = Math.random() > 0.5
return {
id: id,
title: title,
intro: `这是一条关于"${title}"的重要通知,请及时查看详情...`,
content: `这是一条关于"${title}"的重要通知,请及时查看详情。如需了解更多信息,请联系客服。`,
create_time: formatDate(createDate),
is_read: Math.random() > 0.5 ? 1 : 0,
type: Math.random() > 0.5 ? 'notice' : 'system'
title: title, // API 返回的标题字段
note: `这是一条关于"${title}"的通知。\n点击查看详情了解更多信息。`, // 消息内容(note 字段)
created_time: formatDate(createDate), // 发消息时间
status: isUnread ? 'send' : 'read', // send=已发送未读取,read=已读取
pk_id: Math.floor(Math.random() * 10000) // 计划书订单 ID(可选)
}
}
......@@ -646,21 +658,52 @@ function formatDate(date) {
/**
* Mock: myListAPI (消息列表)
*
* @description 第1页(page=0)前面会插入三条测试消息用于测试计划书查看功能
*/
export async function mockMessageListAPI(params) {
await mockDelay()
const { page = 1, limit = 10 } = params
const { page = 0, limit = 10 } = params // 前端传的是从0开始的页码
const totalPages = 8
if (page > totalPages) {
if (page >= totalPages) {
return { code: 1, msg: 'success', data: { list: [] } }
}
const list = []
const startIndex = (page - 1) * limit
for (let i = 0; i < limit; i++) {
// 第1页(page=0):在前面插入三条测试消息(用于测试计划书查看)
if (page === 0) {
list.push(
{
id: '1001',
title: '【测试】已生成计划书(单文件)',
note: '测试场景:状态为"已生成",只有一个计划书文件,可直接查看。',
created_time: '2026-02-13',
status: 'send'
},
{
id: '1002',
title: '【测试】已生成计划书(多文件)',
note: '测试场景:状态为"已生成",有3个计划书文件,点击后会显示选择弹框。',
created_time: '2026-02-13',
status: 'send'
},
{
id: '1003',
title: '【测试】已查看计划书',
note: '测试场景:状态为"已查看",查看后不会再次标记。',
created_time: '2026-02-13',
status: 'read'
}
)
}
const startIndex = page * limit
const remainingCount = limit - list.length
for (let i = 0; i < remainingCount; i++) {
list.push(generateMessageItem(startIndex + i + 1))
}
......@@ -673,6 +716,136 @@ export async function mockMessageListAPI(params) {
}
}
/**
* Mock: detailAPI (消息详情)
*
* @description 根据 ID 返回消息详情,包含完整的 proposal 数据
* @param {Object} params 请求参数
* @param {string|number} params.i 消息ID
* @returns {Promise} 详情数据
*
* @description 测试数据说明:
* - id='1001': 已生成 + 单文件
* - id='1002': 已生成 + 多文件 (3个文件)
* - id='1003': 已查看 + 单文件
* - 其他ID: 待处理状态 + 2个文件
*/
export async function mockDetailAPI(params) {
await mockDelay()
const { i: id } = params
if (!id) {
return { code: 0, msg: '消息ID不能为空', data: null }
}
// 生成基础消息数据
const messageItem = generateMessageItem(id)
// 根据消息 ID 返回不同状态的计划书数据(用于测试)
let proposal = null
if (id === '1001') {
// 场景1: 已生成 + 单文件
proposal = {
id: 1001,
customer_name: '张三',
product_name: '年金险产品A',
categories: [{ id: '1', name: '基本信息' }],
created_time: messageItem.created_time,
order_status: '7', // 已生成
proposal_files: [
{
id: 1,
file_name: '计划书.pdf',
file_url: TEST_FILES.pdf[0] // 使用真实的 PDF 测试文件
}
]
}
} else if (id === '1002') {
// 场景2: 已生成 + 多文件 (3个文件)
proposal = {
id: 1002,
customer_name: '李四',
product_name: '终身寿险产品B',
categories: [
{ id: '1', name: '基本信息' },
{ id: '2', name: '保障内容' },
{ id: '3', name: '缴费方式' }
],
created_time: messageItem.created_time,
order_status: '7', // 已生成
proposal_files: [
{
id: 1,
file_name: '计划书完整版.pdf',
file_url: TEST_FILES.pdf[0]
},
{
id: 2,
file_name: '产品条款说明书.pdf',
file_url: TEST_FILES.pdf[0]
},
{
id: 3,
file_name: '费率表.pdf',
file_url: TEST_FILES.pdf[0]
}
]
}
} else if (id === '1003') {
// 场景3: 已查看 + 单文件
proposal = {
id: 1003,
customer_name: '王五',
product_name: '重疾险产品C',
categories: [{ id: '1', name: '基本信息' }],
created_time: messageItem.created_time,
order_status: '9', // 已查看
proposal_files: [
{
id: 1,
file_name: '计划书.pdf',
file_url: TEST_FILES.pdf[0]
}
]
}
} else {
// 默认: 待处理状态 + 2个文件(无法查看)
proposal = {
id: id,
customer_name: '测试用户',
product_name: '测试产品',
categories: [{ id: '1', name: '基本信息' }],
created_time: messageItem.created_time,
order_status: '3', // 待处理
proposal_files: [
{
id: 1,
file_name: '计划书文件.pdf',
file_url: TEST_FILES.pdf[0]
},
{
id: 2,
file_name: '产品说明.pdf',
file_url: TEST_FILES.pdf[0]
}
]
}
}
console.log(`[Mock] detailAPI - 消息ID: ${id}, 计划书状态: ${proposal.order_status}`)
return {
code: 1,
msg: 'success',
data: {
...messageItem,
proposal
}
}
}
// ============================================================================
// 6. 收藏列表 Mock (favoriteListAPI)
// ============================================================================
......@@ -1018,6 +1191,8 @@ export async function mockAPI(apiName, params) {
return await mockSearchAPI(params)
case 'myListAPI':
return await mockMessageListAPI(params)
case 'detailAPI':
return await mockDetailAPI(params)
case 'favoriteListAPI':
return await mockFavoriteListAPI(params)
case 'feedbackListAPI':
......