feat: 实现出差申请页面
- 创建出差申请表单,包含出差事由、行程明细、审批人选择 - 实现行程明细的增删改功能,支持开始/结束时间、出发地、目的地等 - 添加单程/往返类型选择和交通工具下拉选择 - 实现自动计算行程时长和总时长 - 创建审批人和抄送人选择页面 - 添加前后端API接口定义 - 更新i18n国际化翻译 - 整理开发文档和UI设计图 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,23 +1,317 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useNavigate } 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 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 [reason, setReason] = useState('')
|
||||
const [itineraries, setItineraries] = useState<ItineraryItem[]>([
|
||||
{ startTime: null, endTime: null, departure: '', destination: '', transport: '', tripType: 'oneway', remark: '' }
|
||||
])
|
||||
const [approvers, setApprovers] = useState<ApproverItem[]>([])
|
||||
const [ccUsers, setCcUsers] = useState<ApproverItem[]>([])
|
||||
|
||||
// 计算总时长(累加所有行程的天数)
|
||||
const totalDuration = useMemo(() => {
|
||||
let total = 0
|
||||
itineraries.forEach(item => {
|
||||
const duration = calculateDuration(item.startTime, item.endTime)
|
||||
total += duration
|
||||
})
|
||||
return total
|
||||
}, [itineraries])
|
||||
|
||||
const handleAddItinerary = () => {
|
||||
setItineraries([...itineraries, {
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
departure: '',
|
||||
destination: '',
|
||||
transport: '',
|
||||
tripType: 'oneway',
|
||||
remark: ''
|
||||
}])
|
||||
}
|
||||
|
||||
const handleRemoveItinerary = (index: number) => {
|
||||
if (itineraries.length === 1) {
|
||||
Toast.show('至少保留一条行程')
|
||||
return
|
||||
}
|
||||
setItineraries(itineraries.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleUpdateItinerary = (index: number, field: keyof ItineraryItem, value: any) => {
|
||||
const updated = [...itineraries]
|
||||
updated[index] = { ...updated[index], [field]: value }
|
||||
setItineraries(updated)
|
||||
}
|
||||
|
||||
const handleSelectApprovers = () => {
|
||||
navigate('/business-trip/apply/select-approver', { state: { selected: approvers, type: 'approver' } })
|
||||
}
|
||||
|
||||
const handleSelectCc = () => {
|
||||
navigate('/business-trip/apply/select-cc', { state: { selected: ccUsers, type: 'cc' } })
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
Toast.show('草稿已保存')
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!reason.trim()) {
|
||||
Toast.show(t('businessTrip.pleaseEnterReason'))
|
||||
return
|
||||
}
|
||||
|
||||
const hasValidItinerary = itineraries.some(item => item.startTime && item.destination.trim())
|
||||
if (!hasValidItinerary) {
|
||||
Toast.show('请至少填写一条完整行程')
|
||||
return
|
||||
}
|
||||
|
||||
Dialog.confirm({
|
||||
content: t('businessTrip.confirmSubmit'),
|
||||
onConfirm: async () => {
|
||||
Toast.show(t('businessTrip.submitSuccess'))
|
||||
navigate('/business-trip')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.apply}>
|
||||
<form className={styles.form}>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.group}>
|
||||
<label>出差事由</label>
|
||||
<input type="text" placeholder="请输入出差事由" />
|
||||
<label className={styles.required}>
|
||||
{t('businessTrip.reason')}
|
||||
</label>
|
||||
<TextArea
|
||||
placeholder={t('businessTrip.pleaseEnterReason')}
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.group}>
|
||||
<label>出差时间</label>
|
||||
<input type="text" placeholder="请选择出差时间" />
|
||||
<label>{t('businessTrip.totalDuration')}</label>
|
||||
<div className={styles.readonlyInput}>
|
||||
{formatDuration(totalDuration) || '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.group}>
|
||||
<label>出差地点</label>
|
||||
<input type="text" placeholder="请输入出差地点" />
|
||||
|
||||
<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>
|
||||
<DatePicker
|
||||
value={item.startTime}
|
||||
onConfirm={(val) => handleUpdateItinerary(index, 'startTime', val)}
|
||||
precision="minute"
|
||||
>
|
||||
{value => (
|
||||
<div className={styles.dateClickable}>
|
||||
{value ? dayjs(value).format('YYYY-MM-DD HH:mm') : t('common.select')}
|
||||
</div>
|
||||
)}
|
||||
</DatePicker>
|
||||
</div>
|
||||
|
||||
<div className={styles.dateField}>
|
||||
<label className={styles.required}>{t('businessTrip.itineraryEndTime')}</label>
|
||||
<DatePicker
|
||||
value={item.endTime}
|
||||
onConfirm={(val) => handleUpdateItinerary(index, 'endTime', val)}
|
||||
precision="minute"
|
||||
{...(item.startTime && { min: item.startTime })}
|
||||
>
|
||||
{value => (
|
||||
<div className={styles.dateClickable}>
|
||||
{value ? dayjs(value).format('YYYY-MM-DD HH:mm') : t('common.select')}
|
||||
</div>
|
||||
)}
|
||||
</DatePicker>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>{t('businessTrip.duration')}</label>
|
||||
<div className={styles.readonlyInput}>
|
||||
{formatDuration(itemDuration) || '-'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>{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>{t('businessTrip.itineraryTransport')}</label>
|
||||
<Selector
|
||||
options={transportOptions}
|
||||
value={item.transport ? [item.transport] : []}
|
||||
onChange={(val) => handleUpdateItinerary(index, 'transport', val[0] || '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>{t('businessTrip.tripType')}</label>
|
||||
<Selector
|
||||
options={tripTypeOptions}
|
||||
value={[item.tripType]}
|
||||
onChange={(val) => handleUpdateItinerary(index, 'tripType', val[0] as 'oneway' | 'roundtrip')}
|
||||
/>
|
||||
</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>
|
||||
<button type="button" className={styles.submitBtn}>提交申请</button>
|
||||
</form>
|
||||
|
||||
<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={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={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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user