index.vue 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. <template>
  2. <view class="order-apply-page">
  3. <nav-bar title="下单预约"></nav-bar>
  4. <view class="apply-content">
  5. <!-- 01 服务类型 -->
  6. <text class="section-title">01 服务类型</text>
  7. <view class="card service-info-card">
  8. <view class="service-type-display">
  9. <view :class="['service-icon-box', activeService]">
  10. <!-- CSS 手绘服务图标 @Author: Antigravity -->
  11. <view class="pure-css-icon" :class="serviceIconClass"></view>
  12. </view>
  13. <view class="service-info-text">
  14. <text class="main-name">{{ currentServiceName }}</text>
  15. <text class="sub-desc">{{ serviceDesc }}</text>
  16. </view>
  17. </view>
  18. </view>
  19. <!-- 02 基础信息 -->
  20. <text class="section-title">02 基础信息</text>
  21. <view class="card basic-info-card">
  22. <view class="field-item" @click="showShopSelect = true">
  23. <text class="field-label require">服务门店</text>
  24. <text :class="['field-value', !formData.shopName ? 'placeholder' : '']">{{ formData.shopName || '请选择商户门店' }}</text>
  25. <view class="right-arrow"></view>
  26. </view>
  27. <view class="field-item" @click="showUserSelect = true">
  28. <text class="field-label require">宠主用户</text>
  29. <view class="field-value-wrap">
  30. <template v-if="selectedUser">
  31. <text class="selected-name">{{ selectedUser.name }}</text>
  32. <text class="selected-phone">{{ selectedUser.phone || selectedUser.phoneNumber }}</text>
  33. </template>
  34. <text v-else class="placeholder">点击搜索</text>
  35. </view>
  36. <view class="right-arrow"></view>
  37. </view>
  38. <view class="field-item" @click="openPetPicker">
  39. <text class="field-label require">选择宠物</text>
  40. <text :class="['field-value', !formData.petName ? 'placeholder' : '']">{{ formData.petName || '选择宠物档案' }}</text>
  41. <view class="right-arrow"></view>
  42. </view>
  43. </view>
  44. <!-- 03 业务表单 - 宠物接送 -->
  45. <template v-if="activeService === 'transport'">
  46. <text class="section-title">03 接送路线与时间</text>
  47. <view class="card transport-card">
  48. <view class="field-item">
  49. <text class="field-label">团购套餐</text>
  50. <input class="field-input" v-model="formData.packageName" placeholder="请输入套餐名称(选填)" />
  51. </view>
  52. <text class="form-item-label require">接送模式</text>
  53. <view class="mode-select">
  54. <view v-for="mode in transportModes" :key="mode.value"
  55. :class="['mode-btn', { active: formData.transportMode === mode.value }]"
  56. @click="formData.transportMode = mode.value">
  57. <text>{{ mode.label }}</text>
  58. </view>
  59. </view>
  60. <!-- 接宠路线 @Author: Antigravity -->
  61. <view class="route-box" v-if="formData.transportMode !== 'return_home'">
  62. <view class="route-icon pick">接</view>
  63. <view class="route-fields">
  64. <text class="addr-label require">起点 (用户家)</text>
  65. <view class="route-picker-trigger" @click="openRegionSelect('pick')">
  66. <text :class="['display-text', !formData.pickArea ? 'placeholder' : '']">{{ pickAreaLabel || '选择省/市/区' }}</text>
  67. <view class="right-arrow"></view>
  68. </view>
  69. <input class="route-input" v-model="formData.pickAddress" placeholder="详细地址" />
  70. <text class="addr-label require">终点 (门店)</text>
  71. <view class="route-picker-trigger" @click="openRegionSelect('pickEnd')">
  72. <text :class="['display-text', !formData.pickEndArea ? 'placeholder' : '']">{{ pickEndAreaLabel || '选择省/市/区' }}</text>
  73. <view class="right-arrow"></view>
  74. </view>
  75. <input class="route-input" v-model="formData.pickEndAddress" placeholder="详细地址" />
  76. <view class="contact-row">
  77. <input class="route-input half" v-model="formData.pickContact" placeholder="联系人" />
  78. <input class="route-input half" v-model="formData.pickPhone" placeholder="电话" type="tel" />
  79. </view>
  80. <view class="route-time-trigger" @click="openTimeModal('pick')">
  81. <text :class="!formData.pickTime ? 'placeholder' : ''">{{ pickTimeDisplay || '设置接宠时间' }}</text>
  82. </view>
  83. </view>
  84. </view>
  85. <!-- 送宠路线 @Author: Antigravity -->
  86. <view class="route-box" v-if="formData.transportMode !== 'pick_up'">
  87. <view class="route-icon send">送</view>
  88. <view class="route-fields">
  89. <text class="addr-label require">起点 (门店)</text>
  90. <view class="route-picker-trigger" @click="openRegionSelect('sendStart')">
  91. <text :class="['display-text', !formData.sendStartArea ? 'placeholder' : '']">{{ sendStartAreaLabel || '选择省/市/区' }}</text>
  92. <view class="right-arrow"></view>
  93. </view>
  94. <input class="route-input" v-model="formData.sendStartAddress" placeholder="详细地址" />
  95. <text class="addr-label require">终点 (用户家)</text>
  96. <view class="route-picker-trigger" @click="openRegionSelect('send')">
  97. <text :class="['display-text', !formData.sendArea ? 'placeholder' : '']">{{ sendAreaLabel || '选择省/市/区' }}</text>
  98. <view class="right-arrow"></view>
  99. </view>
  100. <input class="route-input" v-model="formData.sendAddress" placeholder="详细地址" />
  101. <view class="contact-row">
  102. <input class="route-input half" v-model="formData.sendContact" placeholder="联系人" />
  103. <input class="route-input half" v-model="formData.sendPhone" placeholder="电话" type="tel" />
  104. </view>
  105. <view class="route-time-trigger" @click="openTimeModal('send')">
  106. <text :class="!formData.sendTime ? 'placeholder' : ''">{{ sendTimeDisplay || '设置送宠时间' }}</text>
  107. </view>
  108. </view>
  109. </view>
  110. <!-- 接送备注 -->
  111. <text class="remarks-title">备注信息</text>
  112. <textarea class="remarks-textarea" v-model="formData.transportNote" placeholder="请添加接送备注 (如宠物性格、接送要求等)"></textarea>
  113. </view>
  114. </template>
  115. <!-- 03 业务表单 - 上门 -->
  116. <template v-else>
  117. <text class="section-title">03 服务细则</text>
  118. <view class="card feed-card">
  119. <view class="field-item">
  120. <text class="field-label">团购套餐</text>
  121. <input class="field-input" v-model="formData.packageName" placeholder="请输入套餐名称(选填)" />
  122. </view>
  123. <view class="route-box">
  124. <view class="route-icon service">服</view>
  125. <view class="route-fields">
  126. <text class="addr-label require">上门服务地址</text>
  127. <view class="route-picker-trigger" @click="openRegionSelect('service')">
  128. <text :class="['display-text', !formData.serviceArea ? 'placeholder' : '']">{{ serviceAreaLabel || '请选择省/市/区' }}</text>
  129. <view class="right-arrow"></view>
  130. </view>
  131. <input class="route-input" v-model="formData.serviceAddress" placeholder="详细地址 (街道/路名/门牌号)" />
  132. </view>
  133. </view>
  134. <view class="booking-section">
  135. <view class="booking-header">
  136. <text class="label require">预约服务时间</text>
  137. <view class="count-tag">共 {{ formData.feedTimes.length }} 次</view>
  138. </view>
  139. <view class="time-item-row" v-for="(time, index) in formData.feedTimes" :key="index">
  140. <text class="index">{{ index + 1 }}.</text>
  141. <view class="flex-time-box" @click="openTimeModal('feed', index, 'start')">
  142. <text :class="['time-text', !time.start ? 'placeholder' : '']">{{ truncateTime(time.start) || '开始' }}</text>
  143. </view>
  144. <text class="to-line">~</text>
  145. <view class="flex-time-box" @click="openTimeModal('feed', index, 'end')">
  146. <text :class="['time-text', !time.end ? 'placeholder' : '']">{{ truncateTime(time.end) || '结束' }}</text>
  147. </view>
  148. <view class="action-buttons">
  149. <view class="circle-btn add" v-if="index === formData.feedTimes.length - 1" @click="addFeedTime">+</view>
  150. <view class="circle-btn remove" v-if="formData.feedTimes.length > 1" @click="removeFeedTime(index)">-</view>
  151. </view>
  152. </view>
  153. </view>
  154. <text class="remarks-title">备注信息</text>
  155. <textarea class="remarks-textarea" v-model="formData.otherNote" placeholder="如有其他注意事项请备注"></textarea>
  156. </view>
  157. </template>
  158. <!-- 04 报价信息 -->
  159. <text class="section-title">04 报价信息</text>
  160. <view class="card quote-card">
  161. <view class="field-item">
  162. <text class="field-label require">报价金额</text>
  163. <input class="field-input quote-input" v-model="formData.quoteAmount" type="digit" placeholder="填入数字" />
  164. <text class="unit-text">元</text>
  165. </view>
  166. <text class="quote-tips">注:此价格将作为订单最终结算金额。</text>
  167. </view>
  168. </view>
  169. <!-- 底部操作栏 -->
  170. <view class="footer-bar safe-bottom">
  171. <view class="quotation-box">
  172. <text class="p-label">总计报价:</text>
  173. <text class="p-symbol">¥</text>
  174. <text class="p-amount">{{ totalFulfillmentCommission }}</text>
  175. </view>
  176. <button class="submit-btn" @click="onSubmit">立即下单</button>
  177. </view>
  178. <!-- 居中联动选择弹窗群 @Author: Antigravity -->
  179. <!-- 宠主搜索弹窗 -->
  180. <view class="center-modal-mask" v-if="showUserSelect" @click="showUserSelect = false">
  181. <view class="center-modal-content user-search-modal" @click.stop>
  182. <view class="modal-header">
  183. <view class="search-box">
  184. <view class="search-icon"></view>
  185. <input class="search-input" v-model="userSearchKey" placeholder="搜索宠主姓名/手机号" @confirm="fetchUsers" confirm-type="search" />
  186. <view class="search-btn" @click="fetchUsers">查询</view>
  187. </view>
  188. </view>
  189. <scroll-view scroll-y class="modal-list-scroll">
  190. <view class="list-item" v-for="user in userList" :key="user.id" @click="onUserSelect(user)">
  191. <view class="user-info">
  192. <text class="name">{{ user.name }}</text>
  193. <text class="phone">{{ user.phone || user.phoneNumber }}</text>
  194. </view>
  195. <view class="checkmark" v-if="formData.customerId === user.id"></view>
  196. </view>
  197. <view class="empty-tip" v-if="userList.length === 0">未找到相关宠主</view>
  198. </scroll-view>
  199. </view>
  200. </view>
  201. <!-- 区域选择器 (Cascader) @Author: Antigravity -->
  202. <view class="center-modal-mask" v-if="showRegionModal" @click="showRegionModal = false">
  203. <view class="center-modal-content region-modal" @click.stop>
  204. <view class="modal-header"><text class="modal-title">选择区域</text><view class="close-btn" @click="showRegionModal = false"></view></view>
  205. <view class="cascade-indicator">
  206. <text v-for="(node, idx) in regionPath" :key="idx" class="path-node" @click="backToLevel(idx)">{{ node.name }}</text>
  207. <text class="path-node active" v-if="regionPath.length < 3">请选择</text>
  208. </view>
  209. <scroll-view scroll-y class="modal-list-scroll">
  210. <view class="list-item" v-for="item in currentRegionList" :key="item.code" @click="onRegionStepSelect(item)">
  211. <text class="item-text">{{ item.name }}</text>
  212. <view class="checkmark" v-if="isRegionSelected(item)"></view>
  213. </view>
  214. </scroll-view>
  215. </view>
  216. </view>
  217. <!-- 门店选择 -->
  218. <center-select v-model="showShopSelect" title="选择服务门店" :options="shopList" labelKey="name" valueKey="id" :value="formData.merchantId" @select="onShopSelect" />
  219. <!-- 宠物选择 -->
  220. <center-select v-model="showPetPopup" title="选择指定宠物" :options="petOptions" labelKey="_label" valueKey="id" :value="formData.petId" @select="onPetSelect" />
  221. <!-- 日期时间选择弹窗 @Author: Antigravity -->
  222. <view class="center-modal-mask" v-if="showTimeModal" @click="showTimeModal = false">
  223. <view class="center-modal-content time-modal" @click.stop>
  224. <view class="modal-header"><text class="modal-title">选择预约时间</text></view>
  225. <view class="datetime-picker-body">
  226. <picker-view class="picker-view" :value="tempTimeIdx" @change="onTempTimeChange">
  227. <picker-view-column><view class="picker-item" v-for="d in timeRanges[0]" :key="d">{{ d }}</view></picker-view-column>
  228. <picker-view-column><view class="picker-item" v-for="h in timeRanges[1]" :key="h">{{ h }}时</view></picker-view-column>
  229. <picker-view-column><view class="picker-item" v-for="m in timeRanges[2]" :key="m">{{ m }}分</view></picker-view-column>
  230. </picker-view>
  231. </view>
  232. <view class="modal-footer">
  233. <view class="modal-cancel" @click="showTimeModal = false">取消</view>
  234. <view class="modal-confirm" @click="confirmTime">确定</view>
  235. </view>
  236. </view>
  237. </view>
  238. </view>
  239. </template>
  240. <script setup>
  241. /**
  242. * @Author: Antigravity
  243. */
  244. import { ref, reactive, computed, watch } from 'vue'
  245. import { onLoad } from '@dcloudio/uni-app'
  246. import navBar from '@/components/nav-bar/index.vue'
  247. import centerSelect from '@/components/center-select/index.vue'
  248. import { listStoreOnOrder } from '@/api/system/store'
  249. import { listCustomerOnOrder } from '@/api/archieves/customer'
  250. import { listPetByUser } from '@/api/archieves/pet'
  251. import { createOrder } from '@/api/order/order'
  252. import { listRegionTree } from '@/api/system/region'
  253. const activeService = ref('transport')
  254. const serviceInfo = ref(null)
  255. const shopList = ref([])
  256. const userList = ref([])
  257. const petList = ref([])
  258. const regionTree = ref([])
  259. // 弹窗控制
  260. const showShopSelect = ref(false)
  261. const showUserSelect = ref(false)
  262. const showPetPopup = ref(false)
  263. const showRegionModal = ref(false)
  264. const showTimeModal = ref(false)
  265. const userSearchKey = ref('')
  266. const selectedUser = ref(null)
  267. const selectedShop = ref(null)
  268. const pickAreaLabel = ref('')
  269. const pickEndAreaLabel = ref('')
  270. const sendStartAreaLabel = ref('')
  271. const sendAreaLabel = ref('')
  272. const serviceAreaLabel = ref('')
  273. const formData = reactive({
  274. merchantId: '', shopName: '', customerId: '', customerName: '', petId: '', petName: '',
  275. packageName: '', transportMode: 'round_trip',
  276. pickArea: '', pickAddress: '', pickEndArea: '', pickEndAddress: '', pickContact: '', pickPhone: '', pickTime: '',
  277. sendStartArea: '', sendStartAddress: '', sendArea: '', sendAddress: '', sendContact: '', sendPhone: '', sendTime: '',
  278. serviceArea: '', serviceAddress: '', feedTimes: [{ start: '', end: '' }],
  279. otherNote: '', transportNote: '', quoteAmount: ''
  280. })
  281. // 时间选择器逻辑 (5分钟一个间隔 @Author: Antigravity)
  282. const timeRanges = ref([[], [], []])
  283. const tempTimeIdx = ref([0, 0, 0])
  284. const timeCtx = reactive({ type: '', index: 0, field: '' })
  285. onLoad((options) => {
  286. if (options.service) activeService.value = options.service
  287. const stored = uni.getStorageSync('currentService')
  288. if (stored) serviceInfo.value = stored
  289. initTimeRanges()
  290. fetchShops(); fetchUsers(); fetchRegionTree()
  291. })
  292. const initTimeRanges = () => {
  293. const dates = []
  294. const now = new Date()
  295. for (let i = 0; i < 30; i++) {
  296. const d = new Date(now); d.setDate(d.getDate() + i)
  297. dates.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
  298. }
  299. timeRanges.value = [
  300. dates,
  301. Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')),
  302. // 五分钟间隔生成 @Author: Antigravity
  303. Array.from({ length: 12 }, (_, i) => String(i * 5).padStart(2, '0'))
  304. ]
  305. }
  306. const openTimeModal = (type, index = 0, field = '') => {
  307. timeCtx.type = type; timeCtx.index = index; timeCtx.field = field
  308. tempTimeIdx.value = [0, 0, 0]
  309. showTimeModal.value = true
  310. }
  311. const onTempTimeChange = (e) => { tempTimeIdx.value = e.detail.value }
  312. const confirmTime = () => {
  313. const [di, hi, mi] = tempTimeIdx.value
  314. const val = `${new Date().getFullYear()}-${timeRanges.value[0][di]} ${timeRanges.value[1][hi]}:${timeRanges.value[2][mi]}:00`
  315. if (timeCtx.type === 'pick') formData.pickTime = val
  316. else if (timeCtx.type === 'send') formData.sendTime = val
  317. else if (timeCtx.type === 'feed') {
  318. if (timeCtx.field === 'start') formData.feedTimes[timeCtx.index].start = val
  319. else formData.feedTimes[timeCtx.index].end = val
  320. }
  321. showTimeModal.value = false
  322. }
  323. const truncateTime = (t) => t ? t.substring(5, 16) : ''
  324. const pickTimeDisplay = computed(() => truncateTime(formData.pickTime))
  325. const sendTimeDisplay = computed(() => truncateTime(formData.sendTime))
  326. // 区域选择逻辑
  327. const regionPath = ref([])
  328. const activeRegionType = ref('')
  329. const currentRegionList = computed(() => {
  330. let list = regionTree.value
  331. for (let node of regionPath.value) {
  332. const found = list.find(l => l.code === node.code)
  333. if (found && found.children) list = found.children
  334. else list = []
  335. }
  336. return list
  337. })
  338. const openRegionSelect = (type) => {
  339. activeRegionType.value = type
  340. regionPath.value = []
  341. showRegionModal.value = true
  342. }
  343. const backToLevel = (idx) => { regionPath.value = regionPath.value.slice(0, idx) }
  344. const onRegionStepSelect = (item) => {
  345. regionPath.value.push({ code: item.code, name: item.name })
  346. if (!item.children || item.children.length === 0 || regionPath.value.length >= 3) {
  347. const fullLabel = regionPath.value.map(p => p.name).join(' / ')
  348. const finalCode = item.code
  349. if (activeRegionType.value === 'pick') { formData.pickArea = finalCode; pickAreaLabel.value = fullLabel }
  350. else if (activeRegionType.value === 'pickEnd') { formData.pickEndArea = finalCode; pickEndAreaLabel.value = fullLabel }
  351. else if (activeRegionType.value === 'sendStart') { formData.sendStartArea = finalCode; sendStartAreaLabel.value = fullLabel }
  352. else if (activeRegionType.value === 'send') { formData.sendArea = finalCode; sendAreaLabel.value = fullLabel }
  353. else if (activeRegionType.value === 'service') { formData.serviceArea = finalCode; serviceAreaLabel.value = fullLabel }
  354. showRegionModal.value = false
  355. }
  356. }
  357. const isRegionSelected = (item) => {
  358. const level = regionPath.value.length
  359. return regionPath.value[level]?.code === item.code
  360. }
  361. // 核心回填逻辑修正 @Author: Antigravity
  362. watch([selectedShop, selectedUser, regionTree], ([shop, user, tree]) => {
  363. if (!shop && !user) return
  364. // 处理门店信息
  365. const storeAreaCode = (shop?.areaCode || '').replace(/,/g, '/')
  366. const storeLeaf = storeAreaCode.split('/').pop() || ''
  367. const storePath = findRegionLabel(storeAreaCode, tree)
  368. // 处理用户信息
  369. const userAreaCode = (user?.regionCode || '')
  370. const userLeaf = userAreaCode.split('/').pop() || ''
  371. const userPath = findRegionLabel(userAreaCode, tree)
  372. if (shop) {
  373. formData.merchantId = shop.id; formData.shopName = shop.name
  374. // 接送单终点 = 门店
  375. formData.pickEndArea = storeLeaf; formData.pickEndAddress = shop.address || ''
  376. pickEndAreaLabel.value = storePath
  377. // 接送单起点 = 门店
  378. formData.sendStartArea = storeLeaf; formData.sendStartAddress = shop.address || ''
  379. sendStartAreaLabel.value = storePath
  380. }
  381. if (user) {
  382. formData.customerId = user.id; formData.customerName = user.name
  383. // 接宠单起点 = 宠主家
  384. formData.pickArea = userLeaf; formData.pickAddress = user.address || ''
  385. pickAreaLabel.value = userPath
  386. // 送宠单终点 = 宠主家
  387. formData.sendArea = userLeaf; formData.sendAddress = user.address || ''
  388. sendAreaLabel.value = userPath
  389. // 服务单地址 = 宠主家
  390. formData.serviceArea = userLeaf; formData.serviceAddress = user.address || ''
  391. serviceAreaLabel.value = userPath
  392. formData.pickContact = user.name; formData.pickPhone = user.phoneNumber || user.phone || ''
  393. formData.sendContact = user.name; formData.sendPhone = formData.pickPhone
  394. }
  395. }, { deep: true })
  396. const findRegionLabel = (code, list) => {
  397. if (!code || !list || list.length === 0) return ''
  398. const target = code.split('/').pop()
  399. const find = (nodes, t) => {
  400. for (let n of nodes) {
  401. if (n.code === t) return n.name
  402. if (n.children) {
  403. const res = find(n.children, t)
  404. if (res) return n.name + ' / ' + res
  405. }
  406. }
  407. return null
  408. }
  409. return find(list, target) || ''
  410. }
  411. const fetchShops = () => listStoreOnOrder({ pageNum: 1, pageSize: 50, serviceId: serviceInfo.value?.id }).then(res => { shopList.value = res.rows || [] })
  412. const fetchUsers = () => listCustomerOnOrder({ pageNum: 1, pageSize: 20, content: userSearchKey.value }).then(res => { userList.value = res.rows || [] })
  413. const fetchPets = (uid) => listPetByUser(uid).then(res => { petList.value = Array.isArray(res) ? res : (res.rows || []) })
  414. const fetchRegionTree = () => listRegionTree().then(res => { regionTree.value = res || [] })
  415. const onShopSelect = (shop) => { selectedShop.value = shop; showShopSelect.value = false }
  416. const onUserSelect = (user) => {
  417. selectedUser.value = user; formData.customerId = user.id;
  418. formData.petId = ''; formData.petName = ''; petList.value = []; fetchPets(user.id)
  419. showUserSelect.value = false
  420. }
  421. const openPetPicker = () => { if (!formData.customerId) return uni.showToast({ title: '先选择宠主', icon: 'none' }); showPetPopup.value = true }
  422. const petOptions = computed(() => petList.value.map(p => ({ ...p, _label: `${p.name} (${p.breed || '未知'})` })))
  423. const onPetSelect = (pet) => { formData.petId = pet.id; formData.petName = pet.name; showPetPopup.value = false }
  424. const currentServiceName = computed(() => serviceInfo.value?.name || (activeService.value === 'transport' ? '宠物接送' : '上门喂遛'))
  425. const serviceIconClass = computed(() => activeService.value)
  426. const serviceDesc = computed(() => serviceInfo.value?.remark || '专人专项 · 贴心呵护')
  427. const transportModes = [{ label: '往返', value: 'round_trip' }, { label: '单程接', value: 'pick_up' }, { label: '单程送', value: 'return_home' }]
  428. const addFeedTime = () => formData.feedTimes.push({ start: '', end: '' })
  429. const removeFeedTime = (idx) => formData.feedTimes.splice(idx, 1)
  430. const totalFulfillmentCommission = computed(() => formData.quoteAmount ? parseFloat(formData.quoteAmount).toFixed(2) : '0.00')
  431. const onSubmit = async () => {
  432. if (!formData.merchantId || !formData.customerId || !formData.petId || !formData.quoteAmount) return uni.showToast({ title: '请完善红星必填项', icon: 'none' })
  433. uni.showLoading({ title: '提交中...', mask: true })
  434. try {
  435. const subOrders = []
  436. const baseMode = serviceInfo.value?.mode || 0
  437. const defC = selectedUser.value?.name; const defP = selectedUser.value?.phone || selectedUser.value?.phoneNumber
  438. if (activeService.value === 'transport') {
  439. if (formData.transportMode !== 'return_home') subOrders.push({ mode: baseMode, type: formData.transportMode === 'round_trip' ? 0 : 2, contact: formData.pickContact || defC, contactPhoneNumber: formData.pickPhone || defP, serviceTime: formData.pickTime, endServiceTime: formData.pickTime, fromCode: formData.pickArea, fromAddress: formData.pickAddress, toCode: formData.pickEndArea, toAddress: formData.pickEndAddress })
  440. if (formData.transportMode !== 'pick_up') subOrders.push({ mode: baseMode, type: formData.transportMode === 'round_trip' ? 1 : 3, contact: formData.sendContact || defC, contactPhoneNumber: formData.sendPhone || defP, serviceTime: formData.sendTime, endServiceTime: formData.sendTime, fromCode: formData.sendStartArea, fromAddress: formData.sendStartAddress, toCode: formData.sendArea, toAddress: formData.sendAddress })
  441. } else {
  442. formData.feedTimes.forEach(t => subOrders.push({ mode: baseMode, contact: defC, contactPhoneNumber: defP, serviceTime: t.start, endServiceTime: t.end || t.start, fromCode: formData.serviceArea, fromAddress: formData.serviceAddress, toCode: formData.serviceArea, toAddress: formData.serviceAddress }))
  443. }
  444. // 根据服务类型选择对应备注字段(与Web端逻辑对齐:transport用transportNote,其他用otherNote)
  445. const orderRemark = activeService.value === 'transport' ? formData.transportNote : formData.otherNote
  446. const payload = { store: formData.merchantId, storeSite: selectedShop.value?.site, customer: formData.customerId, pet: formData.petId, groupPurchasePackageName: formData.packageName, service: serviceInfo.value?.id, orderCommission: Math.round(Number(formData.quoteAmount) * 100), remark: orderRemark, tenantId: selectedShop.value?.tenantId, subOrders }
  447. await createOrder(payload)
  448. uni.showToast({ title: '成功', icon: 'success' })
  449. setTimeout(() => uni.reLaunch({ url: '/pages/order/list/index' }), 1000)
  450. } catch (e) { } finally { uni.hideLoading() }
  451. }
  452. </script>
  453. <style lang="scss" scoped>
  454. /* 统一页面字体栈 @Author: Antigravity */
  455. .order-apply-page {
  456. background: #f7f8fa;
  457. min-height: 100vh;
  458. padding-bottom: 220rpx;
  459. font-family: 'PingFang SC', 'Helvetica Neue', Helvetica, 'STHeitiSTXihei', 'Microsoft YaHei', Arial, sans-serif;
  460. }
  461. .apply-content { padding: 0 28rpx; }
  462. .section-title { display: flex; align-items: center; font-size: 28rpx; font-weight: bold; color: #333; margin: 32rpx 0 20rpx; &::before { content: ''; width: 8rpx; height: 26rpx; background: #f7ca3e; margin-right: 16rpx; border-radius: 4rpx; } }
  463. .card { background: #fff; border-radius: 24rpx; padding: 24rpx; margin-bottom: 24rpx; }
  464. .service-type-display { display: flex; align-items: center; gap: 24rpx; }
  465. .service-icon-box { width: 88rpx; height: 88rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; }
  466. .service-icon-box.transport { background: linear-gradient(135deg, #64b5f6, #2196f3); }
  467. .service-icon-box.feed { background: linear-gradient(135deg, #ffb74d, #ff9800); }
  468. .service-icon-box.wash { background: linear-gradient(135deg, #81c784, #4caf50); }
  469. .main-name {
  470. display: block;
  471. font-size: 32rpx;
  472. font-weight: bold;
  473. color: #333;
  474. }
  475. .sub-desc {
  476. display: block;
  477. font-size: 24rpx;
  478. color: #999;
  479. margin-top: 4rpx;
  480. }
  481. /* CSS 手绘图标 @Author: Antigravity */
  482. .pure-css-icon { width: 40rpx; height: 40rpx; border: 4rpx solid #fff; border-radius: 8rpx; position: relative; &::after { content: ''; position: absolute; top: 10rpx; left: 10rpx; width: 12rpx; height: 12rpx; background: #fff; border-radius: 50%; } }
  483. .field-item { display: flex; align-items: center; padding: 28rpx 0; border-bottom: 2rpx solid #f5f5f5; height: 44rpx; &:last-child { border-bottom: none; } }
  484. .field-label { width: 180rpx; font-size: 28rpx; color: #333; flex-shrink: 0; }
  485. .require::before { content: '*'; color: #f56c6c; margin-right: 4rpx; }
  486. .field-value { flex: 1; font-size: 28rpx; color: #333; text-align: right; margin-right: 16rpx; }
  487. .field-value.placeholder { color: #ccc; }
  488. .field-value-wrap { flex: 1; display: flex; flex-direction: column; align-items: flex-end; margin-right: 16rpx; .selected-name { font-size: 28rpx; font-weight: bold; color: #333; } .selected-phone { font-size: 22rpx; color: #999; } }
  489. .placeholder { color: #ccc; font-size: 28rpx; }
  490. .mode-select { display: flex; gap: 16rpx; margin: 20rpx 0 32rpx; }
  491. .mode-btn { flex: 1; height: 60rpx; display: flex; align-items: center; justify-content: center; border: 2rpx solid #f0f0f0; border-radius: 30rpx; font-size: 24rpx; color: #666; &.active { background: #fef8e5; border-color: #f7ca3e; color: #f7ca3e; font-weight: bold; } }
  492. .route-box { display: flex; gap: 20rpx; margin-bottom: 30rpx; }
  493. .route-icon { width: 44rpx; height: 44rpx; border-radius: 8rpx; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 22rpx; font-weight: bold; flex-shrink: 0; margin-top: 10rpx; }
  494. .route-icon.pick { background: #5bb7ff; }
  495. .route-icon.send { background: #64cf5c; }
  496. .route-icon.service { background: #ff9500; }
  497. .route-fields { flex: 1; display: flex; flex-direction: column; gap: 6rpx; }
  498. .addr-label { font-size: 22rpx; color: #999; margin-top: 10rpx; }
  499. .route-picker-trigger { height: 64rpx; border-bottom: 2rpx solid #f5f5f5; display: flex; align-items: center; justify-content: space-between; .display-text { font-size: 26rpx; color: #333; &.placeholder { color: #ccc; } } }
  500. .route-input { height: 72rpx; font-size: 26rpx; border-bottom: 2rpx solid #f5f5f5; &.half { flex: 1; } }
  501. .contact-row { display: flex; gap: 16rpx; }
  502. .route-time-trigger { height: 72rpx; background: #f9f9f9; border-radius: 12rpx; display: flex; align-items: center; padding: 0 20rpx; font-size: 26rpx; color: #333; margin-top: 10rpx; .placeholder { color: #ccc; } }
  503. .address-title, .form-item-label, .booking-header .label, .remarks-title { display: block; font-size: 26rpx; color: #666; margin: 20rpx 0 10rpx; }
  504. .booking-section { margin-top: 24rpx; }
  505. .count-tag { font-size: 20rpx; color: #ff9500; background: #fff3e0; padding: 4rpx 12rpx; border-radius: 6rpx; }
  506. .time-item-row { display: flex; align-items: center; gap: 12rpx; margin-bottom: 16rpx; }
  507. .index { font-size: 24rpx; color: #999; width: 30rpx; }
  508. .flex-time-box { flex: 1; height: 64rpx; background: #fcfcfc; border: 2rpx solid #eee; border-radius: 10rpx; display: flex; align-items: center; justify-content: center; .time-text { font-size: 24rpx; color: #333; &.placeholder { color: #ccc; } } }
  509. .action-buttons { display: flex; gap: 12rpx; margin-left: 8rpx; }
  510. .circle-btn { width: 44rpx; height: 44rpx; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 32rpx; font-weight: bold; &.add { background: #e3f2fd; color: #2196f3; } &.remove { background: #fde2e2; color: #f56c6c; } }
  511. .remarks-textarea { width: 100%; height: 140rpx; font-size: 26rpx; background: #f9f9f9; border-radius: 16rpx; padding: 16rpx; box-sizing: border-box; }
  512. .quote-input { flex: 1; font-size: 36rpx; color: #f44336; font-weight: bold; text-align: right; }
  513. .unit-text { font-size: 28rpx; color: #333; margin-left: 8rpx; }
  514. .quote-tips { display: block; font-size: 22rpx; color: #999; margin-top: 20rpx; }
  515. .footer-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 24rpx 32rpx; display: flex; align-items: center; justify-content: space-between; box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.05); z-index: 100; }
  516. .quotation-box { display: flex; align-items: baseline; .p-label { font-size: 24rpx; color: #333; } .p-symbol { font-size: 28rpx; color: #f44336; font-weight: bold; margin-left: 8rpx; } .p-amount { font-size: 40rpx; font-weight: 900; color: #f44336; } }
  517. .submit-btn { width: 280rpx; height: 84rpx; background: linear-gradient(90deg, #ffd53f, #ff9500); color: #fff; border-radius: 42rpx; font-size: 28rpx; font-weight: bold; line-height: 84rpx; &::after { border: none; } }
  518. /* CSS Common Modal UI @Author: Antigravity */
  519. .center-modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 10000; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4rpx); }
  520. .center-modal-content { width: 620rpx; background: #fff; border-radius: 32rpx; display: flex; flex-direction: column; overflow: hidden; animation: popIn 0.3s ease-out; }
  521. @keyframes popIn { from { transform: scale(0.9); opacity: 0; } to { transform: scale(1); opacity: 1; } }
  522. .modal-header { padding: 32rpx; border-bottom: 2rpx solid #f2f2f2; position: relative; text-align: center; .modal-title { font-size: 30rpx; font-weight: bold; color: #333; } }
  523. .close-btn { position: absolute; right: 24rpx; top: 24rpx; width: 44rpx; height: 44rpx; &::before, &::after { content: ''; position: absolute; top: 20rpx; left: 8rpx; width: 28rpx; height: 4rpx; background: #999; transform: rotate(45deg); border-radius: 4rpx; } &::after { transform: rotate(-45deg); } }
  524. .search-box { display: flex; align-items: center; background: #f5f5f5; border-radius: 36rpx; padding: 0 24rpx; height: 72rpx; margin: 0 4rpx; .search-icon { width: 20rpx; height: 20rpx; border: 3rpx solid #999; border-radius: 50%; margin-right: 12rpx; position: relative; &::after { content: ''; width: 10rpx; height: 3rpx; background: #999; position: absolute; bottom: -4rpx; right: -4rpx; transform: rotate(45deg); } } .search-input { flex: 1; font-size: 26rpx; } .search-btn { font-size: 26rpx; color: #ff9500; font-weight: bold; margin-left: 20rpx; } }
  525. .modal-list-scroll { flex: 1; max-height: 55vh; padding: 0 32rpx; }
  526. .list-item { display: flex; align-items: center; justify-content: space-between; padding: 30rpx 0; border-bottom: 2rpx solid #f9f9f9; .user-info { display: flex; flex-direction: column; .name { font-size: 28rpx; font-weight: bold; color: #333; } .phone { font-size: 22rpx; color: #999; margin-top: 4rpx; } } }
  527. .checkmark { width: 12rpx; height: 22rpx; border-right: 4rpx solid #ff9500; border-bottom: 4rpx solid #ff9500; transform: rotate(45deg); }
  528. .cascade-indicator { display: flex; padding: 20rpx 32rpx; background: #fafafa; border-bottom: 2rpx solid #f2f2f2; flex-wrap: wrap; gap: 12rpx; .path-node { font-size: 24rpx; color: #666; &.active { color: #ff9500; font-weight: bold; } } }
  529. .datetime-picker-body { height: 400rpx; padding: 20rpx 0; }
  530. .picker-view { width: 100%; height: 100%; }
  531. .picker-item { line-height: 80rpx; text-align: center; font-size: 28rpx; }
  532. .modal-footer { display: flex; border-top: 2rpx solid #f2f2f2; .modal-cancel, .modal-confirm { flex: 1; height: 96rpx; line-height: 96rpx; text-align: center; font-size: 28rpx; } .modal-confirm { color: #ff9500; font-weight: bold; border-left: 2rpx solid #f2f2f2; } }
  533. .right-arrow { width: 12rpx; height: 12rpx; border-right: 3rpx solid #ccc; border-top: 3rpx solid #ccc; transform: rotate(45deg); flex-shrink: 0; }
  534. .empty-tip { padding: 80rpx 0; text-align: center; color: #ccc; font-size: 24rpx; }
  535. </style>