| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219 |
- 'use client';
- import { useEffect, useState } from 'react';
- import api from '@/lib/api';
- import { Loader2, MessageSquare, AlertCircle, Clock, CheckCircle, XCircle, ArrowRight, Eye, Search, FileText } from 'lucide-react';
- import Pagination from '@/components/common/Pagination';
- import { UserTicket } from './UserTicketDetailModal';
- import { useLanguage } from '@/lib/i18n/LanguageContext';
- // 1. 引入 LocalTime 组件
- import LocalTime from '@/components/common/LocalTime';
- interface TicketListProps {
- onViewDetail: (ticket: UserTicket) => void;
- refreshTrigger?: number;
- }
- interface TicketData extends UserTicket {
- id: number;
- order_id: string;
- type: 'refund' | 'dispute' | 'change_request';
- reason: string;
- status: 'pending' | 'info_required' | 'resolved' | 'rejected';
- admin_comment?: string;
- created_at: string;
- }
- export default function TicketList({ onViewDetail, refreshTrigger }: TicketListProps) {
- const { t } = useLanguage();
- const [loading, setLoading] = useState<boolean>(true);
- const [tickets, setTickets] = useState<TicketData[]>([]);
-
- const [page, setPage] = useState(1);
- const [pageSize] = useState(5);
- const [total, setTotal] = useState(0);
- const [keyword, setKeyword] = useState('');
- useEffect(() => {
- fetchTickets(1);
- }, [refreshTrigger]);
- const fetchTickets = async (targetPage: number) => {
- try {
- setLoading(true);
- const res = await api.get('/api/vas/ticket/list_by_user', {
- params: {
- page: targetPage,
- size: pageSize,
- keyword: keyword
- }
- });
- const data = res.data.data;
- if (data && Array.isArray(data.items)) {
- setTickets(data.items);
- setTotal(data.total || 0);
- } else {
- setTickets([]);
- setTotal(0);
- }
- setPage(targetPage);
- } catch (error) {
- console.error("Failed to fetch tickets", error);
- setTickets([]);
- } finally {
- setLoading(false);
- }
- };
- const handleSearch = () => {
- fetchTickets(1);
- };
- const handleKeyDown = (e: React.KeyboardEvent) => {
- if (e.key === 'Enter') handleSearch();
- };
- const getStatusConfig = (status: string) => {
- const label = t(`ticket.status.${status}`) !== `ticket.status.${status}`
- ? t(`ticket.status.${status}`)
- : status;
- switch (status) {
- case 'pending': return { text: label, color: 'text-yellow-700 bg-yellow-50 border-yellow-200', icon: Clock };
- case 'info_required': return { text: label, color: 'text-blue-700 bg-blue-50 border-blue-200', icon: AlertCircle };
- case 'resolved': return { text: label, color: 'text-green-700 bg-green-50 border-green-200', icon: CheckCircle };
- case 'rejected': return { text: label, color: 'text-red-700 bg-red-50 border-red-200', icon: XCircle };
- default: return { text: label, color: 'text-gray-600 bg-gray-50 border-gray-200', icon: MessageSquare };
- }
- };
- const getTypeText = (type: string) => {
- const key = `ticket.types.${type}`;
- return t(key) !== key ? t(key) : type;
- };
- return (
- <div className="space-y-4">
-
- {/* Top Toolbar */}
- <div className="flex gap-2 items-center bg-white p-3 rounded-xl shadow-sm border border-slate-200">
- <div className="relative flex-1">
- <input
- type="text"
- placeholder={t('ticket.search_placeholder')}
- 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"
- value={keyword}
- onChange={(e) => setKeyword(e.target.value)}
- onKeyDown={handleKeyDown}
- />
- <Search size={16} className="absolute left-3 top-2.5 text-gray-400" />
- </div>
- <button
- onClick={handleSearch}
- className="px-5 py-2 bg-slate-800 text-white rounded-lg text-sm font-medium hover:bg-slate-700 transition shadow-sm"
- >
- {t('common.search')}
- </button>
- </div>
- {/* Ticket List */}
- <div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
- <div className="px-6 py-4 border-b border-slate-100 bg-gray-50/50 flex justify-between items-center">
- <h2 className="text-sm font-bold uppercase text-slate-500 tracking-wide">{t('ticket.my_tickets')}</h2>
- <span className="text-xs font-medium px-2 py-1 bg-white border rounded text-slate-500">{t('common.total')}: {total}</span>
- </div>
- {loading ? (
- <div className="p-16 flex justify-center"><Loader2 className="animate-spin text-blue-600 w-8 h-8" /></div>
- ) : tickets.length === 0 ? (
- <div className="p-16 text-center text-gray-500 flex flex-col items-center">
- <div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
- <FileText className="h-8 w-8 text-gray-400" />
- </div>
- <h3 className="text-lg font-medium text-gray-900">{t('ticket.empty_title')}</h3>
- <p className="text-sm text-gray-500 mt-1">{t('ticket.empty_desc')}</p>
- </div>
- ) : (
- <div className="divide-y divide-slate-100">
- {tickets.map((ticket) => {
- const status = getStatusConfig(ticket.status);
- const StatusIcon = status.icon;
- const isActionRequired = ticket.status === 'info_required';
-
- return (
- <div key={ticket.id} className="p-5 hover:bg-slate-50 transition group">
- <div className="flex flex-col sm:flex-row gap-4">
- {/* Left Info */}
- <div className="flex-1">
- <div className="flex items-center flex-wrap gap-2 mb-2">
- <span className="font-bold text-gray-900 text-base">{getTypeText(ticket.type)}</span>
- <span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium border ${status.color}`}>
- <StatusIcon size={12} /> {status.text}
- </span>
- <span className="text-xs text-gray-400 font-mono">#{ticket.id}</span>
- </div>
-
- <div className="text-sm text-gray-600 mb-3 line-clamp-2">
- {ticket.reason}
- </div>
- <div className="flex items-center gap-4 text-xs text-gray-400">
- <span className="flex items-center gap-1">
- <FileText size={12} /> {t('ticket.order_id')}: {ticket.order_id}
- </span>
- <span className="flex items-center gap-1">
- <Clock size={12} />
- {/* 2. 使用 LocalTime 组件 */}
- <LocalTime date={ticket.created_at} />
- </span>
- </div>
- </div>
- {/* Right Actions */}
- <div className="flex flex-col justify-center items-end gap-2 min-w-[120px]">
- {isActionRequired ? (
- <button
- onClick={() => onViewDetail(ticket)}
- 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"
- >
- {t('ticket.reply')} <ArrowRight size={16} />
- </button>
- ) : (
- <button
- onClick={() => onViewDetail(ticket)}
- 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"
- >
- <Eye size={16} /> {t('ticket.view_details')}
- </button>
- )}
- </div>
- </div>
- {/* Admin Feedback */}
- {ticket.admin_comment && (
- <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">
- <MessageSquare className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" />
- <div>
- <span className="font-bold text-slate-900 mr-1">{t('ticket.latest_feedback')}:</span>
- {ticket.admin_comment}
- </div>
- </div>
- )}
- </div>
- );
- })}
- </div>
- )}
-
- <Pagination
- currentPage={page}
- total={total}
- pageSize={pageSize}
- onPageChange={(p) => fetchTickets(p)}
- />
- </div>
- </div>
- );
- }
|