import { Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tabs, Tag, Typography, message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import dayjs from 'dayjs'; import { RefreshCw, Search, Settings2, UserRoundCheck, UserRoundX, } from 'lucide-react'; import { useEffect, useState } from 'react'; import { PageHeader, Surface } from '@/components/Page'; import { apiRequest, jsonOptions } from '@/services/api'; import type { DirectoryApplicationStatus, DirectoryEvent, DirectoryLocation, DirectoryProfile, DirectorySettings, Paged, } from '@/types'; const emptyPage = (): Paged => ({ items: [], total: 0, page: 1, page_size: 20, }); const applicationLabels: Record = { none: '学生', pending: '待审批', approved: '已批准', rejected: '已拒绝', revoked: '已撤销', }; const applicationColors: Record = { none: 'default', pending: 'processing', approved: 'success', rejected: 'error', revoked: 'warning', }; const eventLabels: Record = { location_updated: '更新位置', location_cleared: '清除位置', geocode_consent_updated: '区域解析授权', teacher_application_submitted: '提交老师申请', teacher_application_approve: '批准老师', teacher_application_reject: '拒绝申请', teacher_application_revoke: '撤销老师', teacher_list: '上榜', teacher_unlist: '下榜', teacher_online: '上线', teacher_offline: '下线', teacher_auto_offline: '自动下线', teacher_username_removed: '用户名移除', }; type ActionTarget = { kind: 'application' | 'profile' | 'location'; action: string; user_id: string; display_name?: string | null; }; export default function DirectoryPage() { const [applications, setApplications] = useState>(emptyPage()); const [profiles, setProfiles] = useState>(emptyPage()); const [locations, setLocations] = useState>(emptyPage()); const [events, setEvents] = useState>(emptyPage()); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(false); const [query, setQuery] = useState(''); const [applicationStatus, setApplicationStatus] = useState('pending'); const [actionTarget, setActionTarget] = useState(null); const [locationTarget, setLocationTarget] = useState(null); const [actionForm] = Form.useForm<{ reason: string }>(); const [locationForm] = Form.useForm<{ longitude: number; latitude: number; reason: string; }>(); const [settingsForm] = Form.useForm(); const loadApplications = async (page = 1) => { const params = new URLSearchParams({ page: String(page), page_size: '20', status: applicationStatus, query, }); setApplications( await apiRequest>(`/directory/applications?${params}`), ); }; const loadProfiles = async (page = 1) => { const params = new URLSearchParams({ page: String(page), page_size: '20', application_status: 'approved', query, }); setProfiles( await apiRequest>(`/directory/profiles?${params}`), ); }; const loadLocations = async (page = 1) => { const params = new URLSearchParams({ page: String(page), page_size: '20', query, }); setLocations( await apiRequest>(`/directory/locations?${params}`), ); }; const loadEvents = async (page = 1) => { const params = new URLSearchParams({ page: String(page), page_size: '20', query, }); setEvents(await apiRequest>(`/directory/events?${params}`)); }; const loadSettings = async () => { const result = await apiRequest('/directory/settings'); setSettings(result); settingsForm.setFieldsValue(result); }; const refresh = async () => { setLoading(true); try { await Promise.all([ loadApplications(applications.page), loadProfiles(profiles.page), loadLocations(locations.page), loadEvents(events.page), loadSettings(), ]); } catch (error) { message.error(error instanceof Error ? error.message : '目录数据加载失败'); } finally { setLoading(false); } }; useEffect(() => { void refresh(); }, [applicationStatus]); const approve = (item: DirectoryProfile) => { Modal.confirm({ title: `批准 ${item.display_name || item.user_id} 成为老师?`, content: '批准后老师身份、位置和状态将在全部群和全部 Bot 中生效。', okText: '确认批准', onOk: async () => { await apiRequest( `/directory/applications/${item.user_id}/actions`, jsonOptions('POST', { action: 'approve', confirm: true }), ); message.success('老师申请已批准'); await refresh(); }, }); }; const openAction = (target: ActionTarget) => { actionForm.resetFields(); setActionTarget(target); }; const submitAction = async ({ reason }: { reason: string }) => { if (!actionTarget) return; if (actionTarget.kind === 'application') { await apiRequest( `/directory/applications/${actionTarget.user_id}/actions`, jsonOptions('POST', { action: actionTarget.action, reason, confirm: true, }), ); } else if (actionTarget.kind === 'profile') { await apiRequest( `/directory/profiles/${actionTarget.user_id}/actions`, jsonOptions('POST', { action: actionTarget.action, reason, confirm: true, }), ); } else { await apiRequest( `/directory/locations/${actionTarget.user_id}`, jsonOptions('DELETE', { reason, confirm: true }), ); } message.success('全平台目录状态已更新'); setActionTarget(null); await refresh(); }; const applicationColumns: ColumnsType = [ { title: '成员', minWidth: 210, render: (_, item) => ( {item.display_name || `用户 ${item.user_id}`} {item.username ? `@${item.username}` : '未设置用户名'} · {item.user_id} ), }, { title: '状态', dataIndex: 'application_status', width: 110, render: (value: DirectoryApplicationStatus) => ( {applicationLabels[value]} ), }, { title: '位置', minWidth: 170, render: (_, item) => item.location ? item.location.region?.label || '区域暂不可用' : '尚未更新', }, { title: '申请时间', dataIndex: 'applied_at', width: 165, responsive: ['lg'], render: (value) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'), }, { title: '操作', width: 190, fixed: 'right', render: (_, item) => ( {item.application_status !== 'approved' ? ( ) : null} {item.application_status === 'pending' ? ( ) : null} ), }, ]; const profileColumns: ColumnsType = [ applicationColumns[0], { title: '榜单', dataIndex: 'listed', width: 90, render: (value) => {value ? '已上榜' : '未上榜'}, }, { title: '上下线', dataIndex: 'online', width: 90, render: (value) => {value ? '在线' : '离线'}, }, { title: '区域', minWidth: 160, render: (_, item) => item.location?.region?.label || '区域暂不可用', }, { title: '自动下线', dataIndex: 'online_until', width: 165, responsive: ['lg'], render: (value) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'), }, { title: '操作', width: 300, fixed: 'right', render: (_, item) => ( ), }, ]; const locationColumns: ColumnsType = [ { title: '成员', minWidth: 210, render: (_, item) => ( {item.display_name || `用户 ${item.user_id}`} {item.username ? `@${item.username}` : item.user_id} ), }, { title: '精确坐标', width: 220, render: (_, item) => ( {item.latitude.toFixed(6)}, {item.longitude.toFixed(6)} ), }, { title: '区域', minWidth: 170, render: (_, item) => item.region?.label || '区域暂不可用', }, { title: 'Nominatim', width: 130, render: (_, item) => ( {item.geocode_consent === true ? '已授权' : item.geocode_consent === false ? '已关闭' : '待选择'} ), }, { title: '更新时间', dataIndex: 'updated_at', width: 165, responsive: ['lg'], render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm'), }, { title: '操作', width: 150, fixed: 'right', render: (_, item) => ( ), }, ]; const eventColumns: ColumnsType = [ { title: '时间', dataIndex: 'created_at', width: 170, render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'), }, { title: '事件', dataIndex: 'event_type', width: 150, render: (value) => {eventLabels[value] || value}, }, { title: '成员 ID', dataIndex: 'user_id', width: 145, className: 'numeric' }, { title: '操作者', dataIndex: 'actor_name', width: 150, render: (value) => value || '系统' }, { title: '来源', dataIndex: 'source', width: 150, responsive: ['lg'] }, { title: '原因', dataIndex: 'reason', minWidth: 220, ellipsis: true }, ]; const searchToolbar = (
setQuery(event.target.value)} onPressEnter={() => void refresh()} prefix={} placeholder="用户 ID、@username 或昵称" style={{ width: 'min(360px, 100%)' }} />
); return ( <> } loading={loading} onClick={() => void refresh()}> 刷新 } />