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
+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+2
View File
@@ -0,0 +1,2 @@
// App.tsx 仅作为路由入口,具体内容在 router/index.tsx 中定义
export { default } from './router'
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,33 @@
.container {
background: var(--adm-color-background);
}
.header {
padding: 20px;
font-size: 16px;
font-weight: 600;
text-align: center;
border-bottom: 1px solid var(--adm-color-border);
}
.list {
width: 100%;
padding: 10px 0;
}
.item {
width: 100%;
padding: 12px 20px;
display: flex;
align-items: center;
gap: 12px;
}
.flag {
font-size: 20px;
}
.name {
font-size: 15px;
flex: 1;
}
@@ -0,0 +1,49 @@
import { Popup, Radio, Space } from 'antd-mobile'
import { useTranslation } from 'react-i18next'
import styles from './LanguageSwitcher.module.css'
const languages = [
{ code: 'zh', name: '简体中文', flag: '🇨🇳' },
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'ru', name: 'Русский', flag: '🇷🇺' },
{ code: 'es', name: 'Español', flag: '🇪🇸' },
{ code: 'fr', name: 'Français', flag: '🇫🇷' },
]
export default function LanguageSwitcher({ onClose }: { onClose?: () => void }) {
const { i18n, t } = useTranslation()
const currentLang = i18n.language
const handleLanguageChange = (langCode: string) => {
i18n.changeLanguage(langCode)
onClose?.()
}
return (
<div className={styles.container}>
<div className={styles.header}>{t('settings.language')}</div>
<Radio.Group value={currentLang} onChange={(val) => handleLanguageChange(val)}>
<Space direction="vertical" className={styles.list}>
{languages.map((lang) => (
<Radio
key={lang.code}
value={lang.code}
className={styles.item}
checked={currentLang === lang.code}
>
<span className={styles.flag}>{lang.flag}</span>
<span className={styles.name}>{lang.name}</span>
</Radio>
))}
</Space>
</Radio.Group>
</div>
)
}
export function showLanguageSwitcher() {
Popup.show({
content: <LanguageSwitcher onClose={() => Popup.close()} />,
position: 'bottom',
})
}
@@ -0,0 +1,36 @@
.header {
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
height: 44px;
padding: 0 12px;
background: linear-gradient(180deg, #1677ff 0%, #0958d9 100%);
color: #fff;
}
.backBtn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
background: transparent;
color: #fff;
cursor: pointer;
font-size: 20px;
}
.title {
flex: 1;
font-size: 17px;
font-weight: 500;
text-align: center;
}
.extra {
width: 32px;
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react'
import { LeftOutline } from 'antd-mobile-icons'
import { useTranslation } from 'react-i18next'
import styles from './Header.module.css'
interface HeaderProps {
title?: string
onBack?: () => void
extra?: ReactNode
}
export default function Header({ title, onBack, extra }: HeaderProps) {
const { t } = useTranslation()
return (
<header className={styles.header}>
{onBack && (
<button className={styles.backBtn} onClick={onBack}>
<LeftOutline />
</button>
)}
<h1 className={styles.title}>{title || t('app.name')}</h1>
{extra && <div className={styles.extra}>{extra}</div>}
</header>
)
}
@@ -0,0 +1,24 @@
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
background-color: #f5f5f5;
overflow: hidden;
}
.main {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
/* TabBar 高度 50px + 安全区域 */
padding-bottom: calc(50px + env(safe-area-inset-bottom));
/* 确保滚动位置在顶部 */
scroll-behavior: smooth;
}
/* 重置滚动位置 */
.main::-webkit-scrollbar {
width: 0;
display: none;
}
@@ -0,0 +1,31 @@
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import { useEffect, useRef } from 'react'
import TabBar from './TabBar'
import Header from './Header'
import styles from './MobileLayout.module.css'
export default function MobileLayout() {
const navigate = useNavigate()
const location = useLocation()
const mainRef = useRef<HTMLDivElement>(null)
const showHeader = location.pathname !== '/login'
const showTabBar = !location.pathname.includes('/business-trip/')
// 路由变化时滚动到顶部
useEffect(() => {
if (mainRef.current) {
mainRef.current.scrollTop = 0
}
}, [location.pathname])
return (
<div className={styles.layout}>
{showHeader && <Header onBack={() => navigate(-1)} />}
<main className={styles.main} ref={mainRef}>
<Outlet />
</main>
{showTabBar && <TabBar />}
</div>
)
}
@@ -0,0 +1,36 @@
.tabBar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
display: flex;
height: 50px;
padding-bottom: env(safe-area-inset-bottom);
background: #fff;
border-top: 1px solid #eee;
}
.tab {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
color: #999;
cursor: pointer;
transition: color 0.2s;
}
.tab.active {
color: #1677ff;
}
.icon {
font-size: 22px;
}
.label {
font-size: 11px;
}
+47
View File
@@ -0,0 +1,47 @@
import { useNavigate, useLocation } from 'react-router-dom'
import { AppOutline, AppstoreOutline, UserOutline } from 'antd-mobile-icons'
import { useTranslation } from 'react-i18next'
import styles from './TabBar.module.css'
interface TabItem {
key: string
path: string
icon: React.ReactNode
labelKey: string
}
const tabs: TabItem[] = [
{ key: 'home', path: '/dashboard', icon: <AppOutline />, labelKey: 'tab.home' },
{ key: 'apps', path: '/apps', icon: <AppstoreOutline />, labelKey: 'tab.apps' },
{ key: 'profile', path: '/profile', icon: <UserOutline />, labelKey: 'tab.profile' },
]
export default function TabBar() {
const navigate = useNavigate()
const location = useLocation()
const { t } = useTranslation()
const activeTab = tabs.find((tab) => location.pathname.startsWith(tab.path))?.key || 'home'
const handleTabChange = (key: string) => {
const tab = tabs.find((t) => t.key === key)
if (tab) {
navigate(tab.path)
}
}
return (
<div className={styles.tabBar}>
{tabs.map((tab) => (
<div
key={tab.key}
className={`${styles.tab} ${activeTab === tab.key ? styles.active : ''}`}
onClick={() => handleTabChange(tab.key)}
>
<span className={styles.icon}>{tab.icon}</span>
<span className={styles.label}>{t(tab.labelKey)}</span>
</div>
))}
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
// API 配置
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
export const API_TIMEOUT = 15000
// 应用配置
export const APP_NAME = '企业工具台'
export const APP_VERSION = '1.0.0'
// 环境配置
export const isDev = import.meta.env.DEV
export const isProd = import.meta.env.PROD
// 飞书配置
export const FEISHU_APP_ID = import.meta.env.VITE_FEISHU_APP_ID || ''
// rem 配置(移动端适配)
export const REM_BASE = 37.5 // 750px 设计稿基准
+39
View File
@@ -0,0 +1,39 @@
/**
* 国际化配置
* 支持:中文(zh)、英文(en)、俄语(ru)、西语(es)、法语(fr)
*/
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import zh from './locales/zh.json'
import en from './locales/en.json'
import ru from './locales/ru.json'
import es from './locales/es.json'
import fr from './locales/fr.json'
const resources = {
zh: { translation: zh },
en: { translation: en },
ru: { translation: ru },
es: { translation: es },
fr: { translation: fr },
}
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'zh',
lng: 'zh',
debug: import.meta.env.DEV,
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
})
export default i18n
+150
View File
@@ -0,0 +1,150 @@
{
"app": {
"name": "Enterprise Tool System",
"title": "Enterprise Internal Tool Platform"
},
"tab": {
"home": "Home",
"apps": "Apps",
"profile": "Profile"
},
"common": {
"loading": "Loading...",
"confirm": "Confirm",
"cancel": "Cancel",
"submit": "Submit",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"search": "Search",
"refresh": "Refresh",
"back": "Back",
"close": "Close",
"select": "Select",
"all": "All",
"more": "More",
"total": "{{count}} items in total",
"noData": "No data",
"operation": "Operation",
"status": "Status",
"remark": "Remark",
"createTime": "Create Time",
"updateTime": "Update Time"
},
"dashboard": {
"welcome": "Welcome Back",
"greeting": "Hello, {{name}}",
"quickActions": "Quick Actions",
"myTodos": "My Todos",
"myApprovals": "My Approvals",
"notifications": "Notifications",
"recentlyUsed": "Recently Used"
},
"apps": {
"title": "App Center",
"businessTrip": "Business Trip",
"reimbursement": "Reimbursement",
"leave": "Leave Request",
"overtime": "Overtime",
"purchase": "Purchase Request",
"approval": "Approval",
"report": "Reports",
"settings": "Settings",
"addressBook": "Address Book",
"notice": "Notices",
"calendar": "Calendar",
"file": "Files"
},
"businessTrip": {
"title": "Business Trip",
"apply": "Apply",
"myTrips": "My Trips",
"pendingApproval": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"cancelled": "Cancelled",
"tripTitle": "Trip Title",
"destination": "Destination",
"startDate": "Start Date",
"endDate": "End Date",
"duration": "Duration",
"reason": "Reason",
"budget": "Budget",
"submitter": "Applicant",
"submitTime": "Submit Time",
"approver": "Approver",
"approvalStatus": "Status",
"approvalComment": "Comment",
"confirmSubmit": "Confirm to submit?",
"confirmCancel": "Confirm to cancel?",
"submitSuccess": "Submitted successfully",
"cancelSuccess": "Cancelled successfully"
},
"approval": {
"title": "Approval",
"myApproval": "My Approval",
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"detail": "Detail",
"approve": "Approve",
"reject": "Reject",
"comment": "Comment",
"submitComment": "Submit",
"confirmApprove": "Confirm to approve?",
"confirmReject": "Confirm to reject?",
"approveSuccess": "Approved",
"rejectSuccess": "Rejected",
"noPending": "No pending approvals",
"status": {
"pending": "Pending",
"approved": "Approved",
"rejected": "Rejected",
"cancelled": "Cancelled"
}
},
"profile": {
"title": "Profile",
"myInfo": "My Info",
"myMessages": "Messages",
"settings": "Settings",
"about": "About",
"logout": "Logout",
"language": "Language",
"switchCompany": "Switch Company",
"notifications": "Notifications",
"security": "Security"
},
"login": {
"title": "Login",
"username": "Username",
"password": "Password",
"loginBtn": "Login",
"feishuLogin": "Feishu Login",
"loginSuccess": "Login successful",
"loginFailed": "Login failed"
},
"settings": {
"language": "Language",
"theme": "Theme",
"notifications": "Notifications"
},
"error": {
"networkError": "Network error, please try again later",
"serverError": "Server error",
"unauthorized": "Unauthorized, please login first",
"forbidden": "Forbidden",
"notFound": "Page not found",
"unknownError": "Unknown error"
},
"time": {
"today": "Today",
"yesterday": "Yesterday",
"thisWeek": "This Week",
"lastWeek": "Last Week",
"thisMonth": "This Month",
"lastMonth": "Last Month",
"thisYear": "This Year",
"lastYear": "Last Year"
}
}
+150
View File
@@ -0,0 +1,150 @@
{
"app": {
"name": "Sistema Empresarial",
"title": "Plataforma de Herramientas Empresariales"
},
"tab": {
"home": "Inicio",
"apps": "Aplicaciones",
"profile": "Perfil"
},
"common": {
"loading": "Cargando...",
"confirm": "Confirmar",
"cancel": "Cancelar",
"submit": "Enviar",
"save": "Guardar",
"delete": "Eliminar",
"edit": "Editar",
"search": "Buscar",
"refresh": "Actualizar",
"back": "Atrás",
"close": "Cerrar",
"select": "Seleccionar",
"all": "Todos",
"more": "Más",
"total": "{{count}} en total",
"noData": "Sin datos",
"operation": "Operación",
"status": "Estado",
"remark": "Observación",
"createTime": "Fecha de creación",
"updateTime": "Fecha de actualización"
},
"dashboard": {
"welcome": "Bienvenido de nuevo",
"greeting": "Hola, {{name}}",
"quickActions": "Acciones rápidas",
"myTodos": "Mis pendientes",
"myApprovals": "Mis aprobaciones",
"notifications": "Notificaciones",
"recentlyUsed": "Usados recientemente"
},
"apps": {
"title": "Centro de Aplicaciones",
"businessTrip": "Viaje de Negocios",
"reimbursement": "Reembolso",
"leave": "Solicitud de Permiso",
"overtime": "Horas Extra",
"purchase": "Solicitud de Compra",
"approval": "Aprobaciones",
"report": "Reportes",
"settings": "Configuración",
"addressBook": "Directorio",
"notice": "Avisos",
"calendar": "Calendario",
"file": "Archivos"
},
"businessTrip": {
"title": "Viaje de Negocios",
"apply": "Solicitar",
"myTrips": "Mis Viajes",
"pendingApproval": "Pendiente",
"approved": "Aprobado",
"rejected": "Rechazado",
"cancelled": "Cancelado",
"tripTitle": "Título del Viaje",
"destination": "Destino",
"startDate": "Fecha de Inicio",
"endDate": "Fecha de Fin",
"duration": "Duración",
"reason": "Motivo",
"budget": "Presupuesto",
"submitter": "Solicitante",
"submitTime": "Fecha de Solicitud",
"approver": "Aprobador",
"approvalStatus": "Estado de Aprobación",
"approvalComment": "Comentario",
"confirmSubmit": "¿Confirmar envío?",
"confirmCancel": "¿Confirmar cancelación?",
"submitSuccess": "Enviado exitosamente",
"cancelSuccess": "Cancelado exitosamente"
},
"approval": {
"title": "Aprobaciones",
"myApproval": "Mis Aprobaciones",
"pending": "Pendiente",
"approved": "Aprobado",
"rejected": "Rechazado",
"detail": "Detalle",
"approve": "Aprobar",
"reject": "Rechazar",
"comment": "Comentario",
"submitComment": "Enviar",
"confirmApprove": "¿Confirmar aprobación?",
"confirmReject": "¿Confirmar rechazo?",
"approveSuccess": "Aprobado",
"rejectSuccess": "Rechazado",
"noPending": "Sin aprobaciones pendientes",
"status": {
"pending": "Pendiente",
"approved": "Aprobado",
"rejected": "Rechazado",
"cancelled": "Cancelado"
}
},
"profile": {
"title": "Mi Perfil",
"myInfo": "Mi Información",
"myMessages": "Mis Mensajes",
"settings": "Configuración",
"about": "Acerca de",
"logout": "Cerrar Sesión",
"language": "Idioma",
"switchCompany": "Cambiar Empresa",
"notifications": "Notificaciones",
"security": "Seguridad"
},
"login": {
"title": "Iniciar Sesión",
"username": "Usuario",
"password": "Contraseña",
"loginBtn": "Entrar",
"feishuLogin": "Entrar con Feishu",
"loginSuccess": "Inicio de sesión exitoso",
"loginFailed": "Error de inicio de sesión"
},
"settings": {
"language": "Idioma",
"theme": "Tema",
"notifications": "Notificaciones"
},
"error": {
"networkError": "Error de red, inténtelo más tarde",
"serverError": "Error del servidor",
"unauthorized": "No autorizado, inicie sesión primero",
"forbidden": "Acceso prohibido",
"notFound": "Página no encontrada",
"unknownError": "Error desconocido"
},
"time": {
"today": "Hoy",
"yesterday": "Ayer",
"thisWeek": "Esta Semana",
"lastWeek": "Semana Pasada",
"thisMonth": "Este Mes",
"lastMonth": "Mes Pasado",
"thisYear": "Este Año",
"lastYear": "Año Pasado"
}
}
+150
View File
@@ -0,0 +1,150 @@
{
"app": {
"name": "Système d'Entreprise",
"title": "Plateforme d'Outils Entreprise"
},
"tab": {
"home": "Accueil",
"apps": "Applications",
"profile": "Profil"
},
"common": {
"loading": "Chargement...",
"confirm": "Confirmer",
"cancel": "Annuler",
"submit": "Soumettre",
"save": "Enregistrer",
"delete": "Supprimer",
"edit": "Modifier",
"search": "Rechercher",
"refresh": "Actualiser",
"back": "Retour",
"close": "Fermer",
"select": "Sélectionner",
"all": "Tous",
"more": "Plus",
"total": "{{count}} au total",
"noData": "Aucune donnée",
"operation": "Opération",
"status": "Statut",
"remark": "Remarque",
"createTime": "Date de création",
"updateTime": "Date de mise à jour"
},
"dashboard": {
"welcome": "Bienvenue",
"greeting": "Bonjour, {{name}}",
"quickActions": "Actions Rapides",
"myTodos": "Mes Tâches",
"myApprovals": "Mes Approbations",
"notifications": "Notifications",
"recentlyUsed": "Récemment Utilisés"
},
"apps": {
"title": "Centre d'Applications",
"businessTrip": "Déplacement",
"reimbursement": "Remboursement",
"leave": "Demande de Congé",
"overtime": "Heures Supplémentaires",
"purchase": "Demande d'Achat",
"approval": "Approbations",
"report": "Rapports",
"settings": "Paramètres",
"addressBook": "Contacts",
"notice": "Annonces",
"calendar": "Calendrier",
"file": "Fichiers"
},
"businessTrip": {
"title": "Déplacement Professionnel",
"apply": "Demander",
"myTrips": "Mes Déplacements",
"pendingApproval": "En Attente",
"approved": "Approuvé",
"rejected": "Rejeté",
"cancelled": "Annulé",
"tripTitle": "Titre du Déplacement",
"destination": "Destination",
"startDate": "Date de Début",
"endDate": "Date de Fin",
"duration": "Durée",
"reason": "Motif",
"budget": "Budget",
"submitter": "Demandeur",
"submitTime": "Date de Soumission",
"approver": "Approbateur",
"approvalStatus": "Statut d'Approbation",
"approvalComment": "Commentaire",
"confirmSubmit": "Confirmer la soumission?",
"confirmCancel": "Confirmer l'annulation?",
"submitSuccess": "Soumis avec succès",
"cancelSuccess": "Annulé avec succès"
},
"approval": {
"title": "Approbations",
"myApproval": "Mes Approbations",
"pending": "En Attente",
"approved": "Approuvé",
"rejected": "Rejeté",
"detail": "Détail",
"approve": "Approuver",
"reject": "Rejeter",
"comment": "Commentaire",
"submitComment": "Soumettre",
"confirmApprove": "Confirmer l'approbation?",
"confirmReject": "Confirmer le rejet?",
"approveSuccess": "Approuvé",
"rejectSuccess": "Rejeté",
"noPending": "Aucune approbation en attente",
"status": {
"pending": "En Attente",
"approved": "Approuvé",
"rejected": "Rejeté",
"cancelled": "Annulé"
}
},
"profile": {
"title": "Mon Profil",
"myInfo": "Mes Informations",
"myMessages": "Mes Messages",
"settings": "Paramètres",
"about": "À Propos",
"logout": "Déconnexion",
"language": "Langue",
"switchCompany": "Changer d'Entreprise",
"notifications": "Notifications",
"security": "Sécurité"
},
"login": {
"title": "Connexion",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"loginBtn": "Se Connecter",
"feishuLogin": "Connexion Feishu",
"loginSuccess": "Connexion réussie",
"loginFailed": "Échec de la connexion"
},
"settings": {
"language": "Langue",
"theme": "Thème",
"notifications": "Notifications"
},
"error": {
"networkError": "Erreur réseau, veuillez réessayer plus tard",
"serverError": "Erreur du serveur",
"unauthorized": "Non autorisé, veuillez vous connecter d'abord",
"forbidden": "Accès interdit",
"notFound": "Page non trouvée",
"unknownError": "Erreur inconnue"
},
"time": {
"today": "Aujourd'hui",
"yesterday": "Hier",
"thisWeek": "Cette Semaine",
"lastWeek": "Semaine Dernière",
"thisMonth": "Ce Mois",
"lastMonth": "Mois Dernier",
"thisYear": "Cette Année",
"lastYear": "Année Dernière"
}
}
+150
View File
@@ -0,0 +1,150 @@
{
"app": {
"name": "Корпоративная система",
"title": "Корпоративная платформа инструментов"
},
"tab": {
"home": "Главная",
"apps": "Приложения",
"profile": "Профиль"
},
"common": {
"loading": "Загрузка...",
"confirm": "Подтвердить",
"cancel": "Отмена",
"submit": "Отправить",
"save": "Сохранить",
"delete": "Удалить",
"edit": "Редактировать",
"search": "Поиск",
"refresh": "Обновить",
"back": "Назад",
"close": "Закрыть",
"select": "Выбрать",
"all": "Все",
"more": "Больше",
"total": "Всего {{count}}",
"noData": "Нет данных",
"operation": "Операция",
"status": "Статус",
"remark": "Примечание",
"createTime": "Время создания",
"updateTime": "Время обновления"
},
"dashboard": {
"welcome": "Добро пожаловать",
"greeting": "Здравствуйте, {{name}}",
"quickActions": "Быстрые действия",
"myTodos": "Мои задачи",
"myApprovals": "Мои согласования",
"notifications": "Уведомления",
"recentlyUsed": "Недавно использованные"
},
"apps": {
"title": "Центр приложений",
"businessTrip": "Командировка",
"reimbursement": "Возмещение расходов",
"leave": "Отпуск",
"overtime": "Сверхурочные",
"purchase": "Заявка на покупку",
"approval": "Согласование",
"report": "Отчеты",
"settings": "Настройки",
"addressBook": "Адресная книга",
"notice": "Объявления",
"calendar": "Календарь",
"file": "Файлы"
},
"businessTrip": {
"title": "Командировка",
"apply": "Подать заявку",
"myTrips": "Мои командировки",
"pendingApproval": "Ожидает согласования",
"approved": "Согласовано",
"rejected": "Отклонено",
"cancelled": "Отменено",
"tripTitle": "Название командировки",
"destination": "Место назначения",
"startDate": "Дата начала",
"endDate": "Дата окончания",
"duration": "Продолжительность",
"reason": "Причина",
"budget": "Бюджет",
"submitter": "Заявитель",
"submitTime": "Время подачи",
"approver": "Согласующий",
"approvalStatus": "Статус согласования",
"approvalComment": "Комментарий",
"confirmSubmit": "Подтвердить отправку?",
"confirmCancel": "Подтвердить отмену?",
"submitSuccess": "Успешно отправлено",
"cancelSuccess": "Успешно отменено"
},
"approval": {
"title": "Согласование",
"myApproval": "Мои согласования",
"pending": "Ожидает",
"approved": "Согласовано",
"rejected": "Отклонено",
"detail": "Детали",
"approve": "Согласовать",
"reject": "Отклонить",
"comment": "Комментарий",
"submitComment": "Отправить",
"confirmApprove": "Подтвердить согласование?",
"confirmReject": "Подтвердить отклонение?",
"approveSuccess": "Согласовано",
"rejectSuccess": "Отклонено",
"noPending": "Нет ожидающих согласований",
"status": {
"pending": "Ожидает",
"approved": "Согласовано",
"rejected": "Отклонено",
"cancelled": "Отменено"
}
},
"profile": {
"title": "Профиль",
"myInfo": "Моя информация",
"myMessages": "Мои сообщения",
"settings": "Настройки",
"about": "О программе",
"logout": "Выйти",
"language": "Язык",
"switchCompany": "Сменить компанию",
"notifications": "Уведомления",
"security": "Безопасность"
},
"login": {
"title": "Вход",
"username": "Имя пользователя",
"password": "Пароль",
"loginBtn": "Войти",
"feishuLogin": "Войти через Feishu",
"loginSuccess": "Успешный вход",
"loginFailed": "Ошибка входа"
},
"settings": {
"language": "Язык",
"theme": "Тема",
"notifications": "Уведомления"
},
"error": {
"networkError": "Ошибка сети, попробуйте позже",
"serverError": "Ошибка сервера",
"unauthorized": "Не авторизован, войдите сначала",
"forbidden": "Доступ запрещен",
"notFound": "Страница не найдена",
"unknownError": "Неизвестная ошибка"
},
"time": {
"today": "Сегодня",
"yesterday": "Вчера",
"thisWeek": "На этой неделе",
"lastWeek": "На прошлой неделе",
"thisMonth": "В этом месяце",
"lastMonth": "В прошлом месяце",
"thisYear": "В этом году",
"lastYear": "В прошлом году"
}
}
+150
View File
@@ -0,0 +1,150 @@
{
"app": {
"name": "企业工具系统",
"title": "企业内部工具平台"
},
"tab": {
"home": "首页",
"apps": "应用",
"profile": "我的"
},
"common": {
"loading": "加载中...",
"confirm": "确认",
"cancel": "取消",
"submit": "提交",
"save": "保存",
"delete": "删除",
"edit": "编辑",
"search": "搜索",
"refresh": "刷新",
"back": "返回",
"close": "关闭",
"select": "请选择",
"all": "全部",
"more": "更多",
"total": "共 {{count}} 条",
"noData": "暂无数据",
"operation": "操作",
"status": "状态",
"remark": "备注",
"createTime": "创建时间",
"updateTime": "更新时间"
},
"dashboard": {
"welcome": "欢迎回来",
"greeting": "你好,{{name}}",
"quickActions": "快捷操作",
"myTodos": "我的待办",
"myApprovals": "我的审批",
"notifications": "通知消息",
"recentlyUsed": "最近使用"
},
"apps": {
"title": "应用中心",
"businessTrip": "出差申请",
"reimbursement": "费用报销",
"leave": "请假申请",
"overtime": "加班申请",
"purchase": "采购申请",
"approval": "审批管理",
"report": "数据报表",
"settings": "系统设置",
"addressBook": "通讯录",
"notice": "通知公告",
"calendar": "日程安排",
"file": "文件管理"
},
"businessTrip": {
"title": "出差申请",
"apply": "申请出差",
"myTrips": "我的出差",
"pendingApproval": "待审批",
"approved": "已批准",
"rejected": "已拒绝",
"cancelled": "已撤销",
"tripTitle": "出差标题",
"destination": "目的地",
"startDate": "开始日期",
"endDate": "结束日期",
"duration": "出差天数",
"reason": "出差事由",
"budget": "预算金额",
"submitter": "申请人",
"submitTime": "申请时间",
"approver": "审批人",
"approvalStatus": "审批状态",
"approvalComment": "审批意见",
"confirmSubmit": "确认提交出差申请?",
"confirmCancel": "确认撤销此申请?",
"submitSuccess": "提交成功",
"cancelSuccess": "撤销成功"
},
"approval": {
"title": "审批管理",
"myApproval": "我的审批",
"pending": "待审批",
"approved": "已通过",
"rejected": "已拒绝",
"detail": "审批详情",
"approve": "通过",
"reject": "拒绝",
"comment": "审批意见",
"submitComment": "提交意见",
"confirmApprove": "确认通过此申请?",
"confirmReject": "确认拒绝此申请?",
"approveSuccess": "审批通过",
"rejectSuccess": "已拒绝",
"noPending": "暂无待审批事项",
"status": {
"pending": "待审批",
"approved": "已通过",
"rejected": "已拒绝",
"cancelled": "已撤销"
}
},
"profile": {
"title": "个人中心",
"myInfo": "我的信息",
"myMessages": "我的消息",
"settings": "设置",
"about": "关于",
"logout": "退出登录",
"language": "语言设置",
"switchCompany": "切换公司",
"notifications": "消息通知",
"security": "账号安全"
},
"login": {
"title": "登录",
"username": "用户名",
"password": "密码",
"loginBtn": "登录",
"feishuLogin": "飞书快捷登录",
"loginSuccess": "登录成功",
"loginFailed": "登录失败"
},
"settings": {
"language": "语言",
"theme": "主题",
"notifications": "通知设置"
},
"error": {
"networkError": "网络错误,请稍后重试",
"serverError": "服务器错误",
"unauthorized": "未授权,请先登录",
"forbidden": "无权访问",
"notFound": "页面不存在",
"unknownError": "未知错误"
},
"time": {
"today": "今天",
"yesterday": "昨天",
"thisWeek": "本周",
"lastWeek": "上周",
"thisMonth": "本月",
"lastMonth": "上月",
"thisYear": "今年",
"lastYear": "去年"
}
}
+68
View File
@@ -0,0 +1,68 @@
/* 移动端基础样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-size: 16px; /* 基础字体大小 */
}
html,
body {
width: 100%;
height: 100%;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
font-size: 16px; /* 提高基础字体大小 */
line-height: 1.5;
color: #262626;
background-color: #f5f5f5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-size-adjust: 100%; /* 防止 iOS 自动调整字体大小 */
}
#root {
width: 100%;
height: 100%;
}
/* 链接样式 */
a {
color: #1677ff;
text-decoration: none;
}
a:active {
color: #0958d9;
}
/* 按钮重置 */
button {
border: none;
background: none;
padding: 0;
font: inherit;
cursor: pointer;
outline: none;
}
input,
textarea,
select {
font: inherit;
outline: none;
}
/* 安全区域适配 */
@supports (bottom: env(safe-area-inset-bottom)) {
body {
padding-bottom: env(safe-area-inset-bottom);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { StrictMode, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import router from './router'
import { initRem } from './utils/rem'
import './i18n'
import './index.css'
// 初始化 rem 适配
initRem()
function App() {
useEffect(() => {
// 确保在 DOM 加载后再次设置
initRem()
}, [])
return <RouterProvider router={router} />
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)
@@ -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
}
+80
View File
@@ -0,0 +1,80 @@
.apps {
min-height: 100vh;
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
background: #f5f5f5;
}
.header {
margin-bottom: 20px;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 12px;
color: #fff;
}
.header h1 {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
}
.header p {
margin: 0;
font-size: 13px;
opacity: 0.9;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.appCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px 8px;
background: #fff;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.appCard:active {
transform: scale(0.95);
background: #fafafa;
}
.appIcon {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.appInfo {
text-align: center;
}
.appLabel {
display: block;
font-size: 13px;
color: #262626;
font-weight: 500;
}
.appDesc {
display: block;
font-size: 11px;
color: #8c8c8c;
margin-top: 2px;
}
.safeArea {
height: 16px;
}
+64
View File
@@ -0,0 +1,64 @@
import styles from './apps.module.css'
interface AppItem {
id: string
icon: string
label: string
path: string
color: string
description?: string
}
const apps: AppItem[] = [
{ 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: '/meeting-room', 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 Apps() {
const handleAppClick = (app: AppItem) => {
// TODO: 跳转到应用页面
console.log('点击应用:', app)
}
return (
<div className={styles.apps}>
<div className={styles.header}>
<h1>应用中心</h1>
<p>选择你需要的功能</p>
</div>
<div className={styles.grid}>
{apps.map((app) => (
<div
key={app.id}
className={styles.appCard}
onClick={() => handleAppClick(app)}
>
<div className={styles.appIcon} style={{ backgroundColor: `${app.color}15` }}>
<span style={{ fontSize: 32, color: app.color }}>{app.icon}</span>
</div>
<div className={styles.appInfo}>
<span className={styles.appLabel}>{app.label}</span>
{app.description && (
<span className={styles.appDesc}>{app.description}</span>
)}
</div>
</div>
))}
</div>
{/* 底部安全区域 */}
<div className={styles.safeArea} />
</div>
)
}
@@ -0,0 +1,40 @@
.apply {
padding: 16px;
}
.form {
background: #fff;
border-radius: 8px;
padding: 16px;
}
.group {
margin-bottom: 16px;
}
.group label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #333;
}
.group input {
width: 100%;
padding: 10px 12px;
border: 1px solid #e5e5e5;
border-radius: 4px;
font-size: 14px;
}
.submitBtn {
width: 100%;
padding: 12px;
border: none;
border-radius: 6px;
background: #1677ff;
color: #fff;
font-size: 16px;
font-weight: 500;
cursor: pointer;
}
@@ -0,0 +1,23 @@
import styles from './apply.module.css'
export default function BusinessTripApply() {
return (
<div className={styles.apply}>
<form className={styles.form}>
<div className={styles.group}>
<label>出差事由</label>
<input type="text" placeholder="请输入出差事由" />
</div>
<div className={styles.group}>
<label>出差时间</label>
<input type="text" placeholder="请选择出差时间" />
</div>
<div className={styles.group}>
<label>出差地点</label>
<input type="text" placeholder="请输入出差地点" />
</div>
<button type="button" className={styles.submitBtn}>提交申请</button>
</form>
</div>
)
}
@@ -0,0 +1,39 @@
.detail {
padding: 16px;
}
.card {
background: #fff;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.card h2 {
margin: 0 0 12px 0;
font-size: 16px;
}
.actions {
display: flex;
gap: 12px;
}
.actions button {
flex: 1;
padding: 12px;
border: none;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
}
.approveBtn {
background: #52c41a;
color: #fff;
}
.rejectBtn {
background: #ff4d4f;
color: #fff;
}
@@ -0,0 +1,16 @@
import styles from './detail.module.css'
export default function BusinessTripDetail() {
return (
<div className={styles.detail}>
<div className={styles.card}>
<h2>出差申请详情</h2>
<p>详情内容...</p>
</div>
<div className={styles.actions}>
<button className={styles.approveBtn}>通过</button>
<button className={styles.rejectBtn}>拒绝</button>
</div>
</div>
)
}
@@ -0,0 +1,32 @@
.list {
padding: 16px;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tab {
flex: 1;
padding: 8px 12px;
border: none;
background: #f5f5f5;
border-radius: 6px;
font-size: 14px;
color: #666;
cursor: pointer;
}
.tab.active {
background: #1677ff;
color: #fff;
}
.empty {
padding: 60px 0;
text-align: center;
color: #999;
font-size: 14px;
}
+14
View File
@@ -0,0 +1,14 @@
import styles from './list.module.css'
export default function BusinessTripList() {
return (
<div className={styles.list}>
<div className={styles.tabs}>
<button className={`${styles.tab} ${styles.active}`}>我发起的</button>
<button className={styles.tab}>待我审批</button>
<button className={styles.tab}>我已审批</button>
</div>
<div className={styles.empty}>暂无出差申请</div>
</div>
)
}
@@ -0,0 +1,284 @@
.dashboard {
min-height: 100vh;
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
background: #f5f5f5;
}
/* 顶部欢迎区 */
.welcome {
margin-bottom: 16px;
padding: 20px;
background: linear-gradient(135deg, #1677ff 0%, #0958d9 100%);
border-radius: 16px;
color: #fff;
display: flex;
justify-content: space-between;
align-items: center;
}
.userInfo {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.greetingText {
flex: 1;
}
.langBtn {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: none;
cursor: pointer;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
.langBtn:active {
background: rgba(255, 255, 255, 0.3);
}
.avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
}
.greeting {
margin: 0 0 4px 0;
font-size: 20px;
font-weight: 600;
}
.date {
margin: 0;
font-size: 13px;
opacity: 0.9;
}
/* 通用区块 */
.section {
margin-bottom: 16px;
}
.sectionTitle {
margin: 0 0 12px 0;
font-size: 16px;
font-weight: 600;
color: #262626;
}
.sectionHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.more {
font-size: 13px;
color: #999;
}
/* 统计卡片 */
.statsGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.statCard {
background: #fff;
border-radius: 12px;
padding: 16px 12px;
text-align: center;
}
.statValue {
font-size: 24px;
font-weight: 600;
color: #262626;
margin-bottom: 4px;
}
.statTitle {
font-size: 12px;
color: #8c8c8c;
}
/* 待办事项 */
.todoList {
background: #fff;
border-radius: 12px;
overflow: hidden;
}
.todoItem {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background 0.2s;
}
.todoItem:last-child {
border-bottom: none;
}
.todoItem:active {
background: #f5f5f5;
}
.todoIcon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #f0f0f0;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.todoContent {
flex: 1;
min-width: 0;
}
.todoTitle {
font-size: 14px;
color: #262626;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.todoTime {
font-size: 12px;
color: #8c8c8c;
}
.todoArrow {
font-size: 18px;
color: #bfbfbf;
}
.empty {
padding: 40px 20px;
text-align: center;
background: #fff;
border-radius: 12px;
color: #8c8c8c;
font-size: 14px;
}
/* 快捷入口 */
.actionGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.actionCard {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 8px;
background: #fff;
border-radius: 12px;
cursor: pointer;
transition: all 0.2s;
}
.actionCard:active {
transform: scale(0.95);
}
.actionIcon {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.actionLabel {
font-size: 12px;
color: #595959;
text-align: center;
}
/* 最新动态 */
.activityList {
background: #fff;
border-radius: 12px;
padding: 12px 16px;
}
.activityItem {
display: flex;
gap: 12px;
padding: 12px 0;
position: relative;
}
.activityItem:not(:last-child)::after {
content: '';
position: absolute;
left: 5px;
top: 32px;
bottom: -12px;
width: 2px;
background: #f0f0f0;
}
.activityDot {
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;
}
.activityContent {
flex: 1;
}
.activityContent > div:first-child {
font-size: 14px;
color: #262626;
margin-bottom: 4px;
}
.activityTime {
font-size: 12px;
color: #8c8c8c;
}
/* 底部安全区域 */
.safeArea {
height: 16px;
}
+174
View File
@@ -0,0 +1,174 @@
import { useNavigate } from 'react-router-dom'
import { useUserStore } from '@/stores/user'
import { useTranslation } from 'react-i18next'
import { showLanguageSwitcher } from '@/components/LanguageSwitcher'
import styles from './dashboard.module.css'
interface QuickAction {
id: string
icon: string
label: string
path: string
color: 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' },
]
export default function Dashboard() {
const navigate = useNavigate()
const { user } = useUserStore()
const { t } = useTranslation()
const handleQuickAction = (action: QuickAction) => {
navigate(action.path)
}
const getCurrentLanguage = () => {
const lang = localStorage.getItem('i18nextLng') || 'zh'
const langMap: Record<string, string> = {
zh: '🇨🇳',
en: '🇬🇧',
ru: '🇷🇺',
es: '🇪🇸',
fr: '🇫🇷',
}
return langMap[lang] || '🇨🇳'
}
return (
<div className={styles.dashboard}>
{/* 顶部欢迎区 */}
<div className={styles.welcome}>
<div className={styles.userInfo}>
<div className={styles.avatar}>{user?.name?.[0] || 'U'}</div>
<div className={styles.greetingText}>
<h2 className={styles.greeting}>{t('dashboard.greeting', { name: user?.name || t('login.username') })}</h2>
<p className={styles.date}>{new Date().toLocaleDateString('zh-CN', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}</p>
</div>
</div>
<button className={styles.langBtn} onClick={() => showLanguageSwitcher()}>
{getCurrentLanguage()}
</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) => (
<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>
</div>
<span className={styles.actionLabel}>{action.label}</span>
</div>
))}
</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>
</div>
</div>
<div className={styles.activityItem}>
<div className={styles.activityDot}></div>
<div className={styles.activityContent}>
<div>李四 审批通过了采购申请</div>
<div className={styles.activityTime}>1小时前</div>
</div>
</div>
</div>
</div>
{/* 底部安全区域 */}
<div className={styles.safeArea} />
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import styles from './login.module.css'
export default function Login() {
return (
<div className={styles.login}>
<div className={styles.container}>
<h1>企业工具台</h1>
<button className={styles.feishuBtn}>飞书授权登录</button>
</div>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
.login {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.container {
padding: 40px;
text-align: center;
}
.container h1 {
margin: 0 0 40px 0;
color: #fff;
font-size: 28px;
}
.feishuBtn {
width: 240px;
padding: 14px;
border: none;
border-radius: 6px;
background: #fff;
color: #1677ff;
font-size: 16px;
font-weight: 500;
cursor: pointer;
}
+113
View File
@@ -0,0 +1,113 @@
import { useNavigate } from 'react-router-dom'
import { useUserStore } from '@/stores/user'
import { useTranslation } from 'react-i18next'
import { showLanguageSwitcher } from '@/components/LanguageSwitcher'
import styles from './profile.module.css'
interface MenuItem {
id: string
icon: string
label: string
path?: string
onClick?: () => void
badge?: number
}
export default function Profile() {
const navigate = useNavigate()
const { user, logout } = useUserStore()
const { t } = useTranslation()
const menuItems: MenuItem[] = [
{
id: 'messages',
icon: '💬',
label: t('profile.myMessages'),
path: '/messages',
badge: 5,
},
{
id: 'language',
icon: '🌐',
label: t('profile.language'),
onClick: () => showLanguageSwitcher(),
},
{
id: 'settings',
icon: '⚙️',
label: t('profile.settings'),
path: '/settings',
},
{
id: 'about',
icon: 'ℹ️',
label: t('profile.about'),
path: '/about',
},
]
const handleMenuClick = (item: MenuItem) => {
if (item.path) {
navigate(item.path)
} else if (item.onClick) {
item.onClick()
}
}
return (
<div className={styles.profile}>
{/* 用户信息卡片 */}
<div className={styles.userCard}>
<div className={styles.avatar}>{user?.name?.[0] || 'U'}</div>
<div className={styles.userInfo}>
<div className={styles.userName}>{user?.name || t('login.username')}</div>
{user?.email && <div className={styles.userEmail}>{user.email}</div>}
</div>
</div>
{/* 统计数据 */}
<div className={styles.stats}>
<div className={styles.statItem}>
<div className={styles.statValue}>5</div>
<div className={styles.statLabel}>{t('approval.pending')}</div>
</div>
<div className={styles.statItem}>
<div className={styles.statValue}>12</div>
<div className={styles.statLabel}>{t('approval.approved')}</div>
</div>
<div className={styles.statItem}>
<div className={styles.statValue}>8</div>
<div className={styles.statLabel}>{t('businessTrip.myTrips')}</div>
</div>
</div>
{/* 功能菜单 */}
<div className={styles.menu}>
{menuItems.map((item) => (
<div
key={item.id}
className={styles.menuItem}
onClick={() => handleMenuClick(item)}
>
<div className={styles.menuLeft}>
<span className={styles.menuIcon}>{item.icon}</span>
<span className={styles.menuLabel}>{item.label}</span>
{item.badge && item.badge > 0 && (
<span className={styles.badge}>{item.badge > 99 ? '99+' : item.badge}</span>
)}
</div>
{item.path && <span className={styles.menuArrow}>›</span>}
</div>
))}
</div>
{/* 退出登录按钮 */}
<button className={styles.logoutBtn} onClick={logout}>
{t('profile.logout')}
</button>
{/* 底部安全区域 */}
<div className={styles.safeArea} />
</div>
)
}
@@ -0,0 +1,154 @@
.profile {
min-height: 100vh;
padding: 16px;
padding-bottom: calc(16px + env(safe-area-inset-bottom));
background: #f5f5f5;
}
/* 用户信息卡片 */
.userCard {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: linear-gradient(135deg, #1677ff 0%, #0958d9 100%);
border-radius: 16px;
color: #fff;
margin-bottom: 16px;
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
font-weight: 600;
}
.userInfo {
flex: 1;
}
.userName {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
.userEmail {
font-size: 13px;
opacity: 0.8;
}
/* 统计数据 */
.stats {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.statItem {
flex: 1;
background: #fff;
border-radius: 12px;
padding: 16px;
text-align: center;
}
.statValue {
font-size: 24px;
font-weight: 600;
color: #1677ff;
margin-bottom: 4px;
}
.statLabel {
font-size: 12px;
color: #8c8c8c;
}
/* 功能菜单 */
.menu {
background: #fff;
border-radius: 12px;
overflow: hidden;
margin-bottom: 16px;
}
.menuItem {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background 0.2s;
}
.menuItem:last-child {
border-bottom: none;
}
.menuItem:active {
background: #f5f5f5;
}
.menuLeft {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.menuIcon {
font-size: 20px;
color: #1677ff;
}
.menuLabel {
font-size: 15px;
color: #262626;
}
.badge {
margin-left: auto;
padding: 2px 8px;
background: #ff4d4f;
color: #fff;
font-size: 11px;
border-radius: 10px;
min-width: 18px;
text-align: center;
}
.menuArrow {
font-size: 14px;
color: #bfbfbf;
}
/* 退出登录按钮 */
.logoutBtn {
width: 100%;
padding: 14px;
border: none;
border-radius: 12px;
background: #fff;
color: #ff4d4f;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.logoutBtn:active {
background: #f5f5f5;
}
/* 底部安全区域 */
.safeArea {
height: 16px;
}
+29
View File
@@ -0,0 +1,29 @@
import { createBrowserRouter, Navigate } from 'react-router-dom'
import MobileLayout from '@/components/layout/MobileLayout'
import Dashboard from '@/pages/dashboard'
import Apps from '@/pages/apps'
import BusinessTripList from '@/pages/business-trip/list'
import BusinessTripApply from '@/pages/business-trip/apply'
import BusinessTripDetail from '@/pages/business-trip/detail'
import Profile from '@/pages/profile'
import Login from '@/pages/login'
const router = createBrowserRouter([
{
path: '/',
element: <MobileLayout />,
children: [
{ index: true, element: <Navigate to='/dashboard' replace /> },
{ path: 'dashboard', element: <Dashboard /> },
{ path: 'apps', element: <Apps /> },
{ path: 'business-trip', element: <BusinessTripList /> },
{ path: 'business-trip/apply', element: <BusinessTripApply /> },
{ path: 'business-trip/:id', element: <BusinessTripDetail /> },
{ path: 'profile', element: <Profile /> },
],
},
{ path: '/login', element: <Login /> },
{ path: '*', element: <Navigate to='/dashboard' replace /> },
])
export default router
+99
View File
@@ -0,0 +1,99 @@
import axios, { AxiosError, type AxiosInstance, type InternalAxiosRequestConfig, type AxiosResponse } from 'axios'
import { API_BASE_URL, API_TIMEOUT } from '@/config'
// 请求响应接口
export interface ApiResponse<T = any> {
code: number
message: string
data: T
}
// 请求配置接口
export interface RequestConfig extends InternalAxiosRequestConfig {
skipAuth?: boolean
}
// 创建 axios 实例
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: API_TIMEOUT,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
apiClient.interceptors.request.use(
(config: RequestConfig) => {
// 从 localStorage 获取 token
const token = localStorage.getItem('access_token')
if (token && !config.skipAuth) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: AxiosError) => {
return Promise.reject(error)
}
)
// 响应拦截器
apiClient.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { data } = response
if (data.code === 0) {
return response
}
// 处理业务错误
return Promise.reject(new Error(data.message || 'Request failed'))
},
(error: AxiosError<ApiResponse>) => {
if (error.response) {
const { status, data } = error.response
switch (status) {
case 401:
// 未授权,跳转登录
localStorage.removeItem('access_token')
window.location.href = '/login'
break
case 403:
console.error('Forbidden:', data?.message)
break
case 404:
console.error('Not Found:', data?.message)
break
case 500:
console.error('Server Error:', data?.message)
break
default:
console.error('HTTP Error:', status, data?.message)
}
} else if (error.request) {
console.error('Network Error:', error.message)
} else {
console.error('Error:', error.message)
}
return Promise.reject(error)
}
)
// API 方法封装
export const api = {
get: <T = any>(url: string, config?: RequestConfig): Promise<ApiResponse<T>> => {
return apiClient.get(url, config).then(res => res.data)
},
post: <T = any>(url: string, data?: any, config?: RequestConfig): Promise<ApiResponse<T>> => {
return apiClient.post(url, data, config).then(res => res.data)
},
put: <T = any>(url: string, data?: any, config?: RequestConfig): Promise<ApiResponse<T>> => {
return apiClient.put(url, data, config).then(res => res.data)
},
delete: <T = any>(url: string, config?: RequestConfig): Promise<ApiResponse<T>> => {
return apiClient.delete(url, config).then(res => res.data)
},
}
export default apiClient
+51
View File
@@ -0,0 +1,51 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User, Company } from '@/types'
interface UserState {
user: User | null
token: string | null
currentCompany: Company | null
isLoggedIn: boolean
// Actions
setUser: (user: User | null) => void
setToken: (token: string | null) => void
setCurrentCompany: (company: Company | null) => void
login: (user: User, token: string) => void
logout: () => void
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
user: null,
token: null,
currentCompany: null,
isLoggedIn: false,
setUser: (user) => set({ user }),
setToken: (token) => set({ token }),
setCurrentCompany: (currentCompany) => set({ currentCompany }),
login: (user, token) => {
localStorage.setItem('access_token', token)
set({ user, token, isLoggedIn: true })
},
logout: () => {
localStorage.removeItem('access_token')
set({ user: null, token: null, currentCompany: null, isLoggedIn: false })
},
}),
{
name: 'user-storage',
partialize: (state) => ({
user: state.user,
token: state.token,
currentCompany: state.currentCompany,
isLoggedIn: state.isLoggedIn,
}),
}
)
)
+103
View File
@@ -0,0 +1,103 @@
// 通用类型定义
// 审批相关类型(使用 const 对象替代 enum)
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 interface User {
id: string
companyId: string
name: string
email?: string
phone?: string
avatar?: string
status: number
}
// 公司信息
export interface Company {
id: string
name: string
feishuAppId?: string
status: number
}
// 分页参数
export interface PaginationParams {
page: number
pageSize: number
}
// 分页响应
export interface PaginationResponse<T> {
list: T[]
total: number
page: number
pageSize: number
}
export interface ApprovalNode {
id: string
type: ApprovalNodeType
approvers: User[]
order: number
conditions?: any
}
export interface ApprovalFlow {
id: string
businessType: string
name: string
nodes: ApprovalNode[]
}
export interface ApprovalOrder {
id: string
businessType: string
businessId: string
title: string
applicant: User
status: ApprovalStatus
currentNodes: string[]
flowId: string
formData: 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: ApprovalNodeType
approver: User
action: 'approve' | 'reject' | 'cancel' | 'submit'
comment?: string
createdAt: string
}
+92
View File
@@ -0,0 +1,92 @@
/**
* 飞书 SDK 封装
*/
export const FeishuSDK = {
/**
* 判断是否在飞书环境
*/
isFeishuEnv(): boolean {
return !!(window as any).h5panel || !!(window as any).tt
},
/**
* 获取免登授权码
*/
async getAuthCode(): Promise<string> {
return new Promise((resolve, reject) => {
if (!this.isFeishuEnv()) {
reject(new Error('Not in Feishu environment'))
return
}
try {
const tt = (window as any).tt || (window as any).h5panel
tt.ready(() => {
tt.requestAuthCode({
success: (res: { authCode: string }) => {
resolve(res.authCode)
},
fail: (err: Error) => {
reject(err)
},
})
})
} catch (error) {
reject(error)
}
})
},
/**
* 获取当前租户信息(用于后端识别公司)
*/
getTenantContext(): { tenantKey?: string } {
if (!this.isFeishuEnv()) {
return {}
}
try {
const tt = (window as any).tt || (window as any).h5panel
// 飞书容器中的租户信息
return {
tenantKey: tt?.tenantKey || '',
}
} catch {
return {}
}
},
/**
* 初始化飞书 JSAPI
*/
async init(): Promise<void> {
if (!this.isFeishuEnv()) {
console.log('Not in Feishu environment, skip initialization')
return
}
return new Promise((resolve, reject) => {
try {
const tt = (window as any).tt || (window as any).h5panel
tt.ready(() => {
console.log('Feishu JSAPI initialized')
resolve()
})
tt.error((err: Error) => {
console.error('Feishu JSAPI error:', err)
reject(err)
})
} catch (error) {
reject(error)
}
})
},
}
// 声明飞书全局类型
declare global {
interface Window {
tt?: any
h5panel?: any
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* 动态设置根字体大小,实现移动端适配
*/
export function setRem(): void {
// 设计稿宽度
const designWidth = 375 // iPhone 6/7/8 的宽度
// 基础字体大小
const baseFontSize = 16
// 获取当前设备宽度
const deviceWidth = document.documentElement.clientWidth || window.innerWidth
// 计算缩放比例
const scale = deviceWidth / designWidth
// 设置根字体大小(最大不超过 20px,最小不小于 12px)
const fontSize = Math.min(Math.max(scale * baseFontSize, 12), 20)
document.documentElement.style.fontSize = fontSize + 'px'
console.log(`设备宽度: ${deviceWidth}px, 根字体: ${fontSize}px`)
}
/**
* 初始化 rem 适配
*/
export function initRem(): void {
setRem()
// 监听窗口大小变化
window.addEventListener('resize', setRem)
// 监听设备方向变化
window.addEventListener('orientationchange', setRem)
}