feat: 实现出差申请页面
- 创建出差申请表单,包含出差事由、行程明细、审批人选择 - 实现行程明细的增删改功能,支持开始/结束时间、出发地、目的地等 - 添加单程/往返类型选择和交通工具下拉选择 - 实现自动计算行程时长和总时长 - 创建审批人和抄送人选择页面 - 添加前后端API接口定义 - 更新i18n国际化翻译 - 整理开发文档和UI设计图 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 160 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 256 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 154 KiB |
@@ -0,0 +1,55 @@
|
||||
# 前端开发已知问题和解决方案
|
||||
|
||||
## 1. antd-mobile-icons 导入错误
|
||||
|
||||
**问题**: `The requested module '/node_modules/.vite/deps/antd-mobile-icons.js' does not provide an export named 'PlusOutline'`
|
||||
|
||||
**原因**: antd-mobile-icons 中没有 `PlusOutline` 图标
|
||||
|
||||
**解决方案**: 使用正确的图标名称:
|
||||
- `PlusOutline` → `AddOutline`
|
||||
- 可通过查看 `node_modules/antd-mobile-icons/es/index.d.ts` 确认可用的图标
|
||||
|
||||
## 2. DatePicker 组件报错
|
||||
|
||||
**问题**: `Cannot read properties of null (reading 'getTime')`
|
||||
|
||||
**原因**: DatePicker 组件的 `min` 或 `max` 属性接收到了 `null` 值
|
||||
|
||||
**解决方案**: 使用条件展开运算符,只在值存在时传递属性:
|
||||
```tsx
|
||||
// 错误写法
|
||||
<DatePicker min={startDate || undefined} />
|
||||
|
||||
// 正确写法
|
||||
<DatePicker {...(startDate && { min: startDate })} />
|
||||
```
|
||||
|
||||
## 3. 端口占用问题
|
||||
|
||||
**问题**: Vite 启动时端口被占用,自动尝试其他端口
|
||||
|
||||
**解决方案**: 清理旧进程后重启
|
||||
```bash
|
||||
ps aux | grep vite | grep -v grep | awk '{print $2}' | xargs -r kill -9
|
||||
```
|
||||
|
||||
## 4. 防火墙规则
|
||||
|
||||
**问题**: 外网无法访问开发服务器
|
||||
|
||||
**解决方案**: 添加防火墙规则
|
||||
```bash
|
||||
iptables -I INPUT -p tcp --dport 5173 -j ACCEPT
|
||||
```
|
||||
|
||||
## 5. Vite 配置
|
||||
|
||||
确保 `vite.config.ts` 中配置正确的 host:
|
||||
```ts
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '0.0.0.0', // 允许外网访问
|
||||
proxy: { ... }
|
||||
}
|
||||
```
|
||||
@@ -68,17 +68,42 @@
|
||||
"startDate": "Start Date",
|
||||
"endDate": "End Date",
|
||||
"duration": "Duration",
|
||||
"totalDuration": "Total Duration",
|
||||
"reason": "Reason",
|
||||
"budget": "Budget",
|
||||
"estimatedCost": "Estimated Cost",
|
||||
"submitter": "Applicant",
|
||||
"submitTime": "Submit Time",
|
||||
"approver": "Approver",
|
||||
"approvers": "Approvers",
|
||||
"approvalStatus": "Status",
|
||||
"approvalComment": "Comment",
|
||||
"confirmSubmit": "Confirm to submit?",
|
||||
"confirmCancel": "Confirm to cancel?",
|
||||
"submitSuccess": "Submitted successfully",
|
||||
"cancelSuccess": "Cancelled successfully"
|
||||
"cancelSuccess": "Cancelled successfully",
|
||||
"itinerary": "Itinerary",
|
||||
"itineraryDate": "Date",
|
||||
"itineraryStartTime": "Start Time",
|
||||
"itineraryEndTime": "End Time",
|
||||
"itineraryDeparture": "Departure",
|
||||
"itineraryDestination": "Destination",
|
||||
"itineraryTransport": "Transport Vehicle",
|
||||
"itineraryAccommodation": "Accommodation",
|
||||
"itineraryRemark": "Remark",
|
||||
"tripType": "Trip Type",
|
||||
"tripTypeOneWay": "One Way",
|
||||
"tripTypeRoundTrip": "Round Trip",
|
||||
"addItinerary": "Add Itinerary",
|
||||
"selectApprovers": "Select Approvers",
|
||||
"selectCc": "Select CC",
|
||||
"cc": "CC",
|
||||
"pleaseEnterReason": "Please enter reason",
|
||||
"pleaseSelectDate": "Please select date",
|
||||
"pleaseEnterDestination": "Please enter destination",
|
||||
"pleaseEnterCost": "Please enter estimated cost",
|
||||
"noItinerary": "No itinerary",
|
||||
"removeItinerary": "Remove Itinerary"
|
||||
},
|
||||
"approval": {
|
||||
"title": "Approval",
|
||||
|
||||
@@ -68,8 +68,10 @@
|
||||
"startDate": "开始日期",
|
||||
"endDate": "结束日期",
|
||||
"duration": "出差天数",
|
||||
"totalDuration": "出差总时长",
|
||||
"reason": "出差事由",
|
||||
"budget": "预算金额",
|
||||
"budget": "预计费用",
|
||||
"estimatedCost": "预计费用",
|
||||
"submitter": "申请人",
|
||||
"submitTime": "申请时间",
|
||||
"approver": "审批人",
|
||||
@@ -78,7 +80,30 @@
|
||||
"confirmSubmit": "确认提交出差申请?",
|
||||
"confirmCancel": "确认撤销此申请?",
|
||||
"submitSuccess": "提交成功",
|
||||
"cancelSuccess": "撤销成功"
|
||||
"cancelSuccess": "撤销成功",
|
||||
"itinerary": "行程明细",
|
||||
"itineraryDate": "日期",
|
||||
"itineraryStartTime": "开始时间",
|
||||
"itineraryEndTime": "结束时间",
|
||||
"itineraryDeparture": "出发地",
|
||||
"itineraryDestination": "目的地",
|
||||
"itineraryTransport": "交通工具",
|
||||
"itineraryAccommodation": "住宿安排",
|
||||
"itineraryRemark": "备注",
|
||||
"tripType": "行程类型",
|
||||
"tripTypeOneWay": "单程",
|
||||
"tripTypeRoundTrip": "往返",
|
||||
"addItinerary": "添加行程",
|
||||
"selectApprovers": "选择审批人",
|
||||
"selectCc": "选择抄送",
|
||||
"approvers": "审批人",
|
||||
"cc": "抄送",
|
||||
"pleaseEnterReason": "请输入出差事由",
|
||||
"pleaseSelectDate": "请选择日期",
|
||||
"pleaseEnterDestination": "请输入目的地",
|
||||
"pleaseEnterCost": "请输入预计费用",
|
||||
"noItinerary": "暂无行程",
|
||||
"removeItinerary": "删除行程"
|
||||
},
|
||||
"approval": {
|
||||
"title": "审批管理",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
.apply {
|
||||
padding: 16px;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding: 12px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.form {
|
||||
@@ -9,32 +12,343 @@
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group label {
|
||||
display: block;
|
||||
min-width: 70px;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
.group label.required::after {
|
||||
content: ' *';
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.group input,
|
||||
.group textarea,
|
||||
.group .readonlyInput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.group input,
|
||||
.group textarea {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.group textarea {
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.required::after {
|
||||
content: ' *';
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.readonlyInput {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
min-height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
min-width: 70px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.field > *:not(label) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field .readonlyInput {
|
||||
min-height: 32px;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.dateField {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dateField label {
|
||||
min-width: 70px;
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dateField > *:not(label) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dateClickable {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
min-height: 32px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.group input {
|
||||
.col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.col label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.col input,
|
||||
.col .readonlyInput,
|
||||
.col .datePicker {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
min-height: 32px;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.col textarea {
|
||||
width: 100%;
|
||||
min-height: 50px;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.datePicker {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.itineraryItem {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.itineraryHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.itineraryIndex {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff4d4f;
|
||||
padding: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.addBtn:active {
|
||||
background: #f5f5f5;
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.selector {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selector:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.selector .label {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.selector .value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selector .placeholder {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.selector .icon {
|
||||
color: #999;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.selectedUsers {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: -8px;
|
||||
}
|
||||
|
||||
.selectedUsers .avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
margin-left: -8px;
|
||||
}
|
||||
|
||||
.selectedUsers .avatar:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
padding: 10px 16px;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.saveBtn,
|
||||
.submitBtn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.saveBtn {
|
||||
background: #fff;
|
||||
border: 1px solid #d9d9d9;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.saveBtn:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.submitBtn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.submitBtn:active {
|
||||
background: #0958d9;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navBar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.selectedSection {
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.selectedHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.selectedTitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.addBtn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #d9d9d9;
|
||||
background: #fff;
|
||||
color: #666;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addBtn:active {
|
||||
border-color: #1677ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
|
||||
.selectedList {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.selectedUser {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.selectedAvatar {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.selectedName {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.searchSection {
|
||||
background: #fff;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.userList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.userItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.userItem:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.userDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.position {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
color: #1677ff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavBar, SearchBar, Checkbox, Button, Toast, Empty } from 'antd-mobile'
|
||||
import { CheckOutline } from 'antd-mobile-icons'
|
||||
import styles from './select-approver.module.css'
|
||||
|
||||
interface UserItem {
|
||||
id: number
|
||||
name: string
|
||||
avatar?: string
|
||||
position?: string
|
||||
}
|
||||
|
||||
interface LocationState {
|
||||
selected: UserItem[]
|
||||
type: 'approver' | 'cc'
|
||||
}
|
||||
|
||||
export default function SelectUserPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const state = location.state as LocationState
|
||||
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserItem[]>(state?.selected || [])
|
||||
const [allUsers, setAllUsers] = useState<UserItem[]>([])
|
||||
|
||||
// Mock data - 模拟用户数据
|
||||
useEffect(() => {
|
||||
setAllUsers([
|
||||
{ id: 1, name: '张三', position: '部门主管', avatar: '/default-avatar.png' },
|
||||
{ id: 2, name: '李四', position: '人事经理', avatar: '/default-avatar.png' },
|
||||
{ id: 3, name: '王五', position: '总经理', avatar: '/default-avatar.png' },
|
||||
{ id: 4, name: '赵六', position: '财务总监', avatar: '/default-avatar.png' },
|
||||
{ id: 5, name: '孙七', position: '部门主管', avatar: '/default-avatar.png' },
|
||||
{ id: 6, name: '周八', position: '人事专员', avatar: '/default-avatar.png' },
|
||||
])
|
||||
}, [])
|
||||
|
||||
const filteredUsers = allUsers.filter(user =>
|
||||
user.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
|
||||
const isUserSelected = (userId: number) => {
|
||||
return selectedUsers.some(u => u.id === userId)
|
||||
}
|
||||
|
||||
const handleToggleUser = (user: UserItem) => {
|
||||
if (isUserSelected(user.id)) {
|
||||
setSelectedUsers(selectedUsers.filter(u => u.id !== user.id))
|
||||
} else {
|
||||
setSelectedUsers([...selectedUsers, user])
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (state?.type === 'approver') {
|
||||
// TODO: 保存选中的审批人并返回
|
||||
} else {
|
||||
// TODO: 保存选中的抄送人并返回
|
||||
}
|
||||
navigate(-1)
|
||||
}
|
||||
|
||||
const handleAddApprover = () => {
|
||||
// 审批人页面特有的添加功能
|
||||
Toast.show('添加自定义审批人')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<NavBar
|
||||
onBack={() => navigate(-1)}
|
||||
className={styles.navBar}
|
||||
>
|
||||
{state?.type === 'approver' ? t('businessTrip.approvers') : t('businessTrip.cc')}
|
||||
</NavBar>
|
||||
|
||||
{state?.type === 'approver' && (
|
||||
<div className={styles.selectedSection}>
|
||||
<div className={styles.selectedHeader}>
|
||||
<span className={styles.selectedTitle}>已选审批人</span>
|
||||
<button className={styles.addBtn} onClick={handleAddApprover}>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.selectedList}>
|
||||
{selectedUsers.length > 0 ? (
|
||||
selectedUsers.map(user => (
|
||||
<div key={user.id} className={styles.selectedUser}>
|
||||
<img
|
||||
src={user.avatar || '/default-avatar.png'}
|
||||
alt={user.name}
|
||||
className={styles.selectedAvatar}
|
||||
/>
|
||||
<span className={styles.selectedName}>{user.name}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className={styles.emptyText}>请选择审批人</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.searchSection}>
|
||||
<SearchBar
|
||||
placeholder={t('common.search')}
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.userList}>
|
||||
{filteredUsers.length > 0 ? (
|
||||
filteredUsers.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
className={styles.userItem}
|
||||
onClick={() => handleToggleUser(user)}
|
||||
>
|
||||
<div className={styles.userInfo}>
|
||||
<img
|
||||
src={user.avatar || '/default-avatar.png'}
|
||||
alt={user.name}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.userDetails}>
|
||||
<span className={styles.name}>{user.name}</span>
|
||||
{user.position && (
|
||||
<span className={styles.position}>{user.position}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isUserSelected(user.id) && (
|
||||
<CheckOutline className={styles.checkIcon} />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<Empty description={t('common.noData')} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedUsers.length === 0}
|
||||
>
|
||||
{t('common.confirm')} ({selectedUsers.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navBar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.searchSection {
|
||||
background: #fff;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.userList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.userItem {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.userItem:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.userDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.position {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
--size: 20px;
|
||||
--icon-size: 14px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavBar, SearchBar, Checkbox, Button, Empty } from 'antd-mobile'
|
||||
import styles from './select-cc.module.css'
|
||||
|
||||
interface UserItem {
|
||||
id: number
|
||||
name: string
|
||||
avatar?: string
|
||||
position?: string
|
||||
}
|
||||
|
||||
interface LocationState {
|
||||
selected: UserItem[]
|
||||
type: 'approver' | 'cc'
|
||||
}
|
||||
|
||||
export default function SelectCcPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const state = location.state as LocationState
|
||||
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [selectedUsers, setSelectedUsers] = useState<Set<number>>(
|
||||
new Set(state?.selected?.map(u => u.id) || [])
|
||||
)
|
||||
const [allUsers, setAllUsers] = useState<UserItem[]>([])
|
||||
|
||||
// Mock data - 模拟用户数据
|
||||
useEffect(() => {
|
||||
setAllUsers([
|
||||
{ id: 1, name: '张三', position: '部门主管', avatar: '/default-avatar.png' },
|
||||
{ id: 2, name: '李四', position: '人事经理', avatar: '/default-avatar.png' },
|
||||
{ id: 3, name: '王五', position: '总经理', avatar: '/default-avatar.png' },
|
||||
{ id: 4, name: '赵六', position: '财务总监', avatar: '/default-avatar.png' },
|
||||
{ id: 5, name: '孙七', position: '部门主管', avatar: '/default-avatar.png' },
|
||||
{ id: 6, name: '周八', position: '人事专员', avatar: '/default-avatar.png' },
|
||||
])
|
||||
}, [])
|
||||
|
||||
const filteredUsers = allUsers.filter(user =>
|
||||
user.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
|
||||
const handleToggleUser = (userId: number) => {
|
||||
const newSelected = new Set(selectedUsers)
|
||||
if (newSelected.has(userId)) {
|
||||
newSelected.delete(userId)
|
||||
} else {
|
||||
newSelected.add(userId)
|
||||
}
|
||||
setSelectedUsers(newSelected)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const selected = allUsers.filter(u => selectedUsers.has(u.id))
|
||||
// TODO: 保存选中的抄送人并返回
|
||||
navigate(-1, { selected })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<NavBar
|
||||
onBack={() => navigate(-1)}
|
||||
className={styles.navBar}
|
||||
>
|
||||
{t('businessTrip.cc')}
|
||||
</NavBar>
|
||||
|
||||
<div className={styles.searchSection}>
|
||||
<SearchBar
|
||||
placeholder={t('common.search')}
|
||||
value={searchValue}
|
||||
onChange={setSearchValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.userList}>
|
||||
<Checkbox.Group
|
||||
value={Array.from(selectedUsers)}
|
||||
onChange={val => setSelectedUsers(new Set(val as number[]))}
|
||||
>
|
||||
{filteredUsers.length > 0 ? (
|
||||
filteredUsers.map(user => (
|
||||
<label key={user.id} className={styles.userItem}>
|
||||
<div className={styles.userInfo}>
|
||||
<img
|
||||
src={user.avatar || '/default-avatar.png'}
|
||||
alt={user.name}
|
||||
className={styles.avatar}
|
||||
/>
|
||||
<div className={styles.userDetails}>
|
||||
<span className={styles.name}>{user.name}</span>
|
||||
{user.position && (
|
||||
<span className={styles.position}>{user.position}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox value={user.id} className={styles.checkbox} />
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
<Empty description={t('common.noData')} />
|
||||
)}
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedUsers.size === 0}
|
||||
>
|
||||
{t('common.confirm')} ({selectedUsers.size})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import Apps from '@/pages/apps'
|
||||
import BusinessTripList from '@/pages/business-trip/list'
|
||||
import BusinessTripApply from '@/pages/business-trip/apply'
|
||||
import BusinessTripDetail from '@/pages/business-trip/detail'
|
||||
import SelectApprover from '@/pages/business-trip/select-approver'
|
||||
import SelectCc from '@/pages/business-trip/select-cc'
|
||||
import Profile from '@/pages/profile'
|
||||
import Login from '@/pages/login'
|
||||
|
||||
@@ -18,6 +20,8 @@ const router = createBrowserRouter([
|
||||
{ path: 'apps', element: <Apps /> },
|
||||
{ path: 'business-trip', element: <BusinessTripList /> },
|
||||
{ path: 'business-trip/apply', element: <BusinessTripApply /> },
|
||||
{ path: 'business-trip/apply/select-approver', element: <SelectApprover /> },
|
||||
{ path: 'business-trip/apply/select-cc', element: <SelectCc /> },
|
||||
{ path: 'business-trip/:id', element: <BusinessTripDetail /> },
|
||||
{ path: 'profile', element: <Profile /> },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { api } from './api'
|
||||
|
||||
// 出差申请接口类型定义
|
||||
export interface BusinessTripItinerary {
|
||||
startTime: string
|
||||
endTime: string
|
||||
departure?: string
|
||||
destination: string
|
||||
transport?: string
|
||||
tripType?: 'oneway' | 'roundtrip'
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface CreateBusinessTripDto {
|
||||
reason: string
|
||||
itineraries: BusinessTripItinerary[]
|
||||
approvers: number[]
|
||||
cc_users?: number[]
|
||||
}
|
||||
|
||||
export interface BusinessTripItem {
|
||||
id: number
|
||||
user_id: number
|
||||
user_name: string
|
||||
reason: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
duration: number
|
||||
status: 'draft' | 'pending' | 'approved' | 'rejected' | 'cancelled'
|
||||
approval_order_id: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface BusinessTripDetail extends BusinessTripItem {
|
||||
itineraries: BusinessTripItinerary[]
|
||||
approvers: ApproverInfo[]
|
||||
cc_users: ApproverInfo[]
|
||||
}
|
||||
|
||||
export interface ApproverInfo {
|
||||
id: number
|
||||
name: string
|
||||
avatar?: string
|
||||
position?: string
|
||||
}
|
||||
|
||||
// 出差申请 API
|
||||
export const businessTripApi = {
|
||||
// 创建出差申请
|
||||
create: (data: CreateBusinessTripDto) =>
|
||||
api.post('/business-trip', data),
|
||||
|
||||
// 获取出差列表
|
||||
getList: (params?: { status?: string; page?: number; page_size?: number }) =>
|
||||
api.get<BusinessTripItem[]>('/business-trip', { params }),
|
||||
|
||||
// 获取出差详情
|
||||
getDetail: (id: number) =>
|
||||
api.get<BusinessTripDetail>(`/business-trip/${id}`),
|
||||
|
||||
// 更新出差申请(草稿状态)
|
||||
update: (id: number, data: Partial<CreateBusinessTripDto>) =>
|
||||
api.put(`/business-trip/${id}`, data),
|
||||
|
||||
// 提交审批
|
||||
submit: (id: number) =>
|
||||
api.post(`/business-trip/${id}/submit`),
|
||||
|
||||
// 撤销申请
|
||||
cancel: (id: number) =>
|
||||
api.post(`/business-trip/${id}/cancel`),
|
||||
|
||||
// 获取用户列表(用于选择审批人和抄送)
|
||||
getUsers: (params?: { search?: string }) =>
|
||||
api.get<ApproverInfo[]>('/users', { params }),
|
||||
|
||||
// 保存草稿
|
||||
saveDraft: (data: CreateBusinessTripDto) =>
|
||||
api.post('/business-trip/draft', data),
|
||||
|
||||
// 获取草稿
|
||||
getDrafts: () =>
|
||||
api.get<BusinessTripItem[]>('/business-trip/drafts'),
|
||||
}
|
||||
Reference in New Issue
Block a user