|
@@ -0,0 +1,897 @@
|
|
|
|
|
+import {
|
|
|
|
|
+ Alert,
|
|
|
|
|
+ Button,
|
|
|
|
|
+ Checkbox,
|
|
|
|
|
+ Collapse,
|
|
|
|
|
+ Empty,
|
|
|
|
|
+ Form,
|
|
|
|
|
+ Input,
|
|
|
|
|
+ InputNumber,
|
|
|
|
|
+ message,
|
|
|
|
|
+ Modal,
|
|
|
|
|
+ Radio,
|
|
|
|
|
+ Select,
|
|
|
|
|
+ Spin,
|
|
|
|
|
+ Switch,
|
|
|
|
|
+ Tag,
|
|
|
|
|
+ Typography,
|
|
|
|
|
+} from 'antd';
|
|
|
|
|
+import {
|
|
|
|
|
+ ArrowLeft,
|
|
|
|
|
+ Check,
|
|
|
|
|
+ Clock3,
|
|
|
|
|
+ MapPin,
|
|
|
|
|
+ Pencil,
|
|
|
|
|
+ Plus,
|
|
|
|
|
+ SlidersHorizontal,
|
|
|
|
|
+} from 'lucide-react';
|
|
|
|
|
+import { useEffect, useMemo, useState } from 'react';
|
|
|
|
|
+
|
|
|
|
|
+import {
|
|
|
|
|
+ initializeTelegramMiniApp,
|
|
|
|
|
+ TechnicianApiError,
|
|
|
|
|
+ technicianApiRequest,
|
|
|
|
|
+ technicianJsonOptions,
|
|
|
|
|
+} from '@/services/technicianApi';
|
|
|
|
|
+import type {
|
|
|
|
|
+ ServiceMode,
|
|
|
|
|
+ ServicePackage,
|
|
|
|
|
+ TechnicianMiniAppBootstrap,
|
|
|
|
|
+ TechnicianSelfServiceContext,
|
|
|
|
|
+ TechnicianServiceProfile,
|
|
|
|
|
+} from '@/types';
|
|
|
|
|
+
|
|
|
|
|
+import styles from './TechnicianMiniApp.module.less';
|
|
|
|
|
+
|
|
|
|
|
+type TemplateItem = TechnicianMiniAppBootstrap['templates'][number];
|
|
|
|
|
+
|
|
|
|
|
+interface PackageEditor {
|
|
|
|
|
+ index: number | null;
|
|
|
|
|
+ package: ServicePackage;
|
|
|
|
|
+ templatePackage?: ServicePackage;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface PackageFormValue {
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ category: string;
|
|
|
|
|
+ description: string;
|
|
|
|
|
+ currency: string;
|
|
|
|
|
+ price_mode: ServicePackage['price_mode'];
|
|
|
|
|
+ price_unit: ServicePackage['price_unit'];
|
|
|
|
|
+ min_price?: number;
|
|
|
|
|
+ max_price?: number;
|
|
|
|
|
+ duration_minutes: number;
|
|
|
|
|
+ service_modes: ServiceMode[];
|
|
|
|
|
+ service_radius_km?: number;
|
|
|
|
|
+ public_area_text: string;
|
|
|
|
|
+ venue_name?: string;
|
|
|
|
|
+ venue_address?: string;
|
|
|
|
|
+ included_items?: string;
|
|
|
|
|
+ excluded_items?: string;
|
|
|
|
|
+ preparation?: string;
|
|
|
|
|
+ out_of_range_policy?: string;
|
|
|
|
|
+ travel_fee_mode: ServicePackage['travel_fee']['mode'];
|
|
|
|
|
+ travel_fee_amount?: number;
|
|
|
|
|
+ travel_fee_per_km?: number;
|
|
|
|
|
+ profile_headline?: string;
|
|
|
|
|
+ profile_bio?: string;
|
|
|
|
|
+ contact_hours?: string;
|
|
|
|
|
+ onsite_description?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const modeOptions = [
|
|
|
|
|
+ { label: '到店', value: 'at_store' },
|
|
|
|
|
+ { label: '上门', value: 'onsite' },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+const priceModeOptions = [
|
|
|
|
|
+ { label: '固定价', value: 'fixed' },
|
|
|
|
|
+ { label: '起步价', value: 'starting_at' },
|
|
|
|
|
+ { label: '区间价', value: 'range' },
|
|
|
|
|
+ { label: '面议', value: 'negotiable' },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+const priceUnitOptions = [
|
|
|
|
|
+ { label: '每次', value: 'per_service' },
|
|
|
|
|
+ { label: '每小时', value: 'per_hour' },
|
|
|
|
|
+ { label: '每件', value: 'per_item' },
|
|
|
|
|
+ { label: '每次上门', value: 'per_visit' },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+const travelModeOptions = [
|
|
|
|
|
+ { label: '已包含', value: 'included' },
|
|
|
|
|
+ { label: '固定交通费', value: 'fixed' },
|
|
|
|
|
+ { label: '按公里', value: 'per_km' },
|
|
|
|
|
+ { label: '报价时确认', value: 'quoted' },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+function clonePackage(value: ServicePackage): ServicePackage {
|
|
|
|
|
+ return JSON.parse(JSON.stringify(value)) as ServicePackage;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function newPackageId(): string {
|
|
|
|
|
+ if (typeof crypto.randomUUID === 'function') {
|
|
|
|
|
+ return crypto.randomUUID().replaceAll('-', '');
|
|
|
|
|
+ }
|
|
|
|
|
+ return `${Date.now()}${Math.random().toString(16).slice(2)}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function numeric(value?: string | number | null): number | undefined {
|
|
|
|
|
+ if (value === undefined || value === null || value === '') return undefined;
|
|
|
|
|
+ const parsed = Number(value);
|
|
|
|
|
+ return Number.isFinite(parsed) ? parsed : undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function packagePrice(packageItem: ServicePackage): string {
|
|
|
|
|
+ if (packageItem.price_mode === 'negotiable') return '价格面议';
|
|
|
|
|
+ const unit = {
|
|
|
|
|
+ per_service: '次',
|
|
|
|
|
+ per_hour: '小时',
|
|
|
|
|
+ per_item: '件',
|
|
|
|
|
+ per_visit: '次上门',
|
|
|
|
|
+ }[packageItem.price_unit];
|
|
|
|
|
+ if (packageItem.price_mode === 'range') {
|
|
|
|
|
+ return `${packageItem.currency} ${packageItem.min_price}–${packageItem.max_price}/${unit}`;
|
|
|
|
|
+ }
|
|
|
|
|
+ const suffix = packageItem.price_mode === 'starting_at' ? ' 起' : '';
|
|
|
|
|
+ return `${packageItem.currency} ${packageItem.min_price}${suffix}/${unit}`;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function templatePackage(template: TemplateItem): ServicePackage {
|
|
|
|
|
+ return {
|
|
|
|
|
+ ...clonePackage(template.package),
|
|
|
|
|
+ package_id: newPackageId(),
|
|
|
|
|
+ source_type: 'template',
|
|
|
|
|
+ source_template_id: template.template_id,
|
|
|
|
|
+ source_template_version: template.version,
|
|
|
|
|
+ customized_from_template: false,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function editablePackageValues(
|
|
|
|
|
+ packageItem: ServicePackage,
|
|
|
|
|
+ profile?: TechnicianServiceProfile | null,
|
|
|
|
|
+): PackageFormValue {
|
|
|
|
|
+ return {
|
|
|
|
|
+ name: packageItem.name,
|
|
|
|
|
+ category: packageItem.category,
|
|
|
|
|
+ description: packageItem.description,
|
|
|
|
|
+ currency: packageItem.currency,
|
|
|
|
|
+ price_mode: packageItem.price_mode,
|
|
|
|
|
+ price_unit: packageItem.price_unit,
|
|
|
|
|
+ min_price: numeric(packageItem.min_price),
|
|
|
|
|
+ max_price: numeric(packageItem.max_price),
|
|
|
|
|
+ duration_minutes: packageItem.duration_minutes || 60,
|
|
|
|
|
+ service_modes: packageItem.service_modes,
|
|
|
|
|
+ service_radius_km: numeric(packageItem.service_radius_km),
|
|
|
|
|
+ public_area_text: profile?.public_area_text || '',
|
|
|
|
|
+ venue_name: profile?.venue?.name || '工作地点',
|
|
|
|
|
+ venue_address: profile?.venue?.address_hint || '',
|
|
|
|
|
+ included_items: packageItem.included_items,
|
|
|
|
|
+ excluded_items: packageItem.excluded_items,
|
|
|
|
|
+ preparation: packageItem.preparation,
|
|
|
|
|
+ out_of_range_policy: packageItem.out_of_range_policy,
|
|
|
|
|
+ travel_fee_mode: packageItem.travel_fee.mode,
|
|
|
|
|
+ travel_fee_amount: numeric(packageItem.travel_fee.amount),
|
|
|
|
|
+ travel_fee_per_km: numeric(packageItem.travel_fee.per_km),
|
|
|
|
|
+ profile_headline: profile?.headline || packageItem.name,
|
|
|
|
|
+ profile_bio: profile?.bio || packageItem.description,
|
|
|
|
|
+ contact_hours: profile?.contact_hours || '请通过 Telegram 私聊确认服务时间',
|
|
|
|
|
+ onsite_description:
|
|
|
|
|
+ profile?.onsite_policy?.description ||
|
|
|
|
|
+ `${packageItem.service_radius_km || 20} 公里内可上门,超区请先沟通。`,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function comparablePackage(value: ServicePackage): string {
|
|
|
|
|
+ const copy: Record<string, unknown> = { ...clonePackage(value) };
|
|
|
|
|
+ delete copy.package_id;
|
|
|
|
|
+ delete copy.source_template_id;
|
|
|
|
|
+ delete copy.source_template_version;
|
|
|
|
|
+ delete copy.customized_from_template;
|
|
|
|
|
+ return JSON.stringify(copy);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildPackage(
|
|
|
|
|
+ values: PackageFormValue,
|
|
|
|
|
+ editor: PackageEditor,
|
|
|
|
|
+): ServicePackage {
|
|
|
|
|
+ const minimum = values.price_mode === 'negotiable' ? 0 : values.min_price || 0;
|
|
|
|
|
+ const maximum =
|
|
|
|
|
+ values.price_mode === 'range' ? values.max_price || minimum : minimum;
|
|
|
|
|
+ const packageItem: ServicePackage = {
|
|
|
|
|
+ ...editor.package,
|
|
|
|
|
+ name: values.name.trim(),
|
|
|
|
|
+ category: values.category.trim(),
|
|
|
|
|
+ description: values.description.trim(),
|
|
|
|
|
+ service_modes: values.service_modes,
|
|
|
|
|
+ price_mode: values.price_mode,
|
|
|
|
|
+ currency: values.currency.toUpperCase(),
|
|
|
|
|
+ price_unit: values.price_unit,
|
|
|
|
|
+ min_price: String(minimum),
|
|
|
|
|
+ max_price: String(maximum),
|
|
|
|
|
+ duration_minutes: values.duration_minutes,
|
|
|
|
|
+ service_radius_km: values.service_modes.includes('onsite')
|
|
|
|
|
+ ? values.service_radius_km
|
|
|
|
|
+ : null,
|
|
|
|
|
+ included_items: values.included_items?.trim() || '',
|
|
|
|
|
+ excluded_items: values.excluded_items?.trim() || '',
|
|
|
|
|
+ preparation: values.preparation?.trim() || '',
|
|
|
|
|
+ out_of_range_policy: values.service_modes.includes('onsite')
|
|
|
|
|
+ ? values.out_of_range_policy?.trim() ||
|
|
|
|
|
+ '超出服务半径时,请先沟通交通费和是否可以接单。'
|
|
|
|
|
+ : '',
|
|
|
|
|
+ travel_fee: {
|
|
|
|
|
+ mode: values.travel_fee_mode,
|
|
|
|
|
+ amount: String(values.travel_fee_amount || 0),
|
|
|
|
|
+ per_km: String(values.travel_fee_per_km || 0),
|
|
|
|
|
+ },
|
|
|
|
|
+ addons: editor.package.addons || [],
|
|
|
|
|
+ };
|
|
|
|
|
+ if (packageItem.source_type === 'template' && editor.templatePackage) {
|
|
|
|
|
+ packageItem.customized_from_template =
|
|
|
|
|
+ comparablePackage(packageItem) !== comparablePackage(editor.templatePackage);
|
|
|
|
|
+ } else if (packageItem.source_type === 'template') {
|
|
|
|
|
+ packageItem.customized_from_template = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ return packageItem;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function buildProfile(
|
|
|
|
|
+ values: PackageFormValue,
|
|
|
|
|
+ packageItem: ServicePackage,
|
|
|
|
|
+ context: TechnicianSelfServiceContext,
|
|
|
|
|
+ editorIndex: number | null,
|
|
|
|
|
+): TechnicianServiceProfile {
|
|
|
|
|
+ const current = context.service_profile;
|
|
|
|
|
+ const packages = [...(current?.packages || [])];
|
|
|
|
|
+ if (editorIndex === null) packages.push(packageItem);
|
|
|
|
|
+ else packages[editorIndex] = packageItem;
|
|
|
|
|
+ const tags = Array.from(
|
|
|
|
|
+ new Set(packages.flatMap((item) => item.tags || []).filter(Boolean)),
|
|
|
|
|
+ );
|
|
|
|
|
+ const allModes = new Set(packages.flatMap((item) => item.service_modes));
|
|
|
|
|
+ return {
|
|
|
|
|
+ headline: values.profile_headline?.trim() || packageItem.name,
|
|
|
|
|
+ bio: values.profile_bio?.trim() || packageItem.description,
|
|
|
|
|
+ tags,
|
|
|
|
|
+ contact_hours:
|
|
|
|
|
+ values.contact_hours?.trim() || '请通过 Telegram 私聊确认服务时间',
|
|
|
|
|
+ service_modes: Array.from(allModes),
|
|
|
|
|
+ accepting_requests: current?.accepting_requests ?? true,
|
|
|
|
|
+ public_area_text: values.public_area_text.trim(),
|
|
|
|
|
+ venue: {
|
|
|
|
|
+ name:
|
|
|
|
|
+ values.venue_name?.trim() ||
|
|
|
|
|
+ current?.venue?.name ||
|
|
|
|
|
+ (allModes.has('at_store') ? '工作地点' : ''),
|
|
|
|
|
+ address_hint:
|
|
|
|
|
+ values.venue_address?.trim() || current?.venue?.address_hint || '',
|
|
|
|
|
+ },
|
|
|
|
|
+ onsite_policy: {
|
|
|
|
|
+ description:
|
|
|
|
|
+ values.onsite_description?.trim() ||
|
|
|
|
|
+ current?.onsite_policy?.description ||
|
|
|
|
|
+ (allModes.has('onsite')
|
|
|
|
|
+ ? `${packageItem.service_radius_km || 20} 公里内可上门,超区请先沟通。`
|
|
|
|
|
+ : ''),
|
|
|
|
|
+ },
|
|
|
|
|
+ packages,
|
|
|
|
|
+ is_complete: true,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default function TechnicianMiniApp() {
|
|
|
|
|
+ const [form] = Form.useForm<PackageFormValue>();
|
|
|
|
|
+ const [bootstrap, setBootstrap] = useState<TechnicianMiniAppBootstrap | null>(null);
|
|
|
|
|
+ const [editor, setEditor] = useState<PackageEditor | null>(null);
|
|
|
|
|
+ const [loading, setLoading] = useState(true);
|
|
|
|
|
+ const [saving, setSaving] = useState(false);
|
|
|
|
|
+ const [availabilitySaving, setAvailabilitySaving] = useState(false);
|
|
|
|
|
+ const [pageError, setPageError] = useState('');
|
|
|
|
|
+ const serviceModes = Form.useWatch('service_modes', form) || [];
|
|
|
|
|
+ const priceMode = Form.useWatch('price_mode', form);
|
|
|
|
|
+ const travelMode = Form.useWatch('travel_fee_mode', form);
|
|
|
|
|
+ const currency = Form.useWatch('currency', form) || 'CNY';
|
|
|
|
|
+ const currencyPrefix = currency === 'CNY' ? '¥' : currency;
|
|
|
|
|
+
|
|
|
|
|
+ const load = async () => {
|
|
|
|
|
+ setLoading(true);
|
|
|
|
|
+ setPageError('');
|
|
|
|
|
+ try {
|
|
|
|
|
+ initializeTelegramMiniApp();
|
|
|
|
|
+ setBootstrap(
|
|
|
|
|
+ await technicianApiRequest<TechnicianMiniAppBootstrap>('/bootstrap'),
|
|
|
|
|
+ );
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ setPageError(error instanceof Error ? error.message : '页面加载失败,请重试。');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setLoading(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ void load();
|
|
|
|
|
+ }, []);
|
|
|
|
|
+
|
|
|
|
|
+ const context = bootstrap?.technician;
|
|
|
|
|
+ const packages = context?.service_profile?.packages || [];
|
|
|
|
|
+ const canAdd = packages.length < (bootstrap?.limits.max_packages || 5);
|
|
|
|
|
+
|
|
|
|
|
+ const selectedTemplate = useMemo(() => {
|
|
|
|
|
+ if (!editor?.package.source_template_id) return undefined;
|
|
|
|
|
+ return bootstrap?.templates.find(
|
|
|
|
|
+ (item) => item.template_id === editor.package.source_template_id,
|
|
|
|
|
+ );
|
|
|
|
|
+ }, [bootstrap?.templates, editor?.package.source_template_id]);
|
|
|
|
|
+
|
|
|
|
|
+ const openTemplate = (template: TemplateItem) => {
|
|
|
|
|
+ const packageItem = templatePackage(template);
|
|
|
|
|
+ setEditor({
|
|
|
|
|
+ index: null,
|
|
|
|
|
+ package: packageItem,
|
|
|
|
|
+ templatePackage: {
|
|
|
|
|
+ ...clonePackage(template.package),
|
|
|
|
|
+ package_id: packageItem.package_id,
|
|
|
|
|
+ source_type: 'template',
|
|
|
|
|
+ source_template_id: template.template_id,
|
|
|
|
|
+ source_template_version: template.version,
|
|
|
|
|
+ customized_from_template: false,
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ form.setFieldsValue(
|
|
|
|
|
+ editablePackageValues(packageItem, context?.service_profile),
|
|
|
|
|
+ );
|
|
|
|
|
+ window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const openExisting = (packageItem: ServicePackage, index: number) => {
|
|
|
|
|
+ setEditor({ index, package: clonePackage(packageItem) });
|
|
|
|
|
+ form.setFieldsValue(
|
|
|
|
|
+ editablePackageValues(packageItem, context?.service_profile),
|
|
|
|
|
+ );
|
|
|
|
|
+ window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const applyContext = (updated: TechnicianSelfServiceContext) => {
|
|
|
|
|
+ setBootstrap((current) =>
|
|
|
|
|
+ current ? { ...current, technician: updated } : current,
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const save = async (values: PackageFormValue) => {
|
|
|
|
|
+ if (!editor || !context) return;
|
|
|
|
|
+ setSaving(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ const packageItem = buildPackage(values, editor);
|
|
|
|
|
+ const profile = buildProfile(values, packageItem, context, editor.index);
|
|
|
|
|
+ const updated = await technicianApiRequest<TechnicianSelfServiceContext>(
|
|
|
|
|
+ '/profile',
|
|
|
|
|
+ technicianJsonOptions('PUT', { service_profile: profile }),
|
|
|
|
|
+ );
|
|
|
|
|
+ applyContext(updated);
|
|
|
|
|
+ setEditor(null);
|
|
|
|
|
+ form.resetFields();
|
|
|
|
|
+ message.success(editor.index === null ? '套餐已发布' : '套餐已更新');
|
|
|
|
|
+ window.Telegram?.WebApp?.expand();
|
|
|
|
|
+ window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const code = error instanceof TechnicianApiError ? error.code : '';
|
|
|
|
|
+ const fieldByCode: Record<string, keyof PackageFormValue> = {
|
|
|
|
|
+ invalid_price: 'min_price',
|
|
|
|
|
+ invalid_price_range: 'max_price',
|
|
|
|
|
+ invalid_fixed_price: 'min_price',
|
|
|
|
|
+ invalid_duration: 'duration_minutes',
|
|
|
|
|
+ service_radius_required: 'service_radius_km',
|
|
|
|
|
+ invalid_service_radius: 'service_radius_km',
|
|
|
|
|
+ required_field: 'public_area_text',
|
|
|
|
|
+ };
|
|
|
|
|
+ const field = fieldByCode[code];
|
|
|
|
|
+ const errorMessage =
|
|
|
|
|
+ error instanceof Error ? error.message : '发布失败,请检查后重试。';
|
|
|
|
|
+ if (field) {
|
|
|
|
|
+ form.setFields([{ name: field, errors: [errorMessage] }]);
|
|
|
|
|
+ form.scrollToField(field, { behavior: 'smooth', block: 'center' });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ message.error(errorMessage);
|
|
|
|
|
+ }
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setSaving(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const toggleAvailability = async (checked: boolean) => {
|
|
|
|
|
+ if (!context?.service_profile) return;
|
|
|
|
|
+ setAvailabilitySaving(true);
|
|
|
|
|
+ try {
|
|
|
|
|
+ applyContext(
|
|
|
|
|
+ await technicianApiRequest<TechnicianSelfServiceContext>(
|
|
|
|
|
+ '/availability',
|
|
|
|
|
+ technicianJsonOptions('PATCH', { accepting_requests: checked }),
|
|
|
|
|
+ ),
|
|
|
|
|
+ );
|
|
|
|
|
+ message.success(checked ? '已开始接单' : '已暂停接单');
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ message.error(error instanceof Error ? error.message : '接单状态更新失败');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ setAvailabilitySaving(false);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const deleteCurrentPackage = () => {
|
|
|
|
|
+ if (!editor || editor.index === null || !context?.service_profile) return;
|
|
|
|
|
+ Modal.confirm({
|
|
|
|
|
+ title: '删除这个套餐?',
|
|
|
|
|
+ content: '历史服务单不会受影响。',
|
|
|
|
|
+ okText: '删除',
|
|
|
|
|
+ okButtonProps: { danger: true },
|
|
|
|
|
+ cancelText: '取消',
|
|
|
|
|
+ async onOk() {
|
|
|
|
|
+ const profile = context.service_profile;
|
|
|
|
|
+ if (!profile) return;
|
|
|
|
|
+ const updated = await technicianApiRequest<TechnicianSelfServiceContext>(
|
|
|
|
|
+ '/profile',
|
|
|
|
|
+ technicianJsonOptions('PUT', {
|
|
|
|
|
+ service_profile: {
|
|
|
|
|
+ ...profile,
|
|
|
|
|
+ packages: profile.packages.filter((_, index) => index !== editor.index),
|
|
|
|
|
+ },
|
|
|
|
|
+ }),
|
|
|
|
|
+ );
|
|
|
|
|
+ applyContext(updated);
|
|
|
|
|
+ setEditor(null);
|
|
|
|
|
+ form.resetFields();
|
|
|
|
|
+ message.success('套餐已删除');
|
|
|
|
|
+ },
|
|
|
|
|
+ });
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (loading) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <main className={styles.centerState}>
|
|
|
|
|
+ <Spin size="large" />
|
|
|
|
|
+ <span>正在确认技师身份…</span>
|
|
|
|
|
+ </main>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (pageError || !bootstrap || !context) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <main className={styles.centerState}>
|
|
|
|
|
+ <Alert
|
|
|
|
|
+ type="error"
|
|
|
|
|
+ showIcon
|
|
|
|
|
+ message="无法打开技师套餐"
|
|
|
|
|
+ description={pageError || '请从 Telegram Bot 重新打开。'}
|
|
|
|
|
+ />
|
|
|
|
|
+ <Button size="large" onClick={() => void load()}>
|
|
|
|
|
+ 重试
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </main>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (editor) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <main className={styles.appShell}>
|
|
|
|
|
+ <header className={styles.editorHeader}>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ type="text"
|
|
|
|
|
+ className={styles.backButton}
|
|
|
|
|
+ icon={<ArrowLeft size={20} />}
|
|
|
|
|
+ onClick={() => setEditor(null)}
|
|
|
|
|
+ >
|
|
|
|
|
+ 返回
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <Typography.Title level={1}>
|
|
|
|
|
+ {editor.index === null ? '发布新套餐' : '编辑套餐'}
|
|
|
|
|
+ </Typography.Title>
|
|
|
|
|
+ <Typography.Text type="secondary">
|
|
|
|
|
+ {selectedTemplate ? `已选:${selectedTemplate.name}` : '修改后直接发布'}
|
|
|
|
|
+ </Typography.Text>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ <Form
|
|
|
|
|
+ form={form}
|
|
|
|
|
+ layout="vertical"
|
|
|
|
|
+ requiredMark={false}
|
|
|
|
|
+ onFinish={(values) => void save(values)}
|
|
|
|
|
+ className={styles.editorForm}
|
|
|
|
|
+ >
|
|
|
|
|
+ <section className={styles.formSection}>
|
|
|
|
|
+ <div className={styles.sectionTitle}>
|
|
|
|
|
+ <span>只确认这些内容</span>
|
|
|
|
|
+ <Tag color="green">约 1 分钟</Tag>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="name"
|
|
|
|
|
+ label="套餐名称"
|
|
|
|
|
+ rules={[{ required: true, message: '请填写套餐名称' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input size="large" maxLength={50} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ <Form.Item name="price_mode" label="价格方式">
|
|
|
|
|
+ <Radio.Group
|
|
|
|
|
+ optionType="button"
|
|
|
|
|
+ buttonStyle="solid"
|
|
|
|
|
+ options={priceModeOptions}
|
|
|
|
|
+ className={styles.choiceGrid}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ {priceMode !== 'negotiable' ? (
|
|
|
|
|
+ <div className={styles.priceRow}>
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="min_price"
|
|
|
|
|
+ label={priceMode === 'range' ? '最低价' : '服务价格'}
|
|
|
|
|
+ rules={[{ required: true, message: '请填写价格' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <InputNumber
|
|
|
|
|
+ size="large"
|
|
|
|
|
+ min={0}
|
|
|
|
|
+ precision={2}
|
|
|
|
|
+ prefix={currencyPrefix}
|
|
|
|
|
+ inputMode="decimal"
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ {priceMode === 'range' ? (
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="max_price"
|
|
|
|
|
+ label="最高价"
|
|
|
|
|
+ dependencies={['min_price']}
|
|
|
|
|
+ rules={[
|
|
|
|
|
+ { required: true, message: '请填写最高价' },
|
|
|
|
|
+ ({ getFieldValue }) => ({
|
|
|
|
|
+ validator(_, value) {
|
|
|
|
|
+ return Number(value) >= Number(getFieldValue('min_price'))
|
|
|
|
|
+ ? Promise.resolve()
|
|
|
|
|
+ : Promise.reject(new Error('最高价不能低于最低价'));
|
|
|
|
|
+ },
|
|
|
|
|
+ }),
|
|
|
|
|
+ ]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <InputNumber
|
|
|
|
|
+ size="large"
|
|
|
|
|
+ min={0}
|
|
|
|
|
+ precision={2}
|
|
|
|
|
+ prefix={currencyPrefix}
|
|
|
|
|
+ inputMode="decimal"
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ <Form.Item name="price_unit" label="计价单位">
|
|
|
|
|
+ <Select size="large" options={priceUnitOptions} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+
|
|
|
|
|
+ <div className={styles.twoColumns}>
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="duration_minutes"
|
|
|
|
|
+ label="服务时长"
|
|
|
|
|
+ rules={[{ required: true, message: '请填写服务时长' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <InputNumber
|
|
|
|
|
+ size="large"
|
|
|
|
|
+ min={15}
|
|
|
|
|
+ max={480}
|
|
|
|
|
+ suffix="分钟"
|
|
|
|
|
+ inputMode="numeric"
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="service_modes"
|
|
|
|
|
+ label="服务方式"
|
|
|
|
|
+ rules={[{ required: true, message: '至少选择一种服务方式' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Checkbox.Group options={modeOptions} className={styles.checkGrid} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="public_area_text"
|
|
|
|
|
+ label="对外展示的服务区域"
|
|
|
|
|
+ extra="例如:洛杉矶市中心及附近。不展示精确地址。"
|
|
|
|
|
+ rules={[{ required: true, message: '请填写对外服务区域' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input
|
|
|
|
|
+ size="large"
|
|
|
|
|
+ prefix={<MapPin size={18} />}
|
|
|
|
|
+ maxLength={80}
|
|
|
|
|
+ placeholder="填写顾客能看懂的区域"
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+
|
|
|
|
|
+ {serviceModes.includes('at_store') ? (
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="venue_address"
|
|
|
|
|
+ label="到店地址"
|
|
|
|
|
+ extra="确认最终报价后才会向顾客展示。"
|
|
|
|
|
+ rules={[{ required: true, message: '请填写到店地址' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input size="large" maxLength={120} placeholder="街道、门牌号、楼层" />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+
|
|
|
|
|
+ {serviceModes.includes('onsite') ? (
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="service_radius_km"
|
|
|
|
|
+ label="上门范围"
|
|
|
|
|
+ rules={[{ required: true, message: '请填写上门范围' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <InputNumber
|
|
|
|
|
+ size="large"
|
|
|
|
|
+ min={0.5}
|
|
|
|
|
+ max={200}
|
|
|
|
|
+ suffix="公里"
|
|
|
|
|
+ inputMode="decimal"
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </section>
|
|
|
|
|
+
|
|
|
|
|
+ <Collapse
|
|
|
|
|
+ ghost
|
|
|
|
|
+ className={styles.advanced}
|
|
|
|
|
+ items={[
|
|
|
|
|
+ {
|
|
|
|
|
+ key: 'advanced',
|
|
|
|
|
+ forceRender: true,
|
|
|
|
|
+ label: (
|
|
|
|
|
+ <span className={styles.advancedLabel}>
|
|
|
|
|
+ <SlidersHorizontal size={18} />
|
|
|
|
|
+ 更多设置(可跳过)
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ),
|
|
|
|
|
+ children: (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="category"
|
|
|
|
|
+ label="服务分类"
|
|
|
|
|
+ rules={[{ required: true, message: '请填写服务分类' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input size="large" maxLength={30} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="currency"
|
|
|
|
|
+ label="币种"
|
|
|
|
|
+ rules={[
|
|
|
|
|
+ {
|
|
|
|
|
+ pattern: /^[A-Za-z]{3}$/,
|
|
|
|
|
+ message: '请选择三位 ISO 币种',
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Select
|
|
|
|
|
+ size="large"
|
|
|
|
|
+ showSearch
|
|
|
|
|
+ options={[
|
|
|
|
|
+ { value: 'CNY', label: 'CNY 人民币' },
|
|
|
|
|
+ { value: 'USD', label: 'USD 美元' },
|
|
|
|
|
+ { value: 'HKD', label: 'HKD 港币' },
|
|
|
|
|
+ { value: 'EUR', label: 'EUR 欧元' },
|
|
|
|
|
+ { value: 'GBP', label: 'GBP 英镑' },
|
|
|
|
|
+ { value: 'JPY', label: 'JPY 日元' },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item
|
|
|
|
|
+ name="description"
|
|
|
|
|
+ label="套餐说明"
|
|
|
|
|
+ rules={[{ required: true, message: '请填写套餐说明' }]}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Input.TextArea rows={3} maxLength={800} showCount />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="included_items" label="包含内容">
|
|
|
|
|
+ <Input.TextArea rows={2} maxLength={500} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="excluded_items" label="不包含内容">
|
|
|
|
|
+ <Input.TextArea rows={2} maxLength={500} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="preparation" label="顾客准备事项">
|
|
|
|
|
+ <Input.TextArea rows={2} maxLength={500} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="travel_fee_mode" label="交通费">
|
|
|
|
|
+ <Select size="large" options={travelModeOptions} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ {travelMode === 'fixed' ? (
|
|
|
|
|
+ <Form.Item name="travel_fee_amount" label="固定交通费">
|
|
|
|
|
+ <InputNumber
|
|
|
|
|
+ size="large"
|
|
|
|
|
+ min={0}
|
|
|
|
|
+ precision={2}
|
|
|
|
|
+ prefix={currencyPrefix}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ {travelMode === 'per_km' ? (
|
|
|
|
|
+ <Form.Item name="travel_fee_per_km" label="每公里交通费">
|
|
|
|
|
+ <InputNumber
|
|
|
|
|
+ size="large"
|
|
|
|
|
+ min={0}
|
|
|
|
|
+ precision={2}
|
|
|
|
|
+ prefix={currencyPrefix}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ {serviceModes.includes('onsite') ? (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <Form.Item name="out_of_range_policy" label="超区说明">
|
|
|
|
|
+ <Input.TextArea rows={2} maxLength={200} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="onsite_description" label="上门服务说明">
|
|
|
|
|
+ <Input.TextArea rows={2} maxLength={300} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ </>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ {serviceModes.includes('at_store') ? (
|
|
|
|
|
+ <Form.Item name="venue_name" label="到店地点名称">
|
|
|
|
|
+ <Input size="large" maxLength={80} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ <Form.Item name="profile_headline" label="技师服务标题">
|
|
|
|
|
+ <Input size="large" maxLength={80} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="profile_bio" label="技师服务介绍">
|
|
|
|
|
+ <Input.TextArea rows={3} maxLength={800} />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ <Form.Item name="contact_hours" label="可联系时段">
|
|
|
|
|
+ <Input
|
|
|
|
|
+ size="large"
|
|
|
|
|
+ maxLength={120}
|
|
|
|
|
+ prefix={<Clock3 size={18} />}
|
|
|
|
|
+ />
|
|
|
|
|
+ </Form.Item>
|
|
|
|
|
+ {editor.index !== null && packages.length > 1 ? (
|
|
|
|
|
+ <Button
|
|
|
|
|
+ danger
|
|
|
|
|
+ block
|
|
|
|
|
+ size="large"
|
|
|
|
|
+ onClick={deleteCurrentPackage}
|
|
|
|
|
+ >
|
|
|
|
|
+ 删除这个套餐
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </>
|
|
|
|
|
+ ),
|
|
|
|
|
+ },
|
|
|
|
|
+ ]}
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ <div className={styles.stickyAction}>
|
|
|
|
|
+ <Button
|
|
|
|
|
+ htmlType="submit"
|
|
|
|
|
+ type="primary"
|
|
|
|
|
+ size="large"
|
|
|
|
|
+ block
|
|
|
|
|
+ loading={saving}
|
|
|
|
|
+ icon={!saving ? <Check size={20} /> : undefined}
|
|
|
|
|
+ >
|
|
|
|
|
+ {editor.index === null ? '确认并发布套餐' : '保存修改'}
|
|
|
|
|
+ </Button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </Form>
|
|
|
|
|
+ </main>
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <main className={styles.appShell}>
|
|
|
|
|
+ <header className={styles.homeHeader}>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <Typography.Text type="secondary">技师自助中心</Typography.Text>
|
|
|
|
|
+ <Typography.Title level={1}>你好,{context.display_name}</Typography.Title>
|
|
|
|
|
+ <Typography.Paragraph>
|
|
|
|
|
+ 选择模板后只需确认价格、时长和服务区域。
|
|
|
|
|
+ </Typography.Paragraph>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {context.service_profile ? (
|
|
|
|
|
+ <div className={styles.availability}>
|
|
|
|
|
+ <span>接单</span>
|
|
|
|
|
+ <Switch
|
|
|
|
|
+ checked={context.service_profile.accepting_requests}
|
|
|
|
|
+ loading={availabilitySaving}
|
|
|
|
|
+ disabled={availabilitySaving}
|
|
|
|
|
+ onChange={(checked) => void toggleAvailability(checked)}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ {!context.eligibility.ready ? (
|
|
|
|
|
+ <Alert
|
|
|
|
|
+ className={styles.eligibilityAlert}
|
|
|
|
|
+ type="warning"
|
|
|
|
|
+ showIcon
|
|
|
|
|
+ message="发布前还需完成"
|
|
|
|
|
+ description={
|
|
|
|
|
+ <ul>
|
|
|
|
|
+ {context.eligibility.issues.map((issue) => (
|
|
|
|
|
+ <li key={issue.code}>{issue.message}</li>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </ul>
|
|
|
|
|
+ }
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+
|
|
|
|
|
+ {packages.length ? (
|
|
|
|
|
+ <section className={styles.homeSection}>
|
|
|
|
|
+ <div className={styles.homeSectionHeading}>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <Typography.Title level={2}>我的套餐</Typography.Title>
|
|
|
|
|
+ <Typography.Text type="secondary">
|
|
|
|
|
+ 已发布 {packages.length}/{bootstrap.limits.max_packages}
|
|
|
|
|
+ </Typography.Text>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className={styles.packageList}>
|
|
|
|
|
+ {packages.map((packageItem, index) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className={styles.packageCard}
|
|
|
|
|
+ key={packageItem.package_id}
|
|
|
|
|
+ onClick={() => openExisting(packageItem, index)}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span>
|
|
|
|
|
+ <strong>{packageItem.name}</strong>
|
|
|
|
|
+ <small>
|
|
|
|
|
+ {packagePrice(packageItem)} · 约{' '}
|
|
|
|
|
+ {packageItem.duration_minutes || '—'} 分钟
|
|
|
|
|
+ </small>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <Pencil size={18} aria-hidden="true" />
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </section>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+
|
|
|
|
|
+ {canAdd ? (
|
|
|
|
|
+ <section className={styles.homeSection}>
|
|
|
|
|
+ <div className={styles.homeSectionHeading}>
|
|
|
|
|
+ <div>
|
|
|
|
|
+ <Typography.Title level={2}>
|
|
|
|
|
+ {packages.length ? '新增套餐' : '选一个模板开始'}
|
|
|
|
|
+ </Typography.Title>
|
|
|
|
|
+ <Typography.Text type="secondary">
|
|
|
|
|
+ 所有内容都能修改,后台模板不会覆盖已发布套餐。
|
|
|
|
|
+ </Typography.Text>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {bootstrap.templates.length ? (
|
|
|
|
|
+ <div className={styles.templateList}>
|
|
|
|
|
+ {bootstrap.templates.map((template, index) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ type="button"
|
|
|
|
|
+ className={styles.templateCard}
|
|
|
|
|
+ key={template.template_id}
|
|
|
|
|
+ onClick={() => openTemplate(template)}
|
|
|
|
|
+ disabled={!context.eligibility.ready}
|
|
|
|
|
+ >
|
|
|
|
|
+ <span className={styles.templateIcon}>
|
|
|
|
|
+ {index === 0 ? <Check size={20} /> : <Plus size={20} />}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ <span>
|
|
|
|
|
+ <strong>
|
|
|
|
|
+ {template.name}
|
|
|
|
|
+ {index === 0 ? <Tag color="green">推荐</Tag> : null}
|
|
|
|
|
+ </strong>
|
|
|
|
|
+ <small>
|
|
|
|
|
+ {packagePrice(template.package)} · 约{' '}
|
|
|
|
|
+ {template.package.duration_minutes || '—'} 分钟
|
|
|
|
|
+ </small>
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <Empty description="后台暂未启用套餐模板" />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </section>
|
|
|
|
|
+ ) : null}
|
|
|
|
|
+ </main>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|