ba41b340fc
- 移除 loadingRef,解决 React 18 StrictMode 下请求阻塞问题 - 提交申请时根据选择的审批人动态创建/更新审批节点 - 修改申请提交后跳转到审批列表而非出差列表 - 更新 PROJECT_ISSUES.md 记录所有问题和解决方案 修复问题: 1. 首次进入审批列表"我发起的"不显示数据 2. 发起人也是审批人时"待我审批"不显示 3. 申请提交后跳转到错误的页面 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
551 lines
19 KiB
TypeScript
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>
|
|
)
|
|
}
|