index.vue 32 KB

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