@allbluecn/web-app 0.5.0 → 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 (69) hide show
  1. package/package.json +11 -7
  2. package/src/components/knowledge/knowledge-toolbar.tsx +124 -1
  3. package/src/components/story/constants.ts +11 -1
  4. package/src/components/story/create-story-dialog.tsx +12 -6
  5. package/src/components/story/detail/__tests__/associated-attachments-panel.test.tsx +114 -0
  6. package/src/components/story/detail/activity-timeline.tsx +15 -0
  7. package/src/components/story/detail/story-detail-body.tsx +1 -0
  8. package/src/components/story/detail/story-detail-toolbar/associated-attachments-panel.tsx +748 -0
  9. package/src/components/story/detail/story-detail-toolbar/categories.tsx +23 -18
  10. package/src/components/story/detail/story-detail-toolbar/children.tsx +2 -0
  11. package/src/components/story/detail/story-detail-toolbar/dependency-panel.tsx +29 -21
  12. package/src/components/story/detail/story-detail-toolbar.tsx +154 -8
  13. package/src/components/story/inline-editors/story-select-dialog.tsx +19 -4
  14. package/src/components/story/relations/__tests__/attachment-manage-dialog.test.tsx +171 -0
  15. package/src/components/story/relations/__tests__/knowledge-select-dialog.test.tsx +174 -0
  16. package/src/components/story/relations/__tests__/link-manage-dialog.test.tsx +93 -0
  17. package/src/components/story/relations/attachment-manage-dialog.tsx +490 -0
  18. package/src/components/story/relations/file-preview.tsx +173 -0
  19. package/src/components/story/relations/format.ts +120 -0
  20. package/src/components/story/relations/general-groups.ts +32 -0
  21. package/src/components/story/relations/knowledge-select-dialog.tsx +403 -0
  22. package/src/components/story/relations/link-manage-dialog.tsx +191 -0
  23. package/src/components/story/types.ts +13 -0
  24. package/src/lib/changelog/sdk-versions.ts +19 -19
  25. package/src/lib/story/__tests__/video-embed.test.ts +77 -0
  26. package/src/lib/story/video-embed.ts +59 -0
  27. package/src/plugins/.gen-manifest.json +13 -0
  28. package/src/plugins/modules.gen.ts +20 -0
  29. package/src/plugins/plugins.gen.ts +20 -0
  30. package/src/plugins/ui.gen.ts +8 -0
  31. package/src/routeTree.gen.ts +42 -0
  32. package/src/routes/_nav/inspiration/route.tsx +8 -0
  33. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  34. package/src/routes/_nav/prd/index.tsx +9 -0
  35. package/src/routes/_nav/prd/route.tsx +8 -0
  36. package/src/routes/_nav/projects/$projectId/settings/route.lazy.tsx +74 -3
  37. package/src/routes/_nav/projects/$projectId/settings/route.tsx +6 -2
  38. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  39. package/src/routes/_nav/tags/index.tsx +8 -0
  40. package/src/routes/_nav/tags/route.tsx +8 -0
  41. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  42. package/src/routes/api/attachments/$id/content.ts +60 -0
  43. package/src/routes/api/attachments/upload.ts +148 -0
  44. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  45. package/src/server/serverFns/__tests__/project-kb.test.ts +147 -0
  46. package/src/server/serverFns/knowledge.ts +50 -4
  47. package/src/server/serverFns/project.ts +43 -0
  48. package/src/server/serverFns/story/__tests__/kb-scope.test.ts +56 -0
  49. package/src/server/serverFns/story/__tests__/story-assoc.test.ts +222 -0
  50. package/src/server/serverFns/story/__tests__/story-dependencies.test.ts +26 -0
  51. package/src/server/serverFns/story/index.ts +6 -4
  52. package/src/server/serverFns/story/kb-general.ts +39 -0
  53. package/src/server/serverFns/story/kb-scope.ts +52 -0
  54. package/src/server/serverFns/story/link-preview.ts +115 -0
  55. package/src/server/serverFns/story/story-assoc.ts +339 -50
  56. package/src/server/serverFns/story/story-attachments.ts +12 -43
  57. package/src/server/serverFns/story/story-crud.ts +21 -4
  58. package/src/server/serverFns/story/story-dependencies.ts +35 -3
  59. package/src/server/serverFns/story/story-links.ts +13 -1
  60. package/src/server/serverFns/story/story-tree.ts +28 -1
  61. package/src/server/serverFns/story/types.ts +37 -0
  62. package/src/styles/globals.css +25 -0
  63. package/vite.shared.ts +2 -0
  64. package/src/components/story/detail/story-detail-toolbar/attachments.tsx +0 -73
  65. package/src/components/story/detail/story-detail-toolbar/knowledge.tsx +0 -73
  66. package/src/components/story/detail/story-detail-toolbar/links.tsx +0 -73
  67. package/src/components/story/relations/attachments-panel.tsx +0 -179
  68. package/src/components/story/relations/knowledge-panel.tsx +0 -284
  69. package/src/components/story/relations/links-panel.tsx +0 -240
@@ -0,0 +1,191 @@
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 { useTranslation } from "react-i18next";
20
+ import { useServerFn } from "@tanstack/react-start";
21
+ import { toast } from "sonner";
22
+ import { Button } from "@/components/ui/button";
23
+ import { Input } from "@/components/ui/input";
24
+ import { Textarea } from "@/components/ui/textarea";
25
+ import {
26
+ Dialog,
27
+ DialogContent,
28
+ DialogHeader,
29
+ DialogTitle,
30
+ } from "@/components/ui/dialog";
31
+ import { addLinkFn, updateLinkFn } from "@/server/serverFns/story";
32
+ import type { StoryLinkItem } from "../types";
33
+
34
+ export interface LinkManageDialogProps {
35
+ open: boolean;
36
+ onOpenChange: (open: boolean) => void;
37
+ storyId: string;
38
+ data: StoryLinkItem[];
39
+ onUpdate: (links: StoryLinkItem[]) => void;
40
+ /** 添加链接产生的活动,供时间线即时追加 */
41
+ onActivity?: (activity: import("../types").ActivityItem) => void;
42
+ /** 非空时进入编辑态(由合并面板「编辑」触发) */
43
+ initialEdit?: StoryLinkItem | null;
44
+ }
45
+
46
+ export function LinkManageDialog({
47
+ open,
48
+ onOpenChange,
49
+ storyId,
50
+ data,
51
+ onUpdate,
52
+ onActivity,
53
+ initialEdit = null,
54
+ }: LinkManageDialogProps) {
55
+ const { t } = useTranslation("story");
56
+ const addLink = useServerFn(addLinkFn);
57
+ const updateLink = useServerFn(updateLinkFn);
58
+ const [editing, setEditing] = React.useState<StoryLinkItem | null>(null);
59
+ const [form, setForm] = React.useState({ url: "", title: "", description: "" });
60
+ const [submitting, setSubmitting] = React.useState(false);
61
+
62
+ React.useEffect(() => {
63
+ if (open) {
64
+ setEditing(initialEdit);
65
+ setForm(
66
+ initialEdit
67
+ ? { url: initialEdit.url, title: initialEdit.title ?? "", description: initialEdit.description ?? "" }
68
+ : { url: "", title: "", description: "" },
69
+ );
70
+ }
71
+ }, [open, initialEdit]);
72
+
73
+ const handleSubmit = async (e: React.FormEvent) => {
74
+ e.preventDefault();
75
+ if (submitting) return;
76
+ setSubmitting(true);
77
+ try {
78
+ if (editing) {
79
+ await updateLink({
80
+ data: {
81
+ linkId: editing.id,
82
+ url: form.url,
83
+ title: form.title || undefined,
84
+ description: form.description || undefined,
85
+ },
86
+ });
87
+ toast.success(t("linkDialog.updated"));
88
+ onUpdate(
89
+ data.map((l) =>
90
+ l.id === editing.id
91
+ ? { ...l, url: form.url, title: form.title || null, description: form.description || null }
92
+ : l,
93
+ ),
94
+ );
95
+ } else {
96
+ const created = await addLink({
97
+ data: {
98
+ storyId,
99
+ url: form.url,
100
+ title: form.title || undefined,
101
+ description: form.description || undefined,
102
+ },
103
+ });
104
+ toast.success(t("linkDialog.added"));
105
+ if (created.activity) onActivity?.(created.activity);
106
+ onUpdate([
107
+ ...data,
108
+ {
109
+ id: created.id,
110
+ url: created.url,
111
+ title: created.title,
112
+ description: created.description,
113
+ },
114
+ ]);
115
+ setForm({ url: "", title: "", description: "" });
116
+ }
117
+ setEditing(null);
118
+ onOpenChange(false);
119
+ } catch (err) {
120
+ toast.error((err as Error)?.message || t("linkDialog.saveFailed"));
121
+ } finally {
122
+ setSubmitting(false);
123
+ }
124
+ };
125
+
126
+ return (
127
+ <Dialog open={open} onOpenChange={onOpenChange}>
128
+ <DialogContent className="sm:max-w-130">
129
+ <DialogHeader>
130
+ <DialogTitle className="text-xl">
131
+ {t(editing ? "linkDialog.editTitle" : "linkDialog.title")}
132
+ </DialogTitle>
133
+ </DialogHeader>
134
+ <form onSubmit={handleSubmit} className="flex h-96 flex-col gap-4 overflow-y-auto px-1 pb-1">
135
+ <div className="space-y-1.5">
136
+ <label htmlFor="link-url" className="text-xs font-medium text-muted-foreground">
137
+ URL
138
+ </label>
139
+ <Input
140
+ id="link-url"
141
+ type="url"
142
+ required
143
+ placeholder={t("linkDialog.urlPlaceholder")}
144
+ value={form.url}
145
+ onChange={(e) => setForm({ ...form, url: e.target.value })}
146
+ className="h-10 text-base"
147
+ />
148
+ </div>
149
+ <div className="space-y-1.5">
150
+ <label htmlFor="link-title" className="text-xs font-medium text-muted-foreground">
151
+ {t("linkDialog.titleOptional")}
152
+ </label>
153
+ <Input
154
+ id="link-title"
155
+ placeholder={t("linkDialog.titlePlaceholder")}
156
+ value={form.title}
157
+ onChange={(e) => setForm({ ...form, title: e.target.value })}
158
+ className="h-10"
159
+ />
160
+ </div>
161
+ <div className="flex min-h-0 flex-1 flex-col gap-1.5">
162
+ <label htmlFor="link-desc" className="text-xs font-medium text-muted-foreground">
163
+ {t("linkDialog.descOptional")}
164
+ </label>
165
+ <Textarea
166
+ id="link-desc"
167
+ placeholder={t("linkDialog.descPlaceholder")}
168
+ value={form.description}
169
+ onChange={(e) => setForm({ ...form, description: e.target.value })}
170
+ className="min-h-20 flex-1 resize-none"
171
+ />
172
+ </div>
173
+ <div className="flex shrink-0 justify-end gap-2 pt-1">
174
+ {editing && (
175
+ <Button
176
+ type="button"
177
+ variant="outline"
178
+ onClick={() => onOpenChange(false)}
179
+ >
180
+ {t("linkDialog.cancel")}
181
+ </Button>
182
+ )}
183
+ <Button type="submit" disabled={submitting}>
184
+ {t(editing ? "linkDialog.save" : "linkDialog.add")}
185
+ </Button>
186
+ </div>
187
+ </form>
188
+ </DialogContent>
189
+ </Dialog>
190
+ );
191
+ }
@@ -145,7 +145,9 @@ export interface StoryAttachmentItem {
145
145
  fileName: string;
146
146
  fileSize: number;
147
147
  mimeType: string;
148
+ description?: string | null;
148
149
  storageUrl: string;
150
+ fileHash?: string | null;
149
151
  }
150
152
 
151
153
  // PRD 简要信息
@@ -158,6 +160,11 @@ export interface PrdBrief {
158
160
  export interface DocBrief {
159
161
  id: string;
160
162
  title: string;
163
+ kbId: string;
164
+ kbName: string;
165
+ kbIcon: string;
166
+ docIcon: string | null;
167
+ parentId: string | null;
161
168
  }
162
169
 
163
170
  // 关联 Tab 数据汇总
@@ -176,6 +183,12 @@ export interface RelationsData {
176
183
  };
177
184
  }
178
185
 
186
+ /** 关联附件面板统一数据类型 */
187
+ export type AssociatedItem =
188
+ | { kind: "link"; data: StoryLinkItem }
189
+ | { kind: "attachment"; data: StoryAttachmentItem }
190
+ | { kind: "doc"; data: DocBrief };
191
+
179
192
  // ====== 依赖关系(对齐 Plane,新模型) ======
180
193
 
181
194
  // 规范化存储类型:语义统一为「source 领先于 target」
@@ -10,7 +10,7 @@ export const SDK_GROUPS: SdkGroup[] = [
10
10
  "name": "@tanstack/react-start",
11
11
  "displayName": "TanStack Start",
12
12
  "version": "v1.168.49",
13
- "lastUpdate": "Aug 31, 2026",
13
+ "lastUpdate": "Sep 1, 2026",
14
14
  "source": "apps/web/package.json#dependencies",
15
15
  "docsUrl": "https://tanstack.com/start/latest"
16
16
  },
@@ -18,7 +18,7 @@ export const SDK_GROUPS: SdkGroup[] = [
18
18
  "name": "@tanstack/react-router",
19
19
  "displayName": "TanStack Router",
20
20
  "version": "v1.170.32",
21
- "lastUpdate": "Aug 31, 2026",
21
+ "lastUpdate": "Sep 1, 2026",
22
22
  "source": "apps/web/package.json#dependencies",
23
23
  "docsUrl": "https://tanstack.com/router/latest"
24
24
  },
@@ -26,7 +26,7 @@ export const SDK_GROUPS: SdkGroup[] = [
26
26
  "name": "@tanstack/react-query",
27
27
  "displayName": "TanStack Query",
28
28
  "version": "v5.102.8",
29
- "lastUpdate": "Aug 31, 2026",
29
+ "lastUpdate": "Sep 1, 2026",
30
30
  "source": "apps/web/package.json#dependencies",
31
31
  "docsUrl": "https://tanstack.com/query/latest"
32
32
  },
@@ -34,7 +34,7 @@ export const SDK_GROUPS: SdkGroup[] = [
34
34
  "name": "@tanstack/ai",
35
35
  "displayName": "TanStack AI",
36
36
  "version": "v0.52.0",
37
- "lastUpdate": "Aug 31, 2026",
37
+ "lastUpdate": "Sep 1, 2026",
38
38
  "source": "apps/web/package.json#dependencies",
39
39
  "docsUrl": "https://tanstack.com/ai/latest"
40
40
  },
@@ -42,7 +42,7 @@ export const SDK_GROUPS: SdkGroup[] = [
42
42
  "name": "@tanstack/react-table",
43
43
  "displayName": "TanStack Table",
44
44
  "version": "v9.2.3",
45
- "lastUpdate": "Aug 31, 2026",
45
+ "lastUpdate": "Sep 1, 2026",
46
46
  "source": "apps/web/package.json#dependencies",
47
47
  "docsUrl": "https://tanstack.com/table/latest"
48
48
  },
@@ -50,7 +50,7 @@ export const SDK_GROUPS: SdkGroup[] = [
50
50
  "name": "@tanstack/react-form",
51
51
  "displayName": "TanStack Form",
52
52
  "version": "v2.0.0-alpha.2",
53
- "lastUpdate": "Aug 31, 2026",
53
+ "lastUpdate": "Sep 1, 2026",
54
54
  "source": "apps/web/package.json#dependencies",
55
55
  "docsUrl": "https://tanstack.com/form/latest"
56
56
  },
@@ -58,7 +58,7 @@ export const SDK_GROUPS: SdkGroup[] = [
58
58
  "name": "@tanstack/charts",
59
59
  "displayName": "TanStack Charts",
60
60
  "version": "v0.16.0",
61
- "lastUpdate": "Aug 31, 2026",
61
+ "lastUpdate": "Sep 1, 2026",
62
62
  "source": "apps/admin/package.json#dependencies",
63
63
  "docsUrl": "https://tanstack.com/charts/latest"
64
64
  },
@@ -66,7 +66,7 @@ export const SDK_GROUPS: SdkGroup[] = [
66
66
  "name": "@tanstack/react-pacer",
67
67
  "displayName": "TanStack Pacer",
68
68
  "version": "v0.23.0",
69
- "lastUpdate": "Aug 31, 2026",
69
+ "lastUpdate": "Sep 1, 2026",
70
70
  "source": "apps/web/package.json#dependencies",
71
71
  "docsUrl": "https://tanstack.com/pacer/latest"
72
72
  },
@@ -74,7 +74,7 @@ export const SDK_GROUPS: SdkGroup[] = [
74
74
  "name": "@tanstack/react-hotkeys",
75
75
  "displayName": "TanStack Hotkeys",
76
76
  "version": "v0.10.0",
77
- "lastUpdate": "Aug 31, 2026",
77
+ "lastUpdate": "Sep 1, 2026",
78
78
  "source": "apps/web/package.json#dependencies",
79
79
  "docsUrl": "https://tanstack.com/hotkeys/latest"
80
80
  },
@@ -82,7 +82,7 @@ export const SDK_GROUPS: SdkGroup[] = [
82
82
  "name": "@tanstack/react-virtual",
83
83
  "displayName": "TanStack Virtual",
84
84
  "version": "v3.14.10",
85
- "lastUpdate": "Aug 31, 2026",
85
+ "lastUpdate": "Sep 1, 2026",
86
86
  "source": "apps/admin/package.json#dependencies",
87
87
  "docsUrl": "https://tanstack.com/virtual/latest"
88
88
  }
@@ -95,15 +95,15 @@ export const SDK_GROUPS: SdkGroup[] = [
95
95
  "items": [
96
96
  {
97
97
  "name": "@allbluecn/web-app",
98
- "version": "v0.5.0",
99
- "lastUpdate": "Aug 31, 2026",
98
+ "version": "v0.5.1",
99
+ "lastUpdate": "Sep 1, 2026",
100
100
  "source": "apps/web/package.json",
101
101
  "docsUrl": "https://github.com/allbluecn/allblue"
102
102
  },
103
103
  {
104
104
  "name": "@allbluecn/kernel",
105
105
  "version": "v0.3.1",
106
- "lastUpdate": "Aug 31, 2026",
106
+ "lastUpdate": "Sep 1, 2026",
107
107
  "source": "packages/kernel/package.json",
108
108
  "docsUrl": "https://github.com/allbluecn/allblue"
109
109
  }
@@ -117,42 +117,42 @@ export const SDK_GROUPS: SdkGroup[] = [
117
117
  {
118
118
  "name": "Vite",
119
119
  "version": "v8.1.5",
120
- "lastUpdate": "Aug 31, 2026",
120
+ "lastUpdate": "Sep 1, 2026",
121
121
  "source": "apps/web/package.json#devDependencies",
122
122
  "docsUrl": "https://vite.dev/"
123
123
  },
124
124
  {
125
125
  "name": "Node.js",
126
126
  "version": "v24.19.0",
127
- "lastUpdate": "Aug 31, 2026",
127
+ "lastUpdate": "Sep 1, 2026",
128
128
  "source": ".nvmrc",
129
129
  "docsUrl": "https://nodejs.org/"
130
130
  },
131
131
  {
132
132
  "name": "Prisma",
133
133
  "version": "v7.8.0",
134
- "lastUpdate": "Aug 31, 2026",
134
+ "lastUpdate": "Sep 1, 2026",
135
135
  "source": "packages/database/package.json#devDependencies",
136
136
  "docsUrl": "https://www.prisma.io/docs"
137
137
  },
138
138
  {
139
139
  "name": "shadcn UI",
140
140
  "version": "v1.2.4",
141
- "lastUpdate": "Aug 31, 2026",
141
+ "lastUpdate": "Sep 1, 2026",
142
142
  "source": "apps/web/package.json#dependencies",
143
143
  "docsUrl": "https://ui.shadcn.com/"
144
144
  },
145
145
  {
146
146
  "name": "TipTap",
147
147
  "version": "v3.27.4",
148
- "lastUpdate": "Aug 31, 2026",
148
+ "lastUpdate": "Sep 1, 2026",
149
149
  "source": "packages/shared/package.json#devDependencies",
150
150
  "docsUrl": "https://tiptap.dev/"
151
151
  },
152
152
  {
153
153
  "name": "Lucide React",
154
154
  "version": "v1.24.0",
155
- "lastUpdate": "Aug 31, 2026",
155
+ "lastUpdate": "Sep 1, 2026",
156
156
  "source": "packages/ui/package.json#dependencies",
157
157
  "docsUrl": "https://lucide.dev/"
158
158
  }
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolveVideoEmbed } from "../video-embed";
3
+
4
+ describe("resolveVideoEmbed", () => {
5
+ it("非视频链接返回 null", () => {
6
+ expect(resolveVideoEmbed("https://example.com/page")).toBeNull();
7
+ expect(resolveVideoEmbed("https://lucide.dev/icons/layout-list")).toBeNull();
8
+ });
9
+
10
+ it("直链视频文件走 file 分支(忽略 query/hash)", () => {
11
+ expect(resolveVideoEmbed("https://cdn.example.com/a.mp4")).toEqual({
12
+ kind: "file",
13
+ src: "https://cdn.example.com/a.mp4",
14
+ });
15
+ expect(
16
+ resolveVideoEmbed("https://cdn.example.com/a.webm?token=x#t=1"),
17
+ ).toEqual({ kind: "file", src: "https://cdn.example.com/a.webm?token=x#t=1" });
18
+ expect(resolveVideoEmbed("https://cdn.example.com/a.MOV")).toEqual({
19
+ kind: "file",
20
+ src: "https://cdn.example.com/a.MOV",
21
+ });
22
+ // 扩展名仅匹配路径末段,不匹配 query 里的伪扩展名
23
+ expect(resolveVideoEmbed("https://example.com/watch?file=a.mp4")).toBeNull();
24
+ });
25
+
26
+ it("YouTube watch 页转换为 embed 播放器", () => {
27
+ expect(resolveVideoEmbed("https://www.youtube.com/watch?v=dQw4w9WgXcQ")).toEqual({
28
+ kind: "iframe",
29
+ src: "https://www.youtube.com/embed/dQw4w9WgXcQ",
30
+ });
31
+ expect(
32
+ resolveVideoEmbed("https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s"),
33
+ ).toEqual({ kind: "iframe", src: "https://www.youtube.com/embed/dQw4w9WgXcQ" });
34
+ });
35
+
36
+ it("YouTube youtu.be 短链与 shorts 转换", () => {
37
+ expect(resolveVideoEmbed("https://youtu.be/dQw4w9WgXcQ")).toEqual({
38
+ kind: "iframe",
39
+ src: "https://www.youtube.com/embed/dQw4w9WgXcQ",
40
+ });
41
+ expect(resolveVideoEmbed("https://www.youtube.com/shorts/abc123")).toEqual({
42
+ kind: "iframe",
43
+ src: "https://www.youtube.com/embed/abc123",
44
+ });
45
+ // 已是 embed 链接原样返回
46
+ expect(resolveVideoEmbed("https://www.youtube.com/embed/dQw4w9WgXcQ")).toEqual({
47
+ kind: "iframe",
48
+ src: "https://www.youtube.com/embed/dQw4w9WgXcQ",
49
+ });
50
+ });
51
+
52
+ it("Bilibili 视频页转换为官方播放器(BV 号 + 分 P)", () => {
53
+ expect(
54
+ resolveVideoEmbed("https://www.bilibili.com/video/BV1GJ411x7h7/"),
55
+ ).toEqual({
56
+ kind: "iframe",
57
+ src: "https://player.bilibili.com/player.html?bvid=BV1GJ411x7h7&autoplay=0",
58
+ });
59
+ expect(
60
+ resolveVideoEmbed("https://www.bilibili.com/video/BV1GJ411x7h7?p=3"),
61
+ ).toEqual({
62
+ kind: "iframe",
63
+ src: "https://player.bilibili.com/player.html?bvid=BV1GJ411x7h7&autoplay=0&p=3",
64
+ });
65
+ });
66
+
67
+ it("Vimeo 转换为播放器", () => {
68
+ expect(resolveVideoEmbed("https://vimeo.com/76979871")).toEqual({
69
+ kind: "iframe",
70
+ src: "https://player.vimeo.com/video/76979871",
71
+ });
72
+ });
73
+
74
+ it("b23.tv 短链无法本地解析,返回 null 交给原有探测逻辑", () => {
75
+ expect(resolveVideoEmbed("https://b23.tv/BV1GJ411x7h7")).toBeNull();
76
+ });
77
+ });
@@ -0,0 +1,59 @@
1
+ // 视频链接识别与官方 embed 转换:供关联面板链接预览使用
2
+ // 返回 null 表示非视频链接(交由原有的可嵌入探测逻辑处理)
3
+
4
+ export type VideoEmbed =
5
+ | { kind: "file"; src: string }
6
+ | { kind: "iframe"; src: string };
7
+
8
+ const VIDEO_FILE_RE = /\.(mp4|webm|ogg|ogv|mov|m4v)$/i;
9
+
10
+ const YOUTUBE_ID_RE =
11
+ /^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?(?:.*&)?v=|shorts\/|embed\/)|youtu\.be\/)([A-Za-z0-9_-]{6,})/i;
12
+
13
+ const BILIBILI_BV_RE =
14
+ /^(?:https?:\/\/)?(?:www\.)?bilibili\.com\/video\/(BV[0-9A-Za-z]+)\/?(?:\?.*)?$/i;
15
+
16
+ const VIMEO_ID_RE = /^(?:https?:\/\/)?(?:www\.)?vimeo\.com\/(\d+)(?:\?.*)?$/i;
17
+
18
+ /** 解析 B 站分 P 参数(仅接受纯数字,防止参数注入) */
19
+ function getBilibiliPart(rawUrl: string): string | null {
20
+ try {
21
+ const p = new URL(rawUrl).searchParams.get("p");
22
+ return p && /^\d+$/.test(p) ? p : null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ export function resolveVideoEmbed(rawUrl: string): VideoEmbed | null {
29
+ if (!rawUrl) return null;
30
+
31
+ // 直链视频文件:扩展名只看 URL 路径末段,避免 query 中的伪扩展名误判
32
+ try {
33
+ const parsed = new URL(rawUrl);
34
+ if (VIDEO_FILE_RE.test(parsed.pathname)) {
35
+ return { kind: "file", src: rawUrl };
36
+ }
37
+ } catch {
38
+ return null;
39
+ }
40
+
41
+ const youtube = rawUrl.match(YOUTUBE_ID_RE);
42
+ if (youtube) {
43
+ return { kind: "iframe", src: `https://www.youtube.com/embed/${youtube[1]}` };
44
+ }
45
+
46
+ const bilibili = rawUrl.match(BILIBILI_BV_RE);
47
+ if (bilibili) {
48
+ const part = getBilibiliPart(rawUrl);
49
+ const src = `https://player.bilibili.com/player.html?bvid=${bilibili[1]}&autoplay=0${part ? `&p=${part}` : ""}`;
50
+ return { kind: "iframe", src };
51
+ }
52
+
53
+ const vimeo = rawUrl.match(VIMEO_ID_RE);
54
+ if (vimeo) {
55
+ return { kind: "iframe", src: `https://player.vimeo.com/video/${vimeo[1]}` };
56
+ }
57
+
58
+ return null;
59
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "routeFiles": [
3
+ "src/routes/_nav/tags/route.tsx",
4
+ "src/routes/_nav/tags/index.tsx",
5
+ "src/routes/_nav/tags/$id/route.tsx",
6
+ "src/routes/_nav/tags/settings/route.tsx",
7
+ "src/routes/_nav/inspiration/route.tsx",
8
+ "src/routes/_nav/prd/route.tsx",
9
+ "src/routes/_nav/prd/index.tsx",
10
+ "src/routes/_nav/prd/$id/route.tsx",
11
+ "src/routes/share/prd/$prdId/route.tsx"
12
+ ]
13
+ }
@@ -0,0 +1,20 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import type { ProFeature } from '@allbluecn/kernel'
3
+
4
+ export const activePluginIds = ['@allbluecn/tags', '@allbluecn/inspiration', '@allbluecn/prd'] as const
5
+ export type ActivePluginId = (typeof activePluginIds)[number]
6
+
7
+ export const implementedModules: Record<ProFeature, boolean> = {
8
+ billing: false,
9
+ hostedAi: false,
10
+ sso: false,
11
+ audit: false,
12
+ permissions: false,
13
+ license: false,
14
+ versionHistory: false,
15
+ automation: false,
16
+ templates: false,
17
+ analytics: false,
18
+ globalSearch: false,
19
+ compliance: false,
20
+ }
@@ -0,0 +1,20 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ // profile: oss (validated: 3 plugins)
3
+ // 拓扑序:@allbluecn/tags -> @allbluecn/inspiration -> @allbluecn/prd
4
+ import tags from '@allbluecn/plugins-core/tags'
5
+ import inspiration from '@allbluecn/plugins-core/inspiration'
6
+ import prd from '@allbluecn/plugins-core/prd'
7
+ import { defineProfile, type ProfileDefinition } from '@allbluecn/kernel'
8
+
9
+ export const profile: ProfileDefinition = defineProfile({
10
+ id: 'oss',
11
+ plugins: [tags, inspiration, prd],
12
+ })
13
+
14
+ export const pluginIds: string[] = ['@allbluecn/tags', '@allbluecn/inspiration', '@allbluecn/prd']
15
+
16
+ export const pluginMiddleware: unknown[] = [
17
+ ...tags.contributes.serverMiddleware ?? [],
18
+ ...inspiration.contributes.serverMiddleware ?? [],
19
+ ...prd.contributes.serverMiddleware ?? [],
20
+ ]
@@ -0,0 +1,8 @@
1
+ // AUTO-GENERATED by @allbluecn/kernel — DO NOT EDIT
2
+ import { NavTag } from '@allbluecn/plugins-core/tags/ui'
3
+ import { type ComponentType } from 'react'
4
+ import type { SettingsTabContribution } from '@allbluecn/kernel'
5
+
6
+ export const navItems: ComponentType[] = [NavTag]
7
+ export const settingsTabs: SettingsTabContribution[] = []
8
+ export const dashboardWidgets: ComponentType[] = []