Skip to content

Commit bc2746c

Browse files
committed
feat(settings): 添加消息通知设置面板
- 新增消息通知设置卡片,支持钉钉、飞书、企业微信、Webhook 四种接收端 - 支持订阅任务创建、完成、失败及开发环境回收/到期等消息类型 - 接收端为空时留空展示,添加按钮暂时禁用 Made-with: Cursor
1 parent 1bea290 commit bc2746c

File tree

2 files changed

+351
-0
lines changed

2 files changed

+351
-0
lines changed
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
import { useState } from "react"
2+
import { Bell, CirclePlus, Link2, MoreVertical } from "lucide-react"
3+
import { MessageCircle, Bot, Send } from "lucide-react"
4+
import {
5+
Card,
6+
CardAction,
7+
CardContent,
8+
CardDescription,
9+
CardHeader,
10+
CardTitle,
11+
} from "@/components/ui/card"
12+
import { Button } from "@/components/ui/button"
13+
import { Input } from "@/components/ui/input"
14+
import { Label } from "@/components/ui/label"
15+
import { Checkbox } from "@/components/ui/checkbox"
16+
import {
17+
Dialog,
18+
DialogContent,
19+
DialogDescription,
20+
DialogFooter,
21+
DialogHeader,
22+
DialogTitle,
23+
} from "@/components/ui/dialog"
24+
import {
25+
Select,
26+
SelectContent,
27+
SelectItem,
28+
SelectTrigger,
29+
SelectValue,
30+
} from "@/components/ui/select"
31+
import {
32+
DropdownMenu,
33+
DropdownMenuContent,
34+
DropdownMenuItem,
35+
DropdownMenuTrigger,
36+
} from "@/components/ui/dropdown-menu"
37+
import {
38+
Item,
39+
ItemActions,
40+
ItemContent,
41+
ItemDescription,
42+
ItemGroup,
43+
ItemMedia,
44+
ItemTitle,
45+
} from "@/components/ui/item"
46+
import {
47+
AlertDialog,
48+
AlertDialogAction,
49+
AlertDialogCancel,
50+
AlertDialogContent,
51+
AlertDialogDescription,
52+
AlertDialogFooter,
53+
AlertDialogHeader,
54+
AlertDialogTitle,
55+
} from "@/components/ui/alert-dialog"
56+
import { IconPencil, IconTrash } from "@tabler/icons-react"
57+
58+
/** 接收端类型 */
59+
export type ReceiverType = "dingtalk" | "feishu" | "wechat_work" | "webhook"
60+
61+
/** 消息订阅类型 */
62+
export type SubscriptionType =
63+
| "task_created" // 任务创建
64+
| "task_completed" // 任务执行完成
65+
| "task_failed" // 任务执行失败
66+
| "vm_recycled" // 开发环境被回收
67+
| "vm_expiring" // 开发环境即将到期
68+
69+
export interface NotificationReceiver {
70+
id: string
71+
type: ReceiverType
72+
name: string
73+
/** 机器人 Webhook URL 或自定义 Webhook 地址 */
74+
webhookUrl: string
75+
/** 订阅的消息类型 */
76+
subscriptions: SubscriptionType[]
77+
}
78+
79+
const RECEIVER_TYPE_OPTIONS: { value: ReceiverType; label: string; icon: React.ReactNode }[] = [
80+
{ value: "dingtalk", label: "钉钉机器人", icon: <Bot className="size-4" /> },
81+
{ value: "feishu", label: "飞书机器人", icon: <MessageCircle className="size-4" /> },
82+
{ value: "wechat_work", label: "企业微信机器人", icon: <Send className="size-4" /> },
83+
{ value: "webhook", label: "Webhook", icon: <Link2 className="size-4" /> },
84+
]
85+
86+
const SUBSCRIPTION_OPTIONS: { value: SubscriptionType; label: string }[] = [
87+
{ value: "task_created", label: "任务创建" },
88+
{ value: "task_completed", label: "任务执行完成" },
89+
{ value: "task_failed", label: "任务执行失败" },
90+
{ value: "vm_recycled", label: "开发环境被回收" },
91+
{ value: "vm_expiring", label: "开发环境即将到期" },
92+
]
93+
94+
function getReceiverTypeLabel(type: ReceiverType): string {
95+
return RECEIVER_TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type
96+
}
97+
98+
function getReceiverTypeIcon(type: ReceiverType): React.ReactNode {
99+
return RECEIVER_TYPE_OPTIONS.find((o) => o.value === type)?.icon ?? <Bot className="size-4" />
100+
}
101+
102+
export default function Notifications() {
103+
const [receivers, setReceivers] = useState<NotificationReceiver[]>([])
104+
const [addDialogOpen, setAddDialogOpen] = useState(false)
105+
const [editingReceiver, setEditingReceiver] = useState<NotificationReceiver | null>(null)
106+
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
107+
const [receiverToDelete, setReceiverToDelete] = useState<NotificationReceiver | null>(null)
108+
109+
const [formType, setFormType] = useState<ReceiverType>("webhook")
110+
const [formName, setFormName] = useState("")
111+
const [formWebhookUrl, setFormWebhookUrl] = useState("")
112+
const [formSubscriptions, setFormSubscriptions] = useState<SubscriptionType[]>([])
113+
114+
const resetForm = () => {
115+
setFormType("webhook")
116+
setFormName("")
117+
setFormWebhookUrl("")
118+
setFormSubscriptions([])
119+
setEditingReceiver(null)
120+
}
121+
122+
const openAddDialog = () => {
123+
resetForm()
124+
setAddDialogOpen(true)
125+
}
126+
127+
const openEditDialog = (receiver: NotificationReceiver) => {
128+
setEditingReceiver(receiver)
129+
setFormType(receiver.type)
130+
setFormName(receiver.name)
131+
setFormWebhookUrl(receiver.webhookUrl)
132+
setFormSubscriptions([...receiver.subscriptions])
133+
setAddDialogOpen(true)
134+
}
135+
136+
const handleSave = () => {
137+
if (!formWebhookUrl.trim()) return
138+
const name = formName.trim() || getReceiverTypeLabel(formType)
139+
140+
if (editingReceiver) {
141+
setReceivers((prev) =>
142+
prev.map((r) =>
143+
r.id === editingReceiver.id
144+
? {
145+
...r,
146+
type: formType,
147+
name,
148+
webhookUrl: formWebhookUrl.trim(),
149+
subscriptions: formSubscriptions,
150+
}
151+
: r
152+
)
153+
)
154+
} else {
155+
setReceivers((prev) => [
156+
...prev,
157+
{
158+
id: crypto.randomUUID(),
159+
type: formType,
160+
name,
161+
webhookUrl: formWebhookUrl.trim(),
162+
subscriptions: formSubscriptions,
163+
},
164+
])
165+
}
166+
setAddDialogOpen(false)
167+
resetForm()
168+
}
169+
170+
const handleDelete = (receiver: NotificationReceiver) => {
171+
setReceiverToDelete(receiver)
172+
setDeleteDialogOpen(true)
173+
}
174+
175+
const confirmDelete = () => {
176+
if (receiverToDelete) {
177+
setReceivers((prev) => prev.filter((r) => r.id !== receiverToDelete.id))
178+
setReceiverToDelete(null)
179+
}
180+
setDeleteDialogOpen(false)
181+
}
182+
183+
const toggleSubscription = (sub: SubscriptionType) => {
184+
setFormSubscriptions((prev) =>
185+
prev.includes(sub) ? prev.filter((s) => s !== sub) : [...prev, sub]
186+
)
187+
}
188+
189+
const listReceivers = () => (
190+
<ItemGroup className="flex flex-col gap-4">
191+
{receivers.map((receiver) => (
192+
<Item key={receiver.id} variant="outline" className="hover:border-primary/50" size="sm">
193+
<ItemMedia className="hidden sm:flex">
194+
<div className="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
195+
{getReceiverTypeIcon(receiver.type)}
196+
</div>
197+
</ItemMedia>
198+
<ItemContent>
199+
<ItemTitle>{receiver.name}</ItemTitle>
200+
<ItemDescription className="break-all">
201+
{getReceiverTypeLabel(receiver.type)} · 订阅{" "}
202+
{receiver.subscriptions.length > 0
203+
? receiver.subscriptions
204+
.map((s) => SUBSCRIPTION_OPTIONS.find((o) => o.value === s)?.label)
205+
.join("、")
206+
: "无"}
207+
</ItemDescription>
208+
</ItemContent>
209+
<ItemActions>
210+
<DropdownMenu>
211+
<DropdownMenuTrigger asChild>
212+
<Button variant="ghost" size="icon-sm">
213+
<MoreVertical className="size-4" />
214+
</Button>
215+
</DropdownMenuTrigger>
216+
<DropdownMenuContent align="end">
217+
<DropdownMenuItem onClick={() => openEditDialog(receiver)}>
218+
<IconPencil />
219+
编辑
220+
</DropdownMenuItem>
221+
<DropdownMenuItem
222+
className="text-destructive"
223+
onClick={() => handleDelete(receiver)}
224+
>
225+
<IconTrash />
226+
移除
227+
</DropdownMenuItem>
228+
</DropdownMenuContent>
229+
</DropdownMenu>
230+
</ItemActions>
231+
</Item>
232+
))}
233+
</ItemGroup>
234+
)
235+
236+
return (
237+
<Card className="w-full shadow-none">
238+
<CardHeader>
239+
<CardTitle className="flex items-center gap-2">
240+
<Bell />
241+
消息通知
242+
</CardTitle>
243+
<CardDescription>
244+
配置任务、系统等消息的接收方式,支持钉钉、飞书、企业微信机器人和 Webhook
245+
</CardDescription>
246+
<CardAction>
247+
<Button variant="outline" size="sm" onClick={openAddDialog} disabled>
248+
<CirclePlus className="size-4" />
249+
添加接收端
250+
</Button>
251+
</CardAction>
252+
</CardHeader>
253+
<CardContent>
254+
{receivers.length > 0 && listReceivers()}
255+
</CardContent>
256+
257+
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
258+
<DialogContent className="sm:max-w-md">
259+
<DialogHeader>
260+
<DialogTitle>{editingReceiver ? "编辑接收端" : "添加接收端"}</DialogTitle>
261+
<DialogDescription>
262+
选择接收端类型并配置 Webhook 地址,订阅需要接收的消息类型
263+
</DialogDescription>
264+
</DialogHeader>
265+
<div className="space-y-4">
266+
<div className="space-y-2">
267+
<Label>接收端类型</Label>
268+
<Select value={formType} onValueChange={(v) => setFormType(v as ReceiverType)}>
269+
<SelectTrigger className="w-full">
270+
<SelectValue />
271+
</SelectTrigger>
272+
<SelectContent>
273+
{RECEIVER_TYPE_OPTIONS.map((opt) => (
274+
<SelectItem key={opt.value} value={opt.value}>
275+
<span className="flex items-center gap-2">
276+
{opt.icon}
277+
{opt.label}
278+
</span>
279+
</SelectItem>
280+
))}
281+
</SelectContent>
282+
</Select>
283+
</div>
284+
<div className="space-y-2">
285+
<Label>名称(可选)</Label>
286+
<Input
287+
placeholder={`如:${getReceiverTypeLabel(formType)}-1`}
288+
value={formName}
289+
onChange={(e) => setFormName(e.target.value)}
290+
/>
291+
</div>
292+
<div className="space-y-2">
293+
<Label>
294+
{formType === "webhook" ? "Webhook 地址" : "机器人 Webhook 地址"}
295+
</Label>
296+
<Input
297+
placeholder="https://..."
298+
value={formWebhookUrl}
299+
onChange={(e) => setFormWebhookUrl(e.target.value)}
300+
/>
301+
</div>
302+
<div className="space-y-2">
303+
<Label>订阅消息</Label>
304+
<div className="flex flex-col gap-2 rounded-lg border p-3">
305+
{SUBSCRIPTION_OPTIONS.map((opt) => (
306+
<label
307+
key={opt.value}
308+
className="flex cursor-pointer items-center gap-2 text-sm"
309+
>
310+
<Checkbox
311+
checked={formSubscriptions.includes(opt.value)}
312+
onCheckedChange={() => toggleSubscription(opt.value)}
313+
/>
314+
{opt.label}
315+
</label>
316+
))}
317+
</div>
318+
</div>
319+
</div>
320+
<DialogFooter>
321+
<Button variant="outline" onClick={() => setAddDialogOpen(false)}>
322+
取消
323+
</Button>
324+
<Button onClick={handleSave} disabled={!formWebhookUrl.trim()}>
325+
{editingReceiver ? "保存" : "添加"}
326+
</Button>
327+
</DialogFooter>
328+
</DialogContent>
329+
</Dialog>
330+
331+
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
332+
<AlertDialogContent>
333+
<AlertDialogHeader>
334+
<AlertDialogTitle>确认移除</AlertDialogTitle>
335+
<AlertDialogDescription>
336+
确定要移除接收端「{receiverToDelete?.name}」吗?此操作不可撤销。
337+
</AlertDialogDescription>
338+
</AlertDialogHeader>
339+
<AlertDialogFooter>
340+
<AlertDialogCancel onClick={() => setReceiverToDelete(null)}>
341+
取消
342+
</AlertDialogCancel>
343+
<AlertDialogAction onClick={confirmDelete}>确认移除</AlertDialogAction>
344+
</AlertDialogFooter>
345+
</AlertDialogContent>
346+
</AlertDialog>
347+
</Card>
348+
)
349+
}

frontend/src/pages/console/user/settings.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Models from "@/components/console/settings/models"
33
import Hosts from "@/components/console/settings/hosts"
44
import Identities from "@/components/console/settings/identities"
55
import VmsPage from "@/components/console/settings/vms"
6+
import Notifications from "@/components/console/settings/notifications"
67
import { useGitHubSetupCallback } from "@/hooks/useGitHubSetupCallback"
78
import { useCommonData } from "@/components/console/data-provider"
89
import {
@@ -29,6 +30,7 @@ export default function SettingsPage() {
2930
<Images />
3031
<Hosts />
3132
<VmsPage />
33+
<Notifications />
3234

3335
<AlertDialog open={result !== null} onOpenChange={(open) => { if (!open) dismiss() }}>
3436
<AlertDialogContent>

0 commit comments

Comments
 (0)