|
|
@@ -0,0 +1,395 @@
|
|
|
+'use client';
|
|
|
+
|
|
|
+import { useEffect, useState } from 'react';
|
|
|
+import api from '@/lib/api';
|
|
|
+import {
|
|
|
+ AlertTriangle, ChevronDown, ChevronUp, Clock,
|
|
|
+ Mail, Copy, User, FileText, Zap, Loader2, X, Send, Eye, PenLine,
|
|
|
+ MessageCircle // [新增] 引入图标
|
|
|
+} from 'lucide-react';
|
|
|
+import { VasTask } from './TaskTable';
|
|
|
+
|
|
|
+// API 返回的数据结构
|
|
|
+interface ExpiringTaskItem {
|
|
|
+ id: number;
|
|
|
+ order_id: string;
|
|
|
+ routing_key: string;
|
|
|
+ status: string;
|
|
|
+ customer_name: string;
|
|
|
+ expected_end_date: string;
|
|
|
+ email: string;
|
|
|
+ days_left: number;
|
|
|
+ social_media_account?: string; // [新增] 社交账号字段
|
|
|
+}
|
|
|
+
|
|
|
+interface ExpiringTaskAlertProps {
|
|
|
+ onViewDetail: (task: VasTask) => void;
|
|
|
+ onEdit: (task: VasTask) => void;
|
|
|
+}
|
|
|
+
|
|
|
+// 邮件草稿状态接口
|
|
|
+interface EmailDraft {
|
|
|
+ taskId: number;
|
|
|
+ customerName: string;
|
|
|
+ from: string;
|
|
|
+ to: string;
|
|
|
+ subject: string;
|
|
|
+ body: string; // 纯文本内容
|
|
|
+}
|
|
|
+
|
|
|
+export default function ExpiringTaskAlert({ onViewDetail, onEdit }: ExpiringTaskAlertProps) {
|
|
|
+ const [tasks, setTasks] = useState<ExpiringTaskItem[]>([]);
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+ const [isExpanded, setIsExpanded] = useState(false);
|
|
|
+
|
|
|
+ // === 邮件模态框状态 ===
|
|
|
+ const [isEmailModalOpen, setIsEmailModalOpen] = useState(false);
|
|
|
+ const [activeTab, setActiveTab] = useState<'write' | 'preview'>('write');
|
|
|
+ const [sending, setSending] = useState(false);
|
|
|
+
|
|
|
+ const [emailDraft, setEmailDraft] = useState<EmailDraft>({
|
|
|
+ taskId: 0,
|
|
|
+ customerName: '',
|
|
|
+ from: 'support@visafly.top',
|
|
|
+ to: '',
|
|
|
+ subject: '',
|
|
|
+ body: ''
|
|
|
+ });
|
|
|
+
|
|
|
+ const fetchExpiring = async () => {
|
|
|
+ setLoading(true);
|
|
|
+ try {
|
|
|
+ const res = await api.get('/api/vas/task/expiring?days=3');
|
|
|
+ const data = res.data.data || [];
|
|
|
+ setTasks(data);
|
|
|
+ } catch (error) {
|
|
|
+ console.error("Failed to fetch expiring tasks", error);
|
|
|
+ } finally {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ fetchExpiring();
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 1. 初始化草稿
|
|
|
+ const openEmailModal = (item: ExpiringTaskItem) => {
|
|
|
+ const textTemplate = `Dear ${item.customer_name},
|
|
|
+
|
|
|
+We noticed that your expected appointment date (${item.expected_end_date}) is approaching or has passed.
|
|
|
+
|
|
|
+Please provide a new expected date range so we can continue the service.`;
|
|
|
+
|
|
|
+ setEmailDraft({
|
|
|
+ taskId: item.id,
|
|
|
+ customerName: item.customer_name,
|
|
|
+ from: 'support@visafly.top',
|
|
|
+ to: item.email,
|
|
|
+ subject: `Appointment Reschedule Required - Order ${item.order_id}`,
|
|
|
+ body: textTemplate
|
|
|
+ });
|
|
|
+
|
|
|
+ setActiveTab('write');
|
|
|
+ setIsEmailModalOpen(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ // === 生成 HTML 用于预览和发送 ===
|
|
|
+ const generateFinalHtml = (textBody: string) => {
|
|
|
+ const safeText = textBody
|
|
|
+ .replace(/&/g, "&")
|
|
|
+ .replace(/</g, "<")
|
|
|
+ .replace(/>/g, ">");
|
|
|
+
|
|
|
+ const contentHtml = safeText.replace(/\n/g, '<br/>');
|
|
|
+
|
|
|
+ return `
|
|
|
+ <div style="font-family: Arial, sans-serif; font-size: 14px; line-height: 1.6; color: #333;">
|
|
|
+ ${contentHtml}
|
|
|
+
|
|
|
+ <br/><br/>
|
|
|
+ <hr style="border: 0; border-top: 1px solid #eee; margin: 20px 0;" />
|
|
|
+
|
|
|
+ <p style="margin: 0 0 10px 0;">You can visit our official website for more information:</p>
|
|
|
+ <p style="margin: 0;">
|
|
|
+ <a href="https://visafly.top" target="_blank" style="color: #2563eb; text-decoration: underline; font-weight: bold;">
|
|
|
+ https://visafly.top
|
|
|
+ </a>
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <br/>
|
|
|
+ <p style="margin: 0 0 5px 0;">Best Regards,</p>
|
|
|
+ <p style="margin: 0; font-weight: bold;">VisaFly Service Team</p>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 2. 发送邮件
|
|
|
+ const handleConfirmSend = async () => {
|
|
|
+ setSending(true);
|
|
|
+ try {
|
|
|
+ const finalHtmlBody = generateFinalHtml(emailDraft.body);
|
|
|
+
|
|
|
+ await api.post('/api/email-authorizations/sendmail',
|
|
|
+ {
|
|
|
+ body: finalHtmlBody
|
|
|
+ },
|
|
|
+ {
|
|
|
+ params: {
|
|
|
+ emailAccount: emailDraft.from,
|
|
|
+ sendTo: emailDraft.to,
|
|
|
+ subject: emailDraft.subject,
|
|
|
+ contentType: 'html'
|
|
|
+ }
|
|
|
+ }
|
|
|
+ );
|
|
|
+
|
|
|
+ alert(`邮件已成功发送给 ${emailDraft.customerName}`);
|
|
|
+ setIsEmailModalOpen(false);
|
|
|
+
|
|
|
+ } catch (error: any) {
|
|
|
+ console.error("发送邮件失败", error);
|
|
|
+ alert(`发送失败: ${error.response?.data?.message || error.message || "未知错误"}`);
|
|
|
+ } finally {
|
|
|
+ setSending(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 复制辅助函数
|
|
|
+ const copyText = (text: string) => {
|
|
|
+ if (!text) return;
|
|
|
+ if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
|
+ navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
|
|
+ } else { fallbackCopy(text); }
|
|
|
+ };
|
|
|
+ const fallbackCopy = (text: string) => {
|
|
|
+ const ta = document.createElement("textarea");
|
|
|
+ ta.value = text; ta.style.position="fixed"; ta.style.left="-9999px";
|
|
|
+ document.body.appendChild(ta); ta.select(); document.execCommand('copy');
|
|
|
+ document.body.removeChild(ta);
|
|
|
+ };
|
|
|
+
|
|
|
+ if (loading) return null;
|
|
|
+ if (tasks.length === 0) return null;
|
|
|
+
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ {/* === 主列表 === */}
|
|
|
+ <div className="mb-4 bg-white border border-orange-200 rounded-lg shadow-sm overflow-hidden transition-all">
|
|
|
+ <div
|
|
|
+ className={`px-4 py-3 flex justify-between items-center cursor-pointer hover:bg-orange-50 transition-colors ${isExpanded ? 'bg-orange-50 border-b border-orange-100' : 'bg-white'}`}
|
|
|
+ onClick={() => setIsExpanded(!isExpanded)}
|
|
|
+ >
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <AlertTriangle size={18} className="text-orange-500" />
|
|
|
+ <span className="font-bold text-slate-700 text-sm">紧急预警 ({tasks.length})</span>
|
|
|
+ <span className="text-xs text-slate-400 border-l border-slate-200 pl-2 ml-1 hidden sm:inline">
|
|
|
+ {tasks.length} 个任务即将过期或已过期,请及时联系客户改期
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <button className="text-slate-400 hover:text-slate-600">
|
|
|
+ {isExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {isExpanded && (
|
|
|
+ <div className="bg-white">
|
|
|
+ <div className="max-h-[400px] overflow-y-auto">
|
|
|
+ <table className="w-full text-xs text-left relative">
|
|
|
+ <thead className="bg-slate-50 text-slate-500 font-medium sticky top-0 z-10 shadow-sm">
|
|
|
+ <tr>
|
|
|
+ <th className="px-4 py-2 bg-slate-50 w-[140px]">紧迫程度 / 截止</th>
|
|
|
+ <th className="px-4 py-2 bg-slate-50 w-[200px]">客户信息 (Email)</th>
|
|
|
+ <th className="px-4 py-2 bg-slate-50">任务详情 (ID / Route)</th>
|
|
|
+ <th className="px-4 py-2 bg-slate-50 text-right w-[100px]">操作</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody className="divide-y divide-slate-100">
|
|
|
+ {tasks.map((task) => (
|
|
|
+ <tr key={task.id} className="hover:bg-slate-50 transition-colors">
|
|
|
+ <td className="px-4 py-3 align-top">
|
|
|
+ <div className="flex flex-col gap-1">
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
+ <Clock size={12} className={task.days_left < 0 ? "text-red-500" : "text-orange-500"} />
|
|
|
+ <span className={`font-bold ${task.days_left < 0 ? "text-red-600" : "text-orange-600"}`}>
|
|
|
+ {task.days_left < 0 ? `逾期 ${Math.abs(task.days_left)} 天` : `${task.days_left} 天后`}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <span className="text-slate-400 font-mono pl-4">{task.expected_end_date}</span>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+
|
|
|
+ {/* 2. 客户信息列 (修改点) */}
|
|
|
+ <td className="px-4 py-3 align-top">
|
|
|
+ <div className="flex flex-col gap-1">
|
|
|
+ {/* 姓名 */}
|
|
|
+ <div className="flex items-center gap-1.5 font-bold text-slate-700">
|
|
|
+ <User size={12} className="text-slate-400"/> {task.customer_name}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* [新增] 社交账号显示 */}
|
|
|
+ {task.social_media_account && (
|
|
|
+ <div className="flex items-center gap-1.5 text-slate-500" title="社交媒体账号">
|
|
|
+ <MessageCircle size={10} className="text-indigo-400 flex-shrink-0" />
|
|
|
+ <span className="truncate max-w-[160px] font-medium text-[11px] bg-slate-100 px-1 rounded">
|
|
|
+ {task.social_media_account}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {/* 邮箱 */}
|
|
|
+ <div className="flex items-center gap-1 text-slate-500 cursor-pointer hover:text-blue-600 group/copy w-fit" onClick={() => copyText(task.email)}>
|
|
|
+ <Mail size={10} />
|
|
|
+ <span className="truncate max-w-[160px] underline decoration-slate-300 decoration-dotted underline-offset-2">{task.email}</span>
|
|
|
+ <Copy size={8} className="opacity-0 group-hover/copy:opacity-100 transition-opacity" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+
|
|
|
+ <td className="px-4 py-3 align-top">
|
|
|
+ <div className="flex flex-col gap-1.5">
|
|
|
+ <div className="flex items-center gap-1.5 cursor-pointer hover:text-blue-600 w-fit" onClick={() => copyText(task.order_id)}>
|
|
|
+ <FileText size={12} className="text-slate-400 flex-shrink-0" />
|
|
|
+ <span className="font-mono text-slate-700 font-medium">{task.order_id}</span>
|
|
|
+ </div>
|
|
|
+ <div className="flex items-start gap-1.5">
|
|
|
+ <Zap size={12} className="text-indigo-400 flex-shrink-0 mt-0.5" />
|
|
|
+ <span className="font-mono text-[10px] text-slate-500 bg-slate-100 px-1 rounded break-all leading-tight">{task.routing_key}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td className="px-4 py-3 align-middle text-right">
|
|
|
+ <button
|
|
|
+ onClick={(e) => { e.stopPropagation(); openEmailModal(task); }}
|
|
|
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-orange-50 text-orange-700 hover:bg-orange-100 hover:text-orange-800 rounded-md border border-orange-200 transition-colors shadow-sm"
|
|
|
+ >
|
|
|
+ <Mail size={14} />
|
|
|
+ <span className="font-bold">联系改期</span>
|
|
|
+ </button>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ ))}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* === 邮件发送模态框 === */}
|
|
|
+ {isEmailModalOpen && (
|
|
|
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
|
|
+ <div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
|
|
+
|
|
|
+ {/* Modal Header */}
|
|
|
+ <div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50">
|
|
|
+ <h3 className="font-bold text-lg text-slate-800 flex items-center gap-2">
|
|
|
+ <Mail className="text-blue-600" size={20} />
|
|
|
+ 发送通知邮件
|
|
|
+ </h3>
|
|
|
+ <button
|
|
|
+ onClick={() => setIsEmailModalOpen(false)}
|
|
|
+ className="text-slate-400 hover:text-slate-600 hover:bg-slate-200 rounded-full p-1 transition"
|
|
|
+ >
|
|
|
+ <X size={20} />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Modal Body */}
|
|
|
+ <div className="p-6 overflow-y-auto flex-1 flex flex-col gap-4">
|
|
|
+
|
|
|
+ {/* Basic Info */}
|
|
|
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs font-semibold text-slate-500 mb-1">发件人</label>
|
|
|
+ <input type="text" value={emailDraft.from} readOnly className="w-full px-3 py-2 bg-slate-100 border border-slate-200 rounded-lg text-sm text-slate-600 focus:outline-none" />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs font-semibold text-slate-500 mb-1">收件人</label>
|
|
|
+ <input type="text" value={emailDraft.to} readOnly className="w-full px-3 py-2 bg-slate-100 border border-slate-200 rounded-lg text-sm text-slate-800 font-medium focus:outline-none" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ <label className="block text-xs font-semibold text-slate-500 mb-1">邮件主题</label>
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ value={emailDraft.subject}
|
|
|
+ onChange={(e) => setEmailDraft({...emailDraft, subject: e.target.value})}
|
|
|
+ className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm text-slate-800 focus:ring-2 focus:ring-blue-500 outline-none transition"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 编辑容器 */}
|
|
|
+ <div className="flex flex-col h-[350px]">
|
|
|
+ <div className="flex justify-between items-center border-b border-slate-200 mb-0">
|
|
|
+ <div className="flex gap-1">
|
|
|
+ <button
|
|
|
+ onClick={() => setActiveTab('write')}
|
|
|
+ className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'write' ? 'border-blue-500 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
|
|
+ >
|
|
|
+ <PenLine size={14} /> 编辑文本
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={() => setActiveTab('preview')}
|
|
|
+ className={`flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'preview' ? 'border-purple-500 text-purple-600' : 'border-transparent text-slate-500 hover:text-slate-700'}`}
|
|
|
+ >
|
|
|
+ <Eye size={14} /> 预览效果
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <span className="text-[10px] text-slate-400 pr-2">
|
|
|
+ {activeTab === 'write' ? '支持纯文本,自动换行' : '这是最终发送的 HTML 样式'}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex-1 border-x border-b border-slate-200 rounded-b-lg bg-slate-50 overflow-hidden relative">
|
|
|
+ {activeTab === 'write' ? (
|
|
|
+ <textarea
|
|
|
+ value={emailDraft.body}
|
|
|
+ onChange={(e) => setEmailDraft({...emailDraft, body: e.target.value})}
|
|
|
+ placeholder="请输入邮件正文..."
|
|
|
+ className="w-full h-full p-4 text-sm text-slate-800 leading-relaxed bg-white outline-none resize-none focus:bg-slate-50/50 transition"
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <div className="w-full h-full p-4 overflow-y-auto bg-white">
|
|
|
+ <div
|
|
|
+ className="preview-content border border-slate-100 rounded p-4 shadow-sm"
|
|
|
+ dangerouslySetInnerHTML={{ __html: generateFinalHtml(emailDraft.body) }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Modal Footer */}
|
|
|
+ <div className="px-6 py-4 border-t border-slate-100 flex justify-end gap-3 bg-white">
|
|
|
+ <button
|
|
|
+ onClick={() => setIsEmailModalOpen(false)}
|
|
|
+ className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg text-sm font-medium transition"
|
|
|
+ >
|
|
|
+ 取消
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={handleConfirmSend}
|
|
|
+ disabled={sending || !emailDraft.subject || !emailDraft.body}
|
|
|
+ className={`flex items-center gap-2 px-6 py-2 rounded-lg text-sm font-bold text-white transition shadow-sm
|
|
|
+ ${sending || !emailDraft.subject || !emailDraft.body
|
|
|
+ ? 'bg-blue-300 cursor-not-allowed'
|
|
|
+ : 'bg-blue-600 hover:bg-blue-700 hover:shadow-md'}`}
|
|
|
+ >
|
|
|
+ {sending ? (
|
|
|
+ <> <Loader2 size={16} className="animate-spin" /> 发送中... </>
|
|
|
+ ) : (
|
|
|
+ <> <Send size={16} /> 确认发送 </>
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </>
|
|
|
+ );
|
|
|
+}
|