ess-main-template 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -0
- package/.prettierrc +8 -0
- package/README.md +73 -0
- package/commitlint.config.js +6 -0
- package/eslint.config.js +32 -0
- package/index.html +22 -0
- package/mock/auth.mock.ts +39 -0
- package/mock/user.mock.ts +83 -0
- package/package.json +64 -0
- package/public/favicon.svg +1 -0
- package/public/icons.svg +24 -0
- package/src/App.css +182 -0
- package/src/App.tsx +29 -0
- package/src/assets/fonts/.gitkeep +0 -0
- package/src/assets/hero.png +0 -0
- package/src/assets/react.svg +1 -0
- package/src/assets/vite.svg +1 -0
- package/src/components/AuthButton.tsx +23 -0
- package/src/components/LangSwitch.tsx +31 -0
- package/src/components/RouteProgress.tsx +25 -0
- package/src/components/StationPicker.tsx +24 -0
- package/src/components/ThemeSwitch.tsx +18 -0
- package/src/constants/bus-events.ts +15 -0
- package/src/constants/index.ts +25 -0
- package/src/hooks/useAuth.ts +17 -0
- package/src/i18n/index.ts +32 -0
- package/src/i18n/locales/en/auth.json +15 -0
- package/src/i18n/locales/en/common.json +33 -0
- package/src/i18n/locales/en/menu.json +33 -0
- package/src/i18n/locales/zh/auth.json +15 -0
- package/src/i18n/locales/zh/common.json +33 -0
- package/src/i18n/locales/zh/menu.json +33 -0
- package/src/index.css +109 -0
- package/src/layouts/BasicLayout.tsx +73 -0
- package/src/layouts/BlankLayout.tsx +7 -0
- package/src/layouts/components/RightContent.tsx +43 -0
- package/src/main.tsx +13 -0
- package/src/pages/403.tsx +23 -0
- package/src/pages/404.tsx +23 -0
- package/src/pages/Dashboard/index.tsx +15 -0
- package/src/pages/Login/index.module.less +23 -0
- package/src/pages/Login/index.tsx +58 -0
- package/src/router/AppEntry.tsx +93 -0
- package/src/router/AuthGuard.tsx +19 -0
- package/src/router/componentMap.ts +14 -0
- package/src/router/generateRoutes.tsx +55 -0
- package/src/router/index.tsx +18 -0
- package/src/services/auth.ts +42 -0
- package/src/store/appStore.ts +51 -0
- package/src/store/userStore.ts +81 -0
- package/src/styles/global.less +40 -0
- package/src/styles/mixins.less +56 -0
- package/src/styles/reset.less +49 -0
- package/src/styles/variables.less +50 -0
- package/src/types/i18next.d.ts +16 -0
- package/src/types/less.d.ts +6 -0
- package/src/types/wujie-react.d.ts +37 -0
- package/src/utils/auth.ts +28 -0
- package/src/utils/request.ts +118 -0
- package/src/wujie/SubApp.tsx +72 -0
- package/src/wujie/bus.ts +38 -0
- package/src/wujie/config.ts +33 -0
- package/src/wujie/props.ts +42 -0
- package/src/wujie/useBusSync.ts +67 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +4 -0
- package/tsconfig.node.json +24 -0
- package/vite.config.ts +26 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Button, Result } from 'antd'
|
|
2
|
+
import { useNavigate } from 'react-router-dom'
|
|
3
|
+
import { useTranslation } from 'react-i18next'
|
|
4
|
+
|
|
5
|
+
const NotFound: React.FC = () => {
|
|
6
|
+
const navigate = useNavigate()
|
|
7
|
+
const { t } = useTranslation('common')
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<Result
|
|
11
|
+
status="404"
|
|
12
|
+
title="404"
|
|
13
|
+
subTitle={t('noData')}
|
|
14
|
+
extra={
|
|
15
|
+
<Button type="primary" onClick={() => navigate('/')}>
|
|
16
|
+
{t('backHome')}
|
|
17
|
+
</Button>
|
|
18
|
+
}
|
|
19
|
+
/>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default NotFound
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next'
|
|
2
|
+
|
|
3
|
+
const Dashboard: React.FC = () => {
|
|
4
|
+
const { t } = useTranslation('menu')
|
|
5
|
+
|
|
6
|
+
return (
|
|
7
|
+
<div>
|
|
8
|
+
<h2>{t('dashboard')}</h2>
|
|
9
|
+
<p>Dashboard placeholder</p>
|
|
10
|
+
</div>
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default Dashboard
|
|
15
|
+
export const Component = Dashboard
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
.container {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
justify-content: center;
|
|
5
|
+
min-height: 100vh;
|
|
6
|
+
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.card {
|
|
10
|
+
width: 400px;
|
|
11
|
+
padding: 40px;
|
|
12
|
+
background: #fff;
|
|
13
|
+
border-radius: 8px;
|
|
14
|
+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.title {
|
|
18
|
+
margin-bottom: 32px;
|
|
19
|
+
font-size: 24px;
|
|
20
|
+
font-weight: 600;
|
|
21
|
+
text-align: center;
|
|
22
|
+
color: #333;
|
|
23
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React, { useState } from 'react'
|
|
2
|
+
import { Button, Form, Input, message } from 'antd'
|
|
3
|
+
import { LockOutlined, UserOutlined } from '@ant-design/icons'
|
|
4
|
+
import { useNavigate } from 'react-router-dom'
|
|
5
|
+
import { useTranslation } from 'react-i18next'
|
|
6
|
+
import { useUserStore } from '@/store/userStore'
|
|
7
|
+
import { login } from '@/services/auth'
|
|
8
|
+
import type { LoginParams } from '@/services/auth'
|
|
9
|
+
import styles from './index.module.less'
|
|
10
|
+
|
|
11
|
+
const LoginPage: React.FC = () => {
|
|
12
|
+
const navigate = useNavigate()
|
|
13
|
+
const { t } = useTranslation('auth')
|
|
14
|
+
const setAuth = useUserStore((s) => s.setAuth)
|
|
15
|
+
const [form] = Form.useForm<LoginParams>()
|
|
16
|
+
const [loading, setLoading] = useState(false)
|
|
17
|
+
|
|
18
|
+
const handleSubmit = async (values: LoginParams) => {
|
|
19
|
+
setLoading(true)
|
|
20
|
+
try {
|
|
21
|
+
const { data: res } = await login(values)
|
|
22
|
+
if (res.code === 0) {
|
|
23
|
+
setAuth(res.data.accessToken, res.data.refreshToken)
|
|
24
|
+
message.success(t('loginSuccess'))
|
|
25
|
+
navigate('/', { replace: true })
|
|
26
|
+
} else {
|
|
27
|
+
message.error(res.message || t('loginFailed'))
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
message.error(t('loginFailed'))
|
|
31
|
+
} finally {
|
|
32
|
+
setLoading(false)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className={styles.container}>
|
|
38
|
+
<div className={styles.card}>
|
|
39
|
+
<h1 className={styles.title}>{t('loginTitle')}</h1>
|
|
40
|
+
<Form form={form} onFinish={handleSubmit} size="large" autoComplete="off">
|
|
41
|
+
<Form.Item name="username" rules={[{ required: true, message: t('usernameRequired') }]}>
|
|
42
|
+
<Input prefix={<UserOutlined />} placeholder={t('usernamePlaceholder')} />
|
|
43
|
+
</Form.Item>
|
|
44
|
+
<Form.Item name="password" rules={[{ required: true, message: t('passwordRequired') }]}>
|
|
45
|
+
<Input.Password prefix={<LockOutlined />} placeholder={t('passwordPlaceholder')} />
|
|
46
|
+
</Form.Item>
|
|
47
|
+
<Form.Item>
|
|
48
|
+
<Button type="primary" htmlType="submit" loading={loading} block>
|
|
49
|
+
{t('loginButton')}
|
|
50
|
+
</Button>
|
|
51
|
+
</Form.Item>
|
|
52
|
+
</Form>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default LoginPage
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useEffect, useState, useMemo } from 'react'
|
|
2
|
+
import { Navigate, useRoutes } from 'react-router-dom'
|
|
3
|
+
import { Spin } from 'antd'
|
|
4
|
+
import { useUserStore } from '@/store/userStore'
|
|
5
|
+
import { getUserInfo, getUserRoutes, getUserStations } from '@/services/auth'
|
|
6
|
+
import BasicLayout from '@/layouts/BasicLayout'
|
|
7
|
+
import { generateRoutes } from './generateRoutes'
|
|
8
|
+
|
|
9
|
+
const AppEntry: React.FC = () => {
|
|
10
|
+
const token = useUserStore((s) => s.token)
|
|
11
|
+
const dynamicRoutes = useUserStore((s) => s.dynamicRoutes)
|
|
12
|
+
const setUserInfo = useUserStore((s) => s.setUserInfo)
|
|
13
|
+
const setPermissions = useUserStore((s) => s.setPermissions)
|
|
14
|
+
const setDynamicRoutes = useUserStore((s) => s.setDynamicRoutes)
|
|
15
|
+
const setAuthorizedStations = useUserStore((s) => s.setAuthorizedStations)
|
|
16
|
+
const [loading, setLoading] = useState(true)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (!token) {
|
|
20
|
+
setLoading(false)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let cancelled = false
|
|
25
|
+
|
|
26
|
+
async function fetchUserData() {
|
|
27
|
+
try {
|
|
28
|
+
const [infoRes, routesRes, stationsRes] = await Promise.all([
|
|
29
|
+
getUserInfo(),
|
|
30
|
+
getUserRoutes(),
|
|
31
|
+
getUserStations(),
|
|
32
|
+
])
|
|
33
|
+
if (cancelled) return
|
|
34
|
+
|
|
35
|
+
if (infoRes.data.code === 0) {
|
|
36
|
+
setUserInfo(infoRes.data.data)
|
|
37
|
+
setPermissions(infoRes.data.data.roles)
|
|
38
|
+
}
|
|
39
|
+
if (routesRes.data.code === 0) {
|
|
40
|
+
setDynamicRoutes(routesRes.data.data)
|
|
41
|
+
}
|
|
42
|
+
if (stationsRes.data.code === 0) {
|
|
43
|
+
setAuthorizedStations(stationsRes.data.data)
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// token 无效等异常由 axios 拦截器处理
|
|
47
|
+
} finally {
|
|
48
|
+
if (!cancelled) setLoading(false)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
fetchUserData()
|
|
53
|
+
return () => {
|
|
54
|
+
cancelled = true
|
|
55
|
+
}
|
|
56
|
+
}, [token, setUserInfo, setPermissions, setDynamicRoutes, setAuthorizedStations])
|
|
57
|
+
|
|
58
|
+
const childRoutes = useMemo(() => {
|
|
59
|
+
if (!dynamicRoutes.length) return []
|
|
60
|
+
const routes = generateRoutes(dynamicRoutes)
|
|
61
|
+
// 默认重定向到第一条路由
|
|
62
|
+
const firstPath = dynamicRoutes[0]?.path || '/dashboard'
|
|
63
|
+
routes.unshift({ index: true, element: <Navigate to={firstPath} replace /> })
|
|
64
|
+
routes.push({ path: '*', element: <Navigate to={firstPath} replace /> })
|
|
65
|
+
return routes
|
|
66
|
+
}, [dynamicRoutes])
|
|
67
|
+
|
|
68
|
+
const element = useRoutes([
|
|
69
|
+
{
|
|
70
|
+
path: '/',
|
|
71
|
+
element: <BasicLayout />,
|
|
72
|
+
children: childRoutes,
|
|
73
|
+
},
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
if (!token) {
|
|
77
|
+
return <Navigate to="/login" replace />
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (loading) {
|
|
81
|
+
return (
|
|
82
|
+
<div
|
|
83
|
+
style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}
|
|
84
|
+
>
|
|
85
|
+
<Spin size="large" />
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return element
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export default AppEntry
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Navigate, useLocation } from 'react-router-dom'
|
|
2
|
+
import { useUserStore } from '@/store/userStore'
|
|
3
|
+
|
|
4
|
+
interface AuthGuardProps {
|
|
5
|
+
children: React.ReactNode
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const AuthGuard: React.FC<AuthGuardProps> = ({ children }) => {
|
|
9
|
+
const token = useUserStore((s) => s.token)
|
|
10
|
+
const location = useLocation()
|
|
11
|
+
|
|
12
|
+
if (!token) {
|
|
13
|
+
return <Navigate to="/login" state={{ from: location }} replace />
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return <>{children}</>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export default AuthGuard
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { lazy } from 'react'
|
|
2
|
+
import type { ComponentType } from 'react'
|
|
3
|
+
|
|
4
|
+
type LazyComponent = React.LazyExoticComponent<ComponentType>
|
|
5
|
+
|
|
6
|
+
/** 页面组件映射:后端返回的 component 字段 → React.lazy 组件 */
|
|
7
|
+
const componentMap: Record<string, LazyComponent> = {
|
|
8
|
+
'pages/Dashboard': lazy(() => import('@/pages/Dashboard')),
|
|
9
|
+
// 子应用占位:后续步骤中添加 SubApp 容器组件
|
|
10
|
+
// 'sub-operation': lazy(() => import('@/pages/SubApp/Operation')),
|
|
11
|
+
// 'sub-analysis': lazy(() => import('@/pages/SubApp/Analysis')),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default componentMap
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/* eslint-disable react-refresh/only-export-components */
|
|
2
|
+
import { Suspense } from 'react'
|
|
3
|
+
import { Navigate } from 'react-router-dom'
|
|
4
|
+
import type { RouteObject } from 'react-router-dom'
|
|
5
|
+
import type { RouteItem } from '@/store/userStore'
|
|
6
|
+
import componentMap from './componentMap'
|
|
7
|
+
|
|
8
|
+
/** 403 占位(后续步骤替换为真实 403 页面) */
|
|
9
|
+
const Forbidden = () => <div style={{ padding: 48, textAlign: 'center' }}>403 - 无权访问</div>
|
|
10
|
+
|
|
11
|
+
/** 将后端路由数据转换为 react-router RouteObject */
|
|
12
|
+
export function generateRoutes(routes: RouteItem[]): RouteObject[] {
|
|
13
|
+
return routes.map((route) => {
|
|
14
|
+
// 取路径最后一段作为相对路径,确保嵌套路由正确匹配
|
|
15
|
+
// /dashboard → dashboard, /operation/devices → devices
|
|
16
|
+
const segments = route.path.split('/').filter(Boolean)
|
|
17
|
+
const relativePath = segments[segments.length - 1] || route.path
|
|
18
|
+
|
|
19
|
+
const routeObj: RouteObject = {
|
|
20
|
+
path: relativePath,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// 叶子节点:匹配组件
|
|
24
|
+
if (route.component) {
|
|
25
|
+
const LazyComp = componentMap[route.component]
|
|
26
|
+
if (LazyComp) {
|
|
27
|
+
routeObj.element = (
|
|
28
|
+
<Suspense fallback={<div>Loading...</div>}>
|
|
29
|
+
<LazyComp />
|
|
30
|
+
</Suspense>
|
|
31
|
+
)
|
|
32
|
+
} else {
|
|
33
|
+
// 组件未注册,显示 403
|
|
34
|
+
routeObj.element = <Forbidden />
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 有子路由
|
|
39
|
+
if (route.children?.length) {
|
|
40
|
+
routeObj.children = generateRoutes(route.children)
|
|
41
|
+
// 父级路由默认重定向到第一个子路由(使用相对路径)
|
|
42
|
+
if (!route.component && route.children[0]) {
|
|
43
|
+
const firstChildSegments = route.children[0].path.split('/').filter(Boolean)
|
|
44
|
+
const firstChildRelative =
|
|
45
|
+
firstChildSegments[firstChildSegments.length - 1] || route.children[0].path
|
|
46
|
+
routeObj.children.unshift({
|
|
47
|
+
index: true,
|
|
48
|
+
element: <Navigate to={firstChildRelative} replace />,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return routeObj
|
|
54
|
+
})
|
|
55
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createBrowserRouter } from 'react-router-dom'
|
|
2
|
+
import BlankLayout from '@/layouts/BlankLayout'
|
|
3
|
+
import LoginPage from '@/pages/Login'
|
|
4
|
+
import AppEntry from './AppEntry'
|
|
5
|
+
|
|
6
|
+
const router = createBrowserRouter([
|
|
7
|
+
{
|
|
8
|
+
path: '/login',
|
|
9
|
+
element: <BlankLayout />,
|
|
10
|
+
children: [{ index: true, element: <LoginPage /> }],
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
path: '/*',
|
|
14
|
+
element: <AppEntry />,
|
|
15
|
+
},
|
|
16
|
+
])
|
|
17
|
+
|
|
18
|
+
export default router
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import request from '@/utils/request'
|
|
2
|
+
|
|
3
|
+
export interface LoginParams {
|
|
4
|
+
username: string
|
|
5
|
+
password: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface LoginResult {
|
|
9
|
+
accessToken: string
|
|
10
|
+
refreshToken: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ApiResponse<T = unknown> {
|
|
14
|
+
code: number
|
|
15
|
+
message: string
|
|
16
|
+
data: T
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 登录 */
|
|
20
|
+
export function login(data: LoginParams) {
|
|
21
|
+
return request.post<ApiResponse<LoginResult>>('/auth/login', data)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 获取用户信息 */
|
|
25
|
+
export function getUserInfo() {
|
|
26
|
+
return request.get<ApiResponse<import('@/store/userStore').UserInfo>>('/user/info')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 获取权限路由 */
|
|
30
|
+
export function getUserRoutes() {
|
|
31
|
+
return request.get<ApiResponse<import('@/store/userStore').RouteItem[]>>('/user/routes')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 获取授权站点 */
|
|
35
|
+
export function getUserStations() {
|
|
36
|
+
return request.get<ApiResponse<import('@/store/userStore').Station[]>>('/user/stations')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 刷新 Token */
|
|
40
|
+
export function refreshToken() {
|
|
41
|
+
return request.post<ApiResponse<LoginResult>>('/auth/refresh')
|
|
42
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
import { STORAGE_KEYS } from '@/constants'
|
|
3
|
+
|
|
4
|
+
export type Theme = 'light' | 'dark'
|
|
5
|
+
export type Locale = 'zh' | 'en'
|
|
6
|
+
|
|
7
|
+
interface AppState {
|
|
8
|
+
theme: Theme
|
|
9
|
+
locale: Locale
|
|
10
|
+
sidebarCollapsed: boolean
|
|
11
|
+
currentStation: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AppActions {
|
|
15
|
+
setTheme: (theme: Theme) => void
|
|
16
|
+
toggleTheme: () => void
|
|
17
|
+
setLocale: (locale: Locale) => void
|
|
18
|
+
setSidebarCollapsed: (collapsed: boolean) => void
|
|
19
|
+
toggleSidebar: () => void
|
|
20
|
+
setCurrentStation: (stationId: string | null) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const useAppStore = create<AppState & AppActions>()((set) => ({
|
|
24
|
+
theme: (localStorage.getItem(STORAGE_KEYS.THEME) as Theme) || 'light',
|
|
25
|
+
locale: (localStorage.getItem(STORAGE_KEYS.LOCALE) as Locale) || 'zh',
|
|
26
|
+
sidebarCollapsed: false,
|
|
27
|
+
currentStation: null,
|
|
28
|
+
|
|
29
|
+
setTheme: (theme) => {
|
|
30
|
+
localStorage.setItem(STORAGE_KEYS.THEME, theme)
|
|
31
|
+
set({ theme })
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
toggleTheme: () =>
|
|
35
|
+
set((state) => {
|
|
36
|
+
const next = state.theme === 'light' ? 'dark' : 'light'
|
|
37
|
+
localStorage.setItem(STORAGE_KEYS.THEME, next)
|
|
38
|
+
return { theme: next }
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
setLocale: (locale) => {
|
|
42
|
+
localStorage.setItem(STORAGE_KEYS.LOCALE, locale)
|
|
43
|
+
set({ locale })
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
|
|
47
|
+
|
|
48
|
+
toggleSidebar: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
|
49
|
+
|
|
50
|
+
setCurrentStation: (stationId) => set({ currentStation: stationId }),
|
|
51
|
+
}))
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
import { STORAGE_KEYS } from '@/constants'
|
|
3
|
+
import { setTokens, clearTokens } from '@/utils/auth'
|
|
4
|
+
|
|
5
|
+
export interface Station {
|
|
6
|
+
id: string
|
|
7
|
+
name: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UserInfo {
|
|
11
|
+
id: string
|
|
12
|
+
username: string
|
|
13
|
+
realName: string
|
|
14
|
+
avatar?: string
|
|
15
|
+
roles: string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RouteItem {
|
|
19
|
+
path: string
|
|
20
|
+
name: string
|
|
21
|
+
icon?: string
|
|
22
|
+
component?: string
|
|
23
|
+
children?: RouteItem[]
|
|
24
|
+
meta?: {
|
|
25
|
+
title: string
|
|
26
|
+
hideInMenu?: boolean
|
|
27
|
+
permissions?: string[]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface UserState {
|
|
32
|
+
token: string | null
|
|
33
|
+
refreshToken: string | null
|
|
34
|
+
userInfo: UserInfo | null
|
|
35
|
+
permissions: string[]
|
|
36
|
+
dynamicRoutes: RouteItem[]
|
|
37
|
+
authorizedStations: Station[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface UserActions {
|
|
41
|
+
setAuth: (token: string, refreshToken: string) => void
|
|
42
|
+
setUserInfo: (info: UserInfo) => void
|
|
43
|
+
setPermissions: (permissions: string[]) => void
|
|
44
|
+
setDynamicRoutes: (routes: RouteItem[]) => void
|
|
45
|
+
setAuthorizedStations: (stations: Station[]) => void
|
|
46
|
+
logout: () => void
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const initialState: UserState = {
|
|
50
|
+
token: localStorage.getItem(STORAGE_KEYS.ACCESS_TOKEN),
|
|
51
|
+
refreshToken: localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN),
|
|
52
|
+
userInfo: null,
|
|
53
|
+
permissions: [],
|
|
54
|
+
dynamicRoutes: [],
|
|
55
|
+
authorizedStations: [],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const useUserStore = create<UserState & UserActions>()((set) => ({
|
|
59
|
+
...initialState,
|
|
60
|
+
|
|
61
|
+
setAuth: (token, refreshToken) => {
|
|
62
|
+
setTokens(token, refreshToken)
|
|
63
|
+
set({ token, refreshToken })
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
setUserInfo: (info) => {
|
|
67
|
+
localStorage.setItem(STORAGE_KEYS.USER_INFO, JSON.stringify(info))
|
|
68
|
+
set({ userInfo: info })
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
setPermissions: (permissions) => set({ permissions }),
|
|
72
|
+
|
|
73
|
+
setDynamicRoutes: (routes) => set({ dynamicRoutes: routes }),
|
|
74
|
+
|
|
75
|
+
setAuthorizedStations: (stations) => set({ authorizedStations: stations }),
|
|
76
|
+
|
|
77
|
+
logout: () => {
|
|
78
|
+
clearTokens()
|
|
79
|
+
set({ ...initialState, token: null, refreshToken: null })
|
|
80
|
+
},
|
|
81
|
+
}))
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
@import './variables.less';
|
|
2
|
+
@import './mixins.less';
|
|
3
|
+
@import './reset.less';
|
|
4
|
+
|
|
5
|
+
// 自定义字体声明
|
|
6
|
+
@font-face {
|
|
7
|
+
font-family: 'CustomFont';
|
|
8
|
+
src:
|
|
9
|
+
url('../assets/fonts/CustomFont.woff2') format('woff2'),
|
|
10
|
+
url('../assets/fonts/CustomFont.woff') format('woff');
|
|
11
|
+
font-weight: normal;
|
|
12
|
+
font-style: normal;
|
|
13
|
+
font-display: swap;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 全局基础样式
|
|
17
|
+
body {
|
|
18
|
+
font-family: @font-family;
|
|
19
|
+
font-size: @font-size-base;
|
|
20
|
+
color: @text-color;
|
|
21
|
+
background: @bg-layout;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 字体加载状态管理
|
|
25
|
+
.fonts-loading {
|
|
26
|
+
// 字体加载中使用系统字体(保持布局稳定)
|
|
27
|
+
body {
|
|
28
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 全局滚动条美化
|
|
33
|
+
body {
|
|
34
|
+
.custom-scrollbar();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 全局选中色
|
|
38
|
+
::selection {
|
|
39
|
+
background: fade(@color-primary, 20%);
|
|
40
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// 文字省略
|
|
2
|
+
.text-ellipsis() {
|
|
3
|
+
overflow: hidden;
|
|
4
|
+
text-overflow: ellipsis;
|
|
5
|
+
white-space: nowrap;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// 多行省略
|
|
9
|
+
.text-ellipsis-lines(@lines: 2) {
|
|
10
|
+
display: -webkit-box;
|
|
11
|
+
-webkit-box-orient: vertical;
|
|
12
|
+
-webkit-line-clamp: @lines;
|
|
13
|
+
overflow: hidden;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Flex 居中
|
|
17
|
+
.flex-center() {
|
|
18
|
+
display: flex;
|
|
19
|
+
align-items: center;
|
|
20
|
+
justify-content: center;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Flex 两端对齐
|
|
24
|
+
.flex-between() {
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
justify-content: space-between;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 滚动条美化
|
|
31
|
+
.custom-scrollbar() {
|
|
32
|
+
&::-webkit-scrollbar {
|
|
33
|
+
width: 6px;
|
|
34
|
+
height: 6px;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
&::-webkit-scrollbar-thumb {
|
|
38
|
+
background: rgba(0, 0, 0, 0.15);
|
|
39
|
+
border-radius: 3px;
|
|
40
|
+
|
|
41
|
+
&:hover {
|
|
42
|
+
background: rgba(0, 0, 0, 0.25);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
&::-webkit-scrollbar-track {
|
|
47
|
+
background: transparent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 卡片样式
|
|
52
|
+
.card-base() {
|
|
53
|
+
background: @bg-container;
|
|
54
|
+
border-radius: @border-radius-base;
|
|
55
|
+
box-shadow: @box-shadow-card;
|
|
56
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
*,
|
|
2
|
+
*::before,
|
|
3
|
+
*::after {
|
|
4
|
+
box-sizing: border-box;
|
|
5
|
+
margin: 0;
|
|
6
|
+
padding: 0;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
html,
|
|
10
|
+
body {
|
|
11
|
+
height: 100%;
|
|
12
|
+
-webkit-font-smoothing: antialiased;
|
|
13
|
+
-moz-osx-font-smoothing: grayscale;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
body {
|
|
17
|
+
line-height: 1.5;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
img,
|
|
21
|
+
picture,
|
|
22
|
+
video,
|
|
23
|
+
canvas,
|
|
24
|
+
svg {
|
|
25
|
+
display: block;
|
|
26
|
+
max-width: 100%;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
input,
|
|
30
|
+
button,
|
|
31
|
+
textarea,
|
|
32
|
+
select {
|
|
33
|
+
font: inherit;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
a {
|
|
37
|
+
text-decoration: none;
|
|
38
|
+
color: inherit;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
ul,
|
|
42
|
+
ol {
|
|
43
|
+
list-style: none;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
#root {
|
|
47
|
+
height: 100%;
|
|
48
|
+
isolation: isolate;
|
|
49
|
+
}
|