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

This commit is contained in:
TanTan committed 2026-11-13 09:02:00 +08:00
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>
);
}