feat: 实现出差申请页面

- 创建出差申请表单,包含出差事由、行程明细、审批人选择
- 实现行程明细的增删改功能,支持开始/结束时间、出发地、目的地等
- 添加单程/往返类型选择和交通工具下拉选择
- 实现自动计算行程时长和总时长
- 创建审批人和抄送人选择页面
- 添加前后端API接口定义
- 更新i18n国际化翻译
- 整理开发文档和UI设计图

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-24 23:44:40 +08:00
parent 49fa9c3f92
commit 2d39357cc3
14 changed files with 1337 additions and 27 deletions
+304 -10
View File
@@ -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>
)
}