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
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

+55
View File
@@ -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: { ... }
}
```
+26 -1
View File
@@ -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",
+27 -2
View File
@@ -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": "审批管理",
+328 -14
View File
@@ -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;
}
+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>
)
}
@@ -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>
)
}
+4
View File
@@ -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 /> },
],
+85
View File
@@ -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'),
}