169 lines
5.6 KiB
TypeScript
169 lines
5.6 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import { t } from '@/i18n/locales';
|
|
import { useApi } from '@/lib/use-api';
|
|
|
|
/**
|
|
* SMS 发送日志查看页面
|
|
*
|
|
* 管理员可按手机号搜索 SMS 发送记录
|
|
*/
|
|
|
|
interface SmsLogItem {
|
|
id: string;
|
|
phone: string;
|
|
type: string;
|
|
status: string;
|
|
provider: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface SmsLogsResponse {
|
|
items: SmsLogItem[];
|
|
total: number;
|
|
}
|
|
|
|
const loadingBox: React.CSSProperties = {
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
padding: 40, color: 'var(--color-text-tertiary)', font: 'var(--text-body)',
|
|
};
|
|
|
|
export const SmsLogPage: React.FC = () => {
|
|
const [phone, setPhone] = useState('');
|
|
const [searchPhone, setSearchPhone] = useState('');
|
|
const [limit] = useState(20);
|
|
const [offset] = useState(0);
|
|
|
|
const { data, isLoading, error } = useApi<SmsLogsResponse>(
|
|
searchPhone ? '/api/v1/admin/sms/logs' : null,
|
|
{ params: { phone: searchPhone, limit, offset } },
|
|
);
|
|
|
|
const logs = data?.items ?? [];
|
|
|
|
const handleSearch = () => {
|
|
setSearchPhone(phone.trim());
|
|
};
|
|
|
|
const typeBadge = (type: string) => {
|
|
const map: Record<string, { bg: string; color: string; label: string }> = {
|
|
REGISTER: { bg: 'var(--color-primary)', color: 'white', label: t('sms_type_register') },
|
|
LOGIN: { bg: 'var(--color-info)', color: 'white', label: t('sms_type_login') },
|
|
RESET_PASSWORD: { bg: 'var(--color-warning)', color: 'white', label: t('sms_type_reset') },
|
|
CHANGE_PHONE: { bg: 'var(--color-success)', color: 'white', label: t('sms_type_change_phone') },
|
|
};
|
|
const m = map[type] ?? { bg: 'var(--color-gray-400)', color: 'white', label: type };
|
|
return (
|
|
<span style={{
|
|
padding: '2px 8px',
|
|
borderRadius: 'var(--radius-full)',
|
|
background: `${m.bg}20`,
|
|
color: m.bg,
|
|
font: 'var(--text-label-sm)',
|
|
fontWeight: 600,
|
|
}}>{m.label}</span>
|
|
);
|
|
};
|
|
|
|
const statusBadge = (status: string) => {
|
|
const isSuccess = status === 'SUCCESS' || status === 'SENT';
|
|
return (
|
|
<span style={{
|
|
padding: '2px 8px',
|
|
borderRadius: 'var(--radius-full)',
|
|
background: isSuccess ? 'var(--color-success-light)' : 'var(--color-error-light)',
|
|
color: isSuccess ? 'var(--color-success)' : 'var(--color-error)',
|
|
font: 'var(--text-label-sm)',
|
|
fontWeight: 600,
|
|
}}>{isSuccess ? t('sms_status_sent') : t('sms_status_failed')}</span>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<h1 style={{ font: 'var(--text-h1)', marginBottom: 24 }}>{t('sms_log_title')}</h1>
|
|
|
|
{/* Search */}
|
|
<div style={{ display: 'flex', gap: 12, marginBottom: 20 }}>
|
|
<input
|
|
placeholder={t('sms_search_placeholder')}
|
|
value={phone}
|
|
onChange={e => setPhone(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
|
style={{
|
|
flex: 1,
|
|
maxWidth: 360,
|
|
height: 40,
|
|
border: '1px solid var(--color-border)',
|
|
borderRadius: 'var(--radius-sm)',
|
|
padding: '0 16px',
|
|
font: 'var(--text-body)',
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={handleSearch}
|
|
style={{
|
|
padding: '8px 20px',
|
|
border: 'none',
|
|
borderRadius: 'var(--radius-sm)',
|
|
background: 'var(--color-primary)',
|
|
color: 'white',
|
|
cursor: 'pointer',
|
|
font: 'var(--text-body)',
|
|
}}
|
|
>{t('search')}</button>
|
|
</div>
|
|
|
|
{/* Hint when no search */}
|
|
{!searchPhone && (
|
|
<div style={loadingBox}>{t('sms_search_hint')}</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
{searchPhone && (error ? (
|
|
<div style={loadingBox}>Error: {error.message}</div>
|
|
) : isLoading ? (
|
|
<div style={loadingBox}>Loading...</div>
|
|
) : (
|
|
<div style={{
|
|
background: 'var(--color-surface)',
|
|
borderRadius: 'var(--radius-md)',
|
|
border: '1px solid var(--color-border-light)',
|
|
overflow: 'hidden',
|
|
}}>
|
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{ background: 'var(--color-gray-50)', borderBottom: '1px solid var(--color-border)' }}>
|
|
{[t('sms_log_phone'), t('sms_log_type'), t('sms_log_status'), t('sms_log_provider'), t('sms_log_time')].map(h => (
|
|
<th key={h} style={{
|
|
font: 'var(--text-label-sm)',
|
|
color: 'var(--color-text-tertiary)',
|
|
padding: '12px 14px',
|
|
textAlign: 'left',
|
|
}}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{logs.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} style={{ ...loadingBox, padding: '24px 14px' }}>{t('sms_no_logs')}</td>
|
|
</tr>
|
|
) : logs.map(log => (
|
|
<tr key={log.id} style={{ borderBottom: '1px solid var(--color-border-light)' }}>
|
|
<td style={{ font: 'var(--text-body-sm)', padding: '12px 14px' }}>{log.phone}</td>
|
|
<td style={{ padding: '12px 14px' }}>{typeBadge(log.type)}</td>
|
|
<td style={{ padding: '12px 14px' }}>{statusBadge(log.status)}</td>
|
|
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '12px 14px' }}>{log.provider}</td>
|
|
<td style={{ font: 'var(--text-body-sm)', color: 'var(--color-text-tertiary)', padding: '12px 14px' }}>{log.createdAt}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|