TicketList.tsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. 'use client';
  2. import { useEffect, useState } from 'react';
  3. import api from '@/lib/api';
  4. import { Loader2, MessageSquare, AlertCircle, Clock, CheckCircle, XCircle, ArrowRight, Eye, Search, FileText } from 'lucide-react';
  5. import Pagination from '@/components/common/Pagination';
  6. import { UserTicket } from './UserTicketDetailModal';
  7. import { useLanguage } from '@/lib/i18n/LanguageContext';
  8. // 1. 引入 LocalTime 组件
  9. import LocalTime from '@/components/common/LocalTime';
  10. interface TicketListProps {
  11. onViewDetail: (ticket: UserTicket) => void;
  12. refreshTrigger?: number;
  13. }
  14. interface TicketData extends UserTicket {
  15. id: number;
  16. order_id: string;
  17. type: 'refund' | 'dispute' | 'change_request';
  18. reason: string;
  19. status: 'pending' | 'info_required' | 'resolved' | 'rejected';
  20. admin_comment?: string;
  21. created_at: string;
  22. }
  23. export default function TicketList({ onViewDetail, refreshTrigger }: TicketListProps) {
  24. const { t } = useLanguage();
  25. const [loading, setLoading] = useState<boolean>(true);
  26. const [tickets, setTickets] = useState<TicketData[]>([]);
  27. const [page, setPage] = useState(1);
  28. const [pageSize] = useState(5);
  29. const [total, setTotal] = useState(0);
  30. const [keyword, setKeyword] = useState('');
  31. useEffect(() => {
  32. fetchTickets(1);
  33. }, [refreshTrigger]);
  34. const fetchTickets = async (targetPage: number) => {
  35. try {
  36. setLoading(true);
  37. const res = await api.get('/api/vas/ticket/list_by_user', {
  38. params: {
  39. page: targetPage,
  40. size: pageSize,
  41. keyword: keyword
  42. }
  43. });
  44. const data = res.data.data;
  45. if (data && Array.isArray(data.items)) {
  46. setTickets(data.items);
  47. setTotal(data.total || 0);
  48. } else {
  49. setTickets([]);
  50. setTotal(0);
  51. }
  52. setPage(targetPage);
  53. } catch (error) {
  54. console.error("Failed to fetch tickets", error);
  55. setTickets([]);
  56. } finally {
  57. setLoading(false);
  58. }
  59. };
  60. const handleSearch = () => {
  61. fetchTickets(1);
  62. };
  63. const handleKeyDown = (e: React.KeyboardEvent) => {
  64. if (e.key === 'Enter') handleSearch();
  65. };
  66. const getStatusConfig = (status: string) => {
  67. const label = t(`ticket.status.${status}`) !== `ticket.status.${status}`
  68. ? t(`ticket.status.${status}`)
  69. : status;
  70. switch (status) {
  71. case 'pending': return { text: label, color: 'text-yellow-700 bg-yellow-50 border-yellow-200', icon: Clock };
  72. case 'info_required': return { text: label, color: 'text-blue-700 bg-blue-50 border-blue-200', icon: AlertCircle };
  73. case 'resolved': return { text: label, color: 'text-green-700 bg-green-50 border-green-200', icon: CheckCircle };
  74. case 'rejected': return { text: label, color: 'text-red-700 bg-red-50 border-red-200', icon: XCircle };
  75. default: return { text: label, color: 'text-gray-600 bg-gray-50 border-gray-200', icon: MessageSquare };
  76. }
  77. };
  78. const getTypeText = (type: string) => {
  79. const key = `ticket.types.${type}`;
  80. return t(key) !== key ? t(key) : type;
  81. };
  82. return (
  83. <div className="space-y-4">
  84. {/* Top Toolbar */}
  85. <div className="flex gap-2 items-center bg-white p-3 rounded-xl shadow-sm border border-slate-200">
  86. <div className="relative flex-1">
  87. <input
  88. type="text"
  89. placeholder={t('ticket.search_placeholder')}
  90. className="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none transition"
  91. value={keyword}
  92. onChange={(e) => setKeyword(e.target.value)}
  93. onKeyDown={handleKeyDown}
  94. />
  95. <Search size={16} className="absolute left-3 top-2.5 text-gray-400" />
  96. </div>
  97. <button
  98. onClick={handleSearch}
  99. className="px-5 py-2 bg-slate-800 text-white rounded-lg text-sm font-medium hover:bg-slate-700 transition shadow-sm"
  100. >
  101. {t('common.search')}
  102. </button>
  103. </div>
  104. {/* Ticket List */}
  105. <div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
  106. <div className="px-6 py-4 border-b border-slate-100 bg-gray-50/50 flex justify-between items-center">
  107. <h2 className="text-sm font-bold uppercase text-slate-500 tracking-wide">{t('ticket.my_tickets')}</h2>
  108. <span className="text-xs font-medium px-2 py-1 bg-white border rounded text-slate-500">{t('common.total')}: {total}</span>
  109. </div>
  110. {loading ? (
  111. <div className="p-16 flex justify-center"><Loader2 className="animate-spin text-blue-600 w-8 h-8" /></div>
  112. ) : tickets.length === 0 ? (
  113. <div className="p-16 text-center text-gray-500 flex flex-col items-center">
  114. <div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
  115. <FileText className="h-8 w-8 text-gray-400" />
  116. </div>
  117. <h3 className="text-lg font-medium text-gray-900">{t('ticket.empty_title')}</h3>
  118. <p className="text-sm text-gray-500 mt-1">{t('ticket.empty_desc')}</p>
  119. </div>
  120. ) : (
  121. <div className="divide-y divide-slate-100">
  122. {tickets.map((ticket) => {
  123. const status = getStatusConfig(ticket.status);
  124. const StatusIcon = status.icon;
  125. const isActionRequired = ticket.status === 'info_required';
  126. return (
  127. <div key={ticket.id} className="p-5 hover:bg-slate-50 transition group">
  128. <div className="flex flex-col sm:flex-row gap-4">
  129. {/* Left Info */}
  130. <div className="flex-1">
  131. <div className="flex items-center flex-wrap gap-2 mb-2">
  132. <span className="font-bold text-gray-900 text-base">{getTypeText(ticket.type)}</span>
  133. <span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${status.color}`}>
  134. <StatusIcon size={12} /> {status.text}
  135. </span>
  136. <span className="text-xs text-gray-400 font-mono">#{ticket.id}</span>
  137. </div>
  138. <div className="text-sm text-gray-600 mb-3 line-clamp-2">
  139. {ticket.reason}
  140. </div>
  141. <div className="flex items-center gap-4 text-xs text-gray-400">
  142. <span className="flex items-center gap-1">
  143. <FileText size={12} /> {t('ticket.order_id')}: {ticket.order_id}
  144. </span>
  145. <span className="flex items-center gap-1">
  146. <Clock size={12} />
  147. {/* 2. 使用 LocalTime 组件 */}
  148. <LocalTime date={ticket.created_at} />
  149. </span>
  150. </div>
  151. </div>
  152. {/* Right Actions */}
  153. <div className="flex flex-col justify-center items-end gap-2 min-w-[120px]">
  154. {isActionRequired ? (
  155. <button
  156. onClick={() => onViewDetail(ticket)}
  157. className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-bold shadow-sm shadow-blue-200 transition"
  158. >
  159. {t('ticket.reply')} <ArrowRight size={16} />
  160. </button>
  161. ) : (
  162. <button
  163. onClick={() => onViewDetail(ticket)}
  164. className="w-full flex items-center justify-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-slate-600 hover:bg-white hover:border-blue-400 hover:text-blue-600 text-sm font-medium transition bg-slate-50"
  165. >
  166. <Eye size={16} /> {t('ticket.view_details')}
  167. </button>
  168. )}
  169. </div>
  170. </div>
  171. {/* Admin Feedback */}
  172. {ticket.admin_comment && (
  173. <div className="mt-4 bg-slate-100/80 border-l-4 border-blue-400 p-3 rounded-r text-sm text-slate-700 flex gap-2">
  174. <MessageSquare className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" />
  175. <div>
  176. <span className="font-bold text-slate-900 mr-1">{t('ticket.latest_feedback')}:</span>
  177. {ticket.admin_comment}
  178. </div>
  179. </div>
  180. )}
  181. </div>
  182. );
  183. })}
  184. </div>
  185. )}
  186. <Pagination
  187. currentPage={page}
  188. total={total}
  189. pageSize={pageSize}
  190. onPageChange={(p) => fetchTickets(p)}
  191. />
  192. </div>
  193. </div>
  194. );
  195. }