@autional/ui 0.0.0-stage → 0.1.0-rc.43
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 +661 -0
- package/package.json +88 -3
- package/src/antd/AntdThemeProvider.tsx +80 -0
- package/src/antd/Breadcrumb.tsx +118 -0
- package/src/antd/DataTable.tsx +82 -0
- package/src/antd/DateRangeFilter.tsx +64 -0
- package/src/antd/Drawer.tsx +39 -0
- package/src/antd/Modal.tsx +38 -0
- package/src/antd/PageStatus.tsx +68 -0
- package/src/antd/index.ts +27 -0
- package/src/atoms/Button.tsx +69 -0
- package/src/atoms/Input.tsx +19 -0
- package/src/atoms/Label.tsx +18 -0
- package/src/atoms/StatusBadge.tsx +27 -0
- package/src/atoms/Toggle.tsx +43 -0
- package/src/atoms/index.ts +9 -0
- package/src/context/ThemeProvider.tsx +78 -0
- package/src/i18n/index.ts +36 -0
- package/src/i18n/resources.ts +56 -0
- package/src/index.ts +7 -0
- package/src/internal/field-styles.ts +14 -0
- package/src/internal/semantic-styles.ts +28 -0
- package/src/internal/use-menu-button.ts +144 -0
- package/src/molecules/Alert.tsx +100 -0
- package/src/molecules/AppShell.tsx +122 -0
- package/src/molecules/CodeBlock.tsx +55 -0
- package/src/molecules/ConfirmDialog.tsx +101 -0
- package/src/molecules/ConsolePageHeader.tsx +45 -0
- package/src/molecules/EmptyState.tsx +24 -0
- package/src/molecules/ErrorBoundary.tsx +142 -0
- package/src/molecules/ErrorState.tsx +52 -0
- package/src/molecules/FormField.tsx +89 -0
- package/src/molecules/LanguageSwitcher.tsx +39 -0
- package/src/molecules/LoadingScreen.tsx +30 -0
- package/src/molecules/Modal.tsx +178 -0
- package/src/molecules/PageContainer.tsx +35 -0
- package/src/molecules/PageHeader.tsx +27 -0
- package/src/molecules/PortalSwitcher.tsx +132 -0
- package/src/molecules/Result.tsx +83 -0
- package/src/molecules/SectionCard.tsx +45 -0
- package/src/molecules/ThemeToggle.tsx +42 -0
- package/src/molecules/Toast.tsx +106 -0
- package/src/molecules/UserMenu.tsx +168 -0
- package/src/molecules/index.ts +31 -0
- package/src/rhf/FormInput.tsx +67 -0
- package/src/rhf/FormSelect.tsx +43 -0
- package/src/rhf/FormTextarea.tsx +42 -0
- package/src/rhf/index.ts +18 -0
- package/src/rhf/shared.tsx +63 -0
- package/src/styles/globals.css +19 -0
- package/src/test-setup.ts +37 -0
- package/README.md +0 -3
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Loader2 } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
export interface LoadingScreenProps {
|
|
4
|
+
message?: string;
|
|
5
|
+
fullScreen?: boolean;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function LoadingScreen({
|
|
10
|
+
message = '加载中...',
|
|
11
|
+
fullScreen = false,
|
|
12
|
+
className = '',
|
|
13
|
+
}: LoadingScreenProps) {
|
|
14
|
+
const content = (
|
|
15
|
+
<div className="flex flex-col items-center justify-center gap-3">
|
|
16
|
+
<Loader2 size={fullScreen ? 32 : 24} className="animate-spin text-[var(--color-brand)]" />
|
|
17
|
+
<p className="text-sm text-[var(--color-text-secondary)]">{message}</p>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
if (fullScreen) {
|
|
22
|
+
return (
|
|
23
|
+
<div className={`flex min-h-screen items-center justify-center px-4 ${className}`}>
|
|
24
|
+
{content}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return <div className={`flex h-40 items-center justify-center ${className}`}>{content}</div>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useId, useRef } from 'react';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import { useTranslation } from 'react-i18next';
|
|
4
|
+
import { UI_I18N_NS, uiText } from '../i18n';
|
|
5
|
+
|
|
6
|
+
// UP-19:dialog 语义与焦点管理此前整体缺失(裸 div 覆盖层 —— 屏幕阅读器读不到
|
|
7
|
+
// 「这是一个对话框」,Tab 能溜到背后的页面,关闭后焦点丢到 body)。
|
|
8
|
+
// 列在这里的只含可聚焦元素;弹窗内元素在渲染时必然可见,无需再做可见性过滤
|
|
9
|
+
// (jsdom 算不出布局,加了过滤器反而把测试和真实浏览器行为拉开差距)。
|
|
10
|
+
const FOCUSABLE_SELECTOR =
|
|
11
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
12
|
+
|
|
13
|
+
function getFocusableElements(root: HTMLElement): HTMLElement[] {
|
|
14
|
+
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface ModalProps {
|
|
18
|
+
open: boolean;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
title?: string;
|
|
21
|
+
/** 标题下的一行说明。多个页面原本把它写在自绘表头里(如「授权同意」弹窗),收进 API 才不用各自拼表头。 */
|
|
22
|
+
description?: string;
|
|
23
|
+
/** 正文。**可以不给** —— 「标题 + 说明 + 按钮」的确认框是常见形状,不该被逼着塞一个空正文(那会留一段 32px 的空白)。 */
|
|
24
|
+
children?: React.ReactNode;
|
|
25
|
+
footer?: React.ReactNode;
|
|
26
|
+
maxWidth?: 'sm' | 'md' | 'lg';
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const maxWidthClasses: Record<string, string> = {
|
|
31
|
+
sm: 'max-w-sm',
|
|
32
|
+
md: 'max-w-md',
|
|
33
|
+
lg: 'max-w-lg',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const Modal = React.memo(function Modal({
|
|
37
|
+
open,
|
|
38
|
+
onClose,
|
|
39
|
+
title,
|
|
40
|
+
description,
|
|
41
|
+
children,
|
|
42
|
+
footer,
|
|
43
|
+
maxWidth = 'md',
|
|
44
|
+
className = '',
|
|
45
|
+
}: ModalProps) {
|
|
46
|
+
const { t, i18n } = useTranslation();
|
|
47
|
+
// 关闭按钮的无障碍标签此前是写死的中文 —— 与 PortalSwitcher / PageStatus 走同一条解析链:
|
|
48
|
+
// 站点注册过就用站点的,没注册回落内置表。
|
|
49
|
+
const closeLabel = t('modal.close', {
|
|
50
|
+
ns: UI_I18N_NS,
|
|
51
|
+
defaultValue: uiText(i18n.language, 'modal.close') ?? '关闭',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
55
|
+
const previouslyFocused = useRef<HTMLElement | null>(null);
|
|
56
|
+
const wasOpen = useRef(false);
|
|
57
|
+
const dialogId = useId();
|
|
58
|
+
const titleId = `${dialogId}-title`;
|
|
59
|
+
const descriptionId = `${dialogId}-description`;
|
|
60
|
+
|
|
61
|
+
// 打开的那次渲染里记下「开弹窗前的焦点」。必须赶在提交之前 —— 消费方若在弹窗里标了
|
|
62
|
+
// autoFocus,React 会在提交期就把焦点送进输入框,效果函数里再读就晚了(读到的是输入框自己,
|
|
63
|
+
// 我们就把「从哪来」弄丢了,关闭时也无处归还)。读 activeElement 无副作用,重复渲染幂等。
|
|
64
|
+
if (open && !wasOpen.current) {
|
|
65
|
+
previouslyFocused.current = (document.activeElement as HTMLElement | null) ?? null;
|
|
66
|
+
}
|
|
67
|
+
wasOpen.current = open;
|
|
68
|
+
|
|
69
|
+
const handleKeyDown = useCallback(
|
|
70
|
+
(e: KeyboardEvent) => {
|
|
71
|
+
if (e.key === 'Escape') {
|
|
72
|
+
onClose();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (e.key !== 'Tab') return;
|
|
76
|
+
const panel = panelRef.current;
|
|
77
|
+
if (!panel) return;
|
|
78
|
+
const focusable = getFocusableElements(panel);
|
|
79
|
+
if (focusable.length === 0) {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
panel.focus();
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const first = focusable[0];
|
|
85
|
+
const last = focusable[focusable.length - 1];
|
|
86
|
+
const active = document.activeElement as HTMLElement | null;
|
|
87
|
+
const inside = active != null && panel.contains(active);
|
|
88
|
+
if (e.shiftKey) {
|
|
89
|
+
if (!inside || active === first) {
|
|
90
|
+
e.preventDefault();
|
|
91
|
+
last.focus();
|
|
92
|
+
}
|
|
93
|
+
} else if (!inside || active === last) {
|
|
94
|
+
e.preventDefault();
|
|
95
|
+
first.focus();
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
[onClose],
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
if (open) {
|
|
103
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
104
|
+
document.body.style.overflow = 'hidden';
|
|
105
|
+
}
|
|
106
|
+
return () => {
|
|
107
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
108
|
+
document.body.style.overflow = '';
|
|
109
|
+
};
|
|
110
|
+
}, [open, handleKeyDown]);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
if (!open) return;
|
|
114
|
+
const panel = panelRef.current;
|
|
115
|
+
if (panel) {
|
|
116
|
+
// 焦点已在面板内(React 提交期把 autoFocus 元素聚焦了)就不动它;否则移入首个可聚焦元素。
|
|
117
|
+
const active = document.activeElement as HTMLElement | null;
|
|
118
|
+
if (!active || !panel.contains(active)) {
|
|
119
|
+
const focusable = getFocusableElements(panel);
|
|
120
|
+
(focusable[0] ?? panel).focus();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return () => {
|
|
124
|
+
// 焦点归还只在元素还在文档里时做 —— 触发按钮随页面跳转消失的场景静默跳过。
|
|
125
|
+
if (previouslyFocused.current?.isConnected) previouslyFocused.current.focus();
|
|
126
|
+
};
|
|
127
|
+
}, [open]);
|
|
128
|
+
|
|
129
|
+
if (!open) return null;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
|
133
|
+
<div className="absolute inset-0" onClick={onClose} />
|
|
134
|
+
<div
|
|
135
|
+
ref={panelRef}
|
|
136
|
+
role="dialog"
|
|
137
|
+
aria-modal="true"
|
|
138
|
+
aria-labelledby={title ? titleId : undefined}
|
|
139
|
+
aria-describedby={title && description ? descriptionId : undefined}
|
|
140
|
+
tabIndex={-1}
|
|
141
|
+
className={`relative w-full ${maxWidthClasses[maxWidth]} rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] shadow-brand outline-none ${className}`}
|
|
142
|
+
>
|
|
143
|
+
{title && (
|
|
144
|
+
<div className="flex items-start justify-between gap-4 border-b border-[var(--color-border-subtle)] px-6 py-4">
|
|
145
|
+
<div>
|
|
146
|
+
<h3 id={titleId} className="text-base font-semibold text-[var(--color-text-primary)]">{title}</h3>
|
|
147
|
+
{description && (
|
|
148
|
+
<p id={descriptionId} className="mt-0.5 text-sm text-[var(--color-text-secondary)]">{description}</p>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
<button
|
|
152
|
+
onClick={onClose}
|
|
153
|
+
className="text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
|
154
|
+
aria-label={closeLabel}
|
|
155
|
+
>
|
|
156
|
+
<X className="h-4 w-4" />
|
|
157
|
+
</button>
|
|
158
|
+
</div>
|
|
159
|
+
)}
|
|
160
|
+
{!title && (
|
|
161
|
+
<button
|
|
162
|
+
onClick={onClose}
|
|
163
|
+
className="absolute right-4 top-4 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
|
164
|
+
aria-label={closeLabel}
|
|
165
|
+
>
|
|
166
|
+
<X className="h-4 w-4" />
|
|
167
|
+
</button>
|
|
168
|
+
)}
|
|
169
|
+
{children != null && <div className="px-6 py-4">{children}</div>}
|
|
170
|
+
{footer && (
|
|
171
|
+
<div className="flex justify-end gap-3 border-t border-[var(--color-border-subtle)] px-6 py-4">
|
|
172
|
+
{footer}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface PageContainerProps {
|
|
4
|
+
children: React.ReactNode;
|
|
5
|
+
className?: string;
|
|
6
|
+
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
|
7
|
+
padding?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const maxWidthClasses: Record<string, string> = {
|
|
11
|
+
sm: 'max-w-sm',
|
|
12
|
+
md: 'max-w-md',
|
|
13
|
+
lg: 'max-w-lg',
|
|
14
|
+
xl: 'max-w-xl',
|
|
15
|
+
full: 'max-w-2xl',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const PageContainer = React.memo(function PageContainer({
|
|
19
|
+
children,
|
|
20
|
+
className = '',
|
|
21
|
+
maxWidth = 'sm',
|
|
22
|
+
padding = true,
|
|
23
|
+
}: PageContainerProps) {
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={`flex min-h-screen items-center justify-center px-4 ${padding ? 'py-8' : ''} ${className}`}
|
|
27
|
+
>
|
|
28
|
+
<div
|
|
29
|
+
className={`w-full ${maxWidthClasses[maxWidth]} space-y-6 rounded-lg bg-[var(--color-bg-surface)] p-8 shadow-brand`}
|
|
30
|
+
>
|
|
31
|
+
{children}
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
interface PageHeaderProps {
|
|
4
|
+
title: string;
|
|
5
|
+
subtitle?: string;
|
|
6
|
+
children?: React.ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const PageHeader = React.memo(function PageHeader({
|
|
10
|
+
title,
|
|
11
|
+
subtitle,
|
|
12
|
+
children,
|
|
13
|
+
}: PageHeaderProps) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="text-center">
|
|
16
|
+
<h1 className="text-3xl font-bold tracking-tight text-[var(--color-text-primary)] sm:text-4xl">
|
|
17
|
+
{title}
|
|
18
|
+
</h1>
|
|
19
|
+
{subtitle && (
|
|
20
|
+
<p className="mx-auto mt-4 max-w-2xl text-lg text-[var(--color-text-secondary)]">
|
|
21
|
+
{subtitle}
|
|
22
|
+
</p>
|
|
23
|
+
)}
|
|
24
|
+
{children}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type React from 'react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import {
|
|
4
|
+
Activity,
|
|
5
|
+
Check,
|
|
6
|
+
Code2,
|
|
7
|
+
Globe,
|
|
8
|
+
KeyRound,
|
|
9
|
+
LayoutGrid,
|
|
10
|
+
LogIn,
|
|
11
|
+
Settings,
|
|
12
|
+
Shield,
|
|
13
|
+
ShieldAlert,
|
|
14
|
+
ShieldCheck,
|
|
15
|
+
User,
|
|
16
|
+
} from 'lucide-react';
|
|
17
|
+
import { useMenuButton } from '../internal/use-menu-button';
|
|
18
|
+
import { UI_I18N_NS, uiText } from '../i18n';
|
|
19
|
+
|
|
20
|
+
export interface PortalLink {
|
|
21
|
+
code: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
url: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
icon?: React.ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PortalSwitcherLabels {
|
|
29
|
+
switchPortal: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PortalSwitcherProps {
|
|
33
|
+
portals: PortalLink[];
|
|
34
|
+
currentPortal?: string;
|
|
35
|
+
align?: 'left' | 'right';
|
|
36
|
+
className?: string;
|
|
37
|
+
menuClassName?: string;
|
|
38
|
+
labels?: Partial<PortalSwitcherLabels>;
|
|
39
|
+
loading?: boolean;
|
|
40
|
+
/** 单产品隐藏(Gainsight 规则):≤1 个门户时不渲染 */
|
|
41
|
+
hideWhenSingle?: boolean;
|
|
42
|
+
iconForPortal?: Record<string, React.ComponentType<{ className?: string }>>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const DEFAULT_PORTAL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
|
46
|
+
admin: Shield,
|
|
47
|
+
auth: LogIn,
|
|
48
|
+
security: ShieldAlert,
|
|
49
|
+
user: User,
|
|
50
|
+
authenticator: KeyRound,
|
|
51
|
+
status: Activity,
|
|
52
|
+
trust: ShieldCheck,
|
|
53
|
+
platform: Settings,
|
|
54
|
+
developer: Code2,
|
|
55
|
+
landing: Globe,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function PortalSwitcher({
|
|
59
|
+
portals,
|
|
60
|
+
currentPortal,
|
|
61
|
+
align = 'right',
|
|
62
|
+
className = '',
|
|
63
|
+
menuClassName = '',
|
|
64
|
+
labels,
|
|
65
|
+
loading = false,
|
|
66
|
+
hideWhenSingle = true,
|
|
67
|
+
iconForPortal,
|
|
68
|
+
}: PortalSwitcherProps) {
|
|
69
|
+
const { t, i18n } = useTranslation();
|
|
70
|
+
const { open, setOpen, triggerRef, menuRef, triggerProps, menuProps } = useMenuButton();
|
|
71
|
+
|
|
72
|
+
if (!loading && hideWhenSingle && portals.length < 2) return null;
|
|
73
|
+
|
|
74
|
+
const icons = { ...DEFAULT_PORTAL_ICONS, ...iconForPortal };
|
|
75
|
+
// 文案解析链:站点 i18next(注册 registerUiI18n 后,可覆盖/扩语言)→ 内置表(未注册)→ lastResort
|
|
76
|
+
const text = (key: string, lastResort?: string): string => {
|
|
77
|
+
const builtin = uiText(i18n.language, key);
|
|
78
|
+
return t(key, { ns: UI_I18N_NS, defaultValue: builtin ?? lastResort ?? key });
|
|
79
|
+
};
|
|
80
|
+
const switchLabel = labels?.switchPortal ?? text('portal.switcher');
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="relative">
|
|
84
|
+
<button
|
|
85
|
+
{...triggerProps}
|
|
86
|
+
ref={triggerRef}
|
|
87
|
+
aria-label={switchLabel}
|
|
88
|
+
title={switchLabel}
|
|
89
|
+
disabled={loading}
|
|
90
|
+
aria-busy={loading || undefined}
|
|
91
|
+
className={`rounded-md p-2 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-bg-muted)] hover:text-[var(--color-text-primary)] disabled:cursor-default disabled:opacity-50 ${className}`}
|
|
92
|
+
>
|
|
93
|
+
<LayoutGrid className="h-5 w-5 shrink-0" />
|
|
94
|
+
</button>
|
|
95
|
+
{open && (
|
|
96
|
+
<div
|
|
97
|
+
{...menuProps}
|
|
98
|
+
ref={menuRef}
|
|
99
|
+
className={`absolute top-full z-50 mt-2 w-56 rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-elevated)] py-1 shadow-brand ${
|
|
100
|
+
align === 'left' ? 'left-0' : 'right-0'
|
|
101
|
+
} ${menuClassName}`}
|
|
102
|
+
>
|
|
103
|
+
{portals.map((portal) => {
|
|
104
|
+
const Icon = icons[portal.code] ?? Globe;
|
|
105
|
+
const isCurrent = portal.code === currentPortal;
|
|
106
|
+
return (
|
|
107
|
+
<a
|
|
108
|
+
key={portal.code}
|
|
109
|
+
role="menuitem"
|
|
110
|
+
tabIndex={-1}
|
|
111
|
+
href={portal.url}
|
|
112
|
+
aria-current={isCurrent || undefined}
|
|
113
|
+
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
|
|
114
|
+
isCurrent
|
|
115
|
+
? 'bg-[var(--color-bg-muted)] text-[var(--color-text-primary)]'
|
|
116
|
+
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)]'
|
|
117
|
+
}`}
|
|
118
|
+
onClick={isCurrent ? (event) => { event.preventDefault(); setOpen(false); } : () => setOpen(false)}
|
|
119
|
+
>
|
|
120
|
+
{portal.icon ?? <Icon className="h-4 w-4 shrink-0 text-[var(--color-text-secondary)]" />}
|
|
121
|
+
<span className="flex-1 truncate">
|
|
122
|
+
{text(`portal.names.${portal.code}`, portal.name ?? portal.code)}
|
|
123
|
+
</span>
|
|
124
|
+
{isCurrent && <Check className="h-4 w-4 shrink-0 text-[var(--color-brand)]" />}
|
|
125
|
+
</a>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { AlertTriangle, CheckCircle2, Info, XCircle } from 'lucide-react';
|
|
5
|
+
import { SOFT_BORDER, SOFT_TEXT, type SemanticVariant } from '../internal/semantic-styles';
|
|
6
|
+
|
|
7
|
+
// Result —— 「结果块」:一个动作做完之后告诉用户结果(成功/失败/无权/找不到),
|
|
8
|
+
// 居中的图标 + 标题 + 说明 + 一个动作。
|
|
9
|
+
//
|
|
10
|
+
// 为什么要有它(第 32 轮实测):这类块在舰队里有两套互不相干的实现 ——
|
|
11
|
+
// · 控制台 9 处用 antd 的 `<Result>`(404 / 403 / not-found / 无权限页 / 校验结果);
|
|
12
|
+
// · user 门户 6 处手写:**两种形态**(`bg-*-soft` 的整块浅底 / 白底 + 同色边框 + 圆形徽标),
|
|
13
|
+
// 外加一页 404 手写 hero。四种门户的「结果」长得各不相同。
|
|
14
|
+
//
|
|
15
|
+
// 与 Alert 的分工:Alert 是**一行提示**(挂在页面里,左对齐、窄、可关闭);
|
|
16
|
+
// Result 是**一个区块的结果**(居中、图标大、带一个动作)。两者共用同一份 `SOFT_TEXT`/`SOFT_BORDER` 配对。
|
|
17
|
+
//
|
|
18
|
+
// 刻意不做 `status` 这样的「大字状态位」:404 那种大字直接写在 `title` 里就行(`title` 是 ReactNode),
|
|
19
|
+
// 一个只为了 5 个待迁移页面而存在的属性不该先长出来。
|
|
20
|
+
export type ResultSurface = 'plain' | 'tinted';
|
|
21
|
+
|
|
22
|
+
export interface ResultProps {
|
|
23
|
+
/** 语义档位,默认 info。 */
|
|
24
|
+
variant?: SemanticVariant;
|
|
25
|
+
/** 底色:`plain` = 白色面 + 同色边框(默认);`tinted` = 整块同色浅底。 */
|
|
26
|
+
surface?: ResultSurface;
|
|
27
|
+
/** 标题(可以是节点 —— 404 那种大字就写在标题里)。 */
|
|
28
|
+
title?: React.ReactNode;
|
|
29
|
+
/** 说明文字。 */
|
|
30
|
+
description?: React.ReactNode;
|
|
31
|
+
/** 图标:默认按档位给一个;显式传 `null` 可以不要图标。 */
|
|
32
|
+
icon?: React.ReactNode | null;
|
|
33
|
+
/** 底部动作(一个或一组按钮)。 */
|
|
34
|
+
action?: React.ReactNode;
|
|
35
|
+
/** 追加类名。 */
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_ICONS: Record<SemanticVariant, React.ReactNode> = {
|
|
40
|
+
success: <CheckCircle2 className="h-7 w-7" />,
|
|
41
|
+
warning: <AlertTriangle className="h-7 w-7" />,
|
|
42
|
+
danger: <XCircle className="h-7 w-7" />,
|
|
43
|
+
info: <Info className="h-7 w-7" />,
|
|
44
|
+
neutral: <Info className="h-7 w-7" />
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// 只取配对里的「文字那一半」:`bg-X-soft text-X-text` → `text-X-text`。
|
|
48
|
+
// 直接写 `text-danger` 是错的(danger 对白底 2.27:1),而配对那一份已经过对比度验证。
|
|
49
|
+
const textTone = (v: SemanticVariant) => SOFT_TEXT[v].split(' ').filter((c) => c.startsWith('text-')).join(' ');
|
|
50
|
+
|
|
51
|
+
export const Result = React.memo(function Result({
|
|
52
|
+
variant = 'info',
|
|
53
|
+
surface = 'plain',
|
|
54
|
+
title,
|
|
55
|
+
description,
|
|
56
|
+
icon,
|
|
57
|
+
action,
|
|
58
|
+
className = ''
|
|
59
|
+
}: ResultProps) {
|
|
60
|
+
const tinted = surface === 'tinted';
|
|
61
|
+
const glyph = icon === null ? null : (icon ?? DEFAULT_ICONS[variant]);
|
|
62
|
+
const surfaceCls = tinted
|
|
63
|
+
? `${SOFT_TEXT[variant]} ${SOFT_BORDER[variant]}`
|
|
64
|
+
: `bg-[var(--color-bg-surface)] ${SOFT_BORDER[variant]}`;
|
|
65
|
+
// 徽标:白底卡片上用同色浅底,整块浅底上退回白色面 —— 两种都保证徽标里的图标仍压在同色浅底上。
|
|
66
|
+
const badgeCls = tinted ? 'bg-[var(--color-bg-surface)]' : SOFT_TEXT[variant];
|
|
67
|
+
return (
|
|
68
|
+
<div className={`flex flex-col items-center rounded-lg border p-8 text-center shadow-card ${surfaceCls} ${className}`}>
|
|
69
|
+
{glyph ? (
|
|
70
|
+
<span className={`flex h-16 w-16 items-center justify-center rounded-full ${badgeCls} ${tinted ? textTone(variant) : ''}`}>
|
|
71
|
+
{glyph}
|
|
72
|
+
</span>
|
|
73
|
+
) : null}
|
|
74
|
+
{title ? (
|
|
75
|
+
<h2 className={`mt-4 text-lg font-semibold ${tinted ? '' : 'text-[var(--color-text-primary)]'}`}>{title}</h2>
|
|
76
|
+
) : null}
|
|
77
|
+
{/* 用 div 而不是 p:description 允许放块级内容(控制台的模拟登录页往里放了 antd 的 <Space>,
|
|
78
|
+
它是一个 div —— 塞进 <p> 里就是非法嵌套,React 会在开发模式告警、浏览器会把 DOM 拆开)。 */}
|
|
79
|
+
{description ? <div className={`mt-2 text-sm ${tinted ? '' : textTone(variant)}`}>{description}</div> : null}
|
|
80
|
+
{action ? <div className="mt-6 flex flex-wrap items-center justify-center gap-3">{action}</div> : null}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
// SectionCard —— 区块容器(带标题的卡片)。
|
|
4
|
+
//
|
|
5
|
+
// 第 30 轮把它的两处规格改回设计系统自己的口径(此前两处都是 Tailwind 出厂值,不是设计系统的档位):
|
|
6
|
+
// · 圆角 `rounded-2xl`(16px)→ `rounded-lg`(24px):DESIGN.md §9 写的是「cards `radius-lg` (24px)」,
|
|
7
|
+
// `primitives.css` 的 `.brand-card` 用的也是 `--radius-lg`,而 user 门户 62 处手写卡片同样是 24px
|
|
8
|
+
// —— 三处口径一致,只有这里不是。
|
|
9
|
+
// · 标题 `text-xl font-bold`(20/700)→ `text-base font-semibold`(16/600):控制台那 363 张 antd Card
|
|
10
|
+
// 的 head title 就是 antd 的 `fontSizeLG`/600(=16/600),而页面标题是 20/600 —— 区块标题既不该与
|
|
11
|
+
// 页面标题同尺寸,也不该比全舰队的卡片标题更重。
|
|
12
|
+
// 边框仍用 `--color-border-subtle`:antd Card 走的是 antd 的 `colorBorderSecondary`(近白灰),
|
|
13
|
+
// 两侧不一致,已登记(要动它得改 antd 的组件令牌,是独立批次)。
|
|
14
|
+
|
|
15
|
+
interface SectionCardProps {
|
|
16
|
+
title?: string;
|
|
17
|
+
children: React.ReactNode;
|
|
18
|
+
className?: string;
|
|
19
|
+
padding?: 'none' | 'sm' | 'md' | 'lg';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const paddingMap = {
|
|
23
|
+
none: '',
|
|
24
|
+
sm: 'p-4',
|
|
25
|
+
md: 'p-6',
|
|
26
|
+
lg: 'p-8',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const SectionCard = React.memo(function SectionCard({
|
|
30
|
+
title,
|
|
31
|
+
children,
|
|
32
|
+
className = '',
|
|
33
|
+
padding = 'md',
|
|
34
|
+
}: SectionCardProps) {
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={`rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] shadow-card ${paddingMap[padding]} ${className}`}
|
|
38
|
+
>
|
|
39
|
+
{title && (
|
|
40
|
+
<h2 className="mb-4 text-base font-semibold text-[var(--color-text-primary)]">{title}</h2>
|
|
41
|
+
)}
|
|
42
|
+
{children}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Sun, Moon } from 'lucide-react';
|
|
2
|
+
import { useTranslation } from 'react-i18next';
|
|
3
|
+
import { useTheme } from '../context/ThemeProvider';
|
|
4
|
+
import { UI_I18N_NS, uiText } from '../i18n';
|
|
5
|
+
|
|
6
|
+
interface ThemeToggleProps {
|
|
7
|
+
className?: string;
|
|
8
|
+
iconSize?: number;
|
|
9
|
+
labelLight?: string;
|
|
10
|
+
labelDark?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ThemeToggle({
|
|
14
|
+
className = '',
|
|
15
|
+
iconSize = 18,
|
|
16
|
+
labelLight,
|
|
17
|
+
labelDark,
|
|
18
|
+
}: ThemeToggleProps) {
|
|
19
|
+
const { theme, toggle } = useTheme();
|
|
20
|
+
const { t, i18n } = useTranslation();
|
|
21
|
+
const isDark = theme === 'dark';
|
|
22
|
+
// 文案解析链与 PortalSwitcher 同构:站点 i18next(注册 registerUiI18n 后可覆盖)→ 内置表(未注册)
|
|
23
|
+
// → 组件参数。原实现为硬编码中文默认值,EN 站点顶栏出现中文标签(UP-04)。
|
|
24
|
+
const text = (key: string): string => {
|
|
25
|
+
const builtin = uiText(i18n.language, key);
|
|
26
|
+
return t(key, { ns: UI_I18N_NS, defaultValue: builtin ?? key });
|
|
27
|
+
};
|
|
28
|
+
const label = isDark
|
|
29
|
+
? labelLight || text('theme.toggleLight')
|
|
30
|
+
: labelDark || text('theme.toggleDark');
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<button
|
|
34
|
+
onClick={toggle}
|
|
35
|
+
className={`rounded-md transition-colors ${className}`}
|
|
36
|
+
title={label}
|
|
37
|
+
aria-label={label}
|
|
38
|
+
>
|
|
39
|
+
{isDark ? <Sun size={iconSize} /> : <Moon size={iconSize} />}
|
|
40
|
+
</button>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useState,
|
|
5
|
+
useCallback,
|
|
6
|
+
useRef,
|
|
7
|
+
useEffect,
|
|
8
|
+
type ReactNode,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from 'lucide-react';
|
|
11
|
+
|
|
12
|
+
export type ToastType = 'success' | 'error' | 'warning' | 'info';
|
|
13
|
+
|
|
14
|
+
export interface ToastItem {
|
|
15
|
+
id: string;
|
|
16
|
+
message: string;
|
|
17
|
+
type: ToastType;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ToastContextValue {
|
|
21
|
+
addToast: (message: string, type?: ToastType, duration?: number) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
25
|
+
|
|
26
|
+
export function useToast(): ToastContextValue {
|
|
27
|
+
const ctx = useContext(ToastContext);
|
|
28
|
+
if (!ctx) throw new Error('useToast must be used within <ToastProvider>');
|
|
29
|
+
return ctx;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let globalShow: ((message: string, type?: ToastType, duration?: number) => void) | null = null;
|
|
33
|
+
|
|
34
|
+
export function showToast(message: string, type: ToastType = 'info', duration?: number) {
|
|
35
|
+
globalShow?.(message, type, duration);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const ICONS: Record<ToastType, typeof CheckCircle> = {
|
|
39
|
+
success: CheckCircle,
|
|
40
|
+
error: AlertCircle,
|
|
41
|
+
warning: AlertTriangle,
|
|
42
|
+
info: Info,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const STYLES: Record<ToastType, string> = {
|
|
46
|
+
success: 'bg-success text-white',
|
|
47
|
+
error: 'bg-danger text-white',
|
|
48
|
+
warning: 'bg-amber-500 text-white',
|
|
49
|
+
info: 'bg-[var(--color-brand)] text-white',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
53
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
54
|
+
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
55
|
+
|
|
56
|
+
const removeToast = useCallback((id: string) => {
|
|
57
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
58
|
+
if (timers.current[id]) {
|
|
59
|
+
clearTimeout(timers.current[id]);
|
|
60
|
+
delete timers.current[id];
|
|
61
|
+
}
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const addToast = useCallback(
|
|
65
|
+
(message: string, type: ToastType = 'info', duration = 3000) => {
|
|
66
|
+
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
67
|
+
setToasts((prev) => [...prev, { id, message, type }]);
|
|
68
|
+
timers.current[id] = setTimeout(() => removeToast(id), duration);
|
|
69
|
+
},
|
|
70
|
+
[removeToast],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
globalShow = addToast;
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
return () => {
|
|
77
|
+
Object.values(timers.current).forEach(clearTimeout);
|
|
78
|
+
};
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<ToastContext.Provider value={{ addToast }}>
|
|
83
|
+
{children}
|
|
84
|
+
<div className="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col gap-2">
|
|
85
|
+
{toasts.map((toast) => {
|
|
86
|
+
const Icon = ICONS[toast.type];
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
key={toast.id}
|
|
90
|
+
className={`pointer-events-auto flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium shadow-brand ${STYLES[toast.type]}`}
|
|
91
|
+
>
|
|
92
|
+
<Icon className="h-4 w-4 shrink-0" />
|
|
93
|
+
<span className="flex-1">{toast.message}</span>
|
|
94
|
+
<button
|
|
95
|
+
onClick={() => removeToast(toast.id)}
|
|
96
|
+
className="ml-1 shrink-0 rounded-xs p-0.5 opacity-70 hover:bg-white/20 hover:opacity-100"
|
|
97
|
+
>
|
|
98
|
+
<X className="h-3.5 w-3.5" />
|
|
99
|
+
</button>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
})}
|
|
103
|
+
</div>
|
|
104
|
+
</ToastContext.Provider>
|
|
105
|
+
);
|
|
106
|
+
}
|