Initial commit: X-Server Mobile enterprise tool system

- Frontend: React 18 + TypeScript + Vite + Ant Design Mobile
- Backend: PHP 8.5 + Webman 2.x + MySQL + Redis
- i18n support: Chinese, English, Russian, Spanish, French
- Approval workflow engine
- Docker development environment
- Feishu integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-02-24 17:57:25 +08:00
commit 8d78e18267
121 changed files with 18563 additions and 0 deletions
@@ -0,0 +1,59 @@
.actions {
display: flex;
gap: 12px;
padding: 16px;
background: #fff;
border-radius: 12px;
position: sticky;
bottom: 0;
z-index: 10;
}
.btn {
flex: 1;
padding: 12px;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.approveBtn {
background: #52c41a;
color: #fff;
}
.rejectBtn {
background: #ff4d4f;
color: #fff;
}
.cancelBtn {
background: #8c8c8c;
color: #fff;
}
.modalContent {
padding: 16px 0;
}
.textarea {
width: 100%;
padding: 10px;
border: 1px solid #e5e5e5;
border-radius: 8px;
font-size: 14px;
resize: none;
}
.textarea:focus {
outline: none;
border-color: #1677ff;
}
@@ -0,0 +1,111 @@
import { useApprovalAction } from '../hooks/useApprovalAction'
import type { ApprovalStatus } from '../types'
import { Modal } from 'antd-mobile'
import { useState } from 'react'
import styles from './ApprovalAction.module.css'
interface ApprovalActionProps {
orderId: string
status: ApprovalStatus
}
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('')
}
const handleReject = () => {
reject({ orderId, comment: comment || '已拒绝' })
setShowModal(false)
setComment('')
}
const handleCancel = () => {
if (confirm('确定要撤销此申请吗?')) {
cancel(orderId)
}
}
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 && (
<button
className={`${styles.btn} ${styles.cancelBtn}`}
onClick={handleCancel}
disabled={isCancelling}
>
{isCancelling ? '处理中...' : '撤销申请'}
</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,
},
]}
/>
</>
)
}
@@ -0,0 +1,149 @@
.loading,
.error,
.empty {
padding: 40px 20px;
text-align: center;
color: #8c8c8c;
font-size: 14px;
}
.error {
color: #ff4d4f;
}
.detail {
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid #f5f5f5;
}
.title {
flex: 1;
font-size: 18px;
font-weight: 600;
color: #262626;
margin: 0;
}
.status {
padding: 4px 12px;
background: #f5f5f5;
border-radius: 4px;
font-size: 13px;
color: #1677ff;
}
.section {
margin-bottom: 20px;
}
.sectionTitle {
font-size: 16px;
font-weight: 600;
color: #262626;
margin: 0 0 12px 0;
}
.formData {
background: #fff;
border-radius: 8px;
padding: 12px;
}
.formRow {
display: flex;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
}
.formRow:last-child {
border-bottom: none;
}
.formLabel {
width: 100px;
color: #8c8c8c;
font-size: 14px;
flex-shrink: 0;
}
.formValue {
flex: 1;
color: #262626;
font-size: 14px;
}
.timeline {
background: #fff;
border-radius: 8px;
padding: 12px;
}
.timelineItem {
display: flex;
gap: 12px;
padding: 12px 0;
position: relative;
}
.timelineItem:not(:last-child)::after {
content: '';
position: absolute;
left: 5px;
top: 28px;
bottom: -12px;
width: 2px;
background: #f0f0f0;
}
.timelineDot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #1677ff;
border: 2px solid #fff;
box-shadow: 0 0 0 2px #e6f4ff;
flex-shrink: 0;
margin-top: 4px;
}
.timelineContent {
flex: 1;
}
.timelineHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.timelineUser {
font-size: 14px;
color: #262626;
font-weight: 500;
}
.timelineAction {
font-size: 13px;
color: #1677ff;
}
.timelineComment {
font-size: 14px;
color: #595959;
margin-bottom: 4px;
}
.timelineTime {
font-size: 12px;
color: #8c8c8c;
}
@@ -0,0 +1,108 @@
import { useQuery } from '@tanstack/react-query'
import { getApprovalDetail } from '../services/approval'
import ApprovalFlow from './ApprovalFlow'
import ApprovalAction from './ApprovalAction'
import styles from './ApprovalDetail.module.css'
interface ApprovalDetailProps {
id: string
}
export default function ApprovalDetail({ id }: ApprovalDetailProps) {
const { data, isLoading, error } = useQuery({
queryKey: ['approval-detail', id],
queryFn: () => getApprovalDetail(id),
})
if (isLoading) {
return <div className={styles.loading}>加载中...</div>
}
if (error) {
return <div className={styles.error}>加载失败: {error.message}</div>
}
const order = data?.data
if (!order) {
return <div className={styles.empty}>未找到审批详情</div>
}
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
draft: '草稿',
pending: '审批中',
approved: '已通过',
rejected: '已拒绝',
cancelled: '已撤销',
}
return statusMap[status] || status
}
return (
<div className={styles.detail}>
{/* 标题 */}
<div className={styles.header}>
<h1 className={styles.title}>{order.title}</h1>
<span className={styles.status}>{getStatusText(order.status)}</span>
</div>
{/* 审批流程 */}
{order.flow && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>审批流程</h2>
<ApprovalFlow nodes={order.flow.nodes || []} />
</div>
)}
{/* 表单数据 */}
{order.formData && Object.keys(order.formData).length > 0 && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>申请内容</h2>
<div className={styles.formData}>
{Object.entries(order.formData).map(([key, value]) => (
<div key={key} className={styles.formRow}>
<span className={styles.formLabel}>{key}:</span>
<span className={styles.formValue}>{String(value)}</span>
</div>
))}
</div>
</div>
)}
{/* 审批记录 */}
{order.timeline && order.timeline.length > 0 && (
<div className={styles.section}>
<h2 className={styles.sectionTitle}>审批记录</h2>
<div className={styles.timeline}>
{order.timeline.map((record: any) => (
<div key={record.id} className={styles.timelineItem}>
<div className={styles.timelineDot} />
<div className={styles.timelineContent}>
<div className={styles.timelineHeader}>
<span className={styles.timelineUser}>{record.approver?.name}</span>
<span className={styles.timelineAction}>
{record.action === 'approve' && '通过'}
{record.action === 'reject' && '拒绝'}
{record.action === 'submit' && '提交'}
{record.action === 'cancel' && '撤销'}
</span>
</div>
{record.comment && (
<div className={styles.timelineComment}>{record.comment}</div>
)}
<div className={styles.timelineTime}>
{new Date(record.createdAt).toLocaleString('zh-CN')}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 操作按钮 */}
<ApprovalAction orderId={order.id} status={order.status} />
</div>
)
}
@@ -0,0 +1,62 @@
.flow {
padding: 16px 0;
}
.nodeWrapper {
display: flex;
align-items: flex-start;
}
.node {
display: flex;
align-items: center;
gap: 12px;
}
.nodeIcon {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.nodeContent {
flex: 1;
}
.nodeName {
font-size: 14px;
color: #262626;
margin-bottom: 4px;
}
.nodeApprovers {
font-size: 12px;
color: #8c8c8c;
}
.approver {
display: inline-block;
padding: 2px 6px;
background: #f5f5f5;
border-radius: 4px;
margin-right: 4px;
margin-bottom: 4px;
}
.connector {
width: 28px;
padding-left: 13px;
}
.line {
width: 2px;
height: 20px;
background: #e5e5e5;
}
@@ -0,0 +1,77 @@
import type { ApprovalNode, NodeStatus } from '../types'
import styles from './ApprovalFlow.module.css'
interface ApprovalFlowProps {
nodes: ApprovalNode[]
currentStatus?: NodeStatus
}
export default function ApprovalFlow({ nodes, currentStatus }: ApprovalFlowProps) {
const getNodeStatus = (node: ApprovalNode): NodeStatus => {
if (node.status) return node.status
return 'pending'
}
const getNodeIcon = (status: NodeStatus): string => {
switch (status) {
case 'approved':
return '✓'
case 'rejected':
return '✕'
case 'skipped':
return '○'
default:
return '○'
}
}
const getNodeColor = (status: NodeStatus): string => {
switch (status) {
case 'approved':
return '#52c41a'
case 'rejected':
return '#ff4d4f'
case 'skipped':
return '#d9d9d9'
default:
return '#d9d9d9'
}
}
return (
<div className={styles.flow}>
{nodes.map((node, index) => {
const status = getNodeStatus(node)
const isLast = index === nodes.length - 1
return (
<div key={node.id} className={styles.nodeWrapper}>
<div className={styles.node}>
<div
className={styles.nodeIcon}
style={{ backgroundColor: getNodeColor(status) }}
>
{getNodeIcon(status)}
</div>
<div className={styles.nodeContent}>
<div className={styles.nodeName}>{node.name}</div>
<div className={styles.nodeApprovers}>
{node.approvers.map((approver) => (
<span key={approver.id} className={styles.approver}>
{approver.name}
</span>
))}
</div>
</div>
</div>
{!isLast && (
<div className={styles.connector}>
<div className={styles.line} />
</div>
)}
</div>
)
})}
</div>
)
}
@@ -0,0 +1,70 @@
.loading,
.error,
.empty {
padding: 40px 20px;
text-align: center;
color: #8c8c8c;
font-size: 14px;
}
.error {
color: #ff4d4f;
}
.list {
background: #fff;
border-radius: 12px;
overflow: hidden;
}
.item {
padding: 14px 16px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background 0.2s;
}
.item:last-child {
border-bottom: none;
}
.item:active {
background: #f5f5f5;
}
.itemHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.title {
flex: 1;
font-size: 15px;
color: #262626;
font-weight: 500;
}
.status {
font-size: 13px;
padding: 2px 8px;
background: #f5f5f5;
border-radius: 4px;
}
.itemBody {
display: flex;
justify-content: space-between;
align-items: center;
}
.info {
font-size: 13px;
color: #8c8c8c;
}
.time {
font-size: 12px;
color: #bfbfbf;
}
@@ -0,0 +1,78 @@
import { useApprovalList } from '../hooks/useApprovalList'
import type { ApprovalListParams } from '../types'
import styles from './ApprovalList.module.css'
interface ApprovalListProps {
type?: ApprovalListParams['type']
onItemClick?: (id: string) => void
}
export default function ApprovalList({ type = 'my', onItemClick }: ApprovalListProps) {
const { data, isLoading, error } = useApprovalList({ type })
if (isLoading) {
return <div className={styles.loading}>加载中...</div>
}
if (error) {
return <div className={styles.error}>加载失败: {error.message}</div>
}
const list = data?.data?.list || []
if (list.length === 0) {
return <div className={styles.empty}>暂无数据</div>
}
const getStatusText = (status: string) => {
const statusMap: Record<string, string> = {
draft: '草稿',
pending: '审批中',
approved: '已通过',
rejected: '已拒绝',
cancelled: '已撤销',
}
return statusMap[status] || status
}
const getStatusColor = (status: string) => {
const colorMap: Record<string, string> = {
draft: '#8c8c8c',
pending: '#1677ff',
approved: '#52c41a',
rejected: '#ff4d4f',
cancelled: '#8c8c8c',
}
return colorMap[status] || '#8c8c8c'
}
return (
<div className={styles.list}>
{list.map((item: any) => (
<div
key={item.id}
className={styles.item}
onClick={() => onItemClick?.(item.id)}
>
<div className={styles.itemHeader}>
<span className={styles.title}>{item.title}</span>
<span
className={styles.status}
style={{ color: getStatusColor(item.status) }}
>
{getStatusText(item.status)}
</span>
</div>
<div className={styles.itemBody}>
<div className={styles.info}>
申请人: {item.applicant?.name || '-'}
</div>
<div className={styles.time}>
{new Date(item.createdAt).toLocaleString('zh-CN')}
</div>
</div>
</div>
))}
</div>
)
}
@@ -0,0 +1,59 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { approveOrder, rejectOrder, cancelOrder } from '../services/approval'
import type { ApprovalActionParams } from '../types'
interface UseApprovalActionOptions {
onSuccess?: (data: any) => void
onError?: (error: Error) => void
}
export function useApprovalAction(options?: UseApprovalActionOptions) {
const queryClient = useQueryClient()
const approveMutation = useMutation({
mutationFn: ({ orderId, comment }: { orderId: string; comment?: string }) =>
approveOrder(orderId, comment),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['approval-list'] })
queryClient.invalidateQueries({ queryKey: ['approval-detail'] })
options?.onSuccess?.(data)
},
onError: (error) => {
options?.onError?.(error as Error)
},
})
const rejectMutation = useMutation({
mutationFn: ({ orderId, comment }: { orderId: string; comment?: string }) =>
rejectOrder(orderId, comment),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['approval-list'] })
queryClient.invalidateQueries({ queryKey: ['approval-detail'] })
options?.onSuccess?.(data)
},
onError: (error) => {
options?.onError?.(error as Error)
},
})
const cancelMutation = useMutation({
mutationFn: (orderId: string) => cancelOrder(orderId),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['approval-list'] })
queryClient.invalidateQueries({ queryKey: ['approval-detail'] })
options?.onSuccess?.(data)
},
onError: (error) => {
options?.onError?.(error as Error)
},
})
return {
approve: approveMutation.mutate,
reject: rejectMutation.mutate,
cancel: cancelMutation.mutate,
isApproving: approveMutation.isPending,
isRejecting: rejectMutation.isPending,
isCancelling: cancelMutation.isPending,
}
}
@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { getApprovalList } from '../services'
import type { ApprovalListParams } from '../types'
export function useApprovalList(params: ApprovalListParams = {}) {
const { type = 'my', page = 1, pageSize = 20 } = params
return useQuery({
queryKey: ['approval-list', type, page, pageSize],
queryFn: () => getApprovalList({ type, page, pageSize }),
})
}
@@ -0,0 +1,30 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { submitApproval } from '../services/approval'
import type { SubmitApprovalParams } from '../types'
interface UseSubmitApprovalOptions {
onSuccess?: (data: any) => void
onError?: (error: Error) => void
}
export function useApprovalSubmit(options?: UseSubmitApprovalOptions) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (params: SubmitApprovalParams) => submitApproval(params),
onSuccess: (data) => {
// 刷新审批列表
queryClient.invalidateQueries({ queryKey: ['approval-list'] })
options?.onSuccess?.(data)
},
onError: (error) => {
options?.onError?.(error as Error)
},
})
return {
submitApproval: mutation.mutate,
isSubmitting: mutation.isPending,
error: mutation.error,
}
}
+29
View File
@@ -0,0 +1,29 @@
// 审批模块导出
// Types
export type {
ApprovalNode,
Approver,
ApprovalFlow,
ApprovalOrder,
Attachment,
ApprovalTimeline,
SubmitApprovalParams,
ApprovalListParams,
} from './types'
export { ApprovalStatus, ApprovalNodeType, NodeStatus } from './types'
// Components
export { default as ApprovalFlow } from './components/ApprovalFlow'
export { default as ApprovalList } from './components/ApprovalList'
export { default as ApprovalDetail } from './components/ApprovalDetail'
export { default as ApprovalAction } from './components/ApprovalAction'
// Hooks
export { useApprovalSubmit } from './hooks/useApprovalSubmit'
export { useApprovalAction } from './hooks/useApprovalAction'
export { useApprovalList } from './hooks/useApprovalList'
// Services
export * from './services/approval'
@@ -0,0 +1,42 @@
import { api } from '@/services/api'
import type {
ApprovalOrder,
ApprovalFlow,
SubmitApprovalParams,
ApprovalListParams,
} from '../types'
// 获取审批列表
export function getApprovalList(params: ApprovalListParams) {
return api.get<ApprovalOrder[]>('/approval/list', params)
}
// 获取审批详情
export function getApprovalDetail(id: string) {
return api.get<ApprovalOrder>(`/approval/detail/${id}`)
}
// 提交审批
export function submitApproval(data: SubmitApprovalParams) {
return api.post<{ order: ApprovalOrder; token?: string }>('/approval/submit', data)
}
// 审批通过
export function approveOrder(orderId: string, comment?: string) {
return api.post<ApprovalOrder>(`/approval/approve/${orderId}`, { comment })
}
// 审批拒绝
export function rejectOrder(orderId: string, comment?: string) {
return api.post<ApprovalOrder>(`/approval/reject/${orderId}`, { comment })
}
// 撤销申请
export function cancelOrder(orderId: string) {
return api.post<ApprovalOrder>(`/approval/cancel/${orderId}`)
}
// 获取审批流程
export function getApprovalFlow(type: string) {
return api.get<ApprovalFlow>(`/approval/flow/${type}`)
}
+112
View File
@@ -0,0 +1,112 @@
// 审批模块类型定义
export interface ApprovalNode {
id: string
type: ApprovalNodeType
name: string
approvers: Approver[]
order: number
status?: NodeStatus
}
export interface Approver {
id: string
name: string
avatar?: string
}
export interface ApprovalFlow {
id: string
businessType: string
name: string
nodes: ApprovalNode[]
}
export interface ApprovalOrder {
id: string
businessType: string
businessId: string
title: string
applicant: Approver
status: ApprovalStatus
currentNodes: string[]
flowId: string
formData: Record<string, any>
attachments: Attachment[]
timeline: ApprovalTimeline[]
createdAt: string
updatedAt: string
}
export interface Attachment {
id: string
name: string
url: string
size?: number
type?: string
}
export interface ApprovalTimeline {
id: string
nodeId: string
nodeType: string
approver: Approver
action: ApprovalAction
comment?: string
createdAt: string
}
// 类型常量
export const ApprovalStatus = {
DRAFT: 'draft',
PENDING: 'pending',
APPROVED: 'approved',
REJECTED: 'rejected',
CANCELLED: 'cancelled',
} as const
export type ApprovalStatus = (typeof ApprovalStatus)[keyof typeof ApprovalStatus]
export const ApprovalNodeType = {
APPROVER: 'approver',
AND_APPROVE: 'and_approve',
OR_APPROVE: 'or_approve',
NOTIFY: 'notify',
} as const
export type ApprovalNodeType = (typeof ApprovalNodeType)[keyof typeof ApprovalNodeType]
export const NodeStatus = {
PENDING: 'pending',
APPROVED: 'approved',
REJECTED: 'rejected',
SKIPPED: 'skipped',
} as const
export type NodeStatus = (typeof NodeStatus)[keyof typeof NodeStatus]
export type ApprovalAction = 'submit' | 'approve' | 'reject' | 'cancel'
// 提交审批参数
export interface SubmitApprovalParams {
businessType: string
businessId: string
title: string
flowType?: string
formData?: Record<string, any>
attachments?: Attachment[]
}
// 审批操作参数
export interface ApprovalActionParams {
orderId: string
action: 'approve' | 'reject'
comment?: string
}
// 列表查询参数
export interface ApprovalListParams {
type?: 'my' | 'pending' | 'approved'
page?: number
pageSize?: number
}