feat: 微客宝H5客户端 - 基础架构+核心页面
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
|
||||
const store = useCustomerStore()
|
||||
|
||||
onMounted(async () => {
|
||||
if (store.token && !store.info) {
|
||||
await store.fetchMe()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { clientRequest } from './request'
|
||||
|
||||
export const authApi = {
|
||||
login: (data) => clientRequest.post('/auth/login', data),
|
||||
logout: () => clientRequest.post('/auth/logout'),
|
||||
me: () => clientRequest.get('/auth/me'),
|
||||
changePassword: (data) => clientRequest.post('/auth/change-password', data),
|
||||
}
|
||||
|
||||
export const homeApi = {
|
||||
index: () => clientRequest.get('/home'),
|
||||
}
|
||||
|
||||
export const careApi = {
|
||||
profile: () => clientRequest.get('/care/profile'),
|
||||
records: (params) => clientRequest.get('/care/records', { params }),
|
||||
metrics: (params) => clientRequest.get('/care/metrics', { params }),
|
||||
}
|
||||
|
||||
export const mealApi = {
|
||||
list: (params) => clientRequest.get('/meal/list', { params }),
|
||||
today: () => clientRequest.get('/meal/today'),
|
||||
review: (planId, data) => clientRequest.post(`/meal/review/${planId}`, data),
|
||||
}
|
||||
|
||||
export const accountApi = {
|
||||
index: () => clientRequest.get('/account/'),
|
||||
transactions: (params) => clientRequest.get('/account/transactions', { params }),
|
||||
}
|
||||
|
||||
export const serviceApi = {
|
||||
orders: (params) => clientRequest.get('/service/orders', { params }),
|
||||
executions: (params) => clientRequest.get('/service/executions', { params }),
|
||||
}
|
||||
|
||||
export const nannyApi = {
|
||||
myNanny: () => clientRequest.get('/nanny/my'),
|
||||
orders: (params) => clientRequest.get('/nanny/orders', { params }),
|
||||
}
|
||||
|
||||
export const complaintApi = {
|
||||
list: (params) => clientRequest.get('/complaints', { params }),
|
||||
create: (data) => clientRequest.post('/complaints', data),
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import axios from 'axios'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
import router from '@/router'
|
||||
|
||||
export const clientRequest = axios.create({
|
||||
baseURL: '/api/v1/client',
|
||||
timeout: 15000,
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
})
|
||||
|
||||
clientRequest.interceptors.request.use((config) => {
|
||||
const store = useCustomerStore()
|
||||
if (store.token) {
|
||||
config.headers.Authorization = `Bearer ${store.token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
clientRequest.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
const store = useCustomerStore()
|
||||
store.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Vant from 'vant'
|
||||
import 'vant/lib/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(Vant)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { auth: false },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/views/layout/TabLayout.vue'),
|
||||
meta: { auth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: '/home',
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/home/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'grow',
|
||||
name: 'Grow',
|
||||
component: () => import('@/views/grow/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'meal',
|
||||
name: 'Meal',
|
||||
component: () => import('@/views/meal/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'my',
|
||||
name: 'My',
|
||||
component: () => import('@/views/my/index.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
// 子页面(不在 Tab 内)
|
||||
{
|
||||
path: '/care/records',
|
||||
name: 'CareRecords',
|
||||
component: () => import('@/views/grow/CareRecords.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/care/metrics',
|
||||
name: 'HealthMetrics',
|
||||
component: () => import('@/views/grow/HealthMetrics.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
name: 'Account',
|
||||
component: () => import('@/views/my/Account.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/account/transactions',
|
||||
name: 'Transactions',
|
||||
component: () => import('@/views/my/Transactions.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/service/orders',
|
||||
name: 'ServiceOrders',
|
||||
component: () => import('@/views/my/ServiceOrders.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/nanny',
|
||||
name: 'Nanny',
|
||||
component: () => import('@/views/my/Nanny.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
{
|
||||
path: '/complaints',
|
||||
name: 'Complaints',
|
||||
component: () => import('@/views/my/Complaints.vue'),
|
||||
meta: { auth: true },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const store = useCustomerStore()
|
||||
if (to.meta.auth !== false && !store.token) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { clientRequest } from '@/api/request'
|
||||
|
||||
export const useCustomerStore = defineStore('customer', () => {
|
||||
const token = ref(localStorage.getItem('client_token') || '')
|
||||
const info = ref(null)
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const name = computed(() => info.value?.name || '')
|
||||
|
||||
function setToken(t) {
|
||||
token.value = t
|
||||
localStorage.setItem('client_token', t)
|
||||
}
|
||||
|
||||
function setInfo(data) {
|
||||
info.value = data
|
||||
}
|
||||
|
||||
async function fetchMe() {
|
||||
try {
|
||||
const res = await clientRequest.get('/auth/me')
|
||||
info.value = res.data?.data
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
info.value = null
|
||||
localStorage.removeItem('client_token')
|
||||
}
|
||||
|
||||
return { token, info, isLoggedIn, name, setToken, setInfo, fetchMe, logout }
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
background: #f8f8f8;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif;
|
||||
color: #323233;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-primary: #e8718d;
|
||||
--color-primary-light: #fde8ee;
|
||||
--color-brand: linear-gradient(135deg, #f5a0b0 0%, #e8718d 100%);
|
||||
}
|
||||
|
||||
.page-container {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 60px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { careApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const records = ref([])
|
||||
const loading = ref(true)
|
||||
const page = ref(1)
|
||||
const finished = ref(false)
|
||||
|
||||
const typeLabel = { 1: '妈妈护理', 2: '宝宝护理', 3: '加项护理' }
|
||||
|
||||
async function onLoad() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await careApi.records({ page: page.value, per_page: 15 })
|
||||
const list = res.data?.data?.list || []
|
||||
records.value.push(...list)
|
||||
if (records.value.length >= (res.data?.data?.total || 0)) finished.value = true
|
||||
page.value++
|
||||
} catch { finished.value = true } finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(onLoad)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="护理记录" left-arrow @click-left="router.back()" />
|
||||
<div class="content">
|
||||
<van-list :loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
|
||||
<div v-for="r in records" :key="r.id" class="record-card">
|
||||
<div class="rec-header">
|
||||
<span class="rec-type">{{ typeLabel[r.type] || '护理' }}</span>
|
||||
<span class="rec-time">{{ r.recorded_at?.slice(0, 16) }}</span>
|
||||
</div>
|
||||
<p class="rec-nurse">护理师:{{ r.nurse?.name || '-' }}</p>
|
||||
<p class="rec-remark" v-if="r.remark">{{ r.remark }}</p>
|
||||
</div>
|
||||
</van-list>
|
||||
<van-empty v-if="!loading && !records.length" description="暂无护理记录" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f0f4ff; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.record-card { background: white; border-radius: 16px; padding: 16px; margin-bottom: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.04); }
|
||||
.rec-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.rec-type { font-size: 13px; font-weight: 600; color: #5b8ff9; background: #eff4ff; padding: 2px 10px; border-radius: 10px; }
|
||||
.rec-time { font-size: 12px; color: #999; }
|
||||
.rec-nurse { font-size: 13px; color: #555; margin: 4px 0; }
|
||||
.rec-remark { font-size: 12px; color: #888; margin: 4px 0 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { careApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const metrics = ref([])
|
||||
const loading = ref(true)
|
||||
const metricTypeLabel = { 1: '体温', 2: '体重', 3: '血压', 4: '黄疸', 5: '宝宝体重', 6: '宝宝体温' }
|
||||
const metricUnit = { 1: '℃', 2: 'kg', 3: 'mmHg', 4: 'mg/dL', 5: 'kg', 6: '℃' }
|
||||
const typeColor = { 1: '#f87171', 2: '#60a5fa', 3: '#a78bfa', 4: '#fbbf24', 5: '#34d399', 6: '#f472b6' }
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await careApi.metrics()
|
||||
metrics.value = res.data?.data || []
|
||||
} catch { } finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="健康指标" left-arrow @click-left="router.back()" />
|
||||
<div class="content">
|
||||
<van-loading v-if="loading" vertical color="#e8718d" class="center-load" />
|
||||
<van-empty v-else-if="!metrics.length" description="暂无健康记录" />
|
||||
<div v-else class="metrics-list">
|
||||
<div v-for="m in metrics" :key="m.id" class="metric-card">
|
||||
<div class="metric-icon" :style="{ background: typeColor[m.metric_type] + '22' }">
|
||||
<van-icon name="chart-trending-o" :color="typeColor[m.metric_type]" size="20" />
|
||||
</div>
|
||||
<div class="metric-body">
|
||||
<span class="metric-type">{{ metricTypeLabel[m.metric_type] || '指标' }}</span>
|
||||
<span class="metric-time">{{ m.measured_at?.slice(0, 16) }}</span>
|
||||
</div>
|
||||
<span class="metric-val" :style="{ color: typeColor[m.metric_type] }">
|
||||
{{ m.value }} {{ metricUnit[m.metric_type] || '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f0f4ff; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.center-load { display: flex; justify-content: center; padding: 60px 0; }
|
||||
.metrics-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.metric-card { background: white; border-radius: 16px; padding: 14px; display: flex; align-items: center; gap: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.04); }
|
||||
.metric-icon { width: 44px; height: 44px; border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.metric-body { flex: 1; display: flex; flex-direction: column; gap: 2px; }
|
||||
.metric-type { font-size: 14px; font-weight: 600; color: #333; }
|
||||
.metric-time { font-size: 11px; color: #999; }
|
||||
.metric-val { font-size: 18px; font-weight: 700; white-space: nowrap; }
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { careApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const profile = ref(null)
|
||||
const metrics = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const metricTypeLabel = { 1: '体温', 2: '体重', 3: '血压', 4: '黄疸', 5: '宝宝体重', 6: '宝宝体温' }
|
||||
const metricUnit = { 1: '℃', 2: 'kg', 3: 'mmHg', 4: 'mg/dL', 5: 'kg', 6: '℃' }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [pRes, mRes] = await Promise.all([
|
||||
careApi.profile(),
|
||||
careApi.metrics({ limit: 10 }),
|
||||
])
|
||||
profile.value = pRes.data?.data
|
||||
metrics.value = mRes.data?.data || []
|
||||
} catch { } finally {
|
||||
loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<!-- 头部 -->
|
||||
<div class="top-bar">
|
||||
<h2 class="page-title">共伴成长</h2>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 护理档案卡 -->
|
||||
<section class="card" v-if="profile">
|
||||
<div class="card-title">📋 护理档案</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-item" v-if="profile.admission_date">
|
||||
<span class="info-label">入住日期</span>
|
||||
<span class="info-val">{{ profile.admission_date }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="profile.expected_discharge">
|
||||
<span class="info-label">预计离院</span>
|
||||
<span class="info-val">{{ profile.expected_discharge }}</span>
|
||||
</div>
|
||||
<div class="info-item" v-if="profile.delivery_type">
|
||||
<span class="info-label">分娩方式</span>
|
||||
<span class="info-val">{{ profile.delivery_type === 1 ? '顺产' : '剖腹产' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<van-empty v-else-if="!loading" description="暂无护理档案" image="search" />
|
||||
|
||||
<!-- 快捷入口 -->
|
||||
<section class="card">
|
||||
<div class="quick-entries">
|
||||
<div class="entry-btn" @click="router.push('/care/records')">
|
||||
<van-icon name="orders-o" color="#e8718d" size="24" />
|
||||
<span>护理记录</span>
|
||||
</div>
|
||||
<div class="entry-btn" @click="router.push('/care/metrics')">
|
||||
<van-icon name="chart-trending-o" color="#5b8ff9" size="24" />
|
||||
<span>健康指标</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 最新健康数据 -->
|
||||
<section class="card" v-if="metrics.length">
|
||||
<div class="card-header">
|
||||
<span class="card-title">📊 健康数据</span>
|
||||
<span class="more" @click="router.push('/care/metrics')">全部 ›</span>
|
||||
</div>
|
||||
<div class="metric-item" v-for="m in metrics" :key="m.id">
|
||||
<div class="metric-left">
|
||||
<span class="metric-type">{{ metricTypeLabel[m.metric_type] || '指标' }}</span>
|
||||
<span class="metric-time">{{ m.measured_at?.slice(0, 16) }}</span>
|
||||
</div>
|
||||
<span class="metric-val">{{ m.value }} {{ metricUnit[m.metric_type] || '' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f0f4ff; min-height: 100vh; padding-bottom: 60px; }
|
||||
.top-bar { background: linear-gradient(135deg, #5b8ff9, #7ec0ff); padding: 50px 20px 24px; border-radius: 0 0 24px 24px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: white; margin: 0; }
|
||||
.content { padding: 16px; }
|
||||
.card { background: white; border-radius: 16px; padding: 16px; margin-bottom: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.04); }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.card-title { font-size: 15px; font-weight: 600; color: #1d1d1f; margin-bottom: 12px; }
|
||||
.more { font-size: 13px; color: #5b8ff9; }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.info-item { text-align: center; }
|
||||
.info-label { display: block; font-size: 11px; color: #999; }
|
||||
.info-val { display: block; font-size: 14px; font-weight: 600; color: #333; margin-top: 4px; }
|
||||
.quick-entries { display: flex; gap: 12px; }
|
||||
.entry-btn { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; background: #f8f8f8; border-radius: 12px; padding: 16px; cursor: pointer; font-size: 13px; color: #666; }
|
||||
.metric-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #f5f5f5; }
|
||||
.metric-item:last-child { border: none; }
|
||||
.metric-left { display: flex; flex-direction: column; gap: 2px; }
|
||||
.metric-type { font-size: 13px; font-weight: 600; color: #333; }
|
||||
.metric-time { font-size: 11px; color: #999; }
|
||||
.metric-val { font-size: 15px; font-weight: 700; color: #5b8ff9; }
|
||||
</style>
|
||||
@@ -0,0 +1,150 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { homeApi } from '@/api/index'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useCustomerStore()
|
||||
const homeData = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await homeApi.index()
|
||||
homeData.value = res.data?.data
|
||||
} catch { } finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const customer = computed(() => homeData.value?.customer || store.info)
|
||||
const todayMeals = computed(() => homeData.value?.today_meals || [])
|
||||
const latestRecords = computed(() => homeData.value?.care_records || [])
|
||||
const nannyOrder = computed(() => homeData.value?.nanny_order)
|
||||
|
||||
const mealTypeLabel = { 1: '早餐', 2: '上午茶', 3: '午餐', 4: '下午茶', 5: '晚餐', 6: '宵夜' }
|
||||
|
||||
const features = [
|
||||
{ icon: 'records', label: '护理记录', color: '#ff8fa3', path: '/care/records' },
|
||||
{ icon: 'chart-trending-o', label: '健康指标', color: '#7ec8e3', path: '/care/metrics' },
|
||||
{ icon: 'balance-list-o', label: '我的服务', color: '#a8d8a8', path: '/service/orders' },
|
||||
{ icon: 'user-o', label: '我的月嫂', color: '#c9b8f0', path: '/nanny' },
|
||||
{ icon: 'gold-coin-o', label: '我的账户', color: '#ffd580', path: '/account' },
|
||||
{ icon: 'chat-o', label: '投诉反馈', color: '#ffb085', path: '/complaints' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<!-- 顶部横幅 -->
|
||||
<div class="top-banner">
|
||||
<div class="banner-content">
|
||||
<div>
|
||||
<p class="greeting">您好,{{ customer?.name || '亲爱的客户' }} 👋</p>
|
||||
<p class="sub-text" v-if="customer?.expected_date">
|
||||
预产期:{{ customer.expected_date }}
|
||||
</p>
|
||||
<p class="sub-text" v-else-if="customer?.actual_date">
|
||||
宝宝出生:{{ customer.actual_date }}
|
||||
</p>
|
||||
</div>
|
||||
<van-icon name="home-o" size="40" color="rgba(255,255,255,0.6)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 今日用膳 -->
|
||||
<section class="card" v-if="todayMeals.length">
|
||||
<div class="card-header">
|
||||
<span class="card-title">🍱 今日用膳</span>
|
||||
<span class="more" @click="router.push('/meal')">查看全部 ›</span>
|
||||
</div>
|
||||
<div class="meal-row" v-for="m in todayMeals" :key="m.id">
|
||||
<span class="meal-type">{{ mealTypeLabel[m.meal_type] || '用餐' }}</span>
|
||||
<span class="meal-dishes" v-if="m.dishes && m.dishes.length">
|
||||
{{ m.dishes.map(d => d.name).join(' · ') }}
|
||||
</span>
|
||||
<span class="meal-dishes" v-else>待排餐</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 最新护理 -->
|
||||
<section class="card" v-if="latestRecords.length">
|
||||
<div class="card-header">
|
||||
<span class="card-title">💆 护理记录</span>
|
||||
<span class="more" @click="router.push('/care/records')">更多 ›</span>
|
||||
</div>
|
||||
<div class="record-item" v-for="r in latestRecords" :key="r.id">
|
||||
<span class="record-nurse">{{ r.nurse?.name || '护理师' }}</span>
|
||||
<span class="record-time">{{ r.recorded_at?.slice(0, 10) }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 月嫂信息 -->
|
||||
<section class="card" v-if="nannyOrder">
|
||||
<div class="card-header">
|
||||
<span class="card-title">👩⚕️ 我的月嫂</span>
|
||||
<span class="more" @click="router.push('/nanny')">详情 ›</span>
|
||||
</div>
|
||||
<div class="nanny-row">
|
||||
<van-icon name="user-circle-o" size="36" color="#e8718d" />
|
||||
<div class="nanny-info">
|
||||
<p class="nanny-name">{{ nannyOrder.nanny?.name }}</p>
|
||||
<p class="nanny-sub">{{ nannyOrder.nanny?.level || '专业月嫂' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 功能入口九宫格 -->
|
||||
<section class="card">
|
||||
<div class="card-title" style="margin-bottom:12px">快捷功能</div>
|
||||
<div class="features-grid">
|
||||
<div
|
||||
v-for="f in features"
|
||||
:key="f.path"
|
||||
class="feature-item"
|
||||
@click="router.push(f.path)"
|
||||
>
|
||||
<div class="feature-icon" :style="{ background: f.color + '22' }">
|
||||
<van-icon :name="f.icon" :color="f.color" size="22" />
|
||||
</div>
|
||||
<span class="feature-label">{{ f.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f8f0f5; min-height: 100vh; padding-bottom: 60px; }
|
||||
.top-banner {
|
||||
background: linear-gradient(135deg, #f5a0b0 0%, #e8718d 100%);
|
||||
padding: 50px 20px 30px;
|
||||
border-radius: 0 0 24px 24px;
|
||||
}
|
||||
.banner-content { display: flex; justify-content: space-between; align-items: flex-start; color: white; }
|
||||
.greeting { font-size: 20px; font-weight: 700; margin: 0 0 4px; }
|
||||
.sub-text { font-size: 13px; opacity: 0.85; margin: 0; }
|
||||
.content { padding: 16px; }
|
||||
.card { background: white; border-radius: 16px; padding: 16px; margin-bottom: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.04); }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.card-title { font-size: 15px; font-weight: 600; color: #1d1d1f; }
|
||||
.more { font-size: 13px; color: #e8718d; }
|
||||
.meal-row { display: flex; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; }
|
||||
.meal-row:last-child { border: none; }
|
||||
.meal-type { font-size: 12px; color: white; background: #e8718d; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
|
||||
.meal-dishes { font-size: 13px; color: #666; }
|
||||
.record-item { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f5f5f5; font-size: 13px; }
|
||||
.record-item:last-child { border: none; }
|
||||
.record-nurse { color: #333; }
|
||||
.record-time { color: #999; }
|
||||
.nanny-row { display: flex; align-items: center; gap: 12px; }
|
||||
.nanny-name { font-size: 15px; font-weight: 600; margin: 0 0 2px; }
|
||||
.nanny-sub { font-size: 12px; color: #999; margin: 0; }
|
||||
.features-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.feature-item { display: flex; flex-direction: column; align-items: center; gap: 6px; cursor: pointer; }
|
||||
.feature-icon { width: 50px; height: 50px; border-radius: 14px; display: flex; align-items: center; justify-content: center; }
|
||||
.feature-label { font-size: 12px; color: #666; text-align: center; }
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tabMap = { home: 'home', grow: 'grow', meal: 'meal', my: 'my' }
|
||||
const active = ref(route.name?.toLowerCase() || 'home')
|
||||
|
||||
watch(() => route.name, (n) => {
|
||||
if (n && tabMap[n.toLowerCase()]) active.value = n.toLowerCase()
|
||||
})
|
||||
|
||||
function onChange(val) {
|
||||
router.push({ name: val.charAt(0).toUpperCase() + val.slice(1) })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout-wrap">
|
||||
<router-view />
|
||||
<van-tabbar v-model="active" @change="onChange" active-color="#e8718d" fixed safe-area-inset-bottom>
|
||||
<van-tabbar-item name="home" icon="home-o">首页</van-tabbar-item>
|
||||
<van-tabbar-item name="grow" icon="friends-o">共伴成长</van-tabbar-item>
|
||||
<van-tabbar-item name="meal" icon="restaurant-o">用膳</van-tabbar-item>
|
||||
<van-tabbar-item name="my" icon="contact-o">我的</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout-wrap {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 50px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { showToast, showLoadingToast, closeToast } from 'vant'
|
||||
import { authApi } from '@/api/index'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useCustomerStore()
|
||||
|
||||
const form = reactive({ phone: '', password: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.phone || !form.password) {
|
||||
showToast('请填写手机号和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
showLoadingToast({ message: '登录中...', forbidClick: true })
|
||||
try {
|
||||
const res = await authApi.login(form)
|
||||
const { token, customer } = res.data.data
|
||||
store.setToken(token)
|
||||
store.setInfo(customer)
|
||||
closeToast()
|
||||
showToast({ type: 'success', message: '登录成功' })
|
||||
const redirect = route.query.redirect || '/home'
|
||||
setTimeout(() => router.replace(redirect), 800)
|
||||
} catch (e) {
|
||||
closeToast()
|
||||
showToast(e.response?.data?.message || '登录失败,请检查手机号和密码')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- 头部装饰 -->
|
||||
<div class="login-header">
|
||||
<div class="logo-circle">🏠</div>
|
||||
<h1 class="app-name">宫中有喜</h1>
|
||||
<p class="app-sub">月子会所 · 微客宝</p>
|
||||
</div>
|
||||
|
||||
<!-- 表单卡片 -->
|
||||
<div class="login-card">
|
||||
<h2 class="card-title">客户登录</h2>
|
||||
<van-form @submit="onSubmit">
|
||||
<van-cell-group inset>
|
||||
<van-field
|
||||
v-model="form.phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
label="手机号"
|
||||
placeholder="请输入手机号"
|
||||
:rules="[{ required: true, message: '请输入手机号' }]"
|
||||
left-icon="phone-o"
|
||||
/>
|
||||
<van-field
|
||||
v-model="form.password"
|
||||
name="password"
|
||||
type="password"
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
:rules="[{ required: true, message: '请输入密码' }]"
|
||||
left-icon="lock"
|
||||
/>
|
||||
</van-cell-group>
|
||||
<div class="btn-wrap">
|
||||
<van-button
|
||||
round
|
||||
block
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
color="linear-gradient(135deg, #f5a0b0, #e8718d)"
|
||||
:loading="loading"
|
||||
>
|
||||
登录
|
||||
</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
<p class="tip-text">初次登录请联系门店工作人员获取密码</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(160deg, #fde8ee 0%, #fce4f3 40%, #f8f0ff 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 80px;
|
||||
}
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.logo-circle {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: white;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 38px;
|
||||
margin: 0 auto 16px;
|
||||
box-shadow: 0 8px 24px rgba(232, 113, 141, 0.25);
|
||||
}
|
||||
.app-name {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #c0395d;
|
||||
margin: 0;
|
||||
}
|
||||
.app-sub {
|
||||
font-size: 13px;
|
||||
color: #e8718d;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 24px 24px 0 0;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
padding: 32px 0 40px;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.06);
|
||||
}
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1d1d1f;
|
||||
text-align: center;
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
.btn-wrap {
|
||||
margin: 24px 16px 0;
|
||||
}
|
||||
.tip-text {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { mealApi } from '@/api/index'
|
||||
import { showToast } from 'vant'
|
||||
|
||||
const plans = ref([])
|
||||
const loading = ref(true)
|
||||
const reviewVisible = ref(false)
|
||||
const currentPlan = ref(null)
|
||||
const reviewForm = ref({ rating: 5, content: '' })
|
||||
const submitting = ref(false)
|
||||
|
||||
const mealTypeLabel = { 1: '早餐', 2: '上午茶', 3: '午餐', 4: '下午茶', 5: '晚餐', 6: '宵夜' }
|
||||
const mealTypeColor = { 1: '#ff8c69', 2: '#ffd166', 3: '#06d6a0', 4: '#a8dadc', 5: '#457b9d', 6: '#6d6875' }
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await mealApi.list({ days: 7 })
|
||||
plans.value = res.data?.data?.list || []
|
||||
} catch { } finally {
|
||||
loading.value = false }
|
||||
}
|
||||
|
||||
function openReview(plan) {
|
||||
currentPlan.value = plan
|
||||
reviewForm.value = { rating: plan.review?.score || 5, content: plan.review?.content || '' }
|
||||
reviewVisible.value = true
|
||||
}
|
||||
|
||||
async function submitReview() {
|
||||
if (!currentPlan.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await mealApi.review(currentPlan.value.id, { rating: reviewForm.value.rating, content: reviewForm.value.content })
|
||||
showToast({ type: 'success', message: '评价成功' })
|
||||
reviewVisible.value = false
|
||||
fetchList()
|
||||
} catch {
|
||||
showToast('评价失败,请重试')
|
||||
} finally {
|
||||
submitting.value = false }
|
||||
}
|
||||
|
||||
// 按日期分组
|
||||
const grouped = computed(() => {
|
||||
const map = {}
|
||||
plans.value.forEach(p => {
|
||||
const d = p.plan_date
|
||||
if (!map[d]) map[d] = []
|
||||
map[d].push(p)
|
||||
})
|
||||
return Object.entries(map).sort((a, b) => b[0].localeCompare(a[0]))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="top-bar">
|
||||
<h2 class="page-title">我的用膳</h2>
|
||||
<p class="sub-text">最近 7 天膳食安排</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<van-loading v-if="loading" vertical color="#e8718d" class="center-load">加载中...</van-loading>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="grouped.length === 0">
|
||||
<van-empty description="暂无膳食记录" />
|
||||
</div>
|
||||
|
||||
<div v-for="[date, dayPlans] in grouped" :key="date" class="day-group">
|
||||
<div class="day-header">{{ date }}</div>
|
||||
<div v-for="plan in dayPlans" :key="plan.id" class="meal-card">
|
||||
<div class="meal-header">
|
||||
<span class="meal-type-tag" :style="{ background: mealTypeColor[plan.meal_type] || '#ccc' }">
|
||||
{{ mealTypeLabel[plan.meal_type] || '用餐' }}
|
||||
</span>
|
||||
<span v-if="plan.review" class="reviewed-badge">⭐ 已评价</span>
|
||||
<span
|
||||
v-else-if="plan.status === 2"
|
||||
class="review-btn"
|
||||
@click="openReview(plan)"
|
||||
>评价</span>
|
||||
</div>
|
||||
|
||||
<div class="dishes-list" v-if="plan.dishes?.length">
|
||||
<div v-for="(dish, i) in plan.dishes" :key="i" class="dish-item">
|
||||
<span class="dish-name">{{ dish.name || dish }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="no-dish">待排餐</p>
|
||||
|
||||
<p v-if="plan.special_note" class="special-note">备注:{{ plan.special_note }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 评价弹窗 -->
|
||||
<van-popup v-model:show="reviewVisible" round position="bottom" :style="{ padding: '24px 16px 40px' }">
|
||||
<h3 class="popup-title">膳食评价</h3>
|
||||
<van-rate v-model="reviewForm.rating" :size="28" color="#e8718d" void-icon="star" void-color="#eee" />
|
||||
<van-field
|
||||
v-model="reviewForm.content"
|
||||
type="textarea"
|
||||
placeholder="分享您对这餐的感受..."
|
||||
:rows="3"
|
||||
style="margin-top:16px; background:#f8f8f8; border-radius:12px;"
|
||||
/>
|
||||
<van-button
|
||||
block round type="primary"
|
||||
color="linear-gradient(135deg, #f5a0b0, #e8718d)"
|
||||
style="margin-top:16px"
|
||||
:loading="submitting"
|
||||
@click="submitReview"
|
||||
>提交评价</van-button>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #fff8f5; min-height: 100vh; padding-bottom: 60px; }
|
||||
.top-bar { background: linear-gradient(135deg, #ff9a6c, #ff6b9d); padding: 50px 20px 24px; border-radius: 0 0 24px 24px; }
|
||||
.page-title { font-size: 20px; font-weight: 700; color: white; margin: 0 0 4px; }
|
||||
.sub-text { font-size: 13px; color: rgba(255,255,255,0.8); margin: 0; }
|
||||
.content { padding: 16px; }
|
||||
.center-load { display: flex; justify-content: center; padding: 60px 0; }
|
||||
.day-group { margin-bottom: 16px; }
|
||||
.day-header { font-size: 13px; font-weight: 600; color: #999; margin-bottom: 8px; }
|
||||
.meal-card { background: white; border-radius: 16px; padding: 14px; margin-bottom: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.04); }
|
||||
.meal-header { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.meal-type-tag { color: white; font-size: 11px; font-weight: 600; padding: 3px 10px; border-radius: 20px; }
|
||||
.reviewed-badge { font-size: 11px; color: #f59e0b; }
|
||||
.review-btn { font-size: 12px; color: #e8718d; border: 1px solid #e8718d; padding: 2px 8px; border-radius: 10px; cursor: pointer; margin-left: auto; }
|
||||
.dishes-list { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.dish-item { background: #fff0f4; color: #e8718d; font-size: 12px; padding: 3px 10px; border-radius: 10px; }
|
||||
.no-dish { font-size: 13px; color: #ccc; margin: 4px 0 0; }
|
||||
.special-note { font-size: 12px; color: #999; margin: 8px 0 0; }
|
||||
.popup-title { font-size: 16px; font-weight: 600; text-align: center; margin: 0 0 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const accounts = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const typeLabel = { 1: '会员账户', 2: '押金账户', 3: '预存卡', 4: '服务卡' }
|
||||
const typeColor = { 1: '#e8718d', 2: '#5b8ff9', 3: '#06c755', 4: '#f59e0b' }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await accountApi.index()
|
||||
accounts.value = res.data?.data || []
|
||||
} catch { } finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="我的账户" left-arrow @click-left="router.back()" />
|
||||
<div class="content">
|
||||
<van-loading v-if="loading" vertical color="#e8718d" class="center-load" />
|
||||
<van-empty v-else-if="!accounts.length" description="暂无账户信息" />
|
||||
<div v-else>
|
||||
<div v-for="acc in accounts" :key="acc.id" class="acc-card" :style="{ background: `linear-gradient(135deg, ${typeColor[acc.account_type]}dd, ${typeColor[acc.account_type]}88)` }">
|
||||
<div class="acc-type">{{ typeLabel[acc.account_type] || '账户' }}</div>
|
||||
<div class="acc-balance">¥ {{ (acc.balance || 0).toFixed(2) }}</div>
|
||||
<div class="acc-footer">
|
||||
<span>总充值: ¥{{ (acc.total_deposit || 0).toFixed(2) }}</span>
|
||||
<span>总消费: ¥{{ (acc.total_consumed || 0).toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<van-button block round plain style="margin-top:8px; color:#e8718d; border-color:#e8718d" @click="router.push('/account/transactions')">查看流水记录</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f8f0f5; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.center-load { display: flex; justify-content: center; padding: 60px 0; }
|
||||
.acc-card { border-radius: 16px; padding: 20px; margin-bottom: 12px; color: white; box-shadow: 0 4px 16px rgba(0,0,0,0.12); }
|
||||
.acc-type { font-size: 13px; opacity: 0.9; }
|
||||
.acc-balance { font-size: 32px; font-weight: 700; margin: 8px 0; }
|
||||
.acc-footer { display: flex; gap: 16px; font-size: 12px; opacity: 0.85; }
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from 'vant'
|
||||
import { complaintApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const list = ref([])
|
||||
const loading = ref(true)
|
||||
const showForm = ref(false)
|
||||
const submitting = ref(false)
|
||||
const form = ref({ type: '', content: '' })
|
||||
const typeOptions = [
|
||||
{ text: '服务质量', value: '服务质量' },
|
||||
{ text: '餐饮问题', value: '餐饮问题' },
|
||||
{ text: '环境卫生', value: '环境卫生' },
|
||||
{ text: '其他', value: '其他' },
|
||||
]
|
||||
|
||||
const statusLabel = { 1: '待处理', 2: '处理中', 3: '已解决' }
|
||||
const statusColor = { 1: 'warning', 2: 'primary', 3: 'success' }
|
||||
|
||||
onMounted(fetchList)
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await complaintApi.list()
|
||||
list.value = res.data?.data?.list || []
|
||||
} catch { } finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.value.type || !form.value.content) {
|
||||
showToast('请填写投诉类型和内容')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await complaintApi.create(form.value)
|
||||
showToast({ type: 'success', message: '提交成功' })
|
||||
showForm.value = false
|
||||
form.value = { type: '', content: '' }
|
||||
fetchList()
|
||||
} catch { showToast('提交失败') } finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="投诉反馈" left-arrow @click-left="router.back()">
|
||||
<template #right>
|
||||
<van-icon name="plus" size="18" color="#e8718d" @click="showForm = true" />
|
||||
</template>
|
||||
</van-nav-bar>
|
||||
|
||||
<div class="content">
|
||||
<van-loading v-if="loading" vertical color="#e8718d" class="center-load" />
|
||||
<van-empty v-else-if="!list.length" description="暂无投诉记录" image="comment-o" />
|
||||
<div v-else>
|
||||
<div v-for="item in list" :key="item.id" class="item-card">
|
||||
<div class="item-header">
|
||||
<span class="item-type">{{ item.type }}</span>
|
||||
<van-tag :type="statusColor[item.status] || 'default'" size="small">{{ statusLabel[item.status] || '待处理' }}</van-tag>
|
||||
</div>
|
||||
<p class="item-content">{{ item.content }}</p>
|
||||
<p class="item-time">{{ item.created_at?.slice(0, 16) }}</p>
|
||||
<div v-if="item.reply" class="item-reply">
|
||||
<span class="reply-label">回复:</span>{{ item.reply }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提交表单 -->
|
||||
<van-popup v-model:show="showForm" round position="bottom" :style="{ padding: '24px 16px 40px' }">
|
||||
<h3 class="popup-title">提交投诉反馈</h3>
|
||||
<van-cell-group inset style="margin-top:8px">
|
||||
<van-field label="类型" v-model="form.type" placeholder="如:服务质量 / 餐饮问题" />
|
||||
<van-field label="内容" v-model="form.content" type="textarea" placeholder="请详细描述您的问题..." :rows="4" />
|
||||
</van-cell-group>
|
||||
<van-button block round type="primary" color="linear-gradient(135deg,#f5a0b0,#e8718d)" style="margin-top:16px" :loading="submitting" @click="submit">提交</van-button>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f8f0f5; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.center-load { display: flex; justify-content: center; padding: 60px 0; }
|
||||
.item-card { background: white; border-radius: 16px; padding: 16px; margin-bottom: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.04); }
|
||||
.item-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.item-type { font-size: 14px; font-weight: 600; color: #333; }
|
||||
.item-content { font-size: 13px; color: #555; margin: 0 0 6px; line-height: 1.5; }
|
||||
.item-time { font-size: 11px; color: #999; margin: 0; }
|
||||
.item-reply { margin-top: 8px; padding: 8px; background: #f5f5f5; border-radius: 8px; font-size: 12px; color: #555; }
|
||||
.reply-label { color: #e8718d; font-weight: 600; }
|
||||
.popup-title { font-size: 16px; font-weight: 600; text-align: center; margin: 0 0 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { nannyApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const nannyInfo = ref(null)
|
||||
const loading = ref(true)
|
||||
const orderStatusLabel = { 1: '待确认', 2: '服务中', 3: '已完成', 4: '已取消' }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await nannyApi.myNanny()
|
||||
nannyInfo.value = res.data?.data
|
||||
} catch { } finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="我的月嫂" left-arrow @click-left="router.back()" />
|
||||
<div class="content">
|
||||
<van-loading v-if="loading" vertical color="#e8718d" class="center-load" />
|
||||
<van-empty v-else-if="!nannyInfo" description="暂无月嫂服务信息" image="manager-o" />
|
||||
<div v-else>
|
||||
<!-- 月嫂卡 -->
|
||||
<div class="nanny-card">
|
||||
<div class="nanny-avatar">
|
||||
<van-icon name="user-circle-o" size="64" color="white" />
|
||||
</div>
|
||||
<div class="nanny-detail">
|
||||
<h2 class="nanny-name">{{ nannyInfo.nanny?.name || '- -' }}</h2>
|
||||
<p class="nanny-level">{{ nannyInfo.nanny?.level || '专业月嫂' }}</p>
|
||||
<van-tag type="success" size="medium" v-if="nannyInfo.order?.status === 2">服务中</van-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 服务信息 -->
|
||||
<van-cell-group inset title="服务信息" class="info-group">
|
||||
<van-cell title="服务状态" :value="orderStatusLabel[nannyInfo.order?.status] || '-'" />
|
||||
<van-cell title="服务开始" :value="nannyInfo.order?.start_date || '-'" />
|
||||
<van-cell title="服务结束" :value="nannyInfo.order?.end_date || '-'" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f8f0f5; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.center-load { display: flex; justify-content: center; padding: 60px 0; }
|
||||
.nanny-card { background: linear-gradient(135deg, #f5a0b0, #e8718d); border-radius: 20px; padding: 24px 20px; display: flex; align-items: center; gap: 20px; margin-bottom: 16px; box-shadow: 0 8px 24px rgba(232,113,141,0.3); }
|
||||
.nanny-avatar { width: 80px; height: 80px; background: rgba(255,255,255,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
||||
.nanny-detail { color: white; }
|
||||
.nanny-name { font-size: 20px; font-weight: 700; margin: 0 0 4px; }
|
||||
.nanny-level { font-size: 13px; opacity: 0.85; margin: 0 0 8px; }
|
||||
.info-group { margin-top: 8px; }
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { serviceApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const orders = ref([])
|
||||
const loading = ref(true)
|
||||
const active = ref(0)
|
||||
|
||||
const tabs = ['全部', '进行中', '已完成']
|
||||
const tabStatus = [null, 2, 3]
|
||||
const statusLabel = { 1: '待确认', 2: '进行中', 3: '已完成', 4: '已取消' }
|
||||
const statusColor = { 1: 'warning', 2: 'primary', 3: 'success', 4: 'default' }
|
||||
|
||||
onMounted(fetchOrders)
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {}
|
||||
if (tabStatus[active.value] != null) params.status = tabStatus[active.value]
|
||||
const res = await serviceApi.orders(params)
|
||||
orders.value = res.data?.data?.list || []
|
||||
} catch { } finally { loading.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="我的服务" left-arrow @click-left="router.back()" />
|
||||
<van-tabs v-model:active="active" @change="fetchOrders" color="#e8718d" sticky>
|
||||
<van-tab v-for="(t, i) in tabs" :key="i" :title="t" />
|
||||
</van-tabs>
|
||||
|
||||
<div class="content">
|
||||
<van-loading v-if="loading" vertical color="#e8718d" class="center-load" />
|
||||
<van-empty v-else-if="!orders.length" description="暂无服务订单" />
|
||||
<div v-else>
|
||||
<div v-for="o in orders" :key="o.id" class="order-card">
|
||||
<div class="order-header">
|
||||
<span class="order-name">{{ o.package?.name || o.item?.name || '服务项目' }}</span>
|
||||
<van-tag :type="statusColor[o.status] || 'default'" size="small">{{ statusLabel[o.status] || '-' }}</van-tag>
|
||||
</div>
|
||||
<div class="order-info">
|
||||
<span>下单:{{ o.created_at?.slice(0, 10) }}</span>
|
||||
<span v-if="o.total_sessions">共 {{ o.total_sessions }} 次</span>
|
||||
<span v-if="o.remaining_sessions != null">剩余 {{ o.remaining_sessions }} 次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f8f0f5; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.center-load { display: flex; justify-content: center; padding: 60px 0; }
|
||||
.order-card { background: white; border-radius: 16px; padding: 16px; margin-bottom: 12px; box-shadow: 0 2px 10px rgba(0,0,0,0.04); }
|
||||
.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.order-name { font-size: 15px; font-weight: 600; color: #1d1d1f; }
|
||||
.order-info { display: flex; gap: 12px; font-size: 12px; color: #999; }
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { accountApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const list = ref([])
|
||||
const loading = ref(true)
|
||||
const finished = ref(false)
|
||||
const page = ref(1)
|
||||
const txTypeLabel = { 1: '充值', 2: '消费', 3: '退款', 4: '调整' }
|
||||
const txTypeColor = { 1: '#22c55e', 2: '#ef4444', 3: '#3b82f6', 4: '#f59e0b' }
|
||||
|
||||
async function onLoad() {
|
||||
try {
|
||||
const res = await accountApi.transactions({ page: page.value, per_page: 20 })
|
||||
const items = res.data?.data?.list || []
|
||||
list.value.push(...items)
|
||||
if (list.value.length >= (res.data?.data?.total || 0)) finished.value = true
|
||||
page.value++
|
||||
} catch { finished.value = true } finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(onLoad)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<van-nav-bar title="流水记录" left-arrow @click-left="router.back()" />
|
||||
<div class="content">
|
||||
<van-list :loading="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
|
||||
<div v-for="tx in list" :key="tx.id" class="tx-card">
|
||||
<div class="tx-left">
|
||||
<span class="tx-type">{{ txTypeLabel[tx.type] || '交易' }}</span>
|
||||
<span class="tx-time">{{ tx.created_at?.slice(0, 16) }}</span>
|
||||
</div>
|
||||
<span class="tx-amount" :style="{ color: [1,3].includes(tx.type) ? '#22c55e' : '#ef4444' }">
|
||||
{{ [1,3].includes(tx.type) ? '+' : '-' }}¥{{ Math.abs(tx.amount || 0).toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</van-list>
|
||||
<van-empty v-if="!loading && !list.length" description="暂无流水记录" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f8f0f5; min-height: 100vh; }
|
||||
.content { padding: 16px; }
|
||||
.tx-card { background: white; border-radius: 14px; padding: 14px 16px; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 8px rgba(0,0,0,0.04); }
|
||||
.tx-left { display: flex; flex-direction: column; gap: 2px; }
|
||||
.tx-type { font-size: 14px; font-weight: 600; color: #333; }
|
||||
.tx-time { font-size: 11px; color: #999; }
|
||||
.tx-amount { font-size: 18px; font-weight: 700; }
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showConfirmDialog, showToast } from 'vant'
|
||||
import { useCustomerStore } from '@/stores/customer'
|
||||
import { authApi } from '@/api/index'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useCustomerStore()
|
||||
const customer = computed(() => store.info)
|
||||
|
||||
const menus = [
|
||||
{ icon: 'balance-list-o', label: '我的服务', color: '#5b8ff9', path: '/service/orders' },
|
||||
{ icon: 'gold-coin-o', label: '账户余额', color: '#f59e0b', path: '/account' },
|
||||
{ icon: 'user-o', label: '我的月嫂', color: '#c084fc', path: '/nanny' },
|
||||
{ icon: 'chat-o', label: '投诉反馈', color: '#f87171', path: '/complaints' },
|
||||
]
|
||||
|
||||
async function doLogout() {
|
||||
try {
|
||||
await showConfirmDialog({ title: '确定退出?', message: '退出后需重新登录' })
|
||||
await authApi.logout().catch(() => {})
|
||||
store.logout()
|
||||
router.replace('/login')
|
||||
} catch { }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<!-- 个人信息头 -->
|
||||
<div class="profile-header">
|
||||
<div class="avatar-wrap">
|
||||
<van-icon name="user-circle-o" size="64" color="white" />
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<p class="p-name">{{ customer?.name || '- -' }}</p>
|
||||
<p class="p-phone">{{ customer?.phone }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 账户状态卡 -->
|
||||
<div class="stat-card" v-if="customer">
|
||||
<div class="stat-item">
|
||||
<span class="stat-val">{{ customer.baby_count || 1 }}</span>
|
||||
<span class="stat-label">宝宝数</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-val">{{ customer.actual_date ? '在住' : '待入住' }}</span>
|
||||
<span class="stat-label">状态</span>
|
||||
</div>
|
||||
<div class="stat-divider" />
|
||||
<div class="stat-item">
|
||||
<span class="stat-val">{{ customer.contracts?.length || 0 }}</span>
|
||||
<span class="stat-label">合同数</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 功能列表 -->
|
||||
<van-cell-group inset class="menu-group">
|
||||
<van-cell
|
||||
v-for="m in menus"
|
||||
:key="m.path"
|
||||
:title="m.label"
|
||||
is-link
|
||||
clickable
|
||||
@click="router.push(m.path)"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="menu-icon-wrap" :style="{ background: m.color + '22' }">
|
||||
<van-icon :name="m.icon" :color="m.color" size="18" />
|
||||
</div>
|
||||
</template>
|
||||
</van-cell>
|
||||
</van-cell-group>
|
||||
|
||||
<!-- 退出 -->
|
||||
<div class="logout-wrap">
|
||||
<van-button
|
||||
round
|
||||
block
|
||||
plain
|
||||
type="danger"
|
||||
@click="doLogout"
|
||||
style="color: #e8718d; border-color: #e8718d;"
|
||||
>退出登录</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page { background: #f8f0f5; min-height: 100vh; padding-bottom: 60px; }
|
||||
.profile-header {
|
||||
background: linear-gradient(135deg, #f5a0b0 0%, #e8718d 100%);
|
||||
padding: 50px 20px 30px;
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
}
|
||||
.avatar-wrap { width: 72px; height: 72px; background: rgba(255,255,255,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
||||
.p-name { font-size: 18px; font-weight: 700; color: white; margin: 0 0 4px; }
|
||||
.p-phone { font-size: 13px; color: rgba(255,255,255,0.8); margin: 0; }
|
||||
.content { padding: 16px; }
|
||||
.stat-card { background: white; border-radius: 16px; padding: 16px; display: flex; justify-content: space-around; margin-bottom: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.04); }
|
||||
.stat-item { display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
||||
.stat-val { font-size: 18px; font-weight: 700; color: #e8718d; }
|
||||
.stat-label { font-size: 12px; color: #999; }
|
||||
.stat-divider { width: 1px; background: #f0f0f0; }
|
||||
.menu-group { margin-bottom: 16px; }
|
||||
.menu-icon-wrap { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; margin-right: 10px; }
|
||||
.logout-wrap { margin-top: 24px; padding: 0 8px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user