540 lines
18 KiB
Vue
540 lines
18 KiB
Vue
<template>
|
||
<view class="home-page">
|
||
<!-- C端视角:微官网与预约 -->
|
||
<view v-if="userInfo.role === 'customer'" class="c-home">
|
||
<view class="c-header">
|
||
<view class="c-title">{{ storeInfo.name || '宠伴生活馆' }}</view>
|
||
<view class="c-sub">宠物服务,让爱更专业</view>
|
||
</view>
|
||
|
||
<view class="c-booking-card">
|
||
<view class="c-booking-title">预约服务</view>
|
||
<view class="form-item">
|
||
<text class="form-label">宠物名字</text>
|
||
<input class="form-input" v-model="newAppt.petName" placeholder="请输入宠物名字" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">宠物类型</text>
|
||
<picker :value="petTypeIndex" :range="petTypes" @change="onPetTypeChange">
|
||
<view class="picker-val">{{ newAppt.petType || '请选择' }} ▾</view>
|
||
</picker>
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">服务类型</text>
|
||
<picker :value="serviceTypeIndex" :range="serviceTypeOptions" :range-key="'name'" @change="onServiceTypeChange">
|
||
<view class="picker-val">{{ newAppt.serviceType || '请选择' }} ▾</view>
|
||
</picker>
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">预约时间</text>
|
||
<picker mode="datetime" :value="newAppt.appointmentTime" @change="onDateChange">
|
||
<view class="picker-val">{{ newAppt.appointmentTime || '请选择' }} ▾</view>
|
||
</picker>
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">备注</text>
|
||
<input class="form-input" v-model="newAppt.remark" placeholder="可选" />
|
||
</view>
|
||
<button class="btn-primary-full" :loading="creatingAppt" @click="onNewApptConfirm">提交预约</button>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- B端视角:订单管理 -->
|
||
<view v-else class="b-home">
|
||
<!-- 自定义导航栏 -->
|
||
<view class="nav-bar">
|
||
<view class="nav-title">宠伴生活馆</view>
|
||
<view class="nav-right">
|
||
<view class="new-appt-btn" @click="showNewAppt = true">+ 新建预约</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- Tab切换 -->
|
||
<view class="tab-bar">
|
||
<view
|
||
v-for="tab in tabs"
|
||
:key="tab.key"
|
||
:class="['tab-item', { active: currentStatus === tab.key }]"
|
||
@click="currentStatus = tab.key"
|
||
>{{ tab.label }}</view>
|
||
</view>
|
||
|
||
<!-- 列表 -->
|
||
<view class="list-content">
|
||
<view v-if="filteredOrders.length > 0" class="timeline">
|
||
<view v-for="(item, index) in filteredOrders" :key="item.id" class="timeline-item">
|
||
<!-- 时间轴线+圆点 -->
|
||
<view class="timeline-dot" :class="'dot-' + item.status">
|
||
<view class="dot-inner"></view>
|
||
</view>
|
||
<view v-if="index < filteredOrders.length - 1" class="timeline-line"></view>
|
||
|
||
<!-- 卡片 -->
|
||
<view class="order-card" :class="'card-' + item.status">
|
||
<view class="card-header">
|
||
<view class="pet-info">
|
||
<text class="pet-emoji">{{ petEmoji(item.petType) }}</text>
|
||
<text class="pet-name">{{ item.petName }}</text>
|
||
<text class="service-tag" :class="'tag-' + item.status">{{ item.serviceType }}</text>
|
||
</view>
|
||
<view class="status-tag" :class="'status-' + item.status">{{ item.statusText }}</view>
|
||
</view>
|
||
<view class="card-body">
|
||
<text class="card-time">📅 {{ item.time }}</text>
|
||
</view>
|
||
<view class="card-footer">
|
||
<view v-if="item.status === 'new'" class="action-btns">
|
||
<view class="btn-sm btn-primary-sm" @click="startService(item)">开始服务</view>
|
||
<view class="btn-sm btn-default-sm" @click="cancelService(item)">取消</view>
|
||
</view>
|
||
<view v-else-if="item.status === 'doing'">
|
||
<view class="btn-sm btn-outline-sm" @click="goReport(item)">填写报告</view>
|
||
</view>
|
||
<text v-else class="done-label">{{ item.status === 'cancel' ? '已取消' : '已完成' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else class="empty-wrap">
|
||
<text class="empty-icon">📭</text>
|
||
<text class="empty-text">暂无数据</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 新建预约弹窗 -->
|
||
<view v-if="showNewAppt" class="dialog-mask" @click="showNewAppt = false">
|
||
<view class="dialog-content" @click.stop>
|
||
<view class="dialog-header">
|
||
<text class="dialog-title">新建预约</text>
|
||
<text class="dialog-close" @click="showNewAppt = false">✕</text>
|
||
</view>
|
||
<view class="dialog-body">
|
||
<view class="form-item">
|
||
<text class="form-label">宠物名字</text>
|
||
<input class="form-input" v-model="newAppt.petName" placeholder="请输入" />
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">宠物类型</text>
|
||
<picker :value="petTypeIndex" :range="petTypes" @change="onPetTypeChange">
|
||
<view class="picker-val">{{ newAppt.petType || '请选择' }} ▾</view>
|
||
</picker>
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">服务类型</text>
|
||
<picker :value="serviceTypeIndex" :range="serviceTypeOptions" :range-key="'name'" @change="onServiceTypeChange">
|
||
<view class="picker-val">{{ newAppt.serviceType || '请选择' }} ▾</view>
|
||
</picker>
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">预约时间</text>
|
||
<picker mode="datetime" :value="newAppt.appointmentTime" @change="onDateChange">
|
||
<view class="picker-val">{{ newAppt.appointmentTime || '请选择' }} ▾</view>
|
||
</picker>
|
||
</view>
|
||
<view class="form-item">
|
||
<text class="form-label">备注</text>
|
||
<input class="form-input" v-model="newAppt.remark" placeholder="可选" />
|
||
</view>
|
||
</view>
|
||
<view class="dialog-footer">
|
||
<view class="btn-cancel" @click="showNewAppt = false">取消</view>
|
||
<view class="btn-confirm" :class="{ loading: creatingAppt }" @click="onNewApptConfirm">确定</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import {
|
||
getAppointmentList, createAppointment, startAppointment, cancelAppointment, getServiceTypeList
|
||
} from '../../utils/api.js'
|
||
|
||
const userInfo = JSON.parse(uni.getStorageSync('petstore_user') || '{}')
|
||
const storeInfo = JSON.parse(uni.getStorageSync('petstore_store') || '{}')
|
||
const currentUserId = userInfo.id
|
||
|
||
const currentStatus = ref('new')
|
||
const orders = ref([])
|
||
const serviceTypes = ref([])
|
||
const showNewAppt = ref(false)
|
||
const creatingAppt = ref(false)
|
||
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' })
|
||
const petTypeIndex = ref(0)
|
||
const serviceTypeIndex = ref(0)
|
||
|
||
const petTypes = ['猫', '狗', '其他']
|
||
|
||
const tabs = [
|
||
{ key: 'new', label: '待确认' },
|
||
{ key: 'doing', label: '进行中' },
|
||
{ key: 'done', label: '已完成' }
|
||
]
|
||
|
||
const serviceTypeOptions = computed(() => serviceTypes.value.map(s => ({ name: `${serviceEmoji(s.name)} ${s.name}`, id: s.id })))
|
||
|
||
const filteredOrders = computed(() => orders.value.filter(o => {
|
||
if (currentStatus.value === 'new') return o.status === 'new'
|
||
if (currentStatus.value === 'doing') return o.status === 'doing'
|
||
if (currentStatus.value === 'done') return o.status === 'done' || o.status === 'cancel'
|
||
return true
|
||
}))
|
||
|
||
const petEmoji = (petType) => {
|
||
const map = { '猫': '🐱', '狗': '🐶' }
|
||
return map[petType] || '🐾'
|
||
}
|
||
|
||
const serviceEmoji = (name) => {
|
||
if (!name) return ''
|
||
if (name.includes('洗澡') && name.includes('美容')) return '🛁✂️'
|
||
if (name.includes('洗澡')) return '🛁'
|
||
if (name.includes('美容')) return '✂️'
|
||
if (name.includes('剪指甲')) return '💅'
|
||
if (name.includes('驱虫')) return '🐛'
|
||
return '✨'
|
||
}
|
||
|
||
const statusTagClass = (status) => {
|
||
const map = { new: 'warning', doing: 'primary', done: 'success', cancel: 'default' }
|
||
return map[status] || 'default'
|
||
}
|
||
|
||
const fetchAppointments = async () => {
|
||
if (!currentUserId) return
|
||
const res = await getAppointmentList(currentUserId)
|
||
if (res.code === 200) {
|
||
orders.value = res.data.map(appt => ({
|
||
id: appt.id,
|
||
title: appt.serviceType || '洗澡美容预约',
|
||
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
|
||
time: appt.appointmentTime ? new Date(appt.appointmentTime).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '',
|
||
status: appt.status || 'new',
|
||
statusText: { new: '待确认', doing: '服务中', done: '已完成', cancel: '已取消' }[appt.status] || '待确认',
|
||
petName: appt.petName,
|
||
petType: appt.petType,
|
||
serviceType: appt.serviceType,
|
||
appointmentTime: appt.appointmentTime
|
||
}))
|
||
}
|
||
}
|
||
|
||
const loadServiceTypes = async () => {
|
||
const sid = storeInfo.id || 1
|
||
const res = await getServiceTypeList(sid)
|
||
if (res.code === 200) serviceTypes.value = res.data
|
||
}
|
||
|
||
const onPetTypeChange = (e) => {
|
||
petTypeIndex.value = e.detail.value
|
||
newAppt.value.petType = petTypes[e.detail.value]
|
||
}
|
||
|
||
const onServiceTypeChange = (e) => {
|
||
serviceTypeIndex.value = e.detail.value
|
||
newAppt.value.serviceType = serviceTypes.value[e.detail.value]?.name || ''
|
||
}
|
||
|
||
const onDateChange = (e) => {
|
||
newAppt.value.appointmentTime = e.detail.value
|
||
}
|
||
|
||
const startService = async (item) => {
|
||
const res = await startAppointment(item.id, userInfo.id)
|
||
if (res.code === 200) {
|
||
uni.showToast({ title: '已开始服务', icon: 'success' })
|
||
fetchAppointments()
|
||
} else {
|
||
uni.showToast({ title: res.message || '操作失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const cancelService = async (item) => {
|
||
uni.showModal({
|
||
title: '确认取消',
|
||
content: '确定取消该预约吗?',
|
||
success: async (res) => {
|
||
if (res.confirm) {
|
||
const r = await cancelAppointment(item.id)
|
||
if (r.code === 200) {
|
||
uni.showToast({ title: '已取消', icon: 'success' })
|
||
fetchAppointments()
|
||
} else {
|
||
uni.showToast({ title: r.message || '操作失败', icon: 'none' })
|
||
}
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
const goReport = (item) => {
|
||
uni.setStorageSync('petstore_report_prefill', JSON.stringify({
|
||
appointmentId: item.id,
|
||
petName: item.petName,
|
||
serviceType: item.serviceType,
|
||
appointmentTime: item.appointmentTime
|
||
}))
|
||
uni.switchTab({ url: '/pages/report/report' })
|
||
}
|
||
|
||
const onNewApptConfirm = async () => {
|
||
const a = newAppt.value
|
||
if (!a.petName) { 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.appointmentTime) { uni.showToast({ title: '请选择预约时间', icon: 'none' }); return }
|
||
creatingAppt.value = true
|
||
const res = await createAppointment({ ...a, storeId: storeInfo.id || 1, userId: userInfo.id })
|
||
creatingAppt.value = false
|
||
if (res.code === 200) {
|
||
uni.showToast({ title: '预约创建成功', icon: 'success' })
|
||
newAppt.value = { petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' }
|
||
showNewAppt.value = false
|
||
fetchAppointments()
|
||
} else {
|
||
uni.showToast({ title: res.message || '创建失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchAppointments()
|
||
loadServiceTypes()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.home-page { min-height: 100vh; background: #f5f5f5; padding-bottom: 20px; }
|
||
|
||
/* C端样式 */
|
||
.c-home { padding-bottom: 20px; }
|
||
.c-header {
|
||
background: linear-gradient(135deg, #07c160 0%, #10b76f 100%);
|
||
padding: 60px 20px 40px;
|
||
text-align: center;
|
||
color: #fff;
|
||
border-radius: 0 0 24px 24px;
|
||
}
|
||
.c-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
|
||
.c-sub { font-size: 14px; opacity: 0.8; }
|
||
.c-booking-card {
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
margin: -20px 16px 0;
|
||
padding: 24px 20px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||
}
|
||
.c-booking-title { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 20px; text-align: center; }
|
||
.btn-primary-full {
|
||
width: 100%;
|
||
height: 44px;
|
||
background: #07c160;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 15px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-top: 24px;
|
||
}
|
||
|
||
/* B端样式 */
|
||
.nav-bar {
|
||
background: linear-gradient(135deg, #07c160 0%, #10b76f 100%);
|
||
padding: 40px 16px 12px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.nav-title { color: #fff; font-size: 17px; font-weight: 600; }
|
||
.new-appt-btn {
|
||
background: rgba(255,255,255,0.2);
|
||
color: #fff;
|
||
font-size: 13px;
|
||
padding: 6px 14px;
|
||
border-radius: 20px;
|
||
border: 1px solid rgba(255,255,255,0.4);
|
||
}
|
||
|
||
.tab-bar {
|
||
display: flex;
|
||
background: #fff;
|
||
padding: 0 16px;
|
||
border-bottom: 1px solid #eee;
|
||
}
|
||
.tab-item {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: 12px 0;
|
||
font-size: 14px;
|
||
color: #999;
|
||
border-bottom: 2px solid transparent;
|
||
}
|
||
.tab-item.active { color: #07c160; border-bottom-color: #07c160; font-weight: 600; }
|
||
|
||
.list-content { padding: 12px 16px 0; }
|
||
|
||
.timeline { padding: 4px 0; }
|
||
.timeline-item {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
margin-bottom: 4px;
|
||
position: relative;
|
||
}
|
||
.timeline-dot {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
width: 24px;
|
||
flex-shrink: 0;
|
||
padding-top: 14px;
|
||
}
|
||
.dot-inner {
|
||
width: 12px;
|
||
height: 12px;
|
||
border-radius: 50%;
|
||
border: 2px solid;
|
||
background: #fff;
|
||
z-index: 1;
|
||
}
|
||
.dot-new .dot-inner { border-color: #ff9f00; }
|
||
.dot-doing .dot-inner { border-color: #07c160; }
|
||
.dot-done .dot-inner { border-color: #c0c0c0; }
|
||
.dot-cancel .dot-inner { border-color: #d0d0d0; }
|
||
|
||
.timeline-line {
|
||
position: absolute;
|
||
left: 11px;
|
||
top: 28px;
|
||
bottom: -4px;
|
||
width: 2px;
|
||
background: #e8e0d8;
|
||
}
|
||
|
||
.order-card {
|
||
flex: 1;
|
||
background: #fff;
|
||
border-radius: 12px;
|
||
padding: 14px 16px;
|
||
margin-left: 12px;
|
||
margin-bottom: 12px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||
border-left: 4px solid;
|
||
}
|
||
.card-new { border-left-color: #ff9f00; }
|
||
.card-doing { border-left-color: #07c160; }
|
||
.card-done { border-left-color: #c0c0c0; }
|
||
.card-cancel { border-left-color: #d8d8d8; opacity: 0.7; }
|
||
|
||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||
.pet-info { display: flex; align-items: center; gap: 8px; }
|
||
.pet-emoji { font-size: 20px; }
|
||
.pet-name { font-weight: 600; color: #333; font-size: 15px; }
|
||
.service-tag {
|
||
font-size: 12px;
|
||
background: #f0f9f4;
|
||
color: #52c41a;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
font-weight: 500;
|
||
}
|
||
.tag-new { background: #fff3e8; color: #ff9f00; }
|
||
.tag-doing { background: #e8f7ef; color: #07c160; }
|
||
.tag-done { background: #f0f0f0; color: #888; }
|
||
|
||
.status-tag {
|
||
font-size: 12px;
|
||
padding: 2px 8px;
|
||
border-radius: 10px;
|
||
}
|
||
.status-new { background: #fff3e8; color: #ff9f00; }
|
||
.status-doing { background: #e8f7ef; color: #07c160; }
|
||
.status-done { background: #f0f0f0; color: #888; }
|
||
.status-cancel { background: #f5f5f5; color: #999; }
|
||
|
||
.card-body { margin-bottom: 10px; }
|
||
.card-time { font-size: 13px; color: #999; }
|
||
|
||
.card-footer { display: flex; justify-content: flex-end; align-items: center; }
|
||
.action-btns { display: flex; gap: 8px; }
|
||
.done-label { font-size: 13px; color: #999; }
|
||
|
||
.btn-sm {
|
||
font-size: 12px;
|
||
padding: 5px 14px;
|
||
border-radius: 6px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.btn-primary-sm { background: #07c160; color: #fff; border: 1px solid #07c160; }
|
||
.btn-default-sm { background: #fff; color: #666; border: 1px solid #ddd; }
|
||
.btn-outline-sm { background: #fff; color: #07c160; border: 1px solid #07c160; }
|
||
|
||
.empty-wrap { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; }
|
||
.empty-icon { font-size: 48px; }
|
||
.empty-text { font-size: 14px; color: #999; margin-top: 12px; }
|
||
|
||
/* 弹窗 */
|
||
.dialog-mask {
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0,0,0,0.5);
|
||
z-index: 999;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
}
|
||
.dialog-content {
|
||
width: 100%;
|
||
max-height: 80vh;
|
||
background: #fff;
|
||
border-radius: 16px 16px 0 0;
|
||
overflow: hidden;
|
||
}
|
||
.dialog-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid #eee;
|
||
}
|
||
.dialog-title { font-size: 16px; font-weight: 600; color: #333; }
|
||
.dialog-close { font-size: 18px; color: #999; }
|
||
.dialog-body { padding: 16px 20px; max-height: 55vh; overflow-y: auto; }
|
||
.dialog-footer { display: flex; border-top: 1px solid #eee; }
|
||
.btn-cancel, .btn-confirm {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: 14px 0;
|
||
font-size: 15px;
|
||
}
|
||
.btn-cancel { color: #666; border-right: 1px solid #eee; }
|
||
.btn-confirm { color: #07c160; font-weight: 600; }
|
||
|
||
.form-item { margin-bottom: 16px; }
|
||
.form-label { font-size: 13px; color: #999; display: block; margin-bottom: 6px; }
|
||
.form-input {
|
||
width: 100%;
|
||
height: 40px;
|
||
border: 1px solid #eee;
|
||
border-radius: 8px;
|
||
padding: 0 12px;
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
}
|
||
.picker-val {
|
||
height: 40px;
|
||
border: 1px solid #eee;
|
||
border-radius: 8px;
|
||
padding: 0 12px;
|
||
font-size: 14px;
|
||
line-height: 40px;
|
||
color: #333;
|
||
background: #fafafa;
|
||
}
|
||
</style>
|