@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.
Files changed (75) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +55 -42
  3. package/dist/PagedTable-DyjNokXN.js +1211 -0
  4. package/dist/index.js +310 -0
  5. package/dist/ui.js +27 -0
  6. package/package.json +52 -44
  7. package/src/components/ErrorBoundary/index.jsx +24 -77
  8. package/src/components/Layout/index.jsx +141 -38
  9. package/src/components/LoginPage/index.jsx +47 -21
  10. package/src/components/StatusTag/index.jsx +14 -12
  11. package/src/components/ui/CategoryDot.jsx +38 -0
  12. package/src/components/ui/DarkBanner.jsx +142 -0
  13. package/src/components/ui/EChartsChart.jsx +82 -0
  14. package/src/components/ui/EmptyState.jsx +94 -0
  15. package/src/components/ui/ErrorState.jsx +114 -0
  16. package/src/components/ui/FeatureCard.jsx +205 -0
  17. package/src/components/ui/GradientStatCard.jsx +196 -0
  18. package/src/components/ui/LoadingState.jsx +116 -0
  19. package/src/components/ui/PageHeader.jsx +143 -0
  20. package/src/components/ui/PagedTable.jsx +83 -0
  21. package/src/components/ui/SearchInput.jsx +111 -0
  22. package/src/components/ui/SectionHeader.jsx +76 -0
  23. package/src/components/ui/StatusBadge.jsx +66 -0
  24. package/src/components/ui/TableToolbar.jsx +58 -0
  25. package/src/components/ui/index.js +36 -0
  26. package/src/components/ui/tokens.js +224 -0
  27. package/src/index.js +33 -37
  28. package/src/services/request.js +74 -4
  29. package/dist/z-frontend-common.css +0 -1
  30. package/dist/z-frontend-common.es.js +0 -51275
  31. package/dist/z-frontend-common.umd.js +0 -459
  32. package/src/components/Ctc/Layout.jsx +0 -330
  33. package/src/components/Ctc/Layout.module.css +0 -145
  34. package/src/components/Ctc/agentTeam/index.tsx +0 -308
  35. package/src/components/Ctc/login/AuthPage.module.css +0 -26
  36. package/src/components/Ctc/login/AuthPage.tsx +0 -235
  37. package/src/components/Ctc/login/index.less +0 -49
  38. package/src/components/Ctc/login/index.tsx +0 -142
  39. package/src/components/Ctc/uc/UCLogin.tsx +0 -31
  40. package/src/components/Ctc/uc/UCPhoneLogin.tsx +0 -62
  41. package/src/components/Ctc/uc/UCRegister.tsx +0 -37
  42. package/src/components/Ctc/uc/UCResetPassword.tsx +0 -69
  43. package/src/components/Ctc/uc/index.tsx +0 -15
  44. package/src/components/Ctc/userPanel/index.tsx +0 -999
  45. package/src/components/Ctc/webide/index.tsx +0 -272
  46. package/src/components/LowCode/LowCodeModel.jsx +0 -962
  47. package/src/components/LowCode/LowCodePage.jsx +0 -31
  48. package/src/components/LowCode/LowCodeRuntime.jsx +0 -335
  49. package/src/components/LowCode/MaterializePage.jsx +0 -235
  50. package/src/components/LowCode/index.js +0 -1
  51. package/src/components/MockPlatform/CurlImportModal.jsx +0 -362
  52. package/src/components/MockPlatform/EndpointsTab.jsx +0 -509
  53. package/src/components/MockPlatform/EnvironmentsTab.jsx +0 -212
  54. package/src/components/MockPlatform/MockTemplateHelper.jsx +0 -200
  55. package/src/components/MockPlatform/OpenApiImportModal.jsx +0 -305
  56. package/src/components/MockPlatform/RecordingsTab.jsx +0 -397
  57. package/src/components/MockPlatform/RequestLogsTab.jsx +0 -239
  58. package/src/components/MockPlatform/ScenariosTab.jsx +0 -236
  59. package/src/components/MockPlatform/TestCasesTab.jsx +0 -462
  60. package/src/components/MockPlatform/index.jsx +0 -127
  61. package/src/components/Overview.jsx +0 -74
  62. package/src/dev.jsx +0 -28
  63. package/src/services/agentTeam.js +0 -7
  64. package/src/services/api.js +0 -84
  65. package/src/services/ctcAc.js +0 -7
  66. package/src/services/ctcAcDomain.js +0 -7
  67. package/src/services/ctcAuthorization.js +0 -7
  68. package/src/services/ctcSurl.js +0 -7
  69. package/src/services/ctcUser.js +0 -7
  70. package/src/services/job.js +0 -7
  71. package/src/services/metaApp.js +0 -7
  72. package/src/services/privateConfig.js +0 -7
  73. package/src/services/webide.js +0 -6
  74. package/src/services/workspace.js +0 -21
  75. 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
- * @param {Object} props
11
- * @param {Array} props.menuItems - antd Menu items 格式
12
- * @param {string} props.appTitle - 应用标题
13
- * @param {string} props.appShort - 折叠时标题缩写
14
- * @param {ReactNode} props.headerExtra - 头部右侧额外内容
15
- * @param {boolean} props.loading - 是否加载中
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 default function AppLayout({
18
- menuItems = [],
19
- appTitle = 'One Company',
20
- appShort = 'OC',
21
- headerExtra,
22
- loading = false,
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
- navigate(key)
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
- {collapsed ? appShort : appTitle}
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 theme="dark" mode="inline"
47
- selectedKeys={[location.pathname]}
48
- items={menuItems}
49
- onClick={handleMenuClick}
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
- <span onClick={() => setCollapsed(!collapsed)} style={{fontSize: 18, cursor: 'pointer'}}>
59
- {collapsed ? <MenuUnfoldOutlined/> : <MenuFoldOutlined/>}
60
- </span>
61
- {headerExtra}
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 '../../utils/request'
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
- * @param {Object} props
9
- * @param {string} props.title - 页面标题
10
- * @param {string} props.loginApi - 登录 API 路径,默认 /auth/login
11
- * @param {string} props.redirectUrl - 登录成功后跳转地址,默认 /
12
- * @param {Function} props.onSuccess - 登录成功回调,覆盖 redirectUrl
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.token) {
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
- onSuccess(res)
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
- const defaultStatusMap = {
4
- 1: {color: 'green', text: '正常'},
5
- 0: {color: 'red', text: '禁用'},
6
- pending: {color: 'orange', text: '待处理'},
7
- approved: {color: 'blue', text: '已通过'},
8
- rejected: {color: 'red', text: '已拒绝'},
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
- export default function StatusTag({status, map}) {
12
- const config = map?.[status] || defaultStatusMap[status] || {color: 'default', text: String(status)}
13
- return <Tag color={config.color}>{config.text}</Tag>
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
+ }