index.vue 31 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147
  1. <template>
  2. <div class="page-container">
  3. <div class="create-layout">
  4. <!-- 左侧:下单填写区 -->
  5. <div class="form-container">
  6. <!-- 1. 基础信息:门店与宠主 -->
  7. <el-card shadow="never" class="section-card">
  8. <template #header>
  9. <div class="card-title"><span class="step-num">01</span> 基础信息</div>
  10. </template>
  11. <div class="card-body">
  12. <el-form label-position="top" class="base-form">
  13. <el-row :gutter="20">
  14. <el-col :span="12">
  15. <el-form-item>
  16. <template #label>
  17. <div style="display: flex; align-items: center; height: 24px">
  18. <span>服务门店</span>
  19. </div>
  20. </template>
  21. <PageSelect
  22. v-model="form.merchantId"
  23. placeholder="请选择商户门店"
  24. size="large"
  25. style="width: 100%"
  26. :options="merchantOptions"
  27. :total="storeTotal"
  28. :page-size="5"
  29. @page-change="handleStorePageChange"
  30. @update:modelValue="handleStoreChange"
  31. />
  32. </el-form-item>
  33. </el-col>
  34. <el-col :span="12">
  35. <el-form-item>
  36. <template #label>
  37. <div style="display: flex; justify-content: space-between; align-items: center; width: 100%; height: 24px">
  38. <span>宠主用户</span>
  39. <el-button v-hasPermi="['order:purchase:addCustomer']" type="primary" plain size="small" @click="openAddUser" icon="Plus" style="margin-left: 15px">添加用户</el-button>
  40. </div>
  41. </template>
  42. <PageSelect
  43. v-model="form.userId"
  44. placeholder="搜索姓名/手机号"
  45. size="large"
  46. style="width: 100%"
  47. :options="userSelectOptions"
  48. :total="userTotal"
  49. :page-size="5"
  50. :filter-method="searchUser"
  51. @page-change="handleUserPageChange"
  52. @update:modelValue="handleUserChange"
  53. />
  54. </el-form-item>
  55. </el-col>
  56. </el-row>
  57. <el-form-item label="选择宠物" v-if="form.userId">
  58. <div class="pet-select-row">
  59. <div v-for="p in currentPets" :key="p.id" class="pet-card" :class="{ active: form.petId === p.id }" @click="form.petId = p.id">
  60. <el-avatar :size="48" :src="p.avatar" shape="square" style="border-radius: 6px">{{ p.name.charAt(0) }}</el-avatar>
  61. <div class="pet-info">
  62. <div class="name">{{ p.name }}</div>
  63. <div class="sub">{{ p.breed }}</div>
  64. </div>
  65. <div class="check-mark" v-if="form.petId === p.id">
  66. <el-icon>
  67. <Check />
  68. </el-icon>
  69. </div>
  70. </div>
  71. <!-- Add Button Card (Last Item in Grid) -->
  72. <div v-hasPermi="['order:purchase:addPet']" class="pet-card add-card" @click="openAddPet">
  73. <el-icon :size="24">
  74. <Plus />
  75. </el-icon>
  76. <span style="font-size: 15px; font-weight: bold">新增宠物</span>
  77. </div>
  78. </div>
  79. </el-form-item>
  80. </el-form>
  81. </div>
  82. </el-card>
  83. <!-- 2. 服务类型选择 -->
  84. <div class="type-selection" v-if="form.merchantId">
  85. <div
  86. v-for="item in availableServices"
  87. :key="item.id"
  88. class="type-card"
  89. :class="[getServiceType(item), { active: form.serviceId === item.id }]"
  90. @click="handleServiceChange(item)"
  91. >
  92. <div class="icon-box">
  93. <img
  94. v-if="item.iconUrl"
  95. :src="item.iconUrl"
  96. class="service-icon-img"
  97. />
  98. <el-icon v-else-if="typeof item.icon === 'string' && isNaN(Number(item.icon))">
  99. <component :is="item.icon" />
  100. </el-icon>
  101. <el-icon v-else>
  102. <component :is="getServiceIcon(item)" />
  103. </el-icon>
  104. </div>
  105. <div class="text">
  106. <div class="type-name">{{ item.name }}</div>
  107. <div class="type-desc">{{ item.remark }}</div>
  108. </div>
  109. </div>
  110. <div v-if="availableServices.length === 0" style="grid-column: 1 / -1; color: #909399; text-align: center; padding: 20px">
  111. 该门店暂无可选服务
  112. </div>
  113. </div>
  114. <div v-else style="color: #909399; margin: 20px 0; padding: 20px; text-align: center; background: #fff; border-radius: 8px">
  115. 请先在上一步中选择服务门店
  116. </div>
  117. <!-- 3. 业务详情表单 -->
  118. <el-card shadow="never" class="section-card form-card" v-if="form.type">
  119. <template #header>
  120. <div class="card-title">
  121. <span class="step-num">02</span>
  122. {{ getStepTitle(form.mode, form.type) }}
  123. </div>
  124. </template>
  125. <div class="card-body">
  126. <!-- 服务套餐信息 -->
  127. <el-row :gutter="20">
  128. <el-col :span="12">
  129. <el-form-item label="团购套餐">
  130. <el-input v-model="form.groupBuyPackage" placeholder="请输入团购套餐名称 (选填)" clearable />
  131. </el-form-item>
  132. </el-col>
  133. <el-col :span="12">
  134. <el-form-item label="订单佣金">
  135. <el-input-number v-model="form.orderCommission" :min="0" :precision="2" :step="1" placeholder="请输入佣金 (元)" style="width: 100%" />
  136. </el-form-item>
  137. </el-col>
  138. </el-row>
  139. <div class="divider"></div>
  140. <!-- A. 宠物接送表单 -->
  141. <TransportForm v-show="form.type === 'transport'" :transport-data="form.transport" :pca-options="pcaOptions" @change="calcPrice" />
  142. <!-- B. 上门喂遛表单 -->
  143. <FeedingForm v-show="form.type === 'feeding'" :feeding-data="form.feeding" :pca-options="pcaOptions" @change="calcPrice" />
  144. <!-- C. 上门洗护表单 -->
  145. <WashingForm v-show="form.type === 'washing'" :washing-data="form.washing" :pca-options="pcaOptions" @change="calcPrice" />
  146. </div>
  147. </el-card>
  148. </div>
  149. <!-- 右侧:收银台概览 -->
  150. <div class="summary-sidebar">
  151. <div class="summary-panel">
  152. <div class="summary-header">订单概览</div>
  153. <div class="summary-content">
  154. <div class="row" v-if="selectedMerchantName">
  155. <span class="label">服务门店</span>
  156. <span class="value">{{ selectedMerchantName }}</span>
  157. </div>
  158. <div class="row" v-if="selectedUserName">
  159. <span class="label">客户</span>
  160. <span class="value">{{ selectedUserName }}</span>
  161. </div>
  162. <div class="row" v-if="selectedPetName">
  163. <span class="label">服务对象</span>
  164. <span class="value action-text">{{ selectedPetName }} ({{ selectedPetBreed }})</span>
  165. </div>
  166. <div class="divider"></div>
  167. <div class="service-preview" v-if="form.type">
  168. <div class="preview-title">{{ selectedServiceName }}</div>
  169. <!-- 套餐显示 -->
  170. <div class="preview-detail" v-if="selectedPkgName">
  171. <div style="font-weight: bold; color: #409eff">{{ selectedPkgName }}</div>
  172. </div>
  173. <div class="preview-detail" v-else>
  174. <div style="color: #e6a23c">非服务套餐 (单次)</div>
  175. </div>
  176. <!-- 接送预览 -->
  177. <div v-if="form.type === 'transport'" class="preview-detail">
  178. <div>{{ form.transport.subType === 'round' ? '往返接送' : form.transport.subType === 'pick' ? '单程接' : '单程送' }}</div>
  179. <div class="minor">接: {{ form.transport.pickTime ? formatTime(form.transport.pickTime) : '未选时间' }}</div>
  180. <div class="minor" v-if="form.transport.subType !== 'pick'">
  181. 送: {{ form.transport.dropTime ? formatTime(form.transport.dropTime) : '未选' }}
  182. </div>
  183. </div>
  184. </div>
  185. </div>
  186. <div class="summary-footer">
  187. <el-button v-hasPermi="['order:purchase:create']" type="primary" size="large" class="submit-btn" :disabled="!canSubmit" @click="handleSubmit"> 立即下单 </el-button>
  188. </div>
  189. </div>
  190. </div>
  191. </div>
  192. <!-- Dialogs -->
  193. <!-- Add User Dialog -->
  194. <AddUserDialog v-model:visible="userDialogVisible" :pca-options="pcaOptions" @success="handleUserSuccess" />
  195. <AddPetDialog v-model:visible="petDialogVisible" :user-id="form.userId" :user-options="userOptions" @success="handlePetSuccess" />
  196. </div>
  197. </template>
  198. <script setup lang="ts">
  199. import { ref, reactive, computed, onMounted, watch } from 'vue';
  200. import { ElMessage } from 'element-plus';
  201. import TransportForm from './components/TransportForm.vue';
  202. import FeedingForm from './components/FeedingForm.vue';
  203. import WashingForm from './components/WashingForm.vue';
  204. import AddUserDialog from './components/AddUserDialog.vue';
  205. import AddPetDialog from './components/AddPetDialog.vue';
  206. import PageSelect from '@/components/PageSelect/index.vue';
  207. import { listStoreOnOrder } from '@/api/system/store';
  208. import { listAllService } from '@/api/service/list/index';
  209. import { listCustomerOnOrder } from '@/api/archieves/customer';
  210. import { listPetByUser } from '@/api/archieves/pet';
  211. import { regionData as pcaOptions } from 'element-china-area-data';
  212. import { createOrder } from '@/api/order/order';
  213. // --- State ---
  214. const userOptions = ref([]);
  215. const userTotal = ref(0);
  216. const userQuery = reactive({ pageNum: 1, pageSize: 5, content: '' });
  217. const userLoading = ref(false);
  218. const serviceList = [
  219. { type: 'transport', name: '宠物接送', icon: 'Van', desc: '专车接送 · 全程监护', basePrice: 35 },
  220. { type: 'feeding', name: '上门喂遛', icon: 'Food', desc: '喂食添水 · 陪玩遛狗', basePrice: 68 },
  221. { type: 'washing', name: '上门洗护', icon: 'Soap', desc: '专业设备 · 深度清洁', basePrice: 88 }
  222. ];
  223. const allPackages = [
  224. { id: 10, type: 'transport', name: '包月接送套餐', fulfillmentCommission: 0 },
  225. { id: 11, type: 'feeding', name: '基础喂猫套餐', fulfillmentCommission: 0 },
  226. { id: 12, type: 'feeding', name: '深度陪玩套餐', fulfillmentCommission: 0 },
  227. { id: 13, type: 'washing', name: '精致洗护+美容', fulfillmentCommission: 0 },
  228. { id: 14, type: 'washing', name: '除菌药浴套餐', fulfillmentCommission: 0 }
  229. ];
  230. const currentPets = ref([]);
  231. const stores = ref([]);
  232. const storeTotal = ref(0);
  233. const allServices = ref([]);
  234. const storeQuery = reactive({ pageNum: 1, pageSize: 5 });
  235. const form = reactive({
  236. merchantId: '',
  237. userId: '',
  238. petId: '',
  239. serviceId: '',
  240. type: '',
  241. mode: undefined,
  242. groupBuyPackage: '',
  243. orderCommission: 0,
  244. // Sub Forms Data
  245. transport: {
  246. pkgId: '',
  247. fulfillmentCommission: 0,
  248. pickPrice: 35,
  249. dropPrice: 35,
  250. subType: 'round',
  251. pickStartRegion: [],
  252. pickStartDetail: '',
  253. pickEndRegion: [],
  254. pickEndDetail: '',
  255. pickContact: '',
  256. pickPhone: '',
  257. pickTime: '',
  258. dropStartRegion: [],
  259. dropStartDetail: '',
  260. dropEndRegion: [],
  261. dropEndDetail: '',
  262. dropContact: '',
  263. dropPhone: '',
  264. dropTime: ''
  265. },
  266. feeding: {
  267. pkgId: '',
  268. fulfillmentCommission: 68,
  269. appointments: [{ startTime: '', endTime: '' }],
  270. region: [],
  271. addressDetail: '',
  272. count: 1,
  273. dates: [],
  274. area: '',
  275. itemLoc: '',
  276. cleanLoc: '',
  277. foodAmount: '',
  278. other: ''
  279. },
  280. washing: {
  281. pkgId: '',
  282. fulfillmentCommission: 88,
  283. appointments: [{ startTime: '', endTime: '' }],
  284. region: [],
  285. addressDetail: '',
  286. time: '',
  287. petStatus: '',
  288. cleanLoc: '',
  289. toolLoc: '',
  290. other: ''
  291. }
  292. });
  293. // Address Autofill Watcher
  294. watch([() => form.merchantId, () => form.userId, () => form.petId], () => {
  295. const store = stores.value.find((s) => s.id === form.merchantId);
  296. const user = userOptions.value.find((u) => u.id === form.userId);
  297. const storeRegion = store?.areaCode ? store.areaCode.split(',') : [];
  298. const storeAddr = store?.address || '';
  299. const userRegion = user?.regionCode ? user.regionCode.split('/') : [];
  300. const userAddr = user?.address || '';
  301. // Fill Transport Pick
  302. form.transport.pickStartRegion = userRegion;
  303. form.transport.pickStartDetail = userAddr;
  304. form.transport.pickEndRegion = storeRegion;
  305. form.transport.pickEndDetail = storeAddr;
  306. form.transport.pickContact = user?.name || '';
  307. form.transport.pickPhone = user?.phoneNumber || user?.phone || '';
  308. // Fill Transport Drop
  309. form.transport.dropStartRegion = storeRegion;
  310. form.transport.dropStartDetail = storeAddr;
  311. form.transport.dropEndRegion = userRegion;
  312. form.transport.dropEndDetail = userAddr;
  313. form.transport.dropContact = user?.name || '';
  314. form.transport.dropPhone = user?.phoneNumber || user?.phone || '';
  315. // Fill Feeding (上门服务)
  316. form.feeding.region = userRegion;
  317. form.feeding.addressDetail = userAddr;
  318. // Fill Washing (上门服务)
  319. form.washing.region = userRegion;
  320. form.washing.addressDetail = userAddr;
  321. });
  322. // Current Active Data Helper
  323. const activeData = computed(() => {
  324. return form[form.type];
  325. });
  326. // --- Logic ---
  327. const fetchStores = () => {
  328. listStoreOnOrder(storeQuery).then((res) => {
  329. stores.value = res.rows || [];
  330. storeTotal.value = res.total || 0;
  331. });
  332. };
  333. const handleStorePageChange = (page) => {
  334. storeQuery.pageNum = page;
  335. fetchStores();
  336. };
  337. const handleStoreChange = (val) => {
  338. const store = stores.value.find((s) => s.id === val);
  339. if (store && store.services) {
  340. if (!store.services.includes(form.serviceId)) {
  341. form.serviceId = '';
  342. form.type = '';
  343. form.mode = undefined;
  344. }
  345. } else {
  346. form.serviceId = '';
  347. form.type = '';
  348. form.mode = undefined;
  349. }
  350. };
  351. const getServiceType = (item) => {
  352. if (!item) return 'transport';
  353. // 1. 如果是接送单模式 (mode=1),强制为 transport
  354. if (item.mode === 1 || item.mode === '1') return 'transport';
  355. // 2. 如果是服务单模式 (mode=0),根据名称区分喂遛还是洗护
  356. const name = typeof item === 'string' ? item : (item.name || '');
  357. if (name.includes('喂') || name.includes('遛')) return 'feeding';
  358. if (name.includes('洗护') || name.includes('美容')) return 'washing';
  359. return 'feeding'; // 默认服务类型
  360. };
  361. const getServiceIcon = (item) => {
  362. const type = getServiceType(item);
  363. const map = { transport: 'Van', feeding: 'Food', washing: 'Soap' };
  364. return map[type] || 'Menu';
  365. };
  366. const handleServiceChange = (item) => {
  367. form.serviceId = item.id;
  368. form.mode = item.mode;
  369. form.type = getServiceType(item);
  370. calcPrice(form.type);
  371. };
  372. const currentPackages = computed(() => {
  373. return allPackages.filter((p) => p.type === form.type);
  374. });
  375. const handlePkgSelect = (id) => {
  376. activeData.value.pkgId = id;
  377. // Price calculation should remain same (base fulfillmentCommission), just payable changes
  378. calcPrice(form.type);
  379. };
  380. const calcPrice = (type) => {
  381. const data = form[type];
  382. const base = serviceList.find((s) => s.type === type)?.basePrice || 0;
  383. // Always use Base Logic for "Order Value", regardless of package
  384. if (type === 'transport') {
  385. if (data.subType === 'round') {
  386. data.pickPrice = base;
  387. data.dropPrice = base;
  388. } else if (data.subType === 'pick') {
  389. data.pickPrice = base;
  390. data.dropPrice = 0;
  391. } else if (data.subType === 'drop') {
  392. data.pickPrice = 0;
  393. data.dropPrice = base;
  394. }
  395. } else if (type === 'feeding') {
  396. data.fulfillmentCommission = base * data.count;
  397. } else if (type === 'washing') {
  398. data.fulfillmentCommission = base;
  399. }
  400. };
  401. // Add User Logic
  402. const userDialogVisible = ref(false);
  403. const openAddUser = () => {
  404. userDialogVisible.value = true;
  405. };
  406. const handleUserSuccess = (newUser) => {
  407. // 重新获取列表
  408. userQuery.pageNum = 1;
  409. fetchUsers();
  410. if (newUser && newUser.id) {
  411. // 后端如果直接返回了用户信息或主键,尝试将其加入列表中并选中
  412. const exists = userOptions.value.find((u) => u.id === newUser.id);
  413. if (!exists) {
  414. userOptions.value.unshift(newUser);
  415. }
  416. form.userId = newUser.id;
  417. currentPets.value = [];
  418. form.petId = '';
  419. ElMessage.success('用户添加成功并已自动选中');
  420. } else {
  421. // 未返回具体信息情况,清空当前选中项让用户重选
  422. form.userId = '';
  423. currentPets.value = [];
  424. form.petId = '';
  425. ElMessage.success('用户添加成功');
  426. }
  427. };
  428. // Removed mocked pcaOptions since we now use element-china-area-data
  429. // Add Pet Logic
  430. const petDialogVisible = ref(false);
  431. const openAddPet = () => {
  432. petDialogVisible.value = true;
  433. };
  434. const handlePetSuccess = (newPet) => {
  435. if (form.userId) {
  436. listPetByUser(form.userId).then((res) => {
  437. currentPets.value = res.data || res.rows || [];
  438. if (newPet && newPet.id) {
  439. form.petId = newPet.id;
  440. ElMessage.success('宠物添加成功并已自动选中');
  441. } else {
  442. form.petId = '';
  443. ElMessage.success('宠物添加成功');
  444. }
  445. });
  446. } else {
  447. ElMessage.success('宠物添加成功');
  448. }
  449. };
  450. // --- Computed Helpers ---
  451. const merchantOptions = computed(() => {
  452. return stores.value.map((m) => ({ label: m.name, value: m.id }));
  453. });
  454. const availableServices = computed(() => {
  455. const store = stores.value.find((s) => s.id === form.merchantId);
  456. if (!store || !store.services) return [];
  457. return allServices.value.filter((srv) => store.services.includes(srv.id));
  458. });
  459. const userSelectOptions = computed(() => {
  460. return userOptions.value.map((u) => ({ label: `${u.name} - ${u.phoneNumber || u.phone}`, value: u.id }));
  461. });
  462. const selectedMerchantName = computed(() => stores.value.find((m) => m.id === form.merchantId)?.name);
  463. const selectedUserName = computed(() => userOptions.value.find((u) => u.id === form.userId)?.name);
  464. const selectedPet = computed(() => currentPets.value.find((p) => p.id === form.petId));
  465. const selectedPetName = computed(() => selectedPet.value?.name);
  466. const selectedPetBreed = computed(() => selectedPet.value?.breed);
  467. const selectedServiceName = computed(() => {
  468. return availableServices.value.find((s) => s.id === form.serviceId)?.name || getTypeName(form.type);
  469. });
  470. const selectedPkgName = computed(() => {
  471. const pkgId = activeData.value?.pkgId;
  472. return allPackages.find((p) => p.id === pkgId)?.name || '';
  473. });
  474. const canSubmit = computed(() => {
  475. if (!form.merchantId || !form.userId || !form.petId || !form.serviceId) return false;
  476. return true;
  477. });
  478. // --- Methods ---
  479. const fetchUsers = () => {
  480. if (!userQuery.content) {
  481. userOptions.value = [];
  482. userTotal.value = 0;
  483. return;
  484. }
  485. listCustomerOnOrder(userQuery).then((res) => {
  486. userOptions.value = res.rows || [];
  487. userTotal.value = res.total || 0;
  488. });
  489. };
  490. const handleUserPageChange = (page) => {
  491. userQuery.pageNum = page;
  492. fetchUsers();
  493. };
  494. const searchUser = (query) => {
  495. userQuery.content = query || '';
  496. userQuery.pageNum = 1;
  497. fetchUsers();
  498. };
  499. const handleUserChange = (val) => {
  500. form.petId = '';
  501. currentPets.value = [];
  502. if (!val) return;
  503. listPetByUser(val).then((res) => {
  504. currentPets.value = res.data || res.rows || [];
  505. });
  506. };
  507. const getStepTitle = (mode, type) => {
  508. if (mode === 1 || mode === '1') return '填写接送路线与时间';
  509. return '选择套餐与服务的细则';
  510. };
  511. const getTypeName = (type) => {
  512. const map = { transport: '宠物接送', feeding: '上门喂遛', washing: '上门洗护' };
  513. return map[type];
  514. };
  515. const formatTime = (time) => {
  516. if (!time) return '';
  517. const d = new Date(time);
  518. return `${d.getMonth() + 1}-${d.getDate()} ${d.getHours()}:${d.getMinutes() < 10 ? '0' + d.getMinutes() : d.getMinutes()}`;
  519. };
  520. const resetForm = () => {
  521. form.merchantId = '';
  522. form.userId = '';
  523. form.petId = '';
  524. form.serviceId = '';
  525. form.type = '';
  526. form.mode = undefined;
  527. form.groupBuyPackage = '';
  528. form.orderCommission = 0;
  529. form.transport = {
  530. pkgId: '',
  531. fulfillmentCommission: 0,
  532. pickPrice: 35,
  533. dropPrice: 35,
  534. subType: 'round',
  535. pickStartRegion: [],
  536. pickStartDetail: '',
  537. pickEndRegion: [],
  538. pickEndDetail: '',
  539. pickContact: '',
  540. pickPhone: '',
  541. pickTime: '',
  542. dropStartRegion: [],
  543. dropStartDetail: '',
  544. dropEndRegion: [],
  545. dropEndDetail: '',
  546. dropContact: '',
  547. dropPhone: '',
  548. dropTime: ''
  549. };
  550. form.feeding = {
  551. pkgId: '',
  552. fulfillmentCommission: 68,
  553. appointments: [{ startTime: '', endTime: '' }],
  554. region: [],
  555. addressDetail: '',
  556. count: 1,
  557. dates: [],
  558. area: '',
  559. itemLoc: '',
  560. cleanLoc: '',
  561. foodAmount: '',
  562. other: ''
  563. };
  564. form.washing = {
  565. pkgId: '',
  566. fulfillmentCommission: 88,
  567. appointments: [{ startTime: '', endTime: '' }],
  568. region: [],
  569. addressDetail: '',
  570. time: '',
  571. petStatus: '',
  572. cleanLoc: '',
  573. toolLoc: '',
  574. other: ''
  575. };
  576. currentPets.value = [];
  577. };
  578. const handleSubmit = async () => {
  579. try {
  580. const storeObj = stores.value.find((s) => s.id === form.merchantId);
  581. if (!storeObj) {
  582. ElMessage.warning('请选择门店');
  583. return;
  584. }
  585. let subOrders = [];
  586. const baseMode = form.mode || 0;
  587. // 获取默认客户联系方式
  588. const userObj = userOptions.value.find((u) => u.id === form.userId);
  589. const defaultContact = userObj?.name || '';
  590. const defaultPhone = userObj?.phoneNumber || userObj?.phone || '';
  591. if (form.type === 'transport') {
  592. const td = form.transport;
  593. const createTransportSubOrder = (orderType, time, startRegion, startDetail, endRegion, endDetail, contact, phone) => {
  594. return {
  595. mode: baseMode,
  596. type: orderType,
  597. contact: contact || defaultContact,
  598. contactPhoneNumber: phone || defaultPhone,
  599. serviceTime: time || '',
  600. endServiceTime: time || '',
  601. fromCode: startRegion && startRegion.length > 0 ? startRegion[startRegion.length - 1] : '',
  602. fromAddress: startDetail || '',
  603. toCode: endRegion && endRegion.length > 0 ? endRegion[endRegion.length - 1] : '',
  604. toAddress: endDetail || ''
  605. };
  606. };
  607. // 接送单:往返算两个,接/送分别算一个
  608. if (td.subType === 'round' || td.subType === 'pick') {
  609. subOrders.push(
  610. createTransportSubOrder(
  611. td.subType === 'round' ? 0 : 2,
  612. td.pickTime,
  613. td.pickStartRegion,
  614. td.pickStartDetail,
  615. td.pickEndRegion,
  616. td.pickEndDetail,
  617. td.pickContact,
  618. td.pickPhone
  619. )
  620. );
  621. }
  622. if (td.subType === 'round' || td.subType === 'drop') {
  623. subOrders.push(
  624. createTransportSubOrder(
  625. td.subType === 'round' ? 1 : 3,
  626. td.dropTime,
  627. td.dropStartRegion,
  628. td.dropStartDetail,
  629. td.dropEndRegion,
  630. td.dropEndDetail,
  631. td.dropContact,
  632. td.dropPhone
  633. )
  634. );
  635. }
  636. } else {
  637. // 上门喂遛或洗护:一个服务时间一个子订单
  638. const hd = form[form.type];
  639. let code = hd.region && hd.region.length > 0 ? hd.region[hd.region.length - 1] : '';
  640. let address = hd.addressDetail || '';
  641. const createHomeSubOrder = (startTime, endTime) => {
  642. return {
  643. mode: baseMode,
  644. contact: defaultContact,
  645. contactPhoneNumber: defaultPhone,
  646. serviceTime: startTime || hd.time || '',
  647. endServiceTime: endTime || startTime || hd.time || '',
  648. fromCode: code,
  649. fromAddress: address,
  650. toCode: code,
  651. toAddress: address
  652. };
  653. };
  654. if (hd.appointments && hd.appointments.length > 0) {
  655. hd.appointments.forEach((appt) => {
  656. subOrders.push(createHomeSubOrder(appt.startTime, appt.endTime));
  657. });
  658. } else if (hd.time) {
  659. // 兼容没有appointments只有time的情况
  660. subOrders.push(createHomeSubOrder(hd.time, hd.time));
  661. }
  662. }
  663. const payload = {
  664. store: form.merchantId,
  665. storeSite: storeObj.site,
  666. customer: form.userId,
  667. pet: form.petId,
  668. groupPurchasePackageName: form.groupBuyPackage || '',
  669. service: form.serviceId,
  670. orderCommission: Math.round(Number(form.orderCommission || 0) * 100),
  671. remark: '', // 表单目前暂无备注字段
  672. tenantId: storeObj.tenantId || '',
  673. subOrders: subOrders
  674. };
  675. const res = await createOrder(payload);
  676. if (res && res.code === 200) {
  677. ElMessage.success('下单成功');
  678. resetForm();
  679. } else {
  680. // 如果没有抛异常,走这里
  681. ElMessage.success('下单成功');
  682. resetForm();
  683. }
  684. } catch (error) {
  685. console.error('Create order error: ', error);
  686. }
  687. };
  688. // Initialize
  689. onMounted(() => {
  690. fetchStores();
  691. listAllService().then((res) => {
  692. allServices.value = res.data || [];
  693. });
  694. });
  695. </script>
  696. <style scoped>
  697. .page-container {
  698. padding: 20px;
  699. background-color: #f0f2f5;
  700. min-height: 100vh;
  701. }
  702. .create-layout {
  703. display: flex;
  704. gap: 20px;
  705. align-items: flex-start;
  706. max-width: 1400px;
  707. margin: 0 auto;
  708. }
  709. /* Left Content */
  710. .form-container {
  711. flex: 1;
  712. min-width: 0;
  713. display: flex;
  714. flex-direction: column;
  715. gap: 20px;
  716. }
  717. .section-card {
  718. border-radius: 8px;
  719. border: none;
  720. }
  721. .card-title {
  722. font-size: 16px;
  723. font-weight: bold;
  724. color: #303133;
  725. display: flex;
  726. align-items: center;
  727. gap: 10px;
  728. }
  729. .step-num {
  730. background: #e6f7ff;
  731. color: #1890ff;
  732. width: 28px;
  733. height: 28px;
  734. border-radius: 50%;
  735. text-align: center;
  736. line-height: 28px;
  737. font-family: Impact, sans-serif;
  738. }
  739. .base-form .el-form-item {
  740. margin-bottom: 18px;
  741. }
  742. /* Pet Selection */
  743. /* Pet Selection */
  744. .pet-select-row {
  745. display: grid;
  746. grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  747. gap: 15px;
  748. width: 100%;
  749. }
  750. .pet-card {
  751. border: 1px solid #8d9095;
  752. border-radius: 8px;
  753. padding: 12px 15px;
  754. cursor: pointer;
  755. display: flex;
  756. align-items: center;
  757. gap: 12px;
  758. position: relative;
  759. transition: all 0.2s ease-in-out;
  760. background: #fff;
  761. min-height: 70px;
  762. }
  763. .pet-card:hover {
  764. border-color: #303133;
  765. transform: translateY(-2px);
  766. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  767. }
  768. .pet-card.active {
  769. border-color: #409eff;
  770. background-color: #fff;
  771. box-shadow: 0 0 0 1px #409eff inset;
  772. }
  773. .check-mark {
  774. position: absolute;
  775. right: 0;
  776. top: 0;
  777. background: #409eff;
  778. color: white;
  779. width: 28px;
  780. height: 18px;
  781. border-radius: 0 8px 0 12px;
  782. display: flex;
  783. align-items: center;
  784. justify-content: center;
  785. font-size: 12px;
  786. }
  787. .pet-info .name {
  788. font-weight: bold;
  789. font-size: 15px;
  790. color: #303133;
  791. margin-bottom: 2px;
  792. }
  793. .pet-info .sub {
  794. font-size: 12px;
  795. color: #606266;
  796. line-height: 1.2;
  797. }
  798. .pet-card.add-card {
  799. border: 1px solid #8d9095;
  800. justify-content: center;
  801. align-items: center;
  802. color: #303133;
  803. flex-direction: row;
  804. gap: 8px;
  805. background: #fff;
  806. box-shadow: none;
  807. height: auto;
  808. min-height: 70px;
  809. }
  810. .pet-card.add-card:hover {
  811. border-color: #303133;
  812. color: #303133;
  813. background: #f9f9f9;
  814. transform: translateY(-2px);
  815. }
  816. /* Type Selection */
  817. .type-selection {
  818. display: grid;
  819. grid-template-columns: repeat(3, 1fr);
  820. gap: 15px;
  821. }
  822. .type-card {
  823. background: white;
  824. border-radius: 8px;
  825. padding: 20px;
  826. cursor: pointer;
  827. position: relative;
  828. display: flex;
  829. align-items: center;
  830. gap: 15px;
  831. transition: all 0.2s;
  832. border: 2px solid transparent;
  833. box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
  834. }
  835. .type-card:hover {
  836. transform: translateY(-2px);
  837. box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.1);
  838. }
  839. .type-card.active {
  840. border-color: #409eff;
  841. background-color: #f0f9ff;
  842. }
  843. .type-card .icon-box {
  844. width: 48px;
  845. height: 48px;
  846. border-radius: 12px;
  847. background: #f2f3f5;
  848. display: flex;
  849. align-items: center;
  850. justify-content: center;
  851. font-size: 24px;
  852. color: #606266;
  853. }
  854. .type-card.active .icon-box {
  855. background: #409eff;
  856. color: white;
  857. }
  858. /* Colors */
  859. .type-card.transport.active .icon-box {
  860. background: #409eff;
  861. }
  862. .type-card.transport.active {
  863. border-color: #409eff;
  864. background-color: #f0f9ff;
  865. }
  866. .type-card.feeding.active .icon-box {
  867. background: #e6a23c;
  868. }
  869. .type-card.feeding.active {
  870. border-color: #e6a23c;
  871. background-color: #fdf6ec;
  872. }
  873. .type-card.washing.active .icon-box {
  874. background: #67c23a;
  875. }
  876. .type-card.washing.active {
  877. border-color: #67c23a;
  878. background-color: #f0f9eb;
  879. }
  880. .type-name {
  881. font-weight: bold;
  882. font-size: 16px;
  883. color: #303133;
  884. margin-bottom: 4px;
  885. }
  886. .type-desc {
  887. font-size: 12px;
  888. color: #909399;
  889. margin-bottom: 4px;
  890. display: -webkit-box;
  891. -webkit-line-clamp: 2;
  892. -webkit-box-orient: vertical;
  893. overflow: hidden;
  894. }
  895. .type-fulfillmentCommission {
  896. font-size: 14px;
  897. color: #f56c6c;
  898. font-weight: bold;
  899. }
  900. /* Custom Backend Icon Img */
  901. .service-icon-img {
  902. width: 28px;
  903. height: 28px;
  904. object-fit: cover;
  905. border-radius: 4px;
  906. }
  907. /* Package Selection Grid */
  908. .form-section-title {
  909. font-weight: bold;
  910. margin-bottom: 12px;
  911. font-size: 14px;
  912. }
  913. .package-selection-grid {
  914. display: grid;
  915. grid-template-columns: repeat(4, 1fr);
  916. gap: 12px;
  917. margin-bottom: 20px;
  918. }
  919. .pkg-select-card {
  920. border: 1px solid #dcdfe6;
  921. border-radius: 8px;
  922. padding: 10px 15px;
  923. cursor: pointer;
  924. position: relative;
  925. background: #fff;
  926. transition: all 0.2s;
  927. min-height: 56px;
  928. display: flex;
  929. flex-direction: column;
  930. justify-content: center;
  931. }
  932. .pkg-select-card:hover {
  933. border-color: #409eff;
  934. }
  935. .pkg-select-card.active {
  936. border-color: #409eff;
  937. background-color: #ecf5ff;
  938. }
  939. .pkg-select-card .pkg-name {
  940. font-weight: bold;
  941. font-size: 14px;
  942. color: #303133;
  943. }
  944. .pkg-select-card .pkg-desc {
  945. font-size: 12px;
  946. color: #909399;
  947. margin-top: 2px;
  948. }
  949. .divider {
  950. height: 1px;
  951. background: #ebeef5;
  952. margin: 15px 0;
  953. }
  954. /* Sidebar */
  955. .summary-sidebar {
  956. width: 320px;
  957. flex-shrink: 0;
  958. }
  959. .summary-panel {
  960. background: white;
  961. border-radius: 8px;
  962. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
  963. position: sticky;
  964. top: 20px;
  965. }
  966. .summary-header {
  967. background: #304156;
  968. color: white;
  969. padding: 15px 20px;
  970. font-weight: bold;
  971. font-size: 16px;
  972. border-radius: 8px 8px 0 0;
  973. }
  974. .summary-content {
  975. padding: 20px;
  976. }
  977. .row {
  978. display: flex;
  979. justify-content: space-between;
  980. margin-bottom: 12px;
  981. font-size: 14px;
  982. }
  983. .row .label {
  984. color: #909399;
  985. }
  986. .row .value {
  987. color: #303133;
  988. font-weight: 500;
  989. }
  990. .preview-title {
  991. font-weight: bold;
  992. margin-bottom: 8px;
  993. color: #333;
  994. }
  995. .preview-detail {
  996. background: #f8f9fa;
  997. padding: 10px;
  998. border-radius: 4px;
  999. font-size: 13px;
  1000. margin-bottom: 8px;
  1001. }
  1002. .preview-detail .minor {
  1003. color: #999;
  1004. font-size: 12px;
  1005. margin-top: 2px;
  1006. }
  1007. .placeholder {
  1008. color: #c0c4cc;
  1009. text-align: center;
  1010. padding: 20px 0;
  1011. font-size: 13px;
  1012. font-style: italic;
  1013. }
  1014. .summary-footer {
  1015. background: #f9f9fc;
  1016. padding: 15px 20px;
  1017. border-top: 1px solid #ebeef5;
  1018. text-align: center;
  1019. border-radius: 0 0 8px 8px;
  1020. }
  1021. .submit-btn {
  1022. width: 100%;
  1023. font-weight: bold;
  1024. border-radius: 22px;
  1025. }
  1026. </style>