SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,550 @@
|
||||
// Modal editor for a notification channel — used for both create and edit,
|
||||
// mirroring NodeEditDialog. Holds a local draft until 保存 (cancel discards).
|
||||
// The channel id is the stable key per-account opt-ins reference, so it's
|
||||
// editable only on create and locked on edit.
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DEFAULT_WEBHOOK_BODY,
|
||||
DEFAULT_EMAIL_BODY,
|
||||
DEFAULT_EMAIL_SUBJECT,
|
||||
} from '@snowluma/common/notification-templates';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Modal } from '@/components/interior/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { PasswordVisibilityIcon } from '@/components/ui/password-visibility-icon';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { ToggleSwitch } from '@/components/ui/toggle-switch';
|
||||
import { useTheme } from '@/contexts/ThemeContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
notificationChannelType,
|
||||
type NotificationChannel,
|
||||
type NotificationChannelType,
|
||||
} from '@/types';
|
||||
|
||||
interface HeaderRow {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
function headersToRows(headers?: Record<string, string>): HeaderRow[] {
|
||||
const rows = Object.entries(headers ?? {}).map(([name, value]) => ({ name, value }));
|
||||
return rows.length > 0 ? rows : [{ name: '', value: '' }];
|
||||
}
|
||||
|
||||
function rowsToHeaders(rows: HeaderRow[]): Record<string, string> | undefined {
|
||||
const out: Record<string, string> = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const name = row.name.trim();
|
||||
const value = row.value.trim();
|
||||
|
||||
if (!name || !value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
out[name] = value;
|
||||
}
|
||||
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
const CHANNEL_ID_RE = /^[\w.-]+$/;
|
||||
|
||||
function isHttpUrl(u: string): boolean {
|
||||
try {
|
||||
const x = new URL(u.trim());
|
||||
return x.protocol === 'http:' || x.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeEmail(value: string): boolean {
|
||||
return /^[^\s<>@]+@[^\s<>@]+$/.test(value.trim()) || /<[^<>@\s]+@[^<>@\s]+>/.test(value);
|
||||
}
|
||||
|
||||
function hasRecipient(value: string): boolean {
|
||||
return value.split(/[,;\n]+/).some((part) => looksLikeEmail(part));
|
||||
}
|
||||
|
||||
export function applyType(
|
||||
draft: NotificationChannel,
|
||||
type: NotificationChannelType,
|
||||
): NotificationChannel {
|
||||
if (type === notificationChannelType(draft)) {
|
||||
return { ...draft, type };
|
||||
}
|
||||
|
||||
if (type === 'email') {
|
||||
const keepBody = draft.bodyTemplate && draft.bodyTemplate !== DEFAULT_WEBHOOK_BODY;
|
||||
return {
|
||||
...draft,
|
||||
type,
|
||||
url: '',
|
||||
headers: undefined,
|
||||
smtpHost: draft.smtpHost ?? '',
|
||||
smtpPort: draft.smtpPort ?? 465,
|
||||
smtpSecure: draft.smtpSecure ?? true,
|
||||
smtpUser: draft.smtpUser ?? '',
|
||||
smtpPass: draft.smtpPass ?? '',
|
||||
from: draft.from ?? '',
|
||||
to: draft.to ?? '',
|
||||
subjectTemplate: draft.subjectTemplate || DEFAULT_EMAIL_SUBJECT,
|
||||
bodyTemplate: keepBody ? draft.bodyTemplate : DEFAULT_EMAIL_BODY,
|
||||
};
|
||||
}
|
||||
|
||||
const keepBody = draft.bodyTemplate && draft.bodyTemplate !== DEFAULT_EMAIL_BODY;
|
||||
return {
|
||||
id: draft.id,
|
||||
name: draft.name,
|
||||
type: 'webhook',
|
||||
url: draft.url,
|
||||
bodyTemplate: keepBody ? draft.bodyTemplate : DEFAULT_WEBHOOK_BODY,
|
||||
enabled: draft.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function toChannel(
|
||||
draft: NotificationChannel,
|
||||
headerRows: HeaderRow[],
|
||||
): NotificationChannel {
|
||||
const id = draft.id.trim();
|
||||
const name = draft.name.trim();
|
||||
const enabled = draft.enabled;
|
||||
|
||||
if (notificationChannelType(draft) === 'email') {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: 'email',
|
||||
url: '',
|
||||
bodyTemplate: draft.bodyTemplate,
|
||||
enabled,
|
||||
smtpHost: draft.smtpHost?.trim() ?? '',
|
||||
smtpPort: draft.smtpPort ?? 465,
|
||||
smtpSecure: draft.smtpSecure ?? true,
|
||||
smtpUser: draft.smtpUser?.trim() || undefined,
|
||||
smtpPass: draft.smtpPass || undefined,
|
||||
from: draft.from?.trim() ?? '',
|
||||
to: draft.to?.trim() ?? '',
|
||||
subjectTemplate: draft.subjectTemplate?.trim() || DEFAULT_EMAIL_SUBJECT,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type: 'webhook',
|
||||
url: draft.url.trim(),
|
||||
bodyTemplate: draft.bodyTemplate,
|
||||
enabled,
|
||||
headers: rowsToHeaders(headerRows),
|
||||
};
|
||||
}
|
||||
|
||||
interface NotificationChannelDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isEdit: boolean;
|
||||
/** Seed draft — a blank channel for create, the existing one for edit. */
|
||||
initial: NotificationChannel;
|
||||
/** Ids of every other channel, for the duplicate check. */
|
||||
otherIds: string[];
|
||||
onSubmit: (channel: NotificationChannel) => void;
|
||||
}
|
||||
|
||||
export function NotificationChannelDialog(props: NotificationChannelDialogProps) {
|
||||
const { open, onOpenChange, isEdit, initial, otherIds, onSubmit } = props;
|
||||
// Parent unmounts on close, so each open re-seeds via lazy init.
|
||||
const [draft, setDraft] = useState<NotificationChannel>(() => ({
|
||||
...initial,
|
||||
type: notificationChannelType(initial),
|
||||
}));
|
||||
const [headerRows, setHeaderRows] = useState<HeaderRow[]>(() => headersToRows(initial.headers));
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const reduce = useTheme().appearance.disableMotion;
|
||||
const patch = (p: Partial<NotificationChannel>) => setDraft({ ...draft, ...p });
|
||||
|
||||
const patchHeader = (index: number, p: Partial<HeaderRow>) => {
|
||||
setHeaderRows(headerRows.map((row, i) => (i === index ? { ...row, ...p } : row)));
|
||||
};
|
||||
|
||||
const type = notificationChannelType(draft);
|
||||
const id = draft.id.trim();
|
||||
const idBlank = id.length === 0;
|
||||
const idBad = !idBlank && (!CHANNEL_ID_RE.test(id) || id.length > 64);
|
||||
const idDup = !idBlank && otherIds.includes(id);
|
||||
const urlBlank = draft.url.trim().length === 0;
|
||||
const urlBad = !urlBlank && !isHttpUrl(draft.url);
|
||||
const hostBlank = !(draft.smtpHost ?? '').trim();
|
||||
const fromBlank = !(draft.from ?? '').trim();
|
||||
const fromBad = !fromBlank && !looksLikeEmail(draft.from ?? '');
|
||||
const toBlank = !(draft.to ?? '').trim();
|
||||
const toBad = !toBlank && !hasRecipient(draft.to ?? '');
|
||||
const port = draft.smtpPort ?? 465;
|
||||
const portBad = !Number.isInteger(port) || port < 1 || port > 65535;
|
||||
const canSave =
|
||||
type === 'email'
|
||||
? !idBlank &&
|
||||
!idBad &&
|
||||
!idDup &&
|
||||
!hostBlank &&
|
||||
!fromBlank &&
|
||||
!fromBad &&
|
||||
!toBlank &&
|
||||
!toBad &&
|
||||
!portBad
|
||||
: !idBlank && !idBad && !idDup && !urlBlank && !urlBad;
|
||||
|
||||
const idError = idBlank
|
||||
? '请填写渠道 ID'
|
||||
: idBad
|
||||
? '只能用字母 / 数字 / . _ - ,≤64 字符'
|
||||
: idDup
|
||||
? 'ID 与其它渠道重复'
|
||||
: undefined;
|
||||
const urlError = urlBlank ? '请填写 Webhook URL' : urlBad ? '必须是 http(s) 地址' : undefined;
|
||||
const hostError = hostBlank ? '请填写 SMTP 主机' : undefined;
|
||||
const fromError = fromBlank ? '请填写发件人' : fromBad ? '必须是邮箱地址' : undefined;
|
||||
const toError = toBlank ? '请填写收件人' : toBad ? '至少填写一个邮箱' : undefined;
|
||||
const portError = portBad ? '端口 1–65535' : undefined;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
title={isEdit ? '编辑渠道' : '新建渠道'}
|
||||
closeLabel="关闭渠道编辑弹窗"
|
||||
maxWidth={576}
|
||||
maxHeight="min(86dvh, 780px)"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!canSave}
|
||||
onClick={() => {
|
||||
onSubmit(toChannel(draft, headerRows));
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
{isEdit ? '保存修改' : '创建渠道'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-1 rounded-lg border bg-muted/40 p-1">
|
||||
{(
|
||||
[
|
||||
['webhook', 'Webhook'],
|
||||
['email', '邮件'],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={type === value}
|
||||
onClick={() => setDraft(applyType(draft, value))}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||
type === value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field
|
||||
label="渠道 ID"
|
||||
placeholder="dingtalk"
|
||||
value={draft.id}
|
||||
disabled={isEdit}
|
||||
hint={isEdit ? '创建后不可更改' : '账号据此勾选;字母/数字/. _ -'}
|
||||
error={idError}
|
||||
onChange={(v) => patch({ id: v })}
|
||||
/>
|
||||
<Field
|
||||
label="显示名"
|
||||
placeholder={type === 'email' ? '运维邮箱' : '钉钉群机器人'}
|
||||
value={draft.name}
|
||||
onChange={(v) => patch({ name: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{type === 'webhook' ? (
|
||||
<>
|
||||
<Field
|
||||
label="Webhook URL"
|
||||
type="url"
|
||||
placeholder="https://oapi.dingtalk.com/robot/send?access_token=…"
|
||||
value={draft.url}
|
||||
error={urlError}
|
||||
onChange={(v) => patch({ url: v })}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label>请求头</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setHeaderRows([...headerRows, { name: '', value: '' }])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{headerRows.map((row, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={row.name}
|
||||
placeholder="Authorization"
|
||||
spellCheck={false}
|
||||
onChange={(e) =>
|
||||
patchHeader(i, { name: e.target.value })
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
placeholder="Bearer …"
|
||||
spellCheck={false}
|
||||
onChange={(e) =>
|
||||
patchHeader(i, { value: e.target.value })
|
||||
}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="删除请求头"
|
||||
onClick={() => {
|
||||
const next = headerRows.filter(
|
||||
(_, idx) => idx !== i,
|
||||
);
|
||||
setHeaderRows(
|
||||
next.length > 0
|
||||
? next
|
||||
: [{ name: '', value: '' }],
|
||||
);
|
||||
}}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
可选,默认 Content-Type 为 application/json。
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_7rem]">
|
||||
<Field
|
||||
label="SMTP 主机"
|
||||
placeholder="smtp.qq.com"
|
||||
value={draft.smtpHost ?? ''}
|
||||
error={hostError}
|
||||
onChange={(v) => patch({ smtpHost: v })}
|
||||
/>
|
||||
<Field
|
||||
label="端口"
|
||||
type="number"
|
||||
placeholder="465"
|
||||
value={String(draft.smtpPort ?? 465)}
|
||||
error={portError}
|
||||
onChange={(v) => {
|
||||
const n = Math.trunc(Number(v));
|
||||
|
||||
if (!Number.isFinite(n)) {
|
||||
patch({ smtpPort: 465 });
|
||||
return;
|
||||
}
|
||||
|
||||
patch({
|
||||
smtpPort: n,
|
||||
smtpSecure:
|
||||
n === 465 ? true : n === 587 ? false : draft.smtpSecure,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field
|
||||
label="SMTP 用户名"
|
||||
placeholder="bot@qq.com"
|
||||
value={draft.smtpUser ?? ''}
|
||||
hint="部分内网中继可不填"
|
||||
onChange={(v) => patch({ smtpUser: v })}
|
||||
/>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>SMTP 密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPass ? 'text' : 'password'}
|
||||
value={draft.smtpPass ?? ''}
|
||||
autoComplete="new-password"
|
||||
spellCheck={false}
|
||||
placeholder="授权码 / 密码"
|
||||
onChange={(e) => patch({ smtpPass: e.target.value })}
|
||||
className="pr-11 font-mono text-xs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass((v) => !v)}
|
||||
className="absolute right-1.5 top-1/2 flex size-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
tabIndex={-1}
|
||||
aria-label={showPass ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
<PasswordVisibilityIcon
|
||||
visible={showPass}
|
||||
reduceMotion={reduce}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-card/40 p-3">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm">隐式 TLS</Label>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
465 开启,587 关闭(STARTTLS)。
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={draft.smtpSecure ?? true}
|
||||
onChange={(v) => patch({ smtpSecure: v })}
|
||||
ariaLabel="隐式 TLS"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field
|
||||
label="发件人"
|
||||
placeholder="SnowLuma <bot@qq.com>"
|
||||
value={draft.from ?? ''}
|
||||
error={fromError}
|
||||
onChange={(v) => patch({ from: v })}
|
||||
/>
|
||||
<Field
|
||||
label="收件人"
|
||||
placeholder="ops@example.com, a@b.com"
|
||||
value={draft.to ?? ''}
|
||||
error={toError}
|
||||
hint="多个地址用逗号或分号分隔"
|
||||
onChange={(v) => patch({ to: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Field
|
||||
label="主题模板"
|
||||
placeholder={DEFAULT_EMAIL_SUBJECT}
|
||||
value={draft.subjectTemplate ?? DEFAULT_EMAIL_SUBJECT}
|
||||
onChange={(v) => patch({ subjectTemplate: v })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{type === 'email' ? '正文模板' : 'Body 模板'}</Label>
|
||||
<Textarea
|
||||
value={draft.bodyTemplate}
|
||||
rows={type === 'email' ? 6 : 3}
|
||||
spellCheck={false}
|
||||
onChange={(e) => patch({ bodyTemplate: e.target.value })}
|
||||
className="font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||
变量:<code className="font-mono">{'{uin}'}</code>{' '}
|
||||
<code className="font-mono">{'{nickname}'}</code>{' '}
|
||||
<code className="font-mono">{'{event}'}</code>(offline/online){' '}
|
||||
<code className="font-mono">{'{time}'}</code>{' '}
|
||||
<code className="font-mono">{'{status}'}</code>(Hook 状态){' '}
|
||||
<code className="font-mono">{'{pid}'}</code>(进程 ID){' '}
|
||||
<code className="font-mono">{'{error}'}</code>(进程错误)
|
||||
。进程信息为空表示未知。
|
||||
{type === 'email' ? '正文以 < 开头时按 HTML 发送。' : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-card/40 p-3">
|
||||
<div className="min-w-0">
|
||||
<Label className="text-sm">启用</Label>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
关闭后停止推送,保留配置。
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
value={draft.enabled}
|
||||
onChange={(v) => patch({ enabled: v })}
|
||||
ariaLabel="启用渠道"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
type?: 'text' | 'url' | 'number';
|
||||
error?: string;
|
||||
hint?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
error,
|
||||
hint,
|
||||
disabled,
|
||||
}: FieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={cn(
|
||||
error && 'border-destructive focus-visible:ring-destructive/40',
|
||||
disabled && 'opacity-60',
|
||||
)}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
) : hint ? (
|
||||
<p className="text-xs text-muted-foreground">{hint}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user