@allbluecn/web-app 0.4.22 → 0.5.1

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 (99) hide show
  1. package/package.json +12 -8
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/layout/sidebar-03/app-sidebar.tsx +3 -4
  4. package/src/components/layout/sidebar-03/nav-notifications.tsx +135 -52
  5. package/src/components/layout/sidebar-03/user-menu.tsx +4 -2
  6. package/src/components/notifications/settings-notifications.tsx +198 -0
  7. package/src/components/notifications/team-notifications.tsx +343 -0
  8. package/src/components/settings/personal-team/team/team-client.tsx +51 -12
  9. package/src/components/settings/personal-team/team/team-invitations.tsx +181 -181
  10. package/src/components/shared/wave-physics-loader.tsx +155 -0
  11. package/src/components/story/__tests__/dependency-type-badge.test.ts +89 -0
  12. package/src/components/story/constants.ts +124 -51
  13. package/src/components/story/create-story-dialog.tsx +12 -6
  14. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  15. package/src/components/story/detail/activity-timeline.tsx +15 -0
  16. package/src/components/story/detail/story-detail-body.tsx +14 -3
  17. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  18. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  19. package/src/components/story/detail/story-detail-toolbar/children.tsx +51 -274
  20. package/src/components/story/detail/story-detail-toolbar/dependencies.tsx +13 -45
  21. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +473 -0
  22. package/src/components/story/detail/story-detail-toolbar.tsx +237 -10
  23. package/src/components/story/display/__tests__/use-paged-story-groups.test.ts +152 -1
  24. package/src/components/story/display/use-paged-story-groups.ts +370 -28
  25. package/src/components/story/inline-editors/story-select-dialog.tsx +107 -16
  26. package/src/components/story/peek-panel.tsx +8 -1
  27. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  28. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  29. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  30. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  31. package/src/components/story/relations/file-preview.tsx +173 -0
  32. package/src/components/story/relations/format.ts +120 -0
  33. package/src/{config/sidebar-notifications.ts → components/story/relations/general-groups.ts} +12 -30
  34. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  35. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  36. package/src/components/story/relations/story-ref-row.tsx +319 -0
  37. package/src/components/story/story-list-view.tsx +3 -1
  38. package/src/components/story/story-panel-header.tsx +4 -1
  39. package/src/components/story/types.ts +30 -18
  40. package/src/components/story/views/story-list/batch-toolbar.tsx +12 -1
  41. package/src/components/story/views/story-list/index.tsx +16 -5
  42. package/src/components/story/views/story-list/row.tsx +5 -4
  43. package/src/components/ui/separator.tsx +1 -1
  44. package/src/hooks/use-notifications.ts +4 -1
  45. package/src/lib/__tests__/datetime.test.ts +16 -1
  46. package/src/lib/changelog/sdk-versions.ts +19 -19
  47. package/src/lib/datetime.ts +1 -0
  48. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  49. package/src/lib/story/video-embed.ts +59 -0
  50. package/src/routeTree.gen.ts +65 -0
  51. package/src/routes/_nav/notifications/route.lazy.tsx +22 -0
  52. package/src/routes/_nav/notifications/route.tsx +30 -0
  53. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  54. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  55. package/src/routes/_nav/settings/notifications/route.lazy.tsx +6 -5
  56. package/src/routes/api/attachments/$id/content.ts +60 -0
  57. package/src/routes/api/attachments/upload.ts +148 -0
  58. package/src/routes/api/notifications/stream.ts +4 -0
  59. package/src/server/notifications/__tests__/notification-service.test.ts +189 -0
  60. package/src/server/notifications/notification-service.ts +137 -27
  61. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  62. package/src/server/serverFns/__tests__/team-invitation.test.ts +73 -7
  63. package/src/server/serverFns/__tests__/team-join-request.test.ts +5 -3
  64. package/src/server/serverFns/knowledge.ts +50 -4
  65. package/src/server/serverFns/locale.ts +15 -3
  66. package/src/server/serverFns/notifications.ts +18 -4
  67. package/src/server/serverFns/project.ts +43 -0
  68. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  69. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  70. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +265 -0
  71. package/src/server/serverFns/story/__tests__/story-groups.test.ts +19 -1
  72. package/src/server/serverFns/story/index.ts +7 -6
  73. package/src/server/serverFns/story/kb-general.ts +39 -0
  74. package/src/server/serverFns/story/kb-scope.ts +52 -0
  75. package/src/server/serverFns/story/link-preview.ts +115 -0
  76. package/src/server/serverFns/story/story-assoc.ts +339 -50
  77. package/src/server/serverFns/story/story-attachments.ts +12 -43
  78. package/src/server/serverFns/story/story-crud.ts +21 -4
  79. package/src/server/serverFns/story/story-dependencies.ts +138 -162
  80. package/src/server/serverFns/story/story-groups.ts +23 -3
  81. package/src/server/serverFns/story/story-links.ts +13 -1
  82. package/src/server/serverFns/story/story-transfer.ts +5 -3
  83. package/src/server/serverFns/story/story-tree.ts +28 -1
  84. package/src/server/serverFns/story/types.ts +37 -0
  85. package/src/server/serverFns/team.ts +51 -2
  86. package/src/styles/globals.css +25 -0
  87. package/vite.shared.ts +2 -0
  88. package/src/components/notifications/notification-list.tsx +0 -232
  89. package/src/components/notifications/notification-preferences.tsx +0 -156
  90. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  91. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  92. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  93. package/src/components/story/relation-badge.tsx +0 -57
  94. package/src/components/story/relations/attachments-panel.tsx +0 -179
  95. package/src/components/story/relations/dependency-panel.tsx +0 -190
  96. package/src/components/story/relations/hierarchy-panel.tsx +0 -180
  97. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  98. package/src/components/story/relations/links-panel.tsx +0 -240
  99. package/src/components/story/relations/tab.tsx +0 -212
@@ -0,0 +1,173 @@
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 * as React from "react";
19
+ import { useTheme } from "next-themes";
20
+ import { FileViewer } from "@open-file-viewer/react";
21
+ import { imagePlugin, textPlugin, pdfPlugin, officePlugin, archivePlugin, audioPlugin, videoPlugin, epubPlugin } from "@open-file-viewer/core";
22
+ import type { PreviewToolbarCustomAction } from "@open-file-viewer/core";
23
+ import "@open-file-viewer/core/style.css";
24
+ import pdfWorkerSrc from "pdfjs-dist/build/pdf.worker.mjs?url";
25
+
26
+ export const fileViewerPlugins = [
27
+ imagePlugin(),
28
+ textPlugin(),
29
+ pdfPlugin({ workerSrc: pdfWorkerSrc }),
30
+ officePlugin(),
31
+ archivePlugin(),
32
+ audioPlugin(),
33
+ videoPlugin(),
34
+ epubPlugin(),
35
+ ];
36
+
37
+ // 全屏工具栏图标(16×16 viewBox)
38
+ const FULLSCREEN_TOOLBAR_ICON =
39
+ '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M6 2.9H4.2A1.3 1.3 0 0 0 2.9 4.2V6"/><path d="M10 2.9h1.8a1.3 1.3 0 0 1 1.3 1.3V6"/><path d="M6 13.1H4.2a1.3 1.3 0 0 1-1.3-1.3V10"/><path d="M10 13.1h1.8a1.3 1.3 0 0 0 1.3-1.3V10"/></svg>';
40
+ const EXIT_FULLSCREEN_TOOLBAR_ICON =
41
+ '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M2.9 6h1.8A1.3 1.3 0 0 0 6 4.7V2.9"/><path d="M13.1 6h-1.8A1.3 1.3 0 0 1 10 4.7V2.9"/><path d="M2.9 10h1.8A1.3 1.3 0 0 1 6 11.3v1.8"/><path d="M13.1 10h-1.8a1.3 1.3 0 0 0-1.3 1.3v1.8"/></svg>';
42
+
43
+ // 仅指 text/code 预览顶部的标题栏(文件名 · 类型 · 行数 + Wrap/Copy/Download),
44
+ // 音频等类型的标题不属于遮挡,不隐藏
45
+ const TITLE_SELECTORS = ".ofv-code-header";
46
+
47
+ export interface FilePreviewProps {
48
+ file: File;
49
+ fileName: string;
50
+ /** 工具栏按钮文案 */
51
+ fullscreenLabel?: string;
52
+ exitFullscreenLabel?: string;
53
+ /** 显示标题栏切换按钮 */
54
+ showTitleToggle?: boolean;
55
+ titleToggleLabel?: string;
56
+ titleToggleHideLabel?: string;
57
+ /** 全屏回调 */
58
+ onFullscreen?: () => void;
59
+ onExitFullscreen?: () => void;
60
+ /** 预览容器高度 */
61
+ height?: string;
62
+ className?: string;
63
+ }
64
+
65
+ export function FilePreview({
66
+ file,
67
+ fileName,
68
+ fullscreenLabel = "全屏预览",
69
+ exitFullscreenLabel = "退出全屏",
70
+ showTitleToggle = false,
71
+ titleToggleLabel = "显示标题",
72
+ titleToggleHideLabel = "隐藏标题",
73
+ onFullscreen,
74
+ onExitFullscreen,
75
+ height = "var(--attachment-preview-h, 16rem)",
76
+ className,
77
+ }: FilePreviewProps) {
78
+ const { resolvedTheme } = useTheme();
79
+ const containerRef = React.useRef<HTMLDivElement>(null);
80
+ const [showTitle, setShowTitle] = React.useState(false);
81
+ const [isFullscreen, setIsFullscreen] = React.useState(false);
82
+
83
+ // 锁定传给 FileViewer 的 theme:wrapper 的重建依赖含 options.theme,
84
+ // 若随 resolvedTheme 变化会导致整个 viewer 销毁重建(预览重新加载)
85
+ const themePropRef = React.useRef<"light" | "dark" | null>(null);
86
+ if (themePropRef.current === null && (resolvedTheme === "light" || resolvedTheme === "dark")) {
87
+ themePropRef.current = resolvedTheme;
88
+ }
89
+ const themeProp = themePropRef.current ?? "light";
90
+
91
+ // 主题切换后仅同步 ofv-root 的 theme class(core 的 applyTheme 本质就是切 class),
92
+ // 不触发 viewer 重建;file/toolbar 变化导致 viewer 重建后也会重跑本 effect 修正主题
93
+ React.useEffect(() => {
94
+ if (resolvedTheme !== "light" && resolvedTheme !== "dark") return;
95
+ const root = containerRef.current?.querySelector(".ofv-root");
96
+ if (!root) return;
97
+ const expected = `ofv-theme-${resolvedTheme}`;
98
+ if (!root.classList.contains(expected)) {
99
+ root.classList.remove("ofv-theme-light", "ofv-theme-dark");
100
+ root.classList.add(expected);
101
+ }
102
+ });
103
+
104
+ const toolbarActions = React.useMemo<PreviewToolbarCustomAction[]>(() => {
105
+ const actions: PreviewToolbarCustomAction[] = [];
106
+ if (isFullscreen) {
107
+ actions.push({
108
+ id: "exit-fullscreen",
109
+ label: exitFullscreenLabel,
110
+ title: exitFullscreenLabel,
111
+ icon: EXIT_FULLSCREEN_TOOLBAR_ICON,
112
+ className: "ofv-toolbar-icon-button",
113
+ onClick: () => {
114
+ setIsFullscreen(false);
115
+ onExitFullscreen?.();
116
+ },
117
+ });
118
+ } else {
119
+ actions.push({
120
+ id: "fullscreen",
121
+ label: fullscreenLabel,
122
+ title: fullscreenLabel,
123
+ icon: FULLSCREEN_TOOLBAR_ICON,
124
+ className: "ofv-toolbar-icon-button",
125
+ onClick: () => {
126
+ setIsFullscreen(true);
127
+ onFullscreen?.();
128
+ },
129
+ });
130
+ }
131
+ if (showTitleToggle) {
132
+ actions.push({
133
+ id: "toggle-title",
134
+ label: showTitle ? titleToggleHideLabel : titleToggleLabel,
135
+ title: showTitle ? titleToggleHideLabel : titleToggleLabel,
136
+ icon: '<svg viewBox="0 0 16 16" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M1 3h14v2H1V3zm0 4h14v2H1V7zm0 4h14v2H1v-2z"/></svg>',
137
+ className: "ofv-toolbar-icon-button",
138
+ // 标题栏存在时才显示按钮:open-file-viewer 在插件渲染完成(setCommandSupport)
139
+ // 与翻页/缩放时会重新求值 hidden 函数
140
+ hidden: (ctx) => !ctx.viewport.querySelector(TITLE_SELECTORS),
141
+ onClick: () => setShowTitle((v) => !v),
142
+ });
143
+ }
144
+ return actions;
145
+ }, [isFullscreen, showTitle, showTitleToggle, fullscreenLabel, exitFullscreenLabel, titleToggleLabel, titleToggleHideLabel, onFullscreen, onExitFullscreen]);
146
+
147
+ // rotate 恒为 true:插件不支持时按钮保持 disabled 态,由 CSS(.ofv-preview-scope)隐藏
148
+ const toolbar = React.useMemo(() => {
149
+ const order = ["zoom-out", "zoom-in", "rotate-left", "rotate-right"];
150
+ order.push(isFullscreen ? "exit-fullscreen" : "fullscreen");
151
+ if (showTitleToggle) order.push("toggle-title");
152
+ return { zoom: true, rotate: true, order, actions: toolbarActions };
153
+ }, [toolbarActions, isFullscreen, showTitleToggle]);
154
+
155
+ return (
156
+ <div
157
+ ref={containerRef}
158
+ className={`ofv-preview-scope relative w-full overflow-hidden bg-background ${showTitleToggle && !showTitle ? "ofv-preview-title-hidden" : ""} ${className ?? ""}`}
159
+ style={{ height }}
160
+ >
161
+ <FileViewer
162
+ file={file}
163
+ fileName={fileName}
164
+ width="100%"
165
+ height="100%"
166
+ fit="contain"
167
+ toolbar={toolbar}
168
+ theme={themeProp}
169
+ plugins={fileViewerPlugins}
170
+ />
171
+ </div>
172
+ );
173
+ }
@@ -0,0 +1,120 @@
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 {
19
+ BookOpen,
20
+ File,
21
+ FileArchive,
22
+ FileText,
23
+ Film,
24
+ FolderArchive,
25
+ Image as ImageIcon,
26
+ Music,
27
+ type LucideIcon,
28
+ } from "lucide-react";
29
+
30
+ export function formatFileSize(bytes: number): string {
31
+ if (bytes === 0) return "0 B";
32
+ const units = ["B", "KB", "MB", "GB"];
33
+ const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
34
+ return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
35
+ }
36
+
37
+ /** mimeType → 文件类型图标(弹窗列表与合并面板卡片共用) */
38
+ export function getFileTypeIcon(mimeType: string, fileName?: string): LucideIcon {
39
+ const effective = inferMimeType(fileName ?? "", mimeType);
40
+ if (effective.startsWith("image/")) return ImageIcon;
41
+ if (effective.startsWith("video/")) return Film;
42
+ if (effective.startsWith("audio/")) return Music;
43
+ if (effective === "application/pdf") return FileText;
44
+ if (isEbookMimeType(effective)) return BookOpen;
45
+ if (effective.includes("zip") || effective.includes("rar") || effective.includes("7z")) {
46
+ return FileArchive;
47
+ }
48
+ if (effective.includes("tar") || effective.includes("gz")) return FolderArchive;
49
+ if (effective.startsWith("text/") || effective.includes("json")) return FileText;
50
+ return File;
51
+ }
52
+
53
+ /** 判断是否为电子书 MIME 类型 */
54
+ export function isEbookMimeType(mimeType: string): boolean {
55
+ return (
56
+ mimeType === "application/epub+zip" ||
57
+ mimeType === "application/epub" ||
58
+ mimeType === "application/x-mobipocket-ebook" ||
59
+ mimeType === "application/vnd.amazon.epub"
60
+ );
61
+ }
62
+
63
+ /** mimeType → 文件类型标签(中文,用于附件列表展示) */
64
+ export function getMimeTypeLabel(mimeType: string, fileName?: string): string {
65
+ const effective = inferMimeType(fileName ?? "", mimeType);
66
+ if (effective.startsWith("image/")) return "图片";
67
+ if (effective.startsWith("video/")) return "视频";
68
+ if (effective.startsWith("audio/")) return "音频";
69
+ if (effective === "application/pdf") return "PDF";
70
+ if (isEbookMimeType(effective)) return "电子书";
71
+ if (effective.includes("zip") || effective.includes("rar") || effective.includes("7z")) return "压缩包";
72
+ if (effective.includes("tar") || effective.includes("gz")) return "压缩包";
73
+ if (effective.startsWith("text/") || effective.includes("json")) return "文本";
74
+ if (effective.includes("word") || effective.includes("document")) return "Word";
75
+ if (effective.includes("sheet") || effective.includes("excel") || effective.includes("spreadsheet")) return "表格";
76
+ if (effective.includes("presentation") || effective.includes("powerpoint")) return "演示";
77
+ return "未识别文件";
78
+ }
79
+
80
+ /** 扩展名 → MIME 类型映射(补充浏览器无法识别的格式) */
81
+ const EXTENSION_MIME_MAP: Record<string, string> = {
82
+ ".epub": "application/epub+zip",
83
+ ".mobi": "application/x-mobipocket-ebook",
84
+ ".azw3": "application/vnd.amazon.epub",
85
+ };
86
+
87
+ /** 根据文件名推断 MIME 类型(浏览器缺失时兜底) */
88
+ export function inferMimeType(fileName: string, fileMimeType: string): string {
89
+ if (fileMimeType && !fileMimeType.startsWith("application/octet-stream")) {
90
+ return fileMimeType;
91
+ }
92
+ const ext = fileName.slice(fileName.toLowerCase().lastIndexOf(".")).toLowerCase();
93
+ return EXTENSION_MIME_MAP[ext] ?? fileMimeType;
94
+ }
95
+
96
+ /** 链接 favicon URL(Google 服务,失败时 UI 回退 Link 图标) */
97
+ export function getFaviconUrl(url: string): string | null {
98
+ try {
99
+ const host = new URL(url).hostname;
100
+ return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=32`;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /** 从 URL 自动提取标题(用于用户未填写链接标题时的兜底显示) */
107
+ export function extractLinkTitle(url: string): string {
108
+ try {
109
+ const { hostname, pathname } = new URL(url);
110
+ const cleanHost = hostname.replace(/^www\./, "");
111
+ if (!pathname || pathname === "/" || pathname === "") return cleanHost;
112
+ // 取最后一段路径作为标题,去除扩展名
113
+ const segments = pathname.split("/").filter(Boolean);
114
+ const last = segments[segments.length - 1] ?? "";
115
+ const name = last.replace(/\.\w+$/, "").replace(/[-_]/g, " ");
116
+ return name || cleanHost;
117
+ } catch {
118
+ return url;
119
+ }
120
+ }
@@ -15,36 +15,18 @@
15
15
  // You should have received a copy of the GNU Affero General Public License
16
16
  // along with this program. If not, see <https://www.gnu.org/licenses/>.
17
17
 
18
- import { getBoringAvatarUrl } from "@/lib/avatar";
18
+ import type { TFunction } from "i18next";
19
19
 
20
- export type Notification = {
21
- id: string;
22
- avatar: string;
23
- fallback: string;
24
- text: string;
25
- time: string;
20
+ const GROUP_I18N_KEYS: Record<string, string> = {
21
+ legal: "knowledgeDialog.groupLegal",
22
+ compliance: "knowledgeDialog.groupCompliance",
23
+ dictionary: "knowledgeDialog.groupDictionary",
24
+ bookmark: "knowledgeDialog.groupBookmark",
25
+ summary: "knowledgeDialog.groupSummary",
26
26
  };
27
27
 
28
- export const notifications: Notification[] = [
29
- {
30
- id: "1",
31
- avatar: getBoringAvatarUrl("order-1", { size: 40, square: true }),
32
- fallback: "OM",
33
- text: "New order received.",
34
- time: "10m ago",
35
- },
36
- {
37
- id: "2",
38
- avatar: getBoringAvatarUrl("server-upgrade", { size: 40, square: true }),
39
- fallback: "JL",
40
- text: "Server upgrade completed.",
41
- time: "1h ago",
42
- },
43
- {
44
- id: "3",
45
- avatar: getBoringAvatarUrl("new-user- signup", { size: 40, square: true }),
46
- fallback: "HH",
47
- text: "New user signed up.",
48
- time: "2h ago",
49
- },
50
- ];
28
+ /** 通用知识库分组 key → i18n 标签(未知 key 原样返回) */
29
+ export function generalGroupLabel(t: TFunction, group: string): string {
30
+ const key = GROUP_I18N_KEYS[group];
31
+ return key ? t(key) : group;
32
+ }