feat: 预约组件及多项页面更新

This commit is contained in:
MaDaLei 2026-04-14 21:04:26 +08:00
parent 6640f734bf
commit eda1932d03
10 changed files with 472 additions and 85 deletions

View File

@ -19,8 +19,8 @@
import AppIcon from './AppIcon.vue' import AppIcon from './AppIcon.vue'
const tabs = [ const tabs = [
{ key: 'home', icon: 'home', label: '预约' }, { key: 'home', icon: 'home', label: '服务预约' },
{ key: 'report', icon: 'report', label: '报告' }, { key: 'report', icon: 'report', label: '洗美报告' },
{ key: 'mine', icon: 'mine', label: '我的' } { key: 'mine', icon: 'mine', label: '我的' }
] ]

View File

@ -3,6 +3,7 @@
{"path": "pages/login/Login"}, {"path": "pages/login/Login"},
{"path": "pages/home/Home"}, {"path": "pages/home/Home"},
{"path": "pages/appointment/AppointmentDetail"}, {"path": "pages/appointment/AppointmentDetail"},
{"path": "pages/appointment/CustAppointmentCreate"},
{"path": "pages/report/Report"}, {"path": "pages/report/Report"},
{"path": "pages/mine/Mine"}, {"path": "pages/mine/Mine"},
{"path": "pages/mine/Staff"}, {"path": "pages/mine/Staff"},

View File

@ -0,0 +1,227 @@
<template>
<view class="page-shell cust-appt-create">
<view class="create-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 style="width:32px"></view>
</view>
<view class="c-hero">
<text class="c-hero-title">预约宠物服务</text>
<text class="c-hero-sub">在线预约专业服务</text>
</view>
<view class="c-form-card">
<view class="c-field">
<text class="c-label">宠物名字</text>
<input v-model="newAppt.petName" class="c-input" placeholder="请输入宠物名字" />
</view>
<view class="c-field">
<text class="c-label">宠物类型</text>
<picker mode="selector" :range="petTypes" range-key="label" @change="e => newAppt.petType = petTypes[e.detail.value].value">
<view class="c-input c-picker">
<text :class="{ 'c-placeholder': !newAppt.petType }">{{ newAppt.petType || '请选择' }}</text>
<text class="c-arrow"></text>
</view>
</picker>
</view>
<view class="c-field">
<text class="c-label">服务类型</text>
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value">
<view class="c-input c-picker">
<text :class="{ 'c-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
<text class="c-arrow"></text>
</view>
</picker>
</view>
<view class="c-field">
<text class="c-label">预约时间</text>
<view class="c-time-row">
<picker mode="date" :value="appointmentDate" @change="onAppointmentDateChange" class="c-time-half">
<view class="c-input c-picker">
<text :class="{ 'c-placeholder': !appointmentDate }">{{ appointmentDate || '日期' }}</text>
</view>
</picker>
<picker mode="time" :value="appointmentTime" @change="onAppointmentTimeOnlyChange" class="c-time-half">
<view class="c-input c-picker">
<text :class="{ 'c-placeholder': !appointmentTime }">{{ appointmentTime || '时间' }}</text>
</view>
</picker>
</view>
</view>
<view class="c-field">
<text class="c-label">备注可选</text>
<input v-model="newAppt.remark" class="c-input" placeholder="可选" />
</view>
<button class="c-submit" :loading="creatingAppt" @click="confirmNewAppt">提交预约</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { createAppointment, getServiceTypeList } from '../../api/index.js'
import AppIcon from '../../components/AppIcon.vue'
import { getUserSession, getStoreSession } from '../../utils/session.js'
const userInfo = getUserSession()
const storeInfo = getStoreSession()
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 petTypes = [
{ label: '猫', value: '猫' },
{ label: '狗', value: '狗' },
{ label: '其他', value: '其他' }
]
const serviceTypes = ref([])
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' })
const creatingAppt = ref(false)
const appointmentDate = computed(() => {
const raw = newAppt.value.appointmentTime || ''
return raw.includes('T') ? raw.split('T')[0] : ''
})
const appointmentTime = computed(() => {
const raw = newAppt.value.appointmentTime || ''
if (!raw.includes('T')) return ''
return (raw.split('T')[1] || '').slice(0, 5)
})
const onAppointmentDateChange = (e) => {
const date = e?.detail?.value || ''
const time = appointmentTime.value || '00:00'
newAppt.value.appointmentTime = date ? `${date}T${time}` : ''
}
const onAppointmentTimeOnlyChange = (e) => {
const time = e?.detail?.value || ''
const date = appointmentDate.value
if (!date) { uni.showToast({ title: '请先选择日期', icon: 'none' }); return }
newAppt.value.appointmentTime = `${date}T${time}`
}
const loadServiceTypes = async () => {
if (!storeInfo?.id) return
const res = await getServiceTypeList(storeInfo.id)
if (res.code === 200) {
serviceTypes.value = res.data.map(s => ({ label: s.name, value: s.name }))
}
}
const confirmNewAppt = 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 }
if (!storeInfo?.id) {
uni.showToast({ title: '缺少门店信息,请重新登录', icon: 'none' })
return
}
creatingAppt.value = true
const res = await createAppointment({ ...a, storeId: storeInfo.id, userId: userInfo.id })
creatingAppt.value = false
if (res.code === 200) {
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 600)
} else {
uni.showToast({ title: res.message || '创建失败', icon: 'none' })
}
}
const goBack = () => {
const pages = getCurrentPages()
if (pages.length > 1) uni.navigateBack()
else uni.reLaunch({ url: '/pages/home/Home' })
}
onMounted(() => loadServiceTypes())
</script>
<style scoped>
.cust-appt-create {
background: #fafaf8;
min-height: 100vh;
padding-bottom: env(safe-area-inset-bottom);
}
.create-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; }
.c-hero { padding: 24px 20px 20px; }
.c-hero-title {
display: block;
font-size: 26px;
font-weight: 800;
color: #1a1a1a;
letter-spacing: -0.5px;
}
.c-hero-sub {
display: block;
font-size: 14px;
color: #999993;
margin-top: 4px;
}
.c-form-card {
margin: 0 16px;
background: #fff;
border-radius: 16px;
padding: 24px 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.c-field { margin-bottom: 16px; }
.c-label {
display: block;
font-size: 13px;
font-weight: 600;
color: #666660;
margin-bottom: 6px;
}
.c-input {
background: #f5f5f2;
border: 1.5px solid transparent;
border-radius: 12px;
padding: 11px 14px;
font-size: 15px;
color: #1a1a1a;
width: 100%;
box-sizing: border-box;
min-height: 44px;
}
.c-input:focus { border-color: #2db96d; background: #fff; }
.c-picker { display: flex; align-items: center; justify-content: space-between; }
.c-placeholder { color: #bbbbb5; }
.c-arrow { color: #bbbbb5; font-size: 18px; }
.c-time-row { display: flex; gap: 10px; }
.c-time-half { flex: 1; }
.c-submit {
width: 100%;
height: 48px;
border-radius: 14px;
background: #1a1a1a;
color: #fff;
font-size: 16px;
font-weight: 700;
border: none;
margin-top: 20px;
}
</style>

View File

@ -1,59 +1,51 @@
<template> <template>
<view class="page-shell home-page"> <view class="page-shell home-page">
<!-- C端预约 --> <!-- C端服务预约列表 + 加号新建 -->
<view v-if="userInfo.role === 'customer'" class="c-home"> <view v-if="userInfo.role === 'customer'" class="c-home">
<view class="home-nav" :style="navSafeStyle"> <view class="home-nav home-nav--center nav-gradient" :style="navSafeStyle">
<text class="c-nav-title">{{ storeInfo.name || '宠伴生活馆' }}</text> <text class="nav-title">{{ storeInfo.name || '宠伴生活馆' }}</text>
</view> </view>
<view class="c-hero"> <view class="c-hero">
<text class="c-hero-title">预约宠物服务</text> <text class="c-hero-title">服务预约</text>
<text class="c-hero-sub">在线预约专业服务</text> <text class="c-hero-sub">查看预约进度点击右下角加号新建预约</text>
</view> </view>
<view class="c-form-card"> <view class="tabs-wrap">
<view class="c-field"> <view
<text class="c-label">宠物名字</text> v-for="tab in statusTabs"
<input v-model="newAppt.petName" class="c-input" placeholder="请输入宠物名字" /> :key="tab.name"
:class="['tab', { 'tab-active': currentStatus === tab.name }]"
@click="currentStatus = tab.name"
>
<text class="tab-text">{{ tab.title }}</text>
</view> </view>
<view class="c-field"> </view>
<text class="c-label">宠物类型</text>
<picker mode="selector" :range="petTypes" range-key="label" @change="e => newAppt.petType = petTypes[e.detail.value].value"> <view class="order-list">
<view class="c-input c-picker"> <view
<text :class="{ 'c-placeholder': !newAppt.petType }">{{ newAppt.petType || '请选择' }}</text> v-for="item in filteredOrders"
<text class="c-arrow"></text> :key="item.id"
class="order-card"
@click="goAppointmentDetail(item)"
>
<view class="order-top">
<view class="order-pet">
<text class="order-pet-name">{{ item.petName }}</text>
<text class="order-svc">{{ item.serviceType }}</text>
</view> </view>
</picker> <view :class="['order-status', `status-${item.status}`]">{{ item.statusText }}</view>
</view> </view>
<view class="c-field"> <view class="order-time">{{ item.time }}</view>
<text class="c-label">服务类型</text> <view v-if="item.status === 'new'" class="order-actions">
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value"> <button class="btn-sm btn-outline" @click.stop="cancelService(item)">取消预约</button>
<view class="c-input c-picker">
<text :class="{ 'c-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
<text class="c-arrow"></text>
</view>
</picker>
</view>
<view class="c-field">
<text class="c-label">预约时间</text>
<view class="c-time-row">
<picker mode="date" :value="appointmentDate" @change="onAppointmentDateChange" class="c-time-half">
<view class="c-input c-picker">
<text :class="{ 'c-placeholder': !appointmentDate }">{{ appointmentDate || '日期' }}</text>
</view>
</picker>
<picker mode="time" :value="appointmentTime" @change="onAppointmentTimeOnlyChange" class="c-time-half">
<view class="c-input c-picker">
<text :class="{ 'c-placeholder': !appointmentTime }">{{ appointmentTime || '时间' }}</text>
</view>
</picker>
</view> </view>
</view> </view>
<view class="c-field"> <view v-if="filteredOrders.length === 0" class="empty"><text>暂无预约</text></view>
<text class="c-label">备注可选</text> </view>
<input v-model="newAppt.remark" class="c-input" placeholder="可选" />
</view> <view class="fab-appt" @click="openCustCreateAppt" hover-class="fab-appt--hover">
<button class="c-submit" :loading="creatingAppt" @click="confirmNewAppt">提交预约</button> <text class="fab-appt-icon">+</text>
</view> </view>
</view> </view>
@ -168,17 +160,17 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getAppointmentList, createAppointment, startAppointment, cancelAppointment, getServiceTypeList } from '../../api/index.js' import { getAppointmentList, createAppointment, startAppointment, cancelAppointment, getServiceTypeList } from '../../api/index.js'
import TabBar from '../../components/TabBar.vue' import TabBar from '../../components/TabBar.vue'
import AppIcon from '../../components/AppIcon.vue'
import { getUserSession, getStoreSession } from '../../utils/session.js' import { getUserSession, getStoreSession } from '../../utils/session.js'
import { useNavigator } from '../../composables/useNavigator.js' import { useNavigator } from '../../composables/useNavigator.js'
import { formatDateTimeCN } from '../../utils/datetime.js' import { formatDateTimeCN } from '../../utils/datetime.js'
import { getAppointmentStatusText, getAppointmentTagClass } from '../../utils/appointment.js' import { getAppointmentStatusText } from '../../utils/appointment.js'
const userInfo = getUserSession() const userInfo = getUserSession()
const storeInfo = getStoreSession() const storeInfo = getStoreSession()
const { goPage, navigateTo } = useNavigator() const { goPage } = useNavigator()
const navSafeStyle = (() => { const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20 const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44 let navHeight = statusBarHeight + 44
@ -190,7 +182,7 @@ const navSafeStyle = (() => {
return `padding-top:${statusBarHeight}px;height:${navHeight}px;` return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
})() })()
const currentStatus = ref('doing') const currentStatus = ref(userInfo.role === 'customer' ? 'new' : 'doing')
const statusTabs = [ const statusTabs = [
{ title: '待确认', name: 'new' }, { title: '待确认', name: 'new' },
{ title: '进行中', name: 'doing' }, { title: '进行中', name: 'doing' },
@ -241,6 +233,25 @@ const onAppointmentTimeOnlyChange = (e) => {
} }
const fetchAppointments = async () => { const fetchAppointments = async () => {
if (userInfo.role === 'customer') {
if (!userInfo.id) return
const res = await getAppointmentList(userInfo.id)
if (res.code === 200) {
orders.value = res.data.map(appt => ({
id: appt.id,
title: appt.serviceType || '洗澡美容预约',
desc: `${appt.petType || ''} - ${appt.petName || ''}`,
time: formatDateTimeCN(appt.appointmentTime),
status: appt.status || 'new',
statusText: getAppointmentStatusText(appt.status),
petName: appt.petName,
petType: appt.petType,
serviceType: appt.serviceType,
appointmentTime: appt.appointmentTime
}))
}
return
}
if (!storeInfo.id) return if (!storeInfo.id) return
const res = await getAppointmentList(null, storeInfo.id) const res = await getAppointmentList(null, storeInfo.id)
if (res.code === 200) { if (res.code === 200) {
@ -261,6 +272,10 @@ const fetchAppointments = async () => {
} }
} }
const openCustCreateAppt = () => {
uni.navigateTo({ url: '/pages/appointment/CustAppointmentCreate' })
}
const loadServiceTypes = async () => { const loadServiceTypes = async () => {
if (!storeInfo.id) return if (!storeInfo.id) return
const res = await getServiceTypeList(storeInfo.id) const res = await getServiceTypeList(storeInfo.id)
@ -294,13 +309,13 @@ const goAppointmentDetail = (item) => {
} }
const goReport = (item) => { const goReport = (item) => {
navigateTo('report')
uni.setStorageSync('petstore_report_prefill', JSON.stringify({ uni.setStorageSync('petstore_report_prefill', JSON.stringify({
appointmentId: item.id, appointmentId: item.id,
petName: item.petName, petName: item.petName,
serviceType: item.serviceType, serviceType: item.serviceType,
appointmentTime: item.appointmentTime appointmentTime: item.appointmentTime
})) }))
uni.navigateTo({ url: '/pages/report/Report' })
} }
const confirmNewAppt = async () => { const confirmNewAppt = async () => {
@ -322,7 +337,13 @@ const confirmNewAppt = async () => {
} }
} }
onMounted(() => { fetchAppointments(); loadServiceTypes() }) onMounted(() => {
fetchAppointments()
if (userInfo.role !== 'customer') loadServiceTypes()
})
onShow(() => {
if (userInfo.role === 'customer') fetchAppointments()
})
</script> </script>
<style scoped> <style scoped>
@ -380,10 +401,10 @@ onMounted(() => { fetchAppointments(); loadServiceTypes() })
/* C端 */ /* C端 */
.c-home { background: #fafaf8; min-height: 100vh; } .c-home { background: #fafaf8; min-height: 100vh; }
.c-nav-title { .c-home .home-nav.nav-gradient .nav-title {
color: #fff;
font-size: 17px; font-size: 17px;
font-weight: 700; font-weight: 700;
color: #1a1a1a;
} }
.c-hero { .c-hero {
padding: 24px 20px 32px; padding: 24px 20px 32px;

View File

@ -46,11 +46,6 @@
<text class="menu-text">个人信息</text> <text class="menu-text">个人信息</text>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
<view v-if="userInfo.role !== 'customer'" class="menu-item" @click="goMyReportsPage">
<view class="menu-icon-wrap"><AppIcon name="report" :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>
@ -80,7 +75,6 @@ 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 goMyReportsPage = () => uni.navigateTo({ url: '/pages/mine/MyReports' })
const goMyOrdersPage = () => uni.navigateTo({ url: '/pages/mine/MyOrders' }) const goMyOrdersPage = () => uni.navigateTo({ url: '/pages/mine/MyOrders' })
const userCardSafeStyle = (() => { const userCardSafeStyle = (() => {

View File

@ -164,7 +164,7 @@ const goReport = (item) => {
uni.setStorageSync('petstore_report_prefill', JSON.stringify({ uni.setStorageSync('petstore_report_prefill', JSON.stringify({
appointmentId: item.id, petName: item.petName, serviceType: item.serviceType, appointmentTime: item.appointmentTime appointmentId: item.id, petName: item.petName, serviceType: item.serviceType, appointmentTime: item.appointmentTime
})) }))
navigateTo('report') uni.navigateTo({ url: '/pages/report/Report' })
} }
onMounted(() => fetchOrders()) onMounted(() => fetchOrders())

View File

@ -1,9 +1,9 @@
<template> <template>
<div class="page-shell my-reports-page"> <div class="page-shell my-reports-page">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<view class="report-nav nav-gradient" :style="navSafeStyle"> <view class="report-nav report-nav--tab-root nav-gradient" :style="navSafeStyle">
<view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view> <view class="nav-placeholder"></view>
<text class="nav-title">我的报告</text> <text class="nav-title">洗护美容报告</text>
<view class="nav-placeholder"></view> <view class="nav-placeholder"></view>
</view> </view>
@ -44,16 +44,32 @@
</div> </div>
<view v-if="!loading && reportList.length === 0" class="empty"><text>暂无报告</text></view> <view v-if="!loading && reportList.length === 0" class="empty"><text>暂无报告</text></view>
<!-- 仅商家/员工可新建报告客户仅查看列表 -->
<view
v-if="userInfo.role !== 'customer'"
class="fab-new-report"
@click="openFillReport"
hover-class="fab-new-report--hover"
>
<text class="fab-new-report-icon">+</text>
</view>
<TabBar current-page="report" @change="goPage" />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
import { navigateTo } from '../../utils/globalState.js' import TabBar from '../../components/TabBar.vue'
import { useNavigator } from '../../composables/useNavigator.js'
import { getReportList, imgUrl, API_ORIGIN } from '../../api/index.js' import { getReportList, imgUrl, API_ORIGIN } from '../../api/index.js'
import { getStoreSession, getUserSession } from '../../utils/session.js' import { getStoreSession, getUserSession } from '../../utils/session.js'
const { goPage } = useNavigator()
const emit = defineEmits(['change-page']) const emit = defineEmits(['change-page'])
const userInfo = getUserSession() const userInfo = getUserSession()
const storeInfo = getStoreSession() const storeInfo = getStoreSession()
@ -80,13 +96,8 @@ const loadReports = async () => {
if (res.code === 200) reportList.value = res.data if (res.code === 200) reportList.value = res.data
} }
const goBackToMine = () => { const openFillReport = () => {
const pages = getCurrentPages() uni.navigateTo({ url: '/pages/report/Report' })
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('mine')
} }
const viewReport = (r) => { const viewReport = (r) => {
@ -104,10 +115,14 @@ const viewReport = (r) => {
} }
onMounted(() => loadReports()) onMounted(() => loadReports())
onShow(() => loadReports())
</script> </script>
<style scoped> <style scoped>
.my-reports-page { padding: 0 0 120rpx; } .my-reports-page {
padding: 0 0 calc(76px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.nav-placeholder { width: 32px; } .nav-placeholder { width: 32px; }
.reports-hero { .reports-hero {
margin-top: 12px; margin-top: 12px;
@ -130,11 +145,24 @@ onMounted(() => loadReports())
} }
.report-nav { .report-nav {
padding: 14px 16px; padding: 14px 16px;
display: flex; align-items: center; justify-content: space-between; display: flex;
position: sticky; top: 0; z-index: 10; align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
}
.report-nav--tab-root .nav-title {
flex: 1;
text-align: center;
max-width: 72%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 18px;
font-weight: 700;
color: #fff;
} }
.nav-back { font-size: 20px; color: #fff; }
.nav-title { font-size: 18px; font-weight: 700; color: #fff; }
.gallery-grid { .gallery-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@ -187,4 +215,31 @@ onMounted(() => loadReports())
color: #64748b; color: #64748b;
font-size: 11px; font-size: 11px;
} }
/* 新建报告(与首页新建预约 FAB 对齐z-index 高于 TabBar */
.fab-new-report {
position: fixed;
right: 20px;
bottom: calc(56px + env(safe-area-inset-bottom) + 20px);
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: 1002;
}
.fab-new-report--hover {
opacity: 0.92;
transform: scale(0.96);
}
.fab-new-report-icon {
color: #fff;
font-size: 32px;
font-weight: 300;
line-height: 1;
margin-top: -2px;
}
</style> </style>

View File

@ -22,6 +22,7 @@
<div class="hero-phone text-sub">{{ userInfo.phone || '未绑定手机号' }}</div> <div class="hero-phone text-sub">{{ userInfo.phone || '未绑定手机号' }}</div>
<div class="hero-tags"> <div class="hero-tags">
<span v-if="userInfo.role === 'boss'" class="role-pill hero-role">店长</span> <span v-if="userInfo.role === 'boss'" class="role-pill hero-role">店长</span>
<span v-else-if="userInfo.role === 'customer'" class="role-pill hero-role role-customer">客户</span>
<span v-else class="role-pill hero-role role-staff">员工</span> <span v-else class="role-pill hero-role role-staff">员工</span>
</div> </div>
</div> </div>
@ -33,7 +34,7 @@
<div class="menu-section"> <div class="menu-section">
<div class="menu-card"> <div class="menu-card">
<div class="menu-title">资料与账号</div> <div class="menu-title">资料与账号</div>
<div class="menu-item" @click="showEditName = true"> <div class="menu-item" @click="openEditName">
<div class="menu-left"> <div class="menu-left">
<span class="menu-icon"><AppIcon name="profile" :size="15" /></span> <span class="menu-icon"><AppIcon name="profile" :size="15" /></span>
<span class="menu-text">姓名</span> <span class="menu-text">姓名</span>
@ -43,7 +44,7 @@
<span class="menu-arrow"></span> <span class="menu-arrow"></span>
</div> </div>
</div> </div>
<div class="menu-item" @click="showEditPhone = true"> <div class="menu-item" @click="openEditPhone">
<div class="menu-left"> <div class="menu-left">
<span class="menu-icon"><AppIcon name="orders" :size="15" /></span> <span class="menu-icon"><AppIcon name="orders" :size="15" /></span>
<span class="menu-text">手机号</span> <span class="menu-text">手机号</span>
@ -60,6 +61,7 @@
</div> </div>
<div class="menu-right"> <div class="menu-right">
<span v-if="userInfo.role === 'boss'" class="role-pill role-pill-compact">店长</span> <span v-if="userInfo.role === 'boss'" class="role-pill role-pill-compact">店长</span>
<span v-else-if="userInfo.role === 'customer'" class="role-pill role-pill-compact role-customer">客户</span>
<span v-else class="role-pill role-pill-compact role-staff">员工</span> <span v-else class="role-pill role-pill-compact role-staff">员工</span>
</div> </div>
</div> </div>
@ -67,7 +69,7 @@
</div> </div>
<!-- 底部信息 --> <!-- 底部信息 -->
<div class="footer-tip">宠伴生活馆 · {{ userInfo.role === 'boss' ? '商家版' : '员工版' }}</div> <div class="footer-tip">宠伴生活馆 · {{ editionLabel }}</div>
<!-- 修改姓名弹窗 --> <!-- 修改姓名弹窗 -->
<view v-if="showEditName" class="popup-mask profile-popup-mask" @click="showEditName = false"> <view v-if="showEditName" class="popup-mask profile-popup-mask" @click="showEditName = false">
@ -78,7 +80,16 @@
</view> </view>
<view class="popup-body"> <view class="popup-body">
<view class="field-label">姓名</view> <view class="field-label">姓名</view>
<input v-model="editForm.name" class="van-field" placeholder="请输入姓名" /> <!-- 小程序原生 input 需外包一层可见边框否则易与弹窗底色融为一体 -->
<view class="profile-input-shell">
<input
v-model="editForm.name"
class="profile-input-native"
type="nickname"
placeholder="请输入姓名"
maxlength="32"
/>
</view>
</view> </view>
<view class="popup-footer profile-popup-footer"> <view class="popup-footer profile-popup-footer">
<button class="van-button van-button--block van-button--primary" :loading="saving" @click="confirmEditName">保存</button> <button class="van-button van-button--block van-button--primary" :loading="saving" @click="confirmEditName">保存</button>
@ -95,10 +106,14 @@
</view> </view>
<view class="popup-body"> <view class="popup-body">
<view class="field-label">新手机号</view> <view class="field-label">新手机号</view>
<input v-model="editForm.phone" type="tel" class="van-field" placeholder="请输入手机号" maxlength="11" /> <view class="profile-input-shell">
<input v-model="editForm.phone" class="profile-input-native" type="number" placeholder="请输入手机号" maxlength="11" />
</view>
<view class="field-label field-label-gap">验证码</view> <view class="field-label field-label-gap">验证码</view>
<view class="sms-row"> <view class="sms-row">
<input v-model="editForm.code" type="digit" class="van-field sms-input" placeholder="请输入验证码" maxlength="6" /> <view class="profile-input-shell profile-sms-code-shell">
<input v-model="editForm.code" class="profile-input-native" type="digit" placeholder="请输入验证码" maxlength="6" />
</view>
<button class="van-button van-button--small van-button--primary" :disabled="smsCountdown > 0" @click="sendSms"> <button class="van-button van-button--small van-button--primary" :disabled="smsCountdown > 0" @click="sendSms">
{{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }} {{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }}
</button> </button>
@ -151,6 +166,13 @@ let smsTimer = null
const editForm = ref({ name: '', phone: '', code: '' }) const editForm = ref({ name: '', phone: '', code: '' })
const editionLabel = computed(() => {
const r = userInfo.value.role
if (r === 'boss') return '商家版'
if (r === 'customer') return '客户版'
return '员工版'
})
const initials = computed(() => { const initials = computed(() => {
if (!userInfo.value.name) return '?' if (!userInfo.value.name) return '?'
return userInfo.value.name.slice(0, 1).toUpperCase() return userInfo.value.name.slice(0, 1).toUpperCase()
@ -162,6 +184,18 @@ const avatarBg = computed(() => {
return { background: COLORS[idx] } return { background: COLORS[idx] }
}) })
/** 打开弹窗前同步当前资料,避免仅 onMounted 一次导致输入框空白或与展示不一致(尤其 C 端微信登录后) */
const openEditName = () => {
editForm.value.name = userInfo.value.name || ''
showEditName.value = true
}
const openEditPhone = () => {
editForm.value.phone = userInfo.value.phone || ''
editForm.value.code = ''
showEditPhone.value = true
}
const sendSms = async () => { const sendSms = async () => {
const phone = editForm.value.phone const phone = editForm.value.phone
if (!phone || phone.length !== 11) { if (!phone || phone.length !== 11) {
@ -255,6 +289,7 @@ const changeAvatar = () => {
} }
onMounted(() => { onMounted(() => {
userInfo.value = getUserSession()
editForm.value.name = userInfo.value.name || '' editForm.value.name = userInfo.value.name || ''
editForm.value.phone = userInfo.value.phone || '' editForm.value.phone = userInfo.value.phone || ''
}) })
@ -369,6 +404,11 @@ onUnmounted(() => {
background: #ecfdf3; background: #ecfdf3;
color: #166534; color: #166534;
} }
.role-pill.hero-role.role-customer,
.role-pill.role-pill-compact.role-customer {
background: #eff6ff;
color: #1d4ed8;
}
.role-pill-compact { .role-pill-compact {
height: 24px; height: 24px;
padding: 0 10px; padding: 0 10px;
@ -521,6 +561,11 @@ onUnmounted(() => {
min-width: 0; min-width: 0;
} }
.sms-row .profile-sms-code-shell {
flex: 1;
min-width: 0;
}
.footer-tip { .footer-tip {
text-align: center; text-align: center;
margin-top: 28px; margin-top: 28px;
@ -528,3 +573,39 @@ onUnmounted(() => {
color: #94a3b8; color: #94a3b8;
} }
</style> </style>
<!-- scoped小程序原生 input 在部分机型上 scoped 样式穿透不稳定 -->
<style>
.profile-popup-sheet .profile-input-shell {
box-sizing: border-box;
width: 100%;
min-height: 50px;
padding: 0 14px;
background: #f1f5f9;
border: 2px solid #94a3b8;
border-radius: 12px;
display: flex;
align-items: center;
}
.profile-popup-sheet .profile-input-native {
display: block;
flex: 1;
width: 100%;
min-width: 0;
height: 46px;
min-height: 46px;
line-height: 46px;
padding: 0;
margin: 0;
border: none !important;
outline: none;
background: transparent !important;
font-size: 16px;
color: #0f172a;
-webkit-appearance: none;
appearance: none;
}
.profile-popup-sheet .profile-input-native::placeholder {
color: #64748b;
}
</style>

View File

@ -242,7 +242,14 @@ const submitReport = async () => {
} }
} }
const goBack = () => navigateTo('home') const goBack = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('report')
}
onMounted(async () => { onMounted(async () => {
await loadServiceTypes() await loadServiceTypes()

View File

@ -1,7 +1,8 @@
const ROOT_ROUTE_MAP = { const ROOT_ROUTE_MAP = {
login: '/pages/login/Login', login: '/pages/login/Login',
home: '/pages/home/Home', home: '/pages/home/Home',
report: '/pages/report/Report', /** 底部「洗美报告」统一进列表页,填写报告从列表页加号进入 */
report: '/pages/mine/MyReports',
mine: '/pages/mine/Mine' mine: '/pages/mine/Mine'
} }