Selaa lähdekoodia

feat: add technician mini app

Add signed Telegram self-service APIs, a mobile-first package editor, built-in starter templates, and a simplified Bot fallback flow.

AI-Co-Authored-By: Codex
chendeben 2 kuukautta sitten
vanhempi
sitoutus
f0869469fe

+ 6 - 0
README.md

@@ -21,6 +21,12 @@ uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_
 将输出写入 `SERVICE_ADDRESS_ENCRYPTION_KEY`。精确地址不会进入二维码、评价或
 普通接口,服务单进入终态 7 天后由后台任务自动脱敏。常用顾客/技师命令:
 
+技师套餐默认通过 Telegram Mini App 快速创建,入口地址由
+`SERVICE_TECHNICIAN_MINI_APP_URL` 配置。自助接口会校验 Telegram `initData`
+签名并绑定当前技师,不能通过请求参数修改其他技师资料。系统首次启动时会幂等
+内置快速到店、快速上门和按小时服务模板;管理员可以继续编辑、停用或归档,
+后续启动不会覆盖管理员修改。
+
 - `/find_technician`:查找附近技师并发起服务请求。
 - `/my_services`:查看服务单并继续报价、确认、开始、取消或争议流程。
 - `/technician_profile`:技师选择后台模板或自建套餐并发布服务资料。

+ 10 - 0
admin-web/.umirc.ts

@@ -6,9 +6,19 @@ export default defineConfig({
   publicPath: '/admin/',
   history: { type: 'browser' },
   hash: true,
+  metas: [
+    {
+      name: 'viewport',
+      content: 'width=device-width, initial-scale=1, viewport-fit=cover',
+    },
+  ],
+  headScripts: [
+    { src: 'https://telegram.org/js/telegram-web-app.js' },
+  ],
   routes: [
     { path: '/login', component: '@/pages/Login' },
     { path: '/change-password', component: '@/pages/ChangePassword' },
+    { path: '/technician', component: '@/pages/TechnicianMiniApp' },
     {
       path: '/',
       component: '@/layouts/AdminLayout',

+ 380 - 0
admin-web/src/pages/TechnicianMiniApp.module.less

@@ -0,0 +1,380 @@
+.appShell {
+  --mini-primary: #176b4d;
+  --mini-primary-strong: #0f5138;
+  --mini-surface: #ffffff;
+  --mini-surface-soft: #f0f4ef;
+  --mini-border: #d9e1d8;
+  --mini-text: #17231d;
+  --mini-muted: #5c6c63;
+
+  min-height: 100dvh;
+  padding: 20px 16px calc(104px + env(safe-area-inset-bottom));
+  background:
+    radial-gradient(circle at 92% 0%, rgb(214 231 220 / 72%), transparent 34%),
+    #f6f7f4;
+  color: var(--mini-text);
+}
+
+.appShell :global(.ant-typography) {
+  color: var(--mini-text);
+}
+
+.homeHeader,
+.editorHeader,
+.homeSection,
+.formSection,
+.advanced {
+  width: min(100%, 680px);
+  margin-inline: auto;
+}
+
+.homeHeader {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 8px 4px 18px;
+}
+
+.homeHeader h1,
+.editorHeader h1 {
+  margin: 2px 0 4px !important;
+  font-size: clamp(26px, 8vw, 36px) !important;
+  line-height: 1.15 !important;
+}
+
+.homeHeader p {
+  max-width: 420px;
+  margin: 0 !important;
+  color: var(--mini-muted) !important;
+  font-size: 16px;
+  line-height: 1.55;
+}
+
+.availability {
+  display: flex;
+  flex: none;
+  align-items: center;
+  gap: 8px;
+  min-height: 44px;
+  padding: 8px 10px;
+  background: rgb(255 255 255 / 82%);
+  border: 1px solid var(--mini-border);
+  border-radius: 999px;
+  font-weight: 600;
+}
+
+.eligibilityAlert {
+  width: min(100%, 680px);
+  margin: 0 auto 16px;
+  border-radius: 14px;
+}
+
+.eligibilityAlert ul {
+  margin: 8px 0 0;
+  padding-left: 20px;
+}
+
+.homeSection {
+  margin-bottom: 16px;
+  padding: 18px;
+  background: rgb(255 255 255 / 94%);
+  border: 1px solid var(--mini-border);
+  border-radius: 20px;
+  box-shadow: 0 12px 30px rgb(25 56 39 / 7%);
+}
+
+.homeSectionHeading {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 14px;
+}
+
+.homeSectionHeading h2 {
+  margin: 0 0 2px !important;
+  font-size: 19px !important;
+}
+
+.packageList,
+.templateList {
+  display: grid;
+  gap: 10px;
+}
+
+.packageCard,
+.templateCard {
+  display: flex;
+  width: 100%;
+  min-height: 72px;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 14px;
+  color: var(--mini-text);
+  text-align: left;
+  background: var(--mini-surface-soft);
+  border: 1px solid transparent;
+  border-radius: 14px;
+  cursor: pointer;
+  transition:
+    transform 180ms ease,
+    border-color 180ms ease,
+    background 180ms ease;
+}
+
+.templateCard {
+  justify-content: flex-start;
+  background: var(--mini-surface);
+  border-color: var(--mini-border);
+}
+
+.packageCard:active,
+.templateCard:active {
+  transform: scale(0.985);
+}
+
+.packageCard:focus-visible,
+.templateCard:focus-visible,
+.backButton:focus-visible {
+  outline: 3px solid rgb(23 107 77 / 30%);
+  outline-offset: 2px;
+}
+
+.packageCard strong,
+.templateCard strong {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 6px;
+  margin-bottom: 5px;
+  font-size: 16px;
+  line-height: 1.4;
+}
+
+.packageCard small,
+.templateCard small {
+  display: block;
+  color: var(--mini-muted);
+  font-size: 14px;
+  line-height: 1.45;
+}
+
+.templateCard:disabled {
+  cursor: not-allowed;
+  opacity: 0.48;
+}
+
+.templateIcon {
+  display: grid;
+  width: 42px;
+  height: 42px;
+  flex: none;
+  place-items: center;
+  color: var(--mini-primary);
+  background: #e3f0e8;
+  border-radius: 12px;
+}
+
+.editorHeader {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr);
+  align-items: flex-start;
+  gap: 8px;
+  padding-bottom: 16px;
+}
+
+.backButton {
+  min-width: 44px;
+  min-height: 44px;
+  margin-left: -10px;
+}
+
+.editorForm {
+  width: 100%;
+}
+
+.formSection {
+  padding: 18px;
+  background: var(--mini-surface);
+  border: 1px solid var(--mini-border);
+  border-radius: 20px;
+  box-shadow: 0 12px 30px rgb(25 56 39 / 7%);
+}
+
+.sectionTitle {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 18px;
+  font-size: 18px;
+  font-weight: 700;
+}
+
+.editorForm :global(.ant-form-item-label > label) {
+  font-size: 15px;
+  font-weight: 650;
+}
+
+.editorForm :global(.ant-form-item-extra) {
+  padding-top: 4px;
+  color: var(--mini-muted);
+  line-height: 1.45;
+}
+
+.editorForm :global(.ant-input),
+.editorForm :global(.ant-input-number),
+.editorForm :global(.ant-input-number-group-wrapper),
+.editorForm :global(.ant-select-selector) {
+  min-height: 46px;
+  border-radius: 10px !important;
+}
+
+.editorForm :global(.ant-input-number),
+.editorForm :global(.ant-input-number-group-wrapper) {
+  width: 100%;
+}
+
+.choiceGrid {
+  display: grid !important;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 8px;
+}
+
+.choiceGrid :global(.ant-radio-button-wrapper) {
+  min-height: 44px;
+  padding-inline: 6px;
+  text-align: center;
+  border: 1px solid var(--mini-border) !important;
+  border-radius: 10px !important;
+}
+
+.choiceGrid :global(.ant-radio-button-wrapper::before) {
+  display: none;
+}
+
+.priceRow {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.twoColumns {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+  gap: 10px;
+}
+
+.checkGrid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 8px;
+}
+
+.checkGrid :global(.ant-checkbox-wrapper) {
+  min-height: 46px;
+  align-items: center;
+  justify-content: center;
+  margin-inline-start: 0;
+  padding: 8px;
+  background: #f8faf8;
+  border: 1px solid var(--mini-border);
+  border-radius: 10px;
+}
+
+.advanced {
+  margin-top: 12px;
+  padding: 0 8px;
+  background: rgb(255 255 255 / 72%);
+  border: 1px solid var(--mini-border);
+  border-radius: 16px;
+}
+
+.advancedLabel {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  min-height: 44px;
+  font-weight: 650;
+}
+
+.stickyAction {
+  position: fixed;
+  z-index: 20;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  padding: 12px max(16px, calc((100vw - 680px) / 2))
+    calc(12px + env(safe-area-inset-bottom));
+  background: rgb(246 247 244 / 94%);
+  border-top: 1px solid var(--mini-border);
+  backdrop-filter: blur(14px);
+}
+
+.stickyAction :global(.ant-btn) {
+  min-height: 52px;
+  border-radius: 14px;
+  font-size: 17px;
+  font-weight: 700;
+  box-shadow: 0 8px 20px rgb(23 107 77 / 22%);
+}
+
+.centerState {
+  display: flex;
+  width: min(100%, 520px);
+  min-height: 100dvh;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 18px;
+  margin: 0 auto;
+  padding: 24px;
+  background: #f6f7f4;
+  color: #39473f;
+}
+
+@media (max-width: 575px) {
+  .appShell {
+    padding-inline: 12px;
+    padding-top: 14px;
+  }
+
+  .homeHeader {
+    padding-inline: 2px;
+  }
+
+  .homeSection,
+  .formSection {
+    padding: 15px;
+    border-radius: 17px;
+  }
+
+  .choiceGrid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .priceRow {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .priceRow > :last-child {
+    grid-column: 1 / -1;
+  }
+
+  .twoColumns {
+    grid-template-columns: 1fr;
+  }
+
+  .availability {
+    padding-inline: 8px;
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .packageCard,
+  .templateCard {
+    transition: none;
+  }
+}

+ 897 - 0
admin-web/src/pages/TechnicianMiniApp.tsx

@@ -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>
+  );
+}

+ 94 - 0
admin-web/src/services/technicianApi.ts

@@ -0,0 +1,94 @@
+import type { ApiEnvelope } from '@/types';
+
+const API_ROOT = '/api/technician/v1';
+
+type TelegramWebApp = {
+  initData: string;
+  ready: () => void;
+  expand: () => void;
+  close: () => void;
+  setHeaderColor?: (color: string) => void;
+  setBackgroundColor?: (color: string) => void;
+};
+
+declare global {
+  interface Window {
+    Telegram?: {
+      WebApp?: TelegramWebApp;
+    };
+  }
+}
+
+export class TechnicianApiError extends Error {
+  code: string;
+  status: number;
+
+  constructor(code: string, message: string, status: number) {
+    super(message);
+    this.name = 'TechnicianApiError';
+    this.code = code;
+    this.status = status;
+  }
+}
+
+export function initializeTelegramMiniApp(): TelegramWebApp | undefined {
+  const webApp = window.Telegram?.WebApp;
+  webApp?.ready();
+  webApp?.expand();
+  webApp?.setHeaderColor?.('#f6f7f4');
+  webApp?.setBackgroundColor?.('#f6f7f4');
+  return webApp;
+}
+
+function botId(): string {
+  return new URLSearchParams(window.location.search).get('bot_id') || '';
+}
+
+export async function technicianApiRequest<T>(
+  path: string,
+  options: RequestInit = {},
+): Promise<T> {
+  const initData = window.Telegram?.WebApp?.initData || '';
+  const selectedBotId = botId();
+  if (!initData || !selectedBotId) {
+    throw new TechnicianApiError(
+      'mini_app_auth_required',
+      '请从 Telegram Bot 重新打开此页面。',
+      401,
+    );
+  }
+  const headers = new Headers(options.headers);
+  headers.set('X-Telegram-Init-Data', initData);
+  headers.set('X-Telegram-Bot-Id', selectedBotId);
+  if (options.body) {
+    headers.set('Content-Type', 'application/json');
+  }
+  const response = await fetch(`${API_ROOT}${path}`, {
+    ...options,
+    headers,
+    credentials: 'omit',
+  });
+  const contentType = response.headers.get('Content-Type') || '';
+  const payload = contentType.includes('application/json')
+    ? await response.json()
+    : null;
+  if (!response.ok) {
+    const error = payload?.error || {};
+    throw new TechnicianApiError(
+      error.code || 'request_failed',
+      error.message || `请求失败(${response.status})`,
+      response.status,
+    );
+  }
+  return (payload as ApiEnvelope<T>).data;
+}
+
+export function technicianJsonOptions(
+  method: 'PUT' | 'PATCH',
+  body: unknown,
+): RequestInit {
+  return {
+    method,
+    body: JSON.stringify(body),
+  };
+}

+ 44 - 0
admin-web/src/types.ts

@@ -371,6 +371,50 @@ export interface ServicePackageTemplate {
   updated_at: string;
 }
 
+export interface TechnicianServiceProfile {
+  headline: string;
+  bio: string;
+  tags: string[];
+  contact_hours: string;
+  service_modes: ServiceMode[];
+  accepting_requests: boolean;
+  public_area_text: string;
+  venue: {
+    name: string;
+    address_hint: string;
+  };
+  onsite_policy: {
+    description: string;
+  };
+  packages: ServicePackage[];
+  is_complete: boolean;
+  version?: number;
+  updated_at?: string;
+}
+
+export interface TechnicianSelfServiceContext {
+  user_id: string;
+  display_name: string;
+  username?: string | null;
+  eligibility: {
+    ready: boolean;
+    issues: Array<{ code: string; message: string }>;
+  };
+  service_profile?: TechnicianServiceProfile | null;
+}
+
+export interface TechnicianMiniAppBootstrap {
+  technician: TechnicianSelfServiceContext;
+  templates: Array<
+    Pick<ServicePackageTemplate, 'template_id' | 'version' | 'name' | 'package'> & {
+      builtin: boolean;
+    }
+  >;
+  limits: {
+    max_packages: number;
+  };
+}
+
 export interface ServiceQuote {
   quote_id: string;
   order_id: string;

+ 4 - 0
admin-web/src/typings.d.ts

@@ -0,0 +1,4 @@
+declare module '*.module.less' {
+  const classes: Record<string, string>;
+  export default classes;
+}

+ 95 - 0
admin-web/tests/e2e/technician-mini-app.spec.ts

@@ -0,0 +1,95 @@
+import { expect, test } from '@playwright/test';
+
+const packageTemplate = {
+  package_id: 'template-package',
+  name: '标准到店服务',
+  category: '通用服务',
+  description: '顾客到店完成一次标准服务。',
+  tags: ['到店', '标准服务'],
+  service_modes: ['at_store'],
+  price_mode: 'fixed',
+  currency: 'CNY',
+  price_unit: 'per_service',
+  min_price: '200.00',
+  max_price: '200.00',
+  duration_minutes: 60,
+  included_items: '一次标准服务',
+  excluded_items: '额外耗材',
+  preparation: '请提前说明需求',
+  addons: [],
+  travel_fee: { mode: 'included', amount: '0', per_km: '0' },
+  service_radius_km: null,
+  out_of_range_policy: '',
+  source_type: 'template',
+};
+
+test.beforeEach(async ({ page }) => {
+  await page.route('https://telegram.org/js/telegram-web-app.js', (route) =>
+    route.fulfill({
+      contentType: 'application/javascript',
+      body: `
+        window.Telegram = {
+          WebApp: {
+            initData: 'signed-test-init-data',
+            ready() {},
+            expand() {},
+            close() {},
+            setHeaderColor() {},
+            setBackgroundColor() {}
+          }
+        };
+      `,
+    }),
+  );
+  await page.route('**/api/technician/v1/**', async (route) => {
+    const headers = route.request().headers();
+    expect(headers['x-telegram-bot-id']).toBe('primary');
+    expect(headers['x-telegram-init-data']).toBe('signed-test-init-data');
+    const path = new URL(route.request().url()).pathname;
+    if (path.endsWith('/bootstrap')) {
+      return route.fulfill({
+        contentType: 'application/json',
+        body: JSON.stringify({
+          data: {
+            technician: {
+              user_id: '700',
+              display_name: '测试技师',
+              username: 'technician700',
+              eligibility: { ready: true, issues: [] },
+              service_profile: null,
+            },
+            templates: [
+              {
+                template_id: 'builtin_quick_at_store',
+                version: 1,
+                name: '快速到店服务',
+                builtin: true,
+                package: packageTemplate,
+              },
+            ],
+            limits: { max_packages: 5 },
+          },
+        }),
+      });
+    }
+    return route.fulfill({
+      contentType: 'application/json',
+      body: JSON.stringify({ data: {} }),
+    });
+  });
+});
+
+test('技师可从模板快速进入一分钟发布表单', async ({ page }) => {
+  await page.goto('/admin/technician?bot_id=primary');
+
+  await expect(page.getByRole('heading', { name: '你好,测试技师' })).toBeVisible();
+  await expect(page.getByRole('button', { name: /快速到店服务/ })).toBeVisible();
+  await page.getByRole('button', { name: /快速到店服务/ }).click();
+
+  await expect(page.getByRole('heading', { name: '发布新套餐' })).toBeVisible();
+  await expect(page.getByText('约 1 分钟')).toBeVisible();
+  await expect(page.getByLabel('套餐名称')).toHaveValue('标准到店服务');
+  await expect(page.getByRole('radio', { name: '固定价' })).toBeChecked();
+  await expect(page.getByText('per_service')).toHaveCount(0);
+  await expect(page.getByRole('button', { name: /确认并发布套餐/ })).toBeVisible();
+});

+ 124 - 0
admin-web/tests/unit/TechnicianMiniApp.test.tsx

@@ -0,0 +1,124 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, expect, test, vi } from 'vitest';
+
+import TechnicianMiniApp from '@/pages/TechnicianMiniApp';
+import {
+  technicianApiRequest,
+  technicianJsonOptions,
+} from '@/services/technicianApi';
+
+vi.mock('@/services/technicianApi', () => ({
+  initializeTelegramMiniApp: vi.fn(),
+  TechnicianApiError: class TechnicianApiError extends Error {
+    code = '';
+    status = 400;
+  },
+  technicianApiRequest: vi.fn(),
+  technicianJsonOptions: vi.fn((method, body) => ({
+    method,
+    body: JSON.stringify(body),
+  })),
+}));
+
+const packageTemplate = {
+  package_id: 'template-package',
+  name: '标准到店服务',
+  category: '通用服务',
+  description: '一次标准到店服务',
+  tags: ['到店'],
+  service_modes: ['at_store'] as const,
+  price_mode: 'fixed' as const,
+  currency: 'CNY',
+  price_unit: 'per_service' as const,
+  min_price: '200.00',
+  max_price: '200.00',
+  duration_minutes: 60,
+  included_items: '基础服务',
+  excluded_items: '',
+  preparation: '',
+  addons: [],
+  travel_fee: { mode: 'included' as const, amount: '0', per_km: '0' },
+  service_radius_km: null,
+  out_of_range_policy: '',
+  source_type: 'template' as const,
+};
+
+const bootstrap = {
+  technician: {
+    user_id: '700',
+    display_name: '测试技师',
+    username: 'technician700',
+    eligibility: { ready: true, issues: [] },
+    service_profile: null,
+  },
+  templates: [
+    {
+      template_id: 'builtin_quick_at_store',
+      version: 1,
+      name: '快速到店服务',
+      builtin: true,
+      package: packageTemplate,
+    },
+  ],
+  limits: { max_packages: 5 },
+};
+
+beforeEach(() => {
+  vi.mocked(technicianApiRequest).mockImplementation(async (path) => {
+    if (path === '/bootstrap') return bootstrap;
+    if (path === '/profile') {
+      return {
+        ...bootstrap.technician,
+        service_profile: {
+          headline: '标准到店服务',
+          bio: '一次标准到店服务',
+          tags: ['到店'],
+          contact_hours: '请通过 Telegram 私聊确认服务时间',
+          service_modes: ['at_store'],
+          accepting_requests: true,
+          public_area_text: '测试城区及附近',
+          venue: { name: '工作地点', address_hint: '测试路 100 号' },
+          onsite_policy: { description: '' },
+          packages: [packageTemplate],
+          is_complete: true,
+        },
+      };
+    }
+    throw new Error(`Unexpected request: ${path}`);
+  });
+});
+
+test('模板创建只要求技师确认常用中文字段并可直接发布', async () => {
+  render(<TechnicianMiniApp />);
+
+  expect(await screen.findByText('你好,测试技师')).toBeInTheDocument();
+  fireEvent.click(screen.getByRole('button', { name: /快速到店服务/ }));
+
+  expect(await screen.findByText('发布新套餐')).toBeInTheDocument();
+  expect(screen.getByRole('radio', { name: '固定价' })).toBeChecked();
+  expect(screen.getByText('每次')).toBeInTheDocument();
+  expect(screen.queryByText('per_service')).not.toBeInTheDocument();
+
+  fireEvent.change(screen.getByLabelText('对外展示的服务区域'), {
+    target: { value: '测试城区及附近' },
+  });
+  fireEvent.change(screen.getByLabelText('到店地址'), {
+    target: { value: '测试路 100 号' },
+  });
+  fireEvent.click(screen.getByRole('button', { name: /确认并发布套餐/ }));
+
+  await waitFor(() => {
+    expect(technicianApiRequest).toHaveBeenCalledWith(
+      '/profile',
+      expect.objectContaining({ method: 'PUT' }),
+    );
+  });
+  expect(technicianJsonOptions).toHaveBeenCalledWith(
+    'PUT',
+    expect.objectContaining({
+      service_profile: expect.objectContaining({
+        public_area_text: '测试城区及附近',
+      }),
+    }),
+  );
+});

+ 2 - 0
config.env.template

@@ -43,6 +43,8 @@ SERVICE_ADDRESS_RETENTION_DAYS=7
 SERVICE_REVIEW_QR_EXPIRY_HOURS=24
 SERVICE_CUSTOMER_MAX_OPEN_ORDERS=3
 SERVICE_CUSTOMER_DAILY_REQUEST_LIMIT=10
+SERVICE_TECHNICIAN_MINI_APP_URL=https://tg.97admin.com/admin/technician
+SERVICE_MINI_APP_AUTH_MAX_AGE_SECONDS=3600
 BOT_PROFILES_PATH=runtime/bot_profiles.json
 
 # Optional Nominatim contact is configured in the admin panel.

+ 4 - 0
sample_config.env

@@ -31,3 +31,7 @@ LOG_MENTIONS=0 # Make it 1 if you want to enable it
 RSS_DELAY=300 # In seconds
 
 PM_PERMIT=1 # fill 0 to disable it
+
+# Technician Telegram Mini App.
+SERVICE_TECHNICIAN_MINI_APP_URL=https://tg.97admin.com/admin/technician
+SERVICE_MINI_APP_AUTH_MAX_AGE_SECONDS=3600

+ 8 - 0
sample_config.py

@@ -63,6 +63,14 @@ SERVICE_CUSTOMER_MAX_OPEN_ORDERS = _env_int("SERVICE_CUSTOMER_MAX_OPEN_ORDERS",
 SERVICE_CUSTOMER_DAILY_REQUEST_LIMIT = _env_int(
     "SERVICE_CUSTOMER_DAILY_REQUEST_LIMIT", 10
 )
+SERVICE_TECHNICIAN_MINI_APP_URL = os.environ.get(
+    "SERVICE_TECHNICIAN_MINI_APP_URL",
+    "https://tg.97admin.com/admin/technician",
+)
+SERVICE_MINI_APP_AUTH_MAX_AGE_SECONDS = _env_int(
+    "SERVICE_MINI_APP_AUTH_MAX_AGE_SECONDS",
+    3600,
+)
 
 USERBOT_ENABLED = os.environ.get("USERBOT_ENABLED", "False").lower() in ["true", "1"]
 ADMIN_BOOTSTRAP_MODE = os.environ.get("WBB_ADMIN_BOOTSTRAP", "0").lower() in ["true", "1"]

+ 217 - 0
tests/test_technician_mini_app.py

@@ -0,0 +1,217 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+from datetime import UTC, datetime, timedelta
+from urllib.parse import urlencode
+
+from aiohttp.test_utils import TestClient, TestServer
+
+
+def _signed_init_data(
+    token: str,
+    user_id: int,
+    *,
+    auth_date: datetime | None = None,
+) -> str:
+    values = {
+        "auth_date": str(int((auth_date or datetime.now(UTC)).timestamp())),
+        "query_id": "AAE-test-query",
+        "user": json.dumps(
+            {
+                "id": user_id,
+                "first_name": "认证",
+                "last_name": "技师",
+                "username": f"technician{user_id}",
+                "language_code": "zh-hans",
+            },
+            ensure_ascii=False,
+            separators=(",", ":"),
+        ),
+    }
+    data_check_string = "\n".join(
+        f"{key}={value}" for key, value in sorted(values.items())
+    )
+    secret_key = hmac.new(
+        b"WebAppData",
+        token.encode(),
+        hashlib.sha256,
+    ).digest()
+    values["hash"] = hmac.new(
+        secret_key,
+        data_check_string.encode(),
+        hashlib.sha256,
+    ).hexdigest()
+    return urlencode(values)
+
+
+async def _approved_technician(app_modules, user_id: int) -> None:
+    directory_db = app_modules.load("wbb.utils.dbdirectory")
+    await directory_db.upsert_directory_identity(
+        user_id=user_id,
+        username=f"technician{user_id}",
+        first_name="认证",
+        last_name="技师",
+    )
+    await directory_db.save_directory_location(
+        user_id=user_id,
+        longitude=116.4074,
+        latitude=39.9042,
+        source="test",
+        actor_id=user_id,
+        actor_name="认证技师",
+    )
+    await directory_db.upsert_directory_membership(
+        bot_id="primary",
+        chat_id=-100200,
+        user_id=user_id,
+        status="member",
+        active=True,
+        verified=True,
+    )
+    await directory_db.submit_teacher_application(
+        user_id=user_id,
+        source="test",
+        bot_id="primary",
+        chat_id=-100200,
+    )
+    await directory_db.decide_teacher_application(
+        user_id=user_id,
+        action="approve",
+        actor_id="admin",
+        actor_name="admin",
+        source="test",
+    )
+
+
+async def test_builtin_templates_are_seeded_without_overwriting_admin_changes(
+    app_modules,
+):
+    service_db = app_modules.load("wbb.utils.dbservice")
+    first = await service_db.ensure_builtin_package_templates()
+    assert [item["template_id"] for item in first] == [
+        "builtin_quick_at_store",
+        "builtin_quick_onsite",
+        "builtin_quick_hourly",
+    ]
+    await app_modules.wbb.control_db.service_package_templates.update_one(
+        {"template_id": "builtin_quick_at_store"},
+        {"$set": {"name": "管理员修改后的到店模板", "status": "disabled"}},
+    )
+    await service_db.ensure_builtin_package_templates()
+    stored = await app_modules.wbb.control_db.service_package_templates.find_one(
+        {"template_id": "builtin_quick_at_store"}
+    )
+    assert stored["name"] == "管理员修改后的到店模板"
+    assert stored["status"] == "disabled"
+
+
+async def test_technician_mini_app_uses_signed_identity_without_admin_login(
+    app_modules,
+):
+    user_id = 700
+    await _approved_technician(app_modules, user_id)
+    bot_config = app_modules.load("wbb.admin.bot_config")
+    token = "123456:abcdefghijklmnopqrstuvwxyzABCDE"
+    bot = bot_config.create_bot_profile(
+        app_modules.wbb.BOT_PROFILES_PATH,
+        {
+            "label": "技师服务 Bot",
+            "bot_token": token,
+            "enabled": True,
+        },
+    )
+    admin_api = app_modules.load("wbb.admin.api")
+    application = admin_api.build_admin_application()
+    await application["admin_api"].initialize()
+    client = TestClient(TestServer(application))
+    await client.start_server()
+    headers = {
+        "X-Telegram-Bot-Id": bot["bot_id"],
+        "X-Telegram-Init-Data": _signed_init_data(token, user_id),
+    }
+    try:
+        invalid = await client.get(
+            "/api/technician/v1/bootstrap",
+            headers={**headers, "X-Telegram-Init-Data": f"{headers['X-Telegram-Init-Data']}x"},
+        )
+        assert invalid.status == 401
+        assert (await invalid.json())["error"]["code"] == "mini_app_auth_failed"
+
+        expired = await client.get(
+            "/api/technician/v1/bootstrap",
+            headers={
+                **headers,
+                "X-Telegram-Init-Data": _signed_init_data(
+                    token,
+                    user_id,
+                    auth_date=datetime.now(UTC) - timedelta(hours=2),
+                ),
+            },
+        )
+        assert expired.status == 401
+
+        loaded = await client.get("/api/technician/v1/bootstrap", headers=headers)
+        assert loaded.status == 200
+        payload = (await loaded.json())["data"]
+        assert payload["technician"]["user_id"] == str(user_id)
+        assert payload["technician"]["eligibility"]["ready"] is True
+        assert [item["name"] for item in payload["templates"]] == [
+            "快速到店服务",
+            "快速上门服务",
+            "快速按小时服务",
+        ]
+        assert all("admin_note" not in item for item in payload["templates"])
+
+        package = payload["templates"][0]["package"]
+        package.update(
+            {
+                "package_id": "self_service_package",
+                "source_template_id": payload["templates"][0]["template_id"],
+                "source_template_version": payload["templates"][0]["version"],
+            }
+        )
+        published = await client.put(
+            "/api/technician/v1/profile",
+            headers=headers,
+            json={
+                "user_id": "999999",
+                "service_profile": {
+                    "headline": "一分钟发布套餐",
+                    "bio": package["description"],
+                    "tags": package["tags"],
+                    "contact_hours": "请通过 Telegram 私聊确认",
+                    "public_area_text": "测试城区及附近",
+                    "venue": {
+                        "name": "测试工作地点",
+                        "address_hint": "测试路 100 号",
+                    },
+                    "onsite_policy": {"description": ""},
+                    "packages": [package],
+                    "accepting_requests": True,
+                },
+            },
+        )
+        assert published.status == 200
+        published_data = (await published.json())["data"]
+        assert published_data["user_id"] == str(user_id)
+        assert published_data["service_profile"]["packages"][0]["price_unit"] == "per_service"
+        assert (
+            await app_modules.wbb.control_db.directory_profiles.find_one(
+                {"user_id": 999999}
+            )
+        ) is None
+
+        paused = await client.patch(
+            "/api/technician/v1/availability",
+            headers=headers,
+            json={"accepting_requests": False},
+        )
+        assert paused.status == 200
+        assert (
+            (await paused.json())["data"]["service_profile"]["accepting_requests"]
+            is False
+        )
+    finally:
+        await client.close()

+ 168 - 3
wbb/admin/api.py

@@ -37,6 +37,10 @@ from wbb.admin.security import (
     validate_new_password,
     verify_password,
 )
+from wbb.admin.telegram_webapp import (
+    TelegramWebAppAuthError,
+    verify_telegram_webapp_init_data,
+)
 from wbb.services.bot_permissions import api_permission, has_permission
 from wbb.services.chat_management import (
     ChatManagementError,
@@ -121,10 +125,12 @@ from wbb.utils.dbservice import (
     admin_reveal_order_address,
     create_package_template,
     duplicate_package_template,
+    ensure_builtin_package_templates,
     fulfillment_metrics,
     get_active_review_template,
     get_admin_service_order,
     get_service_settings,
+    get_technician_self_service_context,
     get_technician_service_profile,
     list_customer_blocks,
     list_leaderboard,
@@ -141,10 +147,12 @@ from wbb.utils.dbservice import (
     set_customer_block,
     set_package_template_status,
     set_service_settings,
+    set_technician_accepting_requests,
     update_package_template,
 )
 
 API_PREFIX = "/api/admin/v1"
+TECHNICIAN_API_PREFIX = "/api/technician/v1"
 SESSION_COOKIE = "wbb_admin_session"
 UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
 PUBLIC_API_PATHS = {f"{API_PREFIX}/auth/login", f"{API_PREFIX}/health"}
@@ -346,17 +354,81 @@ async def _record_request_audit(
         return
     request["audit_recorded"] = True
     admin = request.get("admin") or {}
+    technician = request.get("technician") or {}
+    actor_id = admin.get("username") or technician.get("user_id") or "anonymous"
+    actor_name = (
+        admin.get("username")
+        or technician.get("display_name")
+        or str(actor_id)
+    )
     with suppress(Exception):
         await record_audit(
-            source="web",
-            actor_id=admin.get("username", "anonymous"),
-            actor_name=admin.get("username", "anonymous"),
+            source="technician_mini_app" if technician else "web",
+            actor_id=actor_id,
+            actor_name=actor_name,
             success=success_state,
             error=error,
             **context,
         )
 
 
+@web.middleware
+async def technician_authentication_middleware(request: web.Request, handler):
+    if not request.path.startswith(TECHNICIAN_API_PREFIX):
+        return await handler(request)
+    bot_id = str(request.headers.get("X-Telegram-Bot-Id") or "").strip()
+    init_data = str(request.headers.get("X-Telegram-Init-Data") or "").strip()
+    if not bot_id or not init_data:
+        raise ApiProblem(
+            "mini_app_auth_required",
+            "请从 Telegram Bot 重新打开技师套餐页面。",
+            status=401,
+        )
+    try:
+        profile = get_bot_profile_secrets(
+            str(getattr(wbb, "BOT_PROFILES_PATH", "runtime/bot_profiles.json")),
+            bot_id,
+        )
+    except BotConfigError as exc:
+        raise ApiProblem(
+            "mini_app_auth_failed",
+            "Telegram Mini App 鉴权失败。",
+            status=401,
+        ) from exc
+    if (
+        not profile.get("enabled", True)
+        or "teacher_directory.manage" not in set(profile.get("permissions", []))
+    ):
+        raise ApiProblem(
+            "mini_app_bot_unavailable",
+            "当前 Bot 未启用技师自助服务。",
+            status=403,
+        )
+    try:
+        identity = verify_telegram_webapp_init_data(
+            init_data,
+            str(profile.get("bot_token") or ""),
+            max_age_seconds=max(
+                300,
+                min(
+                    86400,
+                    int(
+                        getattr(
+                            wbb,
+                            "SERVICE_MINI_APP_AUTH_MAX_AGE_SECONDS",
+                            3600,
+                        )
+                        or 3600
+                    ),
+                ),
+            ),
+        )
+    except TelegramWebAppAuthError as exc:
+        raise ApiProblem("mini_app_auth_failed", str(exc), status=401) from exc
+    request["technician"] = {**identity, "bot_id": bot_id}
+    return await handler(request)
+
+
 @web.middleware
 async def authentication_middleware(request: web.Request, handler):
     if not request.path.startswith(API_PREFIX) or request.path in PUBLIC_API_PATHS:
@@ -530,9 +602,22 @@ class AdminApi:
             password_hash=hash_password(self.initial_password),
         )
         await ensure_directory_indexes()
+        await ensure_builtin_package_templates()
 
     def register(self, application: web.Application) -> None:
         router = application.router
+        router.add_get(
+            f"{TECHNICIAN_API_PREFIX}/bootstrap",
+            self.technician_mini_app_bootstrap,
+        )
+        router.add_put(
+            f"{TECHNICIAN_API_PREFIX}/profile",
+            self.technician_mini_app_profile_update,
+        )
+        router.add_patch(
+            f"{TECHNICIAN_API_PREFIX}/availability",
+            self.technician_mini_app_availability,
+        )
         router.add_post(
             "/api/internal/v1/directory/verify-platform",
             self.internal_directory_verify_platform,
@@ -1195,6 +1280,85 @@ class AdminApi:
         )
         return success(updated)
 
+    async def technician_mini_app_bootstrap(
+        self,
+        request: web.Request,
+    ) -> web.Response:
+        identity = request["technician"]
+        context = await get_technician_self_service_context(identity["user_id"])
+        templates = await list_package_templates()
+        public_templates = [
+            {
+                "template_id": item["template_id"],
+                "version": item["version"],
+                "name": item["name"],
+                "builtin": bool(item.get("builtin")),
+                "package": item["package"],
+            }
+            for item in templates
+            if item.get("status") == "enabled"
+        ]
+        response = success(
+            {
+                "technician": context,
+                "templates": public_templates,
+                "limits": {"max_packages": 5},
+            }
+        )
+        response.headers["Cache-Control"] = "no-store"
+        return response
+
+    async def technician_mini_app_profile_update(
+        self,
+        request: web.Request,
+    ) -> web.Response:
+        identity = request["technician"]
+        body = await json_body(request)
+        values = body.get("service_profile") or body
+        updated = await publish_technician_profile(
+            identity["user_id"],
+            values,
+            actor_id=identity["user_id"],
+        )
+        set_audit(
+            request,
+            "service.technician_self_profile.update",
+            target_id=identity["user_id"],
+            summary="技师通过 Mini App 更新服务资料和套餐",
+        )
+        return success(
+            await get_technician_self_service_context(updated["user_id"])
+        )
+
+    async def technician_mini_app_availability(
+        self,
+        request: web.Request,
+    ) -> web.Response:
+        identity = request["technician"]
+        body = await json_body(request)
+        if not isinstance(body.get("accepting_requests"), bool):
+            raise ApiProblem(
+                "invalid_accepting_requests",
+                "接单状态必须是布尔值。",
+            )
+        updated = await set_technician_accepting_requests(
+            identity["user_id"],
+            body["accepting_requests"],
+        )
+        set_audit(
+            request,
+            "service.technician_self_availability.update",
+            target_id=identity["user_id"],
+            summary=(
+                "开启接单"
+                if body["accepting_requests"]
+                else "暂停接单"
+            ),
+        )
+        return success(
+            await get_technician_self_service_context(updated["user_id"])
+        )
+
     async def service_orders(self, request: web.Request) -> web.Response:
         page, page_size = page_params(request)
         items, total = await list_service_orders(
@@ -2259,6 +2423,7 @@ def build_admin_application() -> web.Application:
     application = web.Application(
         middlewares=[
             api_error_middleware,
+            technician_authentication_middleware,
             authentication_middleware,
             bot_role_middleware,
             bot_proxy_middleware,

+ 81 - 0
wbb/admin/telegram_webapp.py

@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import json
+from datetime import UTC, datetime
+from typing import Any
+from urllib.parse import parse_qsl
+
+
+class TelegramWebAppAuthError(ValueError):
+    pass
+
+
+def verify_telegram_webapp_init_data(
+    init_data: str,
+    bot_token: str,
+    *,
+    max_age_seconds: int = 3600,
+    now: datetime | None = None,
+) -> dict[str, Any]:
+    """Validate Telegram Mini App init data and return the signed user."""
+    if not init_data or not bot_token:
+        raise TelegramWebAppAuthError("缺少 Telegram Mini App 鉴权信息。")
+    try:
+        values = dict(parse_qsl(init_data, keep_blank_values=True, strict_parsing=True))
+    except ValueError as exc:
+        raise TelegramWebAppAuthError("Telegram Mini App 鉴权信息格式无效。") from exc
+    supplied_hash = values.pop("hash", "")
+    if len(supplied_hash) != 64:
+        raise TelegramWebAppAuthError("Telegram Mini App 签名无效。")
+    data_check_string = "\n".join(
+        f"{key}={value}" for key, value in sorted(values.items())
+    )
+    secret_key = hmac.new(
+        b"WebAppData",
+        bot_token.encode("utf-8"),
+        hashlib.sha256,
+    ).digest()
+    expected_hash = hmac.new(
+        secret_key,
+        data_check_string.encode("utf-8"),
+        hashlib.sha256,
+    ).hexdigest()
+    if not hmac.compare_digest(expected_hash, supplied_hash):
+        raise TelegramWebAppAuthError("Telegram Mini App 签名无效。")
+
+    try:
+        auth_date = int(values.get("auth_date") or 0)
+    except (TypeError, ValueError) as exc:
+        raise TelegramWebAppAuthError("Telegram Mini App 鉴权时间无效。") from exc
+    current = now or datetime.now(UTC)
+    age_seconds = int(current.timestamp()) - auth_date
+    if auth_date <= 0 or age_seconds < -30 or age_seconds > max_age_seconds:
+        raise TelegramWebAppAuthError("Telegram Mini App 登录已过期,请从 Bot 重新打开。")
+
+    try:
+        user = json.loads(values.get("user") or "")
+    except (TypeError, json.JSONDecodeError) as exc:
+        raise TelegramWebAppAuthError("Telegram 用户信息无效。") from exc
+    if not isinstance(user, dict):
+        raise TelegramWebAppAuthError("Telegram 用户信息无效。")
+    try:
+        user_id = int(user.get("id"))
+    except (TypeError, ValueError) as exc:
+        raise TelegramWebAppAuthError("Telegram 用户编号无效。") from exc
+    if user_id <= 0 or user.get("is_bot"):
+        raise TelegramWebAppAuthError("Telegram 用户身份无效。")
+    return {
+        "user_id": user_id,
+        "username": str(user.get("username") or ""),
+        "display_name": " ".join(
+            str(user.get(key) or "").strip()
+            for key in ("first_name", "last_name")
+            if str(user.get(key) or "").strip()
+        )
+        or f"技师 {user_id}",
+        "language_code": str(user.get("language_code") or ""),
+        "auth_date": auth_date,
+        "query_id": str(values.get("query_id") or ""),
+    }

+ 113 - 13
wbb/modules/technician_services.py

@@ -6,6 +6,7 @@ from datetime import UTC, datetime, timedelta
 from html import escape
 from io import BytesIO
 from typing import Any
+from urllib.parse import quote
 
 import qrcode
 from pyrogram import StopPropagation, filters
@@ -17,8 +18,10 @@ from pyrogram.types import (
     KeyboardButton,
     Message,
     ReplyKeyboardMarkup,
+    WebAppInfo,
 )
 
+import wbb
 from wbb import BOT_USERNAME, app, log
 from wbb.utils.dbservice import (
     ServiceDataError,
@@ -97,10 +100,42 @@ TRAVEL_FEE_LABELS = {
     "included": "已包含",
     "quoted": "最终报价确认",
 }
+PRICE_MODE_INPUTS = {
+    **{key: key for key in PRICE_MODE_LABELS},
+    **{label: key for key, label in PRICE_MODE_LABELS.items()},
+}
+PRICE_UNIT_INPUTS = {
+    **{key: key for key in PRICE_UNIT_LABELS},
+    "每次": "per_service",
+    "每小时": "per_hour",
+    "每件": "per_item",
+    "每次上门": "per_visit",
+}
+TRAVEL_FEE_INPUTS = {
+    "included": "included",
+    "fixed": "fixed",
+    "per_km": "per_km",
+    "quoted": "quoted",
+    "已包含": "included",
+    "固定交通费": "fixed",
+    "按公里": "per_km",
+    "报价时确认": "quoted",
+}
 _flows: dict[int, dict[str, Any]] = {}
 _address_redaction_task: asyncio.Task[None] | None = None
 
 
+def _technician_mini_app_url() -> str:
+    base_url = str(
+        getattr(wbb, "SERVICE_TECHNICIAN_MINI_APP_URL", "") or ""
+    ).strip()
+    if not base_url:
+        return ""
+    separator = "&" if "?" in base_url else "?"
+    bot_id = quote(str(getattr(wbb, "BOT_PROFILE_ID", "primary")), safe="")
+    return f"{base_url}{separator}bot_id={bot_id}"
+
+
 async def _address_redaction_sweeper() -> None:
     while True:
         try:
@@ -894,26 +929,42 @@ async def _show_profile(target: Message | CallbackQuery) -> None:
         return await _send_message(target, str(exc))
     if profile.get("application_status") != "approved":
         return await _send_message(target, "只有已通过管理员认证的技师可以发布服务资料。")
+    mini_app_url = _technician_mini_app_url()
     service_profile = profile.get("service_profile") or {}
     if service_profile.get("is_complete"):
         package_items = service_profile.get("packages", [])
         packages = "、".join(item["name"] for item in package_items)
-        rows = [
+        rows = []
+        if mini_app_url:
+            rows.append(
+                [
+                    InlineKeyboardButton(
+                        "快速管理套餐",
+                        web_app=WebAppInfo(url=mini_app_url),
+                    )
+                ]
+            )
+        rows.append(
             [
                 InlineKeyboardButton(
                     "暂停接单" if service_profile.get("accepting_requests") else "开始接单",
                     callback_data="svc:profile_toggle",
                 )
             ]
-        ]
-        if len(package_items) < 5:
+        )
+        if not mini_app_url and len(package_items) < 5:
             rows.append(
                 [InlineKeyboardButton("新增套餐", callback_data="svc:profile_add")]
             )
         rows.append(
             [
                 InlineKeyboardButton(
-                    "重建资料与套餐", callback_data="svc:profile_reconfigure"
+                    (
+                        "使用聊天配置(备用)"
+                        if mini_app_url
+                        else "重建资料与套餐"
+                    ),
+                    callback_data="svc:profile_reconfigure",
                 )
             ]
         )
@@ -927,6 +978,28 @@ async def _show_profile(target: Message | CallbackQuery) -> None:
             parse_mode=ParseMode.HTML,
             reply_markup=InlineKeyboardMarkup(rows),
         )
+    if mini_app_url:
+        return await _send_message(
+            target,
+            "使用快速版选择模板,只需确认价格、时长和服务区域即可发布。"
+            "聊天分步配置继续保留为备用入口。",
+            reply_markup=InlineKeyboardMarkup(
+                [
+                    [
+                        InlineKeyboardButton(
+                            "快速创建套餐",
+                            web_app=WebAppInfo(url=mini_app_url),
+                        )
+                    ],
+                    [
+                        InlineKeyboardButton(
+                            "使用聊天配置(备用)",
+                            callback_data="svc:profile_reconfigure",
+                        )
+                    ],
+                ]
+            ),
+        )
     await _show_profile_sources(target)
 
 
@@ -1285,9 +1358,14 @@ async def service_private_text(_, message: Message):
             values = [item.strip() for item in text.split("|")]
             if len(values) != 3:
                 return await message.reply_text("格式为:价格模式|最低价|最高价。")
+            price_mode = PRICE_MODE_INPUTS.get(values[0])
+            if not price_mode:
+                return await message.reply_text(
+                    "价格模式请选择:固定价、起步价、价格区间或面议。"
+                )
             flow["package"].update(
                 {
-                    "price_mode": values[0],
+                    "price_mode": price_mode,
                     "min_price": values[1],
                     "max_price": values[2],
                     "customized_from_template": True,
@@ -1298,10 +1376,15 @@ async def service_private_text(_, message: Message):
             values = [item.strip() for item in text.split("|")]
             if len(values) != 2:
                 return await message.reply_text("格式为:ISO币种|计价单位。")
+            price_unit = PRICE_UNIT_INPUTS.get(values[1])
+            if not price_unit:
+                return await message.reply_text(
+                    "计价单位请选择:每次、每小时、每件或每次上门。"
+                )
             flow["package"].update(
                 {
                     "currency": values[0],
-                    "price_unit": values[1],
+                    "price_unit": price_unit,
                     "customized_from_template": True,
                 }
             )
@@ -1383,14 +1466,25 @@ async def service_private_text(_, message: Message):
             flow["stage"] = "custom_price"
             await message.reply_text(
                 "自建套餐 6/9:按“价格模式|币种|最低价|最高价|计价单位”输入。\n"
-                "价格模式:fixed/starting_at/range/negotiable;计价单位:per_service/per_hour/per_item/per_visit。\n"
-                "示例:fixed|CNY|200|200|per_service"
+                "价格模式:固定价/起步价/价格区间/面议;"
+                "计价单位:每次/每小时/每件/每次上门。\n"
+                "示例:固定价|CNY|200|200|每次"
             )
         elif stage == "custom_price":
             values = [item.strip() for item in text.split("|")]
             if len(values) != 5:
                 return await message.reply_text("格式不正确,请按 5 个字段重新输入。")
-            price_mode, currency, min_price, max_price, price_unit = values
+            raw_price_mode, currency, min_price, max_price, raw_price_unit = values
+            price_mode = PRICE_MODE_INPUTS.get(raw_price_mode)
+            price_unit = PRICE_UNIT_INPUTS.get(raw_price_unit)
+            if not price_mode:
+                return await message.reply_text(
+                    "价格模式不支持,请填写:固定价、起步价、价格区间或面议。"
+                )
+            if not price_unit:
+                return await message.reply_text(
+                    "计价单位不支持,请填写:每次、每小时、每件或每次上门。"
+                )
             flow["package"].update(
                 {
                     "price_mode": price_mode,
@@ -1427,16 +1521,22 @@ async def service_private_text(_, message: Message):
             flow["stage"] = "custom_travel"
             await message.reply_text(
                 "自建套餐 9/9:输入“交通费模式|固定金额|每公里金额”。"
-                "模式为 included/fixed/per_km/quoted,例如:quoted|0|0。"
+                "模式为已包含/固定交通费/按公里/报价时确认,"
+                "例如:报价时确认|0|0。"
             )
         elif stage == "custom_travel":
             values = [item.strip() for item in text.split("|")]
             if len(values) != 3:
                 return await message.reply_text("格式不正确,请按 3 个字段重新输入。")
+            travel_mode = TRAVEL_FEE_INPUTS.get(values[0])
+            if not travel_mode:
+                return await message.reply_text(
+                    "交通费请选择:已包含、固定交通费、按公里或报价时确认。"
+                )
             flow["package"].update(
                 {
                     "travel_fee": {
-                        "mode": values[0],
+                        "mode": travel_mode,
                         "amount": values[1],
                         "per_km": values[2],
                     },
@@ -1633,8 +1733,8 @@ async def service_callback(_, query: CallbackQuery):
                 "description": "请输入新的套餐完整说明。",
                 "tags": "请输入套餐标签,多个用逗号分隔。",
                 "modes": "请输入“到店”、“上门”或“两者”。",
-                "price": "输入“价格模式|最低价|最高价”,例如 fixed|200|200。",
-                "billing": "输入“ISO币种|计价单位”,例如 CNY|per_service。",
+                "price": "输入“价格模式|最低价|最高价”,例如 固定价|200|200。",
+                "billing": "输入“ISO币种|计价单位”,例如 CNY|每次。",
                 "duration": "输入“时长分钟|上门半径公里”,例如 60|20。",
                 "details": "输入“包含项|不包含项|准备事项|超区说明|交通模式|固定金额|每公里金额”。",
                 "addons": "输入“名称|单位|价格|额外分钟”,多个用分号分隔;输入“清空”可移除全部。",

+ 176 - 0
wbb/utils/dbservice.py

@@ -92,6 +92,86 @@ DEFAULT_REVIEW_TEMPLATE = {
         }
     ],
 }
+BUILTIN_PACKAGE_TEMPLATES = (
+    {
+        "template_id": "builtin_quick_at_store",
+        "name": "快速到店服务",
+        "sort_order": -300,
+        "package": {
+            "package_id": "builtin_package_at_store",
+            "name": "标准到店服务",
+            "category": "通用服务",
+            "description": "顾客到店完成一次标准服务,具体内容可在接单后沟通确认。",
+            "tags": ["到店", "标准服务"],
+            "service_modes": ["at_store"],
+            "price_mode": "fixed",
+            "currency": "CNY",
+            "price_unit": "per_service",
+            "min_price": "200",
+            "max_price": "200",
+            "duration_minutes": 60,
+            "included_items": "一次标准服务",
+            "excluded_items": "额外耗材和临时加项",
+            "preparation": "请提前说明具体需求",
+            "addons": [],
+            "travel_fee": {"mode": "included", "amount": "0", "per_km": "0"},
+            "service_radius_km": None,
+            "out_of_range_policy": "",
+        },
+    },
+    {
+        "template_id": "builtin_quick_onsite",
+        "name": "快速上门服务",
+        "sort_order": -200,
+        "package": {
+            "package_id": "builtin_package_onsite",
+            "name": "标准上门服务",
+            "category": "通用服务",
+            "description": "技师上门完成一次标准服务,最终费用以确认报价为准。",
+            "tags": ["上门", "标准服务"],
+            "service_modes": ["onsite"],
+            "price_mode": "starting_at",
+            "currency": "CNY",
+            "price_unit": "per_visit",
+            "min_price": "300",
+            "max_price": "300",
+            "duration_minutes": 90,
+            "included_items": "一次标准上门服务",
+            "excluded_items": "交通费、额外耗材和临时加项",
+            "preparation": "请提前发送位置并说明具体需求",
+            "addons": [],
+            "travel_fee": {"mode": "quoted", "amount": "0", "per_km": "0"},
+            "service_radius_km": 20,
+            "out_of_range_policy": "超出服务半径时,请先沟通交通费和是否可以接单。",
+        },
+    },
+    {
+        "template_id": "builtin_quick_hourly",
+        "name": "快速按小时服务",
+        "sort_order": -100,
+        "package": {
+            "package_id": "builtin_package_hourly",
+            "name": "按小时专业服务",
+            "category": "通用服务",
+            "description": "按服务时长计价,支持到店或上门,具体工作范围提前确认。",
+            "tags": ["按小时", "到店", "上门"],
+            "service_modes": ["at_store", "onsite"],
+            "price_mode": "starting_at",
+            "currency": "CNY",
+            "price_unit": "per_hour",
+            "min_price": "200",
+            "max_price": "200",
+            "duration_minutes": 60,
+            "included_items": "一小时专业服务",
+            "excluded_items": "交通费、额外耗材和超时服务",
+            "preparation": "请提前说明服务内容和预计时长",
+            "addons": [],
+            "travel_fee": {"mode": "quoted", "amount": "0", "per_km": "0"},
+            "service_radius_km": 20,
+            "out_of_range_policy": "超出服务半径时,请先沟通交通费和是否可以接单。",
+        },
+    },
+)
 
 _indexes_ready = False
 
@@ -467,6 +547,43 @@ async def list_package_templates(*, include_archived: bool = False) -> list[dict
     return await templatesdb.find(filters).sort([("sort_order", ASCENDING), ("created_at", ASCENDING)]).to_list(length=500)
 
 
+async def ensure_builtin_package_templates() -> list[dict[str, Any]]:
+    """Seed editable starter templates once without overwriting administrator changes."""
+    await ensure_service_indexes()
+    now = utc_now()
+    template_ids = []
+    for item in BUILTIN_PACKAGE_TEMPLATES:
+        template_id = str(item["template_id"])
+        template_ids.append(template_id)
+        package = normalize_package(
+            item["package"],
+            package_id=str(item["package"]["package_id"]),
+        )
+        package["source_type"] = "template"
+        await templatesdb.update_one(
+            {"template_id": template_id},
+            {
+                "$setOnInsert": {
+                    "template_id": template_id,
+                    "version": 1,
+                    "name": item["name"],
+                    "admin_note": "系统内置快速模板,可在后台编辑、停用或归档。",
+                    "package": package,
+                    "status": "enabled",
+                    "sort_order": int(item["sort_order"]),
+                    "builtin": True,
+                    "created_by": "system",
+                    "created_at": now,
+                    "updated_at": now,
+                }
+            },
+            upsert=True,
+        )
+    return await templatesdb.find({"template_id": {"$in": template_ids}}).sort(
+        [("sort_order", ASCENDING)]
+    ).to_list(length=len(template_ids))
+
+
 async def create_package_template(values: dict[str, Any], *, actor_id: str) -> dict[str, Any]:
     await ensure_service_indexes()
     now = utc_now()
@@ -568,6 +685,65 @@ async def get_technician_service_profile(user_id: int) -> dict[str, Any]:
     return profile
 
 
+async def get_technician_self_service_context(user_id: int) -> dict[str, Any]:
+    profile = await profilesdb.find_one({"user_id": int(user_id)})
+    if not profile:
+        raise ServiceDataError("technician_not_found", "未找到技师申请资料。")
+    location_configured = bool(
+        await locationsdb.find_one({"user_id": int(user_id)})
+    )
+    membership_active = bool(
+        await membershipsdb.find_one({"user_id": int(user_id), "active": True})
+    )
+    issues = []
+    if profile.get("application_status") != "approved":
+        issues.append({"code": "approval_required", "message": "技师认证尚未通过。"})
+    if not profile.get("username"):
+        issues.append(
+            {
+                "code": "username_required",
+                "message": "请先在 Telegram 设置用户名。",
+            }
+        )
+    if not location_configured:
+        issues.append(
+            {
+                "code": "location_required",
+                "message": "请先在 Bot 中设置服务基准位置。",
+            }
+        )
+    if not membership_active:
+        issues.append(
+            {
+                "code": "membership_required",
+                "message": "当前不在受管群成员名单中。",
+            }
+        )
+    return {
+        "user_id": int(user_id),
+        "display_name": profile.get("display_name") or f"技师 {user_id}",
+        "username": profile.get("username"),
+        "eligibility": {"ready": not issues, "issues": issues},
+        "service_profile": profile.get("service_profile") or None,
+    }
+
+
+async def set_technician_accepting_requests(
+    user_id: int,
+    accepting_requests: bool,
+) -> dict[str, Any]:
+    profile = await get_technician_service_profile(user_id)
+    values = dict(profile.get("service_profile") or {})
+    if not values.get("is_complete"):
+        raise ServiceDataError("technician_profile_required", "请先发布至少一个服务套餐。")
+    values["accepting_requests"] = bool(accepting_requests)
+    return await publish_technician_profile(
+        user_id,
+        values,
+        actor_id=user_id,
+    )
+
+
 async def instantiate_package_template(template_id: str) -> dict[str, Any]:
     template = await templatesdb.find_one(
         {"template_id": str(template_id), "status": "enabled"}