| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410 |
- <template>
- <view class="order-apply-page">
- <nav-bar title="下单预约"></nav-bar>
- <view class="apply-content">
- <!-- 01 服务类型 -->
- <text class="section-title">01 服务类型</text>
- <view class="card service-info-card">
- <view class="service-type-display">
- <view :class="['service-icon-box', activeService]">
- <!-- CSS 手绘服务图标 @Author: Antigravity -->
- <view class="pure-css-icon" :class="serviceIconClass"></view>
- </view>
- <view class="service-info-text">
- <text class="main-name">{{ currentServiceName }}</text>
- <text class="sub-desc">{{ serviceDesc }}</text>
- </view>
- </view>
- </view>
- <!-- 02 基础信息 -->
- <text class="section-title">02 基础信息</text>
- <view class="card basic-info-card">
- <view class="field-item" @click="showShopSelect = true">
- <text class="field-label require">服务门店</text>
- <text :class="['field-value', !formData.shopName ? 'placeholder' : '']">{{ formData.shopName ||
- '请选择商户门店' }}</text>
- <view class="right-arrow"></view>
- </view>
- <view class="field-item" @click="showUserSelect = true">
- <text class="field-label require">宠主用户</text>
- <view class="field-value-wrap">
- <template v-if="selectedUser">
- <text class="selected-name">{{ selectedUser.name }}</text>
- <text class="selected-phone">{{ selectedUser.phone || selectedUser.phoneNumber }}</text>
- </template>
- <text v-else class="placeholder">点击搜索</text>
- </view>
- <view class="right-arrow"></view>
- </view>
- <view class="field-item" @click="openPetPicker">
- <text class="field-label require">选择宠物</text>
- <text :class="['field-value', !formData.petName ? 'placeholder' : '']">{{ formData.petName ||
- '选择宠物档案' }}</text>
- <view class="right-arrow"></view>
- </view>
- </view>
- <!-- 03 业务表单 - 宠物接送 -->
- <template v-if="activeService === 'transport'">
- <text class="section-title">03 接送路线与时间</text>
- <view class="card transport-card">
- <view class="field-item">
- <text class="field-label">团购套餐</text>
- <input class="field-input" v-model="formData.packageName" placeholder="请输入套餐名称(选填)" />
- </view>
- <text class="form-item-label require">接送模式</text>
- <view class="mode-select">
- <view v-for="mode in transportModes" :key="mode.value"
- :class="['mode-btn', { active: formData.transportMode === mode.value }]"
- @click="formData.transportMode = mode.value">
- <text>{{ mode.label }}</text>
- </view>
- </view>
- <!-- 接宠路线 @Author: Antigravity -->
- <view class="route-box" v-if="formData.transportMode !== 'return_home'">
- <view class="route-icon pick">接</view>
- <view class="route-fields">
- <text class="addr-label require">起点 (用户家)</text>
- <view class="route-picker-trigger" @click="openRegionSelect('pick')">
- <text :class="['display-text', !formData.pickArea ? 'placeholder' : '']">{{
- pickAreaLabel || '选择省/市/区' }}</text>
- <view class="right-arrow"></view>
- </view>
- <input class="route-input" v-model="formData.pickAddress" placeholder="详细地址" />
- <text class="addr-label require">终点 (门店)</text>
- <view class="route-picker-trigger" @click="openRegionSelect('pickEnd')">
- <text :class="['display-text', !formData.pickEndArea ? 'placeholder' : '']">{{
- pickEndAreaLabel || '选择省/市/区' }}</text>
- <view class="right-arrow"></view>
- </view>
- <input class="route-input" v-model="formData.pickEndAddress" placeholder="详细地址" />
- <view class="contact-row">
- <input class="route-input half" v-model="formData.pickContact" placeholder="联系人" />
- <input class="route-input half" v-model="formData.pickPhone" placeholder="电话"
- type="tel" />
- </view>
- <view class="route-time-trigger" @click="openTimeModal('pick')">
- <text :class="!formData.pickStartTime ? 'placeholder' : ''">{{
- truncateTime(formData.pickStartTime) || '设置接宠时间' }}</text>
- </view>
- </view>
- </view>
- <!-- 送宠路线 @Author: Antigravity -->
- <view class="route-box" v-if="formData.transportMode !== 'pick_up'">
- <view class="route-icon send">送</view>
- <view class="route-fields">
- <text class="addr-label require">起点 (门店)</text>
- <view class="route-picker-trigger" @click="openRegionSelect('sendStart')">
- <text :class="['display-text', !formData.sendStartArea ? 'placeholder' : '']">{{
- sendStartAreaLabel || '选择省/市/区' }}</text>
- <view class="right-arrow"></view>
- </view>
- <input class="route-input" v-model="formData.sendStartAddress" placeholder="详细地址" />
- <text class="addr-label require">终点 (用户家)</text>
- <view class="route-picker-trigger" @click="openRegionSelect('send')">
- <text :class="['display-text', !formData.sendArea ? 'placeholder' : '']">{{
- sendAreaLabel || '选择省/市/区' }}</text>
- <view class="right-arrow"></view>
- </view>
- <input class="route-input" v-model="formData.sendAddress" placeholder="详细地址" />
- <view class="contact-row">
- <input class="route-input half" v-model="formData.sendContact" placeholder="联系人" />
- <input class="route-input half" v-model="formData.sendPhone" placeholder="电话"
- type="tel" />
- </view>
- <view class="route-time-trigger" @click="openTimeModal('send')">
- <text :class="!formData.sendStartTime ? 'placeholder' : ''">{{
- truncateTime(formData.sendStartTime) || '设置送宠时间' }}</text>
- </view>
- </view>
- </view>
- <!-- 接送备注 -->
- <text class="remarks-title">备注信息</text>
- <textarea class="remarks-textarea" v-model="formData.transportNote"
- placeholder="请添加接送备注 (如宠物性格、接送要求等)"></textarea>
- </view>
- </template>
- <!-- 03 业务表单 - 上门 -->
- <template v-else>
- <text class="section-title">03 服务细则</text>
- <view class="card feed-card">
- <view class="field-item">
- <text class="field-label">团购套餐</text>
- <input class="field-input" v-model="formData.packageName" placeholder="请输入套餐名称(选填)" />
- </view>
- <view class="route-box">
- <view class="route-icon service">服</view>
- <view class="route-fields">
- <text class="addr-label require">上门服务地址</text>
- <view class="route-picker-trigger" @click="openRegionSelect('service')">
- <text :class="['display-text', !formData.serviceArea ? 'placeholder' : '']">{{
- serviceAreaLabel || '请选择省/市/区' }}</text>
- <view class="right-arrow"></view>
- </view>
- <input class="route-input" v-model="formData.serviceAddress"
- placeholder="详细地址 (街道/路名/门牌号)" />
- </view>
- </view>
- <view class="booking-section">
- <view class="booking-header">
- <text class="label require">预约服务时间</text>
- <text class="count-tag">共 {{ formData.feedTimes.length }} 次</text>
- </view>
- <view class="time-item-row" v-for="(time, index) in formData.feedTimes" :key="index">
- <view class="flex-time-range" @click="openTimeModal('feed', index)">
- <text :class="['time-text', !time.start && !time.end ? 'placeholder' : '']">{{
- feedTimeDisplay(time) || '开始 ~ 结束' }}</text>
- </view>
- <view class="action-buttons">
- <view class="circle-btn add" v-if="index === formData.feedTimes.length - 1"
- @click="addFeedTime">+</view>
- <view class="circle-btn remove" v-if="formData.feedTimes.length > 1"
- @click="removeFeedTime(index)">-</view>
- </view>
- </view>
- </view>
- <text class="remarks-title">备注信息</text>
- <textarea class="remarks-textarea" v-model="formData.otherNote"
- placeholder="如有其他注意事项请备注"></textarea>
- </view>
- </template>
- <!-- 04 报价信息 -->
- <text class="section-title">04 报价信息</text>
- <view class="card quote-card">
- <view class="field-item">
- <text class="field-label require">报价金额</text>
- <input class="field-input quote-input" v-model="formData.quoteAmount" type="digit"
- placeholder="填入数字" />
- <text class="unit-text">元</text>
- </view>
- <text class="quote-tips">注:此价格将作为订单最终结算金额。</text>
- </view>
- </view>
- <!-- 底部操作栏 -->
- <view class="footer-bar safe-bottom">
- <view class="quotation-box">
- <text class="p-label">总计报价:</text>
- <text class="p-symbol">¥</text>
- <text class="p-amount">{{ totalFulfillmentCommission }}</text>
- </view>
- <button class="submit-btn" @click="onSubmit">立即下单</button>
- </view>
- <!-- 居中联动选择弹窗群 @Author: Antigravity -->
- <!-- 宠主搜索弹窗 -->
- <page-select v-model="showUserSelect" title="选择宠主用户" searchable :searchKey="userSearchKey"
- searchPlaceholder="搜索宠主姓名/手机号" :options="userList" labelKey="name" valueKey="id"
- :value="formData.customerId" :loading="userPage.loading" :finished="userPage.finished" emptyText="未找到相关宠主"
- @select="onUserSelect" @loadMore="fetchUsers(false)" @search="onUserSearch">
- <template #item="{ item }">
- <view class="user-info">
- <text class="name">{{ item.name }}</text>
- <text class="phone">{{ item.phone || item.phoneNumber }}</text>
- </view>
- </template>
- </page-select>
- <!-- 区域选择器 (Cascader) @Author: Antigravity -->
- <view class="center-modal-mask" v-if="showRegionModal" @click="showRegionModal = false">
- <view class="center-modal-content region-modal" @click.stop>
- <view class="modal-header"><text class="modal-title">选择区域</text>
- <view class="close-btn" @click="showRegionModal = false"></view>
- </view>
- <view class="cascade-indicator">
- <text v-for="(node, idx) in regionPath" :key="idx" class="path-node" @click="backToLevel(idx)">{{
- node.name
- }}</text>
- <text class="path-node active" v-if="regionPath.length < 3">请选择</text>
- </view>
- <scroll-view scroll-y class="modal-list-scroll">
- <view class="list-item" v-for="item in currentRegionList" :key="item.code"
- @click="onRegionStepSelect(item)">
- <text class="item-text">{{ item.name }}</text>
- <view class="checkmark" v-if="isRegionSelected(item)"></view>
- </view>
- </scroll-view>
- </view>
- </view>
- <!-- 门店选择 -->
- <page-select v-model="showShopSelect" title="选择服务门店" searchable :searchKey="shopSearchKey"
- searchPlaceholder="搜索门店名称" :options="shopList" labelKey="name" valueKey="id" :value="formData.merchantId"
- :loading="shopPage.loading" :finished="shopPage.finished" @select="onShopSelect"
- @loadMore="fetchShops(true)" @search="onShopSearch" />
- <!-- 宠物选择 -->
- <center-select v-model="showPetPopup" title="选择指定宠物" :options="petOptions" labelKey="_label" valueKey="id"
- :value="formData.petId" @select="onPetSelect" />
- <!-- 日期时间选择弹窗 @Author: Antigravity -->
- <view class="center-modal-mask" v-if="showTimeModal" @click="showTimeModal = false">
- <view class="center-modal-content time-modal" @click.stop>
- <view class="modal-header"><text class="modal-title">选择预约时间</text></view>
- <view class="datetime-picker-body">
- <template v-if="isDualTimePicker">
- <view class="time-slot-row">
- <view :class="['time-slot', { active: activeSlot === 'start' }]"
- @click="activeSlot = 'start'">
- <text class="slot-label">开始时间</text>
- <text :class="['slot-value', !tempStartDisplay ? 'placeholder' : '']">{{
- tempStartDisplay || '请选择' }}</text>
- </view>
- <view :class="['time-slot', { active: activeSlot === 'end' }]" @click="activeSlot = 'end'">
- <text class="slot-label">结束时间</text>
- <text :class="['slot-value', !tempEndDisplay ? 'placeholder' : '']">{{ tempEndDisplay ||
- '请选择' }}</text>
- </view>
- </view>
- <picker-view class="picker-view" :value="tempTimeIdx" @change="onTempTimeChange">
- <picker-view-column>
- <view class="picker-item" v-for="d in timeRanges[0]" :key="d">{{ d }}</view>
- </picker-view-column>
- <picker-view-column>
- <view class="picker-item" v-for="h in timeRanges[1]" :key="h">{{ h }}时</view>
- </picker-view-column>
- <picker-view-column>
- <view class="picker-item" v-for="m in timeRanges[2]" :key="m">{{ m }}分</view>
- </picker-view-column>
- </picker-view>
- </template>
- <template v-else>
- <picker-view class="picker-view" :value="tempSingleIdx"
- @change="(e) => tempSingleIdx = e.detail.value">
- <picker-view-column>
- <view class="picker-item" v-for="d in timeRanges[0]" :key="d">{{ d }}</view>
- </picker-view-column>
- <picker-view-column>
- <view class="picker-item" v-for="h in timeRanges[1]" :key="h">{{ h }}时</view>
- </picker-view-column>
- <picker-view-column>
- <view class="picker-item" v-for="m in timeRanges[2]" :key="m">{{ m }}分</view>
- </picker-view-column>
- </picker-view>
- </template>
- </view>
- <view class="modal-footer">
- <view class="modal-cancel" @click="showTimeModal = false">取消</view>
- <view class="modal-confirm" @click="confirmTime">确认</view>
- </view>
- </view>
- </view>
- </view>
- </template>
- <script setup>
- /**
- * @Author: Antigravity
- */
- import { ref, reactive, computed, watch } from 'vue'
- import { onLoad } from '@dcloudio/uni-app'
- import navBar from '@/components/nav-bar/index.vue'
- import pageSelect from '@/components/page-select/index.vue'
- import centerSelect from '@/components/center-select/index.vue'
- import { listStoreOnOrder } from '@/api/system/store'
- import { listCustomerOnOrder } from '@/api/archieves/customer'
- import { listPetByUser } from '@/api/archieves/pet'
- import { createOrder } from '@/api/order/order'
- import { listRegionTree } from '@/api/system/region'
- const activeService = ref('transport')
- const serviceInfo = ref(null)
- const shopList = ref([])
- const userList = ref([])
- const petList = ref([])
- const regionTree = ref([])
- // 弹窗控制
- const showShopSelect = ref(false)
- const showUserSelect = ref(false)
- const showPetPopup = ref(false)
- const showRegionModal = ref(false)
- const showTimeModal = ref(false)
- const userSearchKey = ref('')
- const shopSearchKey = ref('')
- const selectedUser = ref(null)
- const selectedShop = ref(null)
- // 门店分页状态
- const shopPage = reactive({ pageNum: 1, pageSize: 20, loading: false, finished: false })
- // 宠主分页状态
- const userPage = reactive({ pageNum: 1, pageSize: 20, loading: false, finished: false })
- const pickAreaLabel = ref('')
- const pickEndAreaLabel = ref('')
- const sendStartAreaLabel = ref('')
- const sendAreaLabel = ref('')
- const serviceAreaLabel = ref('')
- const formData = reactive({
- merchantId: '', shopName: '', customerId: '', customerName: '', petId: '', petName: '',
- packageName: '', transportMode: 'round_trip',
- pickArea: '', pickAddress: '', pickEndArea: '', pickEndAddress: '', pickContact: '', pickPhone: '',
- pickStartTime: '', pickEndTime: '',
- sendStartArea: '', sendStartAddress: '', sendArea: '', sendAddress: '', sendContact: '', sendPhone: '',
- sendStartTime: '', sendEndTime: '',
- serviceArea: '', serviceAddress: '', feedTimes: [{ start: '', end: '' }],
- otherNote: '', transportNote: '', quoteAmount: ''
- })
- // 时间选择器逻辑 (5分钟一个间隔 @Author: Antigravity)
- const timeRanges = ref([[], [], []])
- const activeSlot = ref('start')
- const tempStartIdx = ref([0, 0, 0])
- const tempEndIdx = ref([0, 0, 0])
- const tempTimeIdx = ref([0, 0, 0])
- const tempSingleIdx = ref([0, 0, 0])
- const timeCtx = reactive({ type: '', index: 0 })
- const isDualTimePicker = computed(() => timeCtx.type === 'feed')
- onLoad((options) => {
- if (options.service) activeService.value = options.service
- const stored = uni.getStorageSync('currentService')
- if (stored) serviceInfo.value = stored
- initTimeRanges()
- fetchShops(); fetchUsers(true); fetchRegionTree()
- })
- const initTimeRanges = () => {
- const dates = []
- const now = new Date()
- for (let i = 0; i < 30; i++) {
- const d = new Date(now); d.setDate(d.getDate() + i)
- dates.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
- }
- timeRanges.value = [
- dates,
- Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')),
- // 五分钟间隔生成 @Author: Antigravity
- Array.from({ length: 12 }, (_, i) => String(i * 5).padStart(2, '0'))
- ]
- }
- const openTimeModal = (type, index = 0) => {
- timeCtx.type = type; timeCtx.index = index
- activeSlot.value = 'start'
- const readTime = (t) => {
- if (!t) return null
- const match = t.match(/(\d{2})-(\d{2}) (\d{2}):(\d{2})/)
- if (!match) return null
- const monthDay = `${match[1]}-${match[2]}`
- const di = timeRanges.value[0].findIndex(d => d === monthDay)
- const hi = parseInt(match[3])
- const mi = timeRanges.value[2].findIndex(m => parseInt(m) === parseInt(match[4]))
- return [di < 0 ? 0 : di, hi < 0 ? 0 : hi, mi < 0 ? 0 : mi]
- }
- const nowIdx = () => {
- const now = new Date()
- const monthDay = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
- const di = timeRanges.value[0].findIndex(d => d === monthDay)
- const hi = now.getHours()
- const mi = timeRanges.value[2].findIndex(m => parseInt(m) >= now.getMinutes())
- return [di < 0 ? 0 : di, hi < 0 ? 0 : hi, mi < 0 ? 0 : mi]
- }
- if (type === 'feed') {
- tempStartIdx.value = readTime(formData.feedTimes[index].start) || nowIdx()
- tempEndIdx.value = readTime(formData.feedTimes[index].end) || nowIdx()
- tempTimeIdx.value = [...tempStartIdx.value]
- } else if (type === 'pick') {
- tempSingleIdx.value = readTime(formData.pickStartTime) || nowIdx()
- } else if (type === 'send') {
- tempSingleIdx.value = readTime(formData.sendStartTime) || nowIdx()
- }
- showTimeModal.value = true
- }
- const onTempTimeChange = (e) => {
- tempTimeIdx.value = e.detail.value
- if (activeSlot.value === 'start') {
- tempStartIdx.value = [...e.detail.value]
- } else {
- tempEndIdx.value = [...e.detail.value]
- }
- }
- const buildTimeLabel = (idx) => {
- const d = timeRanges.value[0][idx[0]]
- const h = timeRanges.value[1][idx[1]]
- const m = timeRanges.value[2][idx[2]]
- return d && h !== undefined && m !== undefined ? `${d} ${h}:${m}` : ''
- }
- const tempStartDisplay = computed(() => buildTimeLabel(tempStartIdx.value))
- const tempEndDisplay = computed(() => buildTimeLabel(tempEndIdx.value))
- watch(activeSlot, (val) => {
- tempTimeIdx.value = val === 'start' ? [...tempStartIdx.value] : [...tempEndIdx.value]
- })
- const confirmTime = () => {
- const buildTime = (idx) => {
- const [di, hi, mi] = idx
- return `${new Date().getFullYear()}-${timeRanges.value[0][di]} ${timeRanges.value[1][hi]}:${timeRanges.value[2][mi]}:00`
- }
- if (timeCtx.type === 'feed') {
- const startVal = buildTime(tempStartIdx.value)
- const endVal = buildTime(tempEndIdx.value)
- formData.feedTimes[timeCtx.index].start = startVal
- formData.feedTimes[timeCtx.index].end = endVal
- } else if (timeCtx.type === 'pick') {
- const val = buildTime(tempSingleIdx.value)
- formData.pickStartTime = val
- formData.pickEndTime = val
- } else if (timeCtx.type === 'send') {
- const val = buildTime(tempSingleIdx.value)
- formData.sendStartTime = val
- formData.sendEndTime = val
- }
- showTimeModal.value = false
- }
- const truncateTime = (t) => t ? t.substring(5, 16) : ''
- const feedTimeDisplay = (time) => {
- const s = truncateTime(time.start)
- const e = truncateTime(time.end)
- return s && e ? `${s} ~ ${e}` : (s || e || '')
- }
- const pickTimeDisplay = computed(() => {
- const s = truncateTime(formData.pickStartTime)
- const e = truncateTime(formData.pickEndTime)
- return s && e ? `${s} ~ ${e}` : (s || e || '')
- })
- const sendTimeDisplay = computed(() => {
- const s = truncateTime(formData.sendStartTime)
- const e = truncateTime(formData.sendEndTime)
- return s && e ? `${s} ~ ${e}` : (s || e || '')
- })
- // 区域选择逻辑
- const regionPath = ref([])
- const activeRegionType = ref('')
- const currentRegionList = computed(() => {
- let list = regionTree.value
- for (let node of regionPath.value) {
- const found = list.find(l => l.code === node.code)
- if (found && found.children) list = found.children
- else list = []
- }
- return list
- })
- const openRegionSelect = (type) => {
- activeRegionType.value = type
- regionPath.value = []
- showRegionModal.value = true
- }
- const backToLevel = (idx) => { regionPath.value = regionPath.value.slice(0, idx) }
- const onRegionStepSelect = (item) => {
- regionPath.value.push({ code: item.code, name: item.name })
- if (!item.children || item.children.length === 0 || regionPath.value.length >= 3) {
- const fullLabel = regionPath.value.map(p => p.name).join(' / ')
- const finalCode = item.code
- if (activeRegionType.value === 'pick') { formData.pickArea = finalCode; pickAreaLabel.value = fullLabel }
- else if (activeRegionType.value === 'pickEnd') { formData.pickEndArea = finalCode; pickEndAreaLabel.value = fullLabel }
- else if (activeRegionType.value === 'sendStart') { formData.sendStartArea = finalCode; sendStartAreaLabel.value = fullLabel }
- else if (activeRegionType.value === 'send') { formData.sendArea = finalCode; sendAreaLabel.value = fullLabel }
- else if (activeRegionType.value === 'service') { formData.serviceArea = finalCode; serviceAreaLabel.value = fullLabel }
- showRegionModal.value = false
- }
- }
- const isRegionSelected = (item) => {
- const level = regionPath.value.length
- return regionPath.value[level]?.code === item.code
- }
- // 核心回填逻辑修正 @Author: Antigravity
- watch([selectedShop, selectedUser, regionTree], ([shop, user, tree]) => {
- if (!shop && !user) return
- // 处理门店信息
- const storeAreaCode = (shop?.areaCode || '').replace(/,/g, '/')
- const storeLeaf = storeAreaCode.split('/').pop() || ''
- const storePath = findRegionLabel(storeAreaCode, tree)
- // 处理用户信息
- const userAreaCode = (user?.regionCode || '')
- const userLeaf = userAreaCode.split('/').pop() || ''
- const userPath = findRegionLabel(userAreaCode, tree)
- if (shop) {
- formData.merchantId = shop.id; formData.shopName = shop.name
- // 接送单终点 = 门店
- formData.pickEndArea = storeLeaf; formData.pickEndAddress = shop.address || ''
- pickEndAreaLabel.value = storePath
- // 接送单起点 = 门店
- formData.sendStartArea = storeLeaf; formData.sendStartAddress = shop.address || ''
- sendStartAreaLabel.value = storePath
- }
- if (user) {
- formData.customerId = user.id; formData.customerName = user.name
- // 接宠单起点 = 宠主家
- formData.pickArea = userLeaf; formData.pickAddress = user.address || ''
- pickAreaLabel.value = userPath
- // 送宠单终点 = 宠主家
- formData.sendArea = userLeaf; formData.sendAddress = user.address || ''
- sendAreaLabel.value = userPath
- // 服务单地址 = 宠主家
- formData.serviceArea = userLeaf; formData.serviceAddress = user.address || ''
- serviceAreaLabel.value = userPath
- formData.pickContact = user.name; formData.pickPhone = user.phoneNumber || user.phone || ''
- formData.sendContact = user.name; formData.sendPhone = formData.pickPhone
- }
- }, { deep: true })
- const findRegionLabel = (code, list) => {
- if (!code || !list || list.length === 0) return ''
- const target = code.split('/').pop()
- const find = (nodes, t) => {
- for (let n of nodes) {
- if (n.code === t) return n.name
- if (n.children) {
- const res = find(n.children, t)
- if (res) return n.name + ' / ' + res
- }
- }
- return null
- }
- return find(list, target) || ''
- }
- const fetchShops = (loadMore = false) => {
- if (shopPage.loading) return
- if (loadMore && shopPage.finished) return
- if (!loadMore) {
- shopPage.pageNum = 1
- shopPage.finished = false
- }
- shopPage.loading = true
- listStoreOnOrder({ pageNum: shopPage.pageNum, pageSize: shopPage.pageSize, serviceId: serviceInfo.value?.id, name: shopSearchKey.value }).then(res => {
- const rows = res.rows || []
- if (loadMore) {
- shopList.value = [...shopList.value, ...rows]
- } else {
- shopList.value = rows
- }
- shopPage.finished = rows.length < shopPage.pageSize
- shopPage.pageNum++
- }).catch(e => {
- uni.showToast({ title: typeof e === 'string' ? e : '加载门店失败', icon: 'none' })
- }).finally(() => { shopPage.loading = false })
- }
- const fetchUsers = (reset = false) => {
- if (userPage.loading) return
- if (!reset && userPage.finished) return
- if (reset) {
- userPage.pageNum = 1
- userPage.finished = false
- }
- userPage.loading = true
- listCustomerOnOrder({ pageNum: userPage.pageNum, pageSize: userPage.pageSize, content: userSearchKey.value }).then(res => {
- const rows = res.rows || []
- if (reset) {
- userList.value = rows
- } else {
- userList.value = [...userList.value, ...rows]
- }
- userPage.finished = rows.length < userPage.pageSize
- userPage.pageNum++
- }).catch(e => {
- uni.showToast({ title: typeof e === 'string' ? e : '加载宠主失败', icon: 'none' })
- }).finally(() => { userPage.loading = false })
- }
- const fetchPets = (uid) => listPetByUser(uid).then(res => { petList.value = Array.isArray(res) ? res : (res.rows || []) }).catch(e => { uni.showToast({ title: typeof e === 'string' ? e : '加载宠物列表失败', icon: 'none' }) })
- const fetchRegionTree = () => listRegionTree().then(res => { regionTree.value = res || [] }).catch(e => { uni.showToast({ title: typeof e === 'string' ? e : '加载区域数据失败', icon: 'none' }) })
- const onShopSelect = (shop) => { selectedShop.value = shop; showShopSelect.value = false }
- const onShopSearch = (keyword) => { shopSearchKey.value = keyword; fetchShops(false) }
- const onUserSearch = (keyword) => { userSearchKey.value = keyword; fetchUsers(true) }
- const onUserSelect = (user) => {
- selectedUser.value = user; formData.customerId = user.id;
- formData.petId = ''; formData.petName = ''; petList.value = []; fetchPets(user.id)
- showUserSelect.value = false
- }
- const openPetPicker = () => { if (!formData.customerId) return uni.showToast({ title: '先选择宠主', icon: 'none' }); showPetPopup.value = true }
- const petOptions = computed(() => petList.value.map(p => ({ ...p, _label: `${p.name} (${p.breed || '未知'})` })))
- const onPetSelect = (pet) => { formData.petId = pet.id; formData.petName = pet.name; showPetPopup.value = false }
- const currentServiceName = computed(() => serviceInfo.value?.name || (activeService.value === 'transport' ? '宠物接送' : '上门喂遛'))
- const serviceIconClass = computed(() => activeService.value)
- const serviceDesc = computed(() => serviceInfo.value?.remark || '专人专项 · 贴心呵护')
- const transportModes = [{ label: '往返', value: 'round_trip' }, { label: '单程接', value: 'pick_up' }, { label: '单程送', value: 'return_home' }]
- const addFeedTime = () => formData.feedTimes.push({ start: '', end: '' })
- const removeFeedTime = (idx) => formData.feedTimes.splice(idx, 1)
- const totalFulfillmentCommission = computed(() => formData.quoteAmount ? parseFloat(formData.quoteAmount).toFixed(2) : '0.00')
- const onSubmit = async () => {
- if (!formData.merchantId || !formData.customerId || !formData.petId || !formData.quoteAmount) return uni.showToast({ title: '请完善红星必填项', icon: 'none' })
- uni.showLoading({ title: '提交中...', mask: true })
- try {
- const subOrders = []
- const baseMode = serviceInfo.value?.mode || 0
- const defC = selectedUser.value?.name; const defP = selectedUser.value?.phone || selectedUser.value?.phoneNumber
- if (activeService.value === 'transport') {
- 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.pickStartTime, endServiceTime: formData.pickEndTime, fromCode: formData.pickArea, fromAddress: formData.pickAddress, toCode: formData.pickEndArea, toAddress: formData.pickEndAddress })
- 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.sendStartTime, endServiceTime: formData.sendEndTime, fromCode: formData.sendStartArea, fromAddress: formData.sendStartAddress, toCode: formData.sendArea, toAddress: formData.sendAddress })
- } else {
- 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 }))
- }
- // 根据服务类型选择对应备注字段(与Web端逻辑对齐:transport用transportNote,其他用otherNote)
- const orderRemark = activeService.value === 'transport' ? formData.transportNote : formData.otherNote
- 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 }
- await createOrder(payload)
- uni.showToast({ title: '成功', icon: 'success' })
- setTimeout(() => uni.reLaunch({ url: '/pages/order/list/index' }), 1000)
- } catch (e) {
- uni.showToast({ title: typeof e === 'string' ? e : '下单失败', icon: 'none' })
- } finally { uni.hideLoading() }
- }
- </script>
- <style lang="scss" scoped>
- /* 统一页面字体栈 @Author: Antigravity */
- .order-apply-page {
- background: #f7f8fa;
- min-height: 100vh;
- padding-bottom: 220rpx;
- font-family: 'PingFang SC', 'Helvetica Neue', Helvetica, 'STHeitiSTXihei', 'Microsoft YaHei', Arial, sans-serif;
- }
- .apply-content {
- padding: 0 28rpx;
- }
- .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;
- }
- }
- .card {
- background: #fff;
- border-radius: 24rpx;
- padding: 24rpx;
- margin-bottom: 24rpx;
- }
- .service-type-display {
- display: flex;
- align-items: center;
- gap: 24rpx;
- }
- .service-icon-box {
- width: 88rpx;
- height: 88rpx;
- border-radius: 20rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .service-icon-box.transport {
- background: linear-gradient(135deg, #64b5f6, #2196f3);
- }
- .service-icon-box.feed {
- background: linear-gradient(135deg, #ffb74d, #ff9800);
- }
- .service-icon-box.wash {
- background: linear-gradient(135deg, #81c784, #4caf50);
- }
- .main-name {
- display: block;
- font-size: 32rpx;
- font-weight: bold;
- color: #333;
- }
- .sub-desc {
- display: block;
- font-size: 24rpx;
- color: #999;
- margin-top: 4rpx;
- }
- /* CSS 手绘图标 @Author: Antigravity */
- .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%;
- }
- }
- .field-item {
- display: flex;
- align-items: center;
- padding: 28rpx 0;
- border-bottom: 2rpx solid #f5f5f5;
- height: 44rpx;
- &:last-child {
- border-bottom: none;
- }
- }
- .field-label {
- width: 180rpx;
- font-size: 28rpx;
- color: #333;
- flex-shrink: 0;
- }
- .require::before {
- content: '*';
- color: #f56c6c;
- margin-right: 4rpx;
- }
- .field-value {
- flex: 1;
- font-size: 28rpx;
- color: #333;
- text-align: right;
- margin-right: 16rpx;
- }
- .field-value.placeholder {
- color: #ccc;
- }
- .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;
- }
- }
- .placeholder {
- color: #ccc;
- font-size: 28rpx;
- }
- .mode-select {
- display: flex;
- gap: 16rpx;
- margin: 20rpx 0 32rpx;
- }
- .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;
- }
- }
- .route-box {
- display: flex;
- gap: 20rpx;
- margin-bottom: 30rpx;
- }
- .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;
- }
- .route-icon.pick {
- background: #5bb7ff;
- }
- .route-icon.send {
- background: #64cf5c;
- }
- .route-icon.service {
- background: #ff9500;
- }
- .route-fields {
- flex: 1;
- display: flex;
- flex-direction: column;
- gap: 6rpx;
- }
- .addr-label {
- font-size: 22rpx;
- color: #999;
- margin-top: 10rpx;
- }
- .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;
- }
- }
- }
- .route-input {
- height: 72rpx;
- font-size: 26rpx;
- border-bottom: 2rpx solid #f5f5f5;
- &.half {
- flex: 1;
- }
- }
- .contact-row {
- display: flex;
- gap: 16rpx;
- }
- .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;
- }
- }
- .address-title,
- .form-item-label,
- .remarks-title {
- display: block;
- font-size: 26rpx;
- color: #666;
- margin: 20rpx 0 10rpx;
- }
- .booking-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin: 20rpx 0 10rpx;
- }
- .booking-header .label {
- display: block;
- font-size: 26rpx;
- color: #666;
- }
- .booking-section {
- margin-top: 24rpx;
- }
- .count-tag {
- font-size: 20rpx;
- color: #f44336;
- padding: 4rpx 0;
- }
- .time-item-row {
- display: flex;
- align-items: center;
- gap: 12rpx;
- margin-bottom: 16rpx;
- }
- .flex-time-range {
- 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;
- }
- }
- }
- .action-buttons {
- display: flex;
- gap: 12rpx;
- margin-left: 8rpx;
- }
- .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;
- }
- }
- .remarks-textarea {
- width: 100%;
- height: 140rpx;
- font-size: 26rpx;
- background: #f9f9f9;
- border-radius: 16rpx;
- padding: 16rpx;
- box-sizing: border-box;
- }
- .quote-input {
- flex: 1;
- font-size: 36rpx;
- color: #f44336;
- font-weight: bold;
- text-align: right;
- }
- .unit-text {
- font-size: 28rpx;
- color: #333;
- margin-left: 8rpx;
- }
- .quote-tips {
- display: block;
- font-size: 22rpx;
- color: #999;
- margin-top: 20rpx;
- }
- .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;
- }
- .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;
- }
- }
- .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;
- }
- }
- /* CSS Common Modal UI @Author: Antigravity */
- .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);
- }
- .center-modal-content {
- width: 620rpx;
- background: #fff;
- border-radius: 32rpx;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- animation: popIn 0.3s ease-out;
- }
- @keyframes popIn {
- from {
- transform: scale(0.9);
- opacity: 0;
- }
- to {
- transform: scale(1);
- opacity: 1;
- }
- }
- .modal-header {
- padding: 32rpx;
- border-bottom: 2rpx solid #f2f2f2;
- position: relative;
- text-align: center;
- .modal-title {
- font-size: 30rpx;
- font-weight: bold;
- color: #333;
- }
- }
- .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);
- }
- }
- .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;
- }
- }
- .modal-list-scroll {
- flex: 1;
- max-height: 55vh;
- padding: 0 32rpx;
- }
- .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;
- }
- }
- }
- .checkmark {
- width: 12rpx;
- height: 22rpx;
- border-right: 4rpx solid #ff9500;
- border-bottom: 4rpx solid #ff9500;
- transform: rotate(45deg);
- }
- .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;
- }
- }
- }
- .datetime-picker-body {
- padding: 20rpx 0;
- }
- .time-slot-row {
- display: flex;
- padding: 0 20rpx;
- margin-bottom: 20rpx;
- gap: 16rpx;
- }
- .time-slot {
- flex: 1;
- border: 2rpx solid #eee;
- border-radius: 12rpx;
- padding: 16rpx;
- text-align: center;
- &.active {
- border-color: #ff9500;
- background: #fff8f0;
- }
- }
- .slot-label {
- display: block;
- font-size: 22rpx;
- color: #999;
- margin-bottom: 6rpx;
- }
- .slot-value {
- display: block;
- font-size: 26rpx;
- color: #333;
- font-weight: 500;
- &.placeholder {
- color: #ccc;
- }
- }
- .picker-view {
- width: 100%;
- height: 360rpx;
- }
- .picker-item {
- line-height: 80rpx;
- text-align: center;
- font-size: 28rpx;
- }
- .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;
- }
- }
- .right-arrow {
- width: 12rpx;
- height: 12rpx;
- border-right: 3rpx solid #ccc;
- border-top: 3rpx solid #ccc;
- transform: rotate(45deg);
- flex-shrink: 0;
- }
- .empty-tip {
- padding: 80rpx 0;
- text-align: center;
- color: #ccc;
- font-size: 24rpx;
- }
- .user-info {
- display: flex;
- flex-direction: column;
- flex: 1;
- .name {
- font-size: 28rpx;
- font-weight: bold;
- color: #333;
- }
- .phone {
- font-size: 22rpx;
- color: #999;
- margin-top: 4rpx;
- }
- }
- </style>
|