diff --git a/dev/chaosong.jpg b/dev/chaosong.jpg new file mode 100644 index 0000000..604b502 Binary files /dev/null and b/dev/chaosong.jpg differ diff --git a/dev/input.jpg b/dev/input.jpg new file mode 100644 index 0000000..27c7bf2 Binary files /dev/null and b/dev/input.jpg differ diff --git a/dev/shenpi.jpg b/dev/shenpi.jpg new file mode 100644 index 0000000..a0d509c Binary files /dev/null and b/dev/shenpi.jpg differ diff --git a/dev/已知问题和解决方案.md b/dev/已知问题和解决方案.md new file mode 100644 index 0000000..c50a33f --- /dev/null +++ b/dev/已知问题和解决方案.md @@ -0,0 +1,55 @@ +# 前端开发已知问题和解决方案 + +## 1. antd-mobile-icons 导入错误 + +**问题**: `The requested module '/node_modules/.vite/deps/antd-mobile-icons.js' does not provide an export named 'PlusOutline'` + +**原因**: antd-mobile-icons 中没有 `PlusOutline` 图标 + +**解决方案**: 使用正确的图标名称: +- `PlusOutline` → `AddOutline` +- 可通过查看 `node_modules/antd-mobile-icons/es/index.d.ts` 确认可用的图标 + +## 2. DatePicker 组件报错 + +**问题**: `Cannot read properties of null (reading 'getTime')` + +**原因**: DatePicker 组件的 `min` 或 `max` 属性接收到了 `null` 值 + +**解决方案**: 使用条件展开运算符,只在值存在时传递属性: +```tsx +// 错误写法 + + +// 正确写法 + +``` + +## 3. 端口占用问题 + +**问题**: Vite 启动时端口被占用,自动尝试其他端口 + +**解决方案**: 清理旧进程后重启 +```bash +ps aux | grep vite | grep -v grep | awk '{print $2}' | xargs -r kill -9 +``` + +## 4. 防火墙规则 + +**问题**: 外网无法访问开发服务器 + +**解决方案**: 添加防火墙规则 +```bash +iptables -I INPUT -p tcp --dport 5173 -j ACCEPT +``` + +## 5. Vite 配置 + +确保 `vite.config.ts` 中配置正确的 host: +```ts +server: { + port: 5173, + host: '0.0.0.0', // 允许外网访问 + proxy: { ... } +} +``` diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 33f569b..8f72d07 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -68,17 +68,42 @@ "startDate": "Start Date", "endDate": "End Date", "duration": "Duration", + "totalDuration": "Total Duration", "reason": "Reason", "budget": "Budget", + "estimatedCost": "Estimated Cost", "submitter": "Applicant", "submitTime": "Submit Time", "approver": "Approver", + "approvers": "Approvers", "approvalStatus": "Status", "approvalComment": "Comment", "confirmSubmit": "Confirm to submit?", "confirmCancel": "Confirm to cancel?", "submitSuccess": "Submitted successfully", - "cancelSuccess": "Cancelled successfully" + "cancelSuccess": "Cancelled successfully", + "itinerary": "Itinerary", + "itineraryDate": "Date", + "itineraryStartTime": "Start Time", + "itineraryEndTime": "End Time", + "itineraryDeparture": "Departure", + "itineraryDestination": "Destination", + "itineraryTransport": "Transport Vehicle", + "itineraryAccommodation": "Accommodation", + "itineraryRemark": "Remark", + "tripType": "Trip Type", + "tripTypeOneWay": "One Way", + "tripTypeRoundTrip": "Round Trip", + "addItinerary": "Add Itinerary", + "selectApprovers": "Select Approvers", + "selectCc": "Select CC", + "cc": "CC", + "pleaseEnterReason": "Please enter reason", + "pleaseSelectDate": "Please select date", + "pleaseEnterDestination": "Please enter destination", + "pleaseEnterCost": "Please enter estimated cost", + "noItinerary": "No itinerary", + "removeItinerary": "Remove Itinerary" }, "approval": { "title": "Approval", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index d6fa518..9fc597d 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -68,8 +68,10 @@ "startDate": "开始日期", "endDate": "结束日期", "duration": "出差天数", + "totalDuration": "出差总时长", "reason": "出差事由", - "budget": "预算金额", + "budget": "预计费用", + "estimatedCost": "预计费用", "submitter": "申请人", "submitTime": "申请时间", "approver": "审批人", @@ -78,7 +80,30 @@ "confirmSubmit": "确认提交出差申请?", "confirmCancel": "确认撤销此申请?", "submitSuccess": "提交成功", - "cancelSuccess": "撤销成功" + "cancelSuccess": "撤销成功", + "itinerary": "行程明细", + "itineraryDate": "日期", + "itineraryStartTime": "开始时间", + "itineraryEndTime": "结束时间", + "itineraryDeparture": "出发地", + "itineraryDestination": "目的地", + "itineraryTransport": "交通工具", + "itineraryAccommodation": "住宿安排", + "itineraryRemark": "备注", + "tripType": "行程类型", + "tripTypeOneWay": "单程", + "tripTypeRoundTrip": "往返", + "addItinerary": "添加行程", + "selectApprovers": "选择审批人", + "selectCc": "选择抄送", + "approvers": "审批人", + "cc": "抄送", + "pleaseEnterReason": "请输入出差事由", + "pleaseSelectDate": "请选择日期", + "pleaseEnterDestination": "请输入目的地", + "pleaseEnterCost": "请输入预计费用", + "noItinerary": "暂无行程", + "removeItinerary": "删除行程" }, "approval": { "title": "审批管理", diff --git a/frontend/src/pages/business-trip/apply.module.css b/frontend/src/pages/business-trip/apply.module.css index e74786d..0a879c2 100644 --- a/frontend/src/pages/business-trip/apply.module.css +++ b/frontend/src/pages/business-trip/apply.module.css @@ -1,5 +1,8 @@ .apply { - padding: 16px; + min-height: 100vh; + background: #f5f5f5; + padding: 12px; + padding-bottom: 80px; } .form { @@ -9,32 +12,343 @@ } .group { - margin-bottom: 16px; + margin-bottom: 12px; + display: flex; + align-items: flex-start; + gap: 8px; } .group label { - display: block; + min-width: 70px; + flex-shrink: 0; + font-size: 13px; + color: #333; + font-weight: 500; + line-height: 36px; +} + +.group label.required::after { + content: ' *'; + color: #ff4d4f; +} + +.group input, +.group textarea, +.group .readonlyInput { + flex: 1; +} + +.group input, +.group textarea { + padding: 8px 10px; + border: 1px solid #e5e5e5; + border-radius: 6px; + font-size: 13px; + background: #fff; + min-height: 36px; +} + +.group textarea { + resize: vertical; + min-height: 60px; + line-height: 1.5; +} + +.required::after { + content: ' *'; + color: #ff4d4f; +} + +.readonlyInput { + padding: 8px 10px; + border: 1px solid #e5e5e5; + border-radius: 6px; + font-size: 13px; + background: #f5f5f5; + color: #666; + min-height: 36px; + display: flex; + align-items: center; + flex: 1; +} + +.row { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.field { + display: flex; + align-items: center; + gap: 8px; margin-bottom: 8px; - font-size: 14px; +} + +.field label { + min-width: 70px; + flex-shrink: 0; + font-size: 12px; + color: #333; + font-weight: 500; + white-space: nowrap; +} + +.field > *:not(label) { + flex: 1; + min-width: 0; +} + +.field input, +.field .readonlyInput { + min-height: 32px; + font-size: 12px; + padding: 6px 8px; +} + +.dateField { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} + +.dateField label { + min-width: 70px; + flex-shrink: 0; + font-size: 12px; + color: #333; + font-weight: 500; + white-space: nowrap; +} + +.dateField > *:not(label) { + flex: 1; + min-width: 0; +} + +.dateClickable { + padding: 6px 8px; + border: 1px solid #e5e5e5; + border-radius: 4px; + background: #fff; + min-height: 32px; + font-size: 12px; + display: flex; + align-items: center; + cursor: pointer; color: #333; } -.group input { +.col { + flex: 1; + min-width: 0; +} + +.col label { + display: block; + margin-bottom: 4px; + font-size: 13px; + color: #333; + font-weight: 500; +} + +.col input, +.col .readonlyInput, +.col .datePicker { width: 100%; - padding: 10px 12px; + min-height: 32px; + font-size: 12px; + padding: 6px 8px; +} + +.col textarea { + width: 100%; + min-height: 50px; + font-size: 12px; + padding: 6px 8px; + line-height: 1.4; +} + +.datePicker { + width: 100%; + padding: 6px 8px; border: 1px solid #e5e5e5; - border-radius: 4px; + border-radius: 6px; + font-size: 12px; + background: #fff; + color: #333; + min-height: 32px; + display: flex; + align-items: center; +} + +.section { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid #f0f0f0; +} + +.sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.sectionTitle { font-size: 14px; + font-weight: 600; + color: #333; +} + +.itineraryItem { + background: #fafafa; + border-radius: 8px; + padding: 10px; + margin-bottom: 10px; +} + +.itineraryHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + gap: 8px; +} + +.itineraryIndex { + font-size: 12px; + color: #999; + font-weight: 500; +} + +.removeBtn { + background: none; + border: none; + color: #ff4d4f; + padding: 4px; + cursor: pointer; + display: flex; + align-items: center; + margin-left: auto; +} + +.addBtn { + width: 100%; + padding: 10px; + border: 1px dashed #d9d9d9; + border-radius: 6px; + background: #fff; + color: #666; + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + transition: all 0.2s; +} + +.addBtn:active { + background: #f5f5f5; + border-color: #1677ff; + color: #1677ff; +} + +.selector { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; +} + +.selector:last-child { + border-bottom: none; +} + +.selector .label { + font-size: 13px; + color: #333; + font-weight: 500; +} + +.selector .value { + display: flex; + align-items: center; + gap: 8px; +} + +.selector .placeholder { + font-size: 13px; + color: #999; +} + +.selector .icon { + color: #999; + font-size: 18px; +} + +.selectedUsers { + display: flex; + align-items: center; + gap: -8px; +} + +.selectedUsers .avatar { + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid #fff; + margin-left: -8px; +} + +.selectedUsers .avatar:first-child { + margin-left: 0; +} + +.footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: #fff; + padding: 10px 16px; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06); + display: flex; + gap: 12px; + z-index: 100; +} + +.saveBtn, +.submitBtn { + flex: 1; + padding: 10px; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.saveBtn { + background: #fff; + border: 1px solid #d9d9d9; + color: #333; +} + +.saveBtn:active { + background: #f5f5f5; } .submitBtn { - width: 100%; - padding: 12px; - border: none; - border-radius: 6px; background: #1677ff; color: #fff; - font-size: 16px; - font-weight: 500; - cursor: pointer; +} + +.submitBtn:active { + background: #0958d9; } diff --git a/frontend/src/pages/business-trip/apply.tsx b/frontend/src/pages/business-trip/apply.tsx index f1cacd3..f15bd2d 100644 --- a/frontend/src/pages/business-trip/apply.tsx +++ b/frontend/src/pages/business-trip/apply.tsx @@ -1,23 +1,317 @@ +import { useState, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import { DatePicker, Input, Toast, Dialog, TextArea, Selector } from 'antd-mobile' +import { AddOutline, DeleteOutline } from 'antd-mobile-icons' +import dayjs from 'dayjs' import styles from './apply.module.css' +interface ItineraryItem { + startTime: Date | null + endTime: Date | null + departure: string + destination: string + transport: string + tripType: 'oneway' | 'roundtrip' + remark: string +} + +interface ApproverItem { + id: number + name: string + avatar?: string + position?: string +} + +// 计算时长(天数) +const calculateDuration = (start: Date | null, end: Date | null): number => { + if (!start || !end) return 0 + const startDate = dayjs(start) + const endDate = dayjs(end) + return endDate.diff(startDate, 'day') + 1 +} + +// 格式化时长显示 +const formatDuration = (days: number): string => { + if (days <= 0) return '' + return `${days}天` +} + +// 行程类型选项 +const tripTypeOptions = [ + { label: '单程', value: 'oneway' }, + { label: '往返', value: 'roundtrip' }, +] + +// 交通工具选项 +const transportOptions = [ + { label: '飞机', value: '飞机' }, + { label: '高铁', value: '高铁' }, + { label: '火车', value: '火车' }, + { label: '自驾', value: '自驾' }, + { label: '其他', value: '其他' }, +] + export default function BusinessTripApply() { + const { t } = useTranslation() + const navigate = useNavigate() + + const [reason, setReason] = useState('') + const [itineraries, setItineraries] = useState([ + { startTime: null, endTime: null, departure: '', destination: '', transport: '', tripType: 'oneway', remark: '' } + ]) + const [approvers, setApprovers] = useState([]) + const [ccUsers, setCcUsers] = useState([]) + + // 计算总时长(累加所有行程的天数) + const totalDuration = useMemo(() => { + let total = 0 + itineraries.forEach(item => { + const duration = calculateDuration(item.startTime, item.endTime) + total += duration + }) + return total + }, [itineraries]) + + const handleAddItinerary = () => { + setItineraries([...itineraries, { + startTime: null, + endTime: null, + departure: '', + destination: '', + transport: '', + tripType: 'oneway', + remark: '' + }]) + } + + const handleRemoveItinerary = (index: number) => { + if (itineraries.length === 1) { + Toast.show('至少保留一条行程') + return + } + setItineraries(itineraries.filter((_, i) => i !== index)) + } + + const handleUpdateItinerary = (index: number, field: keyof ItineraryItem, value: any) => { + const updated = [...itineraries] + updated[index] = { ...updated[index], [field]: value } + setItineraries(updated) + } + + const handleSelectApprovers = () => { + navigate('/business-trip/apply/select-approver', { state: { selected: approvers, type: 'approver' } }) + } + + const handleSelectCc = () => { + navigate('/business-trip/apply/select-cc', { state: { selected: ccUsers, type: 'cc' } }) + } + + const handleSave = () => { + Toast.show('草稿已保存') + } + + const handleSubmit = () => { + if (!reason.trim()) { + Toast.show(t('businessTrip.pleaseEnterReason')) + return + } + + const hasValidItinerary = itineraries.some(item => item.startTime && item.destination.trim()) + if (!hasValidItinerary) { + Toast.show('请至少填写一条完整行程') + return + } + + Dialog.confirm({ + content: t('businessTrip.confirmSubmit'), + onConfirm: async () => { + Toast.show(t('businessTrip.submitSuccess')) + navigate('/business-trip') + } + }) + } + return (
-
+
- - + +