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('计划书模块集成测试', () => {
icon: 'none'
})
})
it('应该允许查看处理中且已有文件的计划书', async () => {
viewAPI.mockResolvedValue({ code: 1 })
const proposal = {
id: 790,
order_status: '5', // PROCESSING
proposal_files: [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf', id: 1 }]
}
await viewProposal(proposal)
expect(Taro.showToast).toHaveBeenCalledWith({
title: '已标记为查看',
icon: 'success'
})
expect(viewAPI).toHaveBeenCalledWith({ i: 790 })
})
})
describe('字段依赖关系测试', () => {
......
......@@ -15,6 +15,7 @@ import Taro from '@tarojs/taro'
import { mapOrderStatus, getStatusText } from '@/config/constants/orderStatus'
import { viewAPI } from '@/api/plan'
import { useFileOperation } from './useFileOperation'
import { canViewProposal, getProposalFiles } from '@/utils/proposalView'
export const viewProposal = async (proposal, callbacks = {}) => {
const { beforeView, onViewSuccess, onViewError, onError } = callbacks
......@@ -41,27 +42,26 @@ export const viewProposal = async (proposal, callbacks = {}) => {
}
const status = proposal.status || mapOrderStatus(proposal.order_status)
if (status === 'pending' || status === 'processing') {
if (!canViewProposal(proposal)) {
// `pending` 与“已有状态但文件为空”是两类不同问题,提示文案需要区分。
Taro.showToast({
title: '计划书尚未生成,请稍后',
icon: 'none'
})
emitError(new Error(`计划书状态不允许查看: ${getStatusText(status)}`))
return
}
const proposalFiles = proposal.proposal_files || proposal.proposalFiles || []
if (!proposalFiles || proposalFiles.length === 0) {
Taro.showToast({
title: '暂无可查看的计划书',
title: status === 'pending' ? '计划书尚未生成,请稍后' : '暂无可查看的计划书',
icon: 'none'
})
const error = new Error(
status === 'pending'
? `计划书状态不允许查看: ${getStatusText(status)}`
: 'proposalFiles 为空'
)
if (status !== 'pending') {
console.error('[usePlanView] proposalFiles 为空:', proposal)
emitError(new Error('proposalFiles 为空'))
}
emitError(error)
return
}
const proposalFiles = getProposalFiles(proposal)
if (beforeView) {
try {
const shouldContinue = await beforeView(proposal)
......
......@@ -74,7 +74,7 @@
<!-- 查看按钮 -->
<nut-button
v-if="canViewProposal"
v-if="canViewCurrentProposal"
type="primary"
color="#2563EB"
block
......@@ -108,6 +108,8 @@ import { useUserStore } from '@/stores/user'
import { mockDetailAPI } from '@/utils/mockData'
import { USE_MOCK_DATA } from '@/config/app'
import { usePlanView } from '@/composables/usePlanView'
import { mapOrderStatus, getStatusText } from '@/config/constants/orderStatus'
import { canViewProposal as canViewProposalRule, getProposalFiles } from '@/utils/proposalView'
const detail = ref(null)
const loading = ref(true)
......@@ -123,16 +125,7 @@ const { viewProposal } = usePlanView()
*/
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'
return mapOrderStatus(detail.value.proposal.order_status)
})
/**
......@@ -142,18 +135,17 @@ const proposalStatus = computed(() => {
* @returns {Array} 文件列表
*/
const proposalFiles = computed(() => {
return detail.value?.proposal?.proposal_files || []
return getProposalFiles(detail.value?.proposal)
})
/**
* 是否可以查看计划书
*
* @description 只有"已生成"和"已查看"状态才能查看
* @description 根据统一规则判断是否可查看
* @returns {boolean}
*/
const canViewProposal = computed(() => {
const status = proposalStatus.value
return status === 'generated' || status === 'viewed'
const canViewCurrentProposal = computed(() => {
return canViewProposalRule(detail.value?.proposal)
})
/**
......@@ -163,13 +155,7 @@ const canViewProposal = computed(() => {
* @returns {string} 状态中文文本
*/
const getProposalStatusText = (status) => {
const textMap = {
'pending': '待处理',
'processing': '处理中',
'generated': '已生成',
'viewed': '已查看'
}
return textMap[status] || '待处理'
return getStatusText(status)
}
/**
......
......@@ -100,9 +100,8 @@
<view class="h-[1rpx] bg-gray-100 my-[20rpx]"></view>
<!-- Actions -->
<!-- 只对已生成和已查看状态显示查看按钮 -->
<ListItemActions
:viewable="item.status === 'generated' || item.status === 'viewed'"
:viewable="canViewProposalRule(item)"
:deletable="true"
@view="onView(item)"
@delete="onDelete(item)"
......@@ -136,16 +135,18 @@
<script setup>
import { ref, nextTick } from 'vue'
import Taro, { useLoad, useReachBottom } from '@tarojs/taro'
import { useFileOperation } from '@/composables/useFileOperation'
import { listAPI, viewAPI, deleteAPI } from '@/api/plan'
import { listAPI, deleteAPI } from '@/api/plan'
import { mockPlanListAPI } from '@/utils/mockData'
import NavHeader from '@/components/navigation/NavHeader.vue'
import ListItemActions from '@/components/list/ListItemActions/index.vue'
import SearchBar from '@/components/forms/SearchBar.vue'
import { USE_MOCK_DATA } from '@/config/app'
import { mapOrderStatus, getStatusText } from '@/config/constants/orderStatus'
import { canViewProposal as canViewProposalRule } from '@/utils/proposalView'
import { usePlanView } from '@/composables/usePlanView'
// ⚠️ MOCK 数据开关 - 统一从 @/config/app 导入
const { viewFile } = useFileOperation()
const { viewProposal } = usePlanView()
const searchValue = ref('')
const activeTabId = ref('')
......@@ -177,42 +178,6 @@ const tabsData = ref([
])
/**
* 订单状态映射
* @description 将 API 返回的 order_status 映射到前端使用的状态
* @param {string} orderStatus - API 返回的状态值
* @returns {string} 前端状态:'pending' | 'processing' | 'generated' | 'viewed'
*/
const mapOrderStatus = (orderStatus) => {
// 根据API文档:
// "3" = 待处理
// "5" = 处理中
// "7" = 已生成
// "9" = 已查看
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] || '待处理'
}
/**
* 从 API 数据转换为组件数据格式
* @description 将 API 返回的数据结构转换为组件使用的格式
* @param {Object} apiItem - API 返回的计划书对象
......@@ -451,77 +416,10 @@ useReachBottom(() => {
* @param {Object} item - 计划书对象
*/
const onView = async (item) => {
// 检查是否已生成(只有"已生成"或"已查看"状态才能查看)
if (item.status === 'pending' || item.status === 'processing') {
Taro.showToast({
title: '计划书尚未生成,请稍后',
icon: 'none'
})
return
}
// 检查是否有计划书文件
if (!item.proposalFiles || item.proposalFiles.length === 0) {
Taro.showToast({
title: '暂无可查看的计划书',
icon: 'none'
})
return
}
/**
* 处理文件查看
* @param {Object} file - 文件对象
*/
const handleFileView = async (file) => {
try {
const previewSuccess = await viewFile({
downloadUrl: file.file_url,
fileName: file.file_name
})
if (!previewSuccess) {
return
}
if (item.status !== 'viewed') {
const viewRes = await viewAPI({ i: item.id })
if (viewRes.code === 1) {
await viewProposal(item, {
onViewSuccess: () => {
// 列表页本地同步为已查看,避免用户返回后还看到旧状态。
item.status = 'viewed'
Taro.showToast({
title: '已标记为查看',
icon: 'success',
duration: 1000
})
}
}
} catch (error) {
console.error('标记查看失败:', error)
}
}
// 如果只有一个文件,直接查看
if (item.proposalFiles.length === 1) {
await handleFileView(item.proposalFiles[0])
return
}
// 如果有多个文件,显示选择列表
const fileList = item.proposalFiles.map((file, index) => ({
text: file.file_name || `计划书 ${index + 1}`,
file: file
}))
// 使用 Taro.showActionSheet 显示文件选择列表
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)
}
}
})
}
......
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mockPlanListAPI } from '../mockData'
describe('mockPlanListAPI', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('处理中的 mock 计划书也应该能返回可查看文件场景', async () => {
const randomSpy = vi.spyOn(Math, 'random')
randomSpy
.mockReturnValueOnce(0)
.mockReturnValueOnce(0)
.mockReturnValueOnce(0)
.mockReturnValueOnce(0.3)
.mockReturnValueOnce(0)
.mockReturnValueOnce(0)
.mockReturnValueOnce(0)
const res = await mockPlanListAPI({ page: 0, limit: 1 })
const item = res.data.list[0]
expect(item.order_status).toBe('5')
expect(item.proposal_files.length).toBeGreaterThan(0)
})
})
import { describe, expect, it } from 'vitest'
import { canViewProposal, getProposalFiles } from '../proposalView'
describe('proposalView', () => {
it('处理中且有 proposal_files 时应该允许查看', () => {
expect(canViewProposal({
order_status: '5',
proposal_files: [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf' }]
})).toBe(true)
})
it('处理中但没有文件时不应该允许查看', () => {
expect(canViewProposal({
order_status: '5',
proposal_files: []
})).toBe(false)
})
it('待处理即使有文件时也不应该允许查看', () => {
expect(canViewProposal({
order_status: '3',
proposal_files: [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf' }]
})).toBe(false)
})
it('应该兼容 proposalFiles 字段', () => {
const files = [{ file_name: '计划书.pdf', file_url: 'https://example.com/plan.pdf' }]
expect(getProposalFiles({ proposalFiles: files })).toEqual(files)
expect(canViewProposal({
status: 'processing',
proposalFiles: files
})).toBe(true)
})
})
......@@ -1099,7 +1099,8 @@ function generatePlanItem(id) {
const createTime = new Date(now.getTime() - Math.random() * 30 * 24 * 60 * 60 * 1000)
// 根据状态决定是否有计划书文件
const hasFiles = orderStatus === '7' || orderStatus === '9' // 已生成或已查看才有文件
// 处理中场景也需要覆盖“文件已可查看但状态尚未流转”的联调需求
const hasFiles = orderStatus === '7' || orderStatus === '9' || (orderStatus === '5' && Math.random() < 0.5)
const proposalFiles = []
if (hasFiles) {
......
import { mapOrderStatus } from '@/config/constants/orderStatus'
/**
* 统一获取计划书前端状态。
*
* 兼容列表页传入的 `status` 和接口原始返回的 `order_status`,
* 让页面显隐判断与查看逻辑都走同一套口径。
*/
export const getProposalStatus = (proposal = {}) => {
if (proposal.status) {
return proposal.status
}
return mapOrderStatus(proposal.order_status)
}
/**
* 统一提取计划书文件列表。
*
* 列表页使用 `proposalFiles`,接口原始对象使用 `proposal_files`,
* 这里做一次兼容,避免各处重复写字段分支。
*/
export const getProposalFiles = (proposal = {}) => {
if (Array.isArray(proposal.proposal_files)) {
return proposal.proposal_files
}
if (Array.isArray(proposal.proposalFiles)) {
return proposal.proposalFiles
}
return []
}
/**
* 统一判断计划书是否可查看。
*
* 当前业务规则:
* - 待处理不可查看
* - 处理中/已生成/已查看,只要存在文件就允许查看
*/
export const canViewProposal = (proposal = {}) => {
const status = getProposalStatus(proposal)
const proposalFiles = getProposalFiles(proposal)
if (proposalFiles.length === 0) {
return false
}
return status === 'processing' || status === 'generated' || status === 'viewed'
}