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,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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// App.tsx 仅作为路由入口,具体内容在 router/index.tsx 中定义
|
||||
export { default } from './router'
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 设计稿基准
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "В прошлом году"
|
||||
}
|
||||
}
|
||||
@@ -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": "去年"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user