hookehuyr

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

新增订单驳回状态常量与映射配置,调整计划书查看权限逻辑以允许访问带文件的驳回工单,新增列表页驳回理由展示与交互组件,同步更新API文档、类型定义、模拟数据及测试用例。
......@@ -54,7 +54,7 @@ export const deleteAPI = (params) => fn(fetch.post(Api.Delete, params));
* @description 计划书列表
* @remark
* @param {Object} params 请求参数
* @param {string} params.status (可选) 3:待处理,5:处理中,7:已生成,9:已查看
* @param {string} params.status (可选) 3:待处理,5:处理中,7:已生成,9:已查看,11:驳回
* @param {string} params.keyword (可选)
* @param {string} params.limit (可选)
* @param {string} params.page (可选)
......@@ -72,6 +72,7 @@ export const deleteAPI = (params) => fn(fetch.post(Api.Delete, params));
}>;
created_time: string; // 创建时间
order_status: string; // 状态
reject_reason?: string; // 驳回理由
proposal_files: Array<{
file_name: string; // 名称
file_url: string; // 地址
......
/*
* @Date: 2026-02-13 01:05:52
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2026-02-27 13:17:37
* @LastEditTime: 2026-05-28 14:35:04
* @FilePath: /manulife-weapp/src/config/app.js
* @Description: 应用配置
*/
......
......@@ -28,7 +28,9 @@ export const ORDER_STATUS = {
/** 已生成 - 对应 API 值 '7' */
GENERATED: '7',
/** 已查看 - 对应 API 值 '9' */
VIEWED: '9'
VIEWED: '9',
/** 已驳回 - 对应 API 值 '11' */
REJECTED: '11'
}
/**
......@@ -41,7 +43,8 @@ export const ORDER_STATUS_MAP = {
[ORDER_STATUS.PENDING]: 'pending',
[ORDER_STATUS.PROCESSING]: 'processing',
[ORDER_STATUS.GENERATED]: 'generated',
[ORDER_STATUS.VIEWED]: 'viewed'
[ORDER_STATUS.VIEWED]: 'viewed',
[ORDER_STATUS.REJECTED]: 'rejected'
}
/**
......@@ -54,7 +57,8 @@ export const ORDER_STATUS_TEXT = {
pending: '待处理',
processing: '处理中',
generated: '已生成',
viewed: '已查看'
viewed: '已查看',
rejected: '驳回'
}
/**
......
......@@ -82,7 +82,8 @@
item.status === 'pending' ? 'status-pending' : '',
item.status === 'processing' ? 'status-processing' : '',
item.status === 'generated' ? 'status-generated' : '',
item.status === 'viewed' ? 'status-viewed' : ''
item.status === 'viewed' ? 'status-viewed' : '',
item.status === 'rejected' ? 'status-rejected' : ''
]"
>
{{ getStatusText(item.status) }}
......@@ -91,9 +92,53 @@
</view>
<!-- Info -->
<view class="flex justify-between items-center text-gray-500 text-[24rpx] mb-[24rpx]">
<text>{{ item.client }}</text>
<text>{{ item.date }}</text>
<view class="text-gray-500 text-[24rpx] mb-[24rpx]">
<view class="flex justify-between items-center gap-[24rpx]">
<text>{{ item.client }}</text>
<text class="shrink-0">{{ item.date }}</text>
</view>
<view
v-if="item.status === 'rejected' && item.rejectReason"
class="reject-reason-box relative mt-[16rpx] rounded-[16rpx] px-[20rpx] py-[16rpx]"
>
<text class="reject-reason-label text-[22rpx] font-medium">驳回理由</text>
<view
v-if="!item.rejectReasonExpanded"
class="reject-reason-text relative mt-[8rpx] text-[24rpx] leading-[1.6]"
>
<view
:class="[
'break-all',
item.rejectReasonOverflow ? 'reject-reason-clamp reject-reason-content-with-expand' : ''
]"
>
{{ item.rejectReason }}
</view>
<text
v-if="item.rejectReasonOverflow"
class="reject-reason-expand reject-reason-action font-medium"
@tap.stop="toggleRejectReason(item)"
>
展开
</text>
<view
:id="getRejectMeasureId(item.id)"
class="reject-reason-measure reject-reason-content-with-expand break-all"
>
{{ item.rejectReason }}
</view>
</view>
<view v-else class="reject-reason-text mt-[8rpx] text-[24rpx] leading-[1.6] break-all">
<text>{{ item.rejectReason }}</text>
<text
v-if="item.rejectReasonOverflow"
class="reject-reason-action ml-[8rpx] font-medium"
@tap.stop="toggleRejectReason(item)"
>
收起
</text>
</view>
</view>
</view>
<!-- Divider -->
......@@ -160,6 +205,8 @@ const hasMore = ref(true)
const currentList = ref([])
const currentPage = ref(0) // 当前页码(从0开始)
const pageSize = 20 // 每页数量
const REJECT_REASON_MAX_LINES = 2
const REJECT_REASON_LINE_HEIGHT_RPX = 24 * 1.6
/**
* Tab 数据源
......@@ -170,6 +217,7 @@ const pageSize = 20 // 每页数量
* - "5" = 处理中
* - "7" = 已生成
* - "9" = 已查看
* - "11" = 驳回
*/
const tabsData = ref([
{ id: '', name: '全部', list: [] },
......@@ -204,6 +252,9 @@ const transformApiItem = (apiItem) => {
date: apiItem.created_time || '',
tag: categoryName,
status: mapOrderStatus(apiItem.order_status),
rejectReason: apiItem.reject_reason || '',
rejectReasonExpanded: false,
rejectReasonOverflow: false,
// 默认显示第一个文件
fileName: firstFile?.file_name || '',
downloadUrl: firstFile?.file_url || '',
......@@ -226,6 +277,7 @@ const transformApiItem = (apiItem) => {
* - activeTabId = '5':只显示处理中的计划书
* - activeTabId = '7':只显示已生成的计划书
* - activeTabId = '9':只显示已查看的计划书
* - activeTabId = '11':只显示驳回的计划书(当前页面暂未提供单独 Tab)
*
* ✅ **API参数说明**:
* ```javascript
......@@ -242,6 +294,7 @@ const transformApiItem = (apiItem) => {
* - "5" = 处理中
* - "7" = 已生成
* - "9" = 已查看
* - "11" = 驳回
*
* 🔧 **Mock 数据说明**:
* 开发环境会使用 Mock 数据测试分页、筛选、搜索功能
......@@ -289,6 +342,8 @@ const fetchPlanList = async (page = 0, limit = pageSize, isLoadMore = false) =>
currentList.value = transformedList
}
await measureRejectReasonOverflow()
// 判断是否还有更多数据
// 如果返回的数据量少于请求的量,说明没有更多了
hasMore.value = transformedList.length >= limit
......@@ -316,6 +371,60 @@ const fetchPlanList = async (page = 0, limit = pageSize, isLoadMore = false) =>
}
/**
* 将 rpx 转为 px,便于与节点测量结果比较。
* @param {number} rpx - 设计稿 rpx 数值
* @returns {number} 转换后的 px 数值
*/
const rpxToPx = (rpx) => {
const { windowWidth = 375 } = Taro.getSystemInfoSync()
return (windowWidth / 750) * rpx
}
/**
* 生成驳回理由隐藏测量节点 ID。
* @param {string|number} itemId - 列表项 ID
* @returns {string} 节点 ID
*/
const getRejectMeasureId = (itemId) => `reject-reason-measure-${itemId}`
/**
* 计算驳回理由是否超过两行。
* @description 列表页使用隐藏节点测量真实高度,只在超出两行时显示展开入口。
*/
const measureRejectReasonOverflow = async () => {
const rejectedItems = currentList.value.filter(item => item.status === 'rejected' && item.rejectReason)
if (rejectedItems.length === 0) {
return
}
await nextTick()
const maxHeight = rpxToPx(REJECT_REASON_LINE_HEIGHT_RPX * REJECT_REASON_MAX_LINES) + 1
const query = Taro.createSelectorQuery()
rejectedItems.forEach((item) => {
query.select(`#${getRejectMeasureId(item.id)}`).boundingClientRect()
})
query.exec((rects = []) => {
rects.forEach((rect, index) => {
const item = rejectedItems[index]
if (!item) {
return
}
item.rejectReasonOverflow = Boolean(rect && rect.height > maxHeight)
if (!item.rejectReasonOverflow) {
item.rejectReasonExpanded = false
}
})
})
}
/**
* Tab 点击处理
*/
const onTabClick = (id) => {
......@@ -491,6 +600,14 @@ const onDelete = async (item) => {
}
})
}
/**
* 切换驳回理由展开状态
* @param {Object} item - 计划书对象
*/
const toggleRejectReason = (item) => {
item.rejectReasonExpanded = !item.rejectReasonExpanded
}
</script>
<style lang="less">
......@@ -623,4 +740,56 @@ const onDelete = async (item) => {
background-color: #E5E7EB;
color: #6B7280;
}
.status-rejected {
background-color: #FEE2E2;
color: #DC2626;
}
.reject-reason-box {
background-color: #F8F4EE;
}
.reject-reason-label {
color: #8B6B4A;
}
.reject-reason-text {
color: #5F5A54;
}
.reject-reason-action {
color: #8A735C;
}
.reject-reason-clamp {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
.reject-reason-content-with-expand {
padding-right: 124rpx;
}
.reject-reason-expand {
position: absolute;
right: 0;
bottom: 0;
padding-left: 36rpx;
line-height: inherit;
background: linear-gradient(90deg, rgba(248, 244, 238, 0) 0%, #F8F4EE 30%, #F8F4EE 100%);
}
.reject-reason-measure {
position: absolute;
left: 0;
right: 0;
top: 0;
visibility: hidden;
pointer-events: none;
z-index: -1;
}
</style>
......
......@@ -23,6 +23,13 @@ describe('proposalView', () => {
})).toBe(false)
})
it('驳回且有文件时应该允许查看', () => {
expect(canViewProposal({
order_status: '11',
proposal_files: [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf' }]
})).toBe(true)
})
it('应该兼容 proposalFiles 字段', () => {
const files = [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf' }]
......
......@@ -1067,7 +1067,14 @@ const PLAN_PRODUCT_NAMES = [
'健康保险计划'
]
const PLAN_STATUS = ['3', '5', '7', '9'] // 3=待处理, 5=处理中, 7=已生成, 9=已查看
const PLAN_STATUS = ['3', '5', '7', '9', '11'] // 3=待处理, 5=处理中, 7=已生成, 9=已查看, 11=驳回
const PLAN_REJECT_REASONS = [
'客户资料填写不完整,请补充完整的身份证明文件后重新提交。',
'方案参数与当前产品投保规则不符,请调整缴费年期与提取设置。',
'系统审核发现申请人与产品适配性不足,建议重新评估保障需求后再生成计划书。',
'关键资料缺失,暂无法继续生成计划书,请补充健康告知及财务证明信息。',
]
const PLAN_CATEGORIES = [
{ id: '1', name: '人寿保险' },
......@@ -1093,6 +1100,9 @@ function generatePlanItem(id) {
const customerName = CUSTOMER_NAMES[Math.floor(Math.random() * CUSTOMER_NAMES.length)]
const orderStatus = PLAN_STATUS[Math.floor(Math.random() * PLAN_STATUS.length)]
const category = PLAN_CATEGORIES[Math.floor(Math.random() * PLAN_CATEGORIES.length)]
const rejectReason = orderStatus === '11'
? PLAN_REJECT_REASONS[Math.floor(Math.random() * PLAN_REJECT_REASONS.length)]
: ''
// 生成创建时间(最近30天内)
const now = new Date()
......@@ -1122,6 +1132,7 @@ function generatePlanItem(id) {
categories: [category],
created_time: formatDate(createTime),
order_status: orderStatus,
reject_reason: rejectReason,
proposal_files: proposalFiles
}
}
......@@ -1132,7 +1143,7 @@ function generatePlanItem(id) {
* @param {Object} params - 请求参数
* @param {number} params.page - 页码(从0开始)
* @param {number} params.limit - 每页数量(默认20)
* @param {string} [params.status] - 状态筛选(3=待处理, 5=处理中, 7=已生成, 9=已查看)
* @param {string} [params.status] - 状态筛选(3=待处理, 5=处理中, 7=已生成, 9=已查看, 11=驳回
* @param {string} [params.keyword] - 搜索关键字
* @returns {Promise<Object>} Mock 响应
*/
......
......@@ -37,7 +37,7 @@ export const getProposalFiles = (proposal = {}) => {
*
* 当前业务规则:
* - 待处理不可查看
* - 处理中/已生成/已查看,只要存在文件就允许查看
* - 只要存在文件,除待处理外都允许查看
*/
export const canViewProposal = (proposal = {}) => {
const status = getProposalStatus(proposal)
......@@ -47,5 +47,5 @@ export const canViewProposal = (proposal = {}) => {
return false
}
return status === 'processing' || status === 'generated' || status === 'viewed'
return status !== 'pending'
}
......