@allbluecn/web-app 0.4.16 → 0.4.18
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/package.json +10 -10
- package/src/components/billing/checkout-redirect.ts +1 -1
- package/src/components/changelog/changelog-page.tsx +1 -1
- package/src/components/layout/sidebar-03/app-sidebar.tsx +2 -3
- package/src/components/layout/sidebar-03/nav-notifications.tsx +115 -35
- package/src/components/layout/sidebar-03/user-menu.tsx +5 -4
- package/src/components/notifications/notification-list.tsx +232 -0
- package/src/components/notifications/notification-preferences.tsx +156 -0
- package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
- package/src/components/settings/ai/provider-grid.tsx +46 -25
- package/src/components/settings/personal-team/team/team-client.tsx +118 -15
- package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
- package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
- package/src/components/story/display/__tests__/display-dropdown.test.tsx +30 -7
- package/src/components/story/display/display-dropdown.tsx +6 -7
- package/src/components/story/inline-editors/assignee-editor.tsx +72 -57
- package/src/components/story/story-list-view.tsx +13 -2
- package/src/hooks/__tests__/use-notifications.test.ts +41 -0
- package/src/hooks/use-notifications.ts +119 -0
- package/src/lib/app-version.ts +4 -1
- package/src/lib/auth/auth.config.ts +17 -1
- package/src/lib/changelog/sdk-versions-types.ts +1 -0
- package/src/lib/changelog/sdk-versions.ts +29 -19
- package/src/lib/collections/ai/index.ts +0 -1
- package/src/lib/collections/index.ts +0 -1
- package/src/plugins/.gen-manifest.json +13 -0
- package/src/plugins/modules.gen.ts +20 -0
- package/src/plugins/plugins.gen.ts +20 -0
- package/src/plugins/ui.gen.ts +10 -0
- package/src/routeTree.gen.ts +70 -0
- package/src/routes/_login/login/route.tsx +10 -1
- package/src/routes/_login/register/route.lazy.tsx +3 -2
- package/src/routes/_login/register/route.tsx +5 -0
- package/src/routes/_nav/inspiration/route.tsx +8 -0
- package/src/routes/_nav/notifications/route.lazy.tsx +23 -0
- package/src/routes/_nav/notifications/route.tsx +8 -0
- package/src/routes/_nav/prd/$id/route.tsx +9 -0
- package/src/routes/_nav/prd/index.tsx +9 -0
- package/src/routes/_nav/prd/route.tsx +8 -0
- package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
- package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
- package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
- package/src/routes/_nav/settings/notifications/route.tsx +20 -0
- package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
- package/src/routes/_nav/settings/route.lazy.tsx +1 -1
- package/src/routes/_nav/stories/$storyId/route.tsx +2 -3
- package/src/routes/_nav/tags/$id/route.tsx +8 -0
- package/src/routes/_nav/tags/index.tsx +8 -0
- package/src/routes/_nav/tags/route.tsx +8 -0
- package/src/routes/_nav/tags/settings/route.tsx +9 -0
- package/src/routes/api/notifications/stream.ts +88 -0
- package/src/routes/invite/$token/route.lazy.tsx +308 -44
- package/src/routes/invite/$token/route.tsx +16 -8
- package/src/routes/share/prd/$prdId/route.tsx +10 -0
- package/src/server/notifications/__tests__/notification-service.test.ts +114 -0
- package/src/server/notifications/__tests__/stream.test.ts +30 -0
- package/src/server/notifications/event-bus.ts +16 -0
- package/src/server/notifications/notification-service.ts +86 -0
- package/src/server/resources.ts +10 -2
- package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
- package/src/server/serverFns/__tests__/notifications.test.ts +63 -0
- package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
- package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
- package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
- package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
- package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
- package/src/server/serverFns/billing.ts +23 -2
- package/src/server/serverFns/notifications.ts +155 -0
- package/src/server/serverFns/story/__tests__/story-groups.test.ts +4 -0
- package/src/server/serverFns/story/story-comments.ts +42 -0
- package/src/server/serverFns/story/story-crud/labels.ts +45 -6
- package/src/server/serverFns/story/story-crud.ts +8 -2
- package/src/server/serverFns/story/story-groups.ts +26 -7
- package/src/server/serverFns/story/story-search.ts +4 -0
- package/src/server/serverFns/story-display-options.ts +15 -4
- package/src/server/serverFns/team.ts +573 -53
- package/src/start.ts +23 -1
- package/vite.shared.ts +13 -0
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
17
|
|
|
18
18
|
import { useMemo, useState } from "react";
|
|
19
|
-
import { CircleUser, Search, Check } from "lucide-react";
|
|
19
|
+
import { CircleUser, Search, Check, ArrowDownUp } from "lucide-react";
|
|
20
20
|
import { useTranslation } from "react-i18next";
|
|
21
21
|
import { Input } from "@/components/ui/input";
|
|
22
22
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
|
@@ -38,18 +38,22 @@ interface AssigneeEditorProps {
|
|
|
38
38
|
className?: string;
|
|
39
39
|
/** 渲染形态:chip=有值时显示 chip 边框(默认,筛选场景),button=始终 Button(列表/详情场景) */
|
|
40
40
|
variant?: "chip" | "button";
|
|
41
|
+
/** 成员范围:'project'=仅项目成员(默认),'team'=所有团队成员 */
|
|
42
|
+
scope?: "project" | "team";
|
|
41
43
|
}
|
|
42
44
|
|
|
43
|
-
export function AssigneeEditor({ assignee, users, onChange, mode = "single", selected, onMultiChange, className, variant = "chip" }: AssigneeEditorProps) {
|
|
45
|
+
export function AssigneeEditor({ assignee, users, onChange, mode = "single", selected, onMultiChange, className, variant = "chip", scope = "project" }: AssigneeEditorProps) {
|
|
44
46
|
const { t } = useTranslation("story");
|
|
45
47
|
const [open, setOpen] = useState(false);
|
|
46
48
|
const [search, setSearch] = useState("");
|
|
47
49
|
const isMulti = mode === "multi";
|
|
48
50
|
const showChip = variant === "chip";
|
|
51
|
+
const [currentScope, setCurrentScope] = useState(scope);
|
|
49
52
|
const filtered = useMemo(
|
|
50
53
|
() => (search ? users.filter((u) => u.username.includes(search)) : users),
|
|
51
54
|
[users, search],
|
|
52
55
|
);
|
|
56
|
+
const isProjectScope = currentScope === "project";
|
|
53
57
|
|
|
54
58
|
return (
|
|
55
59
|
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (v) setSearch(""); }}>
|
|
@@ -110,69 +114,80 @@ export function AssigneeEditor({ assignee, users, onChange, mode = "single", sel
|
|
|
110
114
|
className={className}
|
|
111
115
|
/>
|
|
112
116
|
)}
|
|
113
|
-
<PopoverContent className="w-52 p-1" align="start">
|
|
114
|
-
<div className="relative mb-1">
|
|
115
|
-
<Search className="absolute top-1/2 left-
|
|
117
|
+
<PopoverContent className="w-52 p-1 flex flex-col gap-0 max-h-72" align="start">
|
|
118
|
+
<div className="relative mb-1 shrink-0 sticky top-0 z-10 bg-popover py-0.5 -mx-1 px-1 -mt-1 flex items-center gap-1">
|
|
119
|
+
<Search className="absolute top-1/2 left-4 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
116
120
|
<Input
|
|
117
121
|
value={search}
|
|
118
122
|
onChange={(e) => setSearch(e.target.value)}
|
|
119
|
-
placeholder={t("inline.
|
|
120
|
-
className="h-
|
|
123
|
+
placeholder={isProjectScope ? t("inline.searchProjectMembers") : t("inline.searchTeamMembers")}
|
|
124
|
+
className="h-6 pl-8 pr-2 text-xs md:text-xs"
|
|
121
125
|
/>
|
|
122
|
-
</div>
|
|
123
|
-
{!isMulti && (
|
|
124
126
|
<button
|
|
125
127
|
type="button"
|
|
126
|
-
onClick={() =>
|
|
127
|
-
className=
|
|
128
|
-
|
|
129
|
-
!assignee ? "bg-accent font-medium" : "hover:bg-muted",
|
|
130
|
-
)}
|
|
128
|
+
onClick={() => setCurrentScope(isProjectScope ? "team" : "project")}
|
|
129
|
+
className="shrink-0 flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground px-1.5 py-1 rounded hover:bg-accent"
|
|
130
|
+
title={isProjectScope ? t("inline.switchToTeam") : t("inline.switchToProject")}
|
|
131
131
|
>
|
|
132
|
-
<
|
|
133
|
-
{t("
|
|
132
|
+
<ArrowDownUp className="size-3" />
|
|
133
|
+
{isProjectScope ? t("inline.scopeTeam") : t("inline.scopeProject")}
|
|
134
134
|
</button>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
135
|
+
</div>
|
|
136
|
+
<div className="overflow-y-auto min-h-0 flex-1 -mx-1 -mb-1 p-1">
|
|
137
|
+
{!isMulti && (
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
onClick={() => { onChange(null); setOpen(false); }}
|
|
141
|
+
className={cn(
|
|
142
|
+
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs",
|
|
143
|
+
!assignee ? "bg-accent font-medium" : "hover:bg-muted",
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
<CircleUser strokeWidth={1.5} className="size-6 text-muted-foreground" />
|
|
147
|
+
{t("filter.unassignedAssignee")}
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
{filtered.length === 0 ? (
|
|
151
|
+
<div className="px-2 py-1.5 text-xs text-muted-foreground">{t("inline.noMatchMembers")}</div>
|
|
152
|
+
) : (
|
|
153
|
+
filtered.map((u) => {
|
|
154
|
+
const active = isMulti ? (selected ?? []).includes(u.id) : assignee?.id === u.id;
|
|
155
|
+
return (
|
|
156
|
+
<button
|
|
157
|
+
key={u.id}
|
|
158
|
+
type="button"
|
|
159
|
+
onClick={() => {
|
|
160
|
+
if (isMulti) {
|
|
161
|
+
onMultiChange?.(u.id);
|
|
162
|
+
} else {
|
|
163
|
+
onChange(u.id);
|
|
164
|
+
setOpen(false);
|
|
165
|
+
}
|
|
166
|
+
}}
|
|
167
|
+
className={cn(
|
|
168
|
+
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs",
|
|
169
|
+
active ? "bg-accent font-medium" : "hover:bg-muted",
|
|
170
|
+
)}
|
|
171
|
+
>
|
|
172
|
+
{isMulti && (
|
|
173
|
+
<div className={cn("flex size-4 shrink-0 items-center justify-center rounded-sm border", active ? "border-primary bg-primary text-primary-foreground" : "border-input")}>
|
|
174
|
+
{active && <Check className="size-3" />}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
<UserAvatar
|
|
178
|
+
avatarConfig={u.avatarConfig}
|
|
179
|
+
bgShape={u.bgShape as "circle" | "rounded" | "square" | null}
|
|
180
|
+
bgColor={u.bgColor}
|
|
181
|
+
name={u.username}
|
|
182
|
+
size="sm"
|
|
183
|
+
/>
|
|
184
|
+
{u.username}
|
|
185
|
+
{!isMulti && active && <Check className="ml-auto size-3.5 shrink-0 text-primary" />}
|
|
186
|
+
</button>
|
|
187
|
+
);
|
|
188
|
+
})
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
176
191
|
</PopoverContent>
|
|
177
192
|
</Popover>
|
|
178
193
|
);
|
|
@@ -28,7 +28,7 @@ import { CalendarView } from "./views/calendar-view";
|
|
|
28
28
|
import { TimelineView } from "./views/timeline-view";
|
|
29
29
|
import { StoryList } from "./views/story-list";
|
|
30
30
|
import { serializeFilters, type FilterCondition } from "./filters";
|
|
31
|
-
import { listStoriesFn, archiveStoryFn } from "@/server/serverFns/story";
|
|
31
|
+
import { listStoriesFn, archiveStoryFn, listUsersBriefFn } from "@/server/serverFns/story";
|
|
32
32
|
import { listProjectsBriefFn } from "@/server/serverFns/project";
|
|
33
33
|
import {
|
|
34
34
|
listStoryViewsFn, createStoryViewFn, updateStoryViewFn, deleteStoryViewFn,
|
|
@@ -64,7 +64,7 @@ interface StoryListViewProps {
|
|
|
64
64
|
const STORY_PROJECT_FILTER_KEY = "story.projectFilter";
|
|
65
65
|
const STORY_SEARCH_KEY = "story.search";
|
|
66
66
|
|
|
67
|
-
export function StoryListView({ stories: initialStories, users, projects: initialProjects, labels }: StoryListViewProps) {
|
|
67
|
+
export function StoryListView({ stories: initialStories, users: initialUsers, projects: initialProjects, labels }: StoryListViewProps) {
|
|
68
68
|
const { t } = useTranslation("story");
|
|
69
69
|
const { session } = useRouteContext({ from: "/_nav/stories/" });
|
|
70
70
|
const navigate = useNavigate({ from: "/stories" });
|
|
@@ -77,6 +77,7 @@ export function StoryListView({ stories: initialStories, users, projects: initia
|
|
|
77
77
|
const [filterConditions, setFilterConditions] = useState<FilterCondition[]>([]);
|
|
78
78
|
const [stories, setStories] = useState(initialStories);
|
|
79
79
|
const [projects, setProjects] = useState(initialProjects);
|
|
80
|
+
const [users, setUsers] = useState<UserBrief[]>(initialUsers);
|
|
80
81
|
const [projectId, setProjectId] = useState<string | undefined>(() => {
|
|
81
82
|
try {
|
|
82
83
|
return localStorage.getItem(STORY_PROJECT_FILTER_KEY) ?? undefined;
|
|
@@ -105,6 +106,16 @@ export function StoryListView({ stories: initialStories, users, projects: initia
|
|
|
105
106
|
setProjects(initialProjects);
|
|
106
107
|
}, [initialStories, initialProjects]);
|
|
107
108
|
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (!projectId || projectId === "__archived__") {
|
|
111
|
+
setUsers(initialUsers);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
listUsersBriefFn({ data: { projectId } })
|
|
115
|
+
.then((u: UserBrief[]) => setUsers(u))
|
|
116
|
+
.catch(() => {});
|
|
117
|
+
}, [projectId]);
|
|
118
|
+
|
|
108
119
|
useEffect(() => {
|
|
109
120
|
if (!projectId && !searchText) return;
|
|
110
121
|
const isArchived = projectId === "__archived__";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { describe, it, expect } from 'vitest'
|
|
3
|
+
import { parseNotificationEvent } from '../use-notifications'
|
|
4
|
+
|
|
5
|
+
describe('parseNotificationEvent', () => {
|
|
6
|
+
it('解析合法 notification 事件', () => {
|
|
7
|
+
const raw = JSON.stringify({
|
|
8
|
+
type: 'CUSTOM',
|
|
9
|
+
name: 'notifications',
|
|
10
|
+
value: {
|
|
11
|
+
kind: 'notification',
|
|
12
|
+
notification: {
|
|
13
|
+
id: 'n1',
|
|
14
|
+
category: 'team',
|
|
15
|
+
type: 'team.invite_request',
|
|
16
|
+
title: 't',
|
|
17
|
+
body: null,
|
|
18
|
+
link: null,
|
|
19
|
+
readAt: null,
|
|
20
|
+
createdAt: '2026-08-26T00:00:00Z',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
const parsed = parseNotificationEvent(raw)
|
|
25
|
+
expect(parsed?.kind).toBe('notification')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('解析 unread_snapshot 事件', () => {
|
|
29
|
+
const raw = JSON.stringify({ type: 'CUSTOM', name: 'notifications', value: { kind: 'unread_snapshot', count: 5 } })
|
|
30
|
+
expect(parseNotificationEvent(raw)).toEqual({ kind: 'unread_snapshot', count: 5 })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('非法 JSON 返回 null', () => {
|
|
34
|
+
expect(parseNotificationEvent('not json')).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('非 notifications 通道的 CUSTOM 事件返回 null', () => {
|
|
38
|
+
const raw = JSON.stringify({ type: 'CUSTOM', name: 'other', value: { kind: 'unread_snapshot', count: 5 } })
|
|
39
|
+
expect(parseNotificationEvent(raw)).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
import { useCallback, useEffect } from 'react'
|
|
3
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
4
|
+
import { useRouter } from '@tanstack/react-router'
|
|
5
|
+
import { toast } from 'sonner'
|
|
6
|
+
import { useTranslation } from 'react-i18next'
|
|
7
|
+
import type { NotificationSseValue, NotificationItem } from '@allbluecn/shared'
|
|
8
|
+
import { isInDndWindow } from '@allbluecn/shared'
|
|
9
|
+
import { getUnreadCountFn, getNotificationPreferencesFn } from '@/server/serverFns/notifications'
|
|
10
|
+
|
|
11
|
+
export const NOTIFICATION_LIST_KEY = ['notifications', 'list'] as const
|
|
12
|
+
export const NOTIFICATION_UNREAD_KEY = ['notifications', 'unread'] as const
|
|
13
|
+
export const NOTIFICATION_PREFERENCES_KEY = ['notifications', 'preferences'] as const
|
|
14
|
+
|
|
15
|
+
export function parseNotificationEvent(raw: string): NotificationSseValue | null {
|
|
16
|
+
try {
|
|
17
|
+
const parsed = JSON.parse(raw)
|
|
18
|
+
if (parsed?.type === 'CUSTOM' && parsed?.name === 'notifications' && parsed?.value?.kind) {
|
|
19
|
+
return parsed.value as NotificationSseValue
|
|
20
|
+
}
|
|
21
|
+
return null
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function toastNotification(item: NotificationItem, t: (key: string) => string, navigate: (opts: { to: string }) => void) {
|
|
28
|
+
toast(item.title, {
|
|
29
|
+
description: item.body ?? undefined,
|
|
30
|
+
action: item.link
|
|
31
|
+
? {
|
|
32
|
+
label: t('actions.view'),
|
|
33
|
+
onClick: () => {
|
|
34
|
+
navigate({ to: item.link! })
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
: undefined,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let source: EventSource | null = null
|
|
42
|
+
let refCount = 0
|
|
43
|
+
|
|
44
|
+
export function useNotifications(options?: { toastEnabled?: boolean }) {
|
|
45
|
+
const { t } = useTranslation('notifications')
|
|
46
|
+
const queryClient = useQueryClient()
|
|
47
|
+
const router = useRouter()
|
|
48
|
+
const toastEnabled = options?.toastEnabled !== false
|
|
49
|
+
|
|
50
|
+
const { data: unreadData } = useQuery({
|
|
51
|
+
queryKey: NOTIFICATION_UNREAD_KEY,
|
|
52
|
+
queryFn: () => getUnreadCountFn({ data: {} }),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const unreadCount = unreadData?.count ?? 0
|
|
56
|
+
|
|
57
|
+
// N2 修复:预加载偏好数据到缓存,确保 DND 判断在任何页面均可用
|
|
58
|
+
useQuery({
|
|
59
|
+
queryKey: NOTIFICATION_PREFERENCES_KEY,
|
|
60
|
+
queryFn: () => getNotificationPreferencesFn({ data: {} }),
|
|
61
|
+
staleTime: 60000,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const setUnreadCount = useCallback(
|
|
65
|
+
(updater: number | ((c: number) => number)) => {
|
|
66
|
+
if (typeof updater === 'function') {
|
|
67
|
+
queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, (old) => {
|
|
68
|
+
const count = (old as { count?: number } | undefined)?.count ?? 0
|
|
69
|
+
return { count: updater(count) }
|
|
70
|
+
})
|
|
71
|
+
} else {
|
|
72
|
+
queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, { count: updater })
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
[queryClient]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
refCount++
|
|
80
|
+
if (!source) {
|
|
81
|
+
source = new EventSource('/api/notifications/stream')
|
|
82
|
+
}
|
|
83
|
+
const es = source
|
|
84
|
+
|
|
85
|
+
const handleMessage = (event: MessageEvent) => {
|
|
86
|
+
const value = parseNotificationEvent(event.data)
|
|
87
|
+
if (!value) return
|
|
88
|
+
if (value.kind === 'unread_snapshot') {
|
|
89
|
+
queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, { count: value.count })
|
|
90
|
+
} else {
|
|
91
|
+
queryClient.setQueryData(NOTIFICATION_UNREAD_KEY, (old) => {
|
|
92
|
+
const count = (old as { count?: number } | undefined)?.count ?? 0
|
|
93
|
+
return { count: count + 1 }
|
|
94
|
+
})
|
|
95
|
+
queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
|
|
96
|
+
if (toastEnabled) {
|
|
97
|
+
const prefs = queryClient.getQueryData<{ dndEnabled: boolean; dndStart: string; dndEnd: string }>(NOTIFICATION_PREFERENCES_KEY)
|
|
98
|
+
const now = new Date()
|
|
99
|
+
const hhmm = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
|
100
|
+
const inDnd = !!prefs && isInDndWindow(prefs, hhmm)
|
|
101
|
+
if (!inDnd) toastNotification(value.notification, t, (opts) => router.navigate({ to: opts.to as never }))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
es.addEventListener('message', handleMessage as EventListener)
|
|
107
|
+
return () => {
|
|
108
|
+
es.removeEventListener('message', handleMessage as EventListener)
|
|
109
|
+
refCount--
|
|
110
|
+
if (refCount === 0) {
|
|
111
|
+
source?.close()
|
|
112
|
+
source = null
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
116
|
+
}, [toastEnabled, t, router])
|
|
117
|
+
|
|
118
|
+
return { unreadCount, setUnreadCount }
|
|
119
|
+
}
|
package/src/lib/app-version.ts
CHANGED
|
@@ -15,6 +15,9 @@
|
|
|
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
|
-
|
|
18
|
+
// 版本号由 vite.shared.ts 构建时注入(__APP_VERSION__),无需运行时读取 package.json
|
|
19
|
+
declare const __APP_VERSION__: string
|
|
20
|
+
const APP_VERSION: string = __APP_VERSION__
|
|
21
|
+
|
|
19
22
|
export const APP_VERSION_DISPLAY = `v${APP_VERSION}`
|
|
20
23
|
export const APP_FULL_VERSION = `AllBlue v${APP_VERSION}`
|
|
@@ -139,8 +139,8 @@ export const authConfig: StartAuthJSConfig = {
|
|
|
139
139
|
// 安全提取 IP/UA(start-authjs 传递的 request 类型可能不标准,失败不阻塞登录)
|
|
140
140
|
let ip: string | null = null
|
|
141
141
|
let userAgent: string | null = null
|
|
142
|
+
const headers = request?.headers as { get?: (name: string) => string | null } | undefined
|
|
142
143
|
try {
|
|
143
|
-
const headers = request?.headers as { get?: (name: string) => string | null } | undefined
|
|
144
144
|
if (headers && typeof headers.get === 'function') {
|
|
145
145
|
ip = getClientIp(request as Request)
|
|
146
146
|
userAgent = headers.get('user-agent') || null
|
|
@@ -194,6 +194,22 @@ export const authConfig: StartAuthJSConfig = {
|
|
|
194
194
|
|
|
195
195
|
const remember = creds.remember === 'true'
|
|
196
196
|
const stayLoggedIn = creds.stayLoggedIn === 'true'
|
|
197
|
+
|
|
198
|
+
const isNewDevice = (() => {
|
|
199
|
+
const cookieHeader = headers?.get?.('cookie') || ''
|
|
200
|
+
return !cookieHeader.includes('allblue_device_seen=')
|
|
201
|
+
})()
|
|
202
|
+
void (async () => {
|
|
203
|
+
if (!isNewDevice) return
|
|
204
|
+
const { createNotification } = await import('@/server/notifications/notification-service')
|
|
205
|
+
await createNotification({
|
|
206
|
+
userId: user.id,
|
|
207
|
+
category: 'security',
|
|
208
|
+
type: 'security.new_device_login',
|
|
209
|
+
title: '检测到新设备登录',
|
|
210
|
+
body: `登录时间 ${new Date().toLocaleString('zh-CN')}`,
|
|
211
|
+
})
|
|
212
|
+
})()
|
|
197
213
|
return {
|
|
198
214
|
id: user.id,
|
|
199
215
|
email: user.email,
|
|
@@ -8,71 +8,81 @@ export const SDK_GROUPS: SdkGroup[] = [
|
|
|
8
8
|
"items": [
|
|
9
9
|
{
|
|
10
10
|
"name": "@tanstack/react-start",
|
|
11
|
+
"displayName": "TanStack Start",
|
|
11
12
|
"version": "v1.168.46",
|
|
12
|
-
"lastUpdate": "Aug
|
|
13
|
+
"lastUpdate": "Aug 27, 2026",
|
|
13
14
|
"source": "apps/web/package.json#dependencies",
|
|
14
15
|
"docsUrl": "https://tanstack.com/start/latest"
|
|
15
16
|
},
|
|
16
17
|
{
|
|
17
18
|
"name": "@tanstack/react-router",
|
|
19
|
+
"displayName": "TanStack Router",
|
|
18
20
|
"version": "v1.170.29",
|
|
19
|
-
"lastUpdate": "Aug
|
|
21
|
+
"lastUpdate": "Aug 27, 2026",
|
|
20
22
|
"source": "apps/web/package.json#dependencies",
|
|
21
23
|
"docsUrl": "https://tanstack.com/router/latest"
|
|
22
24
|
},
|
|
23
25
|
{
|
|
24
26
|
"name": "@tanstack/react-query",
|
|
27
|
+
"displayName": "TanStack Query",
|
|
25
28
|
"version": "v5.101.2",
|
|
26
|
-
"lastUpdate": "Aug
|
|
29
|
+
"lastUpdate": "Aug 27, 2026",
|
|
27
30
|
"source": "apps/web/package.json#dependencies",
|
|
28
31
|
"docsUrl": "https://tanstack.com/query/latest"
|
|
29
32
|
},
|
|
30
33
|
{
|
|
31
34
|
"name": "@tanstack/ai",
|
|
35
|
+
"displayName": "TanStack AI",
|
|
32
36
|
"version": "v0.43.0",
|
|
33
|
-
"lastUpdate": "Aug
|
|
37
|
+
"lastUpdate": "Aug 27, 2026",
|
|
34
38
|
"source": "apps/web/package.json#dependencies",
|
|
35
39
|
"docsUrl": "https://tanstack.com/ai/latest"
|
|
36
40
|
},
|
|
37
41
|
{
|
|
38
42
|
"name": "@tanstack/react-table",
|
|
43
|
+
"displayName": "TanStack Table",
|
|
39
44
|
"version": "v9.1.0",
|
|
40
|
-
"lastUpdate": "Aug
|
|
45
|
+
"lastUpdate": "Aug 27, 2026",
|
|
41
46
|
"source": "apps/web/package.json#dependencies",
|
|
42
47
|
"docsUrl": "https://tanstack.com/table/latest"
|
|
43
48
|
},
|
|
44
49
|
{
|
|
45
50
|
"name": "@tanstack/react-form",
|
|
51
|
+
"displayName": "TanStack Form",
|
|
46
52
|
"version": "v2.0.0-alpha.0",
|
|
47
|
-
"lastUpdate": "Aug
|
|
53
|
+
"lastUpdate": "Aug 27, 2026",
|
|
48
54
|
"source": "apps/web/package.json#dependencies",
|
|
49
55
|
"docsUrl": "https://tanstack.com/form/latest"
|
|
50
56
|
},
|
|
51
57
|
{
|
|
52
58
|
"name": "@tanstack/charts",
|
|
59
|
+
"displayName": "TanStack Charts",
|
|
53
60
|
"version": "v0.9.0",
|
|
54
|
-
"lastUpdate": "Aug
|
|
61
|
+
"lastUpdate": "Aug 27, 2026",
|
|
55
62
|
"source": "apps/admin/package.json#dependencies",
|
|
56
63
|
"docsUrl": "https://tanstack.com/charts/latest"
|
|
57
64
|
},
|
|
58
65
|
{
|
|
59
66
|
"name": "@tanstack/react-pacer",
|
|
67
|
+
"displayName": "TanStack Pacer",
|
|
60
68
|
"version": "v0.22.1",
|
|
61
|
-
"lastUpdate": "Aug
|
|
69
|
+
"lastUpdate": "Aug 27, 2026",
|
|
62
70
|
"source": "apps/web/package.json#dependencies",
|
|
63
71
|
"docsUrl": "https://tanstack.com/pacer/latest"
|
|
64
72
|
},
|
|
65
73
|
{
|
|
66
74
|
"name": "@tanstack/react-hotkeys",
|
|
75
|
+
"displayName": "TanStack Hotkeys",
|
|
67
76
|
"version": "v0.10.0",
|
|
68
|
-
"lastUpdate": "Aug
|
|
77
|
+
"lastUpdate": "Aug 27, 2026",
|
|
69
78
|
"source": "apps/web/package.json#dependencies",
|
|
70
79
|
"docsUrl": "https://tanstack.com/hotkeys/latest"
|
|
71
80
|
},
|
|
72
81
|
{
|
|
73
82
|
"name": "@tanstack/react-virtual",
|
|
83
|
+
"displayName": "TanStack Virtual",
|
|
74
84
|
"version": "v3.14.9",
|
|
75
|
-
"lastUpdate": "Aug
|
|
85
|
+
"lastUpdate": "Aug 27, 2026",
|
|
76
86
|
"source": "apps/admin/package.json#dependencies",
|
|
77
87
|
"docsUrl": "https://tanstack.com/virtual/latest"
|
|
78
88
|
}
|
|
@@ -85,15 +95,15 @@ export const SDK_GROUPS: SdkGroup[] = [
|
|
|
85
95
|
"items": [
|
|
86
96
|
{
|
|
87
97
|
"name": "@allbluecn/web-app",
|
|
88
|
-
"version": "v0.4.
|
|
89
|
-
"lastUpdate": "Aug
|
|
98
|
+
"version": "v0.4.18",
|
|
99
|
+
"lastUpdate": "Aug 27, 2026",
|
|
90
100
|
"source": "apps/web/package.json",
|
|
91
101
|
"docsUrl": "https://github.com/allbluecn/allblue"
|
|
92
102
|
},
|
|
93
103
|
{
|
|
94
104
|
"name": "@allbluecn/kernel",
|
|
95
105
|
"version": "v0.3.0",
|
|
96
|
-
"lastUpdate": "Aug
|
|
106
|
+
"lastUpdate": "Aug 27, 2026",
|
|
97
107
|
"source": "packages/kernel/package.json",
|
|
98
108
|
"docsUrl": "https://github.com/allbluecn/allblue"
|
|
99
109
|
}
|
|
@@ -107,42 +117,42 @@ export const SDK_GROUPS: SdkGroup[] = [
|
|
|
107
117
|
{
|
|
108
118
|
"name": "Vite",
|
|
109
119
|
"version": "v8.1.5",
|
|
110
|
-
"lastUpdate": "Aug
|
|
120
|
+
"lastUpdate": "Aug 27, 2026",
|
|
111
121
|
"source": "apps/web/package.json#devDependencies",
|
|
112
122
|
"docsUrl": "https://vite.dev/"
|
|
113
123
|
},
|
|
114
124
|
{
|
|
115
125
|
"name": "Node.js",
|
|
116
126
|
"version": "v24.19.0",
|
|
117
|
-
"lastUpdate": "Aug
|
|
127
|
+
"lastUpdate": "Aug 27, 2026",
|
|
118
128
|
"source": ".nvmrc",
|
|
119
129
|
"docsUrl": "https://nodejs.org/"
|
|
120
130
|
},
|
|
121
131
|
{
|
|
122
132
|
"name": "Prisma",
|
|
123
133
|
"version": "v7.8.0",
|
|
124
|
-
"lastUpdate": "Aug
|
|
134
|
+
"lastUpdate": "Aug 27, 2026",
|
|
125
135
|
"source": "packages/database/package.json#devDependencies",
|
|
126
136
|
"docsUrl": "https://www.prisma.io/docs"
|
|
127
137
|
},
|
|
128
138
|
{
|
|
129
139
|
"name": "shadcn UI",
|
|
130
140
|
"version": "v1.2.4",
|
|
131
|
-
"lastUpdate": "Aug
|
|
141
|
+
"lastUpdate": "Aug 27, 2026",
|
|
132
142
|
"source": "apps/web/package.json#dependencies",
|
|
133
143
|
"docsUrl": "https://ui.shadcn.com/"
|
|
134
144
|
},
|
|
135
145
|
{
|
|
136
146
|
"name": "TipTap",
|
|
137
147
|
"version": "v3.27.4",
|
|
138
|
-
"lastUpdate": "Aug
|
|
148
|
+
"lastUpdate": "Aug 27, 2026",
|
|
139
149
|
"source": "packages/shared/package.json#devDependencies",
|
|
140
150
|
"docsUrl": "https://tiptap.dev/"
|
|
141
151
|
},
|
|
142
152
|
{
|
|
143
153
|
"name": "Lucide React",
|
|
144
154
|
"version": "v1.24.0",
|
|
145
|
-
"lastUpdate": "Aug
|
|
155
|
+
"lastUpdate": "Aug 27, 2026",
|
|
146
156
|
"source": "packages/ui/package.json#dependencies",
|
|
147
157
|
"docsUrl": "https://lucide.dev/"
|
|
148
158
|
}
|
|
@@ -20,5 +20,4 @@ export { projectsCollection } from './reference/projects-collection'
|
|
|
20
20
|
export { usersBriefCollection } from './reference/users-brief-collection'
|
|
21
21
|
export { labelsCollection } from './reference/labels-collection'
|
|
22
22
|
export { bookmarkCategoriesCollection } from './reference/bookmark-categories-collection'
|
|
23
|
-
export { aiProvidersCollection } from './ai/ai-providers-collection'
|
|
24
23
|
export { knowledgeBasesCollection } from './knowledge/knowledge-bases-collection'
|
|
@@ -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
|
+
}
|