jerry il y a 3 mois
Parent
commit
b1de6897bc

+ 15 - 7
src/app/admin/page.tsx

@@ -2,8 +2,8 @@
 
 import { useState, useEffect } from 'react';
 import api from '@/lib/api';
-import DataStats from '@/components/admin/DataStats'; // 请确保此路径正确
-import OverviewCharts from '@/components/admin/dashboard/OverviewCharts'; // 请确保此路径正确
+import DataStats from '@/components/admin/DataStats'; 
+import OverviewCharts from '@/components/admin/dashboard/OverviewCharts'; 
 import { ShoppingBag, Users, AlertCircle, CheckCircle, Wallet, ArrowRight } from 'lucide-react';
 import Link from 'next/link';
 
@@ -25,6 +25,11 @@ interface ActivityItem {
   type: 'order' | 'money' | 'ticket' | 'system';
 }
 
+interface TaskDistItem {
+  routing_key: string;
+  count: number;
+}
+
 export default function AdminDashboard() {
   const [loading, setLoading] = useState(true);
   
@@ -42,8 +47,9 @@ export default function AdminDashboard() {
   // 图表数据
   const [chartData, setChartData] = useState<{
     revenue: any[],
-    products: any[]
-  }>({ revenue: [], products: [] });
+    products: any[],
+    tasks: TaskDistItem[] // [新增] 活跃任务数据类型
+  }>({ revenue: [], products: [], tasks: [] });
 
   const [recentActivities, setRecentActivities] = useState<ActivityItem[]>([]);
 
@@ -56,7 +62,7 @@ export default function AdminDashboard() {
     try {
       // 调用后端 API
       const res = await api.get('/api/vas/statistics/overview');
-      // 假设后端返回结构为: { code: 200, data: { stats: ..., revenue_trend: ... } }
+      // 假设后端返回结构为: { code: 0, message: "success", data: { ... } }
       const data = res.data.data;
 
       if (data) {
@@ -64,7 +70,9 @@ export default function AdminDashboard() {
 
         setChartData({ 
           revenue: data.revenue_trend || [], 
-          products: data.product_dist || [] 
+          products: data.product_dist || [],
+          // [新增] 获取后端返回的活跃任务分布数据
+          tasks: data.active_tasks_dist || [] 
         });
 
         setRecentActivities(data.recent_activities || []);
@@ -106,7 +114,6 @@ export default function AdminDashboard() {
           // 动态设置趋势文本
           trend={formatTrend(stats.totalRevenueTrend)} 
           // 动态判断颜色方向 (大于等于0为上升绿色/蓝色,小于0为红色)
-          // 注意:DataStats组件内部通常根据 trendUp=true 显示绿色/上升箭头
           trendUp={stats.totalRevenueTrend >= 0} 
           color="blue"
         />
@@ -146,6 +153,7 @@ export default function AdminDashboard() {
       <OverviewCharts 
         revenueData={chartData.revenue} 
         productData={chartData.products} 
+        taskData={chartData.tasks} // [新增] 传递任务数据给子组件
       />
 
       {/* 3. 底部:最新动态与快捷入口 */}

+ 35 - 13
src/app/admin/tasks/page.tsx

@@ -1,14 +1,19 @@
+// src/app/admin/tasks/page.tsx
 'use client';
 
 import { useState, useEffect } from 'react';
 import api from '@/lib/api';
 import { RefreshCw, Search } from 'lucide-react';
+
+// 组件导入
 import TaskTable, { VasTask } from '@/components/admin/tasks/TaskTable';
 import TaskDetailModal from '@/components/admin/tasks/TaskDetailModal';
 import TaskEditModal from '@/components/admin/tasks/TaskEditModal';
 import Pagination from '@/components/common/Pagination';
+import ExpiringTaskAlert from '@/components/admin/tasks/ExpiringTaskAlert'; // [新增] 紧急任务组件
 
 export default function AdminTasksPage() {
+  // === 状态定义 ===
   const [tasks, setTasks] = useState<VasTask[]>([]);
   const [loading, setLoading] = useState(true);
   
@@ -24,6 +29,7 @@ export default function AdminTasksPage() {
   const [isDetailOpen, setIsDetailOpen] = useState(false);
   const [isEditOpen, setIsEditOpen] = useState(false);
 
+  // === 数据获取逻辑 ===
   const fetchTasks = async (targetPage: number = page) => {
     setLoading(true);
     try {
@@ -41,6 +47,7 @@ export default function AdminTasksPage() {
       
       const data = res.data.data || {};
       
+      // 兼容两种常见的分页返回格式
       if (Array.isArray(data)) {
         setTasks(data);
         setTotal(data.length);
@@ -52,7 +59,7 @@ export default function AdminTasksPage() {
       setPage(targetPage);
 
     } catch (e) {
-      console.warn("API Error, using mock data");
+      console.warn("API Error, maybe using mock data or network failed");
       setTasks([]);
       setTotal(0);
     } finally {
@@ -60,10 +67,13 @@ export default function AdminTasksPage() {
     }
   };
 
+  // 监听筛选条件变化,自动刷新
   useEffect(() => {
     fetchTasks(1);
   }, [statusFilter]);
 
+  // === 事件处理 ===
+
   const handleSearch = () => {
     fetchTasks(1);
   };
@@ -72,8 +82,9 @@ export default function AdminTasksPage() {
     if (e.key === 'Enter') handleSearch();
   };
 
+  // 重置任务回队列
   const handleRetry = async (taskId: number) => {
-    if(!confirm("确定要重置该任务回队列吗?")) return;
+    if(!confirm("确定要重置该任务回队列吗?状态将变为 pending。")) return;
     try {
       await api.post('/api/vas/task/return_to_queue', null, { params: { task_id: taskId } });
       alert("操作成功");
@@ -83,8 +94,9 @@ export default function AdminTasksPage() {
     }
   };
 
+  // 强制标记完成
   const handleConfirm = async (taskId: number) => {
-    if(!confirm("确定要强制标记完成吗?")) return;
+    if(!confirm("确定要强制标记完成吗?这将跳过后续脚本执行。")) return;
     try {
       await api.post('/api/vas/task/manual_confirm', null, { params: { task_id: taskId } });
       alert("操作成功");
@@ -94,22 +106,25 @@ export default function AdminTasksPage() {
     }
   };
 
+  // 打开详情弹窗
   const handleViewDetail = (task: VasTask) => {
     setSelectedTask(task);
     setIsDetailOpen(true);
   };
 
+  // 打开编辑弹窗
   const handleEdit = (task: VasTask) => {
     setSelectedTask(task);
     setIsEditOpen(true);
   };
 
+  // 提交编辑
   const handleSubmitEdit = async (taskId: number, data: any) => {
     try {
       await api.post('/api/vas/task/update', data, {params: {"id": taskId}});
       alert("任务更新成功");
       setIsEditOpen(false);
-      fetchTasks(page);
+      fetchTasks(page); // 刷新列表
     } catch (e: any) {
       alert("更新失败: " + (e.response?.data?.message || "未知错误"));
     }
@@ -118,7 +133,7 @@ export default function AdminTasksPage() {
   return (
     <div className="p-4 md:p-6">
       
-      {/* === 头部区域:响应式布局 === */}
+      {/* === 1. 头部区域 === */}
       <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
         
         {/* 标题 */}
@@ -127,12 +142,12 @@ export default function AdminTasksPage() {
           <p className="text-sm text-slate-500 mt-1">监控机器人执行状态及调试日志</p>
         </div>
         
-        {/* 操作区:移动端垂直堆叠,桌面端水平排列 */}
+        {/* 操作区:筛选、搜索、刷新 */}
         <div className="flex flex-col sm:flex-row gap-3 w-full md:w-auto">
           
-          {/* 状态筛选:移动端全宽 */}
+          {/* 状态筛选下拉框 */}
           <select
-            className="w-full sm:w-auto border border-slate-300 rounded-lg text-sm px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500 bg-white appearance-none"
+            className="w-full sm:w-auto border border-slate-300 rounded-lg text-sm px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500 bg-white appearance-none cursor-pointer"
             value={statusFilter}
             onChange={(e) => setStatusFilter(e.target.value)}
           >
@@ -144,7 +159,7 @@ export default function AdminTasksPage() {
             <option value="cancelled">已取消 (Cancelled)</option>
           </select>
 
-          {/* 搜索框与刷新按钮组 */}
+          {/* 搜索框组 */}
           <div className="flex gap-2 w-full sm:w-auto">
             <div className="relative flex-1 sm:w-64">
               <input 
@@ -161,7 +176,7 @@ export default function AdminTasksPage() {
             <button 
               onClick={handleSearch} 
               className="flex-shrink-0 p-2 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 text-slate-600 transition"
-              title="刷新"
+              title="刷新列表"
             >
               <RefreshCw size={18} />
             </button>
@@ -170,7 +185,14 @@ export default function AdminTasksPage() {
         </div>
       </div>
 
-      {/* 表格组件 (内部已适配移动端卡片视图) */}
+      {/* === [新增] 2. 紧急任务预警区域 === */}
+      {/* 如果有即将过期的任务,这个组件会自动显示;否则不渲染 */}
+      <ExpiringTaskAlert 
+        onViewDetail={handleViewDetail}
+        onEdit={handleEdit}
+      />
+
+      {/* === 3. 主表格区域 === */}
       <TaskTable 
         tasks={tasks} 
         loading={loading} 
@@ -180,7 +202,7 @@ export default function AdminTasksPage() {
         onEdit={handleEdit} 
       />
 
-      {/* 分页组件 */}
+      {/* === 4. 分页区域 === */}
       <div className="mt-4">
         <Pagination 
           currentPage={page}
@@ -190,7 +212,7 @@ export default function AdminTasksPage() {
         />
       </div>
 
-      {/* Modals */}
+      {/* === 5. 弹窗组件 === */}
       <TaskDetailModal 
         isOpen={isDetailOpen}
         onClose={() => setIsDetailOpen(false)}

+ 38 - 3
src/components/ServiceList.tsx

@@ -41,6 +41,7 @@ export default function ServiceList() {
   const [keyword, setKeyword] = useState('');
   const [selectedCountry, setSelectedCountry] = useState('');
   const [selectedType, setSelectedType] = useState('');
+  const [inputWarning, setInputWarning] = useState('');
   
   const [page, setPage] = useState(1);
   const [pageSize] = useState(9);
@@ -165,12 +166,46 @@ export default function ServiceList() {
             <input 
               type="text" 
               placeholder={t('services.search_placeholder') || 'Search services...'} 
-              className="w-full pl-10 pr-4 py-3 md:py-2.5 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none transition"
+              // 动态样式:如果有警告,边框变红,Focus ring 变红
+              className={`w-full pl-10 pr-4 py-3 md:py-2.5 border rounded-lg text-sm outline-none transition
+                ${inputWarning 
+                  ? 'border-red-500 focus:ring-2 focus:ring-red-200 bg-red-50/50' 
+                  : 'border-slate-300 focus:ring-2 focus:ring-blue-500'
+                }`}
               value={keyword}
-              onChange={(e) => setKeyword(e.target.value)}
+              onChange={(e) => {
+                const val = e.target.value;
+                // 正则:检测是否全为 ASCII
+                if (/^[\x00-\x7F]*$/.test(val)) {
+                  setKeyword(val);
+                  setInputWarning(''); // 输入合法时清除警告
+                } else {
+                  // 输入非法:设置警告文字
+                  setInputWarning(t('services.ascii_only_error') || 'Please use English characters only');
+                  
+                  // 2秒后自动消失,提升体验
+                  setTimeout(() => setInputWarning(''), 2000);
+                }
+              }}
               onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
             />
-            <Search size={18} className="absolute left-3 top-3.5 md:top-3 text-slate-400" />
+            <Search 
+              size={18} 
+              // 图标颜色也跟随变红
+              className={`absolute left-3 top-3.5 md:top-3 ${inputWarning ? 'text-red-400' : 'text-slate-400'}`} 
+            />
+
+            {/* 错误提示气泡 (绝对定位,浮在下方,不影响布局) */}
+            {inputWarning && (
+              <div className="absolute top-full left-0 mt-1.5 w-full z-20 animate-in fade-in slide-in-from-top-1 duration-200">
+                <div className="bg-red-50 text-red-600 text-xs px-3 py-2 rounded-lg border border-red-200 shadow-lg flex items-center gap-2">
+                  <span className="w-1.5 h-1.5 rounded-full bg-red-500 shrink-0 animate-pulse"></span>
+                  {inputWarning}
+                </div>
+                {/* 小三角箭头指向上面 */}
+                <div className="absolute -top-1 left-4 w-2 h-2 bg-red-50 border-t border-l border-red-200 transform rotate-45"></div>
+              </div>
+            )}
           </div>
 
           {/* 国家筛选 */}

+ 89 - 17
src/components/admin/dashboard/OverviewCharts.tsx

@@ -4,21 +4,35 @@ import {
   LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, 
   PieChart, Pie, Cell 
 } from 'recharts';
+import { Layers, Zap } from 'lucide-react'; // 引入图标增加视觉识别度
 
 interface OverviewChartsProps {
   revenueData: { date: string; amount: number; orders: number }[];
   productData: { name: string; value: number }[];
+  taskData: { routing_key: string; count: number }[];
 }
 
 const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4'];
 
-export default function OverviewCharts({ revenueData, productData }: OverviewChartsProps) {
+export default function OverviewCharts({ revenueData, productData, taskData }: OverviewChartsProps) {
   
-  // 自定义 Tooltip
-  const CustomTooltip = ({ active, payload, label }: any) => {
+  // 计算任务总数,用于显示进度条占比
+  const totalTasks = taskData.reduce((acc, cur) => acc + cur.count, 0);
+
+  // 辅助函数:简化 Routing Key 显示 (取最后3段)
+  const formatRoutingKey = (key: string) => {
+    const parts = key.split('.');
+    if (parts.length > 3) {
+      return '...' + parts.slice(-3).join('.');
+    }
+    return key;
+  };
+
+  // 自定义营收 Tooltip
+  const RevenueTooltip = ({ active, payload, label }: any) => {
     if (active && payload && payload.length) {
       return (
-        <div className="bg-white p-3 border border-slate-200 shadow-lg rounded-lg text-xs">
+        <div className="bg-white p-3 border border-slate-200 shadow-lg rounded-lg text-xs z-50">
           <p className="font-bold text-slate-700 mb-1">{label}</p>
           <p className="text-blue-600">营收: ¥{payload[0].value.toLocaleString()}</p>
           {payload[1] && <p className="text-emerald-600">订单: {payload[1].value} 单</p>}
@@ -31,9 +45,9 @@ export default function OverviewCharts({ revenueData, productData }: OverviewCha
   return (
     <div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
       
-      {/* 左侧:营收趋势 (占 2/3) */}
+      {/* === 左侧:营收趋势 (占 2/3) === */}
       <div className="lg:col-span-2 bg-white p-6 rounded-xl shadow-sm border border-slate-100">
-        <h3 className="text-lg font-bold text-slate-800 mb-6">近 30 天营收趋势</h3>
+        <h3 className="text-lg font-bold text-slate-800 mb-6">近 7 天营收趋势</h3>
         <div className="h-[300px] w-full">
           <ResponsiveContainer width="100%" height="100%">
             <LineChart data={revenueData} margin={{ top: 5, right: 20, bottom: 5, left: 0 }}>
@@ -51,7 +65,7 @@ export default function OverviewCharts({ revenueData, productData }: OverviewCha
                 tick={{ fontSize: 12, fill: '#64748b' }} 
                 tickFormatter={(value) => `¥${value}`}
               />
-              <Tooltip content={<CustomTooltip />} />
+              <Tooltip content={<RevenueTooltip />} />
               <Line 
                 type="monotone" 
                 dataKey="amount" 
@@ -65,18 +79,18 @@ export default function OverviewCharts({ revenueData, productData }: OverviewCha
         </div>
       </div>
 
-      {/* 右侧:商品销量分布 (占 1/3) */}
-      <div className="bg-white p-6 rounded-xl shadow-sm border border-slate-100">
-        <h3 className="text-lg font-bold text-slate-800 mb-6">商品销量占比</h3>
-        <div className="h-[300px] w-full relative">
+      {/* === 右侧:商品销量占比 (占 1/3) === */}
+      <div className="lg:col-span-1 bg-white p-6 rounded-xl shadow-sm border border-slate-100 flex flex-col">
+        <h3 className="text-lg font-bold text-slate-800 mb-2">商品销量占比</h3>
+        <div className="flex-1 min-h-[300px] relative">
           <ResponsiveContainer width="100%" height="100%">
             <PieChart>
               <Pie
                 data={productData}
                 cx="50%"
                 cy="50%"
-                innerRadius={70} // 稍微调大内径
-                outerRadius={100} // 调大外径以填充空间
+                innerRadius={60}
+                outerRadius={90}
                 paddingAngle={5}
                 dataKey="value"
               >
@@ -84,17 +98,75 @@ export default function OverviewCharts({ revenueData, productData }: OverviewCha
                   <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
                 ))}
               </Pie>
-              {/* 移除了 Legend 组件,防止溢出 */}
               <Tooltip />
             </PieChart>
           </ResponsiveContainer>
-          
-          {/* 中间显示文字装饰 */}
           <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-center pointer-events-none">
-            <p className="text-xs text-slate-400 font-medium">Top Products</p>
+            <p className="text-xs text-slate-400 font-medium">Top Sales</p>
           </div>
         </div>
       </div>
+
+      {/* === [修改后] 底部:活跃任务数据网格 === 
+          不再使用 Chart,改为紧凑的数据卡片网格
+      */}
+      <div className="lg:col-span-3 bg-white p-6 rounded-xl shadow-sm border border-slate-100">
+        <div className="flex items-center gap-2 mb-6">
+          <Layers className="text-indigo-600" size={20} />
+          <h3 className="text-lg font-bold text-slate-800">
+            活跃任务分布
+            <span className="ml-2 text-sm font-normal text-slate-500">
+              (共 {totalTasks} 个进行中任务)
+            </span>
+          </h3>
+        </div>
+
+        {taskData.length === 0 ? (
+          <div className="text-center py-8 text-slate-400 text-sm">暂无活跃任务</div>
+        ) : (
+          <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
+            {taskData.map((item, index) => {
+              // 计算占比,用于显示背景进度条
+              const percent = totalTasks > 0 ? (item.count / totalTasks) * 100 : 0;
+              
+              return (
+                <div 
+                  key={index} 
+                  className="relative overflow-hidden group border border-slate-100 bg-slate-50 hover:border-indigo-200 hover:shadow-md transition-all rounded-lg p-3"
+                  title={item.routing_key} // 鼠标悬停显示完整 Key
+                >
+                  {/* 背景进度条装饰 (透明度低) */}
+                  <div 
+                    className="absolute bottom-0 left-0 h-1 bg-indigo-500 transition-all duration-500" 
+                    style={{ width: `${percent}%` }}
+                  />
+                  
+                  <div className="flex justify-between items-start mb-1">
+                    <div className="bg-indigo-100 text-indigo-700 p-1.5 rounded-md">
+                      <Zap size={14} />
+                    </div>
+                    {/* 数字显示 */}
+                    <span className="text-2xl font-bold text-slate-800 font-mono">
+                      {item.count}
+                    </span>
+                  </div>
+
+                  {/* 队列名称 (简化版) */}
+                  <div className="text-xs text-slate-500 font-medium truncate mt-2">
+                    {formatRoutingKey(item.routing_key)}
+                  </div>
+                  
+                  {/* 悬停提示完整名称 (可选,虽然已有 title 属性) */}
+                  <div className="absolute inset-0 bg-slate-900/90 text-white text-xs p-3 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-center rounded-lg pointer-events-none">
+                    {item.routing_key}
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+        )}
+      </div>
+
     </div>
   );
 }

+ 395 - 0
src/components/admin/tasks/ExpiringTaskAlert.tsx

@@ -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, "&amp;")
+      .replace(/</g, "&lt;")
+      .replace(/>/g, "&gt;");
+
+    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>
+      )}
+    </>
+  );
+}

+ 90 - 56
src/components/slots/CitySlotCard.tsx

@@ -1,7 +1,6 @@
 'use client';
 
-import { Calendar, MapPin, ExternalLink, Clock, AlertCircle, CheckCircle, ListOrdered, Hourglass, Activity, History } from 'lucide-react';
-import LocalTime from '@/components/common/LocalTime';
+import { Calendar, ExternalLink, AlertCircle, CheckCircle, Hourglass, Activity, History } from 'lucide-react';
 import TimeAgo from '@/components/common/TimeAgo';
 import { useLanguage } from '@/lib/i18n/LanguageContext';
 
@@ -20,22 +19,35 @@ export interface SlotSnapshot {
 export default function CitySlotCard({ data }: { data: SlotSnapshot }) {
   const { t, lang } = useLanguage();
   
-  // === 1. 计算过期状态 ===
+  // === 1. 时间计算与解析 ===
   const now = new Date().getTime();
-  let lastCheckTime = 0;
-  
-  if (data.last_check_at) {
-      let timeStr = data.last_check_at;
-      if (!timeStr.endsWith('Z') && !timeStr.includes('+')) timeStr += 'Z';
-      lastCheckTime = new Date(timeStr).getTime();
-  }
+  const THRESHOLD = 6 * 60 * 1000; // 5分钟阈值
+
+  const parseTime = (timeStr: string | null | undefined): number => {
+    if (!timeStr) return 0;
+    let t = timeStr;
+    if (!t.endsWith('Z') && !t.includes('+')) t += 'Z';
+    return new Date(t).getTime();
+  };
 
-  // 阈值:5 分钟
-  const isDataStale = (now - lastCheckTime) > 5 * 60 * 1000;
+  const lastCheckTime = parseTime(data.last_check_at);
+  const snapshotTime = parseTime(data.snapshot_at);
+
+  // === 2. 状态判定逻辑 ===
+  const isMonitorStale = (now - lastCheckTime) > THRESHOLD;
+  const isSnapshotStale = (now - snapshotTime) > THRESHOLD;
+  const isDataStale = isMonitorStale || isSnapshotStale; // 统称数据过期
 
   const rawStatus = data.availability_status;
-  const isAvailable = rawStatus === 'Available';
-  const isWaitlist = rawStatus === 'Waitlist';
+
+  // 定义“有号”:包括具体的 Available 和 Waitlist
+  const hasSlot = rawStatus === 'Available' || rawStatus === 'Waitlist';
+
+  // 1. 真正的有号 (Live Slots) -> 数据新鲜且有号
+  const isLiveSlot = hasSlot && !isDataStale;
+
+  // 2. 历史记录 (History) -> 曾经有号,但数据旧了
+  const isHistorySlot = hasSlot && isDataStale;
 
   // 格式化日期
   const formatDate = (dateStr: string | null) => {
@@ -46,54 +58,54 @@ export default function CitySlotCard({ data }: { data: SlotSnapshot }) {
     });
   };
 
-  // === 2. 动态样式配置 ===
+  // === 3. 动态样式配置 ===
   const getStyleConfig = () => {
     
-    // [特殊处理]:数据过期,但曾经是有号的 -> 显示为“橙色历史记录”风格
-    // 既不显示绿色误导用户,也不显示灰色让用户以为系统挂了
-    if (isDataStale && isAvailable) {
+    // 情况 A: 历史快照 (有号但过期) -> 橙色
+    if (isHistorySlot) {
       return {
-        bg: 'bg-orange-50/40 border-orange-200', // 浅橙色背景
+        bg: 'bg-orange-50/40 border-orange-200',
         badgeBg: 'bg-orange-100 text-orange-700',
-        icon: <History size={14} />, // 历史图标
-        label: lang === 'zh' ? '历史快照' : 'Stale Data', // 提示数据旧
-        dateColor: 'text-slate-700 opacity-80', // 日期颜色加深但不如绿色鲜艳
-        showDate: true // 依然显示日期
+        icon: <History size={14} />,
+        label: lang === 'zh' ? '历史快照' : 'History/Stale', // 依然提示数据旧
+        contentColor: 'text-slate-700 opacity-70',
+        isStale: true
       };
     }
 
-    // 正常 Available (新鲜数据)
-    if (isAvailable) {
+    // 情况 B: 真正的有号 (Available OR Waitlist) -> 绿色
+    // *修改点:Waitlist 现在完全共享 Available 的绿色样式*
+    if (isLiveSlot) {
       return {
         bg: 'bg-green-50/60 border-green-200',
         badgeBg: 'bg-green-100 text-green-700',
         icon: <CheckCircle size={14} />,
-        label: t('slots.status_available') || 'Available',
-        dateColor: 'text-green-700',
-        showDate: true
+        label: t('slots.status_available') || 'Available', // 统称 Available
+        contentColor: 'text-green-700',
+        isStale: false
       };
     }
 
-    // Waitlist
-    if (isWaitlist) {
+    // 情况 C: 监控过期 (Monitor Offline) 且原本就没号
+    if (isMonitorStale) {
       return {
-        bg: 'bg-yellow-50/60 border-yellow-200',
-        badgeBg: 'bg-yellow-100 text-yellow-700',
-        icon: <ListOrdered size={14} />,
-        label: t('slots.status_waitlist') || 'Waitlist Open',
-        dateColor: 'text-yellow-700',
-        showDate: false // Waitlist 通常没日期
+        bg: 'bg-slate-50 border-slate-200',
+        badgeBg: 'bg-slate-200 text-slate-500',
+        icon: <Activity size={14} className="text-slate-400" />,
+        label: lang === 'zh' ? '监控离线' : 'Monitor Offline',
+        contentColor: 'text-slate-400',
+        isStale: true
       };
     }
 
-    // None (无号)
+    // 情况 D: 正常无号 (None) -> 灰色
     return {
       bg: 'bg-white border-slate-200 opacity-90',
       badgeBg: 'bg-slate-100 text-slate-500',
       icon: <AlertCircle size={14} />,
       label: t('slots.status_unavailable') || 'None',
-      dateColor: 'text-slate-400',
-      showDate: false
+      contentColor: 'text-slate-400',
+      isStale: false
     };
   };
 
@@ -101,26 +113,38 @@ export default function CitySlotCard({ data }: { data: SlotSnapshot }) {
 
   // 决定中间显示什么内容
   const renderCenterContent = () => {
-    if (style.showDate) {
+    
+    // 如果是 Waitlist (无论是否过期)
+    if (rawStatus === 'Waitlist') {
       return (
         <>
-          <Calendar size={18} />
-          {formatDate(data.earliest_date)}
-          {/* 如果过期,加一个小尾巴提示 */}
-          {isDataStale && <span className="text-xs font-normal ml-1 opacity-60">(Maybe gone)</span>}
+          <Hourglass size={18} />
+          <span>Waitlist</span>
+          {style.isStale && (
+             <span className="text-xs font-normal ml-2 text-orange-600/70 border border-orange-200 px-1 rounded">Exp?</span>
+          )}
         </>
       );
-    } 
-    
-    if (isWaitlist) {
+    }
+
+    // 如果是 Available (有日期)
+    if (rawStatus === 'Available') {
       return (
         <>
-          <Hourglass size={18} />
-          <span>Waitlist</span>
+          <Calendar size={18} />
+          {formatDate(data.earliest_date)}
+          {style.isStale && (
+             <span className="text-xs font-normal ml-2 text-orange-600/70 border border-orange-200 px-1 rounded">Exp?</span>
+          )}
         </>
       );
     }
 
+    // 既不是 Available 也不是 Waitlist (即 None)
+    if (style.isStale) {
+       return <span className="text-base font-normal text-slate-400">{lang === 'zh' ? '数据已过期' : 'Data Stale'}</span>;
+    }
+
     return <span className="text-base font-normal">暂无名额</span>;
   };
 
@@ -152,9 +176,10 @@ export default function CitySlotCard({ data }: { data: SlotSnapshot }) {
       {/* 核心指标区域 */}
       <div className="bg-white/80 rounded-lg p-3 border border-black/5 mb-4 shadow-sm backdrop-blur-sm flex-1 flex flex-col justify-center">
         <p className="text-[10px] text-slate-400 font-bold uppercase tracking-wider mb-1">
-          {isWaitlist ? 'Current Status' : (t('slots.earliest_date') || 'Earliest Slot')}
+          {/* 这里文案也统一,如果是 Waitlist 也可以算作 Slot Info */}
+          {rawStatus === 'Waitlist' ? 'Slot Status' : (t('slots.earliest_date') || 'Earliest Slot')}
         </p>
-        <div className={`flex items-center gap-2 text-lg font-bold ${style.dateColor}`}>
+        <div className={`flex items-center gap-2 text-lg font-bold ${style.contentColor}`}>
           {renderCenterContent()}
         </div>
       </div>
@@ -164,23 +189,32 @@ export default function CitySlotCard({ data }: { data: SlotSnapshot }) {
         
         <div className="flex items-center justify-between text-xs">
           <span className="text-slate-400 flex items-center gap-1">
-            <Activity size={12} className={!isDataStale ? "text-green-500" : "text-slate-300"} />
+            <Activity size={12} className={!isMonitorStale ? "text-green-500" : "text-slate-300"} />
             {lang === 'zh' ? '监控状态' : 'Monitor'}
           </span>
           <div className="flex items-center gap-1">
-             {/* 数据新鲜才闪烁 */}
-             {!isDataStale && (
+             {/* 
+                只要是有号(日期或Waitlist)且新鲜,就闪烁绿灯
+             */}
+             {isLiveSlot && (
                <span className="relative flex h-2 w-2 mr-1">
                  <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
                  <span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
                </span>
              )}
-             <span className={`font-medium ${!isDataStale ? 'text-green-700' : 'text-slate-400'}`}>
-                {/* 4天前 检测 */}
+             <span className={`font-medium ${!isMonitorStale ? 'text-green-700' : 'text-slate-400'}`}>
                 <TimeAgo date={data.last_check_at} />
              </span>
           </div>
         </div>
+        
+        {/* 调试用:历史快照时间 */}
+        {isHistorySlot && (
+             <div className="flex items-center justify-between text-[10px] text-orange-400 px-1">
+                <span>Last seen:</span>
+                <TimeAgo date={data.snapshot_at} />
+             </div>
+        )}
 
         {data.website && (
           <div className="pt-2 mt-2 border-t border-dashed border-slate-200">

+ 1 - 0
src/lib/i18n/locales/en.ts

@@ -295,6 +295,7 @@ export const en = {
     contact_desc: 'If you have any questions regarding the refund policy, please contact our support team: ',
   },
   services: {
+    ascii_only_error: 'Please use English characters only',
     title: 'Popular Visa Services',
     search_placeholder: 'Search country, city or service...',
     all_countries: 'All Countries',

+ 1 - 0
src/lib/i18n/locales/zh.ts

@@ -295,6 +295,7 @@ export const zh = {
     contact_desc: '如果您对退款政策有任何疑问,请联系我们的客服团队:',
   },
   services: {
+    ascii_only_error: '请输入英文字符查询',
     title: '热门签证服务',
     search_placeholder: '搜索国家、城市或服务...',
     all_countries: '所有国家',