refactor: 重构后端目录结构和命名规范
- 将后端目录名从大写改为小写 (Controller->controller 等) - 将命名空间从 App\ 改为 app\ (符合 webman 官方规范) - 移除不必要的 bootstrap 目录 - 完善出差审批业务功能: - 添加删除草稿功能 - 添加审批操作功能(同意/拒绝) - 完善审批详情页,显示审批人和审批记录 - 前端优化: - 调整列表页行高 - 隐藏底部导航栏 - 仪表盘只显示3个快捷入口 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -155,10 +155,13 @@
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
:global(.adm-mask) {
|
||||
/* 只影响 Popup 的 mask,不影响 Dialog */
|
||||
:global(.adm-popup .adm-mask) {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
/* Dialog 样式在 index.css 中统一设置 */
|
||||
|
||||
/* Selector 组件样式覆盖 */
|
||||
.field :global(.adm-selector) {
|
||||
flex: 1;
|
||||
@@ -407,7 +410,7 @@
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
z-index: 9999;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.saveBtn,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DatePicker, Input, Toast, Dialog, TextArea, Selector } from 'antd-mobile'
|
||||
@@ -65,31 +65,118 @@ export default function BusinessTripApply() {
|
||||
const [approvers, setApprovers] = useState<ApproverItem[]>([])
|
||||
const [ccUsers, setCcUsers] = useState<ApproverItem[]>([])
|
||||
|
||||
// 手动保存草稿的函数
|
||||
const saveDraft = () => {
|
||||
const draftData = {
|
||||
reason,
|
||||
itineraries,
|
||||
}
|
||||
sessionStorage.setItem('businessTripDraft', JSON.stringify(draftData))
|
||||
console.log('💾 Manually saved draft:', { reason, itineraries })
|
||||
}
|
||||
|
||||
// DatePicker 可见性状态
|
||||
const [datePickerVisible, setDatePickerVisible] = useState<{
|
||||
index: number
|
||||
field: 'startTime' | 'endTime'
|
||||
} | null>(null)
|
||||
|
||||
// 处理从选择页面返回的数据
|
||||
// 使用 ref 来跟踪是否已经恢复过草稿
|
||||
const hasRestoredRef = useRef(false)
|
||||
|
||||
// 处理从选择页面返回的数据和草稿恢复
|
||||
useEffect(() => {
|
||||
// 检查 sessionStorage 中是否有选择的数据
|
||||
const approversData = sessionStorage.getItem('selectedApprovers')
|
||||
const ccData = sessionStorage.getItem('selectedCc')
|
||||
const fromSelection = location.state?.fromSelection
|
||||
|
||||
// 从 sessionStorage 恢复状态(组件重新挂载时需要)
|
||||
if (approversData) {
|
||||
const approvers = JSON.parse(approversData) as ApproverItem[]
|
||||
setApprovers(approvers)
|
||||
console.log('✅ Restored approvers from sessionStorage:', approvers)
|
||||
console.log('📍 Page loaded/updated:', {
|
||||
fromSelection,
|
||||
path: location.pathname
|
||||
})
|
||||
|
||||
// 恢复草稿数据(每次组件挂载都需要恢复)
|
||||
const draftData = sessionStorage.getItem('businessTripDraft')
|
||||
console.log('📦 Draft data in sessionStorage:', draftData)
|
||||
|
||||
if (draftData) {
|
||||
try {
|
||||
const draft = JSON.parse(draftData)
|
||||
console.log('📄 Parsed draft:', draft)
|
||||
|
||||
// 恢复理由
|
||||
if (draft.reason) {
|
||||
console.log('✏️ Restoring reason:', draft.reason)
|
||||
setReason(draft.reason)
|
||||
}
|
||||
|
||||
// 恢复行程数据
|
||||
if (draft.itineraries && draft.itineraries.length > 0) {
|
||||
const restoredItineraries = draft.itineraries.map((item: any) => ({
|
||||
...item,
|
||||
startTime: item.startTime ? new Date(item.startTime) : null,
|
||||
endTime: item.endTime ? new Date(item.endTime) : null,
|
||||
}))
|
||||
console.log('🛤️ Restoring itineraries:', restoredItineraries)
|
||||
setItineraries(restoredItineraries)
|
||||
}
|
||||
|
||||
// 有草稿数据,也尝试恢复人员数据
|
||||
if (!fromSelection) {
|
||||
const approversData = sessionStorage.getItem('selectedApprovers')
|
||||
if (approversData) {
|
||||
const approvers = JSON.parse(approversData) as ApproverItem[]
|
||||
setApprovers(approvers)
|
||||
console.log('✅ Restored approvers (with draft):', approvers)
|
||||
}
|
||||
|
||||
const ccData = sessionStorage.getItem('selectedCc')
|
||||
if (ccData) {
|
||||
const cc = JSON.parse(ccData) as ApproverItem[]
|
||||
setCcUsers(cc)
|
||||
console.log('✅ Restored CC (with draft):', cc)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to restore draft:', error)
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ No draft data found in sessionStorage')
|
||||
// 没有草稿数据,清除旧的人员选择数据(开始新申请)
|
||||
if (!fromSelection) {
|
||||
console.log('🧹 Clearing old selection data (new application)')
|
||||
sessionStorage.removeItem('selectedApprovers')
|
||||
sessionStorage.removeItem('selectedCc')
|
||||
setApprovers([])
|
||||
setCcUsers([])
|
||||
}
|
||||
}
|
||||
|
||||
if (ccData) {
|
||||
const cc = JSON.parse(ccData) as ApproverItem[]
|
||||
setCcUsers(cc)
|
||||
console.log('✅ Restored CC from sessionStorage:', cc)
|
||||
// 从选择页面返回,恢复所有人员数据
|
||||
if (fromSelection) {
|
||||
console.log('🔄 Returning from selection page')
|
||||
|
||||
const type = location.state?.type
|
||||
console.log('📋 Selection type:', type)
|
||||
|
||||
// 恢复所有人员数据(审批人 + 抄送人)
|
||||
const approversData = sessionStorage.getItem('selectedApprovers')
|
||||
if (approversData) {
|
||||
const approvers = JSON.parse(approversData) as ApproverItem[]
|
||||
setApprovers(approvers)
|
||||
console.log('✅ Restored approvers from sessionStorage:', approvers)
|
||||
} else {
|
||||
setApprovers([])
|
||||
}
|
||||
|
||||
const ccData = sessionStorage.getItem('selectedCc')
|
||||
if (ccData) {
|
||||
const cc = JSON.parse(ccData) as ApproverItem[]
|
||||
setCcUsers(cc)
|
||||
console.log('✅ Restored CC from sessionStorage:', cc)
|
||||
} else {
|
||||
setCcUsers([])
|
||||
}
|
||||
}
|
||||
}, []) // 只在组件挂载时执行一次
|
||||
}, [location.state, location.pathname])
|
||||
|
||||
// 计算总时长(累加所有行程的天数)
|
||||
const totalDuration = useMemo(() => {
|
||||
@@ -102,7 +189,7 @@ export default function BusinessTripApply() {
|
||||
}, [itineraries])
|
||||
|
||||
const handleAddItinerary = () => {
|
||||
setItineraries([...itineraries, {
|
||||
const newItineraries = [...itineraries, {
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
departure: '',
|
||||
@@ -110,7 +197,9 @@ export default function BusinessTripApply() {
|
||||
transport: '',
|
||||
tripType: '' as 'oneway' | 'roundtrip' | '',
|
||||
remark: ''
|
||||
}])
|
||||
}]
|
||||
setItineraries(newItineraries)
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
const handleRemoveItinerary = (index: number) => {
|
||||
@@ -118,24 +207,54 @@ export default function BusinessTripApply() {
|
||||
Toast.show('至少保留一条行程')
|
||||
return
|
||||
}
|
||||
setItineraries(itineraries.filter((_, i) => i !== index))
|
||||
const newItineraries = itineraries.filter((_, i) => i !== index)
|
||||
setItineraries(newItineraries)
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
const handleUpdateItinerary = (index: number, field: keyof ItineraryItem, value: any) => {
|
||||
const updated = [...itineraries]
|
||||
updated[index] = { ...updated[index], [field]: value }
|
||||
setItineraries(updated)
|
||||
saveDraft()
|
||||
}
|
||||
|
||||
const handleSelectApprovers = () => {
|
||||
console.log('🚀 Navigating to select approvers')
|
||||
saveDraft() // 导航前保存草稿
|
||||
|
||||
// 如果草稿中没有实际内容(空理由且没有有效行程),清除旧的人员数据
|
||||
const hasContent = reason.trim() !== '' || itineraries.some(item => item.startTime || item.destination.trim() !== '')
|
||||
if (!hasContent) {
|
||||
sessionStorage.removeItem('selectedApprovers')
|
||||
sessionStorage.removeItem('selectedCc')
|
||||
console.log('🧹 Cleared old selection data (no content)')
|
||||
}
|
||||
|
||||
navigate('/business-trip/apply/select-approver', { state: { selected: approvers, type: 'approver' } })
|
||||
}
|
||||
|
||||
const handleSelectCc = () => {
|
||||
console.log('🚀 Navigating to select CC')
|
||||
saveDraft() // 导航前保存草稿
|
||||
|
||||
// 如果草稿中没有实际内容(空理由且没有有效行程),清除旧的人员数据
|
||||
const hasContent = reason.trim() !== '' || itineraries.some(item => item.startTime || item.destination.trim() !== '')
|
||||
if (!hasContent) {
|
||||
sessionStorage.removeItem('selectedApprovers')
|
||||
sessionStorage.removeItem('selectedCc')
|
||||
console.log('🧹 Cleared old selection data (no content)')
|
||||
}
|
||||
|
||||
navigate('/business-trip/apply/select-cc', { state: { selected: ccUsers, type: 'cc' } })
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// 移除当前焦点,避免按钮保持高亮状态
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
try {
|
||||
// 验证必填字段
|
||||
if (!reason.trim()) {
|
||||
@@ -170,6 +289,7 @@ export default function BusinessTripApply() {
|
||||
// 保存成功后清除 sessionStorage
|
||||
sessionStorage.removeItem('selectedApprovers')
|
||||
sessionStorage.removeItem('selectedCc')
|
||||
sessionStorage.removeItem('businessTripDraft')
|
||||
|
||||
Toast.show('草稿已保存')
|
||||
navigate('/business-trip')
|
||||
@@ -179,6 +299,11 @@ export default function BusinessTripApply() {
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
// 移除当前焦点,避免按钮保持高亮状态
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
if (!reason.trim()) {
|
||||
Toast.show(t('businessTrip.pleaseEnterReason'))
|
||||
return
|
||||
@@ -197,6 +322,8 @@ export default function BusinessTripApply() {
|
||||
|
||||
Dialog.confirm({
|
||||
content: t('businessTrip.confirmSubmit'),
|
||||
confirmText: '确认',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
// 准备行程数据
|
||||
@@ -228,6 +355,7 @@ export default function BusinessTripApply() {
|
||||
// 提交成功后清除 sessionStorage
|
||||
sessionStorage.removeItem('selectedApprovers')
|
||||
sessionStorage.removeItem('selectedCc')
|
||||
sessionStorage.removeItem('businessTripDraft')
|
||||
|
||||
Toast.show(t('businessTrip.submitSuccess'))
|
||||
navigate('/business-trip')
|
||||
@@ -248,7 +376,10 @@ export default function BusinessTripApply() {
|
||||
<TextArea
|
||||
placeholder={t('businessTrip.pleaseEnterReason')}
|
||||
value={reason}
|
||||
onChange={setReason}
|
||||
onChange={(val) => {
|
||||
setReason(val)
|
||||
saveDraft()
|
||||
}}
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
/>
|
||||
@@ -292,8 +423,11 @@ export default function BusinessTripApply() {
|
||||
<DatePicker
|
||||
visible={datePickerVisible?.index === index && datePickerVisible?.field === 'startTime'}
|
||||
onConfirm={(val) => {
|
||||
handleUpdateItinerary(index, 'startTime', val)
|
||||
const updated = [...itineraries]
|
||||
updated[index] = { ...updated[index], startTime: val }
|
||||
setItineraries(updated)
|
||||
setDatePickerVisible(null)
|
||||
saveDraft()
|
||||
}}
|
||||
onClose={() => setDatePickerVisible(null)}
|
||||
onCancel={() => setDatePickerVisible(null)}
|
||||
@@ -312,8 +446,11 @@ export default function BusinessTripApply() {
|
||||
<DatePicker
|
||||
visible={datePickerVisible?.index === index && datePickerVisible?.field === 'endTime'}
|
||||
onConfirm={(val) => {
|
||||
handleUpdateItinerary(index, 'endTime', val)
|
||||
const updated = [...itineraries]
|
||||
updated[index] = { ...updated[index], endTime: val }
|
||||
setItineraries(updated)
|
||||
setDatePickerVisible(null)
|
||||
saveDraft()
|
||||
}}
|
||||
onClose={() => setDatePickerVisible(null)}
|
||||
onCancel={() => setDatePickerVisible(null)}
|
||||
|
||||
@@ -60,6 +60,37 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.userList {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.userList:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.userListLabel {
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.users {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.user {
|
||||
background: #f5f5f5;
|
||||
padding: 4px 12px;
|
||||
border-radius: 16px;
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -94,11 +125,77 @@
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding: 16px 0;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.timelineItem {
|
||||
position: relative;
|
||||
padding-left: 40px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.timelineItem:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timelineItem:not(:last-child)::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 11px;
|
||||
top: 24px;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.timelineDot {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #1677ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.timelineDotIcon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.timelineContent {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.timelineTitle {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.timelineName {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.timelineTime {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.timelineComment {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
background: #f5f5f5;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.timelineTitle {
|
||||
@@ -152,6 +249,20 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.deleteBtn {
|
||||
flex: 0 0 auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.commentBox {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.commentBox :global(.adm-text-area) {
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:global(.adm-card-body) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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 { NavBar, Card, Tag, Button, Dialog, Toast, TextArea } from 'antd-mobile'
|
||||
import {
|
||||
LeftOutline,
|
||||
ClockCircleOutline,
|
||||
CheckCircleOutline,
|
||||
CloseCircleOutline,
|
||||
ExclamationCircleOutline,
|
||||
DeleteOutline
|
||||
} from 'antd-mobile-icons'
|
||||
import { businessTripApi, userApi } from '../../services/api'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import dayjs from 'dayjs'
|
||||
import styles from './detail.module.css'
|
||||
|
||||
@@ -27,6 +35,17 @@ interface Itinerary {
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface ApprovalRecord {
|
||||
id: number
|
||||
approver_id: number
|
||||
action: string
|
||||
comment: string | null
|
||||
created_at: string
|
||||
approver: {
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
interface BusinessTripDetail {
|
||||
id: number
|
||||
reason: string
|
||||
@@ -42,15 +61,30 @@ interface BusinessTripDetail {
|
||||
email: string
|
||||
}
|
||||
itineraries: Itinerary[]
|
||||
approval_order?: {
|
||||
id: number
|
||||
status: string
|
||||
form_data: {
|
||||
approvers?: any[]
|
||||
cc_users?: any[]
|
||||
}
|
||||
}
|
||||
approvers?: any[]
|
||||
cc_users?: any[]
|
||||
approval_records?: ApprovalRecord[]
|
||||
}
|
||||
|
||||
export default function BusinessTripDetail() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { id } = useParams()
|
||||
const { user } = useUserStore()
|
||||
|
||||
const [detail, setDetail] = useState<BusinessTripDetail | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [commentVisible, setCommentVisible] = useState(false)
|
||||
const [comment, setComment] = useState('')
|
||||
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve')
|
||||
|
||||
// 加载详情
|
||||
const loadDetail = async () => {
|
||||
@@ -72,6 +106,24 @@ export default function BusinessTripDetail() {
|
||||
loadDetail()
|
||||
}, [id])
|
||||
|
||||
// 删除草稿
|
||||
const handleDelete = () => {
|
||||
Dialog.confirm({
|
||||
content: '确定要删除此草稿吗?删除后无法恢复',
|
||||
confirmText: '删除',
|
||||
cancelText: '取消',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await businessTripApi.deleteDraft(Number(id))
|
||||
Toast.show('删除成功')
|
||||
navigate('/business-trip')
|
||||
} catch (error: any) {
|
||||
Toast.show(error.message || '删除失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 撤销申请
|
||||
const handleCancel = () => {
|
||||
Dialog.confirm({
|
||||
@@ -93,13 +145,39 @@ export default function BusinessTripDetail() {
|
||||
navigate(`/business-trip/apply?id=${id}`)
|
||||
}
|
||||
|
||||
// 打开审批意见框
|
||||
const handleApprove = (action: 'approve' | 'reject') => {
|
||||
setActionType(action)
|
||||
setComment('')
|
||||
setCommentVisible(true)
|
||||
}
|
||||
|
||||
// 提交审批
|
||||
const handleConfirmApprove = async () => {
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
try {
|
||||
await businessTripApi.approve(Number(id), actionType, comment)
|
||||
Toast.show(actionType === 'approve' ? '已同意' : '已拒绝')
|
||||
setCommentVisible(false)
|
||||
loadDetail()
|
||||
} catch (error: any) {
|
||||
Toast.show(error.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
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 isOwner = detail.user?.name === user?.name
|
||||
const canEdit = detail.status === 'draft' && isOwner
|
||||
const canDelete = detail.status === 'draft' && isOwner
|
||||
const canCancel = detail.status === 'pending' && isOwner
|
||||
const canApprove = detail.status === 'pending' && !isOwner
|
||||
|
||||
// 计算天数
|
||||
const calculateDays = (start: string, end: string) => {
|
||||
@@ -119,6 +197,22 @@ export default function BusinessTripDetail() {
|
||||
return dayjs(dateStr).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// 获取审批记录配置
|
||||
const getRecordConfig = (action: string) => {
|
||||
switch (action) {
|
||||
case 'submit':
|
||||
return { color: '#999', icon: '📝', text: '提交申请' }
|
||||
case 'approve':
|
||||
return { color: '#52c41a', icon: '✅', text: '已同意' }
|
||||
case 'reject':
|
||||
return { color: '#ff4d4f', icon: '❌', text: '已拒绝' }
|
||||
case 'cancel':
|
||||
return { color: '#999', icon: '🔄', text: '已撤销' }
|
||||
default:
|
||||
return { color: '#999', icon: '📌', text: action }
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
<NavBar onBack={() => navigate(-1)}>出差申请详情</NavBar>
|
||||
@@ -138,10 +232,6 @@ export default function BusinessTripDetail() {
|
||||
<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>
|
||||
@@ -158,15 +248,39 @@ export default function BusinessTripDetail() {
|
||||
<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>
|
||||
|
||||
{/* 审批人和抄送人 */}
|
||||
{(detail.approvers?.length || detail.cc_users?.length) && (
|
||||
<Card title="审批信息" className={styles.section}>
|
||||
{detail.approvers && detail.approvers.length > 0 && (
|
||||
<div className={styles.userList}>
|
||||
<div className={styles.userListLabel}>审批人:</div>
|
||||
<div className={styles.users}>
|
||||
{detail.approvers.map((u: any) => (
|
||||
<div key={u.id} className={styles.user}>
|
||||
<span>{u.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{detail.cc_users && detail.cc_users.length > 0 && (
|
||||
<div className={styles.userList}>
|
||||
<div className={styles.userListLabel}>抄送人:</div>
|
||||
<div className={styles.users}>
|
||||
{detail.cc_users.map((u: any) => (
|
||||
<div key={u.id} className={styles.user}>
|
||||
<span>{u.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 行程明细 */}
|
||||
<Card title="行程明细" className={styles.section}>
|
||||
{detail.itineraries && detail.itineraries.length > 0 ? (
|
||||
@@ -216,63 +330,55 @@ export default function BusinessTripDetail() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 审批进度 */}
|
||||
<Card title="审批进度" className={styles.section}>
|
||||
{/* 审批记录 */}
|
||||
<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>
|
||||
{detail.approval_records && detail.approval_records.length > 0 ? (
|
||||
detail.approval_records.map((record) => {
|
||||
const recordConfig = getRecordConfig(record.action)
|
||||
return (
|
||||
<div key={record.id} className={styles.timelineItem}>
|
||||
<div className={styles.timelineDot} style={{ background: recordConfig.color }}>
|
||||
<span className={styles.timelineDotIcon}>{recordConfig.icon}</span>
|
||||
</div>
|
||||
<div className={styles.timelineContent}>
|
||||
<div className={styles.timelineTitle}>
|
||||
{recordConfig.text}
|
||||
<span className={styles.timelineName}>
|
||||
{record.approver?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.timelineTime}>
|
||||
{dayjs(record.created_at).format('YYYY-MM-DD HH:mm')}
|
||||
</div>
|
||||
{record.comment && (
|
||||
<div className={styles.timelineComment}>备注: {record.comment}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className={styles.empty}>暂无审批记录</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 底部操作栏 */}
|
||||
{(canEdit || canCancel) && (
|
||||
{(canEdit || canDelete || canCancel || canApprove) && (
|
||||
<div className={styles.footer}>
|
||||
{canDelete && (
|
||||
<Button
|
||||
block
|
||||
color="danger"
|
||||
fill="outline"
|
||||
onClick={handleDelete}
|
||||
className={styles.deleteBtn}
|
||||
>
|
||||
<DeleteOutline /> 删除草稿
|
||||
</Button>
|
||||
)}
|
||||
{canEdit && (
|
||||
<Button block color="primary" onClick={handleEdit}>
|
||||
编辑申请
|
||||
@@ -283,8 +389,49 @@ export default function BusinessTripDetail() {
|
||||
撤销申请
|
||||
</Button>
|
||||
)}
|
||||
{canApprove && (
|
||||
<>
|
||||
<Button
|
||||
block
|
||||
color="danger"
|
||||
fill="outline"
|
||||
onClick={() => handleApprove('reject')}
|
||||
>
|
||||
拒绝
|
||||
</Button>
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
onClick={() => handleApprove('approve')}
|
||||
>
|
||||
同意
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 审批意见弹框 */}
|
||||
<Dialog
|
||||
visible={commentVisible}
|
||||
title={actionType === 'approve' ? '同意申请' : '拒绝申请'}
|
||||
content={
|
||||
<div className={styles.commentBox}>
|
||||
<TextArea
|
||||
placeholder="请输入审批意见(可选)"
|
||||
value={comment}
|
||||
onChange={setComment}
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
onConfirm={handleConfirmApprove}
|
||||
onClose={() => setCommentVisible(false)}
|
||||
onCancel={() => setCommentVisible(false)}
|
||||
confirmText="确认"
|
||||
cancelText="取消"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 12px 16px !important;
|
||||
padding: 0 12px !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
@@ -14,11 +14,15 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card :global(.adm-card-body) {
|
||||
padding: 8px 12px !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.reason {
|
||||
@@ -32,7 +36,7 @@
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info {
|
||||
|
||||
@@ -34,17 +34,10 @@ export default function SelectUserPage() {
|
||||
const state = location.state as LocationState
|
||||
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
// 优先从 location.state 获取,否则从 sessionStorage 获取
|
||||
// 只从 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 data = sessionStorage.getItem('selectedApprovers')
|
||||
return data ? JSON.parse(data) : []
|
||||
}
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserItem[]>(getInitialSelected())
|
||||
const [allUsers, setAllUsers] = useState<UserItem[]>([])
|
||||
@@ -118,7 +111,8 @@ export default function SelectUserPage() {
|
||||
} else if (type === 'cc') {
|
||||
sessionStorage.setItem('selectedCc', JSON.stringify(selectedUsers))
|
||||
}
|
||||
navigate(-1)
|
||||
// 使用 navigate 而不是 navigate(-1),传递标志表示从选择页面返回
|
||||
navigate('/business-trip/apply', { state: { fromSelection: true, type } })
|
||||
}
|
||||
|
||||
const handleAddApprover = () => {
|
||||
|
||||
@@ -34,9 +34,8 @@ export default function SelectCcPage() {
|
||||
const state = location.state as LocationState
|
||||
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
// 优先从 location.state 获取,否则从 sessionStorage 获取
|
||||
// 只从 sessionStorage 获取对应类型的数据
|
||||
const getInitialSelected = () => {
|
||||
if (state?.selected) return state.selected
|
||||
const data = sessionStorage.getItem('selectedCc')
|
||||
return data ? JSON.parse(data) : []
|
||||
}
|
||||
@@ -107,7 +106,8 @@ export default function SelectCcPage() {
|
||||
const handleConfirm = () => {
|
||||
// 使用 sessionStorage 来传递数据
|
||||
sessionStorage.setItem('selectedCc', JSON.stringify(selectedUsers))
|
||||
navigate(-1)
|
||||
// 使用 navigate 而不是 navigate(-1),传递标志表示从选择页面返回
|
||||
navigate('/business-trip/apply', { state: { fromSelection: true, type: 'cc' } })
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Toast } from 'antd-mobile'
|
||||
import { showLanguageSwitcher } from '@/components/LanguageSwitcher'
|
||||
import styles from './dashboard.module.css'
|
||||
|
||||
@@ -10,40 +11,23 @@ interface QuickAction {
|
||||
label: string
|
||||
path: string
|
||||
color: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
// 从应用页面迁移的完整应用列表
|
||||
const quickActions: QuickAction[] = [
|
||||
{ id: '1', icon: '✈️', label: '出差申请', path: '/business-trip/apply', color: '#1677ff' },
|
||||
{ id: '2', icon: '📝', label: '采购申请', path: '/purchase/apply', color: '#52c41a' },
|
||||
{ id: '3', icon: '🏠', label: '会议室', path: '/meeting-room', color: '#faad14' },
|
||||
{ id: '4', icon: '📅', label: '日程管理', path: '/schedule', color: '#722ed1' },
|
||||
{ id: '5', icon: '💰', label: '报销申请', path: '/expense/apply', color: '#eb2f96' },
|
||||
{ id: '6', icon: '📋', label: '请假申请', path: '/leave/apply', color: '#13c2c2' },
|
||||
]
|
||||
|
||||
interface TodoItem {
|
||||
id: string
|
||||
title: string
|
||||
type: 'approval' | 'notice' | 'task'
|
||||
time: string
|
||||
}
|
||||
|
||||
const mockTodos: TodoItem[] = [
|
||||
{ id: '1', title: '张三的出差申请待审批', type: 'approval', time: '10分钟前' },
|
||||
{ id: '2', title: '本周部门会议', type: 'task', time: '今天 14:00' },
|
||||
]
|
||||
|
||||
interface StatCard {
|
||||
title: string
|
||||
value: number
|
||||
unit: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const mockStats: StatCard[] = [
|
||||
{ title: '待审批', value: 5, unit: '条', color: '#ff4d4f' },
|
||||
{ title: '已审批', value: 12, unit: '条', color: '#52c41a' },
|
||||
{ title: '我发起', value: 8, unit: '条', color: '#1677ff' },
|
||||
{ id: '1', icon: '✈️', label: '出差申请', path: '/business-trip/apply', color: '#1677ff', description: '提交出差申请' },
|
||||
{ id: '2', icon: '📝', label: '采购申请', path: '/purchase/apply', color: '#52c41a', description: '物品采购审批' },
|
||||
{ id: '3', icon: '📦', label: '备货录入', path: '/stock/entry', color: '#faad14', description: '备货信息录入' },
|
||||
{ id: '4', icon: '📅', label: '日程管理', path: '/schedule', color: '#722ed1', description: '个人日程安排' },
|
||||
{ id: '5', icon: '💰', label: '报销申请', path: '/expense/apply', color: '#eb2f96', description: '费用报销审批' },
|
||||
{ id: '6', icon: '📋', label: '请假申请', path: '/leave/apply', color: '#13c2c2', description: '请假审批' },
|
||||
{ id: '7', icon: '🚗', label: '用车申请', path: '/car/apply', color: '#f759ab', description: '公车使用申请' },
|
||||
{ id: '8', icon: '🎁', label: '礼品申请', path: '/gift/apply', color: '#ff7a45', description: '礼品采购申请' },
|
||||
{ id: '9', icon: '📊', label: '数据报表', path: '/reports', color: '#1890ff', description: '业务数据统计' },
|
||||
{ id: '10', icon: '👥', label: '通讯录', path: '/contacts', color: '#096dd9', description: '内部通讯录' },
|
||||
{ id: '11', icon: '📁', label: '文件中心', path: '/files', color: '#595959', description: '文件共享管理' },
|
||||
{ id: '12', icon: '⚙️', label: '系统设置', path: '/settings', color: '#8c8c8c', description: '系统偏好设置' },
|
||||
]
|
||||
|
||||
export default function Dashboard() {
|
||||
@@ -52,7 +36,13 @@ export default function Dashboard() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleQuickAction = (action: QuickAction) => {
|
||||
navigate(action.path)
|
||||
// 允许的应用:出差申请、审批
|
||||
const allowedPaths = ['/business-trip/apply', '/business-trip']
|
||||
if (allowedPaths.includes(action.path)) {
|
||||
navigate(action.path)
|
||||
} else {
|
||||
Toast.show({ content: '功能开发中...', duration: 1500 })
|
||||
}
|
||||
}
|
||||
|
||||
const getCurrentLanguage = () => {
|
||||
@@ -83,59 +73,18 @@ export default function Dashboard() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 数据统计卡片 */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>数据概览</h3>
|
||||
<div className={styles.statsGrid}>
|
||||
{mockStats.map((stat) => (
|
||||
<div key={stat.title} className={styles.statCard} style={{ borderTop: `3px solid ${stat.color}` }}>
|
||||
<div className={styles.statValue}>{stat.value}</div>
|
||||
<div className={styles.statTitle}>{stat.title}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 待办事项 */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3 className={styles.sectionTitle}>待办事项</h3>
|
||||
<span className={styles.more}>查看全部 {'>'}</span>
|
||||
</div>
|
||||
{mockTodos.length > 0 ? (
|
||||
<div className={styles.todoList}>
|
||||
{mockTodos.map((todo) => (
|
||||
<div key={todo.id} className={styles.todoItem} onClick={() => navigate('/approval/pending')}>
|
||||
<div className={styles.todoIcon}>
|
||||
{todo.type === 'approval' && '📋'}
|
||||
{todo.type === 'notice' && '🔔'}
|
||||
{todo.type === 'task' && '✓'}
|
||||
</div>
|
||||
<div className={styles.todoContent}>
|
||||
<div className={styles.todoTitle}>{todo.title}</div>
|
||||
<div className={styles.todoTime}>{todo.time}</div>
|
||||
</div>
|
||||
<div className={styles.todoArrow}>{'>'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.empty}>暂无待办事项</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 快捷入口 */}
|
||||
<div className={styles.section}>
|
||||
<h3 className={styles.sectionTitle}>快捷入口</h3>
|
||||
<div className={styles.actionGrid}>
|
||||
{quickActions.map((action) => (
|
||||
{quickActions.slice(0, 3).map((action) => (
|
||||
<div
|
||||
key={action.id}
|
||||
className={styles.actionCard}
|
||||
onClick={() => handleQuickAction(action)}
|
||||
>
|
||||
<div className={styles.actionIcon} style={{ backgroundColor: `${action.color}15` }}>
|
||||
<span style={{ fontSize: 28 }}>{action.icon}</span>
|
||||
<span style={{ fontSize: 32 }}>{action.icon}</span>
|
||||
</div>
|
||||
<span className={styles.actionLabel}>{action.label}</span>
|
||||
</div>
|
||||
@@ -143,26 +92,27 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最新动态 */}
|
||||
{/* 所有应用 */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<h3 className={styles.sectionTitle}>最新动态</h3>
|
||||
<span className={styles.more}>更多 {'>'}</span>
|
||||
</div>
|
||||
<div className={styles.activityList}>
|
||||
<div className={styles.activityItem}>
|
||||
<div className={styles.activityDot}></div>
|
||||
<div className={styles.activityContent}>
|
||||
<div>张三 提交了出差申请</div>
|
||||
<div className={styles.activityTime}>10分钟前</div>
|
||||
<h3 className={styles.sectionTitle}>所有应用</h3>
|
||||
<div className={styles.actionGrid}>
|
||||
<div className={styles.actionCard} onClick={() => navigate('/business-trip')}>
|
||||
<div className={styles.actionIcon} style={{ backgroundColor: '#1677ff15' }}>
|
||||
<span style={{ fontSize: 32 }}>✍️</span>
|
||||
</div>
|
||||
<span className={styles.actionLabel}>审批</span>
|
||||
</div>
|
||||
<div className={styles.activityItem}>
|
||||
<div className={styles.activityDot}></div>
|
||||
<div className={styles.activityContent}>
|
||||
<div>李四 审批通过了采购申请</div>
|
||||
<div className={styles.activityTime}>1小时前</div>
|
||||
<div className={styles.actionCard} onClick={() => handleQuickAction(quickActions[8])}>
|
||||
<div className={styles.actionIcon} style={{ backgroundColor: `${quickActions[8].color}15` }}>
|
||||
<span style={{ fontSize: 32 }}>{quickActions[8].icon}</span>
|
||||
</div>
|
||||
<span className={styles.actionLabel}>{quickActions[8].label}</span>
|
||||
</div>
|
||||
<div className={styles.actionCard} onClick={() => handleQuickAction(quickActions[11])}>
|
||||
<div className={styles.actionIcon} style={{ backgroundColor: `${quickActions[11].color}15` }}>
|
||||
<span style={{ fontSize: 32 }}>{quickActions[11].icon}</span>
|
||||
</div>
|
||||
<span className={styles.actionLabel}>{quickActions[11].label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button, Input, Toast } from 'antd-mobile'
|
||||
import styles from './login-simple.module.css'
|
||||
|
||||
export default function SimpleLogin() {
|
||||
const navigate = useNavigate()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username || !password) {
|
||||
Toast.show('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
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 || '登录失败')
|
||||
}
|
||||
} catch (error: any) {
|
||||
Toast.show(error.message || '登录失败,请重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.loginPage}>
|
||||
<div className={styles.loginCard}>
|
||||
<h1 className={styles.title}>企业工具台</h1>
|
||||
<p className={styles.subtitle}>出差申请系统</p>
|
||||
|
||||
<div className={styles.form}>
|
||||
<div className={styles.field}>
|
||||
<label>用户名</label>
|
||||
<Input
|
||||
placeholder="请输入用户名"
|
||||
value={username}
|
||||
onChange={setUsername}
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<label>密码</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
block
|
||||
color="primary"
|
||||
size="large"
|
||||
onClick={handleLogin}
|
||||
loading={loading}
|
||||
>
|
||||
登录
|
||||
</Button>
|
||||
|
||||
<p className={styles.tip}>
|
||||
💡 开发模式:输入任意用户名密码即可登录
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.loginPage {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.loginCard {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 40px 30px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin: 0 0 30px 0;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tip {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 20px 0 0 0;
|
||||
}
|
||||
Reference in New Issue
Block a user