petstore-frontend/pages/home/home.vue
2026-04-12 22:57:48 +08:00

540 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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