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:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user