Services.tsx 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748
  1. import {
  2. Alert,
  3. Button,
  4. Card,
  5. Col,
  6. Descriptions,
  7. Divider,
  8. Drawer,
  9. Form,
  10. Input,
  11. InputNumber,
  12. Modal,
  13. Row,
  14. Select,
  15. Space,
  16. Statistic,
  17. Table,
  18. Tabs,
  19. Tag,
  20. Typography,
  21. message,
  22. } from 'antd';
  23. import type { ColumnsType } from 'antd/es/table';
  24. import dayjs from 'dayjs';
  25. import { Copy, Eye, Plus, RefreshCw, ShieldBan, Trash2 } 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. FulfillmentMetrics,
  31. Paged,
  32. ServiceCustomerBlock,
  33. ServiceLeaderboardItem,
  34. ServiceOrder,
  35. ServiceOrderStatus,
  36. ServicePackageTemplate,
  37. ServiceReview,
  38. ServiceReviewQr,
  39. ServiceReviewTemplate,
  40. ServiceReport,
  41. ServiceSettings,
  42. } from '@/types';
  43. const emptyPage = <T,>(): Paged<T> => ({ items: [], total: 0, page: 1, page_size: 20 });
  44. const statusLabels: Record<ServiceOrderStatus, string> = {
  45. requested: '待处理',
  46. quoted: '待确认报价',
  47. confirmed: '已确认',
  48. in_progress: '服务中',
  49. completion_pending: '待扫码确认',
  50. completed: '已完成',
  51. rejected: '已拒绝',
  52. canceled_customer: '顾客取消',
  53. canceled_technician: '技师取消',
  54. disputed: '争议中',
  55. voided: '已作废',
  56. };
  57. const statusColors: Record<ServiceOrderStatus, string> = {
  58. requested: 'processing',
  59. quoted: 'warning',
  60. confirmed: 'cyan',
  61. in_progress: 'blue',
  62. completion_pending: 'purple',
  63. completed: 'success',
  64. rejected: 'error',
  65. canceled_customer: 'default',
  66. canceled_technician: 'default',
  67. disputed: 'volcano',
  68. voided: 'default',
  69. };
  70. type TemplateFormValue = {
  71. template_name: string;
  72. admin_note?: string;
  73. sort_order?: number;
  74. name: string;
  75. category: string;
  76. description: string;
  77. price_display: string;
  78. duration_minutes?: number;
  79. additional_fee_description: string;
  80. };
  81. export default function ServicesPage() {
  82. const [loading, setLoading] = useState(false);
  83. const [metrics, setMetrics] = useState<FulfillmentMetrics | null>(null);
  84. const [settings, setSettings] = useState<ServiceSettings | null>(null);
  85. const [templates, setTemplates] = useState<ServicePackageTemplate[]>([]);
  86. const [orders, setOrders] = useState<Paged<ServiceOrder>>(emptyPage());
  87. const [reviews, setReviews] = useState<Paged<ServiceReview>>(emptyPage());
  88. const [qrs, setQrs] = useState<Paged<ServiceReviewQr>>(emptyPage());
  89. const [leaderboard, setLeaderboard] = useState<Paged<ServiceLeaderboardItem>>(emptyPage());
  90. const [customerBlocks, setCustomerBlocks] = useState<Paged<ServiceCustomerBlock>>(emptyPage());
  91. const [reports, setReports] = useState<Paged<ServiceReport>>(emptyPage());
  92. const [reviewTemplate, setReviewTemplate] = useState<ServiceReviewTemplate | null>(null);
  93. const [orderStatus, setOrderStatus] = useState('');
  94. const [reviewStatus, setReviewStatus] = useState('pending');
  95. const [query, setQuery] = useState('');
  96. const [detail, setDetail] = useState<ServiceOrder | null>(null);
  97. const [templateTarget, setTemplateTarget] = useState<ServicePackageTemplate | null>(null);
  98. const [templateOpen, setTemplateOpen] = useState(false);
  99. const [orderAction, setOrderAction] = useState<{ order: ServiceOrder; action: 'resume' | 'void' } | null>(null);
  100. const [addressTarget, setAddressTarget] = useState<ServiceOrder | null>(null);
  101. const [reviewAction, setReviewAction] = useState<{ review: ServiceReview; action: 'reject' | 'void' } | null>(null);
  102. const [blockOpen, setBlockOpen] = useState(false);
  103. const [reportTarget, setReportTarget] = useState<{ report: ServiceReport; action: 'resolve' | 'dismiss' } | null>(null);
  104. const [settingsForm] = Form.useForm<ServiceSettings>();
  105. const [templateForm] = Form.useForm<TemplateFormValue>();
  106. const [reviewTemplateForm] = Form.useForm<Pick<ServiceReviewTemplate, 'questions'>>();
  107. const [orderReasonForm] = Form.useForm<{ reason: string }>();
  108. const [reviewReasonForm] = Form.useForm<{ reason: string }>();
  109. const [addressForm] = Form.useForm<{ reason: string }>();
  110. const [blockForm] = Form.useForm<{ customer_id: string; reason: string }>();
  111. const [reportForm] = Form.useForm<{ reason: string }>();
  112. const loadOrders = async (page = 1) => {
  113. const params = new URLSearchParams({ page: String(page), page_size: '20', status: orderStatus, query });
  114. setOrders(await apiRequest<Paged<ServiceOrder>>(`/directory/service-orders?${params}`));
  115. };
  116. const loadReviews = async (page = 1) => {
  117. const params = new URLSearchParams({ page: String(page), page_size: '20', status: reviewStatus, query });
  118. setReviews(await apiRequest<Paged<ServiceReview>>(`/directory/reviews?${params}`));
  119. };
  120. const refresh = async () => {
  121. setLoading(true);
  122. try {
  123. const [metricsValue, settingsValue, templateList, qrList, rankList, activeReviewTemplate, blockList, reportList] = await Promise.all([
  124. apiRequest<FulfillmentMetrics>('/directory/fulfillment-metrics'),
  125. apiRequest<ServiceSettings>('/directory/service-settings'),
  126. apiRequest<{ items: ServicePackageTemplate[] }>('/directory/package-templates?include_archived=true'),
  127. apiRequest<Paged<ServiceReviewQr>>('/directory/review-qrs?page=1&page_size=20'),
  128. apiRequest<Paged<ServiceLeaderboardItem>>('/directory/leaderboard?page=1&page_size=20'),
  129. apiRequest<ServiceReviewTemplate>('/directory/review-template'),
  130. apiRequest<Paged<ServiceCustomerBlock>>('/directory/customer-blocks?scope=global&active=true&page=1&page_size=20'),
  131. apiRequest<Paged<ServiceReport>>('/directory/reports?status=open&page=1&page_size=20'),
  132. ]);
  133. setMetrics(metricsValue);
  134. setSettings(settingsValue);
  135. settingsForm.setFieldsValue(settingsValue);
  136. setTemplates(templateList.items);
  137. setQrs(qrList);
  138. setLeaderboard(rankList);
  139. setCustomerBlocks(blockList);
  140. setReports(reportList);
  141. setReviewTemplate(activeReviewTemplate);
  142. reviewTemplateForm.setFieldsValue({
  143. questions: activeReviewTemplate.questions,
  144. });
  145. await Promise.all([loadOrders(orders.page), loadReviews(reviews.page)]);
  146. } catch (error) {
  147. message.error(error instanceof Error ? error.message : '咨询与评价数据加载失败');
  148. } finally {
  149. setLoading(false);
  150. }
  151. };
  152. useEffect(() => {
  153. void refresh();
  154. }, []);
  155. const saveSettings = async (values: ServiceSettings) => {
  156. const saved = await apiRequest<ServiceSettings>(
  157. '/directory/service-settings',
  158. jsonOptions('PUT', { ...values, confirm: true }),
  159. );
  160. setSettings(saved);
  161. settingsForm.setFieldsValue(saved);
  162. message.success('咨询与评价设置已更新');
  163. };
  164. const openTemplate = (item?: ServicePackageTemplate) => {
  165. setTemplateTarget(item || null);
  166. setTemplateOpen(true);
  167. if (!item) {
  168. templateForm.setFieldsValue({
  169. sort_order: 0,
  170. category: '通用服务',
  171. duration_minutes: 60,
  172. additional_fee_description: '其他费用请提前私聊沟通',
  173. });
  174. return;
  175. }
  176. const value = item.package;
  177. templateForm.setFieldsValue({
  178. template_name: item.name,
  179. admin_note: item.admin_note,
  180. sort_order: item.sort_order,
  181. name: value.name,
  182. category: value.category,
  183. description: value.description,
  184. price_display:
  185. value.price_display ||
  186. `${value.currency} ${value.min_price}/${value.price_unit === 'per_hour' ? '小时' : '次'}`,
  187. duration_minutes: value.duration_minutes || undefined,
  188. additional_fee_description:
  189. value.travel_fee.description || '其他费用请提前私聊沟通',
  190. });
  191. };
  192. const saveTemplate = async (values: TemplateFormValue) => {
  193. const payload = {
  194. template_name: values.template_name,
  195. admin_note: values.admin_note,
  196. sort_order: values.sort_order,
  197. confirm: true,
  198. package: {
  199. name: values.name,
  200. category: values.category,
  201. description: values.description,
  202. tags: [],
  203. service_modes: ['at_store', 'onsite'],
  204. price_mode: 'negotiable',
  205. currency: 'CNY',
  206. price_unit: 'per_service',
  207. min_price: '0',
  208. max_price: '0',
  209. price_display: values.price_display,
  210. duration_minutes: values.duration_minutes,
  211. service_radius_km: null,
  212. included_items: '',
  213. excluded_items: '',
  214. preparation: '',
  215. out_of_range_policy: '',
  216. travel_fee: {
  217. mode: 'quoted',
  218. amount: '0',
  219. per_km: '0',
  220. description: values.additional_fee_description,
  221. },
  222. addons: [],
  223. },
  224. };
  225. await apiRequest(
  226. templateTarget
  227. ? `/directory/package-templates/${templateTarget.template_id}`
  228. : '/directory/package-templates',
  229. jsonOptions(templateTarget ? 'PUT' : 'POST', payload),
  230. );
  231. setTemplateOpen(false);
  232. templateForm.resetFields();
  233. message.success(templateTarget ? '套餐模板已创建新版本' : '套餐模板已创建');
  234. await refresh();
  235. };
  236. const templateAction = (item: ServicePackageTemplate, action: string) => {
  237. Modal.confirm({
  238. title: action === 'archive' ? '归档套餐模板?' : `确认执行 ${action}?`,
  239. content: '模板操作不会同步、提醒或覆盖技师已发布套餐和历史服务单快照。',
  240. okText: '确认执行',
  241. onOk: async () => {
  242. await apiRequest(
  243. `/directory/package-templates/${item.template_id}/actions`,
  244. jsonOptions('POST', { action, confirm: true }),
  245. );
  246. message.success('模板状态已更新');
  247. await refresh();
  248. },
  249. });
  250. };
  251. const showOrder = async (order: ServiceOrder) => {
  252. setDetail(await apiRequest<ServiceOrder>(`/directory/service-orders/${order.order_id}`));
  253. };
  254. const submitOrderAction = async ({ reason }: { reason: string }) => {
  255. if (!orderAction) return;
  256. await apiRequest(
  257. `/directory/service-orders/${orderAction.order.order_id}/actions`,
  258. jsonOptions('POST', { action: orderAction.action, reason, confirm: true }),
  259. );
  260. setOrderAction(null);
  261. orderReasonForm.resetFields();
  262. message.success('服务单状态已更新');
  263. await refresh();
  264. };
  265. const revealAddress = async ({ reason }: { reason: string }) => {
  266. if (!addressTarget) return;
  267. const address = await apiRequest<{ kind: string; address_text: string; longitude: number; latitude: number }>(
  268. `/directory/service-orders/${addressTarget.order_id}/address`,
  269. jsonOptions('POST', { reason, confirm: true }),
  270. );
  271. setAddressTarget(null);
  272. addressForm.resetFields();
  273. Modal.info({
  274. title: '精确履约地址(敏感信息)',
  275. content: (
  276. <Space direction="vertical">
  277. <Typography.Text>{address.address_text}</Typography.Text>
  278. <Typography.Text type="secondary" className="numeric">
  279. {address.latitude}, {address.longitude}
  280. </Typography.Text>
  281. <Typography.Text type="warning">本次查看原因和管理员账号已记录审计。</Typography.Text>
  282. </Space>
  283. ),
  284. });
  285. };
  286. const approveReview = async (review: ServiceReview) => {
  287. const approved = await apiRequest<ServiceReview>(
  288. `/directory/reviews/${review.review_id}/actions`,
  289. jsonOptions('POST', { action: 'approve', confirm: true }),
  290. );
  291. if (approved.topic_publication?.status === 'published') {
  292. message.success('评价已审核通过并发布到技师公开话题');
  293. } else if (approved.topic_publication?.status === 'failed') {
  294. message.warning(`评价已通过,但话题发布失败:${approved.topic_publication.message}`);
  295. } else {
  296. message.warning('评价已通过;配置公开评价群后可自动发布');
  297. }
  298. await refresh();
  299. };
  300. const submitReviewAction = async ({ reason }: { reason: string }) => {
  301. if (!reviewAction) return;
  302. await apiRequest(
  303. `/directory/reviews/${reviewAction.review.review_id}/actions`,
  304. jsonOptions('POST', { action: reviewAction.action, reason, confirm: true }),
  305. );
  306. setReviewAction(null);
  307. reviewReasonForm.resetFields();
  308. message.success('评价状态已更新并重算排行');
  309. await refresh();
  310. };
  311. const publishReviewTemplate = async (values: Pick<ServiceReviewTemplate, 'questions'>) => {
  312. const published = await apiRequest<ServiceReviewTemplate>(
  313. '/directory/review-template',
  314. jsonOptions('PUT', { ...values, confirm: true }),
  315. );
  316. setReviewTemplate(published);
  317. message.success(`评价模板 V${published.version} 已发布,仅影响后续评价`);
  318. };
  319. const blockCustomer = async ({ customer_id, reason }: { customer_id: string; reason: string }) => {
  320. await apiRequest(
  321. `/directory/customer-blocks/${customer_id}`,
  322. jsonOptions('POST', { active: true, reason, confirm: true }),
  323. );
  324. setBlockOpen(false);
  325. blockForm.resetFields();
  326. message.success('顾客已全局封禁');
  327. await refresh();
  328. };
  329. const unblockCustomer = (item: ServiceCustomerBlock) => {
  330. Modal.confirm({
  331. title: `解除顾客 ${item.customer_id} 的全局封禁?`,
  332. okText: '确认解除',
  333. onOk: async () => {
  334. await apiRequest(
  335. `/directory/customer-blocks/${item.customer_id}`,
  336. jsonOptions('POST', { active: false, reason: '管理员解除全局封禁', confirm: true }),
  337. );
  338. message.success('顾客封禁已解除');
  339. await refresh();
  340. },
  341. });
  342. };
  343. const submitReportAction = async ({ reason }: { reason: string }) => {
  344. if (!reportTarget) return;
  345. await apiRequest(
  346. `/directory/reports/${reportTarget.report.report_id}/actions`,
  347. jsonOptions('POST', { action: reportTarget.action, reason, confirm: true }),
  348. );
  349. setReportTarget(null);
  350. reportForm.resetFields();
  351. message.success('举报已处理');
  352. await refresh();
  353. };
  354. const templateColumns: ColumnsType<ServicePackageTemplate> = [
  355. {
  356. title: '模板',
  357. minWidth: 220,
  358. render: (_, item) => (
  359. <Space direction="vertical" size={0}>
  360. <Typography.Text strong>{item.name}</Typography.Text>
  361. <Typography.Text type="secondary">{item.package.category} · V{item.version}</Typography.Text>
  362. </Space>
  363. ),
  364. },
  365. {
  366. title: '价格 / 时长',
  367. minWidth: 220,
  368. render: (_, item) => (
  369. <Space direction="vertical" size={0}>
  370. <Typography.Text>{item.package.price_display || `${item.package.currency} ${item.package.min_price}`}</Typography.Text>
  371. <Typography.Text type="secondary">约 {item.package.duration_minutes || '-'} 分钟</Typography.Text>
  372. </Space>
  373. ),
  374. },
  375. { title: '排序', dataIndex: 'sort_order', width: 80 },
  376. { title: '状态', dataIndex: 'status', width: 100, render: (value) => <Tag color={value === 'enabled' ? 'success' : value === 'archived' ? 'default' : 'warning'}>{value === 'enabled' ? '启用' : value === 'archived' ? '已归档' : '停用'}</Tag> },
  377. {
  378. title: '操作',
  379. width: 300,
  380. fixed: 'right',
  381. render: (_, item) => (
  382. <Space wrap>
  383. <Button size="small" onClick={() => openTemplate(item)}>编辑</Button>
  384. <Button size="small" icon={<Copy size={14} />} onClick={() => templateAction(item, 'duplicate')}>复制</Button>
  385. {item.status !== 'archived' ? (
  386. <Button size="small" onClick={() => templateAction(item, item.status === 'enabled' ? 'disable' : 'enable')}>
  387. {item.status === 'enabled' ? '停用' : '启用'}
  388. </Button>
  389. ) : null}
  390. {item.status !== 'archived' ? <Button size="small" danger onClick={() => templateAction(item, 'archive')}>归档</Button> : null}
  391. </Space>
  392. ),
  393. },
  394. ];
  395. const orderColumns: ColumnsType<ServiceOrder> = [
  396. { title: '服务单', dataIndex: 'order_id', width: 120, render: (value) => <Typography.Text className="numeric">{value.slice(0, 8)}</Typography.Text> },
  397. { title: '技师 / 顾客', minWidth: 220, render: (_, item) => <Space direction="vertical" size={0}><Typography.Text>{item.technician_name} · {item.technician_id}</Typography.Text><Typography.Text type="secondary">{item.customer_name} · {item.customer_id}</Typography.Text></Space> },
  398. { title: '套餐', minWidth: 180, render: (_, item) => <Space direction="vertical" size={0}><Typography.Text>{item.package_snapshot.name}</Typography.Text><Typography.Text type="secondary">{item.service_mode === 'at_store' ? '到店' : '上门'} · {item.distance_band}</Typography.Text></Space> },
  399. { title: '最终报价', width: 140, render: (_, item) => item.quote_snapshot ? `${item.quote_snapshot.currency} ${item.quote_snapshot.total_amount}` : '-' },
  400. { title: '状态', dataIndex: 'status', width: 130, render: (value: ServiceOrderStatus) => <Tag color={statusColors[value]}>{statusLabels[value]}</Tag> },
  401. { title: '更新时间', dataIndex: 'updated_at', width: 160, responsive: ['lg'], render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
  402. {
  403. title: '操作',
  404. width: 250,
  405. fixed: 'right',
  406. render: (_, item) => (
  407. <Space wrap>
  408. <Button size="small" icon={<Eye size={14} />} onClick={() => void showOrder(item)}>详情</Button>
  409. <Button size="small" onClick={() => { addressForm.resetFields(); setAddressTarget(item); }}>查看地址</Button>
  410. {item.status === 'disputed' ? <Button size="small" onClick={() => { orderReasonForm.resetFields(); setOrderAction({ order: item, action: 'resume' }); }}>恢复履约</Button> : null}
  411. {item.status !== 'voided' ? <Button size="small" danger onClick={() => { orderReasonForm.resetFields(); setOrderAction({ order: item, action: 'void' }); }}>作废</Button> : null}
  412. </Space>
  413. ),
  414. },
  415. ];
  416. const reviewColumns: ColumnsType<ServiceReview> = [
  417. { title: '评价', dataIndex: 'review_id', width: 110, render: (value) => value.slice(0, 8) },
  418. { title: '技师 / 顾客', minWidth: 210, render: (_, item) => <Space direction="vertical" size={0}><Typography.Text>{item.technician_name} · {item.technician_id}</Typography.Text><Typography.Text type="secondary">{item.customer_name} · {item.customer_id}</Typography.Text></Space> },
  419. { title: '来源', dataIndex: 'source', width: 150, render: (value) => <Tag color={value === 'qr_verified' ? 'success' : 'blue'}>{value === 'qr_verified' ? '完成服务单评价' : '用户主动评价'}</Tag> },
  420. { title: '评分', dataIndex: 'score', width: 90, render: (value) => value.toFixed(2) },
  421. { title: '重复风险', width: 190, render: (_, item) => <Space direction="vertical" size={0}><Typography.Text>累计 {item.customer_review_count} 次</Typography.Text><Typography.Text type="secondary">30天 {item.customer_review_count_30d} / 90天 {item.customer_review_count_90d}</Typography.Text><Typography.Text type={item.similar_content_count > 1 ? 'danger' : 'secondary'}>相似内容 {item.similar_content_count} 次</Typography.Text></Space> },
  422. {
  423. title: '评价内容',
  424. minWidth: 260,
  425. ellipsis: true,
  426. render: (_, item) =>
  427. [
  428. ...Object.values(item.choice_answers || {}).flat(),
  429. ...Object.values(item.text_answers || {}),
  430. ].join(';') || '-',
  431. },
  432. {
  433. title: '公开话题',
  434. width: 110,
  435. render: (_, item) =>
  436. item.topic_url ? <a href={item.topic_url} target="_blank" rel="noreferrer">查看</a> : '-',
  437. },
  438. { title: '状态', dataIndex: 'status', width: 100, render: (value) => <Tag>{value === 'pending' ? '待审核' : value === 'approved' ? '已批准' : value === 'rejected' ? '已拒绝' : '已作废'}</Tag> },
  439. {
  440. title: '操作',
  441. width: 200,
  442. fixed: 'right',
  443. render: (_, item) => (
  444. <Space>
  445. {item.status === 'pending' ? <Button size="small" type="primary" onClick={() => void approveReview(item)}>批准并发布</Button> : null}
  446. {item.status === 'pending' ? <Button size="small" danger onClick={() => { reviewReasonForm.resetFields(); setReviewAction({ review: item, action: 'reject' }); }}>拒绝</Button> : null}
  447. {item.status === 'approved' ? <Button size="small" danger onClick={() => { reviewReasonForm.resetFields(); setReviewAction({ review: item, action: 'void' }); }}>作废</Button> : null}
  448. </Space>
  449. ),
  450. },
  451. ];
  452. return (
  453. <>
  454. <PageHeader
  455. title="技师咨询与评价"
  456. actions={<Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void refresh()}>刷新</Button>}
  457. />
  458. <Alert
  459. showIcon
  460. type="info"
  461. message="咨询平台模式"
  462. description="顾客直接查看技师、私聊咨询并提交评价;管理员审核通过后,评价立即公开到该技师的独立话题并参与排行。"
  463. style={{ marginBottom: 16 }}
  464. />
  465. <Surface>
  466. <Tabs
  467. items={[
  468. {
  469. key: 'overview',
  470. label: '履约指标',
  471. children: (
  472. <>
  473. <Row gutter={[16, 16]}>
  474. <Col xs={12} lg={6}><Card><Statistic title="有效请求" value={metrics?.valid_requests || 0} /></Card></Col>
  475. <Col xs={12} lg={6}><Card><Statistic title="请求提交率" value={(metrics?.request_submission_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
  476. <Col xs={12} lg={6}><Card><Statistic title="报价率" value={(metrics?.quote_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
  477. <Col xs={12} lg={6}><Card><Statistic title="顾客确认率" value={(metrics?.customer_confirmation_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
  478. <Col xs={12} lg={6}><Card><Statistic title="撮合成功率" value={(metrics?.match_success_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
  479. <Col xs={12} lg={6}><Card><Statistic title="履约完成率" value={(metrics?.fulfillment_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
  480. <Col xs={12} lg={6}><Card><Statistic title="取消率" value={(metrics?.cancellation_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
  481. <Col xs={12} lg={6}><Card><Statistic title="取消方(顾客 / 技师)" value={`${metrics?.cancellation_distribution.customer || 0} / ${metrics?.cancellation_distribution.technician || 0}`} /></Card></Col>
  482. </Row>
  483. <Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>{metrics?.scope_note}</Typography.Paragraph>
  484. <Divider />
  485. <Typography.Title level={5}>公开评价话题</Typography.Title>
  486. <Alert
  487. showIcon
  488. type="warning"
  489. message="请使用已开启话题的公开超级群"
  490. description="把当前 Bot 设为管理员并授予管理话题权限。每位技师会自动创建独立话题,审核通过的评价会立即发布。"
  491. style={{ marginBottom: 16 }}
  492. />
  493. <Form form={settingsForm} layout="vertical" onFinish={(values) => void saveSettings(values)}>
  494. <Row gutter={16}>
  495. <Col xs={24} md={12}><Form.Item name="review_forum_chat_id" label="公开群 Chat ID" extra="例如:-1001234567890"><Input /></Form.Item></Col>
  496. <Col xs={24} md={12}><Form.Item name="review_forum_username" label="公开群用户名" extra="例如:technician_reviews,无需填写 @"><Input /></Form.Item></Col>
  497. </Row>
  498. <Button type="primary" htmlType="submit" disabled={!settings}>保存公开群设置</Button>
  499. </Form>
  500. </>
  501. ),
  502. },
  503. {
  504. key: 'templates',
  505. label: `套餐模板 (${templates.length})`,
  506. children: (
  507. <>
  508. <div className="page-toolbar"><Button type="primary" icon={<Plus size={16} />} onClick={() => openTemplate()}>新建模板</Button></div>
  509. <Table rowKey="template_id" columns={templateColumns} dataSource={templates} loading={loading} pagination={false} scroll={{ x: 1050 }} />
  510. </>
  511. ),
  512. },
  513. {
  514. key: 'orders',
  515. label: `服务订单 (${orders.total})`,
  516. children: (
  517. <>
  518. <div className="page-toolbar">
  519. <Select allowClear value={orderStatus || undefined} placeholder="全部状态" style={{ width: 170 }} onChange={(value) => setOrderStatus(value || '')} options={Object.entries(statusLabels).map(([value, label]) => ({ value, label }))} />
  520. <Input allowClear value={query} onChange={(event) => setQuery(event.target.value)} onPressEnter={() => void loadOrders(1)} placeholder="订单、技师或顾客" style={{ width: 260 }} />
  521. <Button onClick={() => void loadOrders(1)}>查询</Button>
  522. </div>
  523. <Table rowKey="order_id" columns={orderColumns} dataSource={orders.items} loading={loading} pagination={{ current: orders.page, pageSize: orders.page_size, total: orders.total, showSizeChanger: false }} onChange={(pagination) => void loadOrders(pagination.current || 1)} scroll={{ x: 1200 }} />
  524. </>
  525. ),
  526. },
  527. {
  528. key: 'reviews',
  529. label: `评价审核 (${reviews.total})`,
  530. children: (
  531. <>
  532. <div className="page-toolbar">
  533. <Select value={reviewStatus} style={{ width: 140 }} onChange={setReviewStatus} options={[{ value: 'pending', label: '待审核' }, { value: 'approved', label: '已批准' }, { value: 'rejected', label: '已拒绝' }, { value: 'voided', label: '已作废' }, { value: '', label: '全部' }]} />
  534. <Input allowClear value={query} onChange={(event) => setQuery(event.target.value)} onPressEnter={() => void loadReviews(1)} placeholder="技师、顾客或评价 ID" style={{ width: 260 }} />
  535. <Button onClick={() => void loadReviews(1)}>查询</Button>
  536. </div>
  537. <Table rowKey="review_id" columns={reviewColumns} dataSource={reviews.items} loading={loading} pagination={{ current: reviews.page, pageSize: reviews.page_size, total: reviews.total, showSizeChanger: false }} onChange={(pagination) => void loadReviews(pagination.current || 1)} scroll={{ x: 1300 }} />
  538. </>
  539. ),
  540. },
  541. {
  542. key: 'review-template',
  543. label: `评价模板 V${reviewTemplate?.version || '-'}`,
  544. children: (
  545. <Form form={reviewTemplateForm} layout="vertical" onFinish={(values) => void publishReviewTemplate(values)}>
  546. <Alert showIcon type="warning" message="发布会创建新版本" description="支持单选、多选和输入框。单选项用于计算评分,所有单选问题的权重必须合计 100%;历史评价不受影响。" style={{ marginBottom: 16 }} />
  547. <Typography.Title level={5}>评价问题(1–8 个)</Typography.Title>
  548. <Form.List name="questions">
  549. {(fields, { add, remove }) => (
  550. <Space direction="vertical" size={16} style={{ width: '100%' }}>
  551. {fields.map((field) => (
  552. <Card
  553. key={field.key}
  554. size="small"
  555. title={`问题 ${field.name + 1}`}
  556. extra={<Button danger type="text" aria-label="删除问题" icon={<Trash2 size={16} />} onClick={() => remove(field.name)} />}
  557. >
  558. <Row gutter={12}>
  559. <Col xs={24} md={12}><Form.Item {...field} name={[field.name, 'label']} label="问题" rules={[{ required: true, message: '请输入问题' }]}><Input placeholder="例如:这次服务体验怎么样?" /></Form.Item></Col>
  560. <Col xs={12} md={6}><Form.Item {...field} name={[field.name, 'type']} label="控件" rules={[{ required: true }]}><Select options={[{ value: 'single_choice', label: '单选' }, { value: 'multiple_choice', label: '多选' }, { value: 'text', label: '输入框' }]} /></Form.Item></Col>
  561. <Col xs={12} md={6}><Form.Item {...field} name={[field.name, 'required']} label="是否必填"><Select options={[{ value: true, label: '必填' }, { value: false, label: '可跳过' }]} /></Form.Item></Col>
  562. </Row>
  563. <Form.Item {...field} name={[field.name, 'description']} label="提示文字"><Input /></Form.Item>
  564. <Form.Item {...field} name={[field.name, 'question_id']} hidden><Input /></Form.Item>
  565. <Form.Item noStyle shouldUpdate>
  566. {({ getFieldValue }) => {
  567. const questionType = getFieldValue(['questions', field.name, 'type']);
  568. if (questionType === 'text') {
  569. return <Form.Item {...field} name={[field.name, 'max_length']} label="最大字数"><InputNumber min={50} max={1000} /></Form.Item>;
  570. }
  571. return (
  572. <>
  573. {questionType === 'single_choice' ? (
  574. <Form.Item {...field} name={[field.name, 'weight']} label="评分权重" rules={[{ required: true, message: '请输入权重' }]}><InputNumber min={1} max={100} addonAfter="%" /></Form.Item>
  575. ) : null}
  576. <Typography.Text strong>选项(2–8 个)</Typography.Text>
  577. <Form.List name={[field.name, 'options']}>
  578. {(optionFields, optionActions) => (
  579. <Space direction="vertical" style={{ width: '100%', marginTop: 8 }}>
  580. {optionFields.map((optionField) => (
  581. <Row gutter={8} key={optionField.key} align="middle">
  582. <Col flex="auto"><Form.Item {...optionField} name={[optionField.name, 'label']} rules={[{ required: true, message: '请输入选项' }]} style={{ marginBottom: 0 }}><Input placeholder="选项文字" /></Form.Item></Col>
  583. {questionType === 'single_choice' ? <Col flex="100px"><Form.Item {...optionField} name={[optionField.name, 'score']} rules={[{ required: true, message: '分值' }]} style={{ marginBottom: 0 }}><InputNumber min={1} max={5} addonAfter="分" /></Form.Item></Col> : null}
  584. <Col flex="44px"><Button danger type="text" aria-label="删除选项" icon={<Trash2 size={16} />} onClick={() => optionActions.remove(optionField.name)} /></Col>
  585. <Form.Item {...optionField} name={[optionField.name, 'value']} hidden><Input /></Form.Item>
  586. </Row>
  587. ))}
  588. <Button size="small" icon={<Plus size={14} />} disabled={optionFields.length >= 8} onClick={() => optionActions.add({ value: `option_${Date.now()}`, label: '', score: questionType === 'single_choice' ? 5 : undefined })}>增加选项</Button>
  589. </Space>
  590. )}
  591. </Form.List>
  592. </>
  593. );
  594. }}
  595. </Form.Item>
  596. </Card>
  597. ))}
  598. <Button icon={<Plus size={16} />} disabled={fields.length >= 8} onClick={() => add({ question_id: `question_${Date.now()}`, type: 'single_choice', label: '', required: true, weight: 100, options: [{ value: 'excellent', label: '非常满意', score: 5 }, { value: 'good', label: '满意', score: 4 }, { value: 'average', label: '一般', score: 3 }, { value: 'poor', label: '不满意', score: 2 }, { value: 'bad', label: '非常不满意', score: 1 }] })}>增加问题</Button>
  599. </Space>
  600. )}
  601. </Form.List>
  602. <Divider />
  603. <Button type="primary" htmlType="submit">发布新版评价模板</Button>
  604. </Form>
  605. ),
  606. },
  607. {
  608. key: 'ranking-risk',
  609. label: '排行与风控',
  610. children: (
  611. <>
  612. <div className="page-toolbar"><Button danger icon={<ShieldBan size={16} />} onClick={() => setBlockOpen(true)}>全局封禁顾客</Button></div>
  613. <Typography.Title level={5}>审核评价排行</Typography.Title>
  614. <Table rowKey={(item) => `${item.technician_id}:${item.category}`} dataSource={leaderboard.items} pagination={false} columns={[
  615. { title: '技师', render: (_, item) => `${item.display_name || item.technician_id}${item.username ? ` (@${item.username})` : ''}` },
  616. { title: '分类', dataIndex: 'category' },
  617. { title: '排行分', dataIndex: 'rank_score', render: (value) => value.toFixed(3) },
  618. { title: '平均分', dataIndex: 'average_score', render: (value) => value.toFixed(2) },
  619. { title: '批准评价', dataIndex: 'review_count' },
  620. ]} />
  621. <Divider />
  622. <Typography.Title level={5}>全局顾客黑名单</Typography.Title>
  623. <Table rowKey={(item) => `${item.scope}:${item.customer_id}`} dataSource={customerBlocks.items} pagination={false} columns={[
  624. { title: '顾客 ID', dataIndex: 'customer_id' },
  625. { title: '原因', dataIndex: 'reason' },
  626. { title: '更新时间', dataIndex: 'updated_at', render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
  627. { title: '操作', render: (_, item) => <Button size="small" onClick={() => unblockCustomer(item)}>解除封禁</Button> },
  628. ]} />
  629. <Divider />
  630. <Typography.Title level={5}>待处理举报</Typography.Title>
  631. <Table rowKey="report_id" dataSource={reports.items} pagination={false} columns={[
  632. { title: '举报', dataIndex: 'report_id', render: (value) => value.slice(0, 8) },
  633. { title: '举报人', dataIndex: 'reporter_id' },
  634. { title: '对象', render: (_, item) => `${item.target_type} · ${item.target_id.slice(0, 12)}` },
  635. { title: '原因', dataIndex: 'reason' },
  636. { title: '操作', render: (_, item) => <Space><Button size="small" type="primary" onClick={() => { reportForm.resetFields(); setReportTarget({ report: item, action: 'resolve' }); }}>处理完成</Button><Button size="small" onClick={() => { reportForm.resetFields(); setReportTarget({ report: item, action: 'dismiss' }); }}>驳回</Button></Space> },
  637. ]} />
  638. <Divider />
  639. <Typography.Title level={5}>完成评价二维码记录</Typography.Title>
  640. <Table rowKey="qr_id" dataSource={qrs.items} pagination={false} columns={[
  641. { title: '二维码', dataIndex: 'qr_id', render: (value) => value.slice(0, 8) },
  642. { title: '服务单', dataIndex: 'order_id', render: (value) => value.slice(0, 8) },
  643. { title: '技师 / 顾客', render: (_, item) => `${item.technician_id} / ${item.customer_id}` },
  644. { title: '状态', dataIndex: 'status' },
  645. { title: '过期时间', dataIndex: 'expires_at', render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
  646. ]} />
  647. </>
  648. ),
  649. },
  650. ]}
  651. />
  652. </Surface>
  653. <Modal title={templateTarget ? '编辑套餐模板' : '新建套餐模板'} open={templateOpen} width={760} onCancel={() => { setTemplateOpen(false); templateForm.resetFields(); }} onOk={() => templateForm.submit()} okText="保存快照版本">
  654. <Form form={templateForm} layout="vertical" onFinish={(values) => void saveTemplate(values)}>
  655. <Row gutter={16}>
  656. <Col xs={24} md={16}><Form.Item name="template_name" label="后台模板名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
  657. <Col xs={24} md={8}><Form.Item name="sort_order" label="排序"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
  658. </Row>
  659. <Form.Item name="admin_note" label="后台备注"><Input /></Form.Item>
  660. <Divider />
  661. <Row gutter={16}>
  662. <Col xs={24} md={12}><Form.Item name="name" label="默认服务名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
  663. <Col xs={24} md={12}><Form.Item name="category" label="服务分类" rules={[{ required: true }]}><Input /></Form.Item></Col>
  664. </Row>
  665. <Form.Item name="price_display" label="默认服务价格" extra="直接填写给技师和顾客看的价格文字,例如:300 元/次起。" rules={[{ required: true }]}><Input /></Form.Item>
  666. <Form.Item name="description" label="默认服务内容" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item>
  667. <Row gutter={16}>
  668. <Col xs={24} md={8}><Form.Item name="duration_minutes" label="默认服务时长" rules={[{ required: true }]}><InputNumber min={15} max={480} addonAfter="分钟" style={{ width: '100%' }} /></Form.Item></Col>
  669. <Col xs={24} md={16}><Form.Item name="additional_fee_description" label="默认额外费用说明" rules={[{ required: true }]}><Input placeholder="例如:上门车费根据距离另行沟通" /></Form.Item></Col>
  670. </Row>
  671. </Form>
  672. </Modal>
  673. <Drawer title={`服务单 ${detail?.order_id.slice(0, 8) || ''}`} open={Boolean(detail)} width={680} onClose={() => setDetail(null)}>
  674. {detail ? (
  675. <>
  676. <Descriptions column={1} bordered size="small" items={[
  677. { key: 'status', label: '状态', children: statusLabels[detail.status] },
  678. { key: 'people', label: '技师 / 顾客', children: `${detail.technician_name} (${detail.technician_id}) / ${detail.customer_name} (${detail.customer_id})` },
  679. { key: 'package', label: '套餐', children: `${detail.package_snapshot.name} · ${detail.category}` },
  680. { key: 'mode', label: '方式 / 距离', children: `${detail.service_mode === 'at_store' ? '到店' : '上门'} · ${detail.distance_band}` },
  681. { key: 'requirements', label: '需求', children: detail.requirements },
  682. { key: 'quote', label: '最终报价', children: detail.quote_snapshot ? `${detail.quote_snapshot.currency} ${detail.quote_snapshot.total_amount}` : '-' },
  683. { key: 'reason', label: '关闭/争议原因', children: detail.close_reason || detail.dispute_reason || '-' },
  684. ]} />
  685. <Divider />
  686. <Typography.Title level={5}>状态轨迹</Typography.Title>
  687. {(detail.events || []).map((event, index) => (
  688. <Typography.Paragraph key={String(event.event_id || index)}>
  689. {dayjs(String(event.created_at)).format('YYYY-MM-DD HH:mm:ss')} · {String(event.event_type)} · {String(event.reason || '')}
  690. </Typography.Paragraph>
  691. ))}
  692. </>
  693. ) : null}
  694. </Drawer>
  695. <Modal title={orderAction?.action === 'void' ? '作废服务单' : '恢复争议服务单'} open={Boolean(orderAction)} onCancel={() => setOrderAction(null)} onOk={() => orderReasonForm.submit()} okText="确认执行" okButtonProps={{ danger: orderAction?.action === 'void' }}>
  696. <Form form={orderReasonForm} layout="vertical" onFinish={(values) => void submitOrderAction(values)}><Form.Item name="reason" label="处理原因" rules={[{ required: true, message: '必须填写原因' }]}><Input.TextArea rows={4} /></Form.Item></Form>
  697. </Modal>
  698. <Modal title="查看精确地址" open={Boolean(addressTarget)} onCancel={() => setAddressTarget(null)} onOk={() => addressForm.submit()} okText="确认查看" okButtonProps={{ danger: true }}>
  699. <Alert showIcon type="error" message="精确地址属于敏感信息" description="必须填写真实争议处理原因。本次访问会写入服务单事件和管理员审计。" style={{ marginBottom: 16 }} />
  700. <Form form={addressForm} layout="vertical" onFinish={(values) => void revealAddress(values)}><Form.Item name="reason" label="查看原因" rules={[{ required: true, message: '必须填写查看原因' }]}><Input.TextArea rows={4} /></Form.Item></Form>
  701. </Modal>
  702. <Modal title={reviewAction?.action === 'void' ? '作废已批准评价' : '拒绝评价'} open={Boolean(reviewAction)} onCancel={() => setReviewAction(null)} onOk={() => reviewReasonForm.submit()} okText="确认执行" okButtonProps={{ danger: true }}>
  703. <Form form={reviewReasonForm} layout="vertical" onFinish={(values) => void submitReviewAction(values)}><Form.Item name="reason" label="原因" rules={[{ required: true, message: '必须填写原因' }]}><Input.TextArea rows={4} /></Form.Item></Form>
  704. </Modal>
  705. <Modal title="全局封禁顾客" open={blockOpen} onCancel={() => setBlockOpen(false)} onOk={() => blockForm.submit()} okText="确认封禁" okButtonProps={{ danger: true }}>
  706. <Form form={blockForm} layout="vertical" onFinish={(values) => void blockCustomer(values)}>
  707. <Form.Item name="customer_id" label="Telegram 用户 ID" rules={[{ required: true }]}><Input /></Form.Item>
  708. <Form.Item name="reason" label="封禁原因" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item>
  709. </Form>
  710. </Modal>
  711. <Modal title={reportTarget?.action === 'resolve' ? '标记举报已处理' : '驳回举报'} open={Boolean(reportTarget)} onCancel={() => setReportTarget(null)} onOk={() => reportForm.submit()} okText="确认处理">
  712. <Form form={reportForm} layout="vertical" onFinish={(values) => void submitReportAction(values)}>
  713. <Form.Item name="reason" label="处理说明" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item>
  714. </Form>
  715. </Modal>
  716. </>
  717. );
  718. }