Files
x-server-mobile/frontend/src/pages/business-trip/apply.tsx
T
root ba41b340fc fix: 修复审批列表首次加载不显示和发起人审批人问题
- 移除 loadingRef,解决 React 18 StrictMode 下请求阻塞问题
- 提交申请时根据选择的审批人动态创建/更新审批节点
- 修改申请提交后跳转到审批列表而非出差列表
- 更新 PROJECT_ISSUES.md 记录所有问题和解决方案

修复问题:
1. 首次进入审批列表"我发起的"不显示数据
2. 发起人也是审批人时"待我审批"不显示
3. 申请提交后跳转到错误的页面

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 09:35:26 +08:00

551 lines
19 KiB
TypeScript

import { useState, useMemo, useEffect, useRef } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { DatePicker, Input, Toast, Dialog, TextArea, Selector } from 'antd-mobile'
import { AddOutline, DeleteOutline } from 'antd-mobile-icons'
import dayjs from 'dayjs'
import { businessTripApi, userApi } from '../../services/api'
import { useUserStore } from '@/stores/user'
import styles from './apply.module.css'
interface ItineraryItem {
startTime: Date | null
endTime: Date | null
departure: string
destination: string
transport: string
tripType: 'oneway' | 'roundtrip' | ''
remark: string
}
interface ApproverItem {
id: number
name: string
avatar?: string
position?: string
}
// 计算时长(天数)
const calculateDuration = (start: Date | null, end: Date | null): number => {
if (!start || !end) return 0
const startDate = dayjs(start)
const endDate = dayjs(end)
return endDate.diff(startDate, 'day') + 1
}
// 格式化时长显示
const formatDuration = (days: number): string => {
if (days <= 0) return ''
return `${days}天`
}
// 行程类型选项
const tripTypeOptions = [
{ label: '单程', value: 'oneway' },
{ label: '往返', value: 'roundtrip' },
]
// 交通工具选项
const transportOptions = [
{ label: '飞机', value: '飞机' },
{ label: '高铁', value: '高铁' },
{ label: '火车', value: '火车' },
{ label: '自驾', value: '自驾' },
{ label: '其他', value: '其他' },
]
export default function BusinessTripApply() {
const { t } = useTranslation()
const navigate = useNavigate()
const location = useLocation()
const [reason, setReason] = useState('')
const [itineraries, setItineraries] = useState<ItineraryItem[]>([
{ startTime: null, endTime: null, departure: '', destination: '', transport: '', tripType: '' as 'oneway' | 'roundtrip' | '', remark: '' }
])
const [approvers, setApprovers] = useState<ApproverItem[]>([])
const [ccUsers, setCcUsers] = useState<ApproverItem[]>([])
const [defaultLoaded, setDefaultLoaded] = useState(false)
// 手动保存草稿的函数
const saveDraft = () => {
const draftData = {
reason,
itineraries,
}
sessionStorage.setItem('businessTripDraft', JSON.stringify(draftData))
console.log('💾 Manually saved draft:', { reason, itineraries })
}
// DatePicker 可见性状态
const [datePickerVisible, setDatePickerVisible] = useState<{
index: number
field: 'startTime' | 'endTime'
} | null>(null)
// 使用 ref 来跟踪是否已经恢复过草稿
const hasRestoredRef = useRef(false)
// 统一处理:草稿恢复、默认审批人加载、选择页面返回
const initRef = useRef(false)
useEffect(() => {
const fromSelection = location.state?.fromSelection
console.log('📍 Page init:', { fromSelection, locationState: location.state, initRef: initRef.current })
// 1. 从选择页面返回:恢复所有数据(草稿 + 人员)
if (fromSelection) {
console.log('🔄 Returning from selection page')
// 恢复草稿数据
const draftData = sessionStorage.getItem('businessTripDraft')
if (draftData) {
const draft = JSON.parse(draftData)
setReason(draft.reason || '')
// 将日期字符串转换回 Date 对象
const restoredItineraries = (draft.itineraries || [{ startTime: null, endTime: null, departure: '', destination: '', transport: '', tripType: '' as 'oneway' | 'roundtrip' | '', remark: '' }]).map((item: any) => ({
...item,
startTime: item.startTime ? new Date(item.startTime) : null,
endTime: item.endTime ? new Date(item.endTime) : null,
}))
setItineraries(restoredItineraries)
console.log('✅ Restored draft')
}
// 恢复人员数据
const approversData = sessionStorage.getItem('selectedApprovers')
if (approversData) {
setApprovers(JSON.parse(approversData))
console.log('✅ Restored approvers')
} else {
setApprovers([])
}
const ccData = sessionStorage.getItem('selectedCc')
if (ccData) {
setCcUsers(JSON.parse(ccData))
console.log('✅ Restored CC')
} else {
setCcUsers([])
}
return
}
// 2. 非首次进入且已初始化过:跳过
if (initRef.current) {
console.log('⏭️ Already initialized, skipping')
return
}
initRef.current = true
// 3. 从列表页新建申请:清除旧数据,加载默认配置
console.log('🆕 New application - clearing old data and loading defaults')
sessionStorage.removeItem('businessTripDraft')
sessionStorage.removeItem('selectedApprovers')
sessionStorage.removeItem('selectedCc')
setReason('')
setItineraries([{ startTime: null, endTime: null, departure: '', destination: '', transport: '', tripType: '' as 'oneway' | 'roundtrip' | '', remark: '' }])
setApprovers([])
setCcUsers([])
// 4. 加载默认审批人和抄送人
const loadDefaultApprovers = async () => {
console.log('📡 Calling API...')
try {
const defaultData = await businessTripApi.getDefaultApprovers()
console.log('✅ API Response:', defaultData)
if (defaultData.approvers && defaultData.approvers.length > 0) {
console.log('✅ Setting approvers:', defaultData.approvers)
setApprovers(defaultData.approvers)
sessionStorage.setItem('selectedApprovers', JSON.stringify(defaultData.approvers))
}
if (defaultData.cc_users && defaultData.cc_users.length > 0) {
console.log('✅ Setting cc_users:', defaultData.cc_users)
setCcUsers(defaultData.cc_users)
sessionStorage.setItem('selectedCc', JSON.stringify(defaultData.cc_users))
}
} catch (error: any) {
console.error('❌ Failed to load default approvers:', error.message)
}
}
loadDefaultApprovers()
}, [location.state?.fromSelection])
// 计算总时长(累加所有行程的天数)
const totalDuration = useMemo(() => {
let total = 0
itineraries.forEach(item => {
const duration = calculateDuration(item.startTime, item.endTime)
total += duration
})
return total
}, [itineraries])
const handleAddItinerary = () => {
const newItineraries = [...itineraries, {
startTime: null,
endTime: null,
departure: '',
destination: '',
transport: '',
tripType: '' as 'oneway' | 'roundtrip' | '',
remark: ''
}]
setItineraries(newItineraries)
saveDraft()
}
const handleRemoveItinerary = (index: number) => {
if (itineraries.length === 1) {
Toast.show('至少保留一条行程')
return
}
const newItineraries = itineraries.filter((_, i) => i !== index)
setItineraries(newItineraries)
saveDraft()
}
const handleUpdateItinerary = (index: number, field: keyof ItineraryItem, value: any) => {
const updated = [...itineraries]
updated[index] = { ...updated[index], [field]: value }
setItineraries(updated)
saveDraft()
}
const handleSelectApprovers = () => {
console.log('🚀 Navigating to select approvers')
saveDraft() // 导航前保存草稿
navigate('/business-trip/apply/select-approver', { state: { selected: approvers, type: 'approver' } })
}
const handleSelectCc = () => {
console.log('🚀 Navigating to select CC')
saveDraft() // 导航前保存草稿
navigate('/business-trip/apply/select-cc', { state: { selected: ccUsers, type: 'cc' } })
}
const handleSave = async () => {
// 移除当前焦点,避免按钮保持高亮状态
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
try {
// 验证必填字段
if (!reason.trim()) {
Toast.show(t('businessTrip.pleaseEnterReason'))
return
}
const hasValidItinerary = itineraries.some(item => item.startTime && item.destination.trim())
if (!hasValidItinerary) {
Toast.show('请至少填写一条完整行程')
return
}
// 准备行程数据
const itineraryData = itineraries
.filter(item => item.startTime) // 只保存有开始时间的行程
.map(item => ({
start_time: dayjs(item.startTime).format('YYYY-MM-DD HH:mm:ss'),
end_time: item.endTime ? dayjs(item.endTime).format('YYYY-MM-DD HH:mm:ss') : '',
departure: item.departure || '',
destination: item.destination || '',
transport: item.transport || '',
trip_type: item.tripType || 'oneway',
remark: item.remark || '',
}))
await businessTripApi.create({
reason,
itineraries: itineraryData,
})
// 保存成功后清除 sessionStorage
sessionStorage.removeItem('selectedApprovers')
sessionStorage.removeItem('selectedCc')
sessionStorage.removeItem('businessTripDraft')
Toast.show('草稿已保存')
navigate('/approval')
} catch (error: any) {
Toast.show(error.message || '保存失败')
}
}
const handleSubmit = () => {
// 移除当前焦点,避免按钮保持高亮状态
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
if (!reason.trim()) {
Toast.show(t('businessTrip.pleaseEnterReason'))
return
}
const hasValidItinerary = itineraries.some(item => item.startTime && item.destination.trim())
if (!hasValidItinerary) {
Toast.show('请至少填写一条完整行程')
return
}
if (approvers.length === 0) {
Toast.show('请选择审批人')
return
}
Dialog.confirm({
content: t('businessTrip.confirmSubmit'),
confirmText: '确认',
cancelText: '取消',
onConfirm: async () => {
try {
// 准备行程数据
const itineraryData = itineraries
.filter(item => item.startTime) // 只保存有开始时间的行程
.map(item => ({
start_time: dayjs(item.startTime).format('YYYY-MM-DD HH:mm:ss'),
end_time: item.endTime ? dayjs(item.endTime).format('YYYY-MM-DD HH:mm:ss') : '',
departure: item.departure || '',
destination: item.destination || '',
transport: item.transport || '',
trip_type: item.tripType || 'oneway',
remark: item.remark || '',
}))
// 先创建草稿
const result = await businessTripApi.create({
reason,
itineraries: itineraryData,
})
// 再提交
await businessTripApi.submit(
result.id,
approvers.map(a => a.id),
ccUsers.map(u => u.id)
)
// 提交成功后清除 sessionStorage
sessionStorage.removeItem('selectedApprovers')
sessionStorage.removeItem('selectedCc')
sessionStorage.removeItem('businessTripDraft')
Toast.show(t('businessTrip.submitSuccess'))
navigate('/approval')
} catch (error: any) {
Toast.show(error.message || '提交失败')
}
}
})
}
return (
<div className={styles.apply}>
<div className={styles.form}>
<div className={styles.group}>
<label className={styles.required}>
{t('businessTrip.reason')}
</label>
<TextArea
placeholder={t('businessTrip.pleaseEnterReason')}
value={reason}
onChange={(val) => {
setReason(val)
saveDraft()
}}
rows={3}
maxLength={500}
/>
</div>
<div className={styles.group}>
<label>{t('businessTrip.totalDuration')}</label>
<div className={styles.readonlyInput}>
{formatDuration(totalDuration) || '-'}
</div>
</div>
<div className={styles.section}>
<div className={styles.sectionHeader}>
<span className={styles.sectionTitle}>{t('businessTrip.itinerary')}</span>
</div>
{itineraries.map((item, index) => {
const itemDuration = calculateDuration(item.startTime, item.endTime)
return (
<div key={index} className={styles.itineraryItem}>
{itineraries.length > 1 && (
<div className={styles.itineraryHeader}>
<span className={styles.itineraryIndex}>行程{index + 1}</span>
<button
className={styles.removeBtn}
onClick={() => handleRemoveItinerary(index)}
>
<DeleteOutline />
</button>
</div>
)}
<div className={styles.dateField}>
<label className={styles.required}>{t('businessTrip.itineraryStartTime')}</label>
<div
className={styles.dateClickable}
onClick={() => setDatePickerVisible({ index, field: 'startTime' })}
>
{item.startTime ? dayjs(item.startTime).format('YYYY-MM-DD HH:mm') : t('common.select')}
</div>
<DatePicker
visible={datePickerVisible?.index === index && datePickerVisible?.field === 'startTime'}
onConfirm={(val) => {
const updated = [...itineraries]
updated[index] = { ...updated[index], startTime: val }
setItineraries(updated)
setDatePickerVisible(null)
saveDraft()
}}
onClose={() => setDatePickerVisible(null)}
onCancel={() => setDatePickerVisible(null)}
precision="minute"
/>
</div>
<div className={styles.dateField}>
<label className={styles.required}>{t('businessTrip.itineraryEndTime')}</label>
<div
className={styles.dateClickable}
onClick={() => setDatePickerVisible({ index, field: 'endTime' })}
>
{item.endTime ? dayjs(item.endTime).format('YYYY-MM-DD HH:mm') : t('common.select')}
</div>
<DatePicker
visible={datePickerVisible?.index === index && datePickerVisible?.field === 'endTime'}
onConfirm={(val) => {
const updated = [...itineraries]
updated[index] = { ...updated[index], endTime: val }
setItineraries(updated)
setDatePickerVisible(null)
saveDraft()
}}
onClose={() => setDatePickerVisible(null)}
onCancel={() => setDatePickerVisible(null)}
precision="minute"
{...(item.startTime && { min: item.startTime })}
/>
</div>
<div className={styles.field}>
<label>{t('businessTrip.duration')}</label>
<div className={styles.readonlyInput}>
{formatDuration(itemDuration) || '-'}
</div>
</div>
<div className={styles.field}>
<label className={styles.required}>{t('businessTrip.itineraryDeparture')}</label>
<Input
placeholder={t('businessTrip.itineraryDeparture')}
value={item.departure}
onChange={val => handleUpdateItinerary(index, 'departure', val)}
clearable
/>
</div>
<div className={styles.field}>
<label className={styles.required}>{t('businessTrip.itineraryDestination')}</label>
<Input
placeholder={t('businessTrip.itineraryDestination')}
value={item.destination}
onChange={val => handleUpdateItinerary(index, 'destination', val)}
clearable
/>
</div>
<div className={styles.field}>
<label className={styles.required}>{t('businessTrip.itineraryTransport')}</label>
<Selector
options={transportOptions}
value={item.transport ? [item.transport] : []}
onChange={(val) => handleUpdateItinerary(index, 'transport', val[0] || '')}
showCheckMark={false}
/>
</div>
<div className={styles.field}>
<label className={styles.required}>{t('businessTrip.tripType')}</label>
<Selector
options={tripTypeOptions}
value={item.tripType ? [item.tripType] : []}
onChange={(val) => handleUpdateItinerary(index, 'tripType', val[0] as 'oneway' | 'roundtrip')}
showCheckMark={false}
/>
</div>
<div className={styles.field}>
<label>{t('businessTrip.itineraryRemark')}</label>
<Input
placeholder={t('businessTrip.itineraryRemark')}
value={item.remark}
onChange={val => handleUpdateItinerary(index, 'remark', val)}
clearable
/>
</div>
</div>
)
})}
<button className={styles.addBtn} onClick={handleAddItinerary}>
<AddOutline />
{t('businessTrip.addItinerary')}
</button>
</div>
<div className={styles.section}>
<div className={styles.selector} onClick={handleSelectApprovers}>
<span className={styles.label}>{t('businessTrip.approvers')}</span>
<div className={styles.value}>
{approvers.length > 0 ? (
<div className={styles.selectedUsers}>
{approvers.map(u => (
<img key={`approver-${u.id}`} src={u.avatar || '/default-avatar.png'} alt={u.name} className={styles.avatar} />
))}
</div>
) : (
<span className={styles.placeholder}>{t('businessTrip.selectApprovers')}</span>
)}
<AddOutline className={styles.icon} />
</div>
</div>
<div className={styles.selector} onClick={handleSelectCc}>
<span className={styles.label}>{t('businessTrip.cc')}</span>
<div className={styles.value}>
{ccUsers.length > 0 ? (
<div className={styles.selectedUsers}>
{ccUsers.map(u => (
<img key={`cc-${u.id}`} src={u.avatar || '/default-avatar.png'} alt={u.name} className={styles.avatar} />
))}
</div>
) : (
<span className={styles.placeholder}>{t('businessTrip.selectCc')}</span>
)}
<AddOutline className={styles.icon} />
</div>
</div>
</div>
</div>
<div className={styles.footer}>
<button className={styles.saveBtn} onClick={handleSave}>
{t('common.save')}
</button>
<button className={styles.submitBtn} onClick={handleSubmit}>
{t('common.submit')}
</button>
</div>
</div>
)
}