fix: 修复页面间状态丢失问题并优化选择页面
- 修复选择审批人后再选择抄送人导致状态丢失的问题 - 使用独立的 sessionStorage key (selectedApprovers, selectedCc) - 组件每次挂载时从 sessionStorage 恢复状态 - 只在提交/保存成功后清除 sessionStorage - 优化人员选择页面布局和样式 - 更新 CLAUDE.md 文档记录问题和解决方案 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -26,6 +27,7 @@ body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-size-adjust: 100%; /* 防止 iOS 自动调整字体大小 */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#root {
|
||||
|
||||
+10
-1
@@ -1,6 +1,13 @@
|
||||
import { StrictMode, useEffect } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { RouterProvider } from 'react-router-dom'
|
||||
import { ConfigProvider } from 'antd-mobile'
|
||||
import zhCN from 'antd-mobile/es/locales/zh-CN'
|
||||
import 'antd-mobile/es/global'
|
||||
// 导入必要的组件样式
|
||||
import 'antd-mobile/es/components/popup/popup.css'
|
||||
import 'antd-mobile/es/components/mask/mask.css'
|
||||
import 'antd-mobile/es/components/date-picker/date-picker.css'
|
||||
import router from './router'
|
||||
import { initRem } from './utils/rem'
|
||||
import './i18n'
|
||||
@@ -20,6 +27,8 @@ function App() {
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</StrictMode>
|
||||
)
|
||||
|
||||
@@ -12,22 +12,50 @@ interface ApprovalActionProps {
|
||||
export default function ApprovalAction({ orderId, status }: ApprovalActionProps) {
|
||||
const { approve, reject, cancel, isApproving, isRejecting, isCancelling } = useApprovalAction()
|
||||
const [comment, setComment] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve')
|
||||
|
||||
const canAction = status === 'pending'
|
||||
const canCancel = status === 'pending' || status === 'draft'
|
||||
|
||||
const handleApprove = () => {
|
||||
approve({ orderId, comment: comment || undefined })
|
||||
setShowModal(false)
|
||||
setComment('')
|
||||
Modal.confirm({
|
||||
content: (
|
||||
<div className={styles.modalContent}>
|
||||
<div className={styles.modalTitle}>确认通过此申请?</div>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
placeholder="请输入审批意见(可选)"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
onConfirm: async () => {
|
||||
await approve({ orderId, comment: comment || undefined })
|
||||
setComment('')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
reject({ orderId, comment: comment || '已拒绝' })
|
||||
setShowModal(false)
|
||||
setComment('')
|
||||
Modal.confirm({
|
||||
content: (
|
||||
<div className={styles.modalContent}>
|
||||
<div className={styles.modalTitle}>确认拒绝此申请?</div>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
placeholder="请输入拒绝原因"
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
onConfirm: async () => {
|
||||
await reject({ orderId, comment: comment || '已拒绝' })
|
||||
setComment('')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
@@ -36,76 +64,39 @@ export default function ApprovalAction({ orderId, status }: ApprovalActionProps)
|
||||
}
|
||||
}
|
||||
|
||||
const openModal = (action: 'approve' | 'reject') => {
|
||||
setActionType(action)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
if (!canAction && !canCancel) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.actions}>
|
||||
{canAction && (
|
||||
<>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.approveBtn}`}
|
||||
onClick={() => openModal('approve')}
|
||||
disabled={isApproving}
|
||||
>
|
||||
{isApproving ? '处理中...' : '通过'}
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.rejectBtn}`}
|
||||
onClick={() => openModal('reject')}
|
||||
disabled={isRejecting}
|
||||
>
|
||||
{isRejecting ? '处理中...' : '拒绝'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canCancel && !canAction && (
|
||||
<div className={styles.actions}>
|
||||
{canAction && (
|
||||
<>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.cancelBtn}`}
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
className={`${styles.btn} ${styles.approveBtn}`}
|
||||
onClick={handleApprove}
|
||||
disabled={isApproving}
|
||||
>
|
||||
{isCancelling ? '处理中...' : '撤销申请'}
|
||||
{isApproving ? '处理中...' : '通过'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
visible={showModal}
|
||||
title={actionType === 'approve' ? '审批通过' : '审批拒绝'}
|
||||
onClose={() => setShowModal(false)}
|
||||
content={
|
||||
<div className={styles.modalContent}>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
placeholder={actionType === 'approve' ? '请输入审批意见(可选)' : '请输入拒绝原因'}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
key: 'cancel',
|
||||
text: '取消',
|
||||
onClick: () => setShowModal(false),
|
||||
},
|
||||
{
|
||||
key: 'confirm',
|
||||
text: actionType === 'approve' ? '确认通过' : '确认拒绝',
|
||||
primary: true,
|
||||
onClick: actionType === 'approve' ? handleApprove : handleReject,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
<button
|
||||
className={`${styles.btn} ${styles.rejectBtn}`}
|
||||
onClick={handleReject}
|
||||
disabled={isRejecting}
|
||||
>
|
||||
{isRejecting ? '处理中...' : '拒绝'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{canCancel && !canAction && (
|
||||
<button
|
||||
className={`${styles.btn} ${styles.cancelBtn}`}
|
||||
onClick={handleCancel}
|
||||
disabled={isCancelling}
|
||||
>
|
||||
{isCancelling ? '处理中...' : '撤销申请'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -104,6 +104,14 @@
|
||||
min-height: 32px;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.field .readonlyInput {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dateField {
|
||||
@@ -127,10 +135,66 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 修复:DatePicker 组件不需要 flex 样式,避免影响内部结构 */
|
||||
.dateField :global(.adm-date-picker) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* DatePicker 弹框样式 - 确保可见 */
|
||||
:global(.adm-popover) {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
:global(.adm-date-picker-popover) {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
:global(.adm-popup) {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
:global(.adm-mask) {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
/* Selector 组件样式覆盖 */
|
||||
.field :global(.adm-selector) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field :global(.adm-selector .adm-selector-multiple) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.field :global(.adm-selector .adm-selector-item) {
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.field :global(.adm-selector .adm-selector-item.adm-selector-item-active) {
|
||||
background: #1677ff;
|
||||
border-color: #1677ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.field :global(.adm-selector .adm-selector-item.adm-selector-item-disabled) {
|
||||
background: #f5f5f5;
|
||||
border-color: #e5e5e5;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.dateClickable {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
min-height: 32px;
|
||||
font-size: 12px;
|
||||
@@ -138,6 +202,29 @@
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* 覆盖全局 button 样式 */
|
||||
border: 1px solid #e5e5e5 !important;
|
||||
background: #fff !important;
|
||||
padding: 6px 8px !important;
|
||||
}
|
||||
|
||||
.dateClickable:active {
|
||||
background: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
/* 确保 DatePicker 弹框能正常显示 */
|
||||
:global(.adm-date-picker-modal) {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
:global(.adm-popup-body) {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
.col {
|
||||
@@ -316,10 +403,11 @@
|
||||
right: 0;
|
||||
background: #fff;
|
||||
padding: 10px 16px;
|
||||
padding-bottom: max(10px, env(safe-area-inset-bottom));
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
z-index: 100;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.saveBtn,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useState, useMemo, useEffect } 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 } from '../../services/api'
|
||||
import styles from './apply.module.css'
|
||||
|
||||
interface ItineraryItem {
|
||||
@@ -12,7 +13,7 @@ interface ItineraryItem {
|
||||
departure: string
|
||||
destination: string
|
||||
transport: string
|
||||
tripType: 'oneway' | 'roundtrip'
|
||||
tripType: 'oneway' | 'roundtrip' | ''
|
||||
remark: string
|
||||
}
|
||||
|
||||
@@ -55,14 +56,41 @@ const transportOptions = [
|
||||
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: 'oneway', remark: '' }
|
||||
{ startTime: null, endTime: null, departure: '', destination: '', transport: '', tripType: '' as 'oneway' | 'roundtrip' | '', remark: '' }
|
||||
])
|
||||
const [approvers, setApprovers] = useState<ApproverItem[]>([])
|
||||
const [ccUsers, setCcUsers] = useState<ApproverItem[]>([])
|
||||
|
||||
// DatePicker 可见性状态
|
||||
const [datePickerVisible, setDatePickerVisible] = useState<{
|
||||
index: number
|
||||
field: 'startTime' | 'endTime'
|
||||
} | null>(null)
|
||||
|
||||
// 处理从选择页面返回的数据
|
||||
useEffect(() => {
|
||||
// 检查 sessionStorage 中是否有选择的数据
|
||||
const approversData = sessionStorage.getItem('selectedApprovers')
|
||||
const ccData = sessionStorage.getItem('selectedCc')
|
||||
|
||||
// 从 sessionStorage 恢复状态(组件重新挂载时需要)
|
||||
if (approversData) {
|
||||
const approvers = JSON.parse(approversData) as ApproverItem[]
|
||||
setApprovers(approvers)
|
||||
console.log('✅ Restored approvers from sessionStorage:', approvers)
|
||||
}
|
||||
|
||||
if (ccData) {
|
||||
const cc = JSON.parse(ccData) as ApproverItem[]
|
||||
setCcUsers(cc)
|
||||
console.log('✅ Restored CC from sessionStorage:', cc)
|
||||
}
|
||||
}, []) // 只在组件挂载时执行一次
|
||||
|
||||
// 计算总时长(累加所有行程的天数)
|
||||
const totalDuration = useMemo(() => {
|
||||
let total = 0
|
||||
@@ -80,7 +108,7 @@ export default function BusinessTripApply() {
|
||||
departure: '',
|
||||
destination: '',
|
||||
transport: '',
|
||||
tripType: 'oneway',
|
||||
tripType: '' as 'oneway' | 'roundtrip' | '',
|
||||
remark: ''
|
||||
}])
|
||||
}
|
||||
@@ -107,8 +135,47 @@ export default function BusinessTripApply() {
|
||||
navigate('/business-trip/apply/select-cc', { state: { selected: ccUsers, type: 'cc' } })
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
Toast.show('草稿已保存')
|
||||
const handleSave = async () => {
|
||||
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')
|
||||
|
||||
Toast.show('草稿已保存')
|
||||
navigate('/business-trip')
|
||||
} catch (error: any) {
|
||||
Toast.show(error.message || '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
@@ -123,11 +190,50 @@ export default function BusinessTripApply() {
|
||||
return
|
||||
}
|
||||
|
||||
if (approvers.length === 0) {
|
||||
Toast.show('请选择审批人')
|
||||
return
|
||||
}
|
||||
|
||||
Dialog.confirm({
|
||||
content: t('businessTrip.confirmSubmit'),
|
||||
onConfirm: async () => {
|
||||
Toast.show(t('businessTrip.submitSuccess'))
|
||||
navigate('/business-trip')
|
||||
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')
|
||||
|
||||
Toast.show(t('businessTrip.submitSuccess'))
|
||||
navigate('/business-trip')
|
||||
} catch (error: any) {
|
||||
Toast.show(error.message || '提交失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -165,7 +271,7 @@ export default function BusinessTripApply() {
|
||||
<div key={index} className={styles.itineraryItem}>
|
||||
{itineraries.length > 1 && (
|
||||
<div className={styles.itineraryHeader}>
|
||||
<span className={styles.itineraryIndex}>{index + 1}</span>
|
||||
<span className={styles.itineraryIndex}>行程{index + 1}</span>
|
||||
<button
|
||||
className={styles.removeBtn}
|
||||
onClick={() => handleRemoveItinerary(index)}
|
||||
@@ -177,33 +283,43 @@ export default function BusinessTripApply() {
|
||||
|
||||
<div className={styles.dateField}>
|
||||
<label className={styles.required}>{t('businessTrip.itineraryStartTime')}</label>
|
||||
<DatePicker
|
||||
value={item.startTime}
|
||||
onConfirm={(val) => handleUpdateItinerary(index, 'startTime', val)}
|
||||
precision="minute"
|
||||
<div
|
||||
className={styles.dateClickable}
|
||||
onClick={() => setDatePickerVisible({ index, field: 'startTime' })}
|
||||
>
|
||||
{value => (
|
||||
<div className={styles.dateClickable}>
|
||||
{value ? dayjs(value).format('YYYY-MM-DD HH:mm') : t('common.select')}
|
||||
</div>
|
||||
)}
|
||||
</DatePicker>
|
||||
{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) => {
|
||||
handleUpdateItinerary(index, 'startTime', val)
|
||||
setDatePickerVisible(null)
|
||||
}}
|
||||
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
|
||||
value={item.endTime}
|
||||
onConfirm={(val) => handleUpdateItinerary(index, 'endTime', val)}
|
||||
visible={datePickerVisible?.index === index && datePickerVisible?.field === 'endTime'}
|
||||
onConfirm={(val) => {
|
||||
handleUpdateItinerary(index, 'endTime', val)
|
||||
setDatePickerVisible(null)
|
||||
}}
|
||||
onClose={() => setDatePickerVisible(null)}
|
||||
onCancel={() => setDatePickerVisible(null)}
|
||||
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}>
|
||||
@@ -214,7 +330,7 @@ export default function BusinessTripApply() {
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>{t('businessTrip.itineraryDeparture')}</label>
|
||||
<label className={styles.required}>{t('businessTrip.itineraryDeparture')}</label>
|
||||
<Input
|
||||
placeholder={t('businessTrip.itineraryDeparture')}
|
||||
value={item.departure}
|
||||
@@ -234,20 +350,22 @@ export default function BusinessTripApply() {
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>{t('businessTrip.itineraryTransport')}</label>
|
||||
<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>{t('businessTrip.tripType')}</label>
|
||||
<label className={styles.required}>{t('businessTrip.tripType')}</label>
|
||||
<Selector
|
||||
options={tripTypeOptions}
|
||||
value={[item.tripType]}
|
||||
value={item.tripType ? [item.tripType] : []}
|
||||
onChange={(val) => handleUpdateItinerary(index, 'tripType', val[0] as 'oneway' | 'roundtrip')}
|
||||
showCheckMark={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,39 +1,161 @@
|
||||
.detail {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 50vh;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
.statusCard {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
.statusHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
.reason {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.statusInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.infoItem {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.itineraries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.itineraryItem {
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.itineraryHeader {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.itineraryContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.timelineItem {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timelineTitle {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timelineTime {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.timelineDesc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.timelineIconSuccess {
|
||||
color: #52c41a;
|
||||
font-size: 18px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.timelineIconError {
|
||||
color: #ff4d4f;
|
||||
font-size: 18px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 20px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
.footer button {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.approveBtn {
|
||||
background: #52c41a;
|
||||
color: #fff;
|
||||
:global(.adm-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rejectBtn {
|
||||
background: #ff4d4f;
|
||||
color: #fff;
|
||||
:global(.adm-card-header) {
|
||||
padding: 16px 16px 12px;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,290 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NavBar, Card, Tag, Button, Dialog, Toast } from 'antd-mobile'
|
||||
import { LeftOutline, ClockCircleOutline, CheckCircleOutline, CloseCircleOutline, ExclamationCircleOutline } from 'antd-mobile-icons'
|
||||
import { businessTripApi, approvalApi } from '../../services/api'
|
||||
import dayjs from 'dayjs'
|
||||
import styles from './detail.module.css'
|
||||
|
||||
// 状态配置
|
||||
const statusConfig: Record<string, { color: string; icon: React.ReactNode; text: string }> = {
|
||||
draft: { color: 'default', icon: <ClockCircleOutline />, text: '草稿' },
|
||||
pending: { color: 'warning', icon: <ExclamationCircleOutline />, text: '审批中' },
|
||||
approved: { color: 'success', icon: <CheckCircleOutline />, text: '已通过' },
|
||||
rejected: { color: 'danger', icon: <CloseCircleOutline />, text: '已拒绝' },
|
||||
cancelled: { color: 'default', icon: <CloseCircleOutline />, text: '已撤销' },
|
||||
}
|
||||
|
||||
interface Itinerary {
|
||||
id: number
|
||||
start_time: string
|
||||
end_time: string
|
||||
departure: string
|
||||
destination: string
|
||||
transport: string
|
||||
trip_type: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface BusinessTripDetail {
|
||||
id: number
|
||||
reason: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
destination: string
|
||||
status: string
|
||||
estimated_cost: string
|
||||
created_at: string
|
||||
user: {
|
||||
name: string
|
||||
phone: string
|
||||
email: string
|
||||
}
|
||||
itineraries: Itinerary[]
|
||||
}
|
||||
|
||||
export default function BusinessTripDetail() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
|
||||
const [detail, setDetail] = useState<BusinessTripDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
// 加载详情
|
||||
const loadDetail = async () => {
|
||||
if (!id) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await businessTripApi.getDetail(Number(id))
|
||||
setDetail(data)
|
||||
} catch (error: any) {
|
||||
Toast.show(error.message || '加载失败')
|
||||
navigate(-1)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadDetail()
|
||||
}, [id])
|
||||
|
||||
// 撤销申请
|
||||
const handleCancel = () => {
|
||||
Dialog.confirm({
|
||||
content: '确定要撤销此申请吗?',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await businessTripApi.cancel(Number(id))
|
||||
Toast.show('已撤销')
|
||||
loadDetail()
|
||||
} catch (error: any) {
|
||||
Toast.show(error.message || '操作失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑申请
|
||||
const handleEdit = () => {
|
||||
navigate(`/business-trip/apply?id=${id}`)
|
||||
}
|
||||
|
||||
if (loading || !detail) {
|
||||
return <div className={styles.loading}>加载中...</div>
|
||||
}
|
||||
|
||||
const config = statusConfig[detail.status] || statusConfig.draft
|
||||
const canEdit = detail.status === 'draft'
|
||||
const canCancel = detail.status === 'pending'
|
||||
|
||||
// 计算天数
|
||||
const calculateDays = (start: string, end: string) => {
|
||||
const startDate = new Date(start)
|
||||
const endDate = new Date(end)
|
||||
const diff = endDate.getTime() - startDate.getTime()
|
||||
return Math.floor(diff / (1000 * 60 * 60 * 24)) + 1
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (timeStr: string) => {
|
||||
return dayjs(timeStr).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
return dayjs(dateStr).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
<div className={styles.card}>
|
||||
<h2>出差申请详情</h2>
|
||||
<p>详情内容...</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<button className={styles.approveBtn}>通过</button>
|
||||
<button className={styles.rejectBtn}>拒绝</button>
|
||||
<NavBar onBack={() => navigate(-1)}>出差申请详情</NavBar>
|
||||
|
||||
<div className={styles.content}>
|
||||
{/* 状态卡片 */}
|
||||
<Card className={styles.statusCard}>
|
||||
<div className={styles.statusHeader}>
|
||||
<span className={styles.reason}>{detail.reason}</span>
|
||||
<Tag color={config.color}>
|
||||
{config.icon}
|
||||
{config.text}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.statusInfo}>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>申请人:</span>
|
||||
<span className={styles.value}>{detail.user?.name}</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>联系电话:</span>
|
||||
<span className={styles.value}>{detail.user?.phone}</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>出发日期:</span>
|
||||
<span className={styles.value}>{formatDate(detail.start_date)}</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>返回日期:</span>
|
||||
<span className={styles.value}>{formatDate(detail.end_date)}</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>出差天数:</span>
|
||||
<span className={styles.value}>{calculateDays(detail.start_date, detail.end_date)} 天</span>
|
||||
</div>
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>目的地:</span>
|
||||
<span className={styles.value}>{detail.destination}</span>
|
||||
</div>
|
||||
{detail.estimated_cost && detail.estimated_cost !== '0.00' && (
|
||||
<div className={styles.infoItem}>
|
||||
<span className={styles.label}>预估费用:</span>
|
||||
<span className={styles.value}>¥{detail.estimated_cost}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 行程明细 */}
|
||||
<Card title="行程明细" className={styles.section}>
|
||||
{detail.itineraries && detail.itineraries.length > 0 ? (
|
||||
<div className={styles.itineraries}>
|
||||
{detail.itineraries.map((item, index) => (
|
||||
<div key={item.id} className={styles.itineraryItem}>
|
||||
<div className={styles.itineraryHeader}>行程 {index + 1}</div>
|
||||
<div className={styles.itineraryContent}>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>出发时间:</span>
|
||||
<span className={styles.value}>{formatTime(item.start_time)}</span>
|
||||
</div>
|
||||
{item.end_time && (
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>结束时间:</span>
|
||||
<span className={styles.value}>{formatTime(item.end_time)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>出发地:</span>
|
||||
<span className={styles.value}>{item.departure || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>目的地:</span>
|
||||
<span className={styles.value}>{item.destination}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>交通工具:</span>
|
||||
<span className={styles.value}>{item.transport || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>行程类型:</span>
|
||||
<span className={styles.value}>{item.trip_type === 'roundtrip' ? '往返' : '单程'}</span>
|
||||
</div>
|
||||
{item.remark && (
|
||||
<div className={styles.row}>
|
||||
<span className={styles.label}>备注:</span>
|
||||
<span className={styles.value}>{item.remark}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>暂无行程明细</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 审批进度 */}
|
||||
<Card title="审批进度" className={styles.section}>
|
||||
<div className={styles.timeline}>
|
||||
<div className={styles.timelineItem}>
|
||||
<div className={styles.timelineDot}></div>
|
||||
<div className={styles.timelineContent}>
|
||||
<div className={styles.timelineTitle}>提交申请</div>
|
||||
<div className={styles.timelineTime}>
|
||||
{dayjs(detail.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{detail.status === 'pending' && (
|
||||
<div className={styles.timelineItem}>
|
||||
<div className={styles.timelineDotPending}></div>
|
||||
<div className={styles.timelineContent}>
|
||||
<div className={styles.timelineTitle}>等待审批中...</div>
|
||||
<div className={styles.timelineDesc}>审批人将收到通知并处理您的申请</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{detail.status === 'approved' && (
|
||||
<div className={styles.timelineItem}>
|
||||
<div className={styles.timelineDotSuccess}>
|
||||
<CheckCircleOutline className={styles.timelineIconSuccess} />
|
||||
</div>
|
||||
<div className={styles.timelineContent}>
|
||||
<div className={styles.timelineTitle}>审批已通过</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{detail.status === 'rejected' && (
|
||||
<div className={styles.timelineItem}>
|
||||
<div className={styles.timelineDotError}>
|
||||
<CloseCircleOutline className={styles.timelineIconError} />
|
||||
</div>
|
||||
<div className={styles.timelineContent}>
|
||||
<div className={styles.timelineTitle}>审批已拒绝</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{detail.status === 'cancelled' && (
|
||||
<div className={styles.timelineItem}>
|
||||
<div className={styles.timelineDotCancelled}></div>
|
||||
<div className={styles.timelineContent}>
|
||||
<div className={styles.timelineTitle}>已撤销</div>
|
||||
<div className={styles.timelineDesc}>申请人撤销了此申请</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
{(canEdit || canCancel) && (
|
||||
<div className={styles.footer}>
|
||||
{canEdit && (
|
||||
<Button block color="primary" onClick={handleEdit}>
|
||||
编辑申请
|
||||
</Button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<Button block color="warning" onClick={handleCancel}>
|
||||
撤销申请
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,97 @@
|
||||
.list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
border-radius: 6px;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.reason {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
.days {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.destination {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.applicant {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 60px 0;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 16px 0 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 80px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.fab button {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
:global(.adm-tabs-header) {
|
||||
background: #fff;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,186 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { List, Card, Tag, Button, Tabs, PullToRefresh, InfiniteScroll } from 'antd-mobile'
|
||||
import { AddOutline, ClockCircleOutline, CheckCircleOutline, CloseCircleOutline, ExclamationCircleOutline } from 'antd-mobile-icons'
|
||||
import { businessTripApi } from '../../services/api'
|
||||
import styles from './list.module.css'
|
||||
|
||||
// 状态映射
|
||||
const statusConfig: Record<string, { color: string; icon: React.ReactNode; text: string }> = {
|
||||
draft: { color: 'default', icon: <ClockCircleOutline />, text: '草稿' },
|
||||
pending: { color: 'warning', icon: <ExclamationCircleOutline />, text: '审批中' },
|
||||
approved: { color: 'success', icon: <CheckCircleOutline />, text: '已通过' },
|
||||
rejected: { color: 'danger', icon: <CloseCircleOutline />, text: '已拒绝' },
|
||||
cancelled: { color: 'default', icon: <CloseCircleOutline />, text: '已撤销' },
|
||||
}
|
||||
|
||||
interface BusinessTripItem {
|
||||
id: number
|
||||
reason: string
|
||||
start_date: string
|
||||
end_date: string
|
||||
destination: string
|
||||
status: string
|
||||
created_at: string
|
||||
user?: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
export default function BusinessTripList() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'my' | 'pending' | 'approved'>('my')
|
||||
const [list, setList] = useState<BusinessTripItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// 加载数据
|
||||
const loadData = async (pageNum: number = 1, isRefresh: boolean = false) => {
|
||||
if (loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await businessTripApi.getList({
|
||||
type: activeTab,
|
||||
page: pageNum,
|
||||
page_size: 20,
|
||||
})
|
||||
|
||||
const newList = result.list || []
|
||||
|
||||
if (isRefresh || pageNum === 1) {
|
||||
setList(newList)
|
||||
} else {
|
||||
setList([...list, ...newList])
|
||||
}
|
||||
|
||||
setHasMore(newList.length >= 20)
|
||||
setPage(pageNum)
|
||||
} catch (error: any) {
|
||||
console.error('Failed to load business trips:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换标签
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key as 'my' | 'pending' | 'approved')
|
||||
setList([])
|
||||
setHasMore(true)
|
||||
setPage(1)
|
||||
loadData(1, true)
|
||||
}
|
||||
|
||||
// 刷新
|
||||
const handleRefresh = async () => {
|
||||
await loadData(1, true)
|
||||
}
|
||||
|
||||
// 加载更多
|
||||
const handleLoadMore = async () => {
|
||||
await loadData(page + 1, false)
|
||||
}
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadData(1, true)
|
||||
}, [])
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (id: number) => {
|
||||
navigate(`/business-trip/detail/${id}`)
|
||||
}
|
||||
|
||||
// 新建申请
|
||||
const handleCreate = () => {
|
||||
navigate('/business-trip/apply')
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return `${date.getMonth() + 1}/${date.getDate()}`
|
||||
}
|
||||
|
||||
// 计算天数
|
||||
const calculateDays = (start: string, end: string) => {
|
||||
const startDate = new Date(start)
|
||||
const endDate = new Date(end)
|
||||
const diff = endDate.getTime() - startDate.getTime()
|
||||
return Math.floor(diff / (1000 * 60 * 60 * 24)) + 1
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
<div className={styles.tabs}>
|
||||
<button className={`${styles.tab} ${styles.active}`}>我发起的</button>
|
||||
<button className={styles.tab}>待我审批</button>
|
||||
<button className={styles.tab}>我已审批</button>
|
||||
<Tabs activeKey={activeTab} onChange={handleTabChange}>
|
||||
<Tabs.Tab title="我发起的" key="my" />
|
||||
<Tabs.Tab title="待我审批" key="pending" />
|
||||
<Tabs.Tab title="我已审批" key="approved" />
|
||||
</Tabs>
|
||||
|
||||
<PullToRefresh onRefresh={handleRefresh}>
|
||||
<List>
|
||||
{list.map((item) => {
|
||||
const config = statusConfig[item.status] || statusConfig.draft
|
||||
const days = calculateDays(item.start_date, item.end_date)
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={item.id}
|
||||
onClick={() => handleViewDetail(item.id)}
|
||||
className={styles.item}
|
||||
>
|
||||
<Card className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.reason}>{item.reason}</span>
|
||||
<Tag color={config.color}>
|
||||
{config.icon}
|
||||
{config.text}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.info}>
|
||||
<span className={styles.date}>
|
||||
{formatDate(item.start_date)} - {formatDate(item.end_date)}
|
||||
</span>
|
||||
<span className={styles.days}>{days}天</span>
|
||||
</div>
|
||||
<div className={styles.destination}>目的地: {item.destination}</div>
|
||||
{item.user && activeTab !== 'my' && (
|
||||
<div className={styles.applicant}>申请人: {item.user.name}</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</List.Item>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</PullToRefresh>
|
||||
|
||||
{list.length === 0 && !loading && (
|
||||
<div className={styles.empty}>
|
||||
<ExclamationCircleOutline fontSize={48} color="#cccccc" />
|
||||
<p>暂无出差申请</p>
|
||||
{activeTab === 'my' && (
|
||||
<Button color="primary" size="small" onClick={handleCreate}>
|
||||
<AddOutline /> 发起申请
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfiniteScroll loadMore={handleLoadMore} hasMore={hasMore} />
|
||||
|
||||
<div className={styles.fab}>
|
||||
<Button color="primary" onClick={handleCreate}>
|
||||
<AddOutline fontSize={24} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.empty}>暂无出差申请</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navBar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.selectedSection {
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.selectedHeader {
|
||||
@@ -51,10 +62,12 @@
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.selectedUser {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
@@ -66,6 +79,7 @@
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selectedName {
|
||||
@@ -73,6 +87,25 @@
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.removeBtn {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.removeBtn:active {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.emptyText {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
@@ -82,12 +115,22 @@
|
||||
background: #fff;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.userList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 0;
|
||||
padding-bottom: 70px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.userItem {
|
||||
@@ -99,16 +142,26 @@
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.userItem:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.userItem.selected {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@@ -116,32 +169,70 @@
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.userDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.position {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
color: #1677ff;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #1677ff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loadMore:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #f0f0f0;
|
||||
z-index: 999;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { NavBar, SearchBar, Button, Toast, Empty } from 'antd-mobile'
|
||||
import { CheckOutline } from 'antd-mobile-icons'
|
||||
import { userApi } from '../../services/api'
|
||||
import styles from './select-approver.module.css'
|
||||
|
||||
interface UserItem {
|
||||
@@ -10,6 +11,8 @@ interface UserItem {
|
||||
name: string
|
||||
avatar?: string
|
||||
position?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
interface LocationState {
|
||||
@@ -17,6 +20,13 @@ interface LocationState {
|
||||
type: 'approver' | 'cc'
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
list: UserItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export default function SelectUserPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
@@ -24,24 +34,69 @@ export default function SelectUserPage() {
|
||||
const state = location.state as LocationState
|
||||
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserItem[]>(state?.selected || [])
|
||||
// 优先从 location.state 获取,否则从 sessionStorage 获取
|
||||
const getInitialSelected = () => {
|
||||
if (state?.selected) return state.selected
|
||||
if (state?.type === 'approver') {
|
||||
const data = sessionStorage.getItem('selectedApprovers')
|
||||
return data ? JSON.parse(data) : []
|
||||
} else if (state?.type === 'cc') {
|
||||
const data = sessionStorage.getItem('selectedCc')
|
||||
return data ? JSON.parse(data) : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserItem[]>(getInitialSelected())
|
||||
const [allUsers, setAllUsers] = useState<UserItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// Mock data - 模拟用户数据
|
||||
// 加载用户列表
|
||||
const loadUsers = async (pageNum: number = 1, isRefresh: boolean = false) => {
|
||||
if (loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result: ApiResponse = await userApi.getUsers({
|
||||
keyword: searchValue,
|
||||
page: pageNum,
|
||||
page_size: 50,
|
||||
})
|
||||
|
||||
const newList = result.list || []
|
||||
|
||||
if (isRefresh || pageNum === 1) {
|
||||
setAllUsers(newList)
|
||||
} else {
|
||||
setAllUsers([...allUsers, ...newList])
|
||||
}
|
||||
|
||||
setHasMore(newList.length >= 50)
|
||||
setPage(pageNum)
|
||||
} catch (error: any) {
|
||||
Toast.show(error.message || '加载用户列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索用户
|
||||
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 timeoutId = setTimeout(() => {
|
||||
setAllUsers([])
|
||||
setPage(1)
|
||||
setHasMore(true)
|
||||
loadUsers(1, true)
|
||||
}, 300)
|
||||
|
||||
const filteredUsers = allUsers.filter(user =>
|
||||
user.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [searchValue])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadUsers(1, true)
|
||||
}, [])
|
||||
|
||||
const isUserSelected = (userId: number) => {
|
||||
return selectedUsers.some(u => u.id === userId)
|
||||
@@ -56,17 +111,18 @@ export default function SelectUserPage() {
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (state?.type === 'approver') {
|
||||
// TODO: 保存选中的审批人并返回
|
||||
} else {
|
||||
// TODO: 保存选中的抄送人并返回
|
||||
// 根据类型使用不同的 key 存储数据
|
||||
const type = location.state?.type || 'approver'
|
||||
if (type === 'approver') {
|
||||
sessionStorage.setItem('selectedApprovers', JSON.stringify(selectedUsers))
|
||||
} else if (type === 'cc') {
|
||||
sessionStorage.setItem('selectedCc', JSON.stringify(selectedUsers))
|
||||
}
|
||||
navigate(-1)
|
||||
}
|
||||
|
||||
const handleAddApprover = () => {
|
||||
// 审批人页面特有的添加功能
|
||||
Toast.show('添加自定义审批人')
|
||||
Toast.show('添加自定义审批人功能开发中')
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -75,16 +131,15 @@ export default function SelectUserPage() {
|
||||
onBack={() => navigate(-1)}
|
||||
className={styles.navBar}
|
||||
>
|
||||
{state?.type === 'approver' ? t('businessTrip.approvers') : t('businessTrip.cc')}
|
||||
{state?.type === 'approver' ? '选择审批人' : '选择抄送人'}
|
||||
</NavBar>
|
||||
|
||||
{state?.type === 'approver' && (
|
||||
<div className={styles.selectedSection}>
|
||||
<div className={styles.selectedHeader}>
|
||||
<span className={styles.selectedTitle}>已选审批人</span>
|
||||
<button className={styles.addBtn} onClick={handleAddApprover}>
|
||||
+
|
||||
</button>
|
||||
<span className={styles.selectedTitle}>
|
||||
已选{selectedUsers.length > 0 && ` (${selectedUsers.length})`}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.selectedList}>
|
||||
{selectedUsers.length > 0 ? (
|
||||
@@ -96,6 +151,12 @@ export default function SelectUserPage() {
|
||||
className={styles.selectedAvatar}
|
||||
/>
|
||||
<span className={styles.selectedName}>{user.name}</span>
|
||||
<button
|
||||
className={styles.removeBtn}
|
||||
onClick={() => handleToggleUser(user)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
@@ -107,51 +168,61 @@ export default function SelectUserPage() {
|
||||
|
||||
<div className={styles.searchSection}>
|
||||
<SearchBar
|
||||
placeholder={t('common.search')}
|
||||
placeholder="搜索用户姓名"
|
||||
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>
|
||||
)}
|
||||
{allUsers.length > 0 ? (
|
||||
<>
|
||||
{allUsers.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
className={`${styles.userItem} ${isUserSelected(user.id) ? styles.selected : ''}`}
|
||||
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>
|
||||
)}
|
||||
{user.email && (
|
||||
<span className={styles.email}>{user.email}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isUserSelected(user.id) && (
|
||||
<CheckOutline className={styles.checkIcon} />
|
||||
)}
|
||||
</div>
|
||||
{isUserSelected(user.id) && (
|
||||
<CheckOutline className={styles.checkIcon} />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className={styles.loadMore} onClick={() => loadUsers(page + 1)}>
|
||||
加载更多...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Empty description={t('common.noData')} />
|
||||
<Empty description={loading ? '加载中...' : '暂无用户数据'} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
color="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedUsers.length === 0}
|
||||
>
|
||||
{t('common.confirm')} ({selectedUsers.length})
|
||||
确定 ({selectedUsers.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
max-width: 100vw;
|
||||
width: 100%;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navBar {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.searchSection {
|
||||
background: #fff;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.userList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 8px 0;
|
||||
padding-bottom: 70px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.userItem {
|
||||
@@ -31,17 +48,26 @@
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.userItem:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.userItem.selected {
|
||||
background: #e6f7ff;
|
||||
}
|
||||
|
||||
.userInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
@@ -49,32 +75,77 @@
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.userDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.position {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
color: #1677ff;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
--size: 20px;
|
||||
--icon-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.loadMore {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #1677ff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.loadMore:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
padding: 12px 16px;
|
||||
padding-bottom: max(12px, env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #f0f0f0;
|
||||
z-index: 999;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 { NavBar, SearchBar, Button, Toast, Empty } from 'antd-mobile'
|
||||
import { CheckOutline } from 'antd-mobile-icons'
|
||||
import { userApi } from '../../services/api'
|
||||
import styles from './select-cc.module.css'
|
||||
|
||||
interface UserItem {
|
||||
@@ -9,6 +11,8 @@ interface UserItem {
|
||||
name: string
|
||||
avatar?: string
|
||||
position?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
}
|
||||
|
||||
interface LocationState {
|
||||
@@ -16,6 +20,13 @@ interface LocationState {
|
||||
type: 'approver' | 'cc'
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
list: UserItem[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export default function SelectCcPage() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
@@ -23,41 +34,80 @@ export default function SelectCcPage() {
|
||||
const state = location.state as LocationState
|
||||
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [selectedUsers, setSelectedUsers] = useState<Set<number>>(
|
||||
new Set(state?.selected?.map(u => u.id) || [])
|
||||
)
|
||||
// 优先从 location.state 获取,否则从 sessionStorage 获取
|
||||
const getInitialSelected = () => {
|
||||
if (state?.selected) return state.selected
|
||||
const data = sessionStorage.getItem('selectedCc')
|
||||
return data ? JSON.parse(data) : []
|
||||
}
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserItem[]>(getInitialSelected())
|
||||
const [allUsers, setAllUsers] = useState<UserItem[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [page, setPage] = useState(1)
|
||||
|
||||
// Mock data - 模拟用户数据
|
||||
// 加载用户列表
|
||||
const loadUsers = async (pageNum: number = 1, isRefresh: boolean = false) => {
|
||||
if (loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result: ApiResponse = await userApi.getUsers({
|
||||
keyword: searchValue,
|
||||
page: pageNum,
|
||||
page_size: 50,
|
||||
})
|
||||
|
||||
const newList = result.list || []
|
||||
|
||||
if (isRefresh || pageNum === 1) {
|
||||
setAllUsers(newList)
|
||||
} else {
|
||||
setAllUsers([...allUsers, ...newList])
|
||||
}
|
||||
|
||||
setHasMore(newList.length >= 50)
|
||||
setPage(pageNum)
|
||||
} catch (error: any) {
|
||||
Toast.show(error.message || '加载用户列表失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索用户
|
||||
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 timeoutId = setTimeout(() => {
|
||||
setAllUsers([])
|
||||
setPage(1)
|
||||
setHasMore(true)
|
||||
loadUsers(1, true)
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [searchValue])
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadUsers(1, true)
|
||||
}, [])
|
||||
|
||||
const filteredUsers = allUsers.filter(user =>
|
||||
user.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
const isUserSelected = (userId: number) => {
|
||||
return selectedUsers.some(u => u.id === userId)
|
||||
}
|
||||
|
||||
const handleToggleUser = (userId: number) => {
|
||||
const newSelected = new Set(selectedUsers)
|
||||
if (newSelected.has(userId)) {
|
||||
newSelected.delete(userId)
|
||||
const handleToggleUser = (user: UserItem) => {
|
||||
if (isUserSelected(user.id)) {
|
||||
setSelectedUsers(selectedUsers.filter(u => u.id !== user.id))
|
||||
} else {
|
||||
newSelected.add(userId)
|
||||
setSelectedUsers([...selectedUsers, user])
|
||||
}
|
||||
setSelectedUsers(newSelected)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
const selected = allUsers.filter(u => selectedUsers.has(u.id))
|
||||
// TODO: 保存选中的抄送人并返回
|
||||
navigate(-1, { selected })
|
||||
// 使用 sessionStorage 来传递数据
|
||||
sessionStorage.setItem('selectedCc', JSON.stringify(selectedUsers))
|
||||
navigate(-1)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -66,25 +116,26 @@ export default function SelectCcPage() {
|
||||
onBack={() => navigate(-1)}
|
||||
className={styles.navBar}
|
||||
>
|
||||
{t('businessTrip.cc')}
|
||||
选择抄送人
|
||||
</NavBar>
|
||||
|
||||
<div className={styles.searchSection}>
|
||||
<SearchBar
|
||||
placeholder={t('common.search')}
|
||||
placeholder="搜索用户姓名"
|
||||
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}>
|
||||
{allUsers.length > 0 ? (
|
||||
<>
|
||||
{allUsers.map(user => (
|
||||
<div
|
||||
key={user.id}
|
||||
className={`${styles.userItem} ${isUserSelected(user.id) ? styles.selected : ''}`}
|
||||
onClick={() => handleToggleUser(user)}
|
||||
>
|
||||
<div className={styles.userInfo}>
|
||||
<img
|
||||
src={user.avatar || '/default-avatar.png'}
|
||||
@@ -96,25 +147,35 @@ export default function SelectCcPage() {
|
||||
{user.position && (
|
||||
<span className={styles.position}>{user.position}</span>
|
||||
)}
|
||||
{user.email && (
|
||||
<span className={styles.email}>{user.email}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Checkbox value={user.id} className={styles.checkbox} />
|
||||
</label>
|
||||
))
|
||||
) : (
|
||||
<Empty description={t('common.noData')} />
|
||||
)}
|
||||
</Checkbox.Group>
|
||||
{isUserSelected(user.id) && (
|
||||
<CheckOutline className={styles.checkIcon} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className={styles.loadMore} onClick={() => loadUsers(page + 1)}>
|
||||
加载更多...
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Empty description={loading ? '加载中...' : '暂无用户数据'} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
color="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={selectedUsers.size === 0}
|
||||
disabled={selectedUsers.length === 0}
|
||||
>
|
||||
{t('common.confirm')} ({selectedUsers.size})
|
||||
确定 ({selectedUsers.length})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,90 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Toast } from 'antd-mobile'
|
||||
import styles from './login.module.css'
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
// 检查 URL 中是否有 auth_code 参数(飞书授权回调)
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const authCode = urlParams.get('auth_code')
|
||||
|
||||
if (authCode) {
|
||||
// 有授权码,执行登录
|
||||
handleFeishuLogin(authCode)
|
||||
} else {
|
||||
// 没有授权码,跳转到飞书授权页面
|
||||
redirectToFeishu()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const redirectToFeishu = () => {
|
||||
// 飞书应用配置
|
||||
const appId = 'cli_a8033d607cba500d'
|
||||
// 当前页面 URL 作为回调地址
|
||||
const redirectUri = encodeURIComponent(window.location.origin + '/login')
|
||||
// 租户密钥(新友合公司)
|
||||
const tenantKey = '14e7bc0fe5985740'
|
||||
|
||||
// 构建飞书授权 URL
|
||||
const authUrl = `https://open.feishu.cn/open-apis/authen/v1/oidc/authorize?app_id=${appId}&redirect_uri=${redirectUri}&scope=&state=${tenantKey}`
|
||||
|
||||
// 跳转到飞书授权页面
|
||||
window.location.href = authUrl
|
||||
}
|
||||
|
||||
const handleFeishuLogin = async (authCode: string) => {
|
||||
try {
|
||||
const tenantKey = '14e7bc0fe5985740'
|
||||
|
||||
// 调用后端登录接口
|
||||
const response = await fetch('/api/v1/auth/feishu-login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
auth_code: authCode,
|
||||
tenant_key: tenantKey,
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.code === 0) {
|
||||
// 登录成功,保存 token
|
||||
localStorage.setItem('token', result.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(result.data.user))
|
||||
|
||||
Toast.show('登录成功')
|
||||
// 跳转到首页
|
||||
navigate('/dashboard')
|
||||
} else {
|
||||
Toast.show(result.message || '登录失败')
|
||||
// 登录失败,重新跳转到飞书授权
|
||||
setTimeout(() => {
|
||||
redirectToFeishu()
|
||||
}, 2000)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('登录失败:', error)
|
||||
Toast.show('登录失败,请重试')
|
||||
setTimeout(() => {
|
||||
redirectToFeishu()
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.login}>
|
||||
<div className={styles.container}>
|
||||
<h1>企业工具台</h1>
|
||||
<button className={styles.feishuBtn}>飞书授权登录</button>
|
||||
<p>正在跳转到飞书授权...</p>
|
||||
<button className={styles.feishuBtn} onClick={redirectToFeishu}>
|
||||
重新跳转飞书登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,99 +1,102 @@
|
||||
import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||
import { API_BASE_URL, API_TIMEOUT } from '@/config'
|
||||
import axios from 'axios'
|
||||
|
||||
// 请求响应接口
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 请求配置接口
|
||||
export interface RequestConfig extends InternalAxiosRequestConfig {
|
||||
skipAuth?: boolean
|
||||
}
|
||||
const API_BASE = '/api/v1'
|
||||
|
||||
// 创建 axios 实例
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: API_TIMEOUT,
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
apiClient.interceptors.request.use(
|
||||
(config: RequestConfig) => {
|
||||
// 从 localStorage 获取 token
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token && !config.skipAuth) {
|
||||
// 请求拦截器:添加 token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
// 响应拦截器:处理错误
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
const { data } = response
|
||||
if (data.code === 0) {
|
||||
return response
|
||||
return data.data
|
||||
}
|
||||
// 处理业务错误
|
||||
return Promise.reject(new Error(data.message || 'Request failed'))
|
||||
return Promise.reject(new Error(data.message || '请求失败'))
|
||||
},
|
||||
(error: AxiosError<ApiResponse>) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
switch (status) {
|
||||
case 401:
|
||||
// 未授权,跳转登录
|
||||
localStorage.removeItem('access_token')
|
||||
window.location.href = '/login'
|
||||
break
|
||||
case 403:
|
||||
console.error('Forbidden:', data?.message)
|
||||
break
|
||||
case 404:
|
||||
console.error('Not Found:', data?.message)
|
||||
break
|
||||
case 500:
|
||||
console.error('Server Error:', data?.message)
|
||||
break
|
||||
default:
|
||||
console.error('HTTP Error:', status, data?.message)
|
||||
}
|
||||
} else if (error.request) {
|
||||
console.error('Network Error:', error.message)
|
||||
} else {
|
||||
console.error('Error:', error.message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
(error) => {
|
||||
const message = error.response?.data?.message || error.message || '网络错误'
|
||||
return Promise.reject(new Error(message))
|
||||
}
|
||||
)
|
||||
|
||||
// API 方法封装
|
||||
export const api = {
|
||||
get: <T = any>(url: string, config?: RequestConfig): Promise<ApiResponse<T>> => {
|
||||
return apiClient.get(url, config).then(res => res.data)
|
||||
},
|
||||
// ========== 认证 API ==========
|
||||
export const authApi = {
|
||||
login: (username: string, password: string) =>
|
||||
api.post('/auth/login', { username, password }),
|
||||
|
||||
post: <T = any>(url: string, data?: any, config?: RequestConfig): Promise<ApiResponse<T>> => {
|
||||
return apiClient.post(url, data, config).then(res => res.data)
|
||||
},
|
||||
|
||||
put: <T = any>(url: string, data?: any, config?: RequestConfig): Promise<ApiResponse<T>> => {
|
||||
return apiClient.put(url, data, config).then(res => res.data)
|
||||
},
|
||||
|
||||
delete: <T = any>(url: string, config?: RequestConfig): Promise<ApiResponse<T>> => {
|
||||
return apiClient.delete(url, config).then(res => res.data)
|
||||
},
|
||||
feishuLogin: (authCode: string, tenantKey: string) =>
|
||||
api.post('/auth/feishu-login', { auth_code: authCode, tenant_key: tenantKey }),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
// ========== 用户 API ==========
|
||||
export const userApi = {
|
||||
getInfo: () => api.get('/user/info'),
|
||||
getMenu: () => api.get('/user/menu'),
|
||||
getCompanies: () => api.get('/user/companies'),
|
||||
switchCompany: (companyId: number) =>
|
||||
api.post('/user/switch-company', { company_id: companyId }),
|
||||
|
||||
// 获取用户列表
|
||||
getUsers: (params: { keyword?: string; page?: number; page_size?: number }) =>
|
||||
api.get('/user/list', { params }),
|
||||
}
|
||||
|
||||
// ========== 出差申请 API ==========
|
||||
export const businessTripApi = {
|
||||
getList: (params: { type?: string; page?: number; page_size?: number }) =>
|
||||
api.get('/business-trip/list', { params }),
|
||||
|
||||
getDetail: (id: number) => api.get(`/business-trip/detail/${id}`),
|
||||
|
||||
create: (data: any) => api.post('/business-trip/create', data),
|
||||
|
||||
update: (id: number, data: any) =>
|
||||
api.post(`/business-trip/update/${id}`, data),
|
||||
|
||||
submit: (id: number, approvers: number[], ccUsers: number[] = []) =>
|
||||
api.post(`/business-trip/submit/${id}`, {
|
||||
approvers,
|
||||
cc_users: ccUsers,
|
||||
}),
|
||||
|
||||
cancel: (id: number) => api.post(`/business-trip/cancel/${id}`),
|
||||
}
|
||||
|
||||
// ========== 审批 API ==========
|
||||
export const approvalApi = {
|
||||
getList: (params: { type?: string; page?: number; page_size?: number }) =>
|
||||
api.get('/approval/list', { params }),
|
||||
|
||||
getDetail: (id: number) => api.get(`/approval/detail/${id}`),
|
||||
|
||||
approve: (id: number, comment?: string) =>
|
||||
api.post(`/approval/approve/${id}`, { comment }),
|
||||
|
||||
reject: (id: number, comment?: string) =>
|
||||
api.post(`/approval/reject/${id}`, { comment }),
|
||||
|
||||
cancel: (id: number) => api.post(`/approval/cancel/${id}`),
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
Reference in New Issue
Block a user