fix: 修复页面间状态丢失问题并优化选择页面

- 修复选择审批人后再选择抄送人导致状态丢失的问题
- 使用独立的 sessionStorage key (selectedApprovers, selectedCc)
- 组件每次挂载时从 sessionStorage 恢复状态
- 只在提交/保存成功后清除 sessionStorage
- 优化人员选择页面布局和样式
- 更新 CLAUDE.md 文档记录问题和解决方案

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-25 15:22:12 +08:00
parent 2d39357cc3
commit 09d4cb8054
51 changed files with 2944 additions and 647 deletions
+2
View File
@@ -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
View File
@@ -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,
+151 -33
View File
@@ -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;
}
+281 -7
View File
@@ -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;
}
+177 -5
View File
@@ -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);
}
+105 -44
View File
@@ -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>
+80 -1
View File
@@ -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>
)
+76 -73
View File
@@ -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