ServiceList.tsx 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. 'use client';
  2. import { useEffect, useState } from 'react';
  3. import { useRouter } from 'next/navigation';
  4. import api from '@/lib/api';
  5. import { Loader2, Search, MapPin, Filter, X, Globe } from 'lucide-react';
  6. import Pagination from '@/components/common/Pagination';
  7. import { useLanguage } from '@/lib/i18n/LanguageContext';
  8. interface Product {
  9. id: number;
  10. title: string;
  11. price_amount: number;
  12. price_currency: string;
  13. description: string;
  14. country: string;
  15. city: string;
  16. visa_type: string;
  17. provider: string;
  18. }
  19. export default function ServiceList() {
  20. const router = useRouter();
  21. const { t } = useLanguage();
  22. const [products, setProducts] = useState<Product[]>([]);
  23. const [loading, setLoading] = useState<boolean>(true);
  24. const [error, setError] = useState<string>('');
  25. const [keyword, setKeyword] = useState('');
  26. const [selectedCountry, setSelectedCountry] = useState('');
  27. const [selectedType, setSelectedType] = useState('');
  28. const [page, setPage] = useState(1);
  29. const [pageSize] = useState(9);
  30. const [total, setTotal] = useState(0);
  31. const countries = ['Austria','Croatia','Denmark','Finland','France','Germany','Greece','Hungary','Iceland','Italy','Netherlands','Poland','Spain'];
  32. const visaTypes = ['Tourist','Business','Family','Student','Work','Transit','e-Visa'];
  33. useEffect(() => {
  34. fetchProducts(1);
  35. }, []);
  36. const fetchProducts = async (targetPage: number) => {
  37. setLoading(true);
  38. setError('');
  39. try {
  40. const res = await api.get('/api/vas/product/list', {
  41. params: {
  42. page: targetPage,
  43. size: pageSize,
  44. keyword: keyword,
  45. country: selectedCountry,
  46. visa_type: selectedType
  47. }
  48. });
  49. const data = res.data.data || {};
  50. if (Array.isArray(data)) {
  51. let filtered = data;
  52. if (keyword) {
  53. const lowerKey = keyword.toLowerCase();
  54. filtered = filtered.filter((p: any) =>
  55. p.title.toLowerCase().includes(lowerKey) ||
  56. p.city?.toLowerCase().includes(lowerKey) ||
  57. p.country?.toLowerCase().includes(lowerKey)
  58. );
  59. }
  60. if (selectedCountry) filtered = filtered.filter((p: any) => p.country === selectedCountry);
  61. if (selectedType) filtered = filtered.filter((p: any) => p.visa_type === selectedType);
  62. setProducts(filtered);
  63. setTotal(filtered.length);
  64. } else {
  65. setProducts(data.items || []);
  66. setTotal(data.total || 0);
  67. }
  68. setPage(targetPage);
  69. } catch (err) {
  70. console.warn("API Error, using mock data");
  71. setProducts([]);
  72. setTotal(0);
  73. } finally {
  74. setLoading(false);
  75. }
  76. };
  77. const handleSearch = () => fetchProducts(1);
  78. const handleReset = () => {
  79. setKeyword('');
  80. setSelectedCountry('');
  81. setSelectedType('');
  82. fetchProducts(1);
  83. };
  84. const handleOrderClick = (id: number) => {
  85. router.push(`/create-order/${id}`);
  86. };
  87. return (
  88. <div className="space-y-8">
  89. {/* === 筛选工具栏:响应式调整 === */}
  90. <div className="bg-white p-5 rounded-xl shadow-sm border border-slate-200">
  91. <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
  92. {/* 搜索框 */}
  93. <div className="relative md:col-span-1">
  94. <input
  95. type="text"
  96. placeholder={t('services.search_placeholder')}
  97. 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"
  98. value={keyword}
  99. onChange={(e) => setKeyword(e.target.value)}
  100. onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
  101. />
  102. <Search size={18} className="absolute left-3 top-3.5 md:top-3 text-slate-400" />
  103. </div>
  104. {/* 国家筛选 */}
  105. <div className="relative md:col-span-1">
  106. <select
  107. 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 appearance-none bg-white text-slate-700"
  108. value={selectedCountry}
  109. onChange={(e) => setSelectedCountry(e.target.value)}
  110. >
  111. <option value="">{t('services.all_countries')}</option>
  112. {countries.map(c => <option key={c} value={c}>{c}</option>)}
  113. </select>
  114. <Globe size={18} className="absolute left-3 top-3.5 md:top-3 text-slate-400" />
  115. </div>
  116. {/* 类型筛选 */}
  117. <div className="relative md:col-span-1">
  118. <select
  119. 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 appearance-none bg-white text-slate-700"
  120. value={selectedType}
  121. onChange={(e) => setSelectedType(e.target.value)}
  122. >
  123. <option value="">{t('services.all_types')}</option>
  124. {visaTypes.map(t => <option key={t} value={t}>{t}</option>)}
  125. </select>
  126. <Filter size={18} className="absolute left-3 top-3.5 md:top-3 text-slate-400" />
  127. </div>
  128. {/* 按钮组 */}
  129. <div className="flex gap-2 md:col-span-1">
  130. <button
  131. onClick={handleSearch}
  132. className="flex-1 bg-slate-900 text-white rounded-lg text-sm font-bold hover:bg-slate-800 transition shadow-sm flex items-center justify-center gap-2 py-3 md:py-2.5 active:scale-95"
  133. >
  134. <Search size={16} /> {t('common.search')}
  135. </button>
  136. {(keyword || selectedCountry || selectedType) && (
  137. <button
  138. onClick={handleReset}
  139. className="px-4 border border-slate-300 text-slate-500 rounded-lg hover:bg-slate-50 hover:text-red-500 transition py-3 md:py-2.5 active:scale-95"
  140. title={t('services.reset_filter')}
  141. >
  142. <X size={18} />
  143. </button>
  144. )}
  145. </div>
  146. </div>
  147. </div>
  148. {/* === 商品列表:响应式调整 === */}
  149. {loading ? (
  150. <div className="flex justify-center p-20">
  151. <Loader2 className="animate-spin text-blue-600 w-8 h-8" />
  152. </div>
  153. ) : error ? (
  154. <div className="text-center text-red-500 p-10 bg-red-50 rounded-xl border border-red-100">{error}</div>
  155. ) : products.length === 0 ? (
  156. <div className="text-center py-20 bg-white rounded-xl border border-dashed border-slate-200">
  157. <div className="mx-auto w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mb-4">
  158. <Search className="text-slate-300" size={32} />
  159. </div>
  160. <h3 className="text-slate-900 font-bold mb-1">{t('services.no_result_title')}</h3>
  161. <p className="text-slate-500 text-sm">{t('services.no_result_desc')}</p>
  162. </div>
  163. ) : (
  164. // 修改:移动端 grid-cols-1,平板 grid-cols-2,桌面 grid-cols-3
  165. <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
  166. {products.map((item) => (
  167. <div key={item.id} className="bg-white p-6 rounded-xl shadow-sm border border-slate-100 hover:border-blue-300 hover:shadow-md transition flex flex-col group h-full">
  168. <div className="flex justify-between items-start mb-4">
  169. <div className="flex flex-wrap gap-2">
  170. <div className="bg-blue-50 text-blue-700 px-2.5 py-1 rounded text-xs font-bold flex items-center gap-1.5 border border-blue-100">
  171. <MapPin size={12} className="flex-shrink-0" />
  172. <span>{item.country}</span>
  173. {item.city && (
  174. <>
  175. <span className="text-blue-300">/</span>
  176. <span className="text-blue-800">{item.city}</span>
  177. </>
  178. )}
  179. </div>
  180. <div className="bg-slate-100 text-slate-600 px-2.5 py-1 rounded text-xs font-medium border border-slate-200">
  181. {item.visa_type}
  182. </div>
  183. </div>
  184. </div>
  185. <h2 className="text-lg font-bold mb-2 text-slate-900 group-hover:text-blue-600 transition-colors line-clamp-2 leading-snug" title={item.title}>
  186. {item.title}
  187. </h2>
  188. <p className="text-slate-500 mb-6 text-sm flex-grow line-clamp-2">
  189. {item.description || t('services.no_desc')}
  190. </p>
  191. <div className="flex items-center justify-between mt-auto pt-4 border-t border-slate-50">
  192. <div className="flex flex-col">
  193. <span className="text-xs text-slate-400 font-medium">{t('services.service_fee')}</span>
  194. <span className="text-lg font-bold text-slate-900 leading-none">
  195. <span className="text-xs font-normal mr-0.5">{item.price_currency === 'CNY' ? '¥' : item.price_currency}</span>
  196. {(item.price_amount / 100).toLocaleString()}
  197. </span>
  198. </div>
  199. <button
  200. onClick={() => handleOrderClick(item.id)}
  201. className="px-5 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition font-bold text-sm shadow-sm shadow-blue-200 active:scale-95"
  202. >
  203. {t('services.apply_now')}
  204. </button>
  205. </div>
  206. </div>
  207. ))}
  208. </div>
  209. )}
  210. <Pagination
  211. currentPage={page}
  212. total={total}
  213. pageSize={pageSize}
  214. onPageChange={(p) => fetchProducts(p)}
  215. />
  216. </div>
  217. );
  218. }