hookehuyr

refactor(plan): 提取计划书查看规则为统一工具函数

消除 plan/index.vue 与 message-detail/index.vue 之间重复的
order_status 映射和可查看判断逻辑,新增 proposalView 工具模块
统一管理 canViewProposal / getProposalFiles 等规则,同时支持
"处理中但已有文件"的业务场景。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
...@@ -120,6 +120,23 @@ describe('计划书模块集成测试', () => { ...@@ -120,6 +120,23 @@ describe('计划书模块集成测试', () => {
120 icon: 'none' 120 icon: 'none'
121 }) 121 })
122 }) 122 })
123 +
124 + it('应该允许查看处理中且已有文件的计划书', async () => {
125 + viewAPI.mockResolvedValue({ code: 1 })
126 + const proposal = {
127 + id: 790,
128 + order_status: '5', // PROCESSING
129 + proposal_files: [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf', id: 1 }]
130 + }
131 +
132 + await viewProposal(proposal)
133 +
134 + expect(Taro.showToast).toHaveBeenCalledWith({
135 + title: '已标记为查看',
136 + icon: 'success'
137 + })
138 + expect(viewAPI).toHaveBeenCalledWith({ i: 790 })
139 + })
123 }) 140 })
124 141
125 describe('字段依赖关系测试', () => { 142 describe('字段依赖关系测试', () => {
......
...@@ -15,6 +15,7 @@ import Taro from '@tarojs/taro' ...@@ -15,6 +15,7 @@ import Taro from '@tarojs/taro'
15 import { mapOrderStatus, getStatusText } from '@/config/constants/orderStatus' 15 import { mapOrderStatus, getStatusText } from '@/config/constants/orderStatus'
16 import { viewAPI } from '@/api/plan' 16 import { viewAPI } from '@/api/plan'
17 import { useFileOperation } from './useFileOperation' 17 import { useFileOperation } from './useFileOperation'
18 +import { canViewProposal, getProposalFiles } from '@/utils/proposalView'
18 19
19 export const viewProposal = async (proposal, callbacks = {}) => { 20 export const viewProposal = async (proposal, callbacks = {}) => {
20 const { beforeView, onViewSuccess, onViewError, onError } = callbacks 21 const { beforeView, onViewSuccess, onViewError, onError } = callbacks
...@@ -41,26 +42,25 @@ export const viewProposal = async (proposal, callbacks = {}) => { ...@@ -41,26 +42,25 @@ export const viewProposal = async (proposal, callbacks = {}) => {
41 } 42 }
42 43
43 const status = proposal.status || mapOrderStatus(proposal.order_status) 44 const status = proposal.status || mapOrderStatus(proposal.order_status)
44 - if (status === 'pending' || status === 'processing') { 45 + if (!canViewProposal(proposal)) {
46 + // `pending` 与“已有状态但文件为空”是两类不同问题,提示文案需要区分。
45 Taro.showToast({ 47 Taro.showToast({
46 - title: '计划书尚未生成,请稍后', 48 + title: status === 'pending' ? '计划书尚未生成,请稍后' : '暂无可查看的计划书',
47 icon: 'none' 49 icon: 'none'
48 }) 50 })
49 - emitError(new Error(`计划书状态不允许查看: ${getStatusText(status)}`)) 51 + const error = new Error(
52 + status === 'pending'
53 + ? `计划书状态不允许查看: ${getStatusText(status)}`
54 + : 'proposalFiles 为空'
55 + )
56 + if (status !== 'pending') {
57 + console.error('[usePlanView] proposalFiles 为空:', proposal)
58 + }
59 + emitError(error)
50 return 60 return
51 } 61 }
52 62
53 - const proposalFiles = proposal.proposal_files || proposal.proposalFiles || [] 63 + const proposalFiles = getProposalFiles(proposal)
54 -
55 - if (!proposalFiles || proposalFiles.length === 0) {
56 - Taro.showToast({
57 - title: '暂无可查看的计划书',
58 - icon: 'none'
59 - })
60 - console.error('[usePlanView] proposalFiles 为空:', proposal)
61 - emitError(new Error('proposalFiles 为空'))
62 - return
63 - }
64 64
65 if (beforeView) { 65 if (beforeView) {
66 try { 66 try {
......
...@@ -74,7 +74,7 @@ ...@@ -74,7 +74,7 @@
74 74
75 <!-- 查看按钮 --> 75 <!-- 查看按钮 -->
76 <nut-button 76 <nut-button
77 - v-if="canViewProposal" 77 + v-if="canViewCurrentProposal"
78 type="primary" 78 type="primary"
79 color="#2563EB" 79 color="#2563EB"
80 block 80 block
...@@ -108,6 +108,8 @@ import { useUserStore } from '@/stores/user' ...@@ -108,6 +108,8 @@ import { useUserStore } from '@/stores/user'
108 import { mockDetailAPI } from '@/utils/mockData' 108 import { mockDetailAPI } from '@/utils/mockData'
109 import { USE_MOCK_DATA } from '@/config/app' 109 import { USE_MOCK_DATA } from '@/config/app'
110 import { usePlanView } from '@/composables/usePlanView' 110 import { usePlanView } from '@/composables/usePlanView'
111 +import { mapOrderStatus, getStatusText } from '@/config/constants/orderStatus'
112 +import { canViewProposal as canViewProposalRule, getProposalFiles } from '@/utils/proposalView'
111 113
112 const detail = ref(null) 114 const detail = ref(null)
113 const loading = ref(true) 115 const loading = ref(true)
...@@ -123,16 +125,7 @@ const { viewProposal } = usePlanView() ...@@ -123,16 +125,7 @@ const { viewProposal } = usePlanView()
123 */ 125 */
124 const proposalStatus = computed(() => { 126 const proposalStatus = computed(() => {
125 if (!detail.value?.proposal) return null 127 if (!detail.value?.proposal) return null
126 - 128 + return mapOrderStatus(detail.value.proposal.order_status)
127 - // 状态映射(与 plan/index.vue 保持一致)
128 - const statusMap = {
129 - '3': 'pending', // 待处理
130 - '5': 'processing', // 处理中
131 - '7': 'generated', // 已生成
132 - '9': 'viewed' // 已查看
133 - }
134 -
135 - return statusMap[detail.value.proposal.order_status] || 'pending'
136 }) 129 })
137 130
138 /** 131 /**
...@@ -142,18 +135,17 @@ const proposalStatus = computed(() => { ...@@ -142,18 +135,17 @@ const proposalStatus = computed(() => {
142 * @returns {Array} 文件列表 135 * @returns {Array} 文件列表
143 */ 136 */
144 const proposalFiles = computed(() => { 137 const proposalFiles = computed(() => {
145 - return detail.value?.proposal?.proposal_files || [] 138 + return getProposalFiles(detail.value?.proposal)
146 }) 139 })
147 140
148 /** 141 /**
149 * 是否可以查看计划书 142 * 是否可以查看计划书
150 * 143 *
151 - * @description 只有"已生成"和"已查看"状态才能查看 144 + * @description 根据统一规则判断是否可查看
152 * @returns {boolean} 145 * @returns {boolean}
153 */ 146 */
154 -const canViewProposal = computed(() => { 147 +const canViewCurrentProposal = computed(() => {
155 - const status = proposalStatus.value 148 + return canViewProposalRule(detail.value?.proposal)
156 - return status === 'generated' || status === 'viewed'
157 }) 149 })
158 150
159 /** 151 /**
...@@ -163,13 +155,7 @@ const canViewProposal = computed(() => { ...@@ -163,13 +155,7 @@ const canViewProposal = computed(() => {
163 * @returns {string} 状态中文文本 155 * @returns {string} 状态中文文本
164 */ 156 */
165 const getProposalStatusText = (status) => { 157 const getProposalStatusText = (status) => {
166 - const textMap = { 158 + return getStatusText(status)
167 - 'pending': '待处理',
168 - 'processing': '处理中',
169 - 'generated': '已生成',
170 - 'viewed': '已查看'
171 - }
172 - return textMap[status] || '待处理'
173 } 159 }
174 160
175 /** 161 /**
......
...@@ -100,9 +100,8 @@ ...@@ -100,9 +100,8 @@
100 <view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view> 100 <view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
101 101
102 <!-- Actions --> 102 <!-- Actions -->
103 - <!-- 只对已生成和已查看状态显示查看按钮 -->
104 <ListItemActions 103 <ListItemActions
105 - :viewable="item.status === 'generated' || item.status === 'viewed'" 104 + :viewable="canViewProposalRule(item)"
106 :deletable="true" 105 :deletable="true"
107 @view="onView(item)" 106 @view="onView(item)"
108 @delete="onDelete(item)" 107 @delete="onDelete(item)"
...@@ -136,16 +135,18 @@ ...@@ -136,16 +135,18 @@
136 <script setup> 135 <script setup>
137 import { ref, nextTick } from 'vue' 136 import { ref, nextTick } from 'vue'
138 import Taro, { useLoad, useReachBottom } from '@tarojs/taro' 137 import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
139 -import { useFileOperation } from '@/composables/useFileOperation' 138 +import { listAPI, deleteAPI } from '@/api/plan'
140 -import { listAPI, viewAPI, deleteAPI } from '@/api/plan'
141 import { mockPlanListAPI } from '@/utils/mockData' 139 import { mockPlanListAPI } from '@/utils/mockData'
142 import NavHeader from '@/components/navigation/NavHeader.vue' 140 import NavHeader from '@/components/navigation/NavHeader.vue'
143 import ListItemActions from '@/components/list/ListItemActions/index.vue' 141 import ListItemActions from '@/components/list/ListItemActions/index.vue'
144 import SearchBar from '@/components/forms/SearchBar.vue' 142 import SearchBar from '@/components/forms/SearchBar.vue'
145 import { USE_MOCK_DATA } from '@/config/app' 143 import { USE_MOCK_DATA } from '@/config/app'
144 +import { mapOrderStatus, getStatusText } from '@/config/constants/orderStatus'
145 +import { canViewProposal as canViewProposalRule } from '@/utils/proposalView'
146 +import { usePlanView } from '@/composables/usePlanView'
146 // ⚠️ MOCK 数据开关 - 统一从 @/config/app 导入 147 // ⚠️ MOCK 数据开关 - 统一从 @/config/app 导入
147 148
148 -const { viewFile } = useFileOperation() 149 +const { viewProposal } = usePlanView()
149 150
150 const searchValue = ref('') 151 const searchValue = ref('')
151 const activeTabId = ref('') 152 const activeTabId = ref('')
...@@ -177,42 +178,6 @@ const tabsData = ref([ ...@@ -177,42 +178,6 @@ const tabsData = ref([
177 ]) 178 ])
178 179
179 /** 180 /**
180 - * 订单状态映射
181 - * @description 将 API 返回的 order_status 映射到前端使用的状态
182 - * @param {string} orderStatus - API 返回的状态值
183 - * @returns {string} 前端状态:'pending' | 'processing' | 'generated' | 'viewed'
184 - */
185 -const mapOrderStatus = (orderStatus) => {
186 - // 根据API文档:
187 - // "3" = 待处理
188 - // "5" = 处理中
189 - // "7" = 已生成
190 - // "9" = 已查看
191 - const statusMap = {
192 - '3': 'pending',
193 - '5': 'processing',
194 - '7': 'generated',
195 - '9': 'viewed'
196 - }
197 - return statusMap[orderStatus] || 'pending'
198 -}
199 -
200 -/**
201 - * 获取状态文本
202 - * @param {string} status - 前端状态值
203 - * @returns {string} 状态文本
204 - */
205 -const getStatusText = (status) => {
206 - const textMap = {
207 - 'pending': '待处理',
208 - 'processing': '处理中',
209 - 'generated': '已生成',
210 - 'viewed': '已查看'
211 - }
212 - return textMap[status] || '待处理'
213 -}
214 -
215 -/**
216 * 从 API 数据转换为组件数据格式 181 * 从 API 数据转换为组件数据格式
217 * @description 将 API 返回的数据结构转换为组件使用的格式 182 * @description 将 API 返回的数据结构转换为组件使用的格式
218 * @param {Object} apiItem - API 返回的计划书对象 183 * @param {Object} apiItem - API 返回的计划书对象
...@@ -451,77 +416,10 @@ useReachBottom(() => { ...@@ -451,77 +416,10 @@ useReachBottom(() => {
451 * @param {Object} item - 计划书对象 416 * @param {Object} item - 计划书对象
452 */ 417 */
453 const onView = async (item) => { 418 const onView = async (item) => {
454 - // 检查是否已生成(只有"已生成"或"已查看"状态才能查看) 419 + await viewProposal(item, {
455 - if (item.status === 'pending' || item.status === 'processing') { 420 + onViewSuccess: () => {
456 - Taro.showToast({ 421 + // 列表页本地同步为已查看,避免用户返回后还看到旧状态。
457 - title: '计划书尚未生成,请稍后', 422 + item.status = 'viewed'
458 - icon: 'none'
459 - })
460 - return
461 - }
462 -
463 - // 检查是否有计划书文件
464 - if (!item.proposalFiles || item.proposalFiles.length === 0) {
465 - Taro.showToast({
466 - title: '暂无可查看的计划书',
467 - icon: 'none'
468 - })
469 - return
470 - }
471 -
472 - /**
473 - * 处理文件查看
474 - * @param {Object} file - 文件对象
475 - */
476 - const handleFileView = async (file) => {
477 - try {
478 - const previewSuccess = await viewFile({
479 - downloadUrl: file.file_url,
480 - fileName: file.file_name
481 - })
482 -
483 - if (!previewSuccess) {
484 - return
485 - }
486 -
487 - if (item.status !== 'viewed') {
488 - const viewRes = await viewAPI({ i: item.id })
489 -
490 - if (viewRes.code === 1) {
491 - item.status = 'viewed'
492 - Taro.showToast({
493 - title: '已标记为查看',
494 - icon: 'success',
495 - duration: 1000
496 - })
497 - }
498 - }
499 - } catch (error) {
500 - console.error('标记查看失败:', error)
501 - }
502 - }
503 -
504 - // 如果只有一个文件,直接查看
505 - if (item.proposalFiles.length === 1) {
506 - await handleFileView(item.proposalFiles[0])
507 - return
508 - }
509 -
510 - // 如果有多个文件,显示选择列表
511 - const fileList = item.proposalFiles.map((file, index) => ({
512 - text: file.file_name || `计划书 ${index + 1}`,
513 - file: file
514 - }))
515 -
516 - // 使用 Taro.showActionSheet 显示文件选择列表
517 - Taro.showActionSheet({
518 - itemList: fileList.map(f => f.text),
519 - success: async (res) => {
520 - const selectedIndex = res.tapIndex
521 - if (selectedIndex !== undefined && selectedIndex >= 0) {
522 - const selectedFile = fileList[selectedIndex].file
523 - await handleFileView(selectedFile)
524 - }
525 } 423 }
526 }) 424 })
527 } 425 }
......
1 +import { afterEach, describe, expect, it, vi } from 'vitest'
2 +import { mockPlanListAPI } from '../mockData'
3 +
4 +describe('mockPlanListAPI', () => {
5 + afterEach(() => {
6 + vi.restoreAllMocks()
7 + })
8 +
9 + it('处理中的 mock 计划书也应该能返回可查看文件场景', async () => {
10 + const randomSpy = vi.spyOn(Math, 'random')
11 + randomSpy
12 + .mockReturnValueOnce(0)
13 + .mockReturnValueOnce(0)
14 + .mockReturnValueOnce(0)
15 + .mockReturnValueOnce(0.3)
16 + .mockReturnValueOnce(0)
17 + .mockReturnValueOnce(0)
18 + .mockReturnValueOnce(0)
19 +
20 + const res = await mockPlanListAPI({ page: 0, limit: 1 })
21 + const item = res.data.list[0]
22 +
23 + expect(item.order_status).toBe('5')
24 + expect(item.proposal_files.length).toBeGreaterThan(0)
25 + })
26 +})
1 +import { describe, expect, it } from 'vitest'
2 +import { canViewProposal, getProposalFiles } from '../proposalView'
3 +
4 +describe('proposalView', () => {
5 + it('处理中且有 proposal_files 时应该允许查看', () => {
6 + expect(canViewProposal({
7 + order_status: '5',
8 + proposal_files: [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf' }]
9 + })).toBe(true)
10 + })
11 +
12 + it('处理中但没有文件时不应该允许查看', () => {
13 + expect(canViewProposal({
14 + order_status: '5',
15 + proposal_files: []
16 + })).toBe(false)
17 + })
18 +
19 + it('待处理即使有文件时也不应该允许查看', () => {
20 + expect(canViewProposal({
21 + order_status: '3',
22 + proposal_files: [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf' }]
23 + })).toBe(false)
24 + })
25 +
26 + it('应该兼容 proposalFiles 字段', () => {
27 + const files = [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf' }]
28 +
29 + expect(getProposalFiles({ proposalFiles: files })).toEqual(files)
30 + expect(canViewProposal({
31 + status: 'processing',
32 + proposalFiles: files
33 + })).toBe(true)
34 + })
35 +})
...@@ -1099,7 +1099,8 @@ function generatePlanItem(id) { ...@@ -1099,7 +1099,8 @@ function generatePlanItem(id) {
1099 const createTime = new Date(now.getTime() - Math.random() * 30 * 24 * 60 * 60 * 1000) 1099 const createTime = new Date(now.getTime() - Math.random() * 30 * 24 * 60 * 60 * 1000)
1100 1100
1101 // 根据状态决定是否有计划书文件 1101 // 根据状态决定是否有计划书文件
1102 - const hasFiles = orderStatus === '7' || orderStatus === '9' // 已生成或已查看才有文件 1102 + // 处理中场景也需要覆盖“文件已可查看但状态尚未流转”的联调需求
1103 + const hasFiles = orderStatus === '7' || orderStatus === '9' || (orderStatus === '5' && Math.random() < 0.5)
1103 const proposalFiles = [] 1104 const proposalFiles = []
1104 1105
1105 if (hasFiles) { 1106 if (hasFiles) {
......
1 +import { mapOrderStatus } from '@/config/constants/orderStatus'
2 +
3 +/**
4 + * 统一获取计划书前端状态。
5 + *
6 + * 兼容列表页传入的 `status` 和接口原始返回的 `order_status`,
7 + * 让页面显隐判断与查看逻辑都走同一套口径。
8 + */
9 +export const getProposalStatus = (proposal = {}) => {
10 + if (proposal.status) {
11 + return proposal.status
12 + }
13 +
14 + return mapOrderStatus(proposal.order_status)
15 +}
16 +
17 +/**
18 + * 统一提取计划书文件列表。
19 + *
20 + * 列表页使用 `proposalFiles`,接口原始对象使用 `proposal_files`,
21 + * 这里做一次兼容,避免各处重复写字段分支。
22 + */
23 +export const getProposalFiles = (proposal = {}) => {
24 + if (Array.isArray(proposal.proposal_files)) {
25 + return proposal.proposal_files
26 + }
27 +
28 + if (Array.isArray(proposal.proposalFiles)) {
29 + return proposal.proposalFiles
30 + }
31 +
32 + return []
33 +}
34 +
35 +/**
36 + * 统一判断计划书是否可查看。
37 + *
38 + * 当前业务规则:
39 + * - 待处理不可查看
40 + * - 处理中/已生成/已查看,只要存在文件就允许查看
41 + */
42 +export const canViewProposal = (proposal = {}) => {
43 + const status = getProposalStatus(proposal)
44 + const proposalFiles = getProposalFiles(proposal)
45 +
46 + if (proposalFiles.length === 0) {
47 + return false
48 + }
49 +
50 + return status === 'processing' || status === 'generated' || status === 'viewed'
51 +}