|
|
@@ -1,236 +1,219 @@
|
|
|
'use client';
|
|
|
|
|
|
-import { useState } from 'react';
|
|
|
+import { useState, useEffect, useMemo } from 'react';
|
|
|
import api from '@/lib/api';
|
|
|
-import { Search, Calendar, Clock, RefreshCw, AlertCircle, CheckCircle, ExternalLink } from 'lucide-react';
|
|
|
+import { RefreshCw, MapPin, Info, Globe, Search } from 'lucide-react';
|
|
|
import { useLanguage } from '@/lib/i18n/LanguageContext';
|
|
|
-import LocalTime from '@/components/common/LocalTime';
|
|
|
-
|
|
|
-// === 类型定义 ===
|
|
|
-
|
|
|
-interface TimeSlot {
|
|
|
- time: string;
|
|
|
- label?: string;
|
|
|
-}
|
|
|
-
|
|
|
-interface DayAvailability {
|
|
|
- date: string;
|
|
|
- times?: TimeSlot[];
|
|
|
-}
|
|
|
-
|
|
|
-interface SlotSnapshot {
|
|
|
- id: number;
|
|
|
- country: string;
|
|
|
- city: string;
|
|
|
- visa_type: string;
|
|
|
- availability_status: 'None' | 'Available' | 'Waitlist';
|
|
|
- earliest_date: string | null;
|
|
|
- snapshot_at: string;
|
|
|
- // 新增 website 字段
|
|
|
- website?: string;
|
|
|
- availability: DayAvailability[];
|
|
|
-}
|
|
|
-
|
|
|
-export default function SlotQueryPage() {
|
|
|
- const [loading, setLoading] = useState(false);
|
|
|
- const [snapshot, setSnapshot] = useState<SlotSnapshot | null>(null);
|
|
|
- const { t, lang } = useLanguage();
|
|
|
+import CitySlotCard, { SlotSnapshot } from '@/components/slots/CitySlotCard';
|
|
|
+
|
|
|
+const LOCATIONS = [
|
|
|
+ { code: 'IE', name: 'Ireland', flag: '🇮🇪', cities: ['Dublin'] },
|
|
|
+ { code: 'GB', name: 'United Kingdom', flag: '🇬🇧', cities: ['London', 'Manchester'] },
|
|
|
+ { code: 'SG', name: 'Singapore', flag: '🇸🇬', cities: ['Singapore'] }
|
|
|
+];
|
|
|
+
|
|
|
+export default function SlotDashboardPage() {
|
|
|
+ const { t } = useLanguage();
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
+ const [snapshots, setSnapshots] = useState<SlotSnapshot[]>([]);
|
|
|
|
|
|
- const [country, setCountry] = useState('France');
|
|
|
- const [city, setCity] = useState('Dublin');
|
|
|
- const [visaType, setVisaType] = useState('Tourist');
|
|
|
-
|
|
|
- // ... (options 保持不变)
|
|
|
- const options = {
|
|
|
- countries: ['Austria','Croatia','Denmark','Finland','France','Germany','Greece','Hungary','Iceland','Italy','Netherlands','Poland','Spain'],
|
|
|
- cities: ['Dublin','Edinburgh','London','Manchester','Melbourne','Montreal','Singapore','Sydney','Toronto'],
|
|
|
- types: ['Tourist','Business','Family','Student','Work','Transit','e-Visa']
|
|
|
+ const [selectedCountryCode, setSelectedCountryCode] = useState('IE');
|
|
|
+ const [selectedCity, setSelectedCity] = useState('Dublin');
|
|
|
+
|
|
|
+ const currentCities = useMemo(() => {
|
|
|
+ return LOCATIONS.find(l => l.code === selectedCountryCode)?.cities || [];
|
|
|
+ }, [selectedCountryCode]);
|
|
|
+
|
|
|
+ const handleCountryChange = (code: string) => {
|
|
|
+ setSelectedCountryCode(code);
|
|
|
+ const countryData = LOCATIONS.find(l => l.code === code);
|
|
|
+ if (countryData && countryData.cities.length > 0) {
|
|
|
+ setSelectedCity(countryData.cities[0]);
|
|
|
+ } else {
|
|
|
+ setSelectedCity('');
|
|
|
+ }
|
|
|
};
|
|
|
|
|
|
- const fetchSlots = async () => {
|
|
|
+ useEffect(() => {
|
|
|
+ if (selectedCity) {
|
|
|
+ fetchDashboard();
|
|
|
+ }
|
|
|
+ }, [selectedCity]);
|
|
|
+
|
|
|
+ const fetchDashboard = async () => {
|
|
|
setLoading(true);
|
|
|
try {
|
|
|
- const res = await api.get('/api/slots/latest', {
|
|
|
- params: { country, city, visa_type: visaType }
|
|
|
+ // 暂时注释掉真实 API,使用下方的 Mock 数据
|
|
|
+ const res = await api.get('/api/slots/overview', { params: { city: selectedCity } });
|
|
|
+ let data = res.data.data || [];
|
|
|
+
|
|
|
+ // === 模拟网络延迟 ===
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 800));
|
|
|
+
|
|
|
+ // === 构造 Mock 数据 ===
|
|
|
+ const now = new Date();
|
|
|
+ const subMins = (m: number) => new Date(now.getTime() - m * 60000).toISOString();
|
|
|
+ const subHours = (h: number) => new Date(now.getTime() - h * 3600000).toISOString();
|
|
|
+
|
|
|
+ // const mockData: SlotSnapshot[] = [
|
|
|
+ // {
|
|
|
+ // id: 1,
|
|
|
+ // country: 'France',
|
|
|
+ // city: 'Dublin',
|
|
|
+ // visa_type: 'Short Stay',
|
|
|
+ // availability_status: 'Available',
|
|
|
+ // earliest_date: '2026-02-14',
|
|
|
+ // website: 'https://google.com',
|
|
|
+ // snapshot_at: subMins(10), // 数据是10分钟前更新的
|
|
|
+ // last_check_at: subMins(1) // 爬虫刚刚(1分钟前)还在跑 -> 健康 (显示呼吸灯)
|
|
|
+ // },
|
|
|
+ // {
|
|
|
+ // id: 2,
|
|
|
+ // country: 'Spain',
|
|
|
+ // city: 'Dublin',
|
|
|
+ // visa_type: 'Tourist',
|
|
|
+ // availability_status: 'Waitlist',
|
|
|
+ // earliest_date: null,
|
|
|
+ // website: 'https://google.com',
|
|
|
+ // snapshot_at: subHours(2),
|
|
|
+ // last_check_at: subMins(5) // 5分钟前跑过 -> 健康 (显示呼吸灯)
|
|
|
+ // },
|
|
|
+ // {
|
|
|
+ // id: 3,
|
|
|
+ // country: 'Italy',
|
|
|
+ // city: 'Dublin',
|
|
|
+ // visa_type: 'Tourism',
|
|
|
+ // availability_status: 'None',
|
|
|
+ // earliest_date: null,
|
|
|
+ // snapshot_at: subHours(5),
|
|
|
+ // last_check_at: subMins(2) // 重点:虽然无号,但2分钟前刚查过 -> 增加用户信任
|
|
|
+ // },
|
|
|
+ // {
|
|
|
+ // id: 4,
|
|
|
+ // country: 'Germany',
|
|
|
+ // city: 'Dublin',
|
|
|
+ // visa_type: 'Business',
|
|
|
+ // availability_status: 'None',
|
|
|
+ // earliest_date: null,
|
|
|
+ // snapshot_at: subHours(10),
|
|
|
+ // last_check_at: subHours(8) // 8小时前查的 -> 离线/过期 (无呼吸灯,提示数据旧)
|
|
|
+ // }
|
|
|
+ // ];
|
|
|
+
|
|
|
+ // 简单过滤一下城市,模拟真实效果
|
|
|
+ const filtered = data.filter(i => i.city === selectedCity);
|
|
|
+
|
|
|
+ // 排序
|
|
|
+ filtered.sort((a, b) => {
|
|
|
+ const score = (s: string) => s === 'Available' ? 3 : s === 'Waitlist' ? 2 : 1;
|
|
|
+ return score(b.availability_status) - score(a.availability_status);
|
|
|
});
|
|
|
- const data = res.data.data;
|
|
|
- if (data) setSnapshot(data);
|
|
|
- else setSnapshot(null);
|
|
|
+
|
|
|
+ setSnapshots(filtered);
|
|
|
+
|
|
|
} catch (e) {
|
|
|
- console.warn("API Error");
|
|
|
- setSnapshot(null);
|
|
|
+ console.warn("Error", e);
|
|
|
+ setSnapshots([]);
|
|
|
} finally {
|
|
|
setLoading(false);
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- const formatDate = (dateStr: string) => {
|
|
|
- const date = new Date(dateStr);
|
|
|
- const locale = lang === 'zh' ? 'zh-CN' : 'en-US';
|
|
|
- return date.toLocaleDateString(locale, { month: 'long', day: 'numeric', weekday: 'short' });
|
|
|
- };
|
|
|
-
|
|
|
return (
|
|
|
- <div className="min-h-screen bg-slate-50 py-6 px-4 md:py-12 md:px-6">
|
|
|
- <div className="max-w-5xl mx-auto">
|
|
|
+ <div className="min-h-screen bg-slate-50 py-6 px-4 md:py-12 md:px-8">
|
|
|
+ <div className="max-w-7xl mx-auto">
|
|
|
|
|
|
- {/* 标题区 */}
|
|
|
- <div className="text-center mb-8 md:mb-10">
|
|
|
+ {/* Header */}
|
|
|
+ <div className="mb-8">
|
|
|
<h1 className="text-2xl md:text-3xl font-bold text-slate-900">{t('slots.title')}</h1>
|
|
|
- <p className="text-sm md:text-base text-slate-500 mt-2">{t('slots.subtitle')}</p>
|
|
|
+ <p className="text-sm text-slate-500 mt-2 flex items-center gap-1">
|
|
|
+ <Info size={14} />
|
|
|
+ {t('slots.subtitle') || '查看各使馆最新名额状态 (实时更新)'}
|
|
|
+ </p>
|
|
|
</div>
|
|
|
|
|
|
- {/* 筛选区 */}
|
|
|
- <div className="bg-white p-5 md:p-6 rounded-2xl shadow-sm border border-slate-200 mb-8">
|
|
|
- <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
|
- <div>
|
|
|
- <label className="block text-xs font-bold text-slate-500 uppercase mb-1">{t('product.country')}</label>
|
|
|
- <select
|
|
|
- className="w-full border rounded-lg p-2.5 bg-slate-50 outline-none focus:ring-2 focus:ring-blue-500 transition"
|
|
|
- value={country} onChange={e => setCountry(e.target.value)}
|
|
|
- >
|
|
|
- {options.countries.map(c => <option key={c} value={c}>{c}</option>)}
|
|
|
- </select>
|
|
|
- </div>
|
|
|
- <div>
|
|
|
- <label className="block text-xs font-bold text-slate-500 uppercase mb-1">{t('product.city')}</label>
|
|
|
+ {/* Filter Bar */}
|
|
|
+ <div className="bg-white p-4 rounded-2xl shadow-sm border border-slate-200 mb-8 flex flex-col md:flex-row gap-4 items-center">
|
|
|
+
|
|
|
+ {/* Country */}
|
|
|
+ <div className="w-full md:w-auto flex-1 max-w-xs relative">
|
|
|
+ <label className="text-xs font-bold text-slate-400 uppercase ml-1 mb-1 block">
|
|
|
+ Application Location (Country)
|
|
|
+ </label>
|
|
|
+ <div className="relative">
|
|
|
<select
|
|
|
- className="w-full border rounded-lg p-2.5 bg-slate-50 outline-none focus:ring-2 focus:ring-blue-500 transition"
|
|
|
- value={city} onChange={e => setCity(e.target.value)}
|
|
|
+ className="w-full pl-10 pr-8 py-3 border border-slate-200 rounded-xl bg-slate-50 text-sm font-bold text-slate-800 outline-none focus:ring-2 focus:ring-blue-500 appearance-none cursor-pointer"
|
|
|
+ value={selectedCountryCode}
|
|
|
+ onChange={(e) => handleCountryChange(e.target.value)}
|
|
|
>
|
|
|
- {options.cities.map(c => <option key={c} value={c}>{c}</option>)}
|
|
|
+ {LOCATIONS.map(loc => (
|
|
|
+ <option key={loc.code} value={loc.code}>
|
|
|
+ {loc.flag} {loc.name}
|
|
|
+ </option>
|
|
|
+ ))}
|
|
|
</select>
|
|
|
+ <Globe size={18} className="absolute left-3 top-3.5 text-slate-400 pointer-events-none" />
|
|
|
+ <div className="absolute right-3 top-4 pointer-events-none text-slate-400">
|
|
|
+ <svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
- <div>
|
|
|
- <label className="block text-xs font-bold text-slate-500 uppercase mb-1">{t('product.visa_type')}</label>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* City */}
|
|
|
+ <div className="w-full md:w-auto flex-1 max-w-xs relative">
|
|
|
+ <label className="text-xs font-bold text-slate-400 uppercase ml-1 mb-1 block">
|
|
|
+ Application City
|
|
|
+ </label>
|
|
|
+ <div className="relative">
|
|
|
<select
|
|
|
- className="w-full border rounded-lg p-2.5 bg-slate-50 outline-none focus:ring-2 focus:ring-blue-500 transition"
|
|
|
- value={visaType} onChange={e => setVisaType(e.target.value)}
|
|
|
+ className="w-full pl-10 pr-8 py-3 border border-slate-200 rounded-xl bg-slate-50 text-sm font-bold text-slate-800 outline-none focus:ring-2 focus:ring-blue-500 appearance-none cursor-pointer"
|
|
|
+ value={selectedCity}
|
|
|
+ onChange={(e) => setSelectedCity(e.target.value)}
|
|
|
>
|
|
|
- {options.types.map(c => <option key={c} value={c}>{c}</option>)}
|
|
|
+ {currentCities.map(city => (
|
|
|
+ <option key={city} value={city}>{city}</option>
|
|
|
+ ))}
|
|
|
</select>
|
|
|
+ <MapPin size={18} className="absolute left-3 top-3.5 text-slate-400 pointer-events-none" />
|
|
|
+ <div className="absolute right-3 top-4 pointer-events-none text-slate-400">
|
|
|
+ <svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/></svg>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
</div>
|
|
|
-
|
|
|
- <div className="mt-6 flex justify-end">
|
|
|
+
|
|
|
+ {/* Refresh */}
|
|
|
+ <div className="w-full md:w-auto pt-5">
|
|
|
<button
|
|
|
- onClick={fetchSlots}
|
|
|
+ onClick={fetchDashboard}
|
|
|
disabled={loading}
|
|
|
- className="w-full md:w-auto flex justify-center items-center gap-2 bg-slate-900 text-white px-8 py-3 md:py-2.5 rounded-lg font-bold hover:bg-slate-800 transition disabled:opacity-70 shadow-lg shadow-slate-200 active:scale-95"
|
|
|
+ className="w-full md:w-auto flex items-center justify-center gap-2 px-6 py-3 bg-slate-900 text-white rounded-xl hover:bg-slate-800 transition disabled:opacity-70 shadow-sm active:scale-95"
|
|
|
>
|
|
|
- {loading ? <RefreshCw className="animate-spin" size={18} /> : <Search size={18} />}
|
|
|
- {t('common.search')}
|
|
|
+ <RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
|
|
+ <span className="font-bold text-sm">{t('common.refresh') || 'Refresh'}</span>
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
- {/* 结果展示区 */}
|
|
|
- {snapshot ? (
|
|
|
- <div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
|
-
|
|
|
- {/* 概览 Banner */}
|
|
|
- <div className={`p-5 md:p-6 rounded-xl border flex flex-col md:flex-row items-start md:items-center justify-between gap-4 md:gap-6
|
|
|
- ${snapshot.availability_status === 'Available' ? 'bg-green-50 border-green-200' : 'bg-gray-50 border-gray-200'}
|
|
|
- `}>
|
|
|
- <div className="flex items-start gap-4">
|
|
|
- <div className={`p-3 rounded-full flex-shrink-0 ${snapshot.availability_status === 'Available' ? 'bg-green-100 text-green-600' : 'bg-gray-200 text-gray-500'}`}>
|
|
|
- {snapshot.availability_status === 'Available' ? <CheckCircle size={28} /> : <AlertCircle size={28} />}
|
|
|
- </div>
|
|
|
- <div>
|
|
|
- <h3 className={`text-lg font-bold ${snapshot.availability_status === 'Available' ? 'text-green-800' : 'text-gray-700'}`}>
|
|
|
- {snapshot.availability_status === 'Available' ? t('slots.status_available') : t('slots.status_unavailable')}
|
|
|
- </h3>
|
|
|
- <p className="text-sm opacity-80 flex flex-wrap items-center gap-1 mt-1">
|
|
|
- <Clock size={12} className="flex-shrink-0" />
|
|
|
- <span>{t('slots.updated_at')}:</span>
|
|
|
- <LocalTime date={snapshot.snapshot_at} />
|
|
|
- </p>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
-
|
|
|
- {snapshot.earliest_date && (
|
|
|
- <div className="w-full md:w-auto bg-white/60 px-5 py-3 rounded-lg border border-black/5 text-center md:text-right">
|
|
|
- <p className="text-xs font-bold uppercase tracking-wider opacity-60">{t('slots.earliest_date')}</p>
|
|
|
- <p className="text-2xl font-bold text-slate-800">{snapshot.earliest_date}</p>
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ {/* Grid List */}
|
|
|
+ {loading && snapshots.length === 0 ? (
|
|
|
+ <div className="text-center py-24 text-slate-400 bg-white rounded-2xl border border-dashed">
|
|
|
+ <RefreshCw size={32} className="animate-spin mx-auto mb-3 opacity-50" />
|
|
|
+ <p>正在获取 {selectedCity} 的最新数据...</p>
|
|
|
+ </div>
|
|
|
+ ) : snapshots.length === 0 ? (
|
|
|
+ <div className="text-center py-24 bg-white rounded-2xl border border-dashed border-slate-200">
|
|
|
+ <div className="mx-auto w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mb-4">
|
|
|
+ <Search className="text-slate-300" size={32} />
|
|
|
</div>
|
|
|
-
|
|
|
- {/*
|
|
|
- === 列表渲染逻辑优化 ===
|
|
|
- 如果没有具体时间段,不要显示空网格,而是显示行动按钮或紧凑状态
|
|
|
- */}
|
|
|
- {snapshot.availability && snapshot.availability.length > 0 && (
|
|
|
- <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
|
- {snapshot.availability.map((day, idx) => {
|
|
|
- const times = day.times || [];
|
|
|
- const hasTimes = times.length > 0;
|
|
|
-
|
|
|
- return (
|
|
|
- <div key={idx} className="bg-white border border-slate-200 rounded-xl overflow-hidden hover:shadow-md transition flex flex-col">
|
|
|
-
|
|
|
- {/* Card Header */}
|
|
|
- <div className="bg-slate-50 px-4 py-3 border-b border-slate-100 flex justify-between items-center">
|
|
|
- <div className="flex items-center gap-2 font-bold text-slate-700">
|
|
|
- <Calendar size={16} className="text-blue-500" />
|
|
|
- {formatDate(day.date)}
|
|
|
- </div>
|
|
|
- {/* 优化徽章:有时间显示数量,没时间显示 Available */}
|
|
|
- <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
|
|
- hasTimes ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'
|
|
|
- }`}>
|
|
|
- {hasTimes ? `${times.length} ${t('slots.slots_count')}` : t('slots.status_available')}
|
|
|
- </span>
|
|
|
- </div>
|
|
|
-
|
|
|
- {/* Card Body */}
|
|
|
- <div className="p-4 flex-1 flex flex-col justify-center">
|
|
|
- {hasTimes ? (
|
|
|
- // 场景 A: 有具体时间段
|
|
|
- <div className="grid grid-cols-2 gap-2">
|
|
|
- {times.map((slot, tIdx) => (
|
|
|
- <div key={tIdx} className="text-sm border border-slate-100 rounded p-2 text-center hover:border-blue-300 hover:bg-blue-50 transition cursor-default">
|
|
|
- <div className="font-mono font-bold text-slate-800">{slot.time}</div>
|
|
|
- {slot.label && (
|
|
|
- <div className="text-[10px] text-orange-500 font-medium mt-0.5">{slot.label}</div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- ) : (
|
|
|
- // 场景 B: 无具体时间段 (Date Only)
|
|
|
- // 显示更友好的提示或跳转按钮
|
|
|
- <div className="text-center space-y-3">
|
|
|
- <p className="text-xs text-slate-500">
|
|
|
- {/* 翻译:该日期已开放预约,具体时间请前往官网查看 */}
|
|
|
- {lang === 'zh' ? '该日期已开放预约,具体时间请前往官网查看' : 'Slots available. Please check details on the official website.'}
|
|
|
- </p>
|
|
|
- {snapshot.website && (
|
|
|
- <a
|
|
|
- href={snapshot.website}
|
|
|
- target="_blank"
|
|
|
- rel="noopener noreferrer"
|
|
|
- className="flex items-center justify-center gap-2 w-full py-2 bg-blue-600 text-white rounded-lg text-sm font-bold hover:bg-blue-700 transition"
|
|
|
- >
|
|
|
- {lang === 'zh' ? '前往官网预约' : 'Book on Website'} <ExternalLink size={14} />
|
|
|
- </a>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- )}
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- );
|
|
|
- })}
|
|
|
- </div>
|
|
|
- )}
|
|
|
+ <h3 className="text-slate-900 font-bold mb-1">暂无监控数据</h3>
|
|
|
+ <p className="text-slate-500 text-sm">当前城市没有被监控的签证服务</p>
|
|
|
</div>
|
|
|
) : (
|
|
|
- !loading && (
|
|
|
- <div className="text-center py-20 text-slate-400">
|
|
|
- <Search size={48} className="mx-auto mb-4 opacity-20" />
|
|
|
- <p>{t('slots.select_hint')}</p>
|
|
|
- </div>
|
|
|
- )
|
|
|
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
|
|
+ {snapshots.map((slot) => (
|
|
|
+ <CitySlotCard key={slot.id} data={slot} />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
)}
|
|
|
|
|
|
</div>
|