| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748 |
- import {
- Alert,
- Button,
- Card,
- Col,
- Descriptions,
- Divider,
- Drawer,
- Form,
- Input,
- InputNumber,
- Modal,
- Row,
- Select,
- Space,
- Statistic,
- Table,
- Tabs,
- Tag,
- Typography,
- message,
- } from 'antd';
- import type { ColumnsType } from 'antd/es/table';
- import dayjs from 'dayjs';
- import { Copy, Eye, Plus, RefreshCw, ShieldBan, Trash2 } from 'lucide-react';
- import { useEffect, useState } from 'react';
- import { PageHeader, Surface } from '@/components/Page';
- import { apiRequest, jsonOptions } from '@/services/api';
- import type {
- FulfillmentMetrics,
- Paged,
- ServiceCustomerBlock,
- ServiceLeaderboardItem,
- ServiceOrder,
- ServiceOrderStatus,
- ServicePackageTemplate,
- ServiceReview,
- ServiceReviewQr,
- ServiceReviewTemplate,
- ServiceReport,
- ServiceSettings,
- } from '@/types';
- const emptyPage = <T,>(): Paged<T> => ({ items: [], total: 0, page: 1, page_size: 20 });
- const statusLabels: Record<ServiceOrderStatus, string> = {
- requested: '待处理',
- quoted: '待确认报价',
- confirmed: '已确认',
- in_progress: '服务中',
- completion_pending: '待扫码确认',
- completed: '已完成',
- rejected: '已拒绝',
- canceled_customer: '顾客取消',
- canceled_technician: '技师取消',
- disputed: '争议中',
- voided: '已作废',
- };
- const statusColors: Record<ServiceOrderStatus, string> = {
- requested: 'processing',
- quoted: 'warning',
- confirmed: 'cyan',
- in_progress: 'blue',
- completion_pending: 'purple',
- completed: 'success',
- rejected: 'error',
- canceled_customer: 'default',
- canceled_technician: 'default',
- disputed: 'volcano',
- voided: 'default',
- };
- type TemplateFormValue = {
- template_name: string;
- admin_note?: string;
- sort_order?: number;
- name: string;
- category: string;
- description: string;
- price_display: string;
- duration_minutes?: number;
- additional_fee_description: string;
- };
- export default function ServicesPage() {
- const [loading, setLoading] = useState(false);
- const [metrics, setMetrics] = useState<FulfillmentMetrics | null>(null);
- const [settings, setSettings] = useState<ServiceSettings | null>(null);
- const [templates, setTemplates] = useState<ServicePackageTemplate[]>([]);
- const [orders, setOrders] = useState<Paged<ServiceOrder>>(emptyPage());
- const [reviews, setReviews] = useState<Paged<ServiceReview>>(emptyPage());
- const [qrs, setQrs] = useState<Paged<ServiceReviewQr>>(emptyPage());
- const [leaderboard, setLeaderboard] = useState<Paged<ServiceLeaderboardItem>>(emptyPage());
- const [customerBlocks, setCustomerBlocks] = useState<Paged<ServiceCustomerBlock>>(emptyPage());
- const [reports, setReports] = useState<Paged<ServiceReport>>(emptyPage());
- const [reviewTemplate, setReviewTemplate] = useState<ServiceReviewTemplate | null>(null);
- const [orderStatus, setOrderStatus] = useState('');
- const [reviewStatus, setReviewStatus] = useState('pending');
- const [query, setQuery] = useState('');
- const [detail, setDetail] = useState<ServiceOrder | null>(null);
- const [templateTarget, setTemplateTarget] = useState<ServicePackageTemplate | null>(null);
- const [templateOpen, setTemplateOpen] = useState(false);
- const [orderAction, setOrderAction] = useState<{ order: ServiceOrder; action: 'resume' | 'void' } | null>(null);
- const [addressTarget, setAddressTarget] = useState<ServiceOrder | null>(null);
- const [reviewAction, setReviewAction] = useState<{ review: ServiceReview; action: 'reject' | 'void' } | null>(null);
- const [blockOpen, setBlockOpen] = useState(false);
- const [reportTarget, setReportTarget] = useState<{ report: ServiceReport; action: 'resolve' | 'dismiss' } | null>(null);
- const [settingsForm] = Form.useForm<ServiceSettings>();
- const [templateForm] = Form.useForm<TemplateFormValue>();
- const [reviewTemplateForm] = Form.useForm<Pick<ServiceReviewTemplate, 'questions'>>();
- const [orderReasonForm] = Form.useForm<{ reason: string }>();
- const [reviewReasonForm] = Form.useForm<{ reason: string }>();
- const [addressForm] = Form.useForm<{ reason: string }>();
- const [blockForm] = Form.useForm<{ customer_id: string; reason: string }>();
- const [reportForm] = Form.useForm<{ reason: string }>();
- const loadOrders = async (page = 1) => {
- const params = new URLSearchParams({ page: String(page), page_size: '20', status: orderStatus, query });
- setOrders(await apiRequest<Paged<ServiceOrder>>(`/directory/service-orders?${params}`));
- };
- const loadReviews = async (page = 1) => {
- const params = new URLSearchParams({ page: String(page), page_size: '20', status: reviewStatus, query });
- setReviews(await apiRequest<Paged<ServiceReview>>(`/directory/reviews?${params}`));
- };
- const refresh = async () => {
- setLoading(true);
- try {
- const [metricsValue, settingsValue, templateList, qrList, rankList, activeReviewTemplate, blockList, reportList] = await Promise.all([
- apiRequest<FulfillmentMetrics>('/directory/fulfillment-metrics'),
- apiRequest<ServiceSettings>('/directory/service-settings'),
- apiRequest<{ items: ServicePackageTemplate[] }>('/directory/package-templates?include_archived=true'),
- apiRequest<Paged<ServiceReviewQr>>('/directory/review-qrs?page=1&page_size=20'),
- apiRequest<Paged<ServiceLeaderboardItem>>('/directory/leaderboard?page=1&page_size=20'),
- apiRequest<ServiceReviewTemplate>('/directory/review-template'),
- apiRequest<Paged<ServiceCustomerBlock>>('/directory/customer-blocks?scope=global&active=true&page=1&page_size=20'),
- apiRequest<Paged<ServiceReport>>('/directory/reports?status=open&page=1&page_size=20'),
- ]);
- setMetrics(metricsValue);
- setSettings(settingsValue);
- settingsForm.setFieldsValue(settingsValue);
- setTemplates(templateList.items);
- setQrs(qrList);
- setLeaderboard(rankList);
- setCustomerBlocks(blockList);
- setReports(reportList);
- setReviewTemplate(activeReviewTemplate);
- reviewTemplateForm.setFieldsValue({
- questions: activeReviewTemplate.questions,
- });
- await Promise.all([loadOrders(orders.page), loadReviews(reviews.page)]);
- } catch (error) {
- message.error(error instanceof Error ? error.message : '咨询与评价数据加载失败');
- } finally {
- setLoading(false);
- }
- };
- useEffect(() => {
- void refresh();
- }, []);
- const saveSettings = async (values: ServiceSettings) => {
- const saved = await apiRequest<ServiceSettings>(
- '/directory/service-settings',
- jsonOptions('PUT', { ...values, confirm: true }),
- );
- setSettings(saved);
- settingsForm.setFieldsValue(saved);
- message.success('咨询与评价设置已更新');
- };
- const openTemplate = (item?: ServicePackageTemplate) => {
- setTemplateTarget(item || null);
- setTemplateOpen(true);
- if (!item) {
- templateForm.setFieldsValue({
- sort_order: 0,
- category: '通用服务',
- duration_minutes: 60,
- additional_fee_description: '其他费用请提前私聊沟通',
- });
- return;
- }
- const value = item.package;
- templateForm.setFieldsValue({
- template_name: item.name,
- admin_note: item.admin_note,
- sort_order: item.sort_order,
- name: value.name,
- category: value.category,
- description: value.description,
- price_display:
- value.price_display ||
- `${value.currency} ${value.min_price}/${value.price_unit === 'per_hour' ? '小时' : '次'}`,
- duration_minutes: value.duration_minutes || undefined,
- additional_fee_description:
- value.travel_fee.description || '其他费用请提前私聊沟通',
- });
- };
- const saveTemplate = async (values: TemplateFormValue) => {
- const payload = {
- template_name: values.template_name,
- admin_note: values.admin_note,
- sort_order: values.sort_order,
- confirm: true,
- package: {
- name: values.name,
- category: values.category,
- description: values.description,
- tags: [],
- service_modes: ['at_store', 'onsite'],
- price_mode: 'negotiable',
- currency: 'CNY',
- price_unit: 'per_service',
- min_price: '0',
- max_price: '0',
- price_display: values.price_display,
- duration_minutes: values.duration_minutes,
- service_radius_km: null,
- included_items: '',
- excluded_items: '',
- preparation: '',
- out_of_range_policy: '',
- travel_fee: {
- mode: 'quoted',
- amount: '0',
- per_km: '0',
- description: values.additional_fee_description,
- },
- addons: [],
- },
- };
- await apiRequest(
- templateTarget
- ? `/directory/package-templates/${templateTarget.template_id}`
- : '/directory/package-templates',
- jsonOptions(templateTarget ? 'PUT' : 'POST', payload),
- );
- setTemplateOpen(false);
- templateForm.resetFields();
- message.success(templateTarget ? '套餐模板已创建新版本' : '套餐模板已创建');
- await refresh();
- };
- const templateAction = (item: ServicePackageTemplate, action: string) => {
- Modal.confirm({
- title: action === 'archive' ? '归档套餐模板?' : `确认执行 ${action}?`,
- content: '模板操作不会同步、提醒或覆盖技师已发布套餐和历史服务单快照。',
- okText: '确认执行',
- onOk: async () => {
- await apiRequest(
- `/directory/package-templates/${item.template_id}/actions`,
- jsonOptions('POST', { action, confirm: true }),
- );
- message.success('模板状态已更新');
- await refresh();
- },
- });
- };
- const showOrder = async (order: ServiceOrder) => {
- setDetail(await apiRequest<ServiceOrder>(`/directory/service-orders/${order.order_id}`));
- };
- const submitOrderAction = async ({ reason }: { reason: string }) => {
- if (!orderAction) return;
- await apiRequest(
- `/directory/service-orders/${orderAction.order.order_id}/actions`,
- jsonOptions('POST', { action: orderAction.action, reason, confirm: true }),
- );
- setOrderAction(null);
- orderReasonForm.resetFields();
- message.success('服务单状态已更新');
- await refresh();
- };
- const revealAddress = async ({ reason }: { reason: string }) => {
- if (!addressTarget) return;
- const address = await apiRequest<{ kind: string; address_text: string; longitude: number; latitude: number }>(
- `/directory/service-orders/${addressTarget.order_id}/address`,
- jsonOptions('POST', { reason, confirm: true }),
- );
- setAddressTarget(null);
- addressForm.resetFields();
- Modal.info({
- title: '精确履约地址(敏感信息)',
- content: (
- <Space direction="vertical">
- <Typography.Text>{address.address_text}</Typography.Text>
- <Typography.Text type="secondary" className="numeric">
- {address.latitude}, {address.longitude}
- </Typography.Text>
- <Typography.Text type="warning">本次查看原因和管理员账号已记录审计。</Typography.Text>
- </Space>
- ),
- });
- };
- const approveReview = async (review: ServiceReview) => {
- const approved = await apiRequest<ServiceReview>(
- `/directory/reviews/${review.review_id}/actions`,
- jsonOptions('POST', { action: 'approve', confirm: true }),
- );
- if (approved.topic_publication?.status === 'published') {
- message.success('评价已审核通过并发布到技师公开话题');
- } else if (approved.topic_publication?.status === 'failed') {
- message.warning(`评价已通过,但话题发布失败:${approved.topic_publication.message}`);
- } else {
- message.warning('评价已通过;配置公开评价群后可自动发布');
- }
- await refresh();
- };
- const submitReviewAction = async ({ reason }: { reason: string }) => {
- if (!reviewAction) return;
- await apiRequest(
- `/directory/reviews/${reviewAction.review.review_id}/actions`,
- jsonOptions('POST', { action: reviewAction.action, reason, confirm: true }),
- );
- setReviewAction(null);
- reviewReasonForm.resetFields();
- message.success('评价状态已更新并重算排行');
- await refresh();
- };
- const publishReviewTemplate = async (values: Pick<ServiceReviewTemplate, 'questions'>) => {
- const published = await apiRequest<ServiceReviewTemplate>(
- '/directory/review-template',
- jsonOptions('PUT', { ...values, confirm: true }),
- );
- setReviewTemplate(published);
- message.success(`评价模板 V${published.version} 已发布,仅影响后续评价`);
- };
- const blockCustomer = async ({ customer_id, reason }: { customer_id: string; reason: string }) => {
- await apiRequest(
- `/directory/customer-blocks/${customer_id}`,
- jsonOptions('POST', { active: true, reason, confirm: true }),
- );
- setBlockOpen(false);
- blockForm.resetFields();
- message.success('顾客已全局封禁');
- await refresh();
- };
- const unblockCustomer = (item: ServiceCustomerBlock) => {
- Modal.confirm({
- title: `解除顾客 ${item.customer_id} 的全局封禁?`,
- okText: '确认解除',
- onOk: async () => {
- await apiRequest(
- `/directory/customer-blocks/${item.customer_id}`,
- jsonOptions('POST', { active: false, reason: '管理员解除全局封禁', confirm: true }),
- );
- message.success('顾客封禁已解除');
- await refresh();
- },
- });
- };
- const submitReportAction = async ({ reason }: { reason: string }) => {
- if (!reportTarget) return;
- await apiRequest(
- `/directory/reports/${reportTarget.report.report_id}/actions`,
- jsonOptions('POST', { action: reportTarget.action, reason, confirm: true }),
- );
- setReportTarget(null);
- reportForm.resetFields();
- message.success('举报已处理');
- await refresh();
- };
- const templateColumns: ColumnsType<ServicePackageTemplate> = [
- {
- title: '模板',
- minWidth: 220,
- render: (_, item) => (
- <Space direction="vertical" size={0}>
- <Typography.Text strong>{item.name}</Typography.Text>
- <Typography.Text type="secondary">{item.package.category} · V{item.version}</Typography.Text>
- </Space>
- ),
- },
- {
- title: '价格 / 时长',
- minWidth: 220,
- render: (_, item) => (
- <Space direction="vertical" size={0}>
- <Typography.Text>{item.package.price_display || `${item.package.currency} ${item.package.min_price}`}</Typography.Text>
- <Typography.Text type="secondary">约 {item.package.duration_minutes || '-'} 分钟</Typography.Text>
- </Space>
- ),
- },
- { title: '排序', dataIndex: 'sort_order', width: 80 },
- { title: '状态', dataIndex: 'status', width: 100, render: (value) => <Tag color={value === 'enabled' ? 'success' : value === 'archived' ? 'default' : 'warning'}>{value === 'enabled' ? '启用' : value === 'archived' ? '已归档' : '停用'}</Tag> },
- {
- title: '操作',
- width: 300,
- fixed: 'right',
- render: (_, item) => (
- <Space wrap>
- <Button size="small" onClick={() => openTemplate(item)}>编辑</Button>
- <Button size="small" icon={<Copy size={14} />} onClick={() => templateAction(item, 'duplicate')}>复制</Button>
- {item.status !== 'archived' ? (
- <Button size="small" onClick={() => templateAction(item, item.status === 'enabled' ? 'disable' : 'enable')}>
- {item.status === 'enabled' ? '停用' : '启用'}
- </Button>
- ) : null}
- {item.status !== 'archived' ? <Button size="small" danger onClick={() => templateAction(item, 'archive')}>归档</Button> : null}
- </Space>
- ),
- },
- ];
- const orderColumns: ColumnsType<ServiceOrder> = [
- { title: '服务单', dataIndex: 'order_id', width: 120, render: (value) => <Typography.Text className="numeric">{value.slice(0, 8)}</Typography.Text> },
- { 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> },
- { 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> },
- { title: '最终报价', width: 140, render: (_, item) => item.quote_snapshot ? `${item.quote_snapshot.currency} ${item.quote_snapshot.total_amount}` : '-' },
- { title: '状态', dataIndex: 'status', width: 130, render: (value: ServiceOrderStatus) => <Tag color={statusColors[value]}>{statusLabels[value]}</Tag> },
- { title: '更新时间', dataIndex: 'updated_at', width: 160, responsive: ['lg'], render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
- {
- title: '操作',
- width: 250,
- fixed: 'right',
- render: (_, item) => (
- <Space wrap>
- <Button size="small" icon={<Eye size={14} />} onClick={() => void showOrder(item)}>详情</Button>
- <Button size="small" onClick={() => { addressForm.resetFields(); setAddressTarget(item); }}>查看地址</Button>
- {item.status === 'disputed' ? <Button size="small" onClick={() => { orderReasonForm.resetFields(); setOrderAction({ order: item, action: 'resume' }); }}>恢复履约</Button> : null}
- {item.status !== 'voided' ? <Button size="small" danger onClick={() => { orderReasonForm.resetFields(); setOrderAction({ order: item, action: 'void' }); }}>作废</Button> : null}
- </Space>
- ),
- },
- ];
- const reviewColumns: ColumnsType<ServiceReview> = [
- { title: '评价', dataIndex: 'review_id', width: 110, render: (value) => value.slice(0, 8) },
- { 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> },
- { title: '来源', dataIndex: 'source', width: 150, render: (value) => <Tag color={value === 'qr_verified' ? 'success' : 'blue'}>{value === 'qr_verified' ? '完成服务单评价' : '用户主动评价'}</Tag> },
- { title: '评分', dataIndex: 'score', width: 90, render: (value) => value.toFixed(2) },
- { 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> },
- {
- title: '评价内容',
- minWidth: 260,
- ellipsis: true,
- render: (_, item) =>
- [
- ...Object.values(item.choice_answers || {}).flat(),
- ...Object.values(item.text_answers || {}),
- ].join(';') || '-',
- },
- {
- title: '公开话题',
- width: 110,
- render: (_, item) =>
- item.topic_url ? <a href={item.topic_url} target="_blank" rel="noreferrer">查看</a> : '-',
- },
- { title: '状态', dataIndex: 'status', width: 100, render: (value) => <Tag>{value === 'pending' ? '待审核' : value === 'approved' ? '已批准' : value === 'rejected' ? '已拒绝' : '已作废'}</Tag> },
- {
- title: '操作',
- width: 200,
- fixed: 'right',
- render: (_, item) => (
- <Space>
- {item.status === 'pending' ? <Button size="small" type="primary" onClick={() => void approveReview(item)}>批准并发布</Button> : null}
- {item.status === 'pending' ? <Button size="small" danger onClick={() => { reviewReasonForm.resetFields(); setReviewAction({ review: item, action: 'reject' }); }}>拒绝</Button> : null}
- {item.status === 'approved' ? <Button size="small" danger onClick={() => { reviewReasonForm.resetFields(); setReviewAction({ review: item, action: 'void' }); }}>作废</Button> : null}
- </Space>
- ),
- },
- ];
- return (
- <>
- <PageHeader
- title="技师咨询与评价"
- actions={<Button icon={<RefreshCw size={16} />} loading={loading} onClick={() => void refresh()}>刷新</Button>}
- />
- <Alert
- showIcon
- type="info"
- message="咨询平台模式"
- description="顾客直接查看技师、私聊咨询并提交评价;管理员审核通过后,评价立即公开到该技师的独立话题并参与排行。"
- style={{ marginBottom: 16 }}
- />
- <Surface>
- <Tabs
- items={[
- {
- key: 'overview',
- label: '履约指标',
- children: (
- <>
- <Row gutter={[16, 16]}>
- <Col xs={12} lg={6}><Card><Statistic title="有效请求" value={metrics?.valid_requests || 0} /></Card></Col>
- <Col xs={12} lg={6}><Card><Statistic title="请求提交率" value={(metrics?.request_submission_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
- <Col xs={12} lg={6}><Card><Statistic title="报价率" value={(metrics?.quote_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
- <Col xs={12} lg={6}><Card><Statistic title="顾客确认率" value={(metrics?.customer_confirmation_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
- <Col xs={12} lg={6}><Card><Statistic title="撮合成功率" value={(metrics?.match_success_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
- <Col xs={12} lg={6}><Card><Statistic title="履约完成率" value={(metrics?.fulfillment_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
- <Col xs={12} lg={6}><Card><Statistic title="取消率" value={(metrics?.cancellation_rate || 0) * 100} precision={1} suffix="%" /></Card></Col>
- <Col xs={12} lg={6}><Card><Statistic title="取消方(顾客 / 技师)" value={`${metrics?.cancellation_distribution.customer || 0} / ${metrics?.cancellation_distribution.technician || 0}`} /></Card></Col>
- </Row>
- <Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>{metrics?.scope_note}</Typography.Paragraph>
- <Divider />
- <Typography.Title level={5}>公开评价话题</Typography.Title>
- <Alert
- showIcon
- type="warning"
- message="请使用已开启话题的公开超级群"
- description="把当前 Bot 设为管理员并授予管理话题权限。每位技师会自动创建独立话题,审核通过的评价会立即发布。"
- style={{ marginBottom: 16 }}
- />
- <Form form={settingsForm} layout="vertical" onFinish={(values) => void saveSettings(values)}>
- <Row gutter={16}>
- <Col xs={24} md={12}><Form.Item name="review_forum_chat_id" label="公开群 Chat ID" extra="例如:-1001234567890"><Input /></Form.Item></Col>
- <Col xs={24} md={12}><Form.Item name="review_forum_username" label="公开群用户名" extra="例如:technician_reviews,无需填写 @"><Input /></Form.Item></Col>
- </Row>
- <Button type="primary" htmlType="submit" disabled={!settings}>保存公开群设置</Button>
- </Form>
- </>
- ),
- },
- {
- key: 'templates',
- label: `套餐模板 (${templates.length})`,
- children: (
- <>
- <div className="page-toolbar"><Button type="primary" icon={<Plus size={16} />} onClick={() => openTemplate()}>新建模板</Button></div>
- <Table rowKey="template_id" columns={templateColumns} dataSource={templates} loading={loading} pagination={false} scroll={{ x: 1050 }} />
- </>
- ),
- },
- {
- key: 'orders',
- label: `服务订单 (${orders.total})`,
- children: (
- <>
- <div className="page-toolbar">
- <Select allowClear value={orderStatus || undefined} placeholder="全部状态" style={{ width: 170 }} onChange={(value) => setOrderStatus(value || '')} options={Object.entries(statusLabels).map(([value, label]) => ({ value, label }))} />
- <Input allowClear value={query} onChange={(event) => setQuery(event.target.value)} onPressEnter={() => void loadOrders(1)} placeholder="订单、技师或顾客" style={{ width: 260 }} />
- <Button onClick={() => void loadOrders(1)}>查询</Button>
- </div>
- <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 }} />
- </>
- ),
- },
- {
- key: 'reviews',
- label: `评价审核 (${reviews.total})`,
- children: (
- <>
- <div className="page-toolbar">
- <Select value={reviewStatus} style={{ width: 140 }} onChange={setReviewStatus} options={[{ value: 'pending', label: '待审核' }, { value: 'approved', label: '已批准' }, { value: 'rejected', label: '已拒绝' }, { value: 'voided', label: '已作废' }, { value: '', label: '全部' }]} />
- <Input allowClear value={query} onChange={(event) => setQuery(event.target.value)} onPressEnter={() => void loadReviews(1)} placeholder="技师、顾客或评价 ID" style={{ width: 260 }} />
- <Button onClick={() => void loadReviews(1)}>查询</Button>
- </div>
- <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 }} />
- </>
- ),
- },
- {
- key: 'review-template',
- label: `评价模板 V${reviewTemplate?.version || '-'}`,
- children: (
- <Form form={reviewTemplateForm} layout="vertical" onFinish={(values) => void publishReviewTemplate(values)}>
- <Alert showIcon type="warning" message="发布会创建新版本" description="支持单选、多选和输入框。单选项用于计算评分,所有单选问题的权重必须合计 100%;历史评价不受影响。" style={{ marginBottom: 16 }} />
- <Typography.Title level={5}>评价问题(1–8 个)</Typography.Title>
- <Form.List name="questions">
- {(fields, { add, remove }) => (
- <Space direction="vertical" size={16} style={{ width: '100%' }}>
- {fields.map((field) => (
- <Card
- key={field.key}
- size="small"
- title={`问题 ${field.name + 1}`}
- extra={<Button danger type="text" aria-label="删除问题" icon={<Trash2 size={16} />} onClick={() => remove(field.name)} />}
- >
- <Row gutter={12}>
- <Col xs={24} md={12}><Form.Item {...field} name={[field.name, 'label']} label="问题" rules={[{ required: true, message: '请输入问题' }]}><Input placeholder="例如:这次服务体验怎么样?" /></Form.Item></Col>
- <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>
- <Col xs={12} md={6}><Form.Item {...field} name={[field.name, 'required']} label="是否必填"><Select options={[{ value: true, label: '必填' }, { value: false, label: '可跳过' }]} /></Form.Item></Col>
- </Row>
- <Form.Item {...field} name={[field.name, 'description']} label="提示文字"><Input /></Form.Item>
- <Form.Item {...field} name={[field.name, 'question_id']} hidden><Input /></Form.Item>
- <Form.Item noStyle shouldUpdate>
- {({ getFieldValue }) => {
- const questionType = getFieldValue(['questions', field.name, 'type']);
- if (questionType === 'text') {
- return <Form.Item {...field} name={[field.name, 'max_length']} label="最大字数"><InputNumber min={50} max={1000} /></Form.Item>;
- }
- return (
- <>
- {questionType === 'single_choice' ? (
- <Form.Item {...field} name={[field.name, 'weight']} label="评分权重" rules={[{ required: true, message: '请输入权重' }]}><InputNumber min={1} max={100} addonAfter="%" /></Form.Item>
- ) : null}
- <Typography.Text strong>选项(2–8 个)</Typography.Text>
- <Form.List name={[field.name, 'options']}>
- {(optionFields, optionActions) => (
- <Space direction="vertical" style={{ width: '100%', marginTop: 8 }}>
- {optionFields.map((optionField) => (
- <Row gutter={8} key={optionField.key} align="middle">
- <Col flex="auto"><Form.Item {...optionField} name={[optionField.name, 'label']} rules={[{ required: true, message: '请输入选项' }]} style={{ marginBottom: 0 }}><Input placeholder="选项文字" /></Form.Item></Col>
- {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}
- <Col flex="44px"><Button danger type="text" aria-label="删除选项" icon={<Trash2 size={16} />} onClick={() => optionActions.remove(optionField.name)} /></Col>
- <Form.Item {...optionField} name={[optionField.name, 'value']} hidden><Input /></Form.Item>
- </Row>
- ))}
- <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>
- </Space>
- )}
- </Form.List>
- </>
- );
- }}
- </Form.Item>
- </Card>
- ))}
- <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>
- </Space>
- )}
- </Form.List>
- <Divider />
- <Button type="primary" htmlType="submit">发布新版评价模板</Button>
- </Form>
- ),
- },
- {
- key: 'ranking-risk',
- label: '排行与风控',
- children: (
- <>
- <div className="page-toolbar"><Button danger icon={<ShieldBan size={16} />} onClick={() => setBlockOpen(true)}>全局封禁顾客</Button></div>
- <Typography.Title level={5}>审核评价排行</Typography.Title>
- <Table rowKey={(item) => `${item.technician_id}:${item.category}`} dataSource={leaderboard.items} pagination={false} columns={[
- { title: '技师', render: (_, item) => `${item.display_name || item.technician_id}${item.username ? ` (@${item.username})` : ''}` },
- { title: '分类', dataIndex: 'category' },
- { title: '排行分', dataIndex: 'rank_score', render: (value) => value.toFixed(3) },
- { title: '平均分', dataIndex: 'average_score', render: (value) => value.toFixed(2) },
- { title: '批准评价', dataIndex: 'review_count' },
- ]} />
- <Divider />
- <Typography.Title level={5}>全局顾客黑名单</Typography.Title>
- <Table rowKey={(item) => `${item.scope}:${item.customer_id}`} dataSource={customerBlocks.items} pagination={false} columns={[
- { title: '顾客 ID', dataIndex: 'customer_id' },
- { title: '原因', dataIndex: 'reason' },
- { title: '更新时间', dataIndex: 'updated_at', render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
- { title: '操作', render: (_, item) => <Button size="small" onClick={() => unblockCustomer(item)}>解除封禁</Button> },
- ]} />
- <Divider />
- <Typography.Title level={5}>待处理举报</Typography.Title>
- <Table rowKey="report_id" dataSource={reports.items} pagination={false} columns={[
- { title: '举报', dataIndex: 'report_id', render: (value) => value.slice(0, 8) },
- { title: '举报人', dataIndex: 'reporter_id' },
- { title: '对象', render: (_, item) => `${item.target_type} · ${item.target_id.slice(0, 12)}` },
- { title: '原因', dataIndex: 'reason' },
- { 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> },
- ]} />
- <Divider />
- <Typography.Title level={5}>完成评价二维码记录</Typography.Title>
- <Table rowKey="qr_id" dataSource={qrs.items} pagination={false} columns={[
- { title: '二维码', dataIndex: 'qr_id', render: (value) => value.slice(0, 8) },
- { title: '服务单', dataIndex: 'order_id', render: (value) => value.slice(0, 8) },
- { title: '技师 / 顾客', render: (_, item) => `${item.technician_id} / ${item.customer_id}` },
- { title: '状态', dataIndex: 'status' },
- { title: '过期时间', dataIndex: 'expires_at', render: (value) => dayjs(value).format('YYYY-MM-DD HH:mm') },
- ]} />
- </>
- ),
- },
- ]}
- />
- </Surface>
- <Modal title={templateTarget ? '编辑套餐模板' : '新建套餐模板'} open={templateOpen} width={760} onCancel={() => { setTemplateOpen(false); templateForm.resetFields(); }} onOk={() => templateForm.submit()} okText="保存快照版本">
- <Form form={templateForm} layout="vertical" onFinish={(values) => void saveTemplate(values)}>
- <Row gutter={16}>
- <Col xs={24} md={16}><Form.Item name="template_name" label="后台模板名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
- <Col xs={24} md={8}><Form.Item name="sort_order" label="排序"><InputNumber style={{ width: '100%' }} /></Form.Item></Col>
- </Row>
- <Form.Item name="admin_note" label="后台备注"><Input /></Form.Item>
- <Divider />
- <Row gutter={16}>
- <Col xs={24} md={12}><Form.Item name="name" label="默认服务名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
- <Col xs={24} md={12}><Form.Item name="category" label="服务分类" rules={[{ required: true }]}><Input /></Form.Item></Col>
- </Row>
- <Form.Item name="price_display" label="默认服务价格" extra="直接填写给技师和顾客看的价格文字,例如:300 元/次起。" rules={[{ required: true }]}><Input /></Form.Item>
- <Form.Item name="description" label="默认服务内容" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item>
- <Row gutter={16}>
- <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>
- <Col xs={24} md={16}><Form.Item name="additional_fee_description" label="默认额外费用说明" rules={[{ required: true }]}><Input placeholder="例如:上门车费根据距离另行沟通" /></Form.Item></Col>
- </Row>
- </Form>
- </Modal>
- <Drawer title={`服务单 ${detail?.order_id.slice(0, 8) || ''}`} open={Boolean(detail)} width={680} onClose={() => setDetail(null)}>
- {detail ? (
- <>
- <Descriptions column={1} bordered size="small" items={[
- { key: 'status', label: '状态', children: statusLabels[detail.status] },
- { key: 'people', label: '技师 / 顾客', children: `${detail.technician_name} (${detail.technician_id}) / ${detail.customer_name} (${detail.customer_id})` },
- { key: 'package', label: '套餐', children: `${detail.package_snapshot.name} · ${detail.category}` },
- { key: 'mode', label: '方式 / 距离', children: `${detail.service_mode === 'at_store' ? '到店' : '上门'} · ${detail.distance_band}` },
- { key: 'requirements', label: '需求', children: detail.requirements },
- { key: 'quote', label: '最终报价', children: detail.quote_snapshot ? `${detail.quote_snapshot.currency} ${detail.quote_snapshot.total_amount}` : '-' },
- { key: 'reason', label: '关闭/争议原因', children: detail.close_reason || detail.dispute_reason || '-' },
- ]} />
- <Divider />
- <Typography.Title level={5}>状态轨迹</Typography.Title>
- {(detail.events || []).map((event, index) => (
- <Typography.Paragraph key={String(event.event_id || index)}>
- {dayjs(String(event.created_at)).format('YYYY-MM-DD HH:mm:ss')} · {String(event.event_type)} · {String(event.reason || '')}
- </Typography.Paragraph>
- ))}
- </>
- ) : null}
- </Drawer>
- <Modal title={orderAction?.action === 'void' ? '作废服务单' : '恢复争议服务单'} open={Boolean(orderAction)} onCancel={() => setOrderAction(null)} onOk={() => orderReasonForm.submit()} okText="确认执行" okButtonProps={{ danger: orderAction?.action === 'void' }}>
- <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>
- </Modal>
- <Modal title="查看精确地址" open={Boolean(addressTarget)} onCancel={() => setAddressTarget(null)} onOk={() => addressForm.submit()} okText="确认查看" okButtonProps={{ danger: true }}>
- <Alert showIcon type="error" message="精确地址属于敏感信息" description="必须填写真实争议处理原因。本次访问会写入服务单事件和管理员审计。" style={{ marginBottom: 16 }} />
- <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>
- </Modal>
- <Modal title={reviewAction?.action === 'void' ? '作废已批准评价' : '拒绝评价'} open={Boolean(reviewAction)} onCancel={() => setReviewAction(null)} onOk={() => reviewReasonForm.submit()} okText="确认执行" okButtonProps={{ danger: true }}>
- <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>
- </Modal>
- <Modal title="全局封禁顾客" open={blockOpen} onCancel={() => setBlockOpen(false)} onOk={() => blockForm.submit()} okText="确认封禁" okButtonProps={{ danger: true }}>
- <Form form={blockForm} layout="vertical" onFinish={(values) => void blockCustomer(values)}>
- <Form.Item name="customer_id" label="Telegram 用户 ID" rules={[{ required: true }]}><Input /></Form.Item>
- <Form.Item name="reason" label="封禁原因" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item>
- </Form>
- </Modal>
- <Modal title={reportTarget?.action === 'resolve' ? '标记举报已处理' : '驳回举报'} open={Boolean(reportTarget)} onCancel={() => setReportTarget(null)} onOk={() => reportForm.submit()} okText="确认处理">
- <Form form={reportForm} layout="vertical" onFinish={(values) => void submitReportAction(values)}>
- <Form.Item name="reason" label="处理说明" rules={[{ required: true }]}><Input.TextArea rows={4} /></Form.Item>
- </Form>
- </Modal>
- </>
- );
- }
|