@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
package/package.json
CHANGED
|
@@ -1,6 +1,91 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@autional/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-rc.43",
|
|
4
|
+
"description": "Autional shared React component library (atoms + molecules) — the canonical home of the component layer",
|
|
5
|
+
"license": "AGPL-3.0",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"module": "./src/index.ts",
|
|
8
|
+
"types": "./src/index.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.ts",
|
|
12
|
+
"default": "./src/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"./i18n": {
|
|
15
|
+
"types": "./src/i18n/index.ts",
|
|
16
|
+
"default": "./src/i18n/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"./antd": {
|
|
19
|
+
"types": "./src/antd/index.ts",
|
|
20
|
+
"default": "./src/antd/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"./rhf": {
|
|
23
|
+
"types": "./src/rhf/index.ts",
|
|
24
|
+
"default": "./src/rhf/index.ts"
|
|
25
|
+
},
|
|
26
|
+
"./test-setup": {
|
|
27
|
+
"types": "./src/test-setup.ts",
|
|
28
|
+
"default": "./src/test-setup.ts"
|
|
29
|
+
},
|
|
30
|
+
"./styles/globals.css": "./src/styles/globals.css",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"!src/test",
|
|
36
|
+
"!src/__tests__",
|
|
37
|
+
"!src/**/__tests__"
|
|
38
|
+
],
|
|
39
|
+
"sideEffects": true,
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@autional/tokens": ">=0.1.0-rc",
|
|
42
|
+
"antd": ">=6.0.0",
|
|
43
|
+
"dayjs": ">=1.11.0",
|
|
44
|
+
"i18next": ">=26.0.0",
|
|
45
|
+
"react-hook-form": ">=7.0.0",
|
|
46
|
+
"react-router": ">=7.0.0",
|
|
47
|
+
"lucide-react": ">=0.500.0",
|
|
48
|
+
"react": ">=18.0.0",
|
|
49
|
+
"react-dom": ">=18.0.0",
|
|
50
|
+
"react-i18next": ">=17.0.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"antd": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"dayjs": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"react-hook-form": {
|
|
60
|
+
"optional": true
|
|
61
|
+
},
|
|
62
|
+
"react-router": {
|
|
63
|
+
"optional": true
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"devDependencies": {
|
|
67
|
+
"antd": "6.5.0",
|
|
68
|
+
"dayjs": "1.11.20",
|
|
69
|
+
"@testing-library/jest-dom": "6.9.1",
|
|
70
|
+
"@testing-library/react": "16.3.2",
|
|
71
|
+
"@testing-library/user-event": "14.6.1",
|
|
72
|
+
"@types/react": "19.2.14",
|
|
73
|
+
"@types/react-dom": "19.2.3",
|
|
74
|
+
"@vitejs/plugin-react": "4.4.0",
|
|
75
|
+
"i18next": "26.0.10",
|
|
76
|
+
"jsdom": "29.1.1",
|
|
77
|
+
"lucide-react": "0.509.0",
|
|
78
|
+
"react-hook-form": "7.78.0",
|
|
79
|
+
"react-router": "7.15.0",
|
|
80
|
+
"react": "19.2.5",
|
|
81
|
+
"react-dom": "19.2.5",
|
|
82
|
+
"react-i18next": "17.0.7",
|
|
83
|
+
"typescript": "5.8.3",
|
|
84
|
+
"vitest": "3.2.0",
|
|
85
|
+
"@autional/tokens": "0.1.0-rc.11"
|
|
86
|
+
},
|
|
87
|
+
"scripts": {
|
|
88
|
+
"typecheck": "tsc --noEmit",
|
|
89
|
+
"test": "vitest run"
|
|
90
|
+
}
|
|
6
91
|
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// AntdThemeProvider —— antd 与设计系统令牌之间的**唯一**桥。
|
|
4
|
+
//
|
|
5
|
+
// 为什么放在这里,而不是让每个站点各写一份:
|
|
6
|
+
// 在此之前,三个控制台各有一份 lib/antd-app.tsx,逻辑相同(ConfigProvider + 中英 locale
|
|
7
|
+
// + dark 算法 + @autional/tokens/antd-theme 的 token)。四份同构代码就是四份会各自漂移的东西。
|
|
8
|
+
// 这一份是它的唯一实现;站点只挂 Provider,不 import antd。
|
|
9
|
+
//
|
|
10
|
+
// 为什么单独一个子路径入口(@autional/ui/antd)而不是并进根入口:
|
|
11
|
+
// 根入口的消费方里有不吃 antd 的(user 之前是、5 个 Astro 站也是)。
|
|
12
|
+
// 把 antd 并进根入口,等于让所有人替一个他们不用的库付包体积。
|
|
13
|
+
// 所以:antd 是**可选** peerDependency,默认入口 0 处 import antd(有闸门断言)。
|
|
14
|
+
|
|
15
|
+
import { useEffect, useMemo } from 'react';
|
|
16
|
+
import { App as AntdApp, ConfigProvider, theme as antdAlgorithm } from 'antd';
|
|
17
|
+
import zhCN from 'antd/locale/zh_CN';
|
|
18
|
+
import enUS from 'antd/locale/en_US';
|
|
19
|
+
import { useTranslation } from 'react-i18next';
|
|
20
|
+
// UP-31:antd 的 locale 只提供格式串(如 yearFormat 'YYYY年'),日期面板的月份/星期缩写
|
|
21
|
+
// 由 rc-picker 经 `dayjs().locale(lang.locale).localeData()` 取 —— locale 定义未加载时
|
|
22
|
+
// dayjs 静默回落 'en',面板呈「2026年 / Oct / Su Mo」中英混排。在此加载 zh-cn 定义
|
|
23
|
+
// (en 为 dayjs 内置默认,无需加载),所有挂本桥的门户日期控件一并生效。
|
|
24
|
+
import 'dayjs/locale/zh-cn';
|
|
25
|
+
// antd 主题由设计系统下发,这里**不写死任何色值**。
|
|
26
|
+
// (控制台此前硬编码过 5 个色值 + borderRadius:6,那是 KI-011:令牌变更传导不到,各控制台各自漂移。)
|
|
27
|
+
import antdTheme from '@autional/tokens/antd-theme';
|
|
28
|
+
import { useTheme } from '../context/ThemeProvider';
|
|
29
|
+
|
|
30
|
+
export interface AntdThemeProviderProps {
|
|
31
|
+
children: React.ReactNode;
|
|
32
|
+
/** 覆盖 locale 判定。默认按 i18n.language 是否以 zh 开头。 */
|
|
33
|
+
locale?: 'zh-CN' | 'en-US';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 把 antd 接到设计系统上:同一个令牌源、同一个暗色开关、同一个语言。
|
|
38
|
+
* 必须挂在 `<ThemeProvider>` 之内(它依赖 `useTheme()`)。
|
|
39
|
+
*/
|
|
40
|
+
export function AntdThemeProvider({ children, locale }: AntdThemeProviderProps) {
|
|
41
|
+
const { theme } = useTheme();
|
|
42
|
+
const { i18n } = useTranslation();
|
|
43
|
+
|
|
44
|
+
const resolved = useMemo(() => {
|
|
45
|
+
if (locale) return locale;
|
|
46
|
+
return String(i18n.language || '').toLowerCase().startsWith('zh') ? 'zh-CN' : 'en-US';
|
|
47
|
+
}, [locale, i18n.language]);
|
|
48
|
+
|
|
49
|
+
const isDark = theme === 'dark';
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ConfigProvider
|
|
53
|
+
locale={resolved === 'zh-CN' ? zhCN : enUS}
|
|
54
|
+
theme={{
|
|
55
|
+
algorithm: isDark ? antdAlgorithm.darkAlgorithm : antdAlgorithm.defaultAlgorithm,
|
|
56
|
+
token: (isDark ? antdTheme.dark : antdTheme.light).token,
|
|
57
|
+
// 组件级 token 由桥下发(不是在这里手写)。它统一的是「视觉维度」——
|
|
58
|
+
// 表头底色 / 悬浮态 / 边框 / 行高 —— 而且**对所有 antd Table 生效**,
|
|
59
|
+
// 包括控制台现存的那些直接使用,它们一行都不用改。
|
|
60
|
+
components: (isDark ? antdTheme.dark : antdTheme.light).components,
|
|
61
|
+
}}
|
|
62
|
+
>
|
|
63
|
+
<AntdApp>{children}</AntdApp>
|
|
64
|
+
</ConfigProvider>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 取 antd 的命令式 API(message / modal / notification)。
|
|
70
|
+
*
|
|
71
|
+
* 刻意**不**做模块级可变单例(那是三个控制台现在各自的做法:
|
|
72
|
+
* `export let message` + 在 useEffect 里赋值)。单例在 SSR、多实例、测试隔离三处都会咬人;
|
|
73
|
+
* 用 hook 拿,作用域天然正确。
|
|
74
|
+
*/
|
|
75
|
+
export function useAntdApp() {
|
|
76
|
+
return AntdApp.useApp();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** `App.useApp()` 的返回类型(message / notification / modal 三个命令式 API)。 */
|
|
80
|
+
export type AntdAppApi = ReturnType<typeof AntdApp.useApp>;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// Breadcrumb —— 三个门户各写了一遍的「把 URL 变成面包屑」。
|
|
4
|
+
//
|
|
5
|
+
// 逐行比对(2026-10-03):admin 145 行(antd + 78 条路由映射 + 按段累积 + 中间段可点 + 详情页启发式),
|
|
6
|
+
// platform 54 行(antd + 27 条映射 + 按段累积,但**中间段不可点**),
|
|
7
|
+
// user 77 行(**手写 nav + lucide 图标**,且**只认完整路径**、不按段累积、上限两项)。
|
|
8
|
+
// 三份的**业务部分**(路由→文案的映射表)内容完全不同,必须留在站点;
|
|
9
|
+
// 而**机制部分**三份各写一遍、还写出了三种行为:
|
|
10
|
+
// 剥租户段(admin 用 stripTenantPrefix / platform 手写 slice / user 正则 replace)、
|
|
11
|
+
// 按段累积 currentPath、中间段可点、末段纯文本、「只有一项就不渲染」。
|
|
12
|
+
// 这个组件收的就是机制,映射表通过 labels 传进来。
|
|
13
|
+
//
|
|
14
|
+
// 为什么渲染用 antd 的 Breadcrumb:三个门户里两个已经在用它(admin / platform),
|
|
15
|
+
// 而 user 那 77 行是手写的 —— 收敛的方向应该是「少数向多数靠」,而不是反过来。
|
|
16
|
+
// 顺带把 user 的「只认完整路径」也统一成按段累积:路径里若有一段在映射表里,
|
|
17
|
+
// 它会多显示一级(并成为可点的链接),这正是 admin / platform 早就有的行为。
|
|
18
|
+
|
|
19
|
+
import { Breadcrumb as AntBreadcrumb } from 'antd';
|
|
20
|
+
import { Link } from 'react-router';
|
|
21
|
+
|
|
22
|
+
export interface BreadcrumbProps {
|
|
23
|
+
/** 完整 pathname(含租户段)。剥段在本组件里做 —— 三个站点此前各写了一遍。 */
|
|
24
|
+
pathname: string;
|
|
25
|
+
/** 当前租户 slug。给了就剥掉首段,没给按原样处理。 */
|
|
26
|
+
tenantSlug?: string | null;
|
|
27
|
+
/** 路由段 → 文案。键是**累积路径**(如 '/wallet/withdrawals')。这段内容各站不同,留在站点。 */
|
|
28
|
+
labels: Record<string, string>;
|
|
29
|
+
/** 首页项。不给则不渲染首页项(只渲染映射表命中的段)。 */
|
|
30
|
+
home?: { label: string; href: string };
|
|
31
|
+
/** 认不出、但形态像 ID 的段(长度 > 8 的字母数字串)显示成什么。不给则原样显示该段。 */
|
|
32
|
+
detailLabel?: string;
|
|
33
|
+
/** 站点的链接构造(各站带租户前缀的方式不同,如 admin 走 buildNavHref)。默认原样返回。 */
|
|
34
|
+
buildHref?: (path: string) => string;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 剥租户段:`/acme/users` + slug `acme` → `/users`。 */
|
|
39
|
+
export function stripTenantSegment(pathname: string, tenantSlug?: string | null): string {
|
|
40
|
+
if (!tenantSlug) return pathname || '/';
|
|
41
|
+
const prefix = '/' + tenantSlug;
|
|
42
|
+
if (pathname === prefix) return '/';
|
|
43
|
+
return pathname.startsWith(prefix + '/') ? pathname.slice(prefix.length) : pathname;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface BreadcrumbTrailItem {
|
|
47
|
+
label: string;
|
|
48
|
+
/** 有 href 的是可点的中间段;末段没有 href。 */
|
|
49
|
+
href?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 纯函数:把站内相对路径变成面包屑的「轨迹」。抽出来是因为它才是三份实现真正重复的部分,
|
|
54
|
+
* 而且是唯一能**脱离 React 单测**的部分(渲染那层用 antd,测试价值低)。
|
|
55
|
+
*/
|
|
56
|
+
export function buildBreadcrumbTrail({
|
|
57
|
+
path,
|
|
58
|
+
labels,
|
|
59
|
+
home,
|
|
60
|
+
detailLabel,
|
|
61
|
+
buildHref = (p) => p,
|
|
62
|
+
}: {
|
|
63
|
+
path: string;
|
|
64
|
+
labels: Record<string, string>;
|
|
65
|
+
home?: { label: string; href: string };
|
|
66
|
+
detailLabel?: string;
|
|
67
|
+
/** 站点的链接构造(各站带租户前缀的方式不同)。默认原样返回。 */
|
|
68
|
+
buildHref?: (path: string) => string;
|
|
69
|
+
}): BreadcrumbTrailItem[] {
|
|
70
|
+
const trail: BreadcrumbTrailItem[] = [];
|
|
71
|
+
if (home && path !== '/') trail.push({ label: home.label, href: home.href });
|
|
72
|
+
|
|
73
|
+
const parts = path.split('/').filter(Boolean);
|
|
74
|
+
let current = '';
|
|
75
|
+
for (let i = 0; i < parts.length; i++) {
|
|
76
|
+
const part = parts[i];
|
|
77
|
+
current += '/' + part;
|
|
78
|
+
const isLast = i === parts.length - 1;
|
|
79
|
+
const known = labels[current];
|
|
80
|
+
if (known) {
|
|
81
|
+
trail.push({ label: known, href: isLast ? undefined : buildHref(current) });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
// 未命中映射表:像 ID 的段显示成 detailLabel(admin 早就有的启发式),
|
|
85
|
+
// 其余原样显示 —— **不静默丢掉一段**,否则面包屑会与实际位置不符(platform 之前就是丢掉)。
|
|
86
|
+
const looksLikeId = /^[a-zA-Z0-9_-]+$/.test(part) && part.length > 8;
|
|
87
|
+
const label = looksLikeId && detailLabel ? detailLabel : part;
|
|
88
|
+
trail.push({ label, href: isLast ? undefined : buildHref(current) });
|
|
89
|
+
}
|
|
90
|
+
return trail;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function Breadcrumb({
|
|
94
|
+
pathname,
|
|
95
|
+
tenantSlug,
|
|
96
|
+
labels,
|
|
97
|
+
home,
|
|
98
|
+
detailLabel,
|
|
99
|
+
buildHref,
|
|
100
|
+
className = 'mb-4',
|
|
101
|
+
}: BreadcrumbProps) {
|
|
102
|
+
const path = stripTenantSegment(pathname, tenantSlug);
|
|
103
|
+
if (path === '/') return null;
|
|
104
|
+
|
|
105
|
+
const trail = buildBreadcrumbTrail({ path, labels, home, detailLabel, buildHref });
|
|
106
|
+
// 只有一项(例如首页项)时不渲染 —— 三个门户原本都有这条规则,保留它。
|
|
107
|
+
if (trail.length <= 1) return null;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<AntBreadcrumb
|
|
111
|
+
className={className}
|
|
112
|
+
items={trail.map((item, i) => ({
|
|
113
|
+
title: item.href ? <Link to={item.href}>{item.label}</Link> : item.label,
|
|
114
|
+
key: (item.href ?? '') + i,
|
|
115
|
+
}))}
|
|
116
|
+
/>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// DataTable —— 四个门户共用的表格。
|
|
4
|
+
//
|
|
5
|
+
// 它是什么:**薄透传**。
|
|
6
|
+
// 统一(本组件 + 上游主题负责):密度 · 表头 · 悬浮态 · 边框 · 行高 · 分页外观 · 与 DS 令牌一致的配色
|
|
7
|
+
// 透传(本组件不碰):columns · rowKey · scroll · onRow · rowSelection · expandable
|
|
8
|
+
// · sticky · tableLayout · 以及 antd Table 的其余全部 props(...rest)
|
|
9
|
+
//
|
|
10
|
+
// 为什么必须是薄透传:三个控制台现存 156 处 antd Table,用到的 props 面远超「视觉六项」。
|
|
11
|
+
// 若这里改成一套自研的窄 API,等于**用自制 API 取代 antd 的成熟 API** —— 与「antd 比我们手写的成熟」
|
|
12
|
+
// 这个定调相反,而且 156 处都要改造。薄透传下,一致性收益收窄为「视觉六项」,但它是真的、代价可控。
|
|
13
|
+
//
|
|
14
|
+
// 视觉那六项**不在这里写死**:它们在 @autional/tokens 的 antd 桥里(components.Table),
|
|
15
|
+
// 由 AntdThemeProvider 下发给 ConfigProvider。所以即便某个门户暂时没换用 DataTable,
|
|
16
|
+
// 只要挂了 Provider,它的表格外观也已经统一 —— 覆盖面比「只统一换用处」大一个数量级。
|
|
17
|
+
|
|
18
|
+
import { Table } from 'antd';
|
|
19
|
+
import type { TableProps } from 'antd';
|
|
20
|
+
|
|
21
|
+
export type DataTableProps<T extends object = Record<string, unknown>> = TableProps<T>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 列定义类型。单独导出是为了让站点**不必**为了写 columns 去 import antd 的类型 ——
|
|
25
|
+
* 站点侧一旦 import 了 antd,C5 台账(入口棘轮)就会记上一笔,
|
|
26
|
+
* 而「一个概念只应有一个入口」正是这一层要守住的东西。
|
|
27
|
+
*/
|
|
28
|
+
export type DataTableColumns<T extends object = Record<string, unknown>> = NonNullable<TableProps<T>['columns']>;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 分页配置类型。站点此前用 antd 的 `TablePaginationConfig`;这个别名是它的替代品 ——
|
|
32
|
+
* 目标(D10 的 Table 批次)是让控制台**不再从 antd 取任何与表格相关的类型或组件**,
|
|
33
|
+
* 少一个类型别名,那批就少一处 import antd 的理由。
|
|
34
|
+
*/
|
|
35
|
+
export type DataTablePagination<T extends object = Record<string, unknown>> = Exclude<NonNullable<TableProps<T>['pagination']>, false>;
|
|
36
|
+
|
|
37
|
+
function DataTableInner<T extends object = Record<string, unknown>>({
|
|
38
|
+
size = 'middle',
|
|
39
|
+
pagination,
|
|
40
|
+
...rest
|
|
41
|
+
}: TableProps<T>) {
|
|
42
|
+
// 分页外观是 DS 的事(每页条数可切换);调用方传 pagination 时仍以它为准。
|
|
43
|
+
const mergedPagination =
|
|
44
|
+
pagination === false || pagination === undefined
|
|
45
|
+
? pagination
|
|
46
|
+
: { showSizeChanger: true, ...pagination };
|
|
47
|
+
|
|
48
|
+
return <Table<T> size={size} pagination={mergedPagination} {...rest} />;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 复合成员必须一并透传 —— 这是「薄透传」契约的一部分,不是可选的糖。
|
|
53
|
+
*
|
|
54
|
+
* 为什么:antd 的 Table 是**复合组件**(Table.Summary / Column / ColumnGroup / 选择与展开的哨兵)。
|
|
55
|
+
* 只导出一个函数组件,等于把这些能力从 DS 入口里**删掉** —— 消费方要用汇总行时只剩两条路:
|
|
56
|
+
* ① 直接 import antd(C5 台账多一笔,两个入口);
|
|
57
|
+
* ② 自己拼裸 <tr>/<td> 绕过 DS 的表格样式(那就等于没用 DS)。
|
|
58
|
+
* 两条路都破坏「一个概念一个入口」。
|
|
59
|
+
* 实测就是这么撞上的:user 的发票明细表需要一行合计(原来的手写表用 <tfoot>)。
|
|
60
|
+
*/
|
|
61
|
+
type DataTableStatics = Pick<
|
|
62
|
+
typeof Table,
|
|
63
|
+
| 'Summary'
|
|
64
|
+
| 'Column'
|
|
65
|
+
| 'ColumnGroup'
|
|
66
|
+
| 'SELECTION_COLUMN'
|
|
67
|
+
| 'SELECTION_ALL'
|
|
68
|
+
| 'SELECTION_INVERT'
|
|
69
|
+
| 'SELECTION_NONE'
|
|
70
|
+
| 'EXPAND_COLUMN'
|
|
71
|
+
>;
|
|
72
|
+
|
|
73
|
+
export const DataTable = Object.assign(DataTableInner, {
|
|
74
|
+
Summary: Table.Summary,
|
|
75
|
+
Column: Table.Column,
|
|
76
|
+
ColumnGroup: Table.ColumnGroup,
|
|
77
|
+
SELECTION_COLUMN: Table.SELECTION_COLUMN,
|
|
78
|
+
SELECTION_ALL: Table.SELECTION_ALL,
|
|
79
|
+
SELECTION_INVERT: Table.SELECTION_INVERT,
|
|
80
|
+
SELECTION_NONE: Table.SELECTION_NONE,
|
|
81
|
+
EXPAND_COLUMN: Table.EXPAND_COLUMN,
|
|
82
|
+
}) as typeof DataTableInner & DataTableStatics;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// DateRangeFilter —— 日期区间筛选器(antd RangePicker 之上的薄封装)。
|
|
4
|
+
//
|
|
5
|
+
// 它要解决的问题很小、但每个站点都在各自解决一遍:antd 的 RangePicker 收发的值是 dayjs 对象,
|
|
6
|
+
// 而**查询参数要的是字符串**。于是每个调用点都得记住用 onChange 的第二个参数(格式化后的字符串数组),
|
|
7
|
+
// 或者自己 dayjs() 一遍。实测控制台里已经有人这么写了:
|
|
8
|
+
// onChange={(_, dates) => setDateRange(dates && dates[0] && dates[1] ? (dates as [string,string]) : null)}
|
|
9
|
+
// —— 也就是说这个约定是**既成事实**,只是没人把它固化下来。固化在这里之后:
|
|
10
|
+
// · 站点不再需要 import dayjs(值进值出都是字符串);
|
|
11
|
+
// · 「空区间」只有一种表示(null),不再有 undefined / [] / ['',''] 三种写法;
|
|
12
|
+
// · 显示格式与产出格式**是同一个** `format`,不会出现「显示带时间、产出只有日期」这种静默丢数据。
|
|
13
|
+
//
|
|
14
|
+
// 与 DataTable 同一条边界:DS 只固定**跨门户必须一致的部分**(值域类型、空值语义、与设计系统同源的
|
|
15
|
+
// 外观/locale),其余(showTime / disabledDate / className / placeholder / presets…)全部透传。
|
|
16
|
+
|
|
17
|
+
import { DatePicker } from 'antd';
|
|
18
|
+
import dayjs from 'dayjs';
|
|
19
|
+
import type { Dayjs } from 'dayjs';
|
|
20
|
+
import type { RangePickerProps } from 'antd/es/date-picker';
|
|
21
|
+
|
|
22
|
+
const { RangePicker } = DatePicker;
|
|
23
|
+
|
|
24
|
+
/** 区间值:ISO 或与 `format` 一致的字符串二元组;`null` 表示未选。 */
|
|
25
|
+
export type DateRangeValue = [string, string] | null;
|
|
26
|
+
|
|
27
|
+
// 除 value / onChange / format 之外全部透传给 antd RangePicker。
|
|
28
|
+
export interface DateRangeFilterProps extends Omit<RangePickerProps, 'value' | 'onChange' | 'format'> {
|
|
29
|
+
/** 当前区间。`null` / 省略表示无筛选。 */
|
|
30
|
+
value?: DateRangeValue;
|
|
31
|
+
/** 选中区间时回调;清空时回调 `null`(**不是**空数组或空串)。 */
|
|
32
|
+
onChange?: (value: DateRangeValue) => void;
|
|
33
|
+
/** 显示格式,同时也是**产出字符串的格式**(默认 `YYYY-MM-DD`)。带 showTime 时应显式给到含时间的格式。 */
|
|
34
|
+
format?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_FORMAT = 'YYYY-MM-DD';
|
|
38
|
+
|
|
39
|
+
const toDayjs = (s: string | undefined): Dayjs | null => {
|
|
40
|
+
const d = s ? dayjs(s) : null;
|
|
41
|
+
return d && d.isValid() ? d : null;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function DateRangeFilter({ value, onChange, format = DEFAULT_FORMAT, ...rest }: DateRangeFilterProps) {
|
|
45
|
+
const range: [Dayjs | null, Dayjs | null] | null = value ? [toDayjs(value[0]), toDayjs(value[1])] : null;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<RangePicker
|
|
49
|
+
{...rest}
|
|
50
|
+
format={format}
|
|
51
|
+
value={range}
|
|
52
|
+
onChange={(dates) => {
|
|
53
|
+
// antd 在清空时给 null(有时是 [null, null]),统一收敛成一种空值表示。
|
|
54
|
+
const start = dates?.[0];
|
|
55
|
+
const end = dates?.[1];
|
|
56
|
+
if (!start || !end) {
|
|
57
|
+
onChange?.(null);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
onChange?.([start.format(format), end.format(format)]);
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Drawer as AntDrawer } from 'antd';
|
|
5
|
+
import type { DrawerProps as AntDrawerProps } from 'antd';
|
|
6
|
+
|
|
7
|
+
// Drawer —— 详情/编辑抽屉(antd Drawer 之上的薄封装)。
|
|
8
|
+
//
|
|
9
|
+
// 与 DataTable 同一条边界:DS 只固定**跨门户必须一致的部分**,其余全部透传。
|
|
10
|
+
//
|
|
11
|
+
// 实测(2026-10-04,21 处直接使用 antd Drawer)暴露的是**词汇**问题,不是外观问题:
|
|
12
|
+
// admin size={600} / size="large" / size={500} / size={560} / size={720}
|
|
13
|
+
// platform (不写尺寸,落到 antd 默认)
|
|
14
|
+
// security width={600} / width={560} / width={700} / width={520} / placement="right" / destroyOnClose
|
|
15
|
+
// 也就是说:同一个东西有**两个属性名**(antd 6 把 width 收成了 size,security 还停在旧名),
|
|
16
|
+
// 尺寸是**八个各写各的数字**,destroyOnClose 在 antd 6.5 里已标 @deprecated(新名 destroyOnHidden)——
|
|
17
|
+
// 那是「改了一半的迁移」留在源码里的样子。
|
|
18
|
+
//
|
|
19
|
+
// 所以这一件固定三样:
|
|
20
|
+
// ① **尺寸词汇**:sm/md/lg/xl 四档(480 / 560 / 720 / 960),不再接受任意数字;
|
|
21
|
+
// ② **placement 恒为 right**:本舰队的抽屉都是「右侧详情」,把方向也收进契约;
|
|
22
|
+
// ③ **不再暴露 width 与 destroyOnClose**:前者是旧名,后者是废弃名(类型上直接 Omit 掉,
|
|
23
|
+
// 写错就编译不过 —— 比在文档里写「请用新名字」有效)。
|
|
24
|
+
//
|
|
25
|
+
// 尺寸阶梯是按现有取值就近归的,不是拍脑袋:
|
|
26
|
+
// 500/520 → sm(480) · 560/600 → md(560) · 700/720/antd-large(736) → lg(720)
|
|
27
|
+
export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
28
|
+
|
|
29
|
+
const SIZES: Record<DrawerSize, number> = { sm: 480, md: 560, lg: 720, xl: 960 };
|
|
30
|
+
|
|
31
|
+
export interface DrawerProps
|
|
32
|
+
extends Omit<AntDrawerProps, 'size' | 'width' | 'destroyOnClose' | 'placement'> {
|
|
33
|
+
/** 尺寸档位,默认 md(560px)。 */
|
|
34
|
+
size?: DrawerSize;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function Drawer({ size = 'md', ...rest }: DrawerProps) {
|
|
38
|
+
return <AntDrawer {...rest} placement="right" size={SIZES[size]} />;
|
|
39
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { Modal as AntModal } from 'antd';
|
|
5
|
+
import type { ModalProps as AntModalProps } from 'antd';
|
|
6
|
+
import { useTranslation } from 'react-i18next';
|
|
7
|
+
import { UI_I18N_NS, uiText } from '../i18n';
|
|
8
|
+
|
|
9
|
+
// Modal —— 对话框(antd Modal 之上的薄封装)。
|
|
10
|
+
//
|
|
11
|
+
// 与 Drawer 同一条边界:DS 只固定**跨门户必须一致的部分**,其余全部透传。
|
|
12
|
+
//
|
|
13
|
+
// 这一件只固定一样:**关闭按钮的无障碍名称**。
|
|
14
|
+
// 按钮的 aria-label 由 rc-dialog 写死为英文 "Close"(Panel 里 `aria-label: "Close"`),
|
|
15
|
+
// antd 的 locale 只把它翻到关闭**图标**元素上、到不了按钮本身 —— 读屏用户听到的恒为 "Close"。
|
|
16
|
+
// 能压过它的只有 closable 对象里的 aria-* 属性(rc-dialog 把它们展开在写死值**之后**),
|
|
17
|
+
// 所以这里默认注入本地化标签;用户显式在 closable 里传了 aria-label 就以用户的为准。
|
|
18
|
+
//
|
|
19
|
+
// 文案走设计系统那套解析链(站点 i18next 注册过就优先,没有就回落内置表),与 PageStatus 同一条。
|
|
20
|
+
|
|
21
|
+
export type ModalProps = AntModalProps;
|
|
22
|
+
|
|
23
|
+
export function Modal({ closable, ...rest }: ModalProps) {
|
|
24
|
+
const { t, i18n } = useTranslation();
|
|
25
|
+
const closeLabel = t('modal.close', {
|
|
26
|
+
ns: UI_I18N_NS,
|
|
27
|
+
defaultValue: uiText(i18n.language, 'modal.close') ?? '关闭',
|
|
28
|
+
});
|
|
29
|
+
// closable === false 保持 false(不渲染关闭按钮);true/undefined/对象统一并入本地化标签。
|
|
30
|
+
const mergedClosable =
|
|
31
|
+
closable === false
|
|
32
|
+
? false
|
|
33
|
+
: {
|
|
34
|
+
'aria-label': closeLabel,
|
|
35
|
+
...(typeof closable === 'object' && closable !== null ? closable : {}),
|
|
36
|
+
};
|
|
37
|
+
return <AntModal closable={mergedClosable} {...rest} />;
|
|
38
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// 控制台的整页占位:加载中 / 加载失败(可重试)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么把它收进设计系统:admin 与 platform 此前**各有一份**
|
|
6
|
+
// src/components/ui/page-status.tsx,内容逐字相同(同一个居中 Spin、同一个
|
|
7
|
+
// Empty.PRESENTED_IMAGE_SIMPLE、同一个重试按钮),唯一差别是 admin 走 react-i18next、
|
|
8
|
+
// platform 把中文写死在组件里 —— 于是同一件事有了两份实现,而且其中一份永远只能是中文。
|
|
9
|
+
// 实测用量:admin 70 个文件 import 它 / <PageError> 96 处;platform 13 个文件 /
|
|
10
|
+
// <PageError> 43 处 + <PageLoading> 13 处。抄成两份的东西必然有一份先坏,这里留唯一一份。
|
|
11
|
+
//
|
|
12
|
+
// 文案走设计系统那套解析链(站点 i18next 注册过就优先,没有就回落内置表),与 PortalSwitcher 同一条。
|
|
13
|
+
// 重试图标用 lucide 而不是 @ant-design/icons:设计系统已有的组件(ErrorState)就是 lucide,
|
|
14
|
+
// 而 antd 的图标包是本包**没有**声明的依赖 —— 为了一个重试图标去给所有人加一条 peer 不划算。
|
|
15
|
+
|
|
16
|
+
import { Button, Empty, Spin } from 'antd';
|
|
17
|
+
import { RefreshCw } from 'lucide-react';
|
|
18
|
+
import { useTranslation } from 'react-i18next';
|
|
19
|
+
import { UI_I18N_NS, uiText } from '../i18n';
|
|
20
|
+
|
|
21
|
+
export interface PageLoadingProps {
|
|
22
|
+
/** 覆盖默认的「加载中…」。 */
|
|
23
|
+
tip?: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PageErrorProps {
|
|
28
|
+
/** 覆盖默认的「数据加载失败」。 */
|
|
29
|
+
message?: string;
|
|
30
|
+
retry?: () => void;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function useStatusText() {
|
|
35
|
+
const { t, i18n } = useTranslation();
|
|
36
|
+
return (key: string, lastResort: string): string => {
|
|
37
|
+
const builtin = uiText(i18n.language, key);
|
|
38
|
+
return t(key, { ns: UI_I18N_NS, defaultValue: builtin ?? lastResort });
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 整页加载占位(居中 Spin)。 */
|
|
43
|
+
export function PageLoading({ tip, className = '' }: PageLoadingProps) {
|
|
44
|
+
const text = useStatusText();
|
|
45
|
+
return (
|
|
46
|
+
<div className={`flex h-64 items-center justify-center ${className}`}>
|
|
47
|
+
<Spin size="large" description={tip ?? text('pageStatus.loading', '加载中…')} />
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 整页错误占位(带重试按钮)。 */
|
|
53
|
+
export function PageError({ message, retry, className = '' }: PageErrorProps) {
|
|
54
|
+
const text = useStatusText();
|
|
55
|
+
return (
|
|
56
|
+
<div className={`flex h-64 flex-col items-center justify-center gap-4 ${className}`}>
|
|
57
|
+
<Empty
|
|
58
|
+
description={message ?? text('pageStatus.loadError', '数据加载失败')}
|
|
59
|
+
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
60
|
+
/>
|
|
61
|
+
{retry && (
|
|
62
|
+
<Button icon={<RefreshCw className="h-4 w-4" />} onClick={retry}>
|
|
63
|
+
{text('pageStatus.retry', '重试')}
|
|
64
|
+
</Button>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// 数据密集入口 —— 消费方从这里取 antd 能力,不直接 import 'antd'。
|
|
2
|
+
//
|
|
3
|
+
// 这一句不是形式主义:四站各自 import antd,同一个概念就会长出四种用法(实测控制台侧
|
|
4
|
+
// 152 处直接 import,user 侧 0 处、手写 13 个表格)。统一入口之后,一致性才来自 API 而不是巧合。
|
|
5
|
+
|
|
6
|
+
export { AntdThemeProvider, useAntdApp } from './AntdThemeProvider';
|
|
7
|
+
export type { AntdThemeProviderProps, AntdAppApi } from './AntdThemeProvider';
|
|
8
|
+
export { Breadcrumb, buildBreadcrumbTrail, stripTenantSegment } from './Breadcrumb';
|
|
9
|
+
export type { BreadcrumbProps, BreadcrumbTrailItem } from './Breadcrumb';
|
|
10
|
+
export { DateRangeFilter } from './DateRangeFilter';
|
|
11
|
+
export type { DateRangeFilterProps, DateRangeValue } from './DateRangeFilter';
|
|
12
|
+
export { Drawer } from './Drawer';
|
|
13
|
+
export type { DrawerProps, DrawerSize } from './Drawer';
|
|
14
|
+
export { Modal } from './Modal';
|
|
15
|
+
export type { ModalProps } from './Modal';
|
|
16
|
+
|
|
17
|
+
// 整页占位:控制台的加载 / 加载失败。与 DataTable 同一个入口,站点一次 import 拿全。
|
|
18
|
+
export { PageLoading, PageError } from './PageStatus';
|
|
19
|
+
export type { PageLoadingProps, PageErrorProps } from './PageStatus';
|
|
20
|
+
export { DataTable } from './DataTable';
|
|
21
|
+
export type { DataTableProps, DataTableColumns, DataTablePagination } from './DataTable';
|
|
22
|
+
|
|
23
|
+
// 类型再导出:站点侧的命令式 API 门面(`export let message` 那类)需要这些类型,
|
|
24
|
+
// 若让它们各自 `import type ... from 'antd/es/message/interface'`,
|
|
25
|
+
// 「站点不直接依赖 antd 的内部路径」这条就破了——antd 只是本包的可选 peer。
|
|
26
|
+
export type { MessageInstance } from 'antd/es/message/interface';
|
|
27
|
+
export type { NotificationInstance } from 'antd/es/notification/interface';
|