-
-
-
-
+
+
+
+
+
+
+
+
+ {list.map((item) => {
+ const config = statusConfig[item.status] || statusConfig.draft
+ const days = calculateDays(item.start_date, item.end_date)
+
+ return (
+ handleViewDetail(item.id)}
+ className={styles.item}
+ >
+
+
+ {item.reason}
+
+ {config.icon}
+ {config.text}
+
+
+
+
+
+ {formatDate(item.start_date)} - {formatDate(item.end_date)}
+
+ {days}天
+
+
目的地: {item.destination}
+ {item.user && activeTab !== 'my' && (
+
申请人: {item.user.name}
+ )}
+
+
+
+ )
+ })}
+
+
+
+ {list.length === 0 && !loading && (
+
+
+
暂无出差申请
+ {activeTab === 'my' && (
+
+ )}
+
+ )}
+
+
+
+
-
暂无出差申请
)
}
diff --git a/frontend/src/pages/business-trip/select-approver.module.css b/frontend/src/pages/business-trip/select-approver.module.css
index f61469b..e25f41e 100644
--- a/frontend/src/pages/business-trip/select-approver.module.css
+++ b/frontend/src/pages/business-trip/select-approver.module.css
@@ -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);
}
diff --git a/frontend/src/pages/business-trip/select-approver.tsx b/frontend/src/pages/business-trip/select-approver.tsx
index e97aaee..36f4ffd 100644
--- a/frontend/src/pages/business-trip/select-approver.tsx
+++ b/frontend/src/pages/business-trip/select-approver.tsx
@@ -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
(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(getInitialSelected())
const [allUsers, setAllUsers] = useState([])
+ 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' ? '选择审批人' : '选择抄送人'}
{state?.type === 'approver' && (
- 已选审批人
-
+
+ 已选{selectedUsers.length > 0 && ` (${selectedUsers.length})`}
+
{selectedUsers.length > 0 ? (
@@ -96,6 +151,12 @@ export default function SelectUserPage() {
className={styles.selectedAvatar}
/>
{user.name}
+
))
) : (
@@ -107,51 +168,61 @@ export default function SelectUserPage() {
- {filteredUsers.length > 0 ? (
- filteredUsers.map(user => (
-
handleToggleUser(user)}
- >
-
-

-
-
{user.name}
- {user.position && (
-
{user.position}
- )}
+ {allUsers.length > 0 ? (
+ <>
+ {allUsers.map(user => (
+
handleToggleUser(user)}
+ >
+
+

+
+ {user.name}
+ {user.position && (
+ {user.position}
+ )}
+ {user.email && (
+ {user.email}
+ )}
+
+ {isUserSelected(user.id) && (
+
+ )}
- {isUserSelected(user.id) && (
-
- )}
-
- ))
+ ))}
+ {hasMore && (
+
loadUsers(page + 1)}>
+ 加载更多...
+
+ )}
+ >
) : (
-
+
)}
diff --git a/frontend/src/pages/business-trip/select-cc.module.css b/frontend/src/pages/business-trip/select-cc.module.css
index 7b551cc..0b9e116 100644
--- a/frontend/src/pages/business-trip/select-cc.module.css
+++ b/frontend/src/pages/business-trip/select-cc.module.css
@@ -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);
}
diff --git a/frontend/src/pages/business-trip/select-cc.tsx b/frontend/src/pages/business-trip/select-cc.tsx
index f0d2fea..aaf1922 100644
--- a/frontend/src/pages/business-trip/select-cc.tsx
+++ b/frontend/src/pages/business-trip/select-cc.tsx
@@ -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
>(
- 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(getInitialSelected())
const [allUsers, setAllUsers] = useState([])
+ 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')}
+ 选择抄送人
-
setSelectedUsers(new Set(val as number[]))}
- >
- {filteredUsers.length > 0 ? (
- filteredUsers.map(user => (
-
- ))
- ) : (
-
- )}
-
+ {isUserSelected(user.id) && (
+
+ )}
+
+ ))}
+ {hasMore && (
+ loadUsers(page + 1)}>
+ 加载更多...
+
+ )}
+ >
+ ) : (
+
+ )}
diff --git a/frontend/src/pages/login/index.tsx b/frontend/src/pages/login/index.tsx
index d5458e8..2a09d77 100644
--- a/frontend/src/pages/login/index.tsx
+++ b/frontend/src/pages/login/index.tsx
@@ -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 (
企业工具台
-
+
正在跳转到飞书授权...
+
)
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 2090515..cc88549 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -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 {
- 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) => {
+// 响应拦截器:处理错误
+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) => {
- 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: (url: string, config?: RequestConfig): Promise> => {
- 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: (url: string, data?: any, config?: RequestConfig): Promise> => {
- return apiClient.post(url, data, config).then(res => res.data)
- },
-
- put: (url: string, data?: any, config?: RequestConfig): Promise> => {
- return apiClient.put(url, data, config).then(res => res.data)
- },
-
- delete: (url: string, config?: RequestConfig): Promise> => {
- 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
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 4879fbe..e8bc3c0 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -13,6 +13,14 @@ export default defineConfig({
server: {
port: 5173,
host: '0.0.0.0',
+ strictPort: false,
+ // 允许公网域名访问
+ allowedHosts: [
+ 'xsm.dev.hnxinyouhe.com',
+ '.dev.hnxinyouhe.com', // 允许所有 dev.hnxinyouhe.com 的子域名
+ 'localhost',
+ '127.0.0.1'
+ ],
proxy: {
'/api': {
target: 'http://127.0.0.1:8787',
diff --git a/frpc/frpc-dev.ini b/frpc/frpc-dev.ini
index a75e54b..a6e1153 100644
--- a/frpc/frpc-dev.ini
+++ b/frpc/frpc-dev.ini
@@ -2,38 +2,53 @@
# 使用方法: frpc -c frpc-dev.ini
[common]
-# ======== 请修改以下配置 ========
-server_addr = your-frp-server.com
-server_port = 7000
-token = your-frp-token
-# ================================
+serverAddr = "8.152.213.143"
+serverPort = 7000
-# 开发环境 API 代理
-[x-server-api-dev]
-type = http
-local_ip = 127.0.0.1
-local_port = 8080
-custom_domains = api-dev.x-server.your-domain.com
+auth.method = "token"
+auth.token = "6dEjuXXMpJSdBbAQDRzECHmAbeGd"
+
+# X-Server 开发环境代理
+[x-server-dev]
+type = "http"
+localIP = "127.0.0.1"
+localPort = 80
+
+# 自定义域名方式(不需要 frps 配置 subdomainHost)
+customDomains = ["xsm.dev.hnxinyouhe.com"]
# 启用加密和压缩
-use_encryption = true
-use_compression = true
+transport.useEncryption = true
+transport.useCompression = true
# 健康检查
-health_check_type = http
-health_check_url = /health
-health_check_interval_s = 30
-health_check_max_failed = 3
+healthCheck.type = "tcp"
+healthCheck.intervalS = 30
+healthCheck.maxFailed = 3
# ========================================
# 配置说明
# ========================================
#
-# 1. 修改 server_addr 为你的 frp 服务器地址
-# 2. 修改 token 为你的 frp 服务器 token
-# 3. 修改 custom_domains 为你的开发域名
-# 4. 确保 local_port (8080) 与 OpenResty 配置一致
-# 5. 将 api-dev.x-server.your-domain.com 的 DNS 指向 frp 服务器
+# frps 服务器配置:
+# - 地址: 8.152.213.143:7000
+# - HTTP 端口: 8080
+# - HTTPS 端口: 8443
+#
+# 访问方式(二选一):
+#
+# 方式1: 使用 subdomain(推荐,无需 DNS 配置)
+# 访问: http://x-server-dev.8.152.213.143:8080
+#
+# 方式2: 使用自定义域名
+# 1. 修改 customDomains
+# 2. 将域名 A 记录指向 8.152.213.143
+# 3. 访问: http://your-domain.com
+#
+# 本地服务端口:
+# - OpenResty: 80 (frpc 指向这里)
+# - 前端: http://127.0.0.1:5173
+# - 后端: http://127.0.0.1:8787
#
# 启动命令:
# frpc -c frpc-dev.ini
@@ -41,7 +56,3 @@ health_check_max_failed = 3
# 后台运行:
# nohup frpc -c frpc-dev.ini > /dev/null 2>&1 &
#
-# 使用 systemd 管理 (推荐):
-# sudo cp frpc-dev.service /etc/systemd/system/
-# sudo systemctl enable frpc-dev
-# sudo systemctl start frpc-dev
diff --git a/frpc/frpc-dev.toml b/frpc/frpc-dev.toml
new file mode 100644
index 0000000..604b016
--- /dev/null
+++ b/frpc/frpc-dev.toml
@@ -0,0 +1,30 @@
+serverAddr = "8.152.213.143"
+serverPort = 7000
+
+auth.method = "token"
+auth.token = "6dEjuXXMpJSdBbAQDRzECHmAbeGd"
+
+webServer.addr = "0.0.0.0"
+webServer.port = 7500
+webServer.user = "admin_mzl92"
+webServer.password = "ydd6Gx7cuA8XwQ38JSpxhpSBHVGy"
+webServer.pprofEnable = false
+
+# X-Server 开发环境代理
+[[proxies]]
+name = "x-server-dev"
+type = "http"
+localIP = "127.0.0.1"
+localPort = 80
+
+# 自定义域名
+customDomains = ["x-server.dev.hnxinyouhe.com"]
+
+# 启用加密和压缩
+transport.useEncryption = true
+transport.useCompression = true
+
+# 健康检查
+healthCheck.type = "tcp"
+healthCheck.intervalS = 30
+healthCheck.maxFailed = 3
diff --git a/nginx/x-server-api.conf b/nginx/x-server-api.conf
index 926c658..27fc8e6 100644
--- a/nginx/x-server-api.conf
+++ b/nginx/x-server-api.conf
@@ -35,14 +35,3 @@ server {
add_header Content-Type text/plain;
}
}
-
-# HTTPS 配置(用于生产环境,需要配合 SSL 证书)
-# server {
-# listen 443 ssl http2;
-# server_name api.x-server.com;
-#
-# ssl_certificate /path/to/cert.pem;
-# ssl_certificate_key /path/to/key.pem;
-#
-# # ... 其他配置同上
-# }