| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789 |
- 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 = <T,>(): Paged<T> => ({
- items: [],
- total: 0,
- page: 1,
- page_size: 20,
- });
- const applicationLabels: Record<DirectoryApplicationStatus, string> = {
- none: '学生',
- pending: '待审批',
- approved: '已批准',
- rejected: '已拒绝',
- revoked: '已撤销',
- };
- const applicationColors: Record<DirectoryApplicationStatus, string> = {
- none: 'default',
- pending: 'processing',
- approved: 'success',
- rejected: 'error',
- revoked: 'warning',
- };
- const eventLabels: Record<string, string> = {
- 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<Paged<DirectoryProfile>>(emptyPage());
- const [profiles, setProfiles] = useState<Paged<DirectoryProfile>>(emptyPage());
- const [locations, setLocations] = useState<Paged<DirectoryLocation>>(emptyPage());
- const [events, setEvents] = useState<Paged<DirectoryEvent>>(emptyPage());
- const [settings, setSettings] = useState<DirectorySettings | null>(null);
- const [loading, setLoading] = useState(false);
- const [query, setQuery] = useState('');
- const [applicationStatus, setApplicationStatus] = useState('pending');
- const [actionTarget, setActionTarget] = useState<ActionTarget | null>(null);
- const [locationTarget, setLocationTarget] = useState<DirectoryLocation | null>(null);
- const [actionForm] = Form.useForm<{ reason: string }>();
- const [locationForm] = Form.useForm<{
- longitude: number;
- latitude: number;
- reason: string;
- }>();
- const [settingsForm] = Form.useForm<DirectorySettings>();
- const loadApplications = async (page = 1) => {
- const params = new URLSearchParams({
- page: String(page),
- page_size: '20',
- status: applicationStatus,
- query,
- });
- setApplications(
- await apiRequest<Paged<DirectoryProfile>>(`/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<Paged<DirectoryProfile>>(`/directory/profiles?${params}`),
- );
- };
- const loadLocations = async (page = 1) => {
- const params = new URLSearchParams({
- page: String(page),
- page_size: '20',
- query,
- });
- setLocations(
- await apiRequest<Paged<DirectoryLocation>>(`/directory/locations?${params}`),
- );
- };
- const loadEvents = async (page = 1) => {
- const params = new URLSearchParams({
- page: String(page),
- page_size: '20',
- query,
- });
- setEvents(await apiRequest<Paged<DirectoryEvent>>(`/directory/events?${params}`));
- };
- const loadSettings = async () => {
- const result = await apiRequest<DirectorySettings>('/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<DirectoryProfile> = [
- {
- title: '成员',
- minWidth: 210,
- render: (_, item) => (
- <Space direction="vertical" size={0}>
- <Typography.Text strong>{item.display_name || `用户 ${item.user_id}`}</Typography.Text>
- <Typography.Text type="secondary">
- {item.username ? `@${item.username}` : '未设置用户名'} · {item.user_id}
- </Typography.Text>
- </Space>
- ),
- },
- {
- title: '状态',
- dataIndex: 'application_status',
- width: 110,
- render: (value: DirectoryApplicationStatus) => (
- <Tag color={applicationColors[value]}>{applicationLabels[value]}</Tag>
- ),
- },
- {
- 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) => (
- <Space>
- {item.application_status !== 'approved' ? (
- <Button
- type="link"
- icon={<UserRoundCheck size={16} />}
- onClick={() => approve(item)}
- >
- 批准
- </Button>
- ) : null}
- {item.application_status === 'pending' ? (
- <Button
- type="link"
- danger
- icon={<UserRoundX size={16} />}
- onClick={() =>
- openAction({
- kind: 'application',
- action: 'reject',
- user_id: item.user_id,
- display_name: item.display_name,
- })
- }
- >
- 拒绝
- </Button>
- ) : null}
- </Space>
- ),
- },
- ];
- const profileColumns: ColumnsType<DirectoryProfile> = [
- applicationColumns[0],
- {
- title: '榜单',
- dataIndex: 'listed',
- width: 90,
- render: (value) => <Tag color={value ? 'success' : 'default'}>{value ? '已上榜' : '未上榜'}</Tag>,
- },
- {
- title: '上下线',
- dataIndex: 'online',
- width: 90,
- render: (value) => <Tag color={value ? 'processing' : 'default'}>{value ? '在线' : '离线'}</Tag>,
- },
- {
- 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) => (
- <Space wrap>
- <Button
- size="small"
- onClick={() =>
- openAction({
- kind: 'profile',
- action: item.listed ? 'unlist' : 'list',
- user_id: item.user_id,
- display_name: item.display_name,
- })
- }
- >
- {item.listed ? '强制下榜' : '强制上榜'}
- </Button>
- <Button
- size="small"
- onClick={() =>
- openAction({
- kind: 'profile',
- action: item.online ? 'offline' : 'online',
- user_id: item.user_id,
- display_name: item.display_name,
- })
- }
- >
- {item.online ? '强制下线' : '强制上线'}
- </Button>
- <Button
- size="small"
- danger
- onClick={() =>
- openAction({
- kind: 'application',
- action: 'revoke',
- user_id: item.user_id,
- display_name: item.display_name,
- })
- }
- >
- 撤销资格
- </Button>
- </Space>
- ),
- },
- ];
- const locationColumns: ColumnsType<DirectoryLocation> = [
- {
- title: '成员',
- minWidth: 210,
- render: (_, item) => (
- <Space direction="vertical" size={0}>
- <Typography.Text strong>{item.display_name || `用户 ${item.user_id}`}</Typography.Text>
- <Typography.Text type="secondary">{item.username ? `@${item.username}` : item.user_id}</Typography.Text>
- </Space>
- ),
- },
- {
- title: '精确坐标',
- width: 220,
- render: (_, item) => (
- <Typography.Text className="numeric">
- {item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
- </Typography.Text>
- ),
- },
- {
- title: '区域',
- minWidth: 170,
- render: (_, item) => item.region?.label || '区域暂不可用',
- },
- {
- title: 'Nominatim',
- width: 130,
- render: (_, item) => (
- <Tag color={item.geocode_consent === true ? 'success' : 'default'}>
- {item.geocode_consent === true
- ? '已授权'
- : item.geocode_consent === false
- ? '已关闭'
- : '待选择'}
- </Tag>
- ),
- },
- {
- 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) => (
- <Space>
- <Button
- type="link"
- onClick={() => {
- setLocationTarget(item);
- locationForm.setFieldsValue({
- longitude: item.longitude,
- latitude: item.latitude,
- reason: '',
- });
- }}
- >
- 修正
- </Button>
- <Button
- type="link"
- danger
- onClick={() =>
- openAction({
- kind: 'location',
- action: 'delete',
- user_id: item.user_id,
- display_name: item.display_name,
- })
- }
- >
- 清除
- </Button>
- </Space>
- ),
- },
- ];
- const eventColumns: ColumnsType<DirectoryEvent> = [
- {
- 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) => <Tag>{eventLabels[value] || value}</Tag>,
- },
- { 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 = (
- <div className="page-toolbar">
- <Input
- allowClear
- value={query}
- onChange={(event) => setQuery(event.target.value)}
- onPressEnter={() => void refresh()}
- prefix={<Search size={16} />}
- placeholder="用户 ID、@username 或昵称"
- style={{ width: 'min(360px, 100%)' }}
- />
- <Button icon={<Search size={16} />} onClick={() => void refresh()}>
- 查询
- </Button>
- </div>
- );
- return (
- <>
- <PageHeader
- title="师生与附近"
- actions={
- <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void refresh()}>
- 刷新
- </Button>
- }
- />
- <Alert
- showIcon
- type="warning"
- message="位置属于敏感信息"
- description="精确坐标仅供 Web 超级管理员修正;Telegram 榜单只显示距离和省市区。所有状态操作均为全平台生效。"
- style={{ marginBottom: 16 }}
- />
- <Surface>
- <Tabs
- items={[
- {
- key: 'applications',
- label: `申请审批 (${applications.total})`,
- children: (
- <>
- <div className="page-toolbar">
- <Select
- value={applicationStatus}
- onChange={setApplicationStatus}
- style={{ width: 150 }}
- options={[
- { value: 'pending', label: '待审批' },
- { value: 'rejected', label: '已拒绝' },
- { value: 'revoked', label: '已撤销' },
- { value: 'approved', label: '已批准' },
- ]}
- />
- {searchToolbar}
- </div>
- <Table
- rowKey="user_id"
- columns={applicationColumns}
- dataSource={applications.items}
- loading={loading}
- pagination={{
- current: applications.page,
- pageSize: applications.page_size,
- total: applications.total,
- showSizeChanger: false,
- }}
- onChange={(pagination) => void loadApplications(pagination.current || 1)}
- scroll={{ x: 850 }}
- />
- </>
- ),
- },
- {
- key: 'teachers',
- label: `老师目录 (${profiles.total})`,
- children: (
- <>
- {searchToolbar}
- <Table
- rowKey="user_id"
- columns={profileColumns}
- dataSource={profiles.items}
- loading={loading}
- pagination={{
- current: profiles.page,
- pageSize: profiles.page_size,
- total: profiles.total,
- showSizeChanger: false,
- }}
- onChange={(pagination) => void loadProfiles(pagination.current || 1)}
- scroll={{ x: 1150 }}
- />
- </>
- ),
- },
- {
- key: 'locations',
- label: `位置管理 (${locations.total})`,
- children: (
- <>
- {searchToolbar}
- <Table
- rowKey="user_id"
- columns={locationColumns}
- dataSource={locations.items}
- loading={loading}
- pagination={{
- current: locations.page,
- pageSize: locations.page_size,
- total: locations.total,
- showSizeChanger: false,
- }}
- onChange={(pagination) => void loadLocations(pagination.current || 1)}
- scroll={{ x: 1050 }}
- />
- </>
- ),
- },
- {
- key: 'events',
- label: `状态事件 (${events.total})`,
- children: (
- <>
- {searchToolbar}
- <Table
- rowKey="event_id"
- columns={eventColumns}
- dataSource={events.items}
- loading={loading}
- pagination={{
- current: events.page,
- pageSize: events.page_size,
- total: events.total,
- showSizeChanger: false,
- }}
- onChange={(pagination) => void loadEvents(pagination.current || 1)}
- scroll={{ x: 1000 }}
- />
- </>
- ),
- },
- {
- key: 'settings',
- label: '目录设置',
- forceRender: true,
- children: (
- <Form
- form={settingsForm}
- layout="vertical"
- onFinish={(values) => {
- Modal.confirm({
- title: '确认更新全平台目录设置?',
- content: '设置会影响所有具备“师生与附近”职责的机器人。',
- okText: '确认保存',
- onOk: async () => {
- const saved = await apiRequest<DirectorySettings>(
- '/directory/settings',
- jsonOptions('PUT', { ...values, confirm: true }),
- );
- setSettings(saved);
- settingsForm.setFieldsValue(saved);
- message.success('目录设置已保存');
- },
- });
- }}
- style={{ maxWidth: 760 }}
- >
- <Alert
- showIcon
- type={settings?.nominatim_ready ? 'success' : 'info'}
- message={
- settings?.nominatim_ready
- ? 'Nominatim 区域解析已就绪'
- : '未填写联系信息时只计算距离,不会发送坐标'
- }
- style={{ marginBottom: 20 }}
- />
- <Form.Item
- name="nominatim_enabled"
- label="启用 OpenStreetMap Nominatim 区域解析"
- valuePropName="checked"
- >
- <Switch />
- </Form.Item>
- <Form.Item
- name="nominatim_contact"
- label="应用联系邮箱或 URL"
- extra="公共 Nominatim 要求 User-Agent 能识别应用;留空时禁止外发坐标。"
- >
- <Input placeholder="admin@example.com" />
- </Form.Item>
- <Form.Item
- name="nominatim_endpoint"
- label="Nominatim 服务地址"
- rules={[{ required: true }]}
- >
- <Input />
- </Form.Item>
- <Form.Item
- name="nominatim_user_agent"
- label="应用 User-Agent"
- rules={[{ required: true }]}
- >
- <Input />
- </Form.Item>
- <Form.Item name="nearby_radius_km" label="默认附近范围">
- <Select
- options={(settings?.nearby_radius_options_km || [5, 10, 20, 50]).map(
- (value) => ({ value, label: `${value} 公里` }),
- )}
- />
- </Form.Item>
- <Space wrap>
- <Button type="primary" htmlType="submit" icon={<Settings2 size={16} />}>
- 保存设置
- </Button>
- <Button
- type="link"
- href={settings?.attribution_url || 'https://www.openstreetmap.org/copyright'}
- target="_blank"
- >
- {settings?.attribution || '© OpenStreetMap contributors'}
- </Button>
- </Space>
- </Form>
- ),
- },
- ]}
- />
- </Surface>
- <Modal
- title={`确认全平台操作:${actionTarget?.display_name || actionTarget?.user_id || ''}`}
- open={Boolean(actionTarget)}
- okText="继续确认"
- okButtonProps={{ danger: true }}
- onCancel={() => setActionTarget(null)}
- onOk={() => actionForm.submit()}
- destroyOnHidden
- >
- <Alert
- showIcon
- type="warning"
- message="此操作将影响全部群和全部 Bot"
- style={{ marginBottom: 16 }}
- />
- <Form form={actionForm} layout="vertical" onFinish={submitAction}>
- <Form.Item
- name="reason"
- label="操作原因"
- rules={[{ required: true, message: '必须填写操作原因' }]}
- >
- <Input.TextArea rows={3} maxLength={500} showCount />
- </Form.Item>
- </Form>
- </Modal>
- <Modal
- title={`修正位置:${locationTarget?.display_name || locationTarget?.user_id || ''}`}
- open={Boolean(locationTarget)}
- okText="继续确认"
- onCancel={() => setLocationTarget(null)}
- onOk={() => locationForm.submit()}
- destroyOnHidden
- >
- <Form
- form={locationForm}
- layout="vertical"
- onFinish={(values) => {
- if (!locationTarget) return;
- Modal.confirm({
- title: '确认保存精确坐标?',
- content: '若该用户已授权区域解析,新坐标会进入 Nominatim 队列。',
- okText: '确认保存',
- onOk: async () => {
- await apiRequest(
- `/directory/locations/${locationTarget.user_id}`,
- jsonOptions('PUT', { ...values, confirm: true }),
- );
- message.success('位置已修正');
- setLocationTarget(null);
- await refresh();
- },
- });
- }}
- >
- <Form.Item
- name="longitude"
- label="经度"
- rules={[{ required: true }]}
- >
- <InputNumber min={-180} max={180} precision={7} style={{ width: '100%' }} />
- </Form.Item>
- <Form.Item
- name="latitude"
- label="纬度"
- rules={[{ required: true }]}
- >
- <InputNumber min={-90} max={90} precision={7} style={{ width: '100%' }} />
- </Form.Item>
- <Form.Item
- name="reason"
- label="修正原因"
- rules={[{ required: true, message: '必须填写修正原因' }]}
- >
- <Input.TextArea rows={3} maxLength={500} showCount />
- </Form.Item>
- </Form>
- </Modal>
- </>
- );
- }
|