@allbluecn/admin-app 0.3.3 → 0.3.4

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 (41) hide show
  1. package/allblue-ui/package.json +6 -0
  2. package/allblue-ui/src/components/confirm-dialog.tsx +94 -0
  3. package/allblue-ui/src/components/infinite-scroll-sentinel.tsx +37 -0
  4. package/allblue-ui/src/components/notion-avatar.tsx +126 -0
  5. package/allblue-ui/src/components/user-avatar.tsx +82 -0
  6. package/allblue-ui/src/data-table/data-table-pagination.tsx +121 -0
  7. package/allblue-ui/src/data-table/data-table.tsx +218 -0
  8. package/allblue-ui/src/data-table/table-detail-layout.tsx +97 -0
  9. package/allblue-ui/src/hooks/use-load-more.ts +57 -0
  10. package/allblue-ui/src/hooks/use-mobile.ts +19 -0
  11. package/allblue-ui/src/index.ts +31 -0
  12. package/allblue-ui/src/lib/avatar.ts +41 -0
  13. package/allblue-ui/src/lib/datetime.ts +226 -0
  14. package/allblue-ui/src/lib/sanitize.ts +18 -0
  15. package/allblue-ui/src/lib/utils.ts +1 -0
  16. package/allblue-ui/src/ui/alert-dialog.tsx +185 -0
  17. package/allblue-ui/src/ui/alert.tsx +75 -0
  18. package/allblue-ui/src/ui/avatar.tsx +106 -0
  19. package/allblue-ui/src/ui/badge.tsx +51 -0
  20. package/allblue-ui/src/ui/button.tsx +57 -0
  21. package/allblue-ui/src/ui/card.tsx +102 -0
  22. package/allblue-ui/src/ui/checkbox.tsx +27 -0
  23. package/allblue-ui/src/ui/collapsible.tsx +19 -0
  24. package/allblue-ui/src/ui/dialog.tsx +155 -0
  25. package/allblue-ui/src/ui/dropdown-menu.tsx +265 -0
  26. package/allblue-ui/src/ui/field.tsx +236 -0
  27. package/allblue-ui/src/ui/input-group.tsx +157 -0
  28. package/allblue-ui/src/ui/input.tsx +19 -0
  29. package/allblue-ui/src/ui/label.tsx +17 -0
  30. package/allblue-ui/src/ui/pagination.tsx +130 -0
  31. package/allblue-ui/src/ui/select.tsx +232 -0
  32. package/allblue-ui/src/ui/separator.tsx +23 -0
  33. package/allblue-ui/src/ui/sheet.tsx +134 -0
  34. package/allblue-ui/src/ui/sidebar.tsx +721 -0
  35. package/allblue-ui/src/ui/skeleton.tsx +13 -0
  36. package/allblue-ui/src/ui/switch.tsx +30 -0
  37. package/allblue-ui/src/ui/table.tsx +113 -0
  38. package/allblue-ui/src/ui/textarea.tsx +17 -0
  39. package/allblue-ui/src/ui/toast.tsx +308 -0
  40. package/allblue-ui/src/ui/tooltip.tsx +64 -0
  41. package/package.json +3 -2
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { useNavigate } from "@tanstack/react-router";
19
+ import { Button } from "../ui/button";
20
+ import { Skeleton } from "../ui/skeleton";
21
+ import { ArrowLeftIcon } from "lucide-react";
22
+
23
+ interface TableDetailLayoutProps {
24
+ backHref: string;
25
+ // i18n 由消费方注入(包内不依赖 @/paraglide),原 admin 实现默认回退文案由调用方显式传入
26
+ backLabel: string;
27
+ title: string;
28
+ subtitle?: string;
29
+ avatar?: React.ReactNode;
30
+ badges?: React.ReactNode;
31
+ actions?: React.ReactNode;
32
+ loading?: boolean;
33
+ children: React.ReactNode;
34
+ }
35
+
36
+ export function TableDetailLayout({
37
+ backHref,
38
+ backLabel,
39
+ title,
40
+ subtitle,
41
+ avatar,
42
+ badges,
43
+ actions,
44
+ loading,
45
+ children,
46
+ }: TableDetailLayoutProps) {
47
+ const navigate = useNavigate();
48
+
49
+ if (loading) {
50
+ return (
51
+ <div className="p-6 space-y-6">
52
+ <Skeleton className="h-8 w-24" />
53
+ <div className="flex items-center gap-4">
54
+ <Skeleton className="h-12 w-12 rounded-full" />
55
+ <div className="space-y-2">
56
+ <Skeleton className="h-6 w-40" />
57
+ <Skeleton className="h-4 w-60" />
58
+ </div>
59
+ </div>
60
+ <Skeleton className="h-64 w-full" />
61
+ </div>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <div>
67
+ <div className="sticky top-0 z-10 bg-background border-b px-6 py-2.5">
68
+ <Button
69
+ variant="ghost"
70
+ size="sm"
71
+ onClick={() => navigate({ to: backHref })}
72
+ className="gap-1.5 text-muted-foreground hover:text-foreground"
73
+ >
74
+ <ArrowLeftIcon className="h-4 w-4" />
75
+ {backLabel}
76
+ </Button>
77
+ </div>
78
+ <div className="p-6 pt-4 space-y-6">
79
+ <div className="flex items-start justify-between">
80
+ <div className="flex items-center gap-4">
81
+ {avatar}
82
+ <div>
83
+ <div className="flex items-center gap-2">
84
+ <h1 className="text-2xl font-bold">{title}</h1>
85
+ {badges}
86
+ </div>
87
+ {subtitle && <p className="text-muted-foreground mt-1">{subtitle}</p>}
88
+ </div>
89
+ </div>
90
+ {actions && <div className="flex gap-2">{actions}</div>}
91
+ </div>
92
+
93
+ {children}
94
+ </div>
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ import { useEffect, useRef, useState } from "react";
6
+
7
+ /**
8
+ * 无限滚动加载状态机:IntersectionObserver 哨兵 + loading 防重入。
9
+ *
10
+ * 哨兵进入视口且 hasMore 时触发 onLoadMore;返回 Promise 时 resolve 后解除
11
+ * loading,同步返回则由调用方经 hasMore/数据更新间接解除。供
12
+ * InfiniteScrollSentinel 等无限滚动组件复用。
13
+ */
14
+ export function useLoadMore(
15
+ onLoadMore: () => void | Promise<void>,
16
+ hasMore: boolean,
17
+ rootRef?: React.RefObject<HTMLElement | null>,
18
+ ): {
19
+ /** 挂到滚动末端哨兵元素上 */
20
+ sentinelRef: React.RefObject<HTMLDivElement | null>;
21
+ loading: boolean;
22
+ } {
23
+ const sentinelRef = useRef<HTMLDivElement>(null);
24
+ const observerRef = useRef<IntersectionObserver | null>(null);
25
+ const [loading, setLoading] = useState(false);
26
+ const loadingRef = useRef(false);
27
+
28
+ useEffect(() => {
29
+ loadingRef.current = loading;
30
+ }, [loading]);
31
+
32
+ useEffect(() => {
33
+ if (!sentinelRef.current || !hasMore) return;
34
+
35
+ if (observerRef.current) observerRef.current.disconnect();
36
+
37
+ const observer = new IntersectionObserver(
38
+ (entries) => {
39
+ if (entries[0]?.isIntersecting && !loadingRef.current) {
40
+ setLoading(true);
41
+ const result = onLoadMore();
42
+ if (result && typeof (result as Promise<void>).then === "function") {
43
+ (result as Promise<void>).then(() => setLoading(false));
44
+ }
45
+ }
46
+ },
47
+ { root: rootRef?.current ?? null, rootMargin: "80px" },
48
+ );
49
+
50
+ observer.observe(sentinelRef.current);
51
+ observerRef.current = observer;
52
+ return () => observer.disconnect();
53
+ // onLoadMore 由调用方保证稳定(多为 useCallback),与原实现一致仅依赖 hasMore/rootRef
54
+ }, [hasMore, rootRef]);
55
+
56
+ return { sentinelRef, loading };
57
+ }
@@ -0,0 +1,19 @@
1
+ import * as React from "react"
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
7
+
8
+ React.useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12
+ }
13
+ mql.addEventListener("change", onChange)
14
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15
+ return () => mql.removeEventListener("change", onChange)
16
+ }, [])
17
+
18
+ return !!isMobile
19
+ }
@@ -0,0 +1,31 @@
1
+ export * from "./ui/alert"
2
+ export * from "./ui/alert-dialog"
3
+ export * from "./ui/avatar"
4
+ export * from "./ui/badge"
5
+ export * from "./ui/button"
6
+ export * from "./ui/card"
7
+ export * from "./ui/checkbox"
8
+ export * from "./ui/collapsible"
9
+ export * from "./ui/dialog"
10
+ export * from "./ui/dropdown-menu"
11
+ export * from "./ui/field"
12
+ export * from "./ui/input"
13
+ export * from "./ui/input-group"
14
+ export * from "./ui/label"
15
+ export * from "./ui/pagination"
16
+ export * from "./ui/select"
17
+ export * from "./ui/separator"
18
+ export * from "./ui/sheet"
19
+ export * from "./ui/sidebar"
20
+ export * from "./ui/skeleton"
21
+ export * from "./ui/switch"
22
+ export * from "./ui/table"
23
+ export * from "./ui/textarea"
24
+ export * from "./ui/toast"
25
+ export * from "./ui/tooltip"
26
+ export { ConfirmDialog, type ConfirmDialogProps } from "./components/confirm-dialog"
27
+ export { InfiniteScrollSentinel } from "./components/infinite-scroll-sentinel"
28
+ export { useLoadMore } from "./hooks/use-load-more"
29
+ export { useIsMobile } from "./hooks/use-mobile"
30
+ export { getBoringAvatarUrl, getAvatarFallback, type AvatarOptions } from "./lib/avatar"
31
+ export { cn } from "./lib/utils"
@@ -0,0 +1,41 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ const DICE_BEAR_BASE = "https://api.dicebear.com/7.x";
19
+
20
+ const DICE_BEAR_STYLES = ["avataaars", "micah", "notionists", "lorelei", "adventurer"] as const;
21
+
22
+ type DiceBearStyle = (typeof DICE_BEAR_STYLES)[number];
23
+
24
+ export interface AvatarOptions {
25
+ style?: DiceBearStyle;
26
+ size?: number;
27
+ square?: boolean;
28
+ }
29
+
30
+ export function getBoringAvatarUrl(
31
+ seed: string,
32
+ options: AvatarOptions = {}
33
+ ): string {
34
+ const { style = "avataaars", size = 80 } = options;
35
+
36
+ return `${DICE_BEAR_BASE}/${style}/svg?seed=${encodeURIComponent(seed)}&size=${size}`;
37
+ }
38
+
39
+ export function getAvatarFallback(name: string): string {
40
+ return name.charAt(0).toUpperCase();
41
+ }
@@ -0,0 +1,226 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+ //
5
+ // This program is free software: you can redistribute it and/or modify
6
+ // it under the terms of the GNU Affero General Public License as published by
7
+ // the Free Software Foundation, either version 3 of the License, or
8
+ // (at your option) any later version.
9
+ //
10
+ // This program is distributed in the hope that it will be useful,
11
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
12
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
+ // GNU Affero General Public License for more details.
14
+ //
15
+ // You should have received a copy of the GNU Affero General Public License
16
+ // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
+
18
+ import { formatInTimeZone, toZonedTime, fromZonedTime } from 'date-fns-tz'
19
+ import { startOfDay, subDays } from 'date-fns'
20
+
21
+ const FALLBACK_TIMEZONE = 'Asia/Shanghai'
22
+
23
+ /**
24
+ * 解析生效时区:项目时区 > 用户时区 > 浏览器时区 > Asia/Shanghai
25
+ */
26
+ export function resolveTimezone(
27
+ projectTz?: string | null,
28
+ userTz?: string | null,
29
+ ): string {
30
+ const browserTz =
31
+ typeof Intl !== 'undefined'
32
+ ? Intl.DateTimeFormat().resolvedOptions().timeZone
33
+ : FALLBACK_TIMEZONE
34
+ return projectTz || userTz || browserTz || FALLBACK_TIMEZONE
35
+ }
36
+
37
+ /**
38
+ * 时区感知的时间格式化。
39
+ * @param date UTC Date 对象(来自 DB)
40
+ * @param timezone IANA 时区,如 "Asia/Shanghai"
41
+ * @param fmt date-fns 格式,默认 "yyyy-MM-dd HH:mm"
42
+ */
43
+ export function formatDate(
44
+ date: Date,
45
+ timezone: string,
46
+ fmt = 'yyyy-MM-dd HH:mm',
47
+ ): string {
48
+ try {
49
+ return formatInTimeZone(date, timezone, fmt)
50
+ } catch {
51
+ return formatInTimeZone(date, FALLBACK_TIMEZONE, fmt)
52
+ }
53
+ }
54
+
55
+ /**
56
+ * 确定性时间戳格式化:固定时区 + date-fns 数字格式,不依赖运行环境 locale/时区。
57
+ * SSR 与客户端输出字节级一致,专治 toLocaleString() 引发的 hydration mismatch。
58
+ * @param date Date 对象或 ISO 字符串
59
+ * @param fmt date-fns 格式,默认 "yyyy-MM-dd HH:mm"
60
+ */
61
+ export function formatTimestamp(
62
+ date: Date | string,
63
+ fmt = 'yyyy-MM-dd HH:mm',
64
+ ): string {
65
+ const d = typeof date === 'string' ? new Date(date) : date
66
+ if (Number.isNaN(d.getTime())) return '-'
67
+ return formatDate(d, FALLBACK_TIMEZONE, fmt)
68
+ }
69
+
70
+ /**
71
+ * 相对时间(今天/昨天/具体日期),基于时区计算「今天的 0 点」。
72
+ * @param now 当前时间(测试注入;生产用默认 new Date())
73
+ */
74
+ export function formatRelative(
75
+ date: Date,
76
+ timezone: string,
77
+ now: Date = new Date(),
78
+ ): string {
79
+ try {
80
+ const todayStart = startOfDayInTimeZone(now, timezone)
81
+ const yesterdayStart = new Date(todayStart)
82
+ yesterdayStart.setDate(yesterdayStart.getDate() - 1)
83
+
84
+ const dateStart = startOfDayInTimeZone(date, timezone)
85
+
86
+ if (dateStart.getTime() === todayStart.getTime()) return '今天'
87
+ if (dateStart.getTime() === yesterdayStart.getTime()) return '昨天'
88
+ return formatInTimeZone(date, timezone, 'yyyy-MM-dd')
89
+ } catch {
90
+ return formatInTimeZone(date, FALLBACK_TIMEZONE, 'yyyy-MM-dd')
91
+ }
92
+ }
93
+
94
+ /**
95
+ * 获取指定时区下某天的 0 点(UTC Date 对象)。
96
+ */
97
+ function startOfDayInTimeZone(date: Date, timezone: string): Date {
98
+ const zoned = toZonedTime(date, timezone)
99
+ const dayStart = startOfDay(zoned)
100
+ return fromZonedTime(dayStart, timezone)
101
+ }
102
+
103
+ /**
104
+ * 对话历史日期分组标签(今天/昨天/本周/具体日期)。
105
+ * @param date UTC Date 对象
106
+ * @param timezone IANA 时区
107
+ */
108
+ export function formatChatDate(date: Date, timezone: string): string {
109
+ try {
110
+ const now = new Date()
111
+ const todayStart = startOfDayInTimeZone(now, timezone)
112
+ const yesterdayStart = subDays(todayStart, 1)
113
+ const weekStart = subDays(todayStart, 7)
114
+
115
+ const dateStart = startOfDayInTimeZone(date, timezone)
116
+
117
+ if (dateStart.getTime() === todayStart.getTime()) return '今天'
118
+ if (dateStart.getTime() === yesterdayStart.getTime()) return '昨天'
119
+ if (dateStart.getTime() >= weekStart.getTime()) return '本周'
120
+
121
+ const currentYear = Number(formatInTimeZone(now, timezone, 'yyyy'))
122
+ const dateYear = Number(formatInTimeZone(date, timezone, 'yyyy'))
123
+ if (dateYear === currentYear) {
124
+ return formatInTimeZone(date, timezone, 'M月d日')
125
+ }
126
+ return formatInTimeZone(date, timezone, 'yyyy年M月d日')
127
+ } catch {
128
+ return formatInTimeZone(date, FALLBACK_TIMEZONE, 'yyyy-MM-dd')
129
+ }
130
+ }
131
+
132
+
133
+ // ---- 本地日历工具(自 lib/date-utils.ts 并入:按本地时区做日历网格/日键计算,无时区转换) ----
134
+
135
+ /** 解析 "yyyy-MM-dd" 前缀为本地 Date(避免 UTC 偏移) */
136
+ export function parseDate(s: string): Date {
137
+ const datePart = s.slice(0, 10)
138
+ const [y, m, d] = datePart.split('-').map(Number)
139
+ return new Date(y, m - 1, d)
140
+ }
141
+
142
+ /** 本地时区 yyyy-MM-dd 日键(与 timezone 版 formatDate 区分) */
143
+ export function formatDateLocal(d: Date): string {
144
+ const y = d.getFullYear()
145
+ const m = String(d.getMonth() + 1).padStart(2, '0')
146
+ const day = String(d.getDate()).padStart(2, '0')
147
+ return `${y}-${m}-${day}`
148
+ }
149
+
150
+ export function isSameDay(a: Date, b: Date): boolean {
151
+ return (
152
+ a.getFullYear() === b.getFullYear() &&
153
+ a.getMonth() === b.getMonth() &&
154
+ a.getDate() === b.getDate()
155
+ )
156
+ }
157
+
158
+ export function getMonthGrid(
159
+ year: number,
160
+ month: number,
161
+ ): Array<{ date: Date; inMonth: boolean }> {
162
+ const firstDay = new Date(year, month, 1)
163
+ const startOffset = firstDay.getDay() // 周日=0
164
+ const gridStart = new Date(year, month, 1 - startOffset)
165
+ const result: Array<{ date: Date; inMonth: boolean }> = []
166
+ for (let i = 0; i < 42; i++) {
167
+ const date = new Date(gridStart)
168
+ date.setDate(gridStart.getDate() + i)
169
+ result.push({ date, inMonth: date.getMonth() === month })
170
+ }
171
+ return result
172
+ }
173
+
174
+ export function getWeekGrid(
175
+ year: number,
176
+ month: number,
177
+ day: number,
178
+ ): Array<{ date: Date; inMonth: boolean }> {
179
+ const anchor = new Date(year, month, day)
180
+ const startOffset = anchor.getDay()
181
+ const gridStart = new Date(year, month, day - startOffset)
182
+ const result: Array<{ date: Date; inMonth: boolean }> = []
183
+ for (let i = 0; i < 7; i++) {
184
+ const date = new Date(gridStart)
185
+ date.setDate(gridStart.getDate() + i)
186
+ result.push({ date, inMonth: date.getMonth() === month })
187
+ }
188
+ return result
189
+ }
190
+
191
+ export function buildTimelineRange(
192
+ start: Date,
193
+ end: Date,
194
+ mode: 'month' | 'week',
195
+ ): Array<{ start: Date; label: string }> {
196
+ const ticks: Array<{ start: Date; label: string }> = []
197
+ const cursor = new Date(start.getFullYear(), start.getMonth(), 1)
198
+ const endDate = new Date(end.getFullYear(), end.getMonth(), 1)
199
+ while (cursor <= endDate) {
200
+ const label =
201
+ mode === 'month'
202
+ ? `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}`
203
+ : `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}`
204
+ ticks.push({ start: new Date(cursor), label })
205
+ if (mode === 'month') cursor.setMonth(cursor.getMonth() + 1)
206
+ else cursor.setDate(cursor.getDate() + 7)
207
+ }
208
+ return ticks
209
+ }
210
+
211
+ export function getRangeFromDates(dates: Date[]): { start: Date; end: Date } {
212
+ if (dates.length === 0) {
213
+ const now = new Date()
214
+ return {
215
+ start: new Date(now.getFullYear(), now.getMonth(), 1),
216
+ end: new Date(now.getFullYear(), now.getMonth() + 2, 0),
217
+ }
218
+ }
219
+ const times = dates.map((d) => d.getTime())
220
+ const min = new Date(Math.min(...times))
221
+ const max = new Date(Math.max(...times))
222
+ return {
223
+ start: new Date(min.getFullYear(), min.getMonth() - 1, 1),
224
+ end: new Date(max.getFullYear(), max.getMonth() + 1, 0),
225
+ }
226
+ }
@@ -0,0 +1,18 @@
1
+ import DOMPurify from 'isomorphic-dompurify'
2
+
3
+ // HTML 清理:默认配置(html + svg + mathMl profile 均启用),
4
+ // 覆盖富文本 diff、附件预览等 7 处 HTML 渲染场景。
5
+ export function sanitize(html: string): string {
6
+ return DOMPurify.sanitize(html)
7
+ }
8
+
9
+ // SVG 清理:头像渲染(NotionAvatar)专用。
10
+ // 显式复用默认配置——DOMPurify 默认 profile 已包含 SVG 滤镜元素
11
+ // (<defs>/<filter>/<feMorphology>/<feFlood>/<feComposite>/<feMerge>/<feMergeNode>),
12
+ // 同时清除 <script>/on*/javascript: 等 XSS 载荷。
13
+ // 禁止改成 USE_PROFILES: { svg: true }——严格 SVG profile 会移除全部 fe* 滤镜元素,
14
+ // 头像轮廓辉光(feMorphology+feFlood+feComposite+feMerge)会失效渲染白屏。
15
+ // 结构完整性由 __tests__/security/xss-sanitize.test.ts 的双向断言锁定。
16
+ export function sanitizeSvg(svg: string): string {
17
+ return DOMPurify.sanitize(svg)
18
+ }
@@ -0,0 +1 @@
1
+ export { cn } from "cn"
@@ -0,0 +1,185 @@
1
+ import * as React from "react"
2
+ import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
3
+ import { cn } from "cn"
4
+
5
+ import { Button } from "./button"
6
+
7
+ function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
8
+ return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
9
+ }
10
+
11
+ function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
12
+ return (
13
+ <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
14
+ )
15
+ }
16
+
17
+ function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
18
+ return (
19
+ <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
20
+ )
21
+ }
22
+
23
+ function AlertDialogOverlay({
24
+ className,
25
+ ...props
26
+ }: AlertDialogPrimitive.Backdrop.Props) {
27
+ return (
28
+ <AlertDialogPrimitive.Backdrop
29
+ data-slot="alert-dialog-overlay"
30
+ className={cn(
31
+ "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
32
+ className
33
+ )}
34
+ {...props}
35
+ />
36
+ )
37
+ }
38
+
39
+ function AlertDialogContent({
40
+ className,
41
+ size = "default",
42
+ ...props
43
+ }: AlertDialogPrimitive.Popup.Props & {
44
+ size?: "default" | "sm"
45
+ }) {
46
+ return (
47
+ <AlertDialogPortal>
48
+ <AlertDialogOverlay />
49
+ <AlertDialogPrimitive.Popup
50
+ data-slot="alert-dialog-content"
51
+ data-size={size}
52
+ className={cn(
53
+ "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-6 rounded-xl bg-popover p-6 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
54
+ className
55
+ )}
56
+ {...props}
57
+ />
58
+ </AlertDialogPortal>
59
+ )
60
+ }
61
+
62
+ function AlertDialogHeader({
63
+ className,
64
+ ...props
65
+ }: React.ComponentProps<"div">) {
66
+ return (
67
+ <div
68
+ data-slot="alert-dialog-header"
69
+ className={cn(
70
+ "grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
71
+ className
72
+ )}
73
+ {...props}
74
+ />
75
+ )
76
+ }
77
+
78
+ function AlertDialogFooter({
79
+ className,
80
+ ...props
81
+ }: React.ComponentProps<"div">) {
82
+ return (
83
+ <div
84
+ data-slot="alert-dialog-footer"
85
+ className={cn(
86
+ "flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
87
+ className
88
+ )}
89
+ {...props}
90
+ />
91
+ )
92
+ }
93
+
94
+ function AlertDialogMedia({
95
+ className,
96
+ ...props
97
+ }: React.ComponentProps<"div">) {
98
+ return (
99
+ <div
100
+ data-slot="alert-dialog-media"
101
+ className={cn(
102
+ "mb-2 inline-flex size-16 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
103
+ className
104
+ )}
105
+ {...props}
106
+ />
107
+ )
108
+ }
109
+
110
+ function AlertDialogTitle({
111
+ className,
112
+ ...props
113
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
114
+ return (
115
+ <AlertDialogPrimitive.Title
116
+ data-slot="alert-dialog-title"
117
+ className={cn(
118
+ "font-heading text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
119
+ className
120
+ )}
121
+ {...props}
122
+ />
123
+ )
124
+ }
125
+
126
+ function AlertDialogDescription({
127
+ className,
128
+ ...props
129
+ }: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
130
+ return (
131
+ <AlertDialogPrimitive.Description
132
+ data-slot="alert-dialog-description"
133
+ className={cn(
134
+ "text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
135
+ className
136
+ )}
137
+ {...props}
138
+ />
139
+ )
140
+ }
141
+
142
+ function AlertDialogAction({
143
+ className,
144
+ ...props
145
+ }: React.ComponentProps<typeof Button>) {
146
+ return (
147
+ <Button
148
+ data-slot="alert-dialog-action"
149
+ className={cn(className)}
150
+ {...props}
151
+ />
152
+ )
153
+ }
154
+
155
+ function AlertDialogCancel({
156
+ className,
157
+ variant = "outline",
158
+ size = "default",
159
+ ...props
160
+ }: AlertDialogPrimitive.Close.Props &
161
+ Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
162
+ return (
163
+ <AlertDialogPrimitive.Close
164
+ data-slot="alert-dialog-cancel"
165
+ className={cn(className)}
166
+ render={<Button variant={variant} size={size} />}
167
+ {...props}
168
+ />
169
+ )
170
+ }
171
+
172
+ export {
173
+ AlertDialog,
174
+ AlertDialogAction,
175
+ AlertDialogCancel,
176
+ AlertDialogContent,
177
+ AlertDialogDescription,
178
+ AlertDialogFooter,
179
+ AlertDialogHeader,
180
+ AlertDialogMedia,
181
+ AlertDialogOverlay,
182
+ AlertDialogPortal,
183
+ AlertDialogTitle,
184
+ AlertDialogTrigger,
185
+ }