feat: 宠物页面及地图组件更新
This commit is contained in:
parent
eda1932d03
commit
dabe0a5fe0
@ -52,6 +52,13 @@ export const registerBoss = (data) => post('/user/register-boss', data)
|
|||||||
// 注册员工
|
// 注册员工
|
||||||
export const registerStaff = (data) => post('/user/register-staff', data)
|
export const registerStaff = (data) => post('/user/register-staff', data)
|
||||||
|
|
||||||
|
// 宠物档案(客户:ownerUserId;门店:storeId)
|
||||||
|
export const getPetList = (params) => get('/pet/list', params)
|
||||||
|
export const createPet = (data) => post('/pet/create', data)
|
||||||
|
export const updatePet = (data) => put('/pet/update', data)
|
||||||
|
export const deletePet = (id, operatorUserId, role) =>
|
||||||
|
del(`/pet/delete?id=${id}&operatorUserId=${operatorUserId}&role=${encodeURIComponent(role)}`)
|
||||||
|
|
||||||
// 预约列表
|
// 预约列表
|
||||||
export const getAppointmentList = (userId, storeId) => get('/appointment/list', { userId, storeId })
|
export const getAppointmentList = (userId, storeId) => get('/appointment/list', { userId, storeId })
|
||||||
|
|
||||||
@ -77,8 +84,12 @@ export const getReportByToken = (token) => get('/report/get', { token })
|
|||||||
// 报告列表
|
// 报告列表
|
||||||
export const getReportList = (params) => get('/report/list', params)
|
export const getReportList = (params) => get('/report/list', params)
|
||||||
|
|
||||||
// 服务类型列表
|
// 服务类型列表(不传 storeId 时仅返回系统默认,供未写入门店会话的 C 端展示)
|
||||||
export const getServiceTypeList = (storeId) => get('/service-type/list', { storeId })
|
export const getServiceTypeList = (storeId) => {
|
||||||
|
const params = {}
|
||||||
|
if (storeId != null && storeId !== '') params.storeId = storeId
|
||||||
|
return get('/service-type/list', params)
|
||||||
|
}
|
||||||
|
|
||||||
// 创建服务类型
|
// 创建服务类型
|
||||||
export const createServiceType = (storeId, name) => post('/service-type/create', { storeId, name })
|
export const createServiceType = (storeId, name) => post('/service-type/create', { storeId, name })
|
||||||
@ -98,6 +109,9 @@ export const deleteStaff = (staffId) => del(`/user/staff?staffId=${staffId}`)
|
|||||||
// 更新用户信息
|
// 更新用户信息
|
||||||
export const updateUser = (data) => put('/user/update', data)
|
export const updateUser = (data) => put('/user/update', data)
|
||||||
|
|
||||||
|
// 门店列表(客户预约选店)
|
||||||
|
export const getStoreList = () => get('/store/list', {})
|
||||||
|
|
||||||
// 更新店铺
|
// 更新店铺
|
||||||
export const updateStore = (data) => put('/store/update', data)
|
export const updateStore = (data) => put('/store/update', data)
|
||||||
|
|
||||||
|
|||||||
@ -11,14 +11,15 @@
|
|||||||
"lazyCodeLoading": "requiredComponents",
|
"lazyCodeLoading": "requiredComponents",
|
||||||
"permission": {
|
"permission": {
|
||||||
"scope.userLocation": {
|
"scope.userLocation": {
|
||||||
"desc": "用于在地图上选择门店位置"
|
"desc": "用于推荐距离您最近的门店,也可在预约页手动切换"
|
||||||
},
|
},
|
||||||
"scope.writePhotosAlbum": {
|
"scope.writePhotosAlbum": {
|
||||||
"desc": "用于将服务报告海报保存到手机相册"
|
"desc": "用于将服务报告海报保存到手机相册"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"requiredPrivateInfos": [
|
"requiredPrivateInfos": [
|
||||||
"chooseLocation"
|
"chooseLocation",
|
||||||
|
"getLocation"
|
||||||
],
|
],
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": false,
|
"urlCheck": false,
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
{"path": "pages/mine/Store"},
|
{"path": "pages/mine/Store"},
|
||||||
{"path": "pages/mine/MyReports"},
|
{"path": "pages/mine/MyReports"},
|
||||||
{"path": "pages/mine/MyOrders"},
|
{"path": "pages/mine/MyOrders"},
|
||||||
|
{"path": "pages/mine/MyPets"},
|
||||||
{"path": "pages/mine/Profile"},
|
{"path": "pages/mine/Profile"},
|
||||||
{"path": "pages/report-view/reportView"}
|
{"path": "pages/report-view/reportView"}
|
||||||
],
|
],
|
||||||
|
|||||||
@ -12,6 +12,32 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="c-form-card">
|
<view class="c-form-card">
|
||||||
|
<view class="c-field">
|
||||||
|
<text class="c-label">预约门店</text>
|
||||||
|
<picker
|
||||||
|
mode="selector"
|
||||||
|
:range="orderedStores"
|
||||||
|
range-key="pickerLabel"
|
||||||
|
:value="selectedStoreIndex"
|
||||||
|
:disabled="!orderedStores.length"
|
||||||
|
@change="onStorePickerChange"
|
||||||
|
>
|
||||||
|
<view class="c-input c-picker">
|
||||||
|
<text :class="{ 'c-placeholder': !orderedStores.length }">{{ storePickerDisplay }}</text>
|
||||||
|
<text class="c-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
<text v-if="storeHintLine" class="c-store-hint">{{ storeHintLine }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="myPets.length" class="c-field">
|
||||||
|
<text class="c-label">使用档案(可选)</text>
|
||||||
|
<picker mode="selector" :range="petPickerOptions" range-key="label" @change="onPickMyPet">
|
||||||
|
<view class="c-input c-picker">
|
||||||
|
<text :class="{ 'c-placeholder': selectedPetIndex === 0 }">{{ petPickerLabel }}</text>
|
||||||
|
<text class="c-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
<view class="c-field">
|
<view class="c-field">
|
||||||
<text class="c-label">宠物名字</text>
|
<text class="c-label">宠物名字</text>
|
||||||
<input v-model="newAppt.petName" class="c-input" placeholder="请输入宠物名字" />
|
<input v-model="newAppt.petName" class="c-input" placeholder="请输入宠物名字" />
|
||||||
@ -60,13 +86,20 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { createAppointment, getServiceTypeList } from '../../api/index.js'
|
import { createAppointment, getServiceTypeList, getPetList, getStoreList } from '../../api/index.js'
|
||||||
import AppIcon from '../../components/AppIcon.vue'
|
import AppIcon from '../../components/AppIcon.vue'
|
||||||
import { getUserSession, getStoreSession } from '../../utils/session.js'
|
import { getUserSession, getStoreSession, setStoreSession } from '../../utils/session.js'
|
||||||
|
import { haversineKm } from '../../utils/geo.js'
|
||||||
|
|
||||||
const userInfo = getUserSession()
|
const userInfo = getUserSession()
|
||||||
const storeInfo = getStoreSession()
|
const storeInfo = getStoreSession()
|
||||||
|
|
||||||
|
const BOOKING_STORE_ID_KEY = 'petstore_booking_store_id'
|
||||||
|
|
||||||
|
const orderedStores = ref([])
|
||||||
|
const selectedStoreIndex = ref(0)
|
||||||
|
const hasUserLocation = ref(false)
|
||||||
|
|
||||||
const navSafeStyle = (() => {
|
const navSafeStyle = (() => {
|
||||||
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
||||||
let navHeight = statusBarHeight + 44
|
let navHeight = statusBarHeight + 44
|
||||||
@ -85,9 +118,120 @@ const petTypes = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const serviceTypes = ref([])
|
const serviceTypes = ref([])
|
||||||
|
|
||||||
|
const storePickerDisplay = computed(() => {
|
||||||
|
const row = orderedStores.value[selectedStoreIndex.value]
|
||||||
|
if (!row) return orderedStores.value.length ? '请选择' : '暂无可选门店'
|
||||||
|
return row.pickerLabel
|
||||||
|
})
|
||||||
|
|
||||||
|
const storeHintLine = computed(() => {
|
||||||
|
if (!orderedStores.value.length) return ''
|
||||||
|
if (hasUserLocation.value) return '已根据定位优先展示最近门店,可点击切换'
|
||||||
|
return '未获取到定位时按列表排序,可手动选择门店'
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildOrderedStores = (raw, lat, lng) => {
|
||||||
|
const withDist = raw.map((s) => {
|
||||||
|
const d =
|
||||||
|
lat != null && lng != null ? haversineKm(lat, lng, s.latitude, s.longitude) : null
|
||||||
|
return { ...s, distKm: d }
|
||||||
|
})
|
||||||
|
withDist.sort((a, b) => {
|
||||||
|
if (a.distKm == null && b.distKm == null) return (a.name || '').localeCompare(b.name || '', 'zh')
|
||||||
|
if (a.distKm == null) return 1
|
||||||
|
if (b.distKm == null) return -1
|
||||||
|
return a.distKm - b.distKm
|
||||||
|
})
|
||||||
|
return withDist.map((s) => ({
|
||||||
|
...s,
|
||||||
|
pickerLabel:
|
||||||
|
s.distKm != null
|
||||||
|
? `${s.name} · 约${s.distKm < 10 ? s.distKm.toFixed(1) : Math.round(s.distKm)}km`
|
||||||
|
: s.name
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const applySelectedStoreToSession = () => {
|
||||||
|
const s = orderedStores.value[selectedStoreIndex.value]
|
||||||
|
if (!s?.id) return
|
||||||
|
uni.setStorageSync(BOOKING_STORE_ID_KEY, s.id)
|
||||||
|
setStoreSession({
|
||||||
|
id: s.id,
|
||||||
|
name: s.name,
|
||||||
|
address: s.address,
|
||||||
|
phone: s.phone,
|
||||||
|
latitude: s.latitude,
|
||||||
|
longitude: s.longitude,
|
||||||
|
logo: s.logo
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const initStoresAndLocation = async () => {
|
||||||
|
const res = await getStoreList()
|
||||||
|
if (res.code !== 200 || !Array.isArray(res.data) || !res.data.length) {
|
||||||
|
uni.showToast({ title: '暂无可预约门店', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let lat
|
||||||
|
let lng
|
||||||
|
try {
|
||||||
|
const loc = await new Promise((resolve, reject) => {
|
||||||
|
uni.getLocation({
|
||||||
|
type: 'gcj02',
|
||||||
|
success: resolve,
|
||||||
|
fail: reject
|
||||||
|
})
|
||||||
|
})
|
||||||
|
lat = loc.latitude
|
||||||
|
lng = loc.longitude
|
||||||
|
hasUserLocation.value = true
|
||||||
|
} catch (_) {
|
||||||
|
hasUserLocation.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
orderedStores.value = buildOrderedStores(res.data, lat, lng)
|
||||||
|
|
||||||
|
const savedId = uni.getStorageSync(BOOKING_STORE_ID_KEY)
|
||||||
|
const sid = storeInfo?.id ?? userInfo?.storeId
|
||||||
|
let pickIdx = 0
|
||||||
|
if (savedId && orderedStores.value.some((x) => String(x.id) === String(savedId))) {
|
||||||
|
pickIdx = orderedStores.value.findIndex((x) => String(x.id) === String(savedId))
|
||||||
|
} else if (sid && orderedStores.value.some((x) => String(x.id) === String(sid))) {
|
||||||
|
pickIdx = orderedStores.value.findIndex((x) => String(x.id) === String(sid))
|
||||||
|
}
|
||||||
|
selectedStoreIndex.value = Math.max(0, pickIdx)
|
||||||
|
applySelectedStoreToSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onStorePickerChange = (e) => {
|
||||||
|
selectedStoreIndex.value = Number(e.detail.value)
|
||||||
|
applySelectedStoreToSession()
|
||||||
|
newAppt.value.serviceType = ''
|
||||||
|
loadServiceTypesForSelected()
|
||||||
|
}
|
||||||
|
const myPets = ref([])
|
||||||
|
const selectedPetIndex = ref(0)
|
||||||
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' })
|
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' })
|
||||||
const creatingAppt = ref(false)
|
const creatingAppt = ref(false)
|
||||||
|
|
||||||
|
const petPickerOptions = computed(() => {
|
||||||
|
const head = [{ label: '手动填写,不关联档案', pet: null }]
|
||||||
|
return head.concat(myPets.value.map((p) => ({ label: `${p.name}(${p.petType})`, pet: p })))
|
||||||
|
})
|
||||||
|
|
||||||
|
const petPickerLabel = computed(() => petPickerOptions.value[selectedPetIndex.value]?.label || '请选择')
|
||||||
|
|
||||||
|
const onPickMyPet = (e) => {
|
||||||
|
const i = Number(e.detail.value)
|
||||||
|
selectedPetIndex.value = i
|
||||||
|
const opt = petPickerOptions.value[i]
|
||||||
|
if (opt?.pet) {
|
||||||
|
newAppt.value.petName = opt.pet.name
|
||||||
|
newAppt.value.petType = opt.pet.petType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const appointmentDate = computed(() => {
|
const appointmentDate = computed(() => {
|
||||||
const raw = newAppt.value.appointmentTime || ''
|
const raw = newAppt.value.appointmentTime || ''
|
||||||
return raw.includes('T') ? raw.split('T')[0] : ''
|
return raw.includes('T') ? raw.split('T')[0] : ''
|
||||||
@ -111,11 +255,12 @@ const onAppointmentTimeOnlyChange = (e) => {
|
|||||||
newAppt.value.appointmentTime = `${date}T${time}`
|
newAppt.value.appointmentTime = `${date}T${time}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadServiceTypes = async () => {
|
const loadServiceTypesForSelected = async () => {
|
||||||
if (!storeInfo?.id) return
|
const row = orderedStores.value[selectedStoreIndex.value]
|
||||||
const res = await getServiceTypeList(storeInfo.id)
|
const sid = row?.id
|
||||||
if (res.code === 200) {
|
const res = await getServiceTypeList(sid ?? undefined)
|
||||||
serviceTypes.value = res.data.map(s => ({ label: s.name, value: s.name }))
|
if (res.code === 200 && Array.isArray(res.data)) {
|
||||||
|
serviceTypes.value = res.data.map((s) => ({ label: s.name, value: s.name }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,12 +270,17 @@ const confirmNewAppt = async () => {
|
|||||||
if (!a.petType) { uni.showToast({ title: '请选择宠物类型', icon: 'none' }); return }
|
if (!a.petType) { uni.showToast({ title: '请选择宠物类型', icon: 'none' }); return }
|
||||||
if (!a.serviceType) { uni.showToast({ title: '请选择服务类型', icon: 'none' }); return }
|
if (!a.serviceType) { uni.showToast({ title: '请选择服务类型', icon: 'none' }); return }
|
||||||
if (!a.appointmentTime) { uni.showToast({ title: '请选择预约时间', icon: 'none' }); return }
|
if (!a.appointmentTime) { uni.showToast({ title: '请选择预约时间', icon: 'none' }); return }
|
||||||
if (!storeInfo?.id) {
|
const row = orderedStores.value[selectedStoreIndex.value]
|
||||||
uni.showToast({ title: '缺少门店信息,请重新登录', icon: 'none' })
|
const storeId = row?.id
|
||||||
|
if (!storeId) {
|
||||||
|
uni.showToast({ title: '请先选择预约门店', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
creatingAppt.value = true
|
creatingAppt.value = true
|
||||||
const res = await createAppointment({ ...a, storeId: storeInfo.id, userId: userInfo.id })
|
const payload = { ...a, storeId, userId: userInfo.id }
|
||||||
|
const opt = petPickerOptions.value[selectedPetIndex.value]
|
||||||
|
if (opt?.pet?.id) payload.petId = opt.pet.id
|
||||||
|
const res = await createAppointment(payload)
|
||||||
creatingAppt.value = false
|
creatingAppt.value = false
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
uni.showToast({ title: '预约成功', icon: 'success' })
|
uni.showToast({ title: '预约成功', icon: 'success' })
|
||||||
@ -146,7 +296,18 @@ const goBack = () => {
|
|||||||
else uni.reLaunch({ url: '/pages/home/Home' })
|
else uni.reLaunch({ url: '/pages/home/Home' })
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => loadServiceTypes())
|
const loadMyPets = async () => {
|
||||||
|
const res = await getPetList({ ownerUserId: userInfo.id })
|
||||||
|
if (res.code === 200) myPets.value = res.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await initStoresAndLocation()
|
||||||
|
if (orderedStores.value.length) {
|
||||||
|
await loadServiceTypesForSelected()
|
||||||
|
}
|
||||||
|
await loadMyPets()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -224,4 +385,11 @@ onMounted(() => loadServiceTypes())
|
|||||||
border: none;
|
border: none;
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
|
.c-store-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #999993;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -19,6 +19,14 @@
|
|||||||
@click="currentStatus = tab.name"
|
@click="currentStatus = tab.name"
|
||||||
>
|
>
|
||||||
<text class="tab-text">{{ tab.title }}</text>
|
<text class="tab-text">{{ tab.title }}</text>
|
||||||
|
<view
|
||||||
|
v-if="tab.name === 'new' && newApptCount > 0"
|
||||||
|
class="tab-badge"
|
||||||
|
>{{ newApptCount > 99 ? '99+' : newApptCount }}</view>
|
||||||
|
<view
|
||||||
|
v-if="tab.name === 'doing' && doingApptCount > 0"
|
||||||
|
class="tab-badge tab-badge--doing"
|
||||||
|
>{{ doingApptCount > 99 ? '99+' : doingApptCount }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -64,7 +72,14 @@
|
|||||||
@click="currentStatus = tab.name"
|
@click="currentStatus = tab.name"
|
||||||
>
|
>
|
||||||
<text class="tab-text">{{ tab.title }}</text>
|
<text class="tab-text">{{ tab.title }}</text>
|
||||||
<view v-if="tab.name === 'new' && newApptCount > 0" class="tab-dot"></view>
|
<view
|
||||||
|
v-if="tab.name === 'new' && newApptCount > 0"
|
||||||
|
class="tab-badge"
|
||||||
|
>{{ newApptCount > 99 ? '99+' : newApptCount }}</view>
|
||||||
|
<view
|
||||||
|
v-if="tab.name === 'doing' && doingApptCount > 0"
|
||||||
|
class="tab-badge tab-badge--doing"
|
||||||
|
>{{ doingApptCount > 99 ? '99+' : doingApptCount }}</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -191,6 +206,7 @@ const statusTabs = [
|
|||||||
|
|
||||||
const orders = ref([])
|
const orders = ref([])
|
||||||
const newApptCount = computed(() => orders.value.filter(o => o.status === 'new').length)
|
const newApptCount = computed(() => orders.value.filter(o => o.status === 'new').length)
|
||||||
|
const doingApptCount = computed(() => orders.value.filter(o => o.status === 'doing').length)
|
||||||
const serviceTypes = ref([])
|
const serviceTypes = ref([])
|
||||||
const showNewAppt = ref(false)
|
const showNewAppt = ref(false)
|
||||||
const creatingAppt = ref(false)
|
const creatingAppt = ref(false)
|
||||||
@ -338,11 +354,10 @@ const confirmNewAppt = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchAppointments()
|
|
||||||
if (userInfo.role !== 'customer') loadServiceTypes()
|
if (userInfo.role !== 'customer') loadServiceTypes()
|
||||||
})
|
})
|
||||||
onShow(() => {
|
onShow(() => {
|
||||||
if (userInfo.role === 'customer') fetchAppointments()
|
fetchAppointments()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -498,14 +513,24 @@ onShow(() => {
|
|||||||
color: #999993;
|
color: #999993;
|
||||||
}
|
}
|
||||||
.tab-active .tab-text { color: #fff; }
|
.tab-active .tab-text { color: #fff; }
|
||||||
.tab-dot {
|
.tab-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 4px;
|
||||||
right: 16px;
|
right: 8px;
|
||||||
width: 7px;
|
min-width: 16px;
|
||||||
height: 7px;
|
height: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
background: #e5534b;
|
background: #e5534b;
|
||||||
border-radius: 50%;
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.tab-badge--doing {
|
||||||
|
background: #2db96d;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* B端列表(底部留白,避免最后一条被悬浮钮挡住) */
|
/* B端列表(底部留白,避免最后一条被悬浮钮挡住) */
|
||||||
|
|||||||
@ -46,6 +46,11 @@
|
|||||||
<text class="menu-text">个人信息</text>
|
<text class="menu-text">个人信息</text>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="menu-item" @click="goMyPetsPage">
|
||||||
|
<view class="menu-icon-wrap"><AppIcon name="service" :size="16" color="#666660" /></view>
|
||||||
|
<text class="menu-text">我的宠物</text>
|
||||||
|
<text class="menu-arrow">›</text>
|
||||||
|
</view>
|
||||||
<view class="menu-item" @click="goMyOrdersPage">
|
<view class="menu-item" @click="goMyOrdersPage">
|
||||||
<view class="menu-icon-wrap"><AppIcon name="orders" :size="16" color="#666660" /></view>
|
<view class="menu-icon-wrap"><AppIcon name="orders" :size="16" color="#666660" /></view>
|
||||||
<text class="menu-text">我的订单</text>
|
<text class="menu-text">我的订单</text>
|
||||||
@ -75,6 +80,7 @@ const goStaffPage = () => uni.navigateTo({ url: '/pages/mine/Staff' })
|
|||||||
const goServiceTypePage = () => uni.navigateTo({ url: '/pages/mine/ServiceType' })
|
const goServiceTypePage = () => uni.navigateTo({ url: '/pages/mine/ServiceType' })
|
||||||
const goStorePage = () => uni.navigateTo({ url: '/pages/mine/Store' })
|
const goStorePage = () => uni.navigateTo({ url: '/pages/mine/Store' })
|
||||||
const openProfile = () => uni.navigateTo({ url: '/pages/mine/Profile' })
|
const openProfile = () => uni.navigateTo({ url: '/pages/mine/Profile' })
|
||||||
|
const goMyPetsPage = () => uni.navigateTo({ url: '/pages/mine/MyPets' })
|
||||||
const goMyOrdersPage = () => uni.navigateTo({ url: '/pages/mine/MyOrders' })
|
const goMyOrdersPage = () => uni.navigateTo({ url: '/pages/mine/MyOrders' })
|
||||||
|
|
||||||
const userCardSafeStyle = (() => {
|
const userCardSafeStyle = (() => {
|
||||||
|
|||||||
553
src/pages/mine/MyPets.vue
Normal file
553
src/pages/mine/MyPets.vue
Normal file
@ -0,0 +1,553 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page-shell my-pets-page">
|
||||||
|
<view class="pets-nav nav-gradient" :style="navSafeStyle">
|
||||||
|
<view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
|
||||||
|
<text class="nav-title">我的宠物</text>
|
||||||
|
<view class="nav-placeholder"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="hero-box">
|
||||||
|
<text class="hero-title">{{ isCustomer ? '我的毛孩子' : '本店服务宠物' }}</text>
|
||||||
|
<text class="hero-sub">{{ heroSub }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading" class="loading-hint"><text>加载中…</text></view>
|
||||||
|
|
||||||
|
<view v-else class="pet-list">
|
||||||
|
<template v-for="p in petList" :key="p.id">
|
||||||
|
<view
|
||||||
|
v-if="isCustomer"
|
||||||
|
class="pet-swipe-wrap"
|
||||||
|
:class="{ 'is-open': swipedId === p.id }"
|
||||||
|
>
|
||||||
|
<view class="pet-swipe-delete" @click.stop="removePet(p)">删除</view>
|
||||||
|
<view
|
||||||
|
class="pet-swipe-main"
|
||||||
|
@touchstart="onSwipeStart($event, p)"
|
||||||
|
@touchmove.stop="onSwipeMove($event)"
|
||||||
|
@touchend="onSwipeEnd($event, p)"
|
||||||
|
@click.stop="onPetMainClick(p)"
|
||||||
|
>
|
||||||
|
<view class="pet-row">
|
||||||
|
<view class="pet-avatar-wrap">
|
||||||
|
<image v-if="p.avatar" :src="imgUrl(p.avatar)" class="pet-avatar" mode="aspectFill" />
|
||||||
|
<view v-else class="pet-avatar-ph">{{ (p.name || '?').slice(0, 1) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="pet-info">
|
||||||
|
<text class="pet-name">{{ p.name }}</text>
|
||||||
|
<text class="pet-meta">{{ p.petType }}{{ p.breed ? ' · ' + p.breed : '' }}</text>
|
||||||
|
<text v-if="p.remark" class="pet-remark">{{ p.remark }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="pet-card">
|
||||||
|
<view class="pet-row">
|
||||||
|
<view class="pet-avatar-wrap">
|
||||||
|
<image v-if="p.avatar" :src="imgUrl(p.avatar)" class="pet-avatar" mode="aspectFill" />
|
||||||
|
<view v-else class="pet-avatar-ph">{{ (p.name || '?').slice(0, 1) }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="pet-info">
|
||||||
|
<text class="pet-name">{{ p.name }}</text>
|
||||||
|
<text class="pet-meta">{{ p.petType }}{{ p.breed ? ' · ' + p.breed : '' }}</text>
|
||||||
|
<text v-if="p.remark" class="pet-remark">{{ p.remark }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!loading && petList.length === 0" class="empty"><text>暂无宠物档案</text></view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-if="isCustomer"
|
||||||
|
class="fab-pet"
|
||||||
|
@click="openCreate"
|
||||||
|
hover-class="fab-pet--hover"
|
||||||
|
>
|
||||||
|
<text class="fab-pet-icon">+</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 新建/编辑 -->
|
||||||
|
<view v-if="showForm" class="popup-mask" @click="showForm = false">
|
||||||
|
<view class="popup-content pet-form-sheet" @click.stop>
|
||||||
|
<view class="popup-header">
|
||||||
|
<text class="popup-title">{{ editingId ? '编辑宠物' : '添加宠物' }}</text>
|
||||||
|
<text class="popup-close" @click="showForm = false">✕</text>
|
||||||
|
</view>
|
||||||
|
<scroll-view scroll-y class="pet-form-body">
|
||||||
|
<view class="c-field">
|
||||||
|
<text class="c-label">名字</text>
|
||||||
|
<input v-model="form.name" class="c-input" placeholder="宠物名字" maxlength="32" />
|
||||||
|
</view>
|
||||||
|
<view class="c-field">
|
||||||
|
<text class="c-label">类型</text>
|
||||||
|
<picker mode="selector" :range="petTypes" range-key="label" @change="onPetTypeChange">
|
||||||
|
<view class="c-input c-picker">
|
||||||
|
<text :class="{ 'c-placeholder': !form.petType }">{{ form.petType || '请选择' }}</text>
|
||||||
|
<text class="c-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="c-field">
|
||||||
|
<text class="c-label">品种(可选)</text>
|
||||||
|
<input v-model="form.breed" class="c-input" placeholder="如:金毛" maxlength="32" />
|
||||||
|
</view>
|
||||||
|
<view class="c-field">
|
||||||
|
<text class="c-label">备注(可选)</text>
|
||||||
|
<input v-model="form.remark" class="c-input" placeholder="习性、注意事项等" maxlength="200" />
|
||||||
|
</view>
|
||||||
|
<view class="c-field">
|
||||||
|
<text class="c-label">头像</text>
|
||||||
|
<view class="avatar-tap" @click="pickAvatar">
|
||||||
|
<image v-if="form.avatarDisplay" :src="form.avatarDisplay" class="avatar-prev" mode="aspectFill" />
|
||||||
|
<text v-else class="avatar-tap-text">点击上传</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
<view class="popup-footer">
|
||||||
|
<button class="c-submit" :loading="saving" @click="savePet">{{ saving ? '保存中…' : '保存' }}</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
|
import AppIcon from '../../components/AppIcon.vue'
|
||||||
|
import {
|
||||||
|
getPetList,
|
||||||
|
createPet,
|
||||||
|
updatePet,
|
||||||
|
deletePet,
|
||||||
|
uploadImage,
|
||||||
|
imgUrl
|
||||||
|
} from '../../api/index.js'
|
||||||
|
import { getUserSession, getStoreSession } from '../../utils/session.js'
|
||||||
|
import { navigateTo } from '../../utils/globalState.js'
|
||||||
|
|
||||||
|
const userInfo = getUserSession()
|
||||||
|
const storeInfo = getStoreSession()
|
||||||
|
const isCustomer = computed(() => userInfo.role === 'customer')
|
||||||
|
|
||||||
|
const heroSub = computed(() => {
|
||||||
|
if (isCustomer.value) return '管理您的宠物档案,创建预约时可关联,方便门店识别。'
|
||||||
|
return '以下为在本店预约中曾关联过的宠物(客户需在预约里选择档案)。'
|
||||||
|
})
|
||||||
|
|
||||||
|
const navSafeStyle = (() => {
|
||||||
|
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
||||||
|
let navHeight = statusBarHeight + 44
|
||||||
|
const menuRect = uni.getMenuButtonBoundingClientRect?.()
|
||||||
|
if (menuRect && menuRect.top && menuRect.height) {
|
||||||
|
const verticalGap = Math.max(menuRect.top - statusBarHeight, 4)
|
||||||
|
navHeight = statusBarHeight + verticalGap * 2 + menuRect.height
|
||||||
|
}
|
||||||
|
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
|
||||||
|
})()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const petList = ref([])
|
||||||
|
const swipedId = ref(null)
|
||||||
|
const touchStartX = ref(0)
|
||||||
|
const touchCurrentX = ref(0)
|
||||||
|
const suppressPetRowClick = ref(false)
|
||||||
|
const showForm = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const editingId = ref(null)
|
||||||
|
const form = ref({
|
||||||
|
name: '',
|
||||||
|
petType: '',
|
||||||
|
breed: '',
|
||||||
|
remark: '',
|
||||||
|
avatar: '',
|
||||||
|
avatarDisplay: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const petTypes = [
|
||||||
|
{ label: '猫', value: '猫' },
|
||||||
|
{ label: '狗', value: '狗' },
|
||||||
|
{ label: '其他', value: '其他' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const loadPets = async () => {
|
||||||
|
loading.value = true
|
||||||
|
let res
|
||||||
|
if (isCustomer.value) {
|
||||||
|
res = await getPetList({ ownerUserId: userInfo.id })
|
||||||
|
} else {
|
||||||
|
if (!storeInfo?.id) {
|
||||||
|
petList.value = []
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res = await getPetList({ storeId: storeInfo.id })
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
if (res.code === 200) petList.value = res.data || []
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length > 1) uni.navigateBack()
|
||||||
|
else navigateTo('mine')
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
editingId.value = null
|
||||||
|
form.value = { name: '', petType: '', breed: '', remark: '', avatar: '', avatarDisplay: '' }
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTouchX = (e) => {
|
||||||
|
if (e?.touches?.[0]) return e.touches[0].pageX
|
||||||
|
if (e?.changedTouches?.[0]) return e.changedTouches[0].pageX
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSwipeStart = (e, p) => {
|
||||||
|
const x = getTouchX(e)
|
||||||
|
touchStartX.value = x
|
||||||
|
touchCurrentX.value = x
|
||||||
|
if (swipedId.value && swipedId.value !== p.id) {
|
||||||
|
swipedId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSwipeMove = (e) => {
|
||||||
|
touchCurrentX.value = getTouchX(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSwipeEnd = (e, p) => {
|
||||||
|
const endX = getTouchX(e) || touchCurrentX.value
|
||||||
|
const deltaX = endX - touchStartX.value
|
||||||
|
if (deltaX < -40) {
|
||||||
|
swipedId.value = p.id
|
||||||
|
suppressPetRowClick.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
suppressPetRowClick.value = false
|
||||||
|
}, 380)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (deltaX > 24 && swipedId.value === p.id) {
|
||||||
|
swipedId.value = null
|
||||||
|
suppressPetRowClick.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
suppressPetRowClick.value = false
|
||||||
|
}, 380)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPetMainClick = (p) => {
|
||||||
|
if (suppressPetRowClick.value) return
|
||||||
|
openEdit(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openEdit = (p) => {
|
||||||
|
editingId.value = p.id
|
||||||
|
form.value = {
|
||||||
|
name: p.name || '',
|
||||||
|
petType: p.petType || '',
|
||||||
|
breed: p.breed || '',
|
||||||
|
remark: p.remark || '',
|
||||||
|
avatar: p.avatar || '',
|
||||||
|
avatarDisplay: p.avatar ? imgUrl(p.avatar) : ''
|
||||||
|
}
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPetTypeChange = (e) => {
|
||||||
|
const i = Number(e.detail.value)
|
||||||
|
form.value.petType = petTypes[i]?.value || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickAvatar = () => {
|
||||||
|
uni.chooseImage({
|
||||||
|
count: 1,
|
||||||
|
success: async (res) => {
|
||||||
|
const path = res.tempFilePaths[0]
|
||||||
|
uni.showLoading({ title: '上传中' })
|
||||||
|
try {
|
||||||
|
const data = await uploadImage(path)
|
||||||
|
uni.hideLoading()
|
||||||
|
if (data.code === 200) {
|
||||||
|
form.value.avatar = data.data.url
|
||||||
|
form.value.avatarDisplay = imgUrl(data.data.url)
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: data.message || '上传失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '上传失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePet = async () => {
|
||||||
|
const f = form.value
|
||||||
|
if (!f.name?.trim()) {
|
||||||
|
uni.showToast({ title: '请填写名字', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!f.petType) {
|
||||||
|
uni.showToast({ title: '请选择类型', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
if (editingId.value) {
|
||||||
|
const res = await updatePet({
|
||||||
|
id: editingId.value,
|
||||||
|
name: f.name.trim(),
|
||||||
|
petType: f.petType,
|
||||||
|
breed: f.breed?.trim() || '',
|
||||||
|
remark: f.remark?.trim() || '',
|
||||||
|
avatar: f.avatar || '',
|
||||||
|
operatorUserId: userInfo.id,
|
||||||
|
role: userInfo.role
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
if (res.code === 200) {
|
||||||
|
uni.showToast({ title: '已保存', icon: 'success' })
|
||||||
|
showForm.value = false
|
||||||
|
loadPets()
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const res = await createPet({
|
||||||
|
name: f.name.trim(),
|
||||||
|
petType: f.petType,
|
||||||
|
breed: f.breed?.trim() || '',
|
||||||
|
remark: f.remark?.trim() || '',
|
||||||
|
avatar: f.avatar || '',
|
||||||
|
ownerUserId: userInfo.id
|
||||||
|
})
|
||||||
|
saving.value = false
|
||||||
|
if (res.code === 200) {
|
||||||
|
uni.showToast({ title: '已添加', icon: 'success' })
|
||||||
|
showForm.value = false
|
||||||
|
loadPets()
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePet = (p) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定删除「${p.name}」?`,
|
||||||
|
success: async (r) => {
|
||||||
|
if (!r.confirm) return
|
||||||
|
const res = await deletePet(p.id, userInfo.id, userInfo.role)
|
||||||
|
if (res.code === 200) {
|
||||||
|
swipedId.value = null
|
||||||
|
uni.showToast({ title: '已删除', icon: 'success' })
|
||||||
|
loadPets()
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: res.message || '删除失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadPets())
|
||||||
|
onShow(() => loadPets())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.my-pets-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #fafaf8;
|
||||||
|
padding-bottom: calc(24px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
.nav-placeholder { width: 32px; }
|
||||||
|
.pets-nav {
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.nav-back { font-size: 20px; color: #fff; }
|
||||||
|
.nav-title { font-size: 18px; font-weight: 700; color: #fff; }
|
||||||
|
|
||||||
|
.hero-box {
|
||||||
|
margin: 12px 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid #dcefe3;
|
||||||
|
background: linear-gradient(135deg, #f3fff7 0%, #ecfbf3 100%);
|
||||||
|
}
|
||||||
|
.hero-title { font-size: 16px; font-weight: 700; color: #166534; display: block; }
|
||||||
|
.hero-sub {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-hint { text-align: center; padding: 24px; color: #94a3b8; font-size: 14px; }
|
||||||
|
|
||||||
|
.pet-list { padding: 0 16px 80px; }
|
||||||
|
/* 与服务类型 / 员工管理一致:左滑露出删除 */
|
||||||
|
.pet-swipe-wrap {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.pet-swipe-delete {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 76px;
|
||||||
|
z-index: 1;
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.pet-swipe-main {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
width: 100%;
|
||||||
|
background: #fff;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
padding: 14px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.pet-swipe-wrap.is-open .pet-swipe-main {
|
||||||
|
transform: translateX(-76px);
|
||||||
|
}
|
||||||
|
.pet-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
.pet-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.pet-avatar-wrap { flex-shrink: 0; }
|
||||||
|
.pet-avatar, .pet-avatar-ph {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.pet-avatar-ph {
|
||||||
|
background: #e8f5ef;
|
||||||
|
color: #2db96d;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.pet-info { flex: 1; min-width: 0; }
|
||||||
|
.pet-name { font-size: 16px; font-weight: 700; color: #1a1a1a; display: block; }
|
||||||
|
.pet-meta { font-size: 12px; color: #999993; display: block; margin-top: 4px; }
|
||||||
|
.pet-remark { font-size: 12px; color: #666660; display: block; margin-top: 6px; }
|
||||||
|
|
||||||
|
.fab-pet {
|
||||||
|
position: fixed;
|
||||||
|
right: 20px;
|
||||||
|
bottom: calc(24px + env(safe-area-inset-bottom));
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: #2db96d;
|
||||||
|
box-shadow: 0 6px 20px rgba(45, 185, 109, 0.42);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.fab-pet--hover { opacity: 0.9; transform: scale(0.96); }
|
||||||
|
.fab-pet-icon { color: #fff; font-size: 32px; font-weight: 300; line-height: 1; margin-top: -2px; }
|
||||||
|
|
||||||
|
.popup-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.pet-form-sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 88vh;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.pet-form-body {
|
||||||
|
max-height: 56vh;
|
||||||
|
padding: 0 20px 12px;
|
||||||
|
}
|
||||||
|
.popup-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 18px 20px 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.popup-title { font-size: 17px; font-weight: 700; color: #1a1a1a; }
|
||||||
|
.popup-close { font-size: 20px; color: #bbbbb5; padding: 8px; }
|
||||||
|
.popup-footer { padding: 12px 20px max(16px, env(safe-area-inset-bottom)); border-top: 1px solid #f0f0f0; }
|
||||||
|
|
||||||
|
.c-field { margin-bottom: 14px; }
|
||||||
|
.c-label { font-size: 13px; font-weight: 600; color: #666660; display: block; margin-bottom: 6px; }
|
||||||
|
.c-input {
|
||||||
|
background: #f5f5f2;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
.c-picker { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.c-placeholder { color: #bbbbb5; }
|
||||||
|
.c-arrow { color: #bbbbb5; }
|
||||||
|
.c-submit {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.avatar-tap {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f5f5f2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.avatar-prev { width: 100%; height: 100%; }
|
||||||
|
.avatar-tap-text { font-size: 12px; color: #999993; }
|
||||||
|
</style>
|
||||||
27
src/utils/geo.js
Normal file
27
src/utils/geo.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* 球面距离(千米)。坐标需同一套系(小程序 getLocation 建议 gcj02,与门店地图选点一致)。
|
||||||
|
*/
|
||||||
|
export function haversineKm(lat1, lon1, lat2, lon2) {
|
||||||
|
if (
|
||||||
|
lat1 == null ||
|
||||||
|
lon1 == null ||
|
||||||
|
lat2 == null ||
|
||||||
|
lon2 == null ||
|
||||||
|
Number.isNaN(lat1) ||
|
||||||
|
Number.isNaN(lon1) ||
|
||||||
|
Number.isNaN(lat2) ||
|
||||||
|
Number.isNaN(lon2)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const R = 6371
|
||||||
|
const r1 = (lat1 * Math.PI) / 180
|
||||||
|
const r2 = (lat2 * Math.PI) / 180
|
||||||
|
const dLat = ((lat2 - lat1) * Math.PI) / 180
|
||||||
|
const dLon = ((lon2 - lon1) * Math.PI) / 180
|
||||||
|
const a =
|
||||||
|
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||||
|
Math.cos(r1) * Math.cos(r2) * Math.sin(dLon / 2) * Math.sin(dLon / 2)
|
||||||
|
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||||
|
return R * c
|
||||||
|
}
|
||||||
@ -12,7 +12,8 @@ const SUB_ROUTE_MAP = {
|
|||||||
store: '/pages/mine/Store',
|
store: '/pages/mine/Store',
|
||||||
profile: '/pages/mine/Profile',
|
profile: '/pages/mine/Profile',
|
||||||
myReports: '/pages/mine/MyReports',
|
myReports: '/pages/mine/MyReports',
|
||||||
myOrders: '/pages/mine/MyOrders'
|
myOrders: '/pages/mine/MyOrders',
|
||||||
|
myPets: '/pages/mine/MyPets'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user