@allbluecn/web-app 0.4.17 → 0.4.19

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 (56) hide show
  1. package/package.json +11 -11
  2. package/src/components/billing/checkout-redirect.ts +1 -1
  3. package/src/components/changelog/changelog-page.tsx +1 -1
  4. package/src/components/layout/sidebar-03/user-menu.tsx +1 -1
  5. package/src/components/notifications/notification-list.tsx +232 -0
  6. package/src/components/notifications/notification-preferences.tsx +156 -0
  7. package/src/components/settings/ai/create-custom-provider-dialog.tsx +145 -0
  8. package/src/components/settings/ai/provider-grid.tsx +46 -25
  9. package/src/components/settings/personal-team/team/team-client.tsx +118 -15
  10. package/src/components/settings/personal-team/team/team-invitations.tsx +887 -416
  11. package/src/components/settings/personal-team/team/team-member-list.tsx +122 -136
  12. package/src/config/sidebar-routes.tsx +16 -0
  13. package/src/hooks/use-notifications.ts +119 -0
  14. package/src/lib/app-version.ts +4 -1
  15. package/src/lib/changelog/sdk-versions-types.ts +1 -0
  16. package/src/lib/changelog/sdk-versions.ts +29 -19
  17. package/src/lib/collections/ai/index.ts +0 -1
  18. package/src/lib/collections/index.ts +0 -1
  19. package/src/plugins/.gen-manifest.json +13 -0
  20. package/src/plugins/modules.gen.ts +20 -0
  21. package/src/plugins/plugins.gen.ts +20 -0
  22. package/src/plugins/ui.gen.ts +8 -0
  23. package/src/routeTree.gen.ts +47 -0
  24. package/src/routes/_login/login/route.tsx +10 -1
  25. package/src/routes/_login/register/route.lazy.tsx +3 -2
  26. package/src/routes/_login/register/route.tsx +5 -0
  27. package/src/routes/_nav/inspiration/route.tsx +8 -0
  28. package/src/routes/_nav/prd/$id/route.tsx +9 -0
  29. package/src/routes/_nav/prd/index.tsx +9 -0
  30. package/src/routes/_nav/prd/route.tsx +8 -0
  31. package/src/routes/_nav/settings/-settings-tabs.tsx +2 -1
  32. package/src/routes/_nav/settings/design/route.lazy.tsx +1 -1
  33. package/src/routes/_nav/settings/notifications/route.lazy.tsx +37 -0
  34. package/src/routes/_nav/settings/notifications/route.tsx +20 -0
  35. package/src/routes/_nav/settings/privacy/route.lazy.tsx +2 -2
  36. package/src/routes/_nav/settings/route.lazy.tsx +1 -1
  37. package/src/routes/_nav/tags/$id/route.tsx +8 -0
  38. package/src/routes/_nav/tags/index.tsx +8 -0
  39. package/src/routes/_nav/tags/route.tsx +8 -0
  40. package/src/routes/_nav/tags/settings/route.tsx +9 -0
  41. package/src/routes/api/notifications/stream.ts +88 -0
  42. package/src/routes/invite/$token/route.lazy.tsx +308 -44
  43. package/src/routes/invite/$token/route.tsx +16 -8
  44. package/src/routes/share/prd/$prdId/route.tsx +10 -0
  45. package/src/server/notifications/event-bus.ts +16 -0
  46. package/src/server/notifications/notification-service.ts +86 -0
  47. package/src/server/resources.ts +10 -2
  48. package/src/server/serverFns/__tests__/compliance.test.ts +6 -6
  49. package/src/server/serverFns/__tests__/team-accept.test.ts +100 -12
  50. package/src/server/serverFns/__tests__/team-invitation.test.ts +130 -60
  51. package/src/server/serverFns/__tests__/team-join-request.test.ts +281 -0
  52. package/src/server/serverFns/__tests__/team-members.test.ts +39 -118
  53. package/src/{lib/collections/ai/ai-providers-collection.ts → server/serverFns/auth/setup.ts} +7 -12
  54. package/src/server/serverFns/notifications.ts +155 -0
  55. package/src/server/serverFns/team.ts +521 -53
  56. package/vite.shared.ts +13 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allbluecn/web-app",
3
- "version": "0.4.17",
3
+ "version": "0.4.19",
4
4
  "license": "AGPL-3.0-or-later",
5
5
  "type": "module",
6
6
  "files": [
@@ -27,7 +27,7 @@
27
27
  "@fontsource-variable/geist": "^5.3.0",
28
28
  "@fontsource-variable/geist-mono": "^5.3.0",
29
29
  "@iconify/react": "^6.0.2",
30
- "@lobehub/icons": "^5.10.0",
30
+ "@lobehub/icons": "^5.16.0",
31
31
  "@prisma/client": "^7.8.0",
32
32
  "@radix-ui/react-dialog": "^1.1.23",
33
33
  "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -135,14 +135,14 @@
135
135
  "three": "^0.180.0",
136
136
  "tw-animate-css": "^1.4.0",
137
137
  "zod": "^4.4.3",
138
- "@allbluecn/ai-gateway": "0.4.12",
139
138
  "@allbluecn/kernel": "0.3.0",
140
- "@allbluecn/database": "0.4.12",
141
- "@allbluecn/plugins-core": "0.4.12",
142
- "@allbluecn/prototype": "0.1.1",
143
- "@allbluecn/ui": "0.4.12",
144
- "@allbluecn/shared": "0.4.12",
145
- "@allbluecn/tiptap": "0.4.12"
139
+ "@allbluecn/shared": "0.4.14",
140
+ "@allbluecn/database": "0.4.14",
141
+ "@allbluecn/tiptap": "0.4.13",
142
+ "@allbluecn/ui": "0.4.14",
143
+ "@allbluecn/ai-gateway": "0.4.14",
144
+ "@allbluecn/plugins-core": "0.4.13",
145
+ "@allbluecn/prototype": "0.1.1"
146
146
  },
147
147
  "devDependencies": {
148
148
  "@babel/parser": "^8.0.4",
@@ -197,8 +197,8 @@
197
197
  "setup": "pnpm --filter @allbluecn/database generate && pnpm --filter @allbluecn/database migrate:dev",
198
198
  "check:features": "tsx scripts/check-feature-keys.ts",
199
199
  "assemble": "pnpm -w exec tsx packages/kernel/src/bin.ts --profile oss --assemble --app-dir apps/web",
200
- "predev": "pnpm assemble",
201
- "prebuild": "pnpm assemble",
200
+ "predev": "pnpm assemble && node scripts/check-sdk-versions.mjs",
201
+ "prebuild": "pnpm assemble && node scripts/check-sdk-versions.mjs",
202
202
  "pretest": "pnpm assemble",
203
203
  "pretest:run": "pnpm assemble"
204
204
  }
@@ -38,7 +38,7 @@ export async function redirectToPortal(): Promise<void> {
38
38
  }
39
39
 
40
40
  try {
41
- const { url } = await createCustomerPortalSessionFn()
41
+ const { url } = await createCustomerPortalSessionFn({ data: {} })
42
42
  window.location.href = url
43
43
  } catch (error) {
44
44
  toast.error(
@@ -378,7 +378,7 @@ function SdkGroupSection({
378
378
  className="flex items-center justify-between gap-2 py-1.5 group"
379
379
  >
380
380
  <span className="text-xs font-medium text-muted-foreground group-hover:text-primary transition-colors truncate">
381
- {sdk.name}
381
+ {sdk.displayName || sdk.name}
382
382
  </span>
383
383
  <code className="text-[10px] font-mono text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded shrink-0 group-hover:text-primary transition-colors">
384
384
  {sdk.version}
@@ -77,7 +77,7 @@ export function UserMenu() {
77
77
 
78
78
  const handleSignOut = async () => {
79
79
  if (isLocalGuest) {
80
- await logoutRemoteFn();
80
+ await logoutRemoteFn({ data: {} });
81
81
  toast.success(t('sidebar.userMenu.logoutToGuest'));
82
82
  queryClient.resetQueries({ queryKey: ['session'] });
83
83
  queryClient.resetQueries({ queryKey: ['effective-plan'] });
@@ -0,0 +1,232 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { useCallback, useState } from 'react'
3
+ import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { useNavigate } from '@tanstack/react-router'
5
+ import { useTranslation } from 'react-i18next'
6
+ import { format } from 'date-fns'
7
+ import { zhCN } from 'date-fns/locale'
8
+ import { Bell, CreditCard, MessageSquare, Megaphone, ShieldCheck, Trash2, Users } from 'lucide-react'
9
+ import type { LucideIcon } from 'lucide-react'
10
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar'
11
+ import { Button } from '@/components/ui/button'
12
+ import { Skeleton } from '@/components/ui/skeleton'
13
+ import { cn } from '@/lib/utils'
14
+ import type { NotificationCategory } from '@allbluecn/shared'
15
+ import {
16
+ deleteNotificationFn,
17
+ listNotificationsFn,
18
+ markAllNotificationsReadFn,
19
+ markNotificationReadFn,
20
+ } from '@/server/serverFns/notifications'
21
+ import {
22
+ NOTIFICATION_LIST_KEY,
23
+ NOTIFICATION_UNREAD_KEY,
24
+ } from '@/hooks/use-notifications'
25
+
26
+ export const CATEGORY_ICONS: Record<NotificationCategory, LucideIcon> & { all: LucideIcon } = {
27
+ all: Bell,
28
+ team: Users,
29
+ collaboration: MessageSquare,
30
+ system: Megaphone,
31
+ billing: CreditCard,
32
+ security: ShieldCheck,
33
+ }
34
+
35
+ const TABS: { key: 'all' | NotificationCategory; labelKey: string }[] = [
36
+ { key: 'all', labelKey: 'tabs.all' },
37
+ { key: 'team', labelKey: 'tabs.team' },
38
+ { key: 'collaboration', labelKey: 'tabs.collaboration' },
39
+ { key: 'system', labelKey: 'tabs.system' },
40
+ { key: 'billing', labelKey: 'tabs.billing' },
41
+ { key: 'security', labelKey: 'tabs.security' },
42
+ ]
43
+
44
+ export interface NotificationListProps {
45
+ category?: NotificationCategory
46
+ }
47
+
48
+ export function NotificationList({ category }: NotificationListProps) {
49
+ const { t } = useTranslation('notifications')
50
+ const navigate = useNavigate()
51
+ const queryClient = useQueryClient()
52
+ const [activeTab, setActiveTab] = useState<'all' | NotificationCategory>(category ?? 'all')
53
+
54
+ const { data, isLoading, fetchNextPage, isFetchingNextPage, hasNextPage } = useInfiniteQuery({
55
+ queryKey: [...NOTIFICATION_LIST_KEY, activeTab === 'all' ? undefined : activeTab],
56
+ queryFn: async ({ pageParam }) => {
57
+ const base = activeTab === 'all' ? {} : { category: activeTab }
58
+ return listNotificationsFn({
59
+ data: pageParam ? { ...base, cursor: pageParam } : base,
60
+ })
61
+ },
62
+ initialPageParam: undefined as string | undefined,
63
+ getNextPageParam: (lastPage) =>
64
+ lastPage.hasMore ? lastPage.items[lastPage.items.length - 1].id : undefined,
65
+ })
66
+
67
+ const handleMarkRead = useCallback(
68
+ async (id: string) => {
69
+ await markNotificationReadFn({ data: { id } })
70
+ queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
71
+ queryClient.invalidateQueries({ queryKey: NOTIFICATION_UNREAD_KEY })
72
+ },
73
+ [queryClient]
74
+ )
75
+
76
+ const handleDelete = useCallback(
77
+ async (id: string, e: React.MouseEvent) => {
78
+ e.stopPropagation()
79
+ await deleteNotificationFn({ data: { id } })
80
+ queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
81
+ queryClient.invalidateQueries({ queryKey: NOTIFICATION_UNREAD_KEY })
82
+ },
83
+ [queryClient]
84
+ )
85
+
86
+ const handleMarkAll = useCallback(async () => {
87
+ await markAllNotificationsReadFn({ data: {} })
88
+ queryClient.invalidateQueries({ queryKey: NOTIFICATION_LIST_KEY })
89
+ queryClient.invalidateQueries({ queryKey: NOTIFICATION_UNREAD_KEY })
90
+ }, [queryClient])
91
+
92
+ const handleClickItem = useCallback(
93
+ (item: { id: string; link: string | null; readAt: string | null }) => {
94
+ if (!item.readAt) {
95
+ void handleMarkRead(item.id)
96
+ }
97
+ if (item.link) {
98
+ navigate({ to: item.link as never })
99
+ }
100
+ },
101
+ [handleMarkRead, navigate]
102
+ )
103
+
104
+ const formatRelativeTime = (createdAt: string) => {
105
+ const date = new Date(createdAt)
106
+ const now = new Date()
107
+ const diffMs = now.getTime() - date.getTime()
108
+ const diffMin = Math.floor(diffMs / 60000)
109
+ const diffHour = Math.floor(diffMs / 3600000)
110
+ const diffDay = Math.floor(diffMs / 86400000)
111
+
112
+ if (diffMin < 1) return t('relativeTime.justNow')
113
+ if (diffMin < 60) return t('relativeTime.minutesAgo', { count: diffMin })
114
+ if (diffHour < 24) return t('relativeTime.hoursAgo', { count: diffHour })
115
+ if (diffDay < 30) return t('relativeTime.daysAgo', { count: diffDay })
116
+ return format(date, 'MM/dd/yyyy HH:mm', { locale: zhCN })
117
+ }
118
+
119
+ if (isLoading) {
120
+ return (
121
+ <div className="flex flex-col gap-3">
122
+ {Array.from({ length: 5 }).map((_, i) => (
123
+ <div key={i} className="flex items-center gap-3 rounded-lg border p-3">
124
+ <Skeleton className="size-9 shrink-0 rounded-full" />
125
+ <div className="flex-1 space-y-2">
126
+ <Skeleton className="h-4 w-3/4" />
127
+ <Skeleton className="h-3 w-1/2" />
128
+ </div>
129
+ </div>
130
+ ))}
131
+ </div>
132
+ )
133
+ }
134
+
135
+ const items = data?.pages.flatMap((page) => page.items) ?? []
136
+ const hasMore = hasNextPage ?? false
137
+
138
+ return (
139
+ <div className="flex flex-col gap-4">
140
+ <div className="flex items-center justify-between">
141
+ <div className="flex gap-1">
142
+ {TABS.map((tab) => {
143
+ const Icon = CATEGORY_ICONS[tab.key]
144
+ return (
145
+ <Button
146
+ key={tab.key}
147
+ variant={activeTab === tab.key ? 'secondary' : 'ghost'}
148
+ size="sm"
149
+ className="gap-1.5"
150
+ onClick={() => setActiveTab(tab.key)}
151
+ >
152
+ <Icon className="size-4" />
153
+ {t(tab.labelKey)}
154
+ </Button>
155
+ )
156
+ })}
157
+ </div>
158
+ {items.some((item) => !item.readAt) && (
159
+ <Button variant="ghost" size="sm" onClick={handleMarkAll}>
160
+ {t('actions.markAllRead')}
161
+ </Button>
162
+ )}
163
+ </div>
164
+
165
+ {items.length === 0 ? (
166
+ <div className="flex flex-col items-center justify-center py-12 text-center">
167
+ <Bell className="size-12 text-muted-foreground/40" />
168
+ <p className="mt-2 text-sm text-muted-foreground">
169
+ {activeTab === 'all' ? t('empty') : t('emptyFiltered')}
170
+ </p>
171
+ </div>
172
+ ) : (
173
+ <div className="flex flex-col gap-1">
174
+ {items.map((item) => {
175
+ const Icon = CATEGORY_ICONS[item.category]
176
+ const isUnread = !item.readAt
177
+ return (
178
+ <div
179
+ key={item.id}
180
+ className={cn(
181
+ 'group flex items-start gap-3 rounded-lg border p-3 transition-colors',
182
+ isUnread ? 'bg-muted/30' : 'opacity-70',
183
+ 'hover:bg-muted/50 cursor-pointer'
184
+ )}
185
+ onClick={() => handleClickItem(item)}
186
+ >
187
+ <Avatar className="size-9 shrink-0">
188
+ <AvatarFallback className="bg-primary/10 text-primary">
189
+ <Icon className="size-4" />
190
+ </AvatarFallback>
191
+ </Avatar>
192
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
193
+ <div className="flex items-center gap-2">
194
+ {isUnread && <span className="size-2 shrink-0 rounded-full bg-primary" />}
195
+ <span className={cn('text-sm font-medium', isUnread ? '' : 'text-muted-foreground')}>
196
+ {item.title}
197
+ </span>
198
+ <span className="ml-auto shrink-0 text-xs text-muted-foreground">
199
+ {formatRelativeTime(item.createdAt)}
200
+ </span>
201
+ </div>
202
+ {item.body && (
203
+ <span className="line-clamp-2 text-xs text-muted-foreground">{item.body}</span>
204
+ )}
205
+ </div>
206
+ <Button
207
+ variant="ghost"
208
+ size="icon"
209
+ className="size-7 shrink-0 opacity-0 group-hover:opacity-100"
210
+ onClick={(e) => handleDelete(item.id, e)}
211
+ >
212
+ <Trash2 className="size-3.5" />
213
+ </Button>
214
+ </div>
215
+ )
216
+ })}
217
+ {hasMore && (
218
+ <Button
219
+ variant="ghost"
220
+ size="sm"
221
+ className="mt-1 mx-auto"
222
+ onClick={() => void fetchNextPage()}
223
+ disabled={isFetchingNextPage}
224
+ >
225
+ {t('actions.loadMore')}
226
+ </Button>
227
+ )}
228
+ </div>
229
+ )}
230
+ </div>
231
+ )
232
+ }
@@ -0,0 +1,156 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ import { useEffect, useState } from 'react'
3
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
4
+ import { useTranslation } from 'react-i18next'
5
+ import { toast } from 'sonner'
6
+ import { useNotifications } from '@/hooks/use-notifications'
7
+ import { getNotificationPreferencesFn, updateNotificationPreferencesFn } from '@/server/serverFns/notifications'
8
+ import type { CategoryPreferences, NotificationCategory, NotificationPreferences } from '@allbluecn/shared'
9
+
10
+ const CATEGORY_META: { key: NotificationCategory; labelKey: string; descKey: string }[] = [
11
+ { key: 'team', labelKey: 'preferences.categories.team', descKey: 'preferences.categories.teamDesc' },
12
+ { key: 'collaboration', labelKey: 'preferences.categories.collaboration', descKey: 'preferences.categories.collaborationDesc' },
13
+ { key: 'system', labelKey: 'preferences.categories.system', descKey: 'preferences.categories.systemDesc' },
14
+ { key: 'billing', labelKey: 'preferences.categories.billing', descKey: 'preferences.categories.billingDesc' },
15
+ { key: 'security', labelKey: 'preferences.categories.security', descKey: 'preferences.categories.securityDesc' },
16
+ ]
17
+
18
+ export function NotificationPreferences() {
19
+ const { t } = useTranslation('notifications')
20
+ const queryClient = useQueryClient()
21
+ const { setUnreadCount } = useNotifications()
22
+
23
+ const { data: prefsData, isLoading } = useQuery({
24
+ queryKey: ['notifications', 'preferences'],
25
+ queryFn: () => getNotificationPreferencesFn({ data: {} }),
26
+ })
27
+
28
+ const [categories, setCategories] = useState<CategoryPreferences | null>(null)
29
+ const [dndEnabled, setDndEnabled] = useState(false)
30
+ const [dndStart, setDndStart] = useState('22:00')
31
+ const [dndEnd, setDndEnd] = useState('08:00')
32
+
33
+ useEffect(() => {
34
+ if (prefsData) {
35
+ setCategories(prefsData.categories)
36
+ setDndEnabled(prefsData.dndEnabled)
37
+ setDndStart(prefsData.dndStart)
38
+ setDndEnd(prefsData.dndEnd)
39
+ }
40
+ }, [prefsData])
41
+
42
+ const save = async () => {
43
+ if (!categories) return
44
+ await updateNotificationPreferencesFn({
45
+ data: {
46
+ categories,
47
+ dndEnabled,
48
+ dndStart,
49
+ dndEnd,
50
+ },
51
+ })
52
+ queryClient.invalidateQueries({ queryKey: ['notifications', 'preferences'] })
53
+ toast.success(t('preferences.saved'))
54
+ }
55
+
56
+ const toggleCategory = (key: NotificationCategory) => {
57
+ setCategories((prev) => (prev ? { ...prev, [key]: !prev[key] } : prev))
58
+ }
59
+
60
+ if (isLoading) {
61
+ return <div className="space-y-4">{Array.from({ length: 4 }).map((_, i) => <div key={i} className="h-16 rounded-lg border bg-muted/30" />)}</div>
62
+ }
63
+
64
+ return (
65
+ <div className="space-y-6">
66
+ <div className="space-y-3">
67
+ {CATEGORY_META.map((meta) => (
68
+ <div key={meta.key} className="flex items-center justify-between rounded-lg border p-4">
69
+ <div className="flex flex-col gap-0.5">
70
+ <span className="text-sm font-medium">{t(meta.labelKey)}</span>
71
+ <span className="text-xs text-muted-foreground">{t(meta.descKey)}</span>
72
+ </div>
73
+ <button
74
+ type="button"
75
+ role="switch"
76
+ aria-checked={categories?.[meta.key] ?? false}
77
+ className={
78
+ 'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ' +
79
+ ((categories?.[meta.key] ?? false) ? 'bg-primary' : 'bg-input')
80
+ }
81
+ onClick={() => toggleCategory(meta.key)}
82
+ >
83
+ <span
84
+ className={
85
+ 'pointer-events-none block size-4 translate-x-0.5 rounded-full bg-background shadow ring-0 transition-transform ' +
86
+ ((categories?.[meta.key] ?? false) ? 'translate-x-5' : '')
87
+ }
88
+ />
89
+ </button>
90
+ </div>
91
+ ))}
92
+ </div>
93
+
94
+ <div className="rounded-lg border p-4 space-y-4">
95
+ <div className="flex flex-col gap-0.5">
96
+ <span className="text-sm font-medium">{t('preferences.dnd.title')}</span>
97
+ <span className="text-xs text-muted-foreground">{t('preferences.dnd.desc')}</span>
98
+ </div>
99
+
100
+ <div className="flex items-center justify-between">
101
+ <span className="text-sm">{t('preferences.dnd.enabled')}</span>
102
+ <button
103
+ type="button"
104
+ role="switch"
105
+ aria-checked={dndEnabled}
106
+ className={
107
+ 'relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ' +
108
+ (dndEnabled ? 'bg-primary' : 'bg-input')
109
+ }
110
+ onClick={() => setDndEnabled(!dndEnabled)}
111
+ >
112
+ <span
113
+ className={
114
+ 'pointer-events-none block size-4 translate-x-0.5 rounded-full bg-background shadow ring-0 transition-transform ' +
115
+ (dndEnabled ? 'translate-x-5' : '')
116
+ }
117
+ />
118
+ </button>
119
+ </div>
120
+
121
+ {dndEnabled && (
122
+ <div className="flex gap-4">
123
+ <div className="flex-1 space-y-1">
124
+ <label className="text-xs text-muted-foreground">{t('preferences.dnd.start')}</label>
125
+ <input
126
+ type="time"
127
+ value={dndStart}
128
+ onChange={(e) => setDndStart(e.target.value)}
129
+ className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
130
+ />
131
+ </div>
132
+ <div className="flex-1 space-y-1">
133
+ <label className="text-xs text-muted-foreground">{t('preferences.dnd.end')}</label>
134
+ <input
135
+ type="time"
136
+ value={dndEnd}
137
+ onChange={(e) => setDndEnd(e.target.value)}
138
+ className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-sm"
139
+ />
140
+ </div>
141
+ </div>
142
+ )}
143
+ </div>
144
+
145
+ <div className="flex justify-end">
146
+ <button
147
+ type="button"
148
+ onClick={save}
149
+ className="inline-flex items-center justify-center rounded-md text-sm font-medium h-9 px-4 py-2 bg-primary text-primary-foreground hover:bg-primary/90"
150
+ >
151
+ {t('actions.save', { defaultValue: '保存' })}
152
+ </button>
153
+ </div>
154
+ </div>
155
+ )
156
+ }
@@ -0,0 +1,145 @@
1
+ import { useEffect, useState } from "react"
2
+ import { useMutation, useQueryClient } from "@tanstack/react-query"
3
+ import { useTranslation } from "react-i18next"
4
+ import { toast } from '@/components/ui/sonner'
5
+ import { Input } from "@/components/ui/input"
6
+ import { Label } from "@/components/ui/label"
7
+ import { Button } from "@/components/ui/button"
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from "@/components/ui/dialog"
16
+ import { createCustomProviderFn } from "@/server/serverFns/ai/providers"
17
+
18
+ interface FormState {
19
+ displayName: string
20
+ baseUrl: string
21
+ apiKey: string
22
+ defaultModel: string
23
+ }
24
+
25
+ const EMPTY_FORM: FormState = {
26
+ displayName: "",
27
+ baseUrl: "https://api.openai.com/v1",
28
+ apiKey: "",
29
+ defaultModel: "",
30
+ }
31
+
32
+ export function CreateCustomProviderDialog({
33
+ open,
34
+ onOpenChange,
35
+ }: {
36
+ open: boolean
37
+ onOpenChange: (v: boolean) => void
38
+ }) {
39
+ const [form, setForm] = useState<FormState>(EMPTY_FORM)
40
+ const { t } = useTranslation("settings")
41
+ const queryClient = useQueryClient()
42
+
43
+ useEffect(() => {
44
+ setForm(EMPTY_FORM)
45
+ }, [open])
46
+
47
+ const mutation = useMutation({
48
+ mutationFn: () =>
49
+ createCustomProviderFn({
50
+ data: {
51
+ displayName: form.displayName,
52
+ baseUrl: form.baseUrl,
53
+ apiKey: form.apiKey,
54
+ defaultModel: form.defaultModel,
55
+ },
56
+ }),
57
+ onSuccess: async () => {
58
+ onOpenChange(false)
59
+ await queryClient.invalidateQueries({ queryKey: ["ai-providers"] })
60
+ await queryClient.invalidateQueries({ queryKey: ["ai-enabled-models"] })
61
+ toast.success(t("ai.providerCreated"))
62
+ },
63
+ onError: (err) => {
64
+ toast.error(err instanceof Error ? err.message : t("ai.operationFailed"))
65
+ },
66
+ })
67
+
68
+ const handleSubmit = () => {
69
+ if (!form.displayName.trim()) {
70
+ toast.error(`${t("ai.customProviderName")} 不能为空`)
71
+ return
72
+ }
73
+ if (!form.defaultModel.trim()) {
74
+ toast.error(`${t("ai.customDefaultModel")} 不能为空`)
75
+ return
76
+ }
77
+ mutation.mutate()
78
+ }
79
+
80
+ const update = (field: keyof FormState, value: string) =>
81
+ setForm((prev) => ({ ...prev, [field]: value }))
82
+
83
+ const isValid = form.displayName.trim() && form.defaultModel.trim()
84
+
85
+ return (
86
+ <Dialog open={open} onOpenChange={onOpenChange}>
87
+ <DialogContent className="sm:max-w-md">
88
+ <DialogHeader>
89
+ <DialogTitle>{t("ai.createCustomProvider")}</DialogTitle>
90
+ <DialogDescription>
91
+ {t("ai.createCustomProviderDescription")}
92
+ </DialogDescription>
93
+ </DialogHeader>
94
+
95
+ <div className="grid gap-4">
96
+ <div className="grid gap-1.5">
97
+ <Label>{t("ai.customProviderName")} <span className="text-red-500">*</span></Label>
98
+ <Input
99
+ value={form.displayName}
100
+ onChange={(e) => update("displayName", e.target.value)}
101
+ placeholder={t("ai.customProviderNamePlaceholder")}
102
+ />
103
+ </div>
104
+ <div className="grid gap-1.5">
105
+ <Label>{t("ai.customBaseUrl")}</Label>
106
+ <Input
107
+ value={form.baseUrl}
108
+ onChange={(e) => update("baseUrl", e.target.value)}
109
+ placeholder={t("ai.customBaseUrlPlaceholder")}
110
+ />
111
+ </div>
112
+ <div className="grid gap-1.5">
113
+ <Label>{t("ai.customApiKey")}</Label>
114
+ <Input
115
+ type="password"
116
+ value={form.apiKey}
117
+ onChange={(e) => update("apiKey", e.target.value)}
118
+ placeholder="sk-..."
119
+ />
120
+ </div>
121
+ <div className="grid gap-1.5">
122
+ <Label>{t("ai.customDefaultModel")} <span className="text-red-500">*</span></Label>
123
+ <Input
124
+ value={form.defaultModel}
125
+ onChange={(e) => update("defaultModel", e.target.value)}
126
+ placeholder={t("ai.customDefaultModelPlaceholder")}
127
+ />
128
+ </div>
129
+ </div>
130
+
131
+ <DialogFooter>
132
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
133
+ 取消
134
+ </Button>
135
+ <Button
136
+ onClick={handleSubmit}
137
+ disabled={mutation.isPending || !isValid}
138
+ >
139
+ {mutation.isPending ? t("ai.creatingProvider") : t("ai.createProvider")}
140
+ </Button>
141
+ </DialogFooter>
142
+ </DialogContent>
143
+ </Dialog>
144
+ )
145
+ }