feat: 宠物页面及地图组件更新

This commit is contained in:
MaDaLei 2026-04-14 21:47:30 +08:00
parent eda1932d03
commit dabe0a5fe0
9 changed files with 821 additions and 25 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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"}
], ],

View File

@ -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>

View File

@ -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端列表底部留白避免最后一条被悬浮钮挡住 */

View File

@ -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
View 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
View 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
}

View File

@ -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'
} }
/** /**