子仓库提交
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
||||
export function MessageInstructions() {
|
||||
return (
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<h4 className="text-blue-900 mb-2">消息发送说明</h4>
|
||||
<ul className="space-y-1 text-sm text-blue-800">
|
||||
<li>• 支持发送短信、邮件、站内信、推送四种类型的消息</li>
|
||||
<li>• 实时发送:消息立即发送给接收人</li>
|
||||
<li>• 定时发送:设定未来的日期和时间,系统到时自动发送</li>
|
||||
<li>• 可以使用消息模版,自动填充变量生成个性化内容</li>
|
||||
<li>• 支持批量发送,一次可向多个接收人发送相同消息</li>
|
||||
<li>• 定时消息在未发送前可以取消,已发送的消息可以删除记录</li>
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { MessageSendRecord } from '@/types/message';
|
||||
|
||||
interface MessagePreviewDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
record: MessageSendRecord | null;
|
||||
getTypeIcon: (type: string) => JSX.Element;
|
||||
getTypeLabel: (type: string) => string;
|
||||
getTypeBadge: (type: string) => string;
|
||||
getStatusBadge: (status: string) => JSX.Element;
|
||||
}
|
||||
|
||||
export function MessagePreviewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
record,
|
||||
getTypeIcon,
|
||||
getTypeLabel,
|
||||
getTypeBadge,
|
||||
getStatusBadge
|
||||
}: MessagePreviewDialogProps) {
|
||||
if (!record) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>消息详情</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
查看消息发送详情
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>消息模版</Label>
|
||||
<div className="field-value-inline">{record.templateName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>消息类型</Label>
|
||||
<div className="mt-2">
|
||||
<Badge className={getTypeBadge(record.type)}>
|
||||
<span className="flex items-center gap-1">
|
||||
{getTypeIcon(record.type)}
|
||||
{getTypeLabel(record.type)}
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>发送方式</Label>
|
||||
<div className="field-value-inline">
|
||||
{record.sendType === 'immediate' ? '实时发送' : '定时发送'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>发送状态</Label>
|
||||
<div className="mt-2">
|
||||
{getStatusBadge(record.status)}
|
||||
</div>
|
||||
</div>
|
||||
{record.scheduledTime && (
|
||||
<div>
|
||||
<Label>定时发送时间</Label>
|
||||
<div className="field-value-inline">
|
||||
{format(new Date(record.scheduledTime), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label>创建时间</Label>
|
||||
<div className="field-value-inline">
|
||||
{format(new Date(record.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{record.subject && (
|
||||
<div>
|
||||
<Label>消息主题</Label>
|
||||
<div className="field-value-inline">{record.subject}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>接收人列表(共 {record.recipientCount} 人)</Label>
|
||||
<Card className="p-3 bg-gray-50 mt-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{record.recipients.map((recipient, index) => (
|
||||
<Badge key={index} variant="outline">
|
||||
{recipient}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>消息内容</Label>
|
||||
<Card className="p-4 bg-blue-50 border-blue-200 mt-2">
|
||||
<pre className="text-sm whitespace-pre-wrap">
|
||||
{record.content}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import {
|
||||
Send,
|
||||
Clock,
|
||||
Users,
|
||||
Eye,
|
||||
Trash2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Timer
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { MessageSendRecord } from '@/types/message';
|
||||
|
||||
interface MessageSendTableProps {
|
||||
sendRecords: MessageSendRecord[];
|
||||
onPreview: (record: MessageSendRecord) => void;
|
||||
onCancel: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
getTypeIcon: (type: string) => JSX.Element;
|
||||
getTypeLabel: (type: string) => string;
|
||||
getTypeBadge: (type: string) => string;
|
||||
getStatusBadge: (status: string) => JSX.Element;
|
||||
}
|
||||
|
||||
export function MessageSendTable({
|
||||
sendRecords,
|
||||
onPreview,
|
||||
onCancel,
|
||||
onDelete,
|
||||
getTypeIcon,
|
||||
getTypeLabel,
|
||||
getTypeBadge,
|
||||
getStatusBadge
|
||||
}: MessageSendTableProps) {
|
||||
return (
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>消息模版</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>接收人数</TableHead>
|
||||
<TableHead>发送方式</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sendRecords.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
|
||||
暂无发送记录
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
sendRecords.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell>
|
||||
<div>{record.templateName}</div>
|
||||
{record.subject && (
|
||||
<p className="text-xs text-muted-foreground">{record.subject}</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getTypeBadge(record.type)}>
|
||||
<span className="flex items-center gap-1">
|
||||
{getTypeIcon(record.type)}
|
||||
{getTypeLabel(record.type)}
|
||||
</span>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="w-4 h-4 text-muted-foreground" />
|
||||
<span>{record.recipientCount}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{record.sendType === 'immediate' ? (
|
||||
<Badge variant="outline">
|
||||
<Send className="w-3 h-3 mr-1" />
|
||||
实时发送
|
||||
</Badge>
|
||||
) : (
|
||||
<div>
|
||||
<Badge variant="outline">
|
||||
<Clock className="w-3 h-3 mr-1" />
|
||||
定时发送
|
||||
</Badge>
|
||||
{record.scheduledTime && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{format(new Date(record.scheduledTime), 'MM-dd HH:mm', { locale: zhCN })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(record.status)}
|
||||
{record.status === 'sent' && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
成功 {record.sentCount}/{record.recipientCount}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{format(new Date(record.createdAt), 'MM-dd HH:mm', { locale: zhCN })}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onPreview(record)}
|
||||
title="查看详情"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
{record.status === 'pending' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onCancel(record.id)}
|
||||
title="取消发送"
|
||||
>
|
||||
<XCircle className="w-4 h-4 text-orange-600" />
|
||||
</Button>
|
||||
)}
|
||||
{(record.status === 'sent' || record.status === 'cancelled') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(record.id)}
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { MessageSendRecord } from '@/types/message';
|
||||
|
||||
interface MessageStatsCardsProps {
|
||||
sendRecords: MessageSendRecord[];
|
||||
}
|
||||
|
||||
export function MessageStatsCards({ sendRecords }: MessageStatsCardsProps) {
|
||||
const stats = [
|
||||
{
|
||||
label: '总发送数',
|
||||
value: sendRecords.length,
|
||||
color: 'text-blue-600',
|
||||
},
|
||||
{
|
||||
label: '已发送',
|
||||
value: sendRecords.filter(r => r.status === 'sent').length,
|
||||
color: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '待发送',
|
||||
value: sendRecords.filter(r => r.status === 'pending').length,
|
||||
color: 'text-yellow-600',
|
||||
},
|
||||
{
|
||||
label: '已取消',
|
||||
value: sendRecords.filter(r => r.status === 'cancelled').length,
|
||||
color: 'text-gray-600',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{stats.map((stat, index) => (
|
||||
<Card key={index} className="p-4">
|
||||
<div className="text-sm text-muted-foreground">{stat.label}</div>
|
||||
<div className={`mt-2 text-2xl font-bold ${stat.color}`}>{stat.value}</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Send, Clock, CalendarIcon } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { MessageTemplate } from '@/types/message';
|
||||
import { MessageSendFormData } from '../types';
|
||||
|
||||
interface SendMessageDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
templates: MessageTemplate[];
|
||||
formData: MessageSendFormData;
|
||||
onFormDataChange: (data: MessageSendFormData) => void;
|
||||
onSend: () => void;
|
||||
getTypeIcon: (type: string) => JSX.Element;
|
||||
getTypeLabel: (type: string) => string;
|
||||
}
|
||||
|
||||
export function SendMessageDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
templates,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
onSend,
|
||||
getTypeIcon,
|
||||
getTypeLabel
|
||||
}: SendMessageDialogProps) {
|
||||
const replaceVariables = (content: string, variables: Record<string, string>): string => {
|
||||
let result = content;
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value || `{{${key}}}`);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleTemplateChange = (templateId: string) => {
|
||||
const template = templates.find(t => t.id === templateId);
|
||||
if (template) {
|
||||
// 初始化变量
|
||||
const vars: Record<string, string> = {};
|
||||
template.variables.forEach(v => {
|
||||
vars[v] = '';
|
||||
});
|
||||
|
||||
onFormDataChange({
|
||||
...formData,
|
||||
templateId,
|
||||
type: template.type,
|
||||
subject: template.subject || '',
|
||||
content: template.content,
|
||||
variables: vars,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTemplate = templates.find(t => t.id === formData.templateId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="w-5 h-5 text-green-600" />
|
||||
发送消息
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
选择消息模版并发送消息
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* 选择模版 */}
|
||||
<div>
|
||||
<Label>选择消息模版 *</Label>
|
||||
<Select value={formData.templateId} onValueChange={handleTemplateChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择消息模版" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templates.filter(t => t.isActive).map(template => (
|
||||
<SelectItem key={template.id} value={template.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeIcon(template.type)}
|
||||
<span>{template.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getTypeLabel(template.type)}
|
||||
</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 发送方式 */}
|
||||
<div>
|
||||
<Label>发送方式 *</Label>
|
||||
<Select
|
||||
value={formData.sendType}
|
||||
onValueChange={(value: 'immediate' | 'scheduled') => onFormDataChange({ ...formData, sendType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="immediate">
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="w-4 h-4" />
|
||||
实时发送
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="scheduled">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
定时发送
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 定时发送设置 */}
|
||||
{formData.sendType === 'scheduled' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>发送日期 *</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-start">
|
||||
<CalendarIcon className="w-4 h-4 mr-2" />
|
||||
{formData.scheduledDate ? (
|
||||
format(formData.scheduledDate, 'yyyy年MM月dd日', { locale: zhCN })
|
||||
) : (
|
||||
'选择日期'
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={formData.scheduledDate}
|
||||
onSelect={(date) => onFormDataChange({ ...formData, scheduledDate: date })}
|
||||
locale={zhCN}
|
||||
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<Label>发送时间 *</Label>
|
||||
<Input
|
||||
type="time"
|
||||
value={formData.scheduledTime}
|
||||
onChange={(e) => onFormDataChange({ ...formData, scheduledTime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 接收人 */}
|
||||
<div>
|
||||
<Label>接收人 *</Label>
|
||||
<Textarea
|
||||
value={formData.recipients}
|
||||
onChange={(e) => onFormDataChange({ ...formData, recipients: e.target.value })}
|
||||
placeholder={
|
||||
formData.type === 'sms' ? '输入手机号,多个用逗号或换行分隔' :
|
||||
formData.type === 'email' ? '输入邮箱地址,多个用逗号或换行分隔' :
|
||||
formData.type === 'push' ? '输入设备ID或用户ID,多个用逗号或换行分隔' :
|
||||
'输入用户名,多个用逗号或换行分隔'
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
支持多个接收人,使用逗号、分号或换行分隔
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 消息主题(邮件和推送) */}
|
||||
{(formData.type === 'email' || formData.type === 'push') && (
|
||||
<div>
|
||||
<Label>消息主题</Label>
|
||||
<Input
|
||||
value={formData.subject}
|
||||
onChange={(e) => onFormDataChange({ ...formData, subject: e.target.value })}
|
||||
placeholder="输入消息主题"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变量填写 */}
|
||||
{selectedTemplate && selectedTemplate.variables.length > 0 && (
|
||||
<div>
|
||||
<Label>填写变量 *</Label>
|
||||
<Card className="p-4 bg-gray-50">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{selectedTemplate.variables.map(variable => (
|
||||
<div key={variable}>
|
||||
<Label htmlFor={`var-${variable}`} className="text-xs">
|
||||
{variable}
|
||||
</Label>
|
||||
<Input
|
||||
id={`var-${variable}`}
|
||||
value={formData.variables[variable] || ''}
|
||||
onChange={(e) => onFormDataChange({
|
||||
...formData,
|
||||
variables: {
|
||||
...formData.variables,
|
||||
[variable]: e.target.value,
|
||||
},
|
||||
})}
|
||||
placeholder={`输入 ${variable}`}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 消息内容预览 */}
|
||||
{formData.content && (
|
||||
<div>
|
||||
<Label>消息内容预览</Label>
|
||||
<Card className="p-4 bg-blue-50 border-blue-200">
|
||||
<pre className="text-sm whitespace-pre-wrap">
|
||||
{replaceVariables(formData.content, formData.variables)}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={onSend} className="bg-green-600 hover:bg-green-700">
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{formData.sendType === 'immediate' ? '立即发送' : '创建定时任务'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { MessageStatsCards } from './MessageStatsCards';
|
||||
export { MessageSendTable } from './MessageSendTable';
|
||||
export { SendMessageDialog } from './SendMessageDialog';
|
||||
export { MessagePreviewDialog } from './MessagePreviewDialog';
|
||||
export { MessageInstructions } from './MessageInstructions';
|
||||
493
crop-x-new/src/app/(app)/central-config/message/send/page.tsx
Normal file
493
crop-x-new/src/app/(app)/central-config/message/send/page.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Send, Mail, MessageSquare, Bell, Smartphone, CheckCircle2, XCircle, Timer } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { MessageTemplate, MessageSendRecord } from '@/types/message';
|
||||
import { MessageSendFormData } from './types';
|
||||
import {
|
||||
MessageStatsCards,
|
||||
MessageSendTable,
|
||||
SendMessageDialog,
|
||||
MessagePreviewDialog,
|
||||
MessageInstructions
|
||||
} from './components';
|
||||
|
||||
// API服务函数
|
||||
const messageApi = {
|
||||
// 获取消息模板
|
||||
getTemplates: async (): Promise<MessageTemplate[]> => {
|
||||
try {
|
||||
const response = await fetch('/api/message/templates');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch message templates');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch message templates, using mock data:', error);
|
||||
return getMockTemplates();
|
||||
}
|
||||
},
|
||||
|
||||
// 获取发送记录
|
||||
getSendRecords: async (): Promise<MessageSendRecord[]> => {
|
||||
try {
|
||||
const response = await fetch('/api/message/send-records');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch send records');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch send records, using mock data:', error);
|
||||
return getMockSendRecords();
|
||||
}
|
||||
},
|
||||
|
||||
// 发送消息
|
||||
sendMessage: async (data: MessageSendFormData): Promise<MessageSendRecord> => {
|
||||
try {
|
||||
const response = await fetch('/api/message/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send message');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn('Failed to send message, using mock response:', error);
|
||||
return createMockSendRecord(data);
|
||||
}
|
||||
},
|
||||
|
||||
// 取消定时消息
|
||||
cancelMessage: async (id: string): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(`/api/message/send/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to cancel message');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to cancel message, updating local state:', error);
|
||||
// 模拟取消操作
|
||||
}
|
||||
},
|
||||
|
||||
// 删除发送记录
|
||||
deleteMessage: async (id: string): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(`/api/message/send/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete message');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to delete message, updating local state:', error);
|
||||
// 模拟删除操作
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 模拟数据生成函数
|
||||
function getMockTemplates(): MessageTemplate[] {
|
||||
return [
|
||||
{
|
||||
id: 'tpl-1',
|
||||
code: 'TASK_ASSIGNMENT',
|
||||
name: '任务分配通知',
|
||||
type: 'internal',
|
||||
subject: '新任务分配',
|
||||
content: '您好,{{username}}!您有新的作业任务:{{taskName}},计划执行时间:{{executeTime}}。请及时查看并准备。',
|
||||
variables: ['username', 'taskName', 'executeTime'],
|
||||
isActive: true,
|
||||
description: '向农机操作员分配新任务时发送',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'tpl-2',
|
||||
code: 'EQUIPMENT_WARNING',
|
||||
name: '设备预警通知',
|
||||
type: 'sms',
|
||||
content: '【智慧农业】设备预警:{{equipmentName}}检测到{{warningType}},请及时处理。',
|
||||
variables: ['equipmentName', 'warningType'],
|
||||
isActive: true,
|
||||
description: '设备出现异常时发送预警',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'tpl-3',
|
||||
code: 'MAINTENANCE_REMINDER',
|
||||
name: '保养提醒',
|
||||
type: 'email',
|
||||
subject: '设备保养提醒',
|
||||
content: '尊敬的用户:\n\n您的设备{{equipmentName}}(编号:{{equipmentNo}})已使用{{usageHours}}小时,建议进行保养维护。\n\n保养周期:每{{maintenanceInterval}}小时\n上次保养时间:{{lastMaintenanceTime}}\n\n请及时安排保养,确保设备正常运行。',
|
||||
variables: ['equipmentName', 'equipmentNo', 'usageHours', 'maintenanceInterval', 'lastMaintenanceTime'],
|
||||
isActive: true,
|
||||
description: '设备需要保养时发送提醒',
|
||||
createdAt: '2024-01-01T00:00:00',
|
||||
updatedAt: '2024-01-01T00:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function getMockSendRecords(): MessageSendRecord[] {
|
||||
return [
|
||||
{
|
||||
id: 'send-1',
|
||||
templateId: 'tpl-1',
|
||||
templateName: '任务分配通知',
|
||||
type: 'internal',
|
||||
recipients: ['张三', '李四', '王五'],
|
||||
recipientCount: 3,
|
||||
subject: '新任务分配',
|
||||
content: '您好,张三!您有新的作业任务:冬小麦播种,计划执行时间:2024-10-16 08:00。请及时查看并准备。',
|
||||
sendType: 'immediate',
|
||||
status: 'sent',
|
||||
sentCount: 3,
|
||||
sentAt: '2024-10-15T14:30:00',
|
||||
createdAt: '2024-10-15T14:30:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'send-2',
|
||||
templateId: 'tpl-2',
|
||||
templateName: '设备预警通知',
|
||||
type: 'sms',
|
||||
recipients: ['13800138001', '13900139002'],
|
||||
recipientCount: 2,
|
||||
content: '【智慧农业】设备预警:拖拉机01检测到异常,油温过高,请及时处理。',
|
||||
sendType: 'immediate',
|
||||
status: 'sent',
|
||||
sentCount: 2,
|
||||
sentAt: '2024-10-15T10:15:00',
|
||||
createdAt: '2024-10-15T10:15:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
{
|
||||
id: 'send-3',
|
||||
templateId: 'tpl-3',
|
||||
templateName: '保养提醒',
|
||||
type: 'email',
|
||||
recipients: ['zhangsan@example.com', 'lisi@example.com'],
|
||||
recipientCount: 2,
|
||||
subject: '设备保养提醒',
|
||||
content: '尊敬的用户:\n\n您的设备拖拉机01(编号:TR001)已使用500小时,建议进行保养维护。\n\n保养周期:每500小时\n上次保养时间:2024-09-01\n\n请及时安排保养,确保设备正常运行。',
|
||||
sendType: 'scheduled',
|
||||
scheduledTime: '2024-10-16T09:00:00',
|
||||
status: 'pending',
|
||||
createdAt: '2024-10-15T15:00:00',
|
||||
createdBy: 'admin',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function createMockSendRecord(data: MessageSendFormData): MessageSendRecord {
|
||||
const now = new Date().toISOString();
|
||||
const scheduledDateTime = data.sendType === 'scheduled' && data.scheduledDate
|
||||
? new Date(data.scheduledDate.getFullYear(), data.scheduledDate.getMonth(), data.scheduledDate.getDate(),
|
||||
parseInt(data.scheduledTime.split(':')[0]), parseInt(data.scheduledTime.split(':')[1])).toISOString()
|
||||
: undefined;
|
||||
|
||||
// 解析接收人
|
||||
const recipients = data.recipients.split(/[,,;;\n]/).map(r => r.trim()).filter(r => r);
|
||||
|
||||
// 替换变量生成最终内容
|
||||
const replaceVariables = (content: string, variables: Record<string, string>): string => {
|
||||
let result = content;
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
result = result.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value || `{{${key}}}`);
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const finalContent = replaceVariables(data.content, data.variables);
|
||||
const finalSubject = data.subject ? replaceVariables(data.subject, data.variables) : undefined;
|
||||
|
||||
return {
|
||||
id: `send-${Date.now()}`,
|
||||
templateId: data.templateId,
|
||||
templateName: getMockTemplates().find(t => t.id === data.templateId)?.name || '',
|
||||
type: data.type,
|
||||
recipients,
|
||||
recipientCount: recipients.length,
|
||||
subject: finalSubject,
|
||||
content: finalContent,
|
||||
sendType: data.sendType,
|
||||
scheduledTime: scheduledDateTime,
|
||||
status: data.sendType === 'immediate' ? 'sent' : 'pending',
|
||||
sentCount: data.sendType === 'immediate' ? recipients.length : undefined,
|
||||
sentAt: data.sendType === 'immediate' ? now : undefined,
|
||||
createdAt: now,
|
||||
createdBy: 'admin',
|
||||
};
|
||||
}
|
||||
|
||||
export default function MessageSendPage() {
|
||||
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
|
||||
const [sendRecords, setSendRecords] = useState<MessageSendRecord[]>([]);
|
||||
const [showSendDialog, setShowSendDialog] = useState(false);
|
||||
const [showPreviewDialog, setShowPreviewDialog] = useState(false);
|
||||
const [previewRecord, setPreviewRecord] = useState<MessageSendRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState<MessageSendFormData>({
|
||||
templateId: '',
|
||||
type: 'internal',
|
||||
recipientType: 'manual',
|
||||
recipients: '',
|
||||
subject: '',
|
||||
content: '',
|
||||
sendType: 'immediate',
|
||||
scheduledDate: undefined,
|
||||
scheduledTime: '09:00',
|
||||
variables: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const [templatesData, recordsData] = await Promise.all([
|
||||
messageApi.getTemplates(),
|
||||
messageApi.getSendRecords()
|
||||
]);
|
||||
setTemplates(templatesData);
|
||||
setSendRecords(recordsData);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载数据失败');
|
||||
console.error('Failed to load data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSendDialog = () => {
|
||||
setFormData({
|
||||
templateId: '',
|
||||
type: 'internal',
|
||||
recipientType: 'manual',
|
||||
recipients: '',
|
||||
subject: '',
|
||||
content: '',
|
||||
sendType: 'immediate',
|
||||
scheduledDate: undefined,
|
||||
scheduledTime: '09:00',
|
||||
variables: {},
|
||||
});
|
||||
setShowSendDialog(true);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
try {
|
||||
// 验证
|
||||
if (!formData.templateId) {
|
||||
toast.error('请选择消息模版');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.recipients.trim()) {
|
||||
toast.error('请输入接收人');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查变量是否填写
|
||||
const template = templates.find(t => t.id === formData.templateId);
|
||||
if (template) {
|
||||
const emptyVars = template.variables.filter(v => !formData.variables[v]?.trim());
|
||||
if (emptyVars.length > 0) {
|
||||
toast.error(`请填写变量:${emptyVars.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.sendType === 'scheduled' && !formData.scheduledDate) {
|
||||
toast.error('请选择定时发送日期');
|
||||
return;
|
||||
}
|
||||
|
||||
// 发送消息
|
||||
const newRecord = await messageApi.sendMessage(formData);
|
||||
setSendRecords([newRecord, ...sendRecords]);
|
||||
|
||||
if (formData.sendType === 'immediate') {
|
||||
toast.success(`消息发送成功!已发送 ${newRecord.recipientCount} 条消息`);
|
||||
} else {
|
||||
toast.success(`定时消息已创建!将于 ${new Date(newRecord.scheduledTime!).toLocaleString('zh-CN')} 发送`);
|
||||
}
|
||||
|
||||
setShowSendDialog(false);
|
||||
} catch (err) {
|
||||
toast.error('发送失败:' + (err instanceof Error ? err.message : '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = (record: MessageSendRecord) => {
|
||||
setPreviewRecord(record);
|
||||
setShowPreviewDialog(true);
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
if (!confirm('确定要取消该定时消息吗?')) return;
|
||||
|
||||
try {
|
||||
await messageApi.cancelMessage(id);
|
||||
setSendRecords(sendRecords.map(r =>
|
||||
r.id === id ? { ...r, status: 'cancelled' as const } : r
|
||||
));
|
||||
toast.success('已取消定时消息');
|
||||
} catch (err) {
|
||||
toast.error('取消失败:' + (err instanceof Error ? err.message : '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('确定要删除该发送记录吗?')) return;
|
||||
|
||||
try {
|
||||
await messageApi.deleteMessage(id);
|
||||
setSendRecords(sendRecords.filter(r => r.id !== id));
|
||||
toast.success('删除成功');
|
||||
} catch (err) {
|
||||
toast.error('删除失败:' + (err instanceof Error ? err.message : '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'sms': return <Smartphone className="w-4 h-4" />;
|
||||
case 'email': return <Mail className="w-4 h-4" />;
|
||||
case 'internal': return <MessageSquare className="w-4 h-4" />;
|
||||
case 'push': return <Bell className="w-4 h-4" />;
|
||||
default: return <MessageSquare className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
sms: '短信',
|
||||
email: '邮件',
|
||||
internal: '站内信',
|
||||
push: '推送',
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
sms: 'bg-blue-100 text-blue-700',
|
||||
email: 'bg-purple-100 text-purple-700',
|
||||
internal: 'bg-green-100 text-green-700',
|
||||
push: 'bg-orange-100 text-orange-700',
|
||||
};
|
||||
return colors[type] || 'bg-gray-100 text-gray-700';
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const config: Record<string, { label: string; className: string; icon: any }> = {
|
||||
pending: { label: '待发送', className: 'bg-yellow-100 text-yellow-700', icon: Timer },
|
||||
sending: { label: '发送中', className: 'bg-blue-100 text-blue-700', icon: Send },
|
||||
sent: { label: '已发送', className: 'bg-green-100 text-green-700', icon: CheckCircle2 },
|
||||
failed: { label: '发送失败', className: 'bg-red-100 text-red-700', icon: XCircle },
|
||||
cancelled: { label: '已取消', className: 'bg-gray-100 text-gray-700', icon: XCircle },
|
||||
};
|
||||
const { label, className, icon: Icon } = config[status] || config.pending;
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${className}`}>
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-4 border-green-600 border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">正在加载数据...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-green-800">消息发送</h2>
|
||||
<p className="text-muted-foreground">发送短信、邮件、站内信消息,支持实时和定时发送</p>
|
||||
</div>
|
||||
<Button onClick={handleOpenSendDialog} className="bg-green-600 hover:bg-green-700">
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
发送消息
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-4 border border-yellow-200 bg-yellow-50 rounded-md">
|
||||
<p className="text-yellow-800 text-sm">
|
||||
警告: {error} (当前显示为模拟数据)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<MessageStatsCards sendRecords={sendRecords} />
|
||||
|
||||
{/* 发送记录列表 */}
|
||||
<MessageSendTable
|
||||
sendRecords={sendRecords}
|
||||
onPreview={handlePreview}
|
||||
onCancel={handleCancel}
|
||||
onDelete={handleDelete}
|
||||
getTypeIcon={getTypeIcon}
|
||||
getTypeLabel={getTypeLabel}
|
||||
getTypeBadge={getTypeBadge}
|
||||
getStatusBadge={getStatusBadge}
|
||||
/>
|
||||
|
||||
{/* 发送消息对话框 */}
|
||||
<SendMessageDialog
|
||||
open={showSendDialog}
|
||||
onOpenChange={setShowSendDialog}
|
||||
templates={templates}
|
||||
formData={formData}
|
||||
onFormDataChange={setFormData}
|
||||
onSend={handleSend}
|
||||
getTypeIcon={getTypeIcon}
|
||||
getTypeLabel={getTypeLabel}
|
||||
/>
|
||||
|
||||
{/* 详情预览对话框 */}
|
||||
<MessagePreviewDialog
|
||||
open={showPreviewDialog}
|
||||
onOpenChange={setShowPreviewDialog}
|
||||
record={previewRecord}
|
||||
getTypeIcon={getTypeIcon}
|
||||
getTypeLabel={getTypeLabel}
|
||||
getTypeBadge={getTypeBadge}
|
||||
getStatusBadge={getStatusBadge}
|
||||
/>
|
||||
|
||||
{/* 使用说明 */}
|
||||
<MessageInstructions />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface MessageSendFormData {
|
||||
templateId: string;
|
||||
type: 'sms' | 'email' | 'internal' | 'push';
|
||||
recipientType: 'manual' | 'role' | 'all';
|
||||
recipients: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
sendType: 'immediate' | 'scheduled';
|
||||
scheduledDate?: Date;
|
||||
scheduledTime: string;
|
||||
variables: Record<string, string>;
|
||||
}
|
||||
Reference in New Issue
Block a user