@yuku123/z-frontend-common 0.2.9 → 1.1.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/LICENSE +25 -0
- package/README.md +55 -42
- package/dist/PagedTable-DyjNokXN.js +1211 -0
- package/dist/index.js +310 -0
- package/dist/ui.js +27 -0
- package/package.json +52 -44
- package/src/components/ErrorBoundary/index.jsx +24 -77
- package/src/components/Layout/index.jsx +141 -38
- package/src/components/LoginPage/index.jsx +47 -21
- package/src/components/StatusTag/index.jsx +14 -12
- package/src/components/ui/CategoryDot.jsx +38 -0
- package/src/components/ui/DarkBanner.jsx +142 -0
- package/src/components/ui/EChartsChart.jsx +82 -0
- package/src/components/ui/EmptyState.jsx +94 -0
- package/src/components/ui/ErrorState.jsx +114 -0
- package/src/components/ui/FeatureCard.jsx +205 -0
- package/src/components/ui/GradientStatCard.jsx +196 -0
- package/src/components/ui/LoadingState.jsx +116 -0
- package/src/components/ui/PageHeader.jsx +143 -0
- package/src/components/ui/PagedTable.jsx +83 -0
- package/src/components/ui/SearchInput.jsx +111 -0
- package/src/components/ui/SectionHeader.jsx +76 -0
- package/src/components/ui/StatusBadge.jsx +66 -0
- package/src/components/ui/TableToolbar.jsx +58 -0
- package/src/components/ui/index.js +36 -0
- package/src/components/ui/tokens.js +224 -0
- package/src/index.js +33 -37
- package/src/services/request.js +74 -4
- package/dist/z-frontend-common.css +0 -1
- package/dist/z-frontend-common.es.js +0 -51275
- package/dist/z-frontend-common.umd.js +0 -459
- package/src/components/Ctc/Layout.jsx +0 -330
- package/src/components/Ctc/Layout.module.css +0 -145
- package/src/components/Ctc/agentTeam/index.tsx +0 -308
- package/src/components/Ctc/login/AuthPage.module.css +0 -26
- package/src/components/Ctc/login/AuthPage.tsx +0 -235
- package/src/components/Ctc/login/index.less +0 -49
- package/src/components/Ctc/login/index.tsx +0 -142
- package/src/components/Ctc/uc/UCLogin.tsx +0 -31
- package/src/components/Ctc/uc/UCPhoneLogin.tsx +0 -62
- package/src/components/Ctc/uc/UCRegister.tsx +0 -37
- package/src/components/Ctc/uc/UCResetPassword.tsx +0 -69
- package/src/components/Ctc/uc/index.tsx +0 -15
- package/src/components/Ctc/userPanel/index.tsx +0 -999
- package/src/components/Ctc/webide/index.tsx +0 -272
- package/src/components/LowCode/LowCodeModel.jsx +0 -962
- package/src/components/LowCode/LowCodePage.jsx +0 -31
- package/src/components/LowCode/LowCodeRuntime.jsx +0 -335
- package/src/components/LowCode/MaterializePage.jsx +0 -235
- package/src/components/LowCode/index.js +0 -1
- package/src/components/MockPlatform/CurlImportModal.jsx +0 -362
- package/src/components/MockPlatform/EndpointsTab.jsx +0 -509
- package/src/components/MockPlatform/EnvironmentsTab.jsx +0 -212
- package/src/components/MockPlatform/MockTemplateHelper.jsx +0 -200
- package/src/components/MockPlatform/OpenApiImportModal.jsx +0 -305
- package/src/components/MockPlatform/RecordingsTab.jsx +0 -397
- package/src/components/MockPlatform/RequestLogsTab.jsx +0 -239
- package/src/components/MockPlatform/ScenariosTab.jsx +0 -236
- package/src/components/MockPlatform/TestCasesTab.jsx +0 -462
- package/src/components/MockPlatform/index.jsx +0 -127
- package/src/components/Overview.jsx +0 -74
- package/src/dev.jsx +0 -28
- package/src/services/agentTeam.js +0 -7
- package/src/services/api.js +0 -84
- package/src/services/ctcAc.js +0 -7
- package/src/services/ctcAcDomain.js +0 -7
- package/src/services/ctcAuthorization.js +0 -7
- package/src/services/ctcSurl.js +0 -7
- package/src/services/ctcUser.js +0 -7
- package/src/services/job.js +0 -7
- package/src/services/metaApp.js +0 -7
- package/src/services/privateConfig.js +0 -7
- package/src/services/webide.js +0 -6
- package/src/services/workspace.js +0 -21
- package/src/utils/request.ts +0 -102
|
@@ -1,69 +1,172 @@
|
|
|
1
|
-
import {Layout, Menu, Spin} from 'antd'
|
|
2
|
-
import {MenuFoldOutlined, MenuUnfoldOutlined} from '@ant-design/icons'
|
|
3
|
-
import {Outlet, useLocation, useNavigate} from 'react-router-dom'
|
|
4
|
-
import {useState} from 'react'
|
|
1
|
+
import { Avatar, Button, Dropdown, Layout, Menu, Spin, Tooltip } from 'antd'
|
|
2
|
+
import { DownOutlined, LoginOutlined, MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons'
|
|
3
|
+
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
|
4
|
+
import { useState } from 'react'
|
|
5
5
|
|
|
6
|
-
const {Header, Sider, Content} = Layout
|
|
6
|
+
const { Header, Sider, Content } = Layout
|
|
7
|
+
|
|
8
|
+
function shade(hex, amount = 0.85) {
|
|
9
|
+
const m = /^#?([0-9a-f]{6})$/i.exec(hex || '')
|
|
10
|
+
if (!m) return hex
|
|
11
|
+
const n = parseInt(m[1], 16)
|
|
12
|
+
const r = Math.max(0, Math.min(255, Math.round(((n >> 16) & 0xff) * amount)))
|
|
13
|
+
const g = Math.max(0, Math.min(255, Math.round(((n >> 8) & 0xff) * amount)))
|
|
14
|
+
const b = Math.max(0, Math.min(255, Math.round((n & 0xff) * amount)))
|
|
15
|
+
return '#' + ((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')
|
|
16
|
+
}
|
|
7
17
|
|
|
8
18
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
19
|
+
* 通用左菜单架子 AppLayout — lead 008 §1/§11/§12/§15/§16 全 46 仓 suit 强制落地。
|
|
20
|
+
*
|
|
21
|
+
* props:
|
|
22
|
+
* - menuItems / appTitle / appShort / appIcon / headerExtra / loading: 同 z-vector-component 原版
|
|
23
|
+
* - appVersion?: string — sider 展开态展示「z-vector@1.0.5」格式
|
|
24
|
+
* - appUser?: { name, role } — Header 右上角 avatar+name+下拉(profile/退出登录)
|
|
25
|
+
* - loginPath?: string — 退出登录跳转目标,缺省按 menuItems[0].key 自动推导(/home → /login)
|
|
16
26
|
*/
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
27
|
+
export function AppLayout({
|
|
28
|
+
menuItems = [],
|
|
29
|
+
appTitle = 'One Company',
|
|
30
|
+
appShort = 'OC',
|
|
31
|
+
appVersion,
|
|
32
|
+
appIcon,
|
|
33
|
+
appUser,
|
|
34
|
+
loginPath,
|
|
35
|
+
headerExtra,
|
|
36
|
+
loading = false,
|
|
37
|
+
}) {
|
|
24
38
|
const [collapsed, setCollapsed] = useState(false)
|
|
25
39
|
const navigate = useNavigate()
|
|
26
40
|
const location = useLocation()
|
|
27
41
|
|
|
28
|
-
const handleMenuClick = ({key}) => {
|
|
29
|
-
|
|
42
|
+
const handleMenuClick = ({ key }) => { navigate(key) }
|
|
43
|
+
|
|
44
|
+
// 退出登录跳转:loginPath 显式传优先;否则按 menuItems[0].key 自动推导 /<产品>/home → /<产品>/login
|
|
45
|
+
const computeLoginPath = () => {
|
|
46
|
+
if (loginPath) return loginPath
|
|
47
|
+
const first = menuItems && menuItems[0]
|
|
48
|
+
const k = first && first.key
|
|
49
|
+
if (typeof k === 'string' && k.endsWith('/home')) {
|
|
50
|
+
return k.slice(0, -4) + 'login'
|
|
51
|
+
}
|
|
52
|
+
return '/login'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// URL 即状态(lead 008 §11):用 location.pathname 的最长菜单前缀作 selectedKey
|
|
56
|
+
const collectKeys = (items, acc = []) => {
|
|
57
|
+
for (const it of items || []) {
|
|
58
|
+
if (it?.key) acc.push(it.key)
|
|
59
|
+
if (it?.children) collectKeys(it.children, acc)
|
|
60
|
+
}
|
|
61
|
+
return acc
|
|
62
|
+
}
|
|
63
|
+
const selectedKey = (() => {
|
|
64
|
+
const keys = collectKeys(menuItems)
|
|
65
|
+
const p = location.pathname
|
|
66
|
+
return keys.filter((k) => p === k || p.startsWith(k + '/'))
|
|
67
|
+
.sort((a, b) => b.length - a.length)[0] || p
|
|
68
|
+
})()
|
|
69
|
+
|
|
70
|
+
const renderBrand = () => {
|
|
71
|
+
if (collapsed) {
|
|
72
|
+
if (appIcon) {
|
|
73
|
+
const { icon, color = '#7c3aed', label = appTitle } = appIcon
|
|
74
|
+
const node = (
|
|
75
|
+
<div style={{
|
|
76
|
+
width: 36, height: 36, borderRadius: 9,
|
|
77
|
+
background: `linear-gradient(135deg, ${color}, ${shade(color, 0.7)})`,
|
|
78
|
+
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
79
|
+
color: '#fff', fontSize: 20, boxShadow: '0 2px 6px rgba(0,0,0,0.25)',
|
|
80
|
+
overflow: 'hidden',
|
|
81
|
+
}}>{icon}</div>
|
|
82
|
+
)
|
|
83
|
+
return <Tooltip title={label} placement="right">{node}</Tooltip>
|
|
84
|
+
}
|
|
85
|
+
return appShort
|
|
86
|
+
}
|
|
87
|
+
return (
|
|
88
|
+
<span style={{ color: '#fff', fontWeight: 600, fontSize: 16, whiteSpace: 'nowrap' }}>
|
|
89
|
+
{appTitle}
|
|
90
|
+
{appVersion && (
|
|
91
|
+
<span style={{
|
|
92
|
+
color: 'rgba(255,255,255,0.55)', fontWeight: 400, fontSize: 12, marginLeft: 4,
|
|
93
|
+
}}>@{appVersion}</span>
|
|
94
|
+
)}
|
|
95
|
+
</span>
|
|
96
|
+
)
|
|
30
97
|
}
|
|
31
98
|
|
|
32
99
|
return (
|
|
33
|
-
<Layout style={{minHeight: '100vh'}}>
|
|
100
|
+
<Layout style={{ minHeight: '100vh' }}>
|
|
34
101
|
<Sider trigger={null} collapsible collapsed={collapsed}>
|
|
35
102
|
<div style={{
|
|
36
103
|
height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
37
|
-
color: 'white', fontSize: collapsed ? 14 : 18, fontWeight: 'bold',
|
|
38
104
|
}}>
|
|
39
|
-
{
|
|
105
|
+
{renderBrand()}
|
|
40
106
|
</div>
|
|
41
107
|
{loading ? (
|
|
42
|
-
<div style={{padding: 16, textAlign: 'center'}}>
|
|
43
|
-
<Spin size="small"/>
|
|
44
|
-
</div>
|
|
108
|
+
<div style={{ padding: 16, textAlign: 'center' }}><Spin size="small" /></div>
|
|
45
109
|
) : (
|
|
46
|
-
<Menu
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
110
|
+
<Menu
|
|
111
|
+
theme="dark"
|
|
112
|
+
mode="inline"
|
|
113
|
+
selectedKeys={[selectedKey]}
|
|
114
|
+
items={menuItems}
|
|
115
|
+
onClick={handleMenuClick}
|
|
50
116
|
/>
|
|
51
117
|
)}
|
|
52
118
|
</Sider>
|
|
53
119
|
<Layout>
|
|
54
120
|
<Header style={{
|
|
55
|
-
padding: '0 16px', background: '#fff',
|
|
121
|
+
height: 48, lineHeight: '48px', padding: '0 16px', background: '#fff',
|
|
56
122
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
123
|
+
borderBottom: '1px solid #f1f5f9',
|
|
57
124
|
}}>
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
{
|
|
125
|
+
<span onClick={() => setCollapsed(!collapsed)} style={{ fontSize: 18, cursor: 'pointer' }}>
|
|
126
|
+
{collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
127
|
+
</span>
|
|
128
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
129
|
+
{appUser && (
|
|
130
|
+
<Dropdown
|
|
131
|
+
menu={{
|
|
132
|
+
items: [
|
|
133
|
+
{ key: 'profile', label: `${appUser.name}(${appUser.role || '用户'})`, disabled: true },
|
|
134
|
+
{ type: 'divider' },
|
|
135
|
+
{
|
|
136
|
+
key: 'logout', label: '退出登录', onClick: () => {
|
|
137
|
+
localStorage.removeItem('token')
|
|
138
|
+
localStorage.removeItem('userInfo')
|
|
139
|
+
window.location.href = computeLoginPath()
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
}}
|
|
144
|
+
placement="bottomRight"
|
|
145
|
+
>
|
|
146
|
+
<span style={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
|
147
|
+
<Avatar size={28} style={{ background: '#7c3aed' }}>
|
|
148
|
+
{(appUser.name || '?').slice(0, 1).toUpperCase()}
|
|
149
|
+
</Avatar>
|
|
150
|
+
<span style={{ fontSize: 13, color: '#0f172a' }}>{appUser.name}</span>
|
|
151
|
+
<DownOutlined style={{ fontSize: 10, color: '#94a3b8' }} />
|
|
152
|
+
</span>
|
|
153
|
+
</Dropdown>
|
|
154
|
+
)}
|
|
155
|
+
{!appUser && (
|
|
156
|
+
<Button type="primary" size="small" icon={<LoginOutlined />}
|
|
157
|
+
onClick={() => { window.location.href = computeLoginPath() }}>
|
|
158
|
+
登录
|
|
159
|
+
</Button>
|
|
160
|
+
)}
|
|
161
|
+
{headerExtra}
|
|
162
|
+
</div>
|
|
62
163
|
</Header>
|
|
63
|
-
<Content style={{margin: 16, padding: 24, background: '#fff', minHeight: 280}}>
|
|
64
|
-
<Outlet/>
|
|
164
|
+
<Content style={{ margin: 16, padding: 24, background: '#fff', minHeight: 280 }}>
|
|
165
|
+
<Outlet />
|
|
65
166
|
</Content>
|
|
66
167
|
</Layout>
|
|
67
168
|
</Layout>
|
|
68
169
|
)
|
|
69
170
|
}
|
|
171
|
+
|
|
172
|
+
export default AppLayout
|
|
@@ -1,37 +1,57 @@
|
|
|
1
|
-
import {useState} from 'react'
|
|
2
|
-
import {Button, Card, Form, Input, message} from 'antd'
|
|
3
|
-
import {LockOutlined, UserOutlined} from '@ant-design/icons'
|
|
4
|
-
import request from '../../
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { Alert, Button, Card, Form, Input, message } from 'antd'
|
|
3
|
+
import { LockOutlined, UserOutlined } from '@ant-design/icons'
|
|
4
|
+
import { request } from '../../services/request'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
7
|
+
* 统一登录页(lead 008 §16)—— mockAuth / 真实 API 双模式:
|
|
8
|
+
* <ul>
|
|
9
|
+
* <li>{@code mockAuth} 传入 → 纯前端校验(admin/123456 硬编码顶替,CTC/SSO 接入前短期形态),
|
|
10
|
+
* 登录成功写 localStorage.token + userInfo 并跳 redirectUrl</li>
|
|
11
|
+
* <li>{@code mockAuth} 缺省 → POST {@code loginApi}(CTC 中央认证),后端签发 token</li>
|
|
12
|
+
* </ul>
|
|
13
|
+
*
|
|
14
|
+
* @param {string} [title='统一管理平台'] 卡片标题
|
|
15
|
+
* @param {Object} [mockAuth] {username, password, user:{name, role}} 本地 mock 校验配置
|
|
16
|
+
* @param {string} [loginApi='/auth/login'] 真实登录端点(mockAuth 缺省时生效)
|
|
17
|
+
* @param {string} [redirectUrl='/'] 登录成功跳转
|
|
18
|
+
* @param {Function} [onSuccess] 登录成功回调(覆盖 redirectUrl 跳转)
|
|
13
19
|
*/
|
|
14
20
|
export default function LoginPage({
|
|
15
21
|
title = '统一管理平台',
|
|
22
|
+
mockAuth,
|
|
16
23
|
loginApi = '/auth/login',
|
|
17
24
|
redirectUrl = '/',
|
|
18
25
|
onSuccess,
|
|
19
26
|
}) {
|
|
20
27
|
const [loading, setLoading] = useState(false)
|
|
21
28
|
|
|
29
|
+
const persistAndRedirect = (user) => {
|
|
30
|
+
localStorage.setItem('token', mockAuth ? 'mock-' + mockAuth.username : user?.token || 'token')
|
|
31
|
+
localStorage.setItem('userInfo', JSON.stringify(user || {}))
|
|
32
|
+
message.success('登录成功' + (mockAuth ? '(mock 模式,待 CTC/SSO 接入)' : ''))
|
|
33
|
+
if (onSuccess) onSuccess(user)
|
|
34
|
+
else window.location.href = redirectUrl
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
const handleSubmit = async (values) => {
|
|
23
38
|
setLoading(true)
|
|
24
39
|
try {
|
|
40
|
+
if (mockAuth) {
|
|
41
|
+
if (values.username === mockAuth.username && values.password === mockAuth.password) {
|
|
42
|
+
persistAndRedirect(mockAuth.user)
|
|
43
|
+
} else {
|
|
44
|
+
message.error('用户名或密码错误')
|
|
45
|
+
}
|
|
46
|
+
return
|
|
47
|
+
}
|
|
25
48
|
const res = await request.post(loginApi, values)
|
|
26
|
-
if (res
|
|
49
|
+
if (res?.token) {
|
|
27
50
|
localStorage.setItem('token', res.token)
|
|
28
51
|
localStorage.setItem('userInfo', JSON.stringify(res.user || res))
|
|
29
52
|
message.success('登录成功')
|
|
30
|
-
if (onSuccess)
|
|
31
|
-
|
|
32
|
-
} else {
|
|
33
|
-
window.location.href = redirectUrl
|
|
34
|
-
}
|
|
53
|
+
if (onSuccess) onSuccess(res)
|
|
54
|
+
else window.location.href = redirectUrl
|
|
35
55
|
} else {
|
|
36
56
|
message.error('登录失败:未获取到 token')
|
|
37
57
|
}
|
|
@@ -47,13 +67,19 @@ export default function LoginPage({
|
|
|
47
67
|
height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center',
|
|
48
68
|
background: '#f0f2f5',
|
|
49
69
|
}}>
|
|
50
|
-
<Card title={title} style={{width: 400}}>
|
|
70
|
+
<Card title={title} style={{ width: 400 }}>
|
|
71
|
+
{mockAuth && (
|
|
72
|
+
<Alert
|
|
73
|
+
type="info" showIcon style={{ marginBottom: 16 }}
|
|
74
|
+
message={`Mock 模式:用户名 ${mockAuth.username} / 密码 ${mockAuth.password}`}
|
|
75
|
+
/>
|
|
76
|
+
)}
|
|
51
77
|
<Form onFinish={handleSubmit} size="large">
|
|
52
|
-
<Form.Item name="username" rules={[{required: true, message: '请输入用户名'}]}>
|
|
53
|
-
<Input prefix={<UserOutlined/>} placeholder="用户名"/>
|
|
78
|
+
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
|
|
79
|
+
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
|
54
80
|
</Form.Item>
|
|
55
|
-
<Form.Item name="password" rules={[{required: true, message: '请输入密码'}]}>
|
|
56
|
-
<Input.Password prefix={<LockOutlined/>} placeholder="密码"/>
|
|
81
|
+
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
|
|
82
|
+
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
|
57
83
|
</Form.Item>
|
|
58
84
|
<Form.Item>
|
|
59
85
|
<Button type="primary" htmlType="submit" loading={loading} block>
|
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import {Tag} from 'antd'
|
|
1
|
+
import { Tag } from 'antd'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
/** 0.1.1 契约保留:数值/枚举 → 语义色 Tag 的默认映射。 */
|
|
4
|
+
export const defaultStatusMap = {
|
|
5
|
+
1: { color: 'green', text: '正常' },
|
|
6
|
+
0: { color: 'red', text: '禁用' },
|
|
7
|
+
pending: { color: 'orange', text: '待处理' },
|
|
8
|
+
approved: { color: 'blue', text: '已通过' },
|
|
9
|
+
rejected: { color: 'red', text: '已拒绝' },
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* 状态标签。props 与 0.1.1 一致:status + 可选 map(覆盖/追加 defaultStatusMap)。
|
|
14
|
+
*/
|
|
15
|
+
export default function StatusTag({ status, map }) {
|
|
16
|
+
const conf = map?.[status] || defaultStatusMap[status] || { color: 'default', text: String(status) }
|
|
17
|
+
return <Tag color={conf.color}>{conf.text}</Tag>
|
|
14
18
|
}
|
|
15
|
-
|
|
16
|
-
export {defaultStatusMap}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {palette} from './tokens'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 分类圆点 - 借鉴 wenming7 分类标识
|
|
5
|
+
*
|
|
6
|
+
* 用法:
|
|
7
|
+
* <CategoryDot color="pink" /> // 仅圆点
|
|
8
|
+
* <CategoryDot color="blue" label="对话" /> // 圆点 + 文字
|
|
9
|
+
* <CategoryDot color="green" size={10} /> // 自定义大小
|
|
10
|
+
*
|
|
11
|
+
* @param {Object} props
|
|
12
|
+
* @param {string} props.color - 配色 (pink|blue|green|orange|violet|cyan|red|yellow)
|
|
13
|
+
* @param {string} [props.label] - 可选标签文字
|
|
14
|
+
* @param {number} [props.size] - 圆点直径 (默认 8)
|
|
15
|
+
*/
|
|
16
|
+
export default function CategoryDot({color = 'blue', label, size = 8}) {
|
|
17
|
+
const c = palette[color] || palette.blue
|
|
18
|
+
return (
|
|
19
|
+
<span style={{
|
|
20
|
+
display: 'inline-flex',
|
|
21
|
+
alignItems: 'center',
|
|
22
|
+
gap: 8,
|
|
23
|
+
fontSize: 14,
|
|
24
|
+
fontWeight: 600,
|
|
25
|
+
color: '#111827',
|
|
26
|
+
}}>
|
|
27
|
+
<span style={{
|
|
28
|
+
width: size,
|
|
29
|
+
height: size,
|
|
30
|
+
borderRadius: '50%',
|
|
31
|
+
background: c.bg,
|
|
32
|
+
boxShadow: `0 0 0 3px ${c.soft}`,
|
|
33
|
+
flexShrink: 0,
|
|
34
|
+
}}/>
|
|
35
|
+
{label && <span>{label}</span>}
|
|
36
|
+
</span>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {radius, shadow} from './tokens'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 深色 CTA 横幅 - 借鉴 wenming7 "FOR DEVELOPERS" 区
|
|
5
|
+
*
|
|
6
|
+
* 视觉:
|
|
7
|
+
* ┌──────────────────────────────────────────────┐
|
|
8
|
+
* │ [标签] │
|
|
9
|
+
* │ 把你的想法做成产品 │ ← 深色渐变背景
|
|
10
|
+
* │ 副标题/描述文案 │
|
|
11
|
+
* │ [行动按钮 →] │
|
|
12
|
+
* └──────────────────────────────────────────────┘
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} props
|
|
15
|
+
* @param {string} [props.tag] - 顶部小标签 (如 "FOR DEVELOPERS")
|
|
16
|
+
* @param {string} props.title - 主标题
|
|
17
|
+
* @param {string} [props.description] - 描述
|
|
18
|
+
* @param {string} [props.buttonText] - 按钮文案
|
|
19
|
+
* @param {string} [props.buttonHref] - 跳转路径
|
|
20
|
+
*/
|
|
21
|
+
export default function DarkBanner({
|
|
22
|
+
tag,
|
|
23
|
+
title,
|
|
24
|
+
description,
|
|
25
|
+
buttonText,
|
|
26
|
+
buttonHref,
|
|
27
|
+
onButtonClick,
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<div style={{
|
|
31
|
+
position: 'relative',
|
|
32
|
+
borderRadius: radius.lg,
|
|
33
|
+
padding: '40px 48px',
|
|
34
|
+
background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #312e81 100%)',
|
|
35
|
+
color: '#ffffff',
|
|
36
|
+
overflow: 'hidden',
|
|
37
|
+
boxShadow: shadow.lg,
|
|
38
|
+
}}>
|
|
39
|
+
{/* 装饰光晕 */}
|
|
40
|
+
<div style={{
|
|
41
|
+
position: 'absolute',
|
|
42
|
+
top: -64,
|
|
43
|
+
right: -64,
|
|
44
|
+
width: 200,
|
|
45
|
+
height: 200,
|
|
46
|
+
borderRadius: '50%',
|
|
47
|
+
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, transparent 70%)',
|
|
48
|
+
pointerEvents: 'none',
|
|
49
|
+
}}/>
|
|
50
|
+
<div style={{
|
|
51
|
+
position: 'absolute',
|
|
52
|
+
bottom: -48,
|
|
53
|
+
left: '30%',
|
|
54
|
+
width: 160,
|
|
55
|
+
height: 160,
|
|
56
|
+
borderRadius: '50%',
|
|
57
|
+
background: 'radial-gradient(circle, rgba(59, 130, 246, 0.3) 0%, transparent 70%)',
|
|
58
|
+
pointerEvents: 'none',
|
|
59
|
+
}}/>
|
|
60
|
+
|
|
61
|
+
<div style={{
|
|
62
|
+
position: 'relative',
|
|
63
|
+
display: 'flex',
|
|
64
|
+
alignItems: 'center',
|
|
65
|
+
justifyContent: 'space-between',
|
|
66
|
+
gap: 32,
|
|
67
|
+
flexWrap: 'wrap',
|
|
68
|
+
}}>
|
|
69
|
+
<div style={{flex: 1, minWidth: 240}}>
|
|
70
|
+
{tag && (
|
|
71
|
+
<div style={{
|
|
72
|
+
fontSize: 11,
|
|
73
|
+
fontWeight: 600,
|
|
74
|
+
letterSpacing: '0.12em',
|
|
75
|
+
color: '#a5b4fc',
|
|
76
|
+
textTransform: 'uppercase',
|
|
77
|
+
marginBottom: 12,
|
|
78
|
+
}}>
|
|
79
|
+
{tag}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
<div style={{
|
|
83
|
+
fontSize: 24,
|
|
84
|
+
fontWeight: 700,
|
|
85
|
+
lineHeight: 1.3,
|
|
86
|
+
marginBottom: 8,
|
|
87
|
+
}}>
|
|
88
|
+
{title}
|
|
89
|
+
</div>
|
|
90
|
+
{description && (
|
|
91
|
+
<div style={{
|
|
92
|
+
fontSize: 14,
|
|
93
|
+
color: 'rgba(255, 255, 255, 0.7)',
|
|
94
|
+
lineHeight: 1.6,
|
|
95
|
+
maxWidth: 540,
|
|
96
|
+
}}>
|
|
97
|
+
{description}
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{(buttonText && (buttonHref || onButtonClick)) && (
|
|
103
|
+
<a
|
|
104
|
+
href={buttonHref || '#'}
|
|
105
|
+
onClick={(e) => {
|
|
106
|
+
if (onButtonClick) {
|
|
107
|
+
e.preventDefault()
|
|
108
|
+
onButtonClick()
|
|
109
|
+
}
|
|
110
|
+
}}
|
|
111
|
+
style={{
|
|
112
|
+
display: 'inline-flex',
|
|
113
|
+
alignItems: 'center',
|
|
114
|
+
gap: 8,
|
|
115
|
+
padding: '12px 24px',
|
|
116
|
+
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
|
117
|
+
color: '#ffffff',
|
|
118
|
+
borderRadius: 999,
|
|
119
|
+
fontSize: 14,
|
|
120
|
+
fontWeight: 600,
|
|
121
|
+
textDecoration: 'none',
|
|
122
|
+
boxShadow: '0 4px 12px rgba(16, 185, 129, 0.4)',
|
|
123
|
+
transition: 'transform 150ms ease, box-shadow 150ms ease',
|
|
124
|
+
whiteSpace: 'nowrap',
|
|
125
|
+
}}
|
|
126
|
+
onMouseEnter={(e) => {
|
|
127
|
+
e.currentTarget.style.transform = 'translateY(-1px)'
|
|
128
|
+
e.currentTarget.style.boxShadow = '0 6px 16px rgba(16, 185, 129, 0.5)'
|
|
129
|
+
}}
|
|
130
|
+
onMouseLeave={(e) => {
|
|
131
|
+
e.currentTarget.style.transform = 'translateY(0)'
|
|
132
|
+
e.currentTarget.style.boxShadow = '0 4px 12px rgba(16, 185, 129, 0.4)'
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
{buttonText}
|
|
136
|
+
<span>→</span>
|
|
137
|
+
</a>
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {useEffect, useRef} from 'react'
|
|
2
|
+
import * as echarts from 'echarts/core'
|
|
3
|
+
import {BarChart, LineChart, PieChart} from 'echarts/charts'
|
|
4
|
+
import {GridComponent, LegendComponent, TitleComponent, TooltipComponent,} from 'echarts/components'
|
|
5
|
+
import {CanvasRenderer} from 'echarts/renderers'
|
|
6
|
+
|
|
7
|
+
// 只注册常用图表, 减小 bundle
|
|
8
|
+
echarts.use([
|
|
9
|
+
LineChart, PieChart, BarChart,
|
|
10
|
+
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
|
|
11
|
+
CanvasRenderer,
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* EChartsChart - 安全包装 echarts 实例, 自动处理初始化/销毁/resize.
|
|
16
|
+
*
|
|
17
|
+
* 修复 z-opc 后台原 Schedule Dashboard 的交互问题:
|
|
18
|
+
* 1. 内存泄漏: 切换路由时 echarts 实例没 dispose, 多次进入页面会重复绑定 DOM
|
|
19
|
+
* 2. resize 监听器没 cleanup, 每次进入页面都新增一个
|
|
20
|
+
* 3. 颜色硬编码, 主题不一致
|
|
21
|
+
*
|
|
22
|
+
* 用法:
|
|
23
|
+
* <EChartsChart option={...} height={300} />
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} props
|
|
26
|
+
* @param {Object} props.option - echarts option
|
|
27
|
+
* @param {number|string} [props.height] - 高度 (默认 300)
|
|
28
|
+
* @param {string} [props.className]
|
|
29
|
+
* @param {Object} [props.style]
|
|
30
|
+
*/
|
|
31
|
+
export default function EChartsChart({
|
|
32
|
+
option,
|
|
33
|
+
height = 300,
|
|
34
|
+
className,
|
|
35
|
+
style,
|
|
36
|
+
}) {
|
|
37
|
+
const containerRef = useRef(null)
|
|
38
|
+
const instanceRef = useRef(null)
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!containerRef.current) return
|
|
42
|
+
instanceRef.current = echarts.init(containerRef.current, null, {renderer: 'canvas'})
|
|
43
|
+
|
|
44
|
+
let resizeObserver
|
|
45
|
+
const onResize = () => instanceRef.current?.resize()
|
|
46
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
47
|
+
resizeObserver = new ResizeObserver(onResize)
|
|
48
|
+
resizeObserver.observe(containerRef.current)
|
|
49
|
+
} else {
|
|
50
|
+
window.addEventListener('resize', onResize)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return () => {
|
|
54
|
+
if (resizeObserver) resizeObserver.disconnect()
|
|
55
|
+
else window.removeEventListener('resize', onResize)
|
|
56
|
+
if (instanceRef.current) {
|
|
57
|
+
instanceRef.current.dispose()
|
|
58
|
+
instanceRef.current = null
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}, [])
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (instanceRef.current && option) {
|
|
65
|
+
instanceRef.current.setOption(option, {notMerge: true})
|
|
66
|
+
}
|
|
67
|
+
}, [option])
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
ref={containerRef}
|
|
72
|
+
className={className}
|
|
73
|
+
role="img"
|
|
74
|
+
aria-label="数据可视化图表"
|
|
75
|
+
style={{
|
|
76
|
+
width: '100%',
|
|
77
|
+
height,
|
|
78
|
+
...style,
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
}
|