@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,122 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { X } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
// AppShell —— 控制台/门户的**外壳**:侧栏 + 顶栏 + 内容区 + 移动端抽屉。
|
|
7
|
+
//
|
|
8
|
+
// 为什么要有它(实测,2026-10-04):四个门户各自把同一套外壳写了一遍 ——
|
|
9
|
+
// admin Header 88 + Sidebar 609 · platform Header 50 + Sidebar 178 ·
|
|
10
|
+
// security AppLayout 203 · user AppLayout 341 → 5 个文件 / 约 1470 行
|
|
11
|
+
// 其中**真正重复的是外壳本身**(外层 flex 框架、固定侧栏、sticky 顶栏、移动端遮罩与抽屉、
|
|
12
|
+
// 内容区的滚动容器),而**不重复的是内容**(导航项、品牌文案、右上角那几个控件)。
|
|
13
|
+
// 于是这里的边界就是这条:**外壳拥有 chrome 与行为,站点拥有内容**。
|
|
14
|
+
//
|
|
15
|
+
// 它刻意**不**依赖 antd:user 门户的 antd 入口已经是 0,让它为了一个外壳回去装 antd,
|
|
16
|
+
// 是拿「一致性」换「多一份依赖」—— 而三个控制台要的是「别再各写一遍框架」,
|
|
17
|
+
// 不是「必须用 antd 的 Layout」。所以:
|
|
18
|
+
// · 外壳是原生 HTML + 设计系统令牌(深色模式由令牌自己切换,不需要 dark: 变体);
|
|
19
|
+
// · nav / headerRight 这类**内容**由站点以 ReactNode 传入 —— 控制台可以照旧塞 antd Menu 与 UserMenu。
|
|
20
|
+
//
|
|
21
|
+
// 顶栏高度走 var(--layout-header-height):第 13 道闸门(顶栏契约)认这个令牌,
|
|
22
|
+
// 写死 h-16 会被它判红 —— 外壳必须满足同一份契约。
|
|
23
|
+
//
|
|
24
|
+
// **页面外框也归外壳**(第 28 轮):沟槽(p-6)+ 内容面(白色圆角面板)。
|
|
25
|
+
// 这条边界不是审美选择,是「同一个东西只能有一个所有者」:外框原本分散在 4 个布局文件里,
|
|
26
|
+
// 结果三个控制台各抄了一份逐字相同的面板、user 一份都没有,而 34 个页面又在面板里再补一层内边距
|
|
27
|
+
// —— 页面内缩因此在 24px 与 48px 之间随机,同一个门户的相邻两页都能不一样。
|
|
28
|
+
|
|
29
|
+
export interface AppShellProps {
|
|
30
|
+
/** 侧栏顶部的品牌区。 */
|
|
31
|
+
brand: React.ReactNode;
|
|
32
|
+
/** 侧栏导航区。控制台传 antd Menu,user 传自定义列表 —— 外壳不关心里面是什么。 */
|
|
33
|
+
nav: React.ReactNode;
|
|
34
|
+
/** 品牌区下方、导航上方的附加区(如租户切换器)。 */
|
|
35
|
+
sidebarExtra?: React.ReactNode;
|
|
36
|
+
/** 顶栏左侧(移动端菜单按钮、折叠按钮、页面标题…)。 */
|
|
37
|
+
headerLeft?: React.ReactNode;
|
|
38
|
+
/** 顶栏右侧(PortalSwitcher / 语言 / 主题 / UserMenu…)。 */
|
|
39
|
+
headerRight?: React.ReactNode;
|
|
40
|
+
/** 内容区。 */
|
|
41
|
+
children: React.ReactNode;
|
|
42
|
+
/** 移动端抽屉是否打开(受控)。 */
|
|
43
|
+
mobileOpen?: boolean;
|
|
44
|
+
/** 请求关闭移动端抽屉(点遮罩 / 点关闭按钮都会走到这里)。 */
|
|
45
|
+
onMobileClose?: () => void;
|
|
46
|
+
/** 关闭按钮的无障碍标签。 */
|
|
47
|
+
closeLabel?: string;
|
|
48
|
+
/** 桌面端侧栏是否收起(只影响宽度:w-64 ↔ w-16)。 */
|
|
49
|
+
sidebarCollapsed?: boolean;
|
|
50
|
+
/** 追加类名。 */
|
|
51
|
+
className?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const AppShell = React.memo(function AppShell({
|
|
55
|
+
brand,
|
|
56
|
+
nav,
|
|
57
|
+
sidebarExtra,
|
|
58
|
+
headerLeft,
|
|
59
|
+
headerRight,
|
|
60
|
+
children,
|
|
61
|
+
mobileOpen = false,
|
|
62
|
+
onMobileClose,
|
|
63
|
+
closeLabel = 'Close menu',
|
|
64
|
+
sidebarCollapsed = false,
|
|
65
|
+
className = '',
|
|
66
|
+
}: AppShellProps) {
|
|
67
|
+
return (
|
|
68
|
+
<div className={`flex h-screen bg-[var(--color-bg-muted)] ${className}`}>
|
|
69
|
+
{/* 移动端遮罩:点它关闭抽屉。桌面端不渲染(lg:hidden)。 */}
|
|
70
|
+
{mobileOpen && (
|
|
71
|
+
<div
|
|
72
|
+
className="fixed inset-0 z-40 bg-black/30 lg:hidden"
|
|
73
|
+
onClick={onMobileClose}
|
|
74
|
+
aria-hidden="true"
|
|
75
|
+
/>
|
|
76
|
+
)}
|
|
77
|
+
|
|
78
|
+
<aside
|
|
79
|
+
className={`fixed inset-y-0 left-0 z-50 flex transform flex-col border-r border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] transition-transform duration-200 lg:static lg:translate-x-0 ${
|
|
80
|
+
mobileOpen ? 'translate-x-0' : '-translate-x-full'
|
|
81
|
+
} ${sidebarCollapsed ? 'w-64 lg:w-16' : 'w-64'}`}
|
|
82
|
+
>
|
|
83
|
+
<div className="flex h-[var(--layout-header-height)] shrink-0 items-center justify-between gap-2 border-b border-[var(--color-border-subtle)] px-4">
|
|
84
|
+
<div className="flex min-w-0 items-center gap-2">{brand}</div>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
className="text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] lg:hidden"
|
|
88
|
+
onClick={onMobileClose}
|
|
89
|
+
aria-label={closeLabel}
|
|
90
|
+
>
|
|
91
|
+
<X size={20} />
|
|
92
|
+
</button>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{sidebarExtra}
|
|
96
|
+
|
|
97
|
+
<nav className="flex min-h-0 flex-1 flex-col overflow-y-auto">{nav}</nav>
|
|
98
|
+
</aside>
|
|
99
|
+
|
|
100
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
101
|
+
<header className="sticky top-0 z-10 flex h-[var(--layout-header-height)] shrink-0 items-center justify-between gap-4 border-b border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] px-4 lg:px-8">
|
|
102
|
+
<div className="flex min-w-0 items-center gap-4">{headerLeft}</div>
|
|
103
|
+
<div className="flex shrink-0 items-center gap-3">{headerRight}</div>
|
|
104
|
+
</header>
|
|
105
|
+
|
|
106
|
+
{/* 内容面:沟槽之内那张白色圆角面板。三个控制台此前各自在自己的 App 里写了一遍
|
|
107
|
+
(逐字相同的 `min-h-[calc(100vh-112px)] rounded-lg bg-surface p-6`),user 没有 ——
|
|
108
|
+
于是四个门户的页面外框有两种:有面 / 无面,且页面还得自己再补一层内边距(实测 34 个页面这么干,
|
|
109
|
+
内缩因此从 24px 变成 48px)。外框属于外壳,搬进来之后站点侧只留内容。
|
|
110
|
+
|
|
111
|
+
min-height 用顶栏令牌表达:112px = 顶栏 64px + 内容区上下沟槽各 24px(p-6),
|
|
112
|
+
写成 calc 之后顶栏高度一变它跟着变 —— 写死的 112 会在顶栏改高度那天悄悄错位。 */}
|
|
113
|
+
{/* 沟槽写死在外壳里:它同时是四站唯一的留白来源。放在调用方手里 = 又允许四个门户各调各的。 */}
|
|
114
|
+
<main className="min-h-0 flex-1 overflow-auto p-6">
|
|
115
|
+
<div className="min-h-[calc(100vh-var(--layout-header-height)-3rem)] rounded-lg bg-[var(--color-bg-surface)] p-6">
|
|
116
|
+
{children}
|
|
117
|
+
</div>
|
|
118
|
+
</main>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import { Check, Copy } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
interface CodeBlockProps {
|
|
5
|
+
code: string;
|
|
6
|
+
language?: string;
|
|
7
|
+
filename?: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const CodeBlock = React.memo(function CodeBlock({
|
|
12
|
+
code,
|
|
13
|
+
language = 'bash',
|
|
14
|
+
filename,
|
|
15
|
+
className = '',
|
|
16
|
+
}: CodeBlockProps) {
|
|
17
|
+
const [copied, setCopied] = useState(false);
|
|
18
|
+
|
|
19
|
+
const handleCopy = useCallback(() => {
|
|
20
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
21
|
+
setCopied(true);
|
|
22
|
+
setTimeout(() => setCopied(false), 2000);
|
|
23
|
+
});
|
|
24
|
+
}, [code]);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
className={`overflow-hidden rounded-lg border border-neutral-200 bg-neutral-900 dark:border-neutral-800 ${className}`}
|
|
29
|
+
>
|
|
30
|
+
{(filename || language) && (
|
|
31
|
+
<div className="flex items-center justify-between border-b border-neutral-800 bg-neutral-950 px-4 py-2">
|
|
32
|
+
<div className="flex items-center gap-2">
|
|
33
|
+
{filename && <span className="text-xs text-neutral-400">{filename}</span>}
|
|
34
|
+
{!filename && <span className="text-xs uppercase text-neutral-500">{language}</span>}
|
|
35
|
+
</div>
|
|
36
|
+
<button
|
|
37
|
+
onClick={handleCopy}
|
|
38
|
+
className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs text-neutral-400 transition-colors hover:bg-neutral-800 hover:text-neutral-200"
|
|
39
|
+
aria-label="复制代码"
|
|
40
|
+
>
|
|
41
|
+
{copied ? (
|
|
42
|
+
<Check className="h-3.5 w-3.5 text-success" />
|
|
43
|
+
) : (
|
|
44
|
+
<Copy className="h-3.5 w-3.5" />
|
|
45
|
+
)}
|
|
46
|
+
{copied ? '已复制' : '复制'}
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
50
|
+
<pre className="overflow-x-auto p-4 text-sm leading-relaxed">
|
|
51
|
+
<code className="font-mono text-neutral-200">{code}</code>
|
|
52
|
+
</pre>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import React, { useCallback, useEffect } from 'react';
|
|
2
|
+
import { AlertTriangle, X } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
interface ConfirmDialogProps {
|
|
5
|
+
open: boolean;
|
|
6
|
+
title: string;
|
|
7
|
+
description: string;
|
|
8
|
+
confirmText?: string;
|
|
9
|
+
cancelText?: string;
|
|
10
|
+
variant?: 'danger' | 'warning' | 'neutral';
|
|
11
|
+
onConfirm: () => void;
|
|
12
|
+
onCancel: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const variantStyles = {
|
|
16
|
+
danger: {
|
|
17
|
+
icon: 'bg-danger-soft text-danger-text',
|
|
18
|
+
confirm: 'bg-danger text-white hover:opacity-90',
|
|
19
|
+
},
|
|
20
|
+
warning: {
|
|
21
|
+
icon: 'bg-warning-soft text-warning-text',
|
|
22
|
+
confirm: 'bg-warning text-white hover:opacity-90',
|
|
23
|
+
},
|
|
24
|
+
neutral: {
|
|
25
|
+
icon: 'bg-brand-soft text-brand',
|
|
26
|
+
confirm: 'bg-[var(--color-brand)] text-white hover:opacity-90',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const ConfirmDialog = React.memo(function ConfirmDialog({
|
|
31
|
+
open,
|
|
32
|
+
title,
|
|
33
|
+
description,
|
|
34
|
+
confirmText = '确认',
|
|
35
|
+
cancelText = '取消',
|
|
36
|
+
variant = 'neutral',
|
|
37
|
+
onConfirm,
|
|
38
|
+
onCancel,
|
|
39
|
+
}: ConfirmDialogProps) {
|
|
40
|
+
const handleKeyDown = useCallback(
|
|
41
|
+
(e: KeyboardEvent) => {
|
|
42
|
+
if (e.key === 'Escape') onCancel();
|
|
43
|
+
},
|
|
44
|
+
[onCancel],
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (open) {
|
|
49
|
+
document.addEventListener('keydown', handleKeyDown);
|
|
50
|
+
document.body.style.overflow = 'hidden';
|
|
51
|
+
}
|
|
52
|
+
return () => {
|
|
53
|
+
document.removeEventListener('keydown', handleKeyDown);
|
|
54
|
+
document.body.style.overflow = '';
|
|
55
|
+
};
|
|
56
|
+
}, [open, handleKeyDown]);
|
|
57
|
+
|
|
58
|
+
if (!open) return null;
|
|
59
|
+
|
|
60
|
+
const style = variantStyles[variant];
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
64
|
+
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onCancel} />
|
|
65
|
+
<div className="relative w-full max-w-md rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] p-6 shadow-brand">
|
|
66
|
+
<button
|
|
67
|
+
onClick={onCancel}
|
|
68
|
+
className="absolute right-4 top-4 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
|
69
|
+
aria-label="关闭"
|
|
70
|
+
>
|
|
71
|
+
<X className="h-4 w-4" />
|
|
72
|
+
</button>
|
|
73
|
+
<div className="flex items-start gap-4">
|
|
74
|
+
<div
|
|
75
|
+
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-full ${style.icon}`}
|
|
76
|
+
>
|
|
77
|
+
<AlertTriangle className="h-5 w-5" />
|
|
78
|
+
</div>
|
|
79
|
+
<div>
|
|
80
|
+
<h3 className="text-base font-semibold text-[var(--color-text-primary)]">{title}</h3>
|
|
81
|
+
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">{description}</p>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="mt-6 flex justify-end gap-3">
|
|
85
|
+
<button
|
|
86
|
+
onClick={onCancel}
|
|
87
|
+
className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] px-4 py-2 text-sm font-medium text-[var(--color-text-primary)] transition-colors hover:bg-[var(--color-bg-muted)]"
|
|
88
|
+
>
|
|
89
|
+
{cancelText}
|
|
90
|
+
</button>
|
|
91
|
+
<button
|
|
92
|
+
onClick={onConfirm}
|
|
93
|
+
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${style.confirm}`}
|
|
94
|
+
>
|
|
95
|
+
{confirmText}
|
|
96
|
+
</button>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
|
|
5
|
+
// ConsolePageHeader —— 控制台页面的页头:左对齐标题 + 右侧操作槽 + 可选副标题。
|
|
6
|
+
//
|
|
7
|
+
// 为什么不是 DS 里已有的 PageHeader:那是**营销 hero**(text-center + text-3xl/sm:text-4xl),
|
|
8
|
+
// 落地页在用;控制台页头是另一种东西(左对齐 + text-xl/font-semibold + 右侧操作槽)。
|
|
9
|
+
// 两者**同名不同物**,互相替换会把控制台的密度打散 —— 所以这里另立一件,PageHeader 保持原样(D5)。
|
|
10
|
+
//
|
|
11
|
+
// 收敛前实测(2026-10):三个控制台在 **106 个页面**里把这段页头内联写了 106 遍,
|
|
12
|
+
// 包裹容器的 className 有 **11 种**(mb-4 / mb-6、有没有 justify-between、有没有响应式断点各不相同)。
|
|
13
|
+
// 这里固定成一种:窄屏纵向堆叠、sm 起横向两端对齐、gap-3、下边距 mb-6 —— 与其中 55 处的多数派一致。
|
|
14
|
+
|
|
15
|
+
export interface ConsolePageHeaderProps {
|
|
16
|
+
/** 页面标题。 */
|
|
17
|
+
title: React.ReactNode;
|
|
18
|
+
/** 标题下方的说明文字(可选)。 */
|
|
19
|
+
description?: React.ReactNode;
|
|
20
|
+
/** 右侧操作区(查询 / 导出 / 新建…)。窄屏时落到标题下方。 */
|
|
21
|
+
actions?: React.ReactNode;
|
|
22
|
+
/** 追加类名。用于个别页面的例外情况;**间距由组件自己定**,不通过这个口子改。 */
|
|
23
|
+
className?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const ConsolePageHeader = React.memo(function ConsolePageHeader({
|
|
27
|
+
title,
|
|
28
|
+
description,
|
|
29
|
+
actions,
|
|
30
|
+
className = '',
|
|
31
|
+
}: ConsolePageHeaderProps) {
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className={`mb-6 flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between ${className}`}
|
|
35
|
+
>
|
|
36
|
+
<div className="min-w-0">
|
|
37
|
+
<h1 className="text-xl font-semibold">{title}</h1>
|
|
38
|
+
{description ? (
|
|
39
|
+
<div className="mt-1 text-sm text-[var(--color-text-secondary)]">{description}</div>
|
|
40
|
+
) : null}
|
|
41
|
+
</div>
|
|
42
|
+
{actions ? <div className="flex flex-wrap items-center gap-2 sm:shrink-0">{actions}</div> : null}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Inbox } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
interface EmptyStateProps {
|
|
5
|
+
title?: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
icon?: React.ReactNode;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const EmptyState = React.memo(function EmptyState({
|
|
11
|
+
title = '暂无数据',
|
|
12
|
+
description = '当前没有可显示的内容。',
|
|
13
|
+
icon,
|
|
14
|
+
}: EmptyStateProps) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-[var(--color-border-subtle)] bg-[var(--color-bg-muted)] px-4 py-12 text-center">
|
|
17
|
+
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-[var(--color-bg-muted)] text-[var(--color-text-muted)]">
|
|
18
|
+
{icon ?? <Inbox className="h-6 w-6" />}
|
|
19
|
+
</div>
|
|
20
|
+
<h3 className="mt-4 text-base font-semibold text-[var(--color-text-primary)]">{title}</h3>
|
|
21
|
+
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">{description}</p>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 权威版 ErrorBoundary —— 由 pnpm sync:consumers 下发到各站点的 packages/ui/src/molecules/。
|
|
3
|
+
*
|
|
4
|
+
* 为什么放在设计系统仓库里:
|
|
5
|
+
* packages/ui 是共享组件库,却在 9 个站点各存一份。逐文件比对(2026-09)后 29 个文件里
|
|
6
|
+
* 只有这一个真正不同,而且差异只在界面默认文案上:
|
|
7
|
+
* 7 个站点用英文(Something went wrong / An unexpected error occurred… / Reload Page)
|
|
8
|
+
* admin 用「出现错误 / 发生意外错误,请刷新页面重试。/ 重新加载页面」
|
|
9
|
+
* platform 用「页面出错了 / 发生了未知错误,请刷新页面重试。/ 刷新页面」
|
|
10
|
+
* 三份都是 97 行、导出与逻辑完全相同。同一个错误组件在不同 portal 显示不同措辞,
|
|
11
|
+
* 这是文案不统一而非实现分叉(ui 仓库 KI-013)。
|
|
12
|
+
*
|
|
13
|
+
* 语言判定为什么不用 react-i18next:
|
|
14
|
+
* packages/ui 里 LanguageSwitcher 确实用了 useTranslation,但各 portal 的 t() 约定并不统一——
|
|
15
|
+
* 例如 auth 的 useI18n 走的是 t('flat.' + key)(前缀 flat.),而那个 wrapper 位于
|
|
16
|
+
* 各 portal 的 apps 目录下(apps slash 站点名 slash src slash lib),不在 packages/ui 内,
|
|
17
|
+
* 组件够不着;键前缀约定也不一致。
|
|
18
|
+
* 所以这里改用**零依赖信号**:document.documentElement.lang。
|
|
19
|
+
*
|
|
20
|
+
* ⚠ 2026-10-03 实测更正:原注释写「各 portal 的 I18nProvider 都在 languageChanged 时调用 syncHtmlLang 同步它」——
|
|
21
|
+
* **那是不成立的**:全舰队当时只有 authenticator 有这段同步,user/admin/platform 都没有。
|
|
22
|
+
* 后果很具体:它们的错误页只会在中文与英文之间**永远停在 index.html 的 zh-CN**,
|
|
23
|
+
* 而 user 当时靠一层本地包装用 i18n 覆盖文案绕过了这一点。
|
|
24
|
+
* 现在四个门户都补上了这 7 行同步(user 因此可以删掉那层包装),这条断言才真正成立 ——
|
|
25
|
+
* 但**依赖它的组件不该假设它天然成立**:将来新增门户若忘了同步,错误页会静默地只显示一种语言。
|
|
26
|
+
* 代价:语言切换时错误页不会实时重渲染(错误边界本就只在出错时渲染一次,可接受)。
|
|
27
|
+
* 若将来统一了跨 portal 的键约定,应改为走 i18n,而不是继续在组件里放文案。
|
|
28
|
+
*
|
|
29
|
+
* 对外 API 不变:<ErrorBoundary>{children}</ErrorBoundary>,仍可传 title / message / retryLabel
|
|
30
|
+
* 覆盖默认文案(props 优先于内置字典)。
|
|
31
|
+
*/
|
|
32
|
+
import { Component, type ReactNode } from 'react';
|
|
33
|
+
import { AlertTriangle } from 'lucide-react';
|
|
34
|
+
|
|
35
|
+
interface ErrorBoundaryProps {
|
|
36
|
+
children: ReactNode;
|
|
37
|
+
fallback?: ReactNode;
|
|
38
|
+
title?: string;
|
|
39
|
+
message?: string;
|
|
40
|
+
retryLabel?: string;
|
|
41
|
+
devMode?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ErrorBoundaryState {
|
|
45
|
+
hasError: boolean;
|
|
46
|
+
error?: Error;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const TEXT = {
|
|
50
|
+
zh: {
|
|
51
|
+
devTitle: '开发模式错误边界',
|
|
52
|
+
unknown: '未知错误',
|
|
53
|
+
title: '页面出错了',
|
|
54
|
+
message: '发生了未知错误,请刷新页面重试。',
|
|
55
|
+
retry: '刷新页面',
|
|
56
|
+
reload: '刷新',
|
|
57
|
+
},
|
|
58
|
+
en: {
|
|
59
|
+
devTitle: 'Dev Error Boundary',
|
|
60
|
+
unknown: 'Unknown error',
|
|
61
|
+
title: 'Something went wrong',
|
|
62
|
+
message: 'An unexpected error occurred. Please try refreshing the page.',
|
|
63
|
+
retry: 'Reload Page',
|
|
64
|
+
reload: 'Reload',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** 各 portal 由 I18nProvider 同步 <html lang>(zh-CN / en-US),以此判定文案语言。 */
|
|
69
|
+
function text(): typeof TEXT.zh {
|
|
70
|
+
if (typeof document === 'undefined') return TEXT.zh;
|
|
71
|
+
const lang = (document.documentElement.getAttribute('lang') || '').toLowerCase();
|
|
72
|
+
return lang.startsWith('zh') ? TEXT.zh : TEXT.en;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
76
|
+
constructor(props: ErrorBoundaryProps) {
|
|
77
|
+
super(props);
|
|
78
|
+
this.state = { hasError: false };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
82
|
+
return { hasError: true, error };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
|
86
|
+
if (this.props.devMode) {
|
|
87
|
+
console.error('[ErrorBoundary] caught:', error, errorInfo);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
render() {
|
|
92
|
+
const { children, fallback, title, message, retryLabel, devMode } = this.props;
|
|
93
|
+
const T = text();
|
|
94
|
+
|
|
95
|
+
if (this.state.hasError) {
|
|
96
|
+
if (fallback) return fallback;
|
|
97
|
+
|
|
98
|
+
if (devMode) {
|
|
99
|
+
return (
|
|
100
|
+
<div className="flex min-h-[50vh] flex-col items-center justify-center p-8 text-center" role="alert">
|
|
101
|
+
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-rose-50">
|
|
102
|
+
<AlertTriangle size={32} className="text-rose-600" />
|
|
103
|
+
</div>
|
|
104
|
+
<h2 className="mt-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100">{T.devTitle}</h2>
|
|
105
|
+
<p className="mt-2 max-w-lg text-sm text-neutral-500 dark:text-neutral-400">
|
|
106
|
+
{this.state.error?.message || T.unknown}
|
|
107
|
+
</p>
|
|
108
|
+
{this.state.error?.stack && (
|
|
109
|
+
<pre className="mt-4 max-h-60 max-w-2xl overflow-auto rounded-xs bg-neutral-100 p-3 text-left text-xs text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400">
|
|
110
|
+
{this.state.error.stack}
|
|
111
|
+
</pre>
|
|
112
|
+
)}
|
|
113
|
+
<button
|
|
114
|
+
onClick={() => window.location.reload()}
|
|
115
|
+
className="mt-6 rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
|
116
|
+
>
|
|
117
|
+
{T.reload}
|
|
118
|
+
</button>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex min-h-[50vh] flex-col items-center justify-center p-8 text-center" role="alert">
|
|
125
|
+
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-rose-50 dark:bg-rose-900/20">
|
|
126
|
+
<AlertTriangle size={32} className="text-rose-600" />
|
|
127
|
+
</div>
|
|
128
|
+
<h2 className="mt-4 text-lg font-semibold text-neutral-900 dark:text-neutral-100">{title || T.title}</h2>
|
|
129
|
+
<p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400">{message || T.message}</p>
|
|
130
|
+
<button
|
|
131
|
+
onClick={() => window.location.reload()}
|
|
132
|
+
className="mt-6 rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 transition-colors"
|
|
133
|
+
>
|
|
134
|
+
{retryLabel || T.retry}
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return children;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
export interface ErrorStateProps {
|
|
4
|
+
title?: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
message?: string;
|
|
7
|
+
onRetry?: () => void;
|
|
8
|
+
action?: { label: string; onClick: () => void };
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function ErrorState({
|
|
13
|
+
title,
|
|
14
|
+
description,
|
|
15
|
+
message,
|
|
16
|
+
onRetry,
|
|
17
|
+
action,
|
|
18
|
+
className = '',
|
|
19
|
+
}: ErrorStateProps) {
|
|
20
|
+
const displayTitle = title || '加载失败';
|
|
21
|
+
const displayMessage = description || message || '请稍后重试';
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className={`flex flex-col items-center justify-center gap-3 rounded-lg border border-danger-soft bg-danger-soft px-4 py-8 text-center ${className}`}
|
|
26
|
+
>
|
|
27
|
+
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-danger-soft">
|
|
28
|
+
<AlertTriangle className="h-7 w-7 text-danger" />
|
|
29
|
+
</div>
|
|
30
|
+
<div>
|
|
31
|
+
<p className="text-sm font-medium text-[var(--color-text-primary)]">{displayTitle}</p>
|
|
32
|
+
<p className="mt-1 text-xs text-[var(--color-text-secondary)]">{displayMessage}</p>
|
|
33
|
+
</div>
|
|
34
|
+
{action ? (
|
|
35
|
+
<button
|
|
36
|
+
onClick={action.onClick}
|
|
37
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg-surface)] px-3 py-1.5 text-xs text-[var(--color-text-primary)] hover:bg-[var(--color-bg-muted)]"
|
|
38
|
+
>
|
|
39
|
+
{action.label}
|
|
40
|
+
</button>
|
|
41
|
+
) : onRetry ? (
|
|
42
|
+
<button
|
|
43
|
+
onClick={onRetry}
|
|
44
|
+
className="inline-flex items-center gap-1.5 rounded-md border border-danger-soft bg-[var(--color-bg-surface)] px-3 py-1.5 text-xs text-danger-text hover:bg-danger-soft"
|
|
45
|
+
>
|
|
46
|
+
<RefreshCw className="h-3.5 w-3.5" />
|
|
47
|
+
重试
|
|
48
|
+
</button>
|
|
49
|
+
) : null}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// FormField —— 一个字段的外壳:标签 + 控件槽 + 帮助/错误文本 + 无障碍接线。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要它:设计系统有 Input / Label 两个 atom,但**没有「一个字段」这个概念**。
|
|
6
|
+
// 于是每个站点自己拼:label 的 htmlFor 常常不写、错误文本的 id 与 aria-describedby 从来没接上、
|
|
7
|
+
// 必填标记各写各的。实测全舰队 50 处 react-hook-form 的 register( 调用点(user 10 / auth 34 / status 4 / trust 2),
|
|
8
|
+
// 每一处都在重新决定「标签怎么摆、错误颜色是什么、读屏器怎么念」。
|
|
9
|
+
//
|
|
10
|
+
// 它**不依赖任何表单库**:配合 react-hook-form 的 register、配合 Controller、配合自研控件都能用。
|
|
11
|
+
// 绑定 react-hook-form 的那一组(把 useController 也包掉)在 @autional/ui/rhf 入口。
|
|
12
|
+
|
|
13
|
+
import * as React from 'react';
|
|
14
|
+
|
|
15
|
+
export interface FormFieldProps {
|
|
16
|
+
/** 标签文案。不给就只渲染控件与错误/帮助文本。 */
|
|
17
|
+
label?: string;
|
|
18
|
+
/** 常显的帮助文本。有错误时被错误文本取代(不会同时显示两行)。 */
|
|
19
|
+
hint?: string;
|
|
20
|
+
/** 错误文案。有值即视为无效:控件会拿到 aria-invalid,读屏器会念到这条。 */
|
|
21
|
+
error?: string;
|
|
22
|
+
/** 必填标记。只影响外观与 `aria-required`,**不做校验** —— 校验归表单库。 */
|
|
23
|
+
required?: boolean;
|
|
24
|
+
/** 覆盖自动生成的控件 id。仅在同一个字段需要被外部引用时才需要。 */
|
|
25
|
+
id?: string;
|
|
26
|
+
className?: string;
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface FieldA11y {
|
|
31
|
+
id: string;
|
|
32
|
+
describedBy?: string;
|
|
33
|
+
invalid: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const FieldA11yContext = React.createContext<FieldA11y | null>(null);
|
|
37
|
+
|
|
38
|
+
/** 供控件取用外壳生成的 id 与 aria 接线(@autional/ui/rhf 的绑定控件已经替你接了)。 */
|
|
39
|
+
export function useFormFieldA11y(): FieldA11y | null {
|
|
40
|
+
return React.useContext(FieldA11yContext);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function FormField({
|
|
44
|
+
label,
|
|
45
|
+
hint,
|
|
46
|
+
error,
|
|
47
|
+
required = false,
|
|
48
|
+
id,
|
|
49
|
+
className = '',
|
|
50
|
+
children,
|
|
51
|
+
}: FormFieldProps) {
|
|
52
|
+
const autoId = React.useId();
|
|
53
|
+
const fieldId = id ?? autoId;
|
|
54
|
+
const errorId = fieldId + '-error';
|
|
55
|
+
const hintId = fieldId + '-hint';
|
|
56
|
+
const invalid = !!error;
|
|
57
|
+
// 帮助文本与错误文本用同一个 aria-describedby 槽位:错误出现时它取代帮助文本,
|
|
58
|
+
// 于是读屏器不会同时念两条互相矛盾的说明。
|
|
59
|
+
const describedBy = invalid ? errorId : hint ? hintId : undefined;
|
|
60
|
+
const a11y = React.useMemo<FieldA11y>(
|
|
61
|
+
() => ({ id: fieldId, describedBy, invalid }),
|
|
62
|
+
[fieldId, describedBy, invalid],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className={'space-y-1.5 ' + className}>
|
|
67
|
+
{label && (
|
|
68
|
+
<label htmlFor={fieldId} className="block text-sm font-medium text-[var(--color-text-primary)]">
|
|
69
|
+
{label}
|
|
70
|
+
{required && (
|
|
71
|
+
<span className="ml-0.5 text-[var(--color-danger)]" aria-hidden="true">
|
|
72
|
+
*
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</label>
|
|
76
|
+
)}
|
|
77
|
+
<FieldA11yContext.Provider value={a11y}>{children}</FieldA11yContext.Provider>
|
|
78
|
+
{invalid ? (
|
|
79
|
+
<p id={errorId} role="alert" className="text-xs text-[var(--color-danger)]">
|
|
80
|
+
{error}
|
|
81
|
+
</p>
|
|
82
|
+
) : hint ? (
|
|
83
|
+
<p id={hintId} className="text-xs text-[var(--color-text-secondary)]">
|
|
84
|
+
{hint}
|
|
85
|
+
</p>
|
|
86
|
+
) : null}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useTranslation } from 'react-i18next';
|
|
2
|
+
import { Globe } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
interface LanguageSwitcherProps {
|
|
5
|
+
className?: string;
|
|
6
|
+
labelEn?: string;
|
|
7
|
+
labelZh?: string;
|
|
8
|
+
showIcon?: boolean;
|
|
9
|
+
currentLang?: string;
|
|
10
|
+
onToggle?: (nextLang: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function LanguageSwitcher({
|
|
14
|
+
className = '',
|
|
15
|
+
labelEn,
|
|
16
|
+
labelZh,
|
|
17
|
+
showIcon = false,
|
|
18
|
+
currentLang,
|
|
19
|
+
onToggle,
|
|
20
|
+
}: LanguageSwitcherProps) {
|
|
21
|
+
const { i18n } = useTranslation();
|
|
22
|
+
const lang = currentLang ?? i18n.language;
|
|
23
|
+
|
|
24
|
+
const toggle = () => {
|
|
25
|
+
const next = lang === 'zh-CN' ? 'en-US' : 'zh-CN';
|
|
26
|
+
if (onToggle) {
|
|
27
|
+
onToggle(next);
|
|
28
|
+
} else {
|
|
29
|
+
i18n.changeLanguage(next);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<button type="button" onClick={toggle} className={`text-xs transition-colors ${className}`}>
|
|
35
|
+
{showIcon && <Globe className="h-3.5 w-3.5 shrink-0" />}
|
|
36
|
+
{lang === 'zh-CN' ? labelEn || 'EN' : labelZh || '中文'}
|
|
37
|
+
</button>
|
|
38
|
+
);
|
|
39
|
+
}
|