hookehuyr

feat(plan): 新增驳回工单支持并展示驳回理由

新增订单驳回状态常量与映射配置,调整计划书查看权限逻辑以允许访问带文件的驳回工单,新增列表页驳回理由展示与交互组件,同步更新API文档、类型定义、模拟数据及测试用例。
...@@ -54,7 +54,7 @@ export const deleteAPI = (params) => fn(fetch.post(Api.Delete, params)); ...@@ -54,7 +54,7 @@ export const deleteAPI = (params) => fn(fetch.post(Api.Delete, params));
54 * @description 计划书列表 54 * @description 计划书列表
55 * @remark 55 * @remark
56 * @param {Object} params 请求参数 56 * @param {Object} params 请求参数
57 - * @param {string} params.status (可选) 3:待处理,5:处理中,7:已生成,9:已查看 57 + * @param {string} params.status (可选) 3:待处理,5:处理中,7:已生成,9:已查看,11:驳回
58 * @param {string} params.keyword (可选) 58 * @param {string} params.keyword (可选)
59 * @param {string} params.limit (可选) 59 * @param {string} params.limit (可选)
60 * @param {string} params.page (可选) 60 * @param {string} params.page (可选)
...@@ -72,6 +72,7 @@ export const deleteAPI = (params) => fn(fetch.post(Api.Delete, params)); ...@@ -72,6 +72,7 @@ export const deleteAPI = (params) => fn(fetch.post(Api.Delete, params));
72 }>; 72 }>;
73 created_time: string; // 创建时间 73 created_time: string; // 创建时间
74 order_status: string; // 状态 74 order_status: string; // 状态
75 + reject_reason?: string; // 驳回理由
75 proposal_files: Array<{ 76 proposal_files: Array<{
76 file_name: string; // 名称 77 file_name: string; // 名称
77 file_url: string; // 地址 78 file_url: string; // 地址
......
1 /* 1 /*
2 * @Date: 2026-02-13 01:05:52 2 * @Date: 2026-02-13 01:05:52
3 * @LastEditors: hookehuyr hookehuyr@gmail.com 3 * @LastEditors: hookehuyr hookehuyr@gmail.com
4 - * @LastEditTime: 2026-02-27 13:17:37 4 + * @LastEditTime: 2026-05-28 14:35:04
5 * @FilePath: /manulife-weapp/src/config/app.js 5 * @FilePath: /manulife-weapp/src/config/app.js
6 * @Description: 应用配置 6 * @Description: 应用配置
7 */ 7 */
......
...@@ -28,7 +28,9 @@ export const ORDER_STATUS = { ...@@ -28,7 +28,9 @@ export const ORDER_STATUS = {
28 /** 已生成 - 对应 API 值 '7' */ 28 /** 已生成 - 对应 API 值 '7' */
29 GENERATED: '7', 29 GENERATED: '7',
30 /** 已查看 - 对应 API 值 '9' */ 30 /** 已查看 - 对应 API 值 '9' */
31 - VIEWED: '9' 31 + VIEWED: '9',
32 + /** 已驳回 - 对应 API 值 '11' */
33 + REJECTED: '11'
32 } 34 }
33 35
34 /** 36 /**
...@@ -41,7 +43,8 @@ export const ORDER_STATUS_MAP = { ...@@ -41,7 +43,8 @@ export const ORDER_STATUS_MAP = {
41 [ORDER_STATUS.PENDING]: 'pending', 43 [ORDER_STATUS.PENDING]: 'pending',
42 [ORDER_STATUS.PROCESSING]: 'processing', 44 [ORDER_STATUS.PROCESSING]: 'processing',
43 [ORDER_STATUS.GENERATED]: 'generated', 45 [ORDER_STATUS.GENERATED]: 'generated',
44 - [ORDER_STATUS.VIEWED]: 'viewed' 46 + [ORDER_STATUS.VIEWED]: 'viewed',
47 + [ORDER_STATUS.REJECTED]: 'rejected'
45 } 48 }
46 49
47 /** 50 /**
...@@ -54,7 +57,8 @@ export const ORDER_STATUS_TEXT = { ...@@ -54,7 +57,8 @@ export const ORDER_STATUS_TEXT = {
54 pending: '待处理', 57 pending: '待处理',
55 processing: '处理中', 58 processing: '处理中',
56 generated: '已生成', 59 generated: '已生成',
57 - viewed: '已查看' 60 + viewed: '已查看',
61 + rejected: '驳回'
58 } 62 }
59 63
60 /** 64 /**
......
...@@ -82,7 +82,8 @@ ...@@ -82,7 +82,8 @@
82 item.status === 'pending' ? 'status-pending' : '', 82 item.status === 'pending' ? 'status-pending' : '',
83 item.status === 'processing' ? 'status-processing' : '', 83 item.status === 'processing' ? 'status-processing' : '',
84 item.status === 'generated' ? 'status-generated' : '', 84 item.status === 'generated' ? 'status-generated' : '',
85 - item.status === 'viewed' ? 'status-viewed' : '' 85 + item.status === 'viewed' ? 'status-viewed' : '',
86 + item.status === 'rejected' ? 'status-rejected' : ''
86 ]" 87 ]"
87 > 88 >
88 {{ getStatusText(item.status) }} 89 {{ getStatusText(item.status) }}
...@@ -91,9 +92,53 @@ ...@@ -91,9 +92,53 @@
91 </view> 92 </view>
92 93
93 <!-- Info --> 94 <!-- Info -->
94 - <view class="flex justify-between items-center text-gray-500 text-[24rpx] mb-[24rpx]"> 95 + <view class="text-gray-500 text-[24rpx] mb-[24rpx]">
95 - <text>{{ item.client }}</text> 96 + <view class="flex justify-between items-center gap-[24rpx]">
96 - <text>{{ item.date }}</text> 97 + <text>{{ item.client }}</text>
98 + <text class="shrink-0">{{ item.date }}</text>
99 + </view>
100 + <view
101 + v-if="item.status === 'rejected' && item.rejectReason"
102 + class="reject-reason-box relative mt-[16rpx] rounded-[16rpx] px-[20rpx] py-[16rpx]"
103 + >
104 + <text class="reject-reason-label text-[22rpx] font-medium">驳回理由</text>
105 + <view
106 + v-if="!item.rejectReasonExpanded"
107 + class="reject-reason-text relative mt-[8rpx] text-[24rpx] leading-[1.6]"
108 + >
109 + <view
110 + :class="[
111 + 'break-all',
112 + item.rejectReasonOverflow ? 'reject-reason-clamp reject-reason-content-with-expand' : ''
113 + ]"
114 + >
115 + {{ item.rejectReason }}
116 + </view>
117 + <text
118 + v-if="item.rejectReasonOverflow"
119 + class="reject-reason-expand reject-reason-action font-medium"
120 + @tap.stop="toggleRejectReason(item)"
121 + >
122 + 展开
123 + </text>
124 + <view
125 + :id="getRejectMeasureId(item.id)"
126 + class="reject-reason-measure reject-reason-content-with-expand break-all"
127 + >
128 + {{ item.rejectReason }}
129 + </view>
130 + </view>
131 + <view v-else class="reject-reason-text mt-[8rpx] text-[24rpx] leading-[1.6] break-all">
132 + <text>{{ item.rejectReason }}</text>
133 + <text
134 + v-if="item.rejectReasonOverflow"
135 + class="reject-reason-action ml-[8rpx] font-medium"
136 + @tap.stop="toggleRejectReason(item)"
137 + >
138 + 收起
139 + </text>
140 + </view>
141 + </view>
97 </view> 142 </view>
98 143
99 <!-- Divider --> 144 <!-- Divider -->
...@@ -160,6 +205,8 @@ const hasMore = ref(true) ...@@ -160,6 +205,8 @@ const hasMore = ref(true)
160 const currentList = ref([]) 205 const currentList = ref([])
161 const currentPage = ref(0) // 当前页码(从0开始) 206 const currentPage = ref(0) // 当前页码(从0开始)
162 const pageSize = 20 // 每页数量 207 const pageSize = 20 // 每页数量
208 +const REJECT_REASON_MAX_LINES = 2
209 +const REJECT_REASON_LINE_HEIGHT_RPX = 24 * 1.6
163 210
164 /** 211 /**
165 * Tab 数据源 212 * Tab 数据源
...@@ -170,6 +217,7 @@ const pageSize = 20 // 每页数量 ...@@ -170,6 +217,7 @@ const pageSize = 20 // 每页数量
170 * - "5" = 处理中 217 * - "5" = 处理中
171 * - "7" = 已生成 218 * - "7" = 已生成
172 * - "9" = 已查看 219 * - "9" = 已查看
220 + * - "11" = 驳回
173 */ 221 */
174 const tabsData = ref([ 222 const tabsData = ref([
175 { id: '', name: '全部', list: [] }, 223 { id: '', name: '全部', list: [] },
...@@ -204,6 +252,9 @@ const transformApiItem = (apiItem) => { ...@@ -204,6 +252,9 @@ const transformApiItem = (apiItem) => {
204 date: apiItem.created_time || '', 252 date: apiItem.created_time || '',
205 tag: categoryName, 253 tag: categoryName,
206 status: mapOrderStatus(apiItem.order_status), 254 status: mapOrderStatus(apiItem.order_status),
255 + rejectReason: apiItem.reject_reason || '',
256 + rejectReasonExpanded: false,
257 + rejectReasonOverflow: false,
207 // 默认显示第一个文件 258 // 默认显示第一个文件
208 fileName: firstFile?.file_name || '', 259 fileName: firstFile?.file_name || '',
209 downloadUrl: firstFile?.file_url || '', 260 downloadUrl: firstFile?.file_url || '',
...@@ -226,6 +277,7 @@ const transformApiItem = (apiItem) => { ...@@ -226,6 +277,7 @@ const transformApiItem = (apiItem) => {
226 * - activeTabId = '5':只显示处理中的计划书 277 * - activeTabId = '5':只显示处理中的计划书
227 * - activeTabId = '7':只显示已生成的计划书 278 * - activeTabId = '7':只显示已生成的计划书
228 * - activeTabId = '9':只显示已查看的计划书 279 * - activeTabId = '9':只显示已查看的计划书
280 + * - activeTabId = '11':只显示驳回的计划书(当前页面暂未提供单独 Tab)
229 * 281 *
230 * ✅ **API参数说明**: 282 * ✅ **API参数说明**:
231 * ```javascript 283 * ```javascript
...@@ -242,6 +294,7 @@ const transformApiItem = (apiItem) => { ...@@ -242,6 +294,7 @@ const transformApiItem = (apiItem) => {
242 * - "5" = 处理中 294 * - "5" = 处理中
243 * - "7" = 已生成 295 * - "7" = 已生成
244 * - "9" = 已查看 296 * - "9" = 已查看
297 + * - "11" = 驳回
245 * 298 *
246 * 🔧 **Mock 数据说明**: 299 * 🔧 **Mock 数据说明**:
247 * 开发环境会使用 Mock 数据测试分页、筛选、搜索功能 300 * 开发环境会使用 Mock 数据测试分页、筛选、搜索功能
...@@ -289,6 +342,8 @@ const fetchPlanList = async (page = 0, limit = pageSize, isLoadMore = false) => ...@@ -289,6 +342,8 @@ const fetchPlanList = async (page = 0, limit = pageSize, isLoadMore = false) =>
289 currentList.value = transformedList 342 currentList.value = transformedList
290 } 343 }
291 344
345 + await measureRejectReasonOverflow()
346 +
292 // 判断是否还有更多数据 347 // 判断是否还有更多数据
293 // 如果返回的数据量少于请求的量,说明没有更多了 348 // 如果返回的数据量少于请求的量,说明没有更多了
294 hasMore.value = transformedList.length >= limit 349 hasMore.value = transformedList.length >= limit
...@@ -316,6 +371,60 @@ const fetchPlanList = async (page = 0, limit = pageSize, isLoadMore = false) => ...@@ -316,6 +371,60 @@ const fetchPlanList = async (page = 0, limit = pageSize, isLoadMore = false) =>
316 } 371 }
317 372
318 /** 373 /**
374 + * 将 rpx 转为 px,便于与节点测量结果比较。
375 + * @param {number} rpx - 设计稿 rpx 数值
376 + * @returns {number} 转换后的 px 数值
377 + */
378 +const rpxToPx = (rpx) => {
379 + const { windowWidth = 375 } = Taro.getSystemInfoSync()
380 + return (windowWidth / 750) * rpx
381 +}
382 +
383 +/**
384 + * 生成驳回理由隐藏测量节点 ID。
385 + * @param {string|number} itemId - 列表项 ID
386 + * @returns {string} 节点 ID
387 + */
388 +const getRejectMeasureId = (itemId) => `reject-reason-measure-${itemId}`
389 +
390 +/**
391 + * 计算驳回理由是否超过两行。
392 + * @description 列表页使用隐藏节点测量真实高度,只在超出两行时显示展开入口。
393 + */
394 +const measureRejectReasonOverflow = async () => {
395 + const rejectedItems = currentList.value.filter(item => item.status === 'rejected' && item.rejectReason)
396 +
397 + if (rejectedItems.length === 0) {
398 + return
399 + }
400 +
401 + await nextTick()
402 +
403 + const maxHeight = rpxToPx(REJECT_REASON_LINE_HEIGHT_RPX * REJECT_REASON_MAX_LINES) + 1
404 + const query = Taro.createSelectorQuery()
405 +
406 + rejectedItems.forEach((item) => {
407 + query.select(`#${getRejectMeasureId(item.id)}`).boundingClientRect()
408 + })
409 +
410 + query.exec((rects = []) => {
411 + rects.forEach((rect, index) => {
412 + const item = rejectedItems[index]
413 +
414 + if (!item) {
415 + return
416 + }
417 +
418 + item.rejectReasonOverflow = Boolean(rect && rect.height > maxHeight)
419 +
420 + if (!item.rejectReasonOverflow) {
421 + item.rejectReasonExpanded = false
422 + }
423 + })
424 + })
425 +}
426 +
427 +/**
319 * Tab 点击处理 428 * Tab 点击处理
320 */ 429 */
321 const onTabClick = (id) => { 430 const onTabClick = (id) => {
...@@ -491,6 +600,14 @@ const onDelete = async (item) => { ...@@ -491,6 +600,14 @@ const onDelete = async (item) => {
491 } 600 }
492 }) 601 })
493 } 602 }
603 +
604 +/**
605 + * 切换驳回理由展开状态
606 + * @param {Object} item - 计划书对象
607 + */
608 +const toggleRejectReason = (item) => {
609 + item.rejectReasonExpanded = !item.rejectReasonExpanded
610 +}
494 </script> 611 </script>
495 612
496 <style lang="less"> 613 <style lang="less">
...@@ -623,4 +740,56 @@ const onDelete = async (item) => { ...@@ -623,4 +740,56 @@ const onDelete = async (item) => {
623 background-color: #E5E7EB; 740 background-color: #E5E7EB;
624 color: #6B7280; 741 color: #6B7280;
625 } 742 }
743 +
744 +.status-rejected {
745 + background-color: #FEE2E2;
746 + color: #DC2626;
747 +}
748 +
749 +.reject-reason-box {
750 + background-color: #F8F4EE;
751 +}
752 +
753 +.reject-reason-label {
754 + color: #8B6B4A;
755 +}
756 +
757 +.reject-reason-text {
758 + color: #5F5A54;
759 +}
760 +
761 +.reject-reason-action {
762 + color: #8A735C;
763 +}
764 +
765 +.reject-reason-clamp {
766 + display: -webkit-box;
767 + overflow: hidden;
768 + -webkit-box-orient: vertical;
769 + -webkit-line-clamp: 2;
770 + line-clamp: 2;
771 +}
772 +
773 +.reject-reason-content-with-expand {
774 + padding-right: 124rpx;
775 +}
776 +
777 +.reject-reason-expand {
778 + position: absolute;
779 + right: 0;
780 + bottom: 0;
781 + padding-left: 36rpx;
782 + line-height: inherit;
783 + background: linear-gradient(90deg, rgba(248, 244, 238, 0) 0%, #F8F4EE 30%, #F8F4EE 100%);
784 +}
785 +
786 +.reject-reason-measure {
787 + position: absolute;
788 + left: 0;
789 + right: 0;
790 + top: 0;
791 + visibility: hidden;
792 + pointer-events: none;
793 + z-index: -1;
794 +}
626 </style> 795 </style>
......
...@@ -23,6 +23,13 @@ describe('proposalView', () => { ...@@ -23,6 +23,13 @@ describe('proposalView', () => {
23 })).toBe(false) 23 })).toBe(false)
24 }) 24 })
25 25
26 + it('驳回且有文件时应该允许查看', () => {
27 + expect(canViewProposal({
28 + order_status: '11',
29 + proposal_files: [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf' }]
30 + })).toBe(true)
31 + })
32 +
26 it('应该兼容 proposalFiles 字段', () => { 33 it('应该兼容 proposalFiles 字段', () => {
27 const files = [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf' }] 34 const files = [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf' }]
28 35
......
...@@ -1067,7 +1067,14 @@ const PLAN_PRODUCT_NAMES = [ ...@@ -1067,7 +1067,14 @@ const PLAN_PRODUCT_NAMES = [
1067 '健康保险计划' 1067 '健康保险计划'
1068 ] 1068 ]
1069 1069
1070 -const PLAN_STATUS = ['3', '5', '7', '9'] // 3=待处理, 5=处理中, 7=已生成, 9=已查看 1070 +const PLAN_STATUS = ['3', '5', '7', '9', '11'] // 3=待处理, 5=处理中, 7=已生成, 9=已查看, 11=驳回
1071 +
1072 +const PLAN_REJECT_REASONS = [
1073 + '客户资料填写不完整,请补充完整的身份证明文件后重新提交。',
1074 + '方案参数与当前产品投保规则不符,请调整缴费年期与提取设置。',
1075 + '系统审核发现申请人与产品适配性不足,建议重新评估保障需求后再生成计划书。',
1076 + '关键资料缺失,暂无法继续生成计划书,请补充健康告知及财务证明信息。',
1077 +]
1071 1078
1072 const PLAN_CATEGORIES = [ 1079 const PLAN_CATEGORIES = [
1073 { id: '1', name: '人寿保险' }, 1080 { id: '1', name: '人寿保险' },
...@@ -1093,6 +1100,9 @@ function generatePlanItem(id) { ...@@ -1093,6 +1100,9 @@ function generatePlanItem(id) {
1093 const customerName = CUSTOMER_NAMES[Math.floor(Math.random() * CUSTOMER_NAMES.length)] 1100 const customerName = CUSTOMER_NAMES[Math.floor(Math.random() * CUSTOMER_NAMES.length)]
1094 const orderStatus = PLAN_STATUS[Math.floor(Math.random() * PLAN_STATUS.length)] 1101 const orderStatus = PLAN_STATUS[Math.floor(Math.random() * PLAN_STATUS.length)]
1095 const category = PLAN_CATEGORIES[Math.floor(Math.random() * PLAN_CATEGORIES.length)] 1102 const category = PLAN_CATEGORIES[Math.floor(Math.random() * PLAN_CATEGORIES.length)]
1103 + const rejectReason = orderStatus === '11'
1104 + ? PLAN_REJECT_REASONS[Math.floor(Math.random() * PLAN_REJECT_REASONS.length)]
1105 + : ''
1096 1106
1097 // 生成创建时间(最近30天内) 1107 // 生成创建时间(最近30天内)
1098 const now = new Date() 1108 const now = new Date()
...@@ -1122,6 +1132,7 @@ function generatePlanItem(id) { ...@@ -1122,6 +1132,7 @@ function generatePlanItem(id) {
1122 categories: [category], 1132 categories: [category],
1123 created_time: formatDate(createTime), 1133 created_time: formatDate(createTime),
1124 order_status: orderStatus, 1134 order_status: orderStatus,
1135 + reject_reason: rejectReason,
1125 proposal_files: proposalFiles 1136 proposal_files: proposalFiles
1126 } 1137 }
1127 } 1138 }
...@@ -1132,7 +1143,7 @@ function generatePlanItem(id) { ...@@ -1132,7 +1143,7 @@ function generatePlanItem(id) {
1132 * @param {Object} params - 请求参数 1143 * @param {Object} params - 请求参数
1133 * @param {number} params.page - 页码(从0开始) 1144 * @param {number} params.page - 页码(从0开始)
1134 * @param {number} params.limit - 每页数量(默认20) 1145 * @param {number} params.limit - 每页数量(默认20)
1135 - * @param {string} [params.status] - 状态筛选(3=待处理, 5=处理中, 7=已生成, 9=已查看) 1146 + * @param {string} [params.status] - 状态筛选(3=待处理, 5=处理中, 7=已生成, 9=已查看, 11=驳回
1136 * @param {string} [params.keyword] - 搜索关键字 1147 * @param {string} [params.keyword] - 搜索关键字
1137 * @returns {Promise<Object>} Mock 响应 1148 * @returns {Promise<Object>} Mock 响应
1138 */ 1149 */
......
...@@ -37,7 +37,7 @@ export const getProposalFiles = (proposal = {}) => { ...@@ -37,7 +37,7 @@ export const getProposalFiles = (proposal = {}) => {
37 * 37 *
38 * 当前业务规则: 38 * 当前业务规则:
39 * - 待处理不可查看 39 * - 待处理不可查看
40 - * - 处理中/已生成/已查看,只要存在文件就允许查看 40 + * - 只要存在文件,除待处理外都允许查看
41 */ 41 */
42 export const canViewProposal = (proposal = {}) => { 42 export const canViewProposal = (proposal = {}) => {
43 const status = getProposalStatus(proposal) 43 const status = getProposalStatus(proposal)
...@@ -47,5 +47,5 @@ export const canViewProposal = (proposal = {}) => { ...@@ -47,5 +47,5 @@ export const canViewProposal = (proposal = {}) => {
47 return false 47 return false
48 } 48 }
49 49
50 - return status === 'processing' || status === 'generated' || status === 'viewed' 50 + return status !== 'pending'
51 } 51 }
......