Directory.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. import {
  2. Alert,
  3. Button,
  4. Form,
  5. Input,
  6. InputNumber,
  7. Modal,
  8. Select,
  9. Space,
  10. Switch,
  11. Table,
  12. Tabs,
  13. Tag,
  14. Typography,
  15. message,
  16. } from 'antd';
  17. import type { ColumnsType } from 'antd/es/table';
  18. import dayjs from 'dayjs';
  19. import {
  20. RefreshCw,
  21. Search,
  22. Settings2,
  23. UserRoundCheck,
  24. UserRoundX,
  25. } from 'lucide-react';
  26. import { useEffect, useState } from 'react';
  27. import { PageHeader, Surface } from '@/components/Page';
  28. import { apiRequest, jsonOptions } from '@/services/api';
  29. import type {
  30. DirectoryApplicationStatus,
  31. DirectoryEvent,
  32. DirectoryLocation,
  33. DirectoryProfile,
  34. DirectorySettings,
  35. Paged,
  36. } from '@/types';
  37. const emptyPage = <T,>(): Paged<T> => ({
  38. items: [],
  39. total: 0,
  40. page: 1,
  41. page_size: 20,
  42. });
  43. const applicationLabels: Record<DirectoryApplicationStatus, string> = {
  44. none: '学生',
  45. pending: '待审批',
  46. approved: '已批准',
  47. rejected: '已拒绝',
  48. revoked: '已撤销',
  49. };
  50. const applicationColors: Record<DirectoryApplicationStatus, string> = {
  51. none: 'default',
  52. pending: 'processing',
  53. approved: 'success',
  54. rejected: 'error',
  55. revoked: 'warning',
  56. };
  57. const eventLabels: Record<string, string> = {
  58. location_updated: '更新位置',
  59. location_cleared: '清除位置',
  60. geocode_consent_updated: '区域解析授权',
  61. teacher_application_submitted: '提交老师申请',
  62. teacher_application_approve: '批准老师',
  63. teacher_application_reject: '拒绝申请',
  64. teacher_application_revoke: '撤销老师',
  65. teacher_list: '上榜',
  66. teacher_unlist: '下榜',
  67. teacher_online: '上线',
  68. teacher_offline: '下线',
  69. teacher_auto_offline: '自动下线',
  70. teacher_username_removed: '用户名移除',
  71. };
  72. type ActionTarget = {
  73. kind: 'application' | 'profile' | 'location';
  74. action: string;
  75. user_id: string;
  76. display_name?: string | null;
  77. };
  78. export default function DirectoryPage() {
  79. const [applications, setApplications] = useState<Paged<DirectoryProfile>>(emptyPage());
  80. const [profiles, setProfiles] = useState<Paged<DirectoryProfile>>(emptyPage());
  81. const [locations, setLocations] = useState<Paged<DirectoryLocation>>(emptyPage());
  82. const [events, setEvents] = useState<Paged<DirectoryEvent>>(emptyPage());
  83. const [settings, setSettings] = useState<DirectorySettings | null>(null);
  84. const [loading, setLoading] = useState(false);
  85. const [query, setQuery] = useState('');
  86. const [applicationStatus, setApplicationStatus] = useState('pending');
  87. const [actionTarget, setActionTarget] = useState<ActionTarget | null>(null);
  88. const [locationTarget, setLocationTarget] = useState<DirectoryLocation | null>(null);
  89. const [actionForm] = Form.useForm<{ reason: string }>();
  90. const [locationForm] = Form.useForm<{
  91. longitude: number;
  92. latitude: number;
  93. reason: string;
  94. }>();
  95. const [settingsForm] = Form.useForm<DirectorySettings>();
  96. const loadApplications = async (page = 1) => {
  97. const params = new URLSearchParams({
  98. page: String(page),
  99. page_size: '20',
  100. status: applicationStatus,
  101. query,
  102. });
  103. setApplications(
  104. await apiRequest<Paged<DirectoryProfile>>(`/directory/applications?${params}`),
  105. );
  106. };
  107. const loadProfiles = async (page = 1) => {
  108. const params = new URLSearchParams({
  109. page: String(page),
  110. page_size: '20',
  111. application_status: 'approved',
  112. query,
  113. });
  114. setProfiles(
  115. await apiRequest<Paged<DirectoryProfile>>(`/directory/profiles?${params}`),
  116. );
  117. };
  118. const loadLocations = async (page = 1) => {
  119. const params = new URLSearchParams({
  120. page: String(page),
  121. page_size: '20',
  122. query,
  123. });
  124. setLocations(
  125. await apiRequest<Paged<DirectoryLocation>>(`/directory/locations?${params}`),
  126. );
  127. };
  128. const loadEvents = async (page = 1) => {
  129. const params = new URLSearchParams({
  130. page: String(page),
  131. page_size: '20',
  132. query,
  133. });
  134. setEvents(await apiRequest<Paged<DirectoryEvent>>(`/directory/events?${params}`));
  135. };
  136. const loadSettings = async () => {
  137. const result = await apiRequest<DirectorySettings>('/directory/settings');
  138. setSettings(result);
  139. settingsForm.setFieldsValue(result);
  140. };
  141. const refresh = async () => {
  142. setLoading(true);
  143. try {
  144. await Promise.all([
  145. loadApplications(applications.page),
  146. loadProfiles(profiles.page),
  147. loadLocations(locations.page),
  148. loadEvents(events.page),
  149. loadSettings(),
  150. ]);
  151. } catch (error) {
  152. message.error(error instanceof Error ? error.message : '目录数据加载失败');
  153. } finally {
  154. setLoading(false);
  155. }
  156. };
  157. useEffect(() => {
  158. void refresh();
  159. }, [applicationStatus]);
  160. const approve = (item: DirectoryProfile) => {
  161. Modal.confirm({
  162. title: `批准 ${item.display_name || item.user_id} 成为老师?`,
  163. content: '批准后老师身份、位置和状态将在全部群和全部 Bot 中生效。',
  164. okText: '确认批准',
  165. onOk: async () => {
  166. await apiRequest(
  167. `/directory/applications/${item.user_id}/actions`,
  168. jsonOptions('POST', { action: 'approve', confirm: true }),
  169. );
  170. message.success('老师申请已批准');
  171. await refresh();
  172. },
  173. });
  174. };
  175. const openAction = (target: ActionTarget) => {
  176. actionForm.resetFields();
  177. setActionTarget(target);
  178. };
  179. const submitAction = async ({ reason }: { reason: string }) => {
  180. if (!actionTarget) return;
  181. if (actionTarget.kind === 'application') {
  182. await apiRequest(
  183. `/directory/applications/${actionTarget.user_id}/actions`,
  184. jsonOptions('POST', {
  185. action: actionTarget.action,
  186. reason,
  187. confirm: true,
  188. }),
  189. );
  190. } else if (actionTarget.kind === 'profile') {
  191. await apiRequest(
  192. `/directory/profiles/${actionTarget.user_id}/actions`,
  193. jsonOptions('POST', {
  194. action: actionTarget.action,
  195. reason,
  196. confirm: true,
  197. }),
  198. );
  199. } else {
  200. await apiRequest(
  201. `/directory/locations/${actionTarget.user_id}`,
  202. jsonOptions('DELETE', { reason, confirm: true }),
  203. );
  204. }
  205. message.success('全平台目录状态已更新');
  206. setActionTarget(null);
  207. await refresh();
  208. };
  209. const applicationColumns: ColumnsType<DirectoryProfile> = [
  210. {
  211. title: '成员',
  212. minWidth: 210,
  213. render: (_, item) => (
  214. <Space direction="vertical" size={0}>
  215. <Typography.Text strong>{item.display_name || `用户 ${item.user_id}`}</Typography.Text>
  216. <Typography.Text type="secondary">
  217. {item.username ? `@${item.username}` : '未设置用户名'} · {item.user_id}
  218. </Typography.Text>
  219. </Space>
  220. ),
  221. },
  222. {
  223. title: '状态',
  224. dataIndex: 'application_status',
  225. width: 110,
  226. render: (value: DirectoryApplicationStatus) => (
  227. <Tag color={applicationColors[value]}>{applicationLabels[value]}</Tag>
  228. ),
  229. },
  230. {
  231. title: '位置',
  232. minWidth: 170,
  233. render: (_, item) =>
  234. item.location ? item.location.region?.label || '区域暂不可用' : '尚未更新',
  235. },
  236. {
  237. title: '申请时间',
  238. dataIndex: 'applied_at',
  239. width: 165,
  240. responsive: ['lg'],
  241. render: (value) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'),
  242. },
  243. {
  244. title: '操作',
  245. width: 190,
  246. fixed: 'right',
  247. render: (_, item) => (
  248. <Space>
  249. {item.application_status !== 'approved' ? (
  250. <Button
  251. type="link"
  252. icon={<UserRoundCheck size={16} />}
  253. onClick={() => approve(item)}
  254. >
  255. 批准
  256. </Button>
  257. ) : null}
  258. {item.application_status === 'pending' ? (
  259. <Button
  260. type="link"
  261. danger
  262. icon={<UserRoundX size={16} />}
  263. onClick={() =>
  264. openAction({
  265. kind: 'application',
  266. action: 'reject',
  267. user_id: item.user_id,
  268. display_name: item.display_name,
  269. })
  270. }
  271. >
  272. 拒绝
  273. </Button>
  274. ) : null}
  275. </Space>
  276. ),
  277. },
  278. ];
  279. const profileColumns: ColumnsType<DirectoryProfile> = [
  280. applicationColumns[0],
  281. {
  282. title: '榜单',
  283. dataIndex: 'listed',
  284. width: 90,
  285. render: (value) => <Tag color={value ? 'success' : 'default'}>{value ? '已上榜' : '未上榜'}</Tag>,
  286. },
  287. {
  288. title: '上下线',
  289. dataIndex: 'online',
  290. width: 90,
  291. render: (value) => <Tag color={value ? 'processing' : 'default'}>{value ? '在线' : '离线'}</Tag>,
  292. },
  293. {
  294. title: '区域',
  295. minWidth: 160,
  296. render: (_, item) => item.location?.region?.label || '区域暂不可用',
  297. },
  298. {
  299. title: '自动下线',
  300. dataIndex: 'online_until',
  301. width: 165,
  302. responsive: ['lg'],
  303. render: (value) => (value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'),
  304. },
  305. {
  306. title: '操作',
  307. width: 300,
  308. fixed: 'right',
  309. render: (_, item) => (
  310. <Space wrap>
  311. <Button
  312. size="small"
  313. onClick={() =>
  314. openAction({
  315. kind: 'profile',
  316. action: item.listed ? 'unlist' : 'list',
  317. user_id: item.user_id,
  318. display_name: item.display_name,
  319. })
  320. }
  321. >
  322. {item.listed ? '强制下榜' : '强制上榜'}
  323. </Button>
  324. <Button
  325. size="small"
  326. onClick={() =>
  327. openAction({
  328. kind: 'profile',
  329. action: item.online ? 'offline' : 'online',
  330. user_id: item.user_id,
  331. display_name: item.display_name,
  332. })
  333. }
  334. >
  335. {item.online ? '强制下线' : '强制上线'}
  336. </Button>
  337. <Button
  338. size="small"
  339. danger
  340. onClick={() =>
  341. openAction({
  342. kind: 'application',
  343. action: 'revoke',
  344. user_id: item.user_id,
  345. display_name: item.display_name,
  346. })
  347. }
  348. >
  349. 撤销资格
  350. </Button>
  351. </Space>
  352. ),
  353. },
  354. ];
  355. const locationColumns: ColumnsType<DirectoryLocation> = [
  356. {
  357. title: '成员',
  358. minWidth: 210,
  359. render: (_, item) => (
  360. <Space direction="vertical" size={0}>
  361. <Typography.Text strong>{item.display_name || `用户 ${item.user_id}`}</Typography.Text>
  362. <Typography.Text type="secondary">{item.username ? `@${item.username}` : item.user_id}</Typography.Text>
  363. </Space>
  364. ),
  365. },
  366. {
  367. title: '精确坐标',
  368. width: 220,
  369. render: (_, item) => (
  370. <Typography.Text className="numeric">
  371. {item.latitude.toFixed(6)}, {item.longitude.toFixed(6)}
  372. </Typography.Text>
  373. ),
  374. },
  375. {
  376. title: '区域',
  377. minWidth: 170,
  378. render: (_, item) => item.region?.label || '区域暂不可用',
  379. },
  380. {
  381. title: 'Nominatim',
  382. width: 130,
  383. render: (_, item) => (
  384. <Tag color={item.geocode_consent === true ? 'success' : 'default'}>
  385. {item.geocode_consent === true
  386. ? '已授权'
  387. : item.geocode_consent === false
  388. ? '已关闭'
  389. : '待选择'}
  390. </Tag>
  391. ),
  392. },
  393. {
  394. title: '更新时间',
  395. dataIndex: 'updated_at',
  396. width: 165,
  397. responsive: ['lg'],
  398. render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm'),
  399. },
  400. {
  401. title: '操作',
  402. width: 150,
  403. fixed: 'right',
  404. render: (_, item) => (
  405. <Space>
  406. <Button
  407. type="link"
  408. onClick={() => {
  409. setLocationTarget(item);
  410. locationForm.setFieldsValue({
  411. longitude: item.longitude,
  412. latitude: item.latitude,
  413. reason: '',
  414. });
  415. }}
  416. >
  417. 修正
  418. </Button>
  419. <Button
  420. type="link"
  421. danger
  422. onClick={() =>
  423. openAction({
  424. kind: 'location',
  425. action: 'delete',
  426. user_id: item.user_id,
  427. display_name: item.display_name,
  428. })
  429. }
  430. >
  431. 清除
  432. </Button>
  433. </Space>
  434. ),
  435. },
  436. ];
  437. const eventColumns: ColumnsType<DirectoryEvent> = [
  438. {
  439. title: '时间',
  440. dataIndex: 'created_at',
  441. width: 170,
  442. render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm:ss'),
  443. },
  444. {
  445. title: '事件',
  446. dataIndex: 'event_type',
  447. width: 150,
  448. render: (value) => <Tag>{eventLabels[value] || value}</Tag>,
  449. },
  450. { title: '成员 ID', dataIndex: 'user_id', width: 145, className: 'numeric' },
  451. { title: '操作者', dataIndex: 'actor_name', width: 150, render: (value) => value || '系统' },
  452. { title: '来源', dataIndex: 'source', width: 150, responsive: ['lg'] },
  453. { title: '原因', dataIndex: 'reason', minWidth: 220, ellipsis: true },
  454. ];
  455. const searchToolbar = (
  456. <div className="page-toolbar">
  457. <Input
  458. allowClear
  459. value={query}
  460. onChange={(event) => setQuery(event.target.value)}
  461. onPressEnter={() => void refresh()}
  462. prefix={<Search size={16} />}
  463. placeholder="用户 ID、@username 或昵称"
  464. style={{ width: 'min(360px, 100%)' }}
  465. />
  466. <Button icon={<Search size={16} />} onClick={() => void refresh()}>
  467. 查询
  468. </Button>
  469. </div>
  470. );
  471. return (
  472. <>
  473. <PageHeader
  474. title="师生与附近"
  475. actions={
  476. <Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void refresh()}>
  477. 刷新
  478. </Button>
  479. }
  480. />
  481. <Alert
  482. showIcon
  483. type="warning"
  484. message="位置属于敏感信息"
  485. description="精确坐标仅供 Web 超级管理员修正;Telegram 榜单只显示距离和省市区。所有状态操作均为全平台生效。"
  486. style={{ marginBottom: 16 }}
  487. />
  488. <Surface>
  489. <Tabs
  490. items={[
  491. {
  492. key: 'applications',
  493. label: `申请审批 (${applications.total})`,
  494. children: (
  495. <>
  496. <div className="page-toolbar">
  497. <Select
  498. value={applicationStatus}
  499. onChange={setApplicationStatus}
  500. style={{ width: 150 }}
  501. options={[
  502. { value: 'pending', label: '待审批' },
  503. { value: 'rejected', label: '已拒绝' },
  504. { value: 'revoked', label: '已撤销' },
  505. { value: 'approved', label: '已批准' },
  506. ]}
  507. />
  508. {searchToolbar}
  509. </div>
  510. <Table
  511. rowKey="user_id"
  512. columns={applicationColumns}
  513. dataSource={applications.items}
  514. loading={loading}
  515. pagination={{
  516. current: applications.page,
  517. pageSize: applications.page_size,
  518. total: applications.total,
  519. showSizeChanger: false,
  520. }}
  521. onChange={(pagination) => void loadApplications(pagination.current || 1)}
  522. scroll={{ x: 850 }}
  523. />
  524. </>
  525. ),
  526. },
  527. {
  528. key: 'teachers',
  529. label: `老师目录 (${profiles.total})`,
  530. children: (
  531. <>
  532. {searchToolbar}
  533. <Table
  534. rowKey="user_id"
  535. columns={profileColumns}
  536. dataSource={profiles.items}
  537. loading={loading}
  538. pagination={{
  539. current: profiles.page,
  540. pageSize: profiles.page_size,
  541. total: profiles.total,
  542. showSizeChanger: false,
  543. }}
  544. onChange={(pagination) => void loadProfiles(pagination.current || 1)}
  545. scroll={{ x: 1150 }}
  546. />
  547. </>
  548. ),
  549. },
  550. {
  551. key: 'locations',
  552. label: `位置管理 (${locations.total})`,
  553. children: (
  554. <>
  555. {searchToolbar}
  556. <Table
  557. rowKey="user_id"
  558. columns={locationColumns}
  559. dataSource={locations.items}
  560. loading={loading}
  561. pagination={{
  562. current: locations.page,
  563. pageSize: locations.page_size,
  564. total: locations.total,
  565. showSizeChanger: false,
  566. }}
  567. onChange={(pagination) => void loadLocations(pagination.current || 1)}
  568. scroll={{ x: 1050 }}
  569. />
  570. </>
  571. ),
  572. },
  573. {
  574. key: 'events',
  575. label: `状态事件 (${events.total})`,
  576. children: (
  577. <>
  578. {searchToolbar}
  579. <Table
  580. rowKey="event_id"
  581. columns={eventColumns}
  582. dataSource={events.items}
  583. loading={loading}
  584. pagination={{
  585. current: events.page,
  586. pageSize: events.page_size,
  587. total: events.total,
  588. showSizeChanger: false,
  589. }}
  590. onChange={(pagination) => void loadEvents(pagination.current || 1)}
  591. scroll={{ x: 1000 }}
  592. />
  593. </>
  594. ),
  595. },
  596. {
  597. key: 'settings',
  598. label: '目录设置',
  599. forceRender: true,
  600. children: (
  601. <Form
  602. form={settingsForm}
  603. layout="vertical"
  604. onFinish={(values) => {
  605. Modal.confirm({
  606. title: '确认更新全平台目录设置?',
  607. content: '设置会影响所有具备“师生与附近”职责的机器人。',
  608. okText: '确认保存',
  609. onOk: async () => {
  610. const saved = await apiRequest<DirectorySettings>(
  611. '/directory/settings',
  612. jsonOptions('PUT', { ...values, confirm: true }),
  613. );
  614. setSettings(saved);
  615. settingsForm.setFieldsValue(saved);
  616. message.success('目录设置已保存');
  617. },
  618. });
  619. }}
  620. style={{ maxWidth: 760 }}
  621. >
  622. <Alert
  623. showIcon
  624. type={settings?.nominatim_ready ? 'success' : 'info'}
  625. message={
  626. settings?.nominatim_ready
  627. ? 'Nominatim 区域解析已就绪'
  628. : '未填写联系信息时只计算距离,不会发送坐标'
  629. }
  630. style={{ marginBottom: 20 }}
  631. />
  632. <Form.Item
  633. name="nominatim_enabled"
  634. label="启用 OpenStreetMap Nominatim 区域解析"
  635. valuePropName="checked"
  636. >
  637. <Switch />
  638. </Form.Item>
  639. <Form.Item
  640. name="nominatim_contact"
  641. label="应用联系邮箱或 URL"
  642. extra="公共 Nominatim 要求 User-Agent 能识别应用;留空时禁止外发坐标。"
  643. >
  644. <Input placeholder="admin@example.com" />
  645. </Form.Item>
  646. <Form.Item
  647. name="nominatim_endpoint"
  648. label="Nominatim 服务地址"
  649. rules={[{ required: true }]}
  650. >
  651. <Input />
  652. </Form.Item>
  653. <Form.Item
  654. name="nominatim_user_agent"
  655. label="应用 User-Agent"
  656. rules={[{ required: true }]}
  657. >
  658. <Input />
  659. </Form.Item>
  660. <Form.Item name="nearby_radius_km" label="默认附近范围">
  661. <Select
  662. options={(settings?.nearby_radius_options_km || [5, 10, 20, 50]).map(
  663. (value) => ({ value, label: `${value} 公里` }),
  664. )}
  665. />
  666. </Form.Item>
  667. <Space wrap>
  668. <Button type="primary" htmlType="submit" icon={<Settings2 size={16} />}>
  669. 保存设置
  670. </Button>
  671. <Button
  672. type="link"
  673. href={settings?.attribution_url || 'https://www.openstreetmap.org/copyright'}
  674. target="_blank"
  675. >
  676. {settings?.attribution || '© OpenStreetMap contributors'}
  677. </Button>
  678. </Space>
  679. </Form>
  680. ),
  681. },
  682. ]}
  683. />
  684. </Surface>
  685. <Modal
  686. title={`确认全平台操作:${actionTarget?.display_name || actionTarget?.user_id || ''}`}
  687. open={Boolean(actionTarget)}
  688. okText="继续确认"
  689. okButtonProps={{ danger: true }}
  690. onCancel={() => setActionTarget(null)}
  691. onOk={() => actionForm.submit()}
  692. destroyOnHidden
  693. >
  694. <Alert
  695. showIcon
  696. type="warning"
  697. message="此操作将影响全部群和全部 Bot"
  698. style={{ marginBottom: 16 }}
  699. />
  700. <Form form={actionForm} layout="vertical" onFinish={submitAction}>
  701. <Form.Item
  702. name="reason"
  703. label="操作原因"
  704. rules={[{ required: true, message: '必须填写操作原因' }]}
  705. >
  706. <Input.TextArea rows={3} maxLength={500} showCount />
  707. </Form.Item>
  708. </Form>
  709. </Modal>
  710. <Modal
  711. title={`修正位置:${locationTarget?.display_name || locationTarget?.user_id || ''}`}
  712. open={Boolean(locationTarget)}
  713. okText="继续确认"
  714. onCancel={() => setLocationTarget(null)}
  715. onOk={() => locationForm.submit()}
  716. destroyOnHidden
  717. >
  718. <Form
  719. form={locationForm}
  720. layout="vertical"
  721. onFinish={(values) => {
  722. if (!locationTarget) return;
  723. Modal.confirm({
  724. title: '确认保存精确坐标?',
  725. content: '若该用户已授权区域解析,新坐标会进入 Nominatim 队列。',
  726. okText: '确认保存',
  727. onOk: async () => {
  728. await apiRequest(
  729. `/directory/locations/${locationTarget.user_id}`,
  730. jsonOptions('PUT', { ...values, confirm: true }),
  731. );
  732. message.success('位置已修正');
  733. setLocationTarget(null);
  734. await refresh();
  735. },
  736. });
  737. }}
  738. >
  739. <Form.Item
  740. name="longitude"
  741. label="经度"
  742. rules={[{ required: true }]}
  743. >
  744. <InputNumber min={-180} max={180} precision={7} style={{ width: '100%' }} />
  745. </Form.Item>
  746. <Form.Item
  747. name="latitude"
  748. label="纬度"
  749. rules={[{ required: true }]}
  750. >
  751. <InputNumber min={-90} max={90} precision={7} style={{ width: '100%' }} />
  752. </Form.Item>
  753. <Form.Item
  754. name="reason"
  755. label="修正原因"
  756. rules={[{ required: true, message: '必须填写修正原因' }]}
  757. >
  758. <Input.TextArea rows={3} maxLength={500} showCount />
  759. </Form.Item>
  760. </Form>
  761. </Modal>
  762. </>
  763. );
  764. }