feat: 新增预约页面及多项UI/逻辑更新

This commit is contained in:
MaDaLei 2026-04-14 16:56:05 +08:00
parent 06d2e48741
commit 6640f734bf
24 changed files with 2885 additions and 1700 deletions

View File

@ -20,6 +20,6 @@
"ignore": [], "ignore": [],
"include": [] "include": []
}, },
"appid": "wx4fe9ae1e083c1c4f", "appid": "wx8ca2dfa89af72edf",
"editorSetting": {} "editorSetting": {}
} }

View File

@ -41,7 +41,8 @@ page {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
background: #f5f5f5; background: #fafaf8;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #1a1a1a;
} }
</style> </style>

View File

@ -43,6 +43,9 @@ export const sendSms = (phone) => post('/sms/send', { phone })
// 登录 // 登录
export const login = (phone, code) => post('/user/login', { phone, code }) export const login = (phone, code) => post('/user/login', { phone, code })
/** 微信小程序getPhoneNumber 返回的 detail.code */
export const wxPhoneLogin = (phoneCode) => post('/user/wx-phone-login', { phoneCode })
// 注册老板 // 注册老板
export const registerBoss = (data) => post('/user/register-boss', data) export const registerBoss = (data) => post('/user/register-boss', data)
@ -52,6 +55,9 @@ export const registerStaff = (data) => post('/user/register-staff', data)
// 预约列表 // 预约列表
export const getAppointmentList = (userId, storeId) => get('/appointment/list', { userId, storeId }) export const getAppointmentList = (userId, storeId) => get('/appointment/list', { userId, storeId })
// 预约详情
export const getAppointmentDetail = (id) => get('/appointment/detail', { id })
// 创建预约 // 创建预约
export const createAppointment = (data) => post('/appointment/create', data) export const createAppointment = (data) => post('/appointment/create', data)

View File

@ -1,133 +1,181 @@
/* ============================================================ /* ============================================================
宠伴生活馆 - 全局样式 宠伴生活馆 设计系统 v2 · 简约 · 温暖 · 专业
============================================================ */ ============================================================ */
:root {
--c-bg: #fafaf8;
--c-surface: #ffffff;
--c-surface-alt: #f5f5f2;
--c-border: #ebebea;
--c-border-light: #f0f0ee;
--c-text: #1a1a1a;
--c-text-2: #666660;
--c-text-3: #999993;
--c-text-4: #bbbbb5;
--c-brand: #2db96d;
--c-brand-light: #eaf8f0;
--c-brand-dark: #1e9a55;
--c-orange: #f5913e;
--c-orange-light: #fef5ec;
--c-red: #e5534b;
--c-shadow-sm: 0 1px 3px rgba(0,0,0,0.04);
--c-shadow: 0 2px 8px rgba(0,0,0,0.05);
--c-shadow-lg: 0 8px 24px rgba(0,0,0,0.06);
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--font: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
page { page {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
background: linear-gradient(180deg, #f7f9fc 0%, #f3f6fa 100%); background: var(--c-bg);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, sans-serif; font-family: var(--font);
color: #1f2937; color: var(--c-text);
font-size: 15px; font-size: 15px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* ---- 输入框 ---- */ /* ---- 输入框 ---- */
.van-field { .van-field {
background: #ffffff !important; background: var(--c-surface-alt) !important;
border: 1px solid #dfe5ee !important; border: 1.5px solid transparent !important;
border-radius: 12px !important; border-radius: var(--radius) !important;
padding: 10px 14px !important; padding: 11px 14px !important;
font-size: 15px !important; font-size: 15px !important;
color: #1f2937 !important; color: var(--c-text) !important;
min-height: 44px !important; min-height: 44px !important;
width: 100% !important; width: 100% !important;
box-sizing: border-box !important; box-sizing: border-box !important;
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, background 0.2s;
} }
.van-field:focus { .van-field:focus {
border-color: #22c55e !important; border-color: var(--c-brand) !important;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.14) !important; background: var(--c-surface) !important;
} }
.van-field::placeholder { color: #9aa4b2 !important; } .van-field::placeholder { color: var(--c-text-4) !important; }
/* ---- 按钮 ---- */ /* ---- 按钮 ----
* 微信小程序原生 button 默认白底会盖住 class 背景且仅写 var(--c-brand) 在部分基础库下可能不生效
* 导致白底 + 白字此处用实色 + button 元素选择器强制覆盖 */
.van-button { .van-button {
border-radius: 12px !important; border-radius: var(--radius) !important;
font-size: 15px !important; font-size: 15px !important;
height: 44px !important; height: 46px !important;
line-height: 44px !important; line-height: 46px !important;
font-weight: 600 !important; font-weight: 600 !important;
display: inline-flex !important; display: inline-flex !important;
align-items: center !important; align-items: center !important;
justify-content: center !important; justify-content: center !important;
transition: opacity 0.15s, transform 0.15s;
box-sizing: border-box !important;
} }
.van-button--primary { .van-button:active { opacity: 0.85; transform: scale(0.98); }
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%) !important; .van-button--primary,
border: 1px solid #16a34a !important; button.van-button--primary {
color: #fff !important; background-color: #2db96d !important;
box-shadow: 0 8px 20px rgba(34, 197, 94, 0.28) !important; background: #2db96d !important;
} border: none !important;
.van-button--primary:active { transform: translateY(1px); } color: #ffffff !important;
.van-button--block { width: 100% !important; margin-top: 12px !important; }
.van-button--small {
height: 34px !important;
line-height: 34px !important;
font-size: 13px !important;
padding: 0 14px !important;
border-radius: 8px !important;
background: #07c160 !important;
border: 1px solid #07c160 !important;
color: #fff !important;
flex-shrink: 0;
}
.van-button[disabled] {
background: #c8e8d8 !important;
border-color: #c8e8d8 !important;
color: #fff !important;
box-shadow: none !important; box-shadow: none !important;
} }
button::after { border: none !important; } .van-button--block { width: 100% !important; margin-top: 12px !important; }
/* ---- 单元格组(卡片化) ---- */ /* 小号按钮仅尺寸;主色需同时加 .van-button--primary避免非主色按钮被设成白字 */
.van-button--small {
height: 34px !important;
min-height: 34px !important;
line-height: 34px !important;
font-size: 13px !important;
padding: 0 16px !important;
border-radius: var(--radius-sm) !important;
flex-shrink: 0;
}
.van-button--small.van-button--primary,
button.van-button--small.van-button--primary {
background-color: #2db96d !important;
background: #2db96d !important;
color: #ffffff !important;
border: none !important;
}
/* 小号默认(取消等):浅底深字,保证对比度 */
.van-button--small:not(.van-button--primary),
button.van-button--small:not(.van-button--primary) {
background-color: #f5f5f2 !important;
background: #f5f5f2 !important;
color: #1a1a1a !important;
border: 1px solid #ebebea !important;
}
.van-button[disabled] {
background: var(--c-border) !important;
color: var(--c-text-4) !important;
box-shadow: none !important;
}
button::after {
border: none !important;
}
/* ---- 单元格组 ---- */
.van-cell-group { .van-cell-group {
background: #fff !important; background: var(--c-surface) !important;
border-radius: 18px !important; border-radius: var(--radius-lg) !important;
overflow: hidden; overflow: hidden;
margin: 0 !important; margin: 0 !important;
border: 1px solid #e7edf5; border: none;
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.04); box-shadow: var(--c-shadow-sm);
} }
.van-cell { .van-cell {
padding: 14px 16px !important; padding: 14px 16px !important;
background: #fff !important; background: var(--c-surface) !important;
font-size: 15px !important; font-size: 15px !important;
min-height: 48px !important; min-height: 48px !important;
} }
.van-cell::after { .van-cell::after {
left: 16px !important; left: 16px !important;
right: 16px !important; right: 16px !important;
border-color: #eef2f7 !important; border-color: var(--c-border-light) !important;
} }
.van-cell--link:active { background: #fafafa !important; } .van-cell--link:active { background: var(--c-surface-alt) !important; }
.van-cell__title { color: #111827 !important; font-size: 15px !important; } .van-cell__title { color: var(--c-text) !important; font-size: 15px !important; }
.van-cell__value { color: #6b7280 !important; font-size: 14px !important; } .van-cell__value { color: var(--c-text-2) !important; font-size: 14px !important; }
.van-cell__arrow { color: #c3ccd8 !important; font-size: 16px !important; margin-left: 4px; }
.van-cell__icon { margin-right: 10px !important; }
/* ---- 标签 ---- */ /* ---- 标签 ---- */
.van-tag { .van-tag {
border-radius: 6px !important; border-radius: 6px !important;
font-size: 12px !important; font-size: 12px !important;
padding: 2px 8px !important; padding: 2px 8px !important;
font-weight: 500;
} }
.van-tag--warning { background: #fff3e8 !important; color: #ff6b35 !important; } .van-tag--warning { background: var(--c-orange-light) !important; color: var(--c-orange) !important; }
.van-tag--primary { background: #e8f7ef !important; color: #07c160 !important; } .van-tag--primary { background: var(--c-brand-light) !important; color: var(--c-brand) !important; }
.van-tag--success { background: #f0f0f0 !important; color: #888 !important; } .van-tag--success { background: var(--c-surface-alt) !important; color: var(--c-text-3) !important; }
.van-tag--default { background: #f5f5f5 !important; color: #999 !important; } .van-tag--default { background: var(--c-surface-alt) !important; color: var(--c-text-4) !important; }
/* ---- 通用卡片容器 ---- */ /* ---- 卡片 ---- */
.card-section { .card-section {
background: #fff; background: var(--c-surface);
border-radius: 16px; border-radius: var(--radius-lg);
overflow: hidden; overflow: hidden;
margin: 0 16px; margin: 0 16px;
} }
/* ---- 通用弹窗 ---- */ /* ---- 弹窗 ---- */
.popup-mask { .popup-mask {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); background: rgba(0,0,0,0.35);
z-index: 200; z-index: 200;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
justify-content: center; justify-content: center;
} }
.popup-content { .popup-content {
background: #fff; background: var(--c-surface);
border-radius: 20px 20px 0 0; border-radius: var(--radius-xl) var(--radius-xl) 0 0;
width: 100%; width: 100%;
max-height: 85vh; max-height: 85vh;
display: flex; display: flex;
@ -137,80 +185,57 @@ button::after { border: none !important; }
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 18px 20px 14px; padding: 20px 20px 16px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0; flex-shrink: 0;
} }
.popup-title { font-size: 17px; font-weight: 600; color: #1a1a1a; } .popup-title { font-size: 17px; font-weight: 700; color: var(--c-text); }
.popup-close { font-size: 20px; color: #bbb; } .popup-close { font-size: 20px; color: var(--c-text-4); }
.popup-body { padding: 16px 20px; overflow-y: auto; flex: 1; } .popup-body { padding: 0 20px 20px; overflow-y: auto; flex: 1; }
.popup-footer { padding: 8px 20px max(env(safe-area-inset-bottom), 20px); flex-shrink: 0; } .popup-footer { padding: 12px 20px max(env(safe-area-inset-bottom), 20px); flex-shrink: 0; border-top: 1px solid var(--c-border-light); }
/* ---- 字段标签 ---- */ /* ---- 字段标签 ---- */
.field-label { .field-label {
font-size: 14px; font-size: 13px;
color: #666; color: var(--c-text-2);
margin-bottom: 8px; margin-bottom: 6px;
margin-top: 14px; margin-top: 14px;
font-weight: 500; font-weight: 600;
letter-spacing: 0.2px;
} }
.field-label:first-child { margin-top: 0; } .field-label:first-child { margin-top: 0; }
/* ---- 表单卡片(用于登录/注册页) ---- */ /* ---- 表单卡片 ---- */
.form-card { .form-card {
background: #fff; background: var(--c-surface);
border-radius: 22px; border-radius: var(--radius-xl);
padding: 28px 24px; padding: 28px 24px;
border: 1px solid #e8edf4; box-shadow: var(--c-shadow);
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.12); }
.form-card > input.van-field { display: block; }
.form-card > input.van-field + input.van-field,
.form-card > input.van-field + .sms-field {
margin-top: 12px;
} }
/* ---- 主色调 ---- */ /* ---- Tabs ---- */
:root {
--space-8: 8px;
--space-12: 12px;
--space-16: 16px;
--space-24: 24px;
--pet-green: #16a34a;
--pet-green-light: #e8f7ef;
--pet-orange: #f97316;
--pet-orange-light: #fff7ed;
--pet-bg: #f5f7fb;
--pet-text: #111827;
--pet-subtext: #6b7280;
--pet-border: #e5ebf3;
--pet-card-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
}
/* Dark mode tokens (预埋,按需启用) */
@media (prefers-color-scheme: dark) {
:root {
--pet-bg: #0f172a;
--pet-text: #e5e7eb;
--pet-subtext: #94a3b8;
--pet-border: #334155;
}
}
/* ---- 通用 TabBar 样式(顶部状态栏) ---- */
.van-tabs { .van-tabs {
display: flex; display: flex;
background: #fff; background: var(--c-surface);
border-bottom: 1px solid #f0f0f0;
padding: 0 16px; padding: 0 16px;
} }
.van-tabs__tab { .van-tabs__tab {
flex: 1; flex: 1;
text-align: center; text-align: center;
padding: 12px 0; padding: 13px 0;
font-size: 15px; font-size: 14px;
color: #999; color: var(--c-text-3);
cursor: pointer; cursor: pointer;
position: relative; position: relative;
font-weight: 500;
} }
.van-tabs__tab.active { .van-tabs__tab.active {
color: #07c160; color: var(--c-text);
font-weight: 600; font-weight: 700;
} }
.van-tabs__tab.active::after { .van-tabs__tab.active::after {
content: ''; content: '';
@ -218,103 +243,84 @@ button::after { border: none !important; }
bottom: 0; bottom: 0;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 40px; width: 20px;
height: 3px; height: 2.5px;
background: #07c160; background: var(--c-brand);
border-radius: 2px; border-radius: 2px;
} }
/* ---- 通用页面头部 ---- */ /* ---- 导航头 ---- */
[class*='-nav'] { [class*='-nav'] {
border-bottom-left-radius: 16px; border-bottom-left-radius: var(--radius-lg);
border-bottom-right-radius: 16px; border-bottom-right-radius: var(--radius-lg);
box-sizing: border-box; box-sizing: border-box;
} }
.nav-title { .nav-title {
letter-spacing: 0.3px; font-size: 17px;
font-weight: 700;
color: #fff;
letter-spacing: 0.2px;
} }
.nav-back { .nav-back {
font-weight: 700; width: 32px;
width: 28px; height: 32px;
height: 28px; border-radius: 10px;
border-radius: 8px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: transform 0.16s ease, background-color 0.16s ease, opacity 0.16s ease; transition: opacity 0.15s;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.nav-back:active { .nav-back:active { opacity: 0.7; }
transform: scale(0.94); .nav-gradient {
opacity: 0.9; background-color: #2db96d !important;
background: rgba(255, 255, 255, 0.15); background: #2db96d !important;
} }
/* ---- 空状态统一 ---- */ /* ---- 空状态 ---- */
.empty { .empty {
margin: 24px 16px; margin: 24px 16px;
padding: 22px 16px; padding: 32px 16px;
background: #ffffff; background: var(--c-surface);
border: 1px solid var(--pet-border); border-radius: var(--radius-lg);
border-radius: 14px; color: var(--c-text-3);
color: var(--pet-subtext);
text-align: center; text-align: center;
font-size: 14px;
} }
/* ---- 页面布局工具类 ---- */ /* ---- 页面布局 ---- */
.page-shell { .page-shell {
min-height: 100vh; min-height: 100vh;
background: var(--pet-bg); background: var(--c-bg);
padding-bottom: 120rpx; padding-bottom: 120rpx;
} }
.page-section { .page-section { margin: 16px; }
margin: var(--space-16); .section-gap { margin-top: 12px; }
}
.section-gap {
margin-top: var(--space-12);
}
/* ---- 常用内容间距 ---- */ /* ---- 文本 ---- */
.module-title { .module-title {
font-size: 12px; font-size: 12px;
color: #94a3b8; color: var(--c-text-3);
font-weight: 700; font-weight: 700;
margin-bottom: var(--space-8); text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
} }
.form-field-wrap { .form-field-wrap {
padding: 6px var(--space-16) var(--space-12); padding: 6px 16px 12px;
} }
.text-title { font-size: 18px; font-weight: 700; color: var(--c-text); }
.text-body { font-size: 14px; color: var(--c-text); line-height: 1.6; }
.text-sub { font-size: 12px; color: var(--c-text-3); }
/* ---- 文本层级工具类 ---- */ /* ---- 微交互 ---- */
.text-title {
font-size: 18px;
font-weight: 700;
color: var(--pet-text);
}
.text-body {
font-size: 14px;
color: var(--pet-text);
line-height: 1.5;
}
.text-sub {
font-size: 12px;
color: var(--pet-subtext);
}
/* ---- 导航头工具类 ---- */
.nav-gradient {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
box-shadow: 0 8px 20px rgba(34, 197, 94, 0.25);
}
/* ---- 微交互:统一点击反馈 ---- */
.van-button, .van-button,
.order-item, .order-item,
.gallery-item, .gallery-item,
.menu-card, .menu-card,
.card-section, .card-section,
.user-card { .user-card {
transition: transform 0.16s ease, box-shadow 0.2s ease, opacity 0.16s ease; transition: transform 0.12s ease, opacity 0.12s ease;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.van-button:active, .van-button:active,
@ -323,6 +329,6 @@ button::after { border: none !important; }
.menu-card:active, .menu-card:active,
.card-section:active, .card-section:active,
.user-card:active { .user-card:active {
transform: translateY(1px) scale(0.995); transform: scale(0.98);
opacity: 0.97; opacity: 0.9;
} }

View File

@ -1,17 +1,6 @@
<template> <template>
<view class="app-icon" :style="{ width: `${size}px`, height: `${size}px` }"> <!-- 小程序对内联 <svg> 支持差易显示为灰块 data URI 背景图统一绘制 -->
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <view class="app-icon" :style="iconWrapStyle" />
<path
v-for="(d, idx) in iconPaths"
:key="`${name}-${idx}`"
:d="d"
:stroke="color"
stroke-width="1.9"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</view>
</template> </template>
<script setup> <script setup>
@ -54,17 +43,45 @@ const ICON_MAP = {
] ]
} }
const iconPaths = computed(() => ICON_MAP[props.name] || ICON_MAP.profile) function escapeXml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
function buildSvgDataUri(paths, strokeColor) {
const stroke = escapeXml(strokeColor || '#475569')
const pathEls = paths
.map(
(d) =>
`<path d="${escapeXml(d)}" fill="none" stroke="${stroke}" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>`
)
.join('')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">${pathEls}</svg>`
return `url("data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}")`
}
const iconWrapStyle = computed(() => {
const paths = ICON_MAP[props.name] || ICON_MAP.profile
return {
width: `${props.size}px`,
height: `${props.size}px`,
backgroundImage: buildSvgDataUri(paths, props.color),
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
backgroundSize: '100% 100%'
}
})
</script> </script>
<style scoped> <style scoped>
.app-icon { .app-icon {
display: inline-flex; display: inline-flex;
flex-shrink: 0;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} vertical-align: middle;
.app-icon svg {
width: 100%;
height: 100%;
} }
</style> </style>

View File

@ -1,22 +1,16 @@
<template> <template>
<view class="tabbar"> <view class="tabbar">
<view class="tabbar-item" :class="{ active: props.currentPage === 'home' }" @click="switchTab('home')"> <view
<view class="tabbar-icon"> v-for="tab in tabs"
<AppIcon name="home" :size="16" :color="props.currentPage === 'home' ? '#16a34a' : '#64748b'" /> :key="tab.key"
class="tabbar-item"
:class="{ active: props.currentPage === tab.key }"
@click="switchTab(tab.key)"
>
<view class="tabbar-icon-wrap">
<AppIcon :name="tab.icon" :size="20" :color="props.currentPage === tab.key ? '#2db96d' : '#999993'" />
</view> </view>
<text class="tabbar-text">服务预约</text> <text class="tabbar-label">{{ tab.label }}</text>
</view>
<view class="tabbar-item" :class="{ active: props.currentPage === 'report' }" @click="switchTab('report')">
<view class="tabbar-icon">
<AppIcon name="report" :size="16" :color="props.currentPage === 'report' ? '#16a34a' : '#64748b'" />
</view>
<text class="tabbar-text">洗美报告</text>
</view>
<view class="tabbar-item" :class="{ active: props.currentPage === 'mine' }" @click="switchTab('mine')">
<view class="tabbar-icon">
<AppIcon name="mine" :size="16" :color="props.currentPage === 'mine' ? '#16a34a' : '#64748b'" />
</view>
<text class="tabbar-text">我的</text>
</view> </view>
</view> </view>
</template> </template>
@ -24,11 +18,14 @@
<script setup> <script setup>
import AppIcon from './AppIcon.vue' import AppIcon from './AppIcon.vue'
const tabs = [
{ key: 'home', icon: 'home', label: '预约' },
{ key: 'report', icon: 'report', label: '报告' },
{ key: 'mine', icon: 'mine', label: '我的' }
]
const props = defineProps({ const props = defineProps({
currentPage: { currentPage: { type: String, default: 'home' }
type: String,
default: 'home'
}
}) })
const emit = defineEmits(['change']) const emit = defineEmits(['change'])
@ -45,13 +42,13 @@ function switchTab(pageName) {
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
height: calc(104rpx + env(safe-area-inset-bottom)); height: calc(56px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
background: rgba(255, 255, 255, 0.96); background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px); backdrop-filter: saturate(180%) blur(16px);
border-top: 1px solid #e8edf4; -webkit-backdrop-filter: saturate(180%) blur(16px);
border-top: 0.5px solid rgba(0, 0, 0, 0.06);
display: flex; display: flex;
flex-direction: row;
z-index: 999; z-index: 999;
} }
.tabbar-item { .tabbar-item {
@ -60,29 +57,23 @@ function switchTab(pageName) {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 100%; gap: 2px;
color: #94a3b8; color: #999993;
transition: color 0.2s ease; transition: color 0.2s;
} }
.tabbar-item.active { .tabbar-item.active {
color: #16a34a; color: #2db96d;
} }
.tabbar-icon { .tabbar-icon-wrap {
width: 40rpx; width: 24px;
height: 40rpx; height: 24px;
border-radius: 11rpx;
background: #eaf0f7;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 4rpx;
} }
.tabbar-item.active .tabbar-icon { .tabbar-label {
background: #dcfce7; font-size: 10px;
}
.tabbar-text {
font-size: 22rpx;
line-height: 1;
font-weight: 600; font-weight: 600;
letter-spacing: 0.3px;
} }
</style> </style>

View File

@ -1,12 +1,13 @@
import { navigateTo } from '../utils/globalState.js' import { openAppPage } from '../utils/globalState.js'
export const useNavigator = () => { export const useNavigator = () => {
const goPage = (page) => { const goPage = (page) => {
navigateTo(page) openAppPage(page)
} }
return { return {
goPage, goPage,
navigateTo navigateTo: openAppPage,
openAppPage
} }
} }

View File

@ -1,17 +1,20 @@
{ {
"name": "宠伴生活馆", "name": "宠伴生活馆",
"appid": "", "appid": "wx8ca2dfa89af72edf",
"description": "宠物服务小程序", "description": "宠物服务小程序",
"versionName": "1.0.0", "versionName": "1.0.0",
"versionCode": "100", "versionCode": "100",
"transformPx": false, "transformPx": false,
"mp-weixin": { "mp-weixin": {
"appid": "", "appid": "wx8ca2dfa89af72edf",
"libVersion": "3.5.0", "libVersion": "3.5.0",
"lazyCodeLoading": "requiredComponents", "lazyCodeLoading": "requiredComponents",
"permission": { "permission": {
"scope.userLocation": { "scope.userLocation": {
"desc": "用于在地图上选择门店位置" "desc": "用于在地图上选择门店位置"
},
"scope.writePhotosAlbum": {
"desc": "用于将服务报告海报保存到手机相册"
} }
}, },
"requiredPrivateInfos": [ "requiredPrivateInfos": [

View File

@ -2,6 +2,7 @@
"pages": [ "pages": [
{"path": "pages/login/Login"}, {"path": "pages/login/Login"},
{"path": "pages/home/Home"}, {"path": "pages/home/Home"},
{"path": "pages/appointment/AppointmentDetail"},
{"path": "pages/report/Report"}, {"path": "pages/report/Report"},
{"path": "pages/mine/Mine"}, {"path": "pages/mine/Mine"},
{"path": "pages/mine/Staff"}, {"path": "pages/mine/Staff"},
@ -16,7 +17,7 @@
"navigationStyle": "custom", "navigationStyle": "custom",
"navigationBarTextStyle": "white", "navigationBarTextStyle": "white",
"navigationBarTitleText": "宠伴生活馆", "navigationBarTitleText": "宠伴生活馆",
"navigationBarBackgroundColor": "#07c160", "navigationBarBackgroundColor": "#2db96d",
"backgroundColor": "#F8F8F8" "backgroundColor": "#fafaf8"
} }
} }

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="page-shell starter-page"> <div class="page-shell starter-page">
<view class="starter-nav nav-gradient" :style="navSafeStyle"> <view class="starter-nav nav-gradient" :style="navSafeStyle">
<text class="nav-back" @click="navigateTo('mine')"><AppIcon name="back" :size="18" color="#ffffff" /></text> <view class="nav-back" @click="navigateTo('mine')"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">页面标题</text> <text class="nav-title">页面标题</text>
<view class="nav-placeholder"></view> <view class="nav-placeholder"></view>
</view> </view>

View File

@ -0,0 +1,202 @@
<template>
<div class="page-shell detail-page">
<view class="detail-nav nav-gradient" :style="navSafeStyle">
<view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">预约详情</text>
<view class="nav-placeholder"></view>
</view>
<view v-if="loading" class="page-section loading-hint"><text>加载中</text></view>
<view v-else-if="detail" class="page-section detail-body">
<view class="detail-card">
<view class="detail-row head-row">
<view class="pet-line">
<span class="pet-emoji"><AppIcon name="profile" :size="14" /></span>
<text class="pet-name">{{ detail.petName || '—' }}</text>
</view>
<view :class="tagClass(detail.status)">{{ statusText }}</view>
</view>
<view class="detail-row">
<text class="label">服务类型</text>
<text class="value">{{ detail.serviceType || '—' }}</text>
</view>
<view class="detail-row">
<text class="label">宠物类型</text>
<text class="value">{{ detail.petType || '—' }}</text>
</view>
<view class="detail-row">
<text class="label">预约时间</text>
<text class="value time">{{ timeDisplay }}</text>
</view>
<view v-if="detail.remark" class="detail-row block">
<text class="label">备注</text>
<text class="value remark">{{ detail.remark }}</text>
</view>
<view v-if="detail.assignedUserId" class="detail-row">
<text class="label">服务技师</text>
<text class="value">#{{ detail.assignedUserId }}</text>
</view>
<view class="detail-meta">
<text v-if="detail.id">预约编号 {{ detail.id }}</text>
</view>
</view>
</view>
<view v-else class="page-section empty"><text>未找到该预约</text></view>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import AppIcon from '../../components/AppIcon.vue'
import { getAppointmentDetail } from '../../api/index.js'
import { formatDateTimeCN } from '../../utils/datetime.js'
import { getAppointmentStatusText, getAppointmentTagClass } from '../../utils/appointment.js'
const loading = ref(true)
const detail = ref(null)
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 statusText = computed(() => (detail.value ? getAppointmentStatusText(detail.value.status) : ''))
const timeDisplay = computed(() => formatDateTimeCN(detail.value?.appointmentTime))
const tagClass = (status) => getAppointmentTagClass(status)
const goBack = () => uni.navigateBack()
const load = async (id) => {
if (!id) {
loading.value = false
return
}
loading.value = true
const res = await getAppointmentDetail(Number(id))
loading.value = false
if (res.code === 200 && res.data) {
detail.value = res.data
} else {
detail.value = null
uni.showToast({ title: res.message || '加载失败', icon: 'none' })
}
}
onLoad((options) => {
load(options.id)
})
</script>
<style scoped>
.detail-page { padding-bottom: 32px; min-height: 100vh; box-sizing: border-box; }
.detail-nav {
padding: 0 16px 10px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
}
.nav-back { font-size: 20px; color: #fff; display: inline-flex; align-items: center; }
.nav-title { font-size: 18px; font-weight: 700; color: #fff; }
.nav-placeholder { width: 32px; }
.loading-hint, .empty { text-align: center; padding: 48px 16px; color: #94a3b8; font-size: 14px; }
.detail-body { margin-top: 12px; padding: 0 16px; }
.detail-card {
background: #fff;
border-radius: 14px;
padding: 16px;
border: 1px solid #e8edf4;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
}
.head-row {
margin-bottom: 14px;
padding-bottom: 12px;
border-bottom: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
align-items: center;
}
.pet-line {
display: flex;
align-items: center;
gap: 8px;
}
.pet-emoji {
width: 20px;
height: 20px;
border-radius: 6px;
background: #eef2f7;
display: inline-flex;
align-items: center;
justify-content: center;
}
.pet-name { font-size: 17px; font-weight: 700; color: #1f2937; }
.detail-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
font-size: 14px;
}
.detail-row.block { flex-direction: column; }
.label { color: #64748b; flex-shrink: 0; }
.value { color: #1f2937; text-align: right; flex: 1; }
.value.time { font-size: 13px; line-height: 1.5; }
.value.remark { text-align: left; line-height: 1.5; white-space: pre-wrap; }
.detail-meta { margin-top: 8px; padding-top: 12px; border-top: 1px solid #f1f5f9; font-size: 12px; color: #94a3b8; }
.tag-warning {
display: inline-flex;
align-items: center;
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: #fff3e8;
color: #ff6b35;
font-weight: 500;
}
.tag-primary {
display: inline-flex;
align-items: center;
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: #e8f7ef;
color: #2db96d;
font-weight: 500;
}
.tag-success {
display: inline-flex;
align-items: center;
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: #f0f0f0;
color: #888;
font-weight: 500;
}
.tag-default {
display: inline-flex;
align-items: center;
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: #f5f5f5;
color: #999;
font-weight: 500;
}
</style>

View File

@ -1,111 +1,108 @@
<template> <template>
<div 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 nav-gradient" :style="navSafeStyle"> <view class="home-nav" :style="navSafeStyle">
<text class="nav-title">宠伴生活馆</text> <text class="c-nav-title">{{ storeInfo.name || '宠伴生活馆' }}</text>
</view>
<view class="c-header">
<view class="c-title">{{ storeInfo.name || '宠伴生活馆' }}</view>
<view class="c-sub">宠物服务让爱更专业</view>
</view> </view>
<view class="c-booking-card"> <view class="c-hero">
<view class="c-booking-title">预约服务</view> <text class="c-hero-title">预约宠物服务</text>
<view class="field-label">宠物名字</view> <text class="c-hero-sub">在线预约专业服务</text>
<input v-model="newAppt.petName" class="van-field" placeholder="请输入宠物名字" /> </view>
<view class="field-label">宠物类型</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"> <picker mode="selector" :range="petTypes" range-key="label" @change="e => newAppt.petType = petTypes[e.detail.value].value">
<view class="van-field picker-field">{{ newAppt.petType || '请选择' }}</view> <view class="c-input c-picker">
<text :class="{ 'c-placeholder': !newAppt.petType }">{{ newAppt.petType || '请选择' }}</text>
<text class="c-arrow"></text>
</view>
</picker> </picker>
</view>
<view class="field-label">服务类型</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"> <picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value">
<view class="van-field picker-field">{{ newAppt.serviceType || '请选择' }}</view> <view class="c-input c-picker">
<text :class="{ 'c-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
<text class="c-arrow"></text>
</view>
</picker> </picker>
</view>
<view class="field-label">预约日期</view> <view class="c-field">
<picker mode="date" :value="appointmentDate" @change="onAppointmentDateChange"> <text class="c-label">预约时间</text>
<view class="van-field picker-field">{{ appointmentDate || '请选择日期' }}</view> <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>
<picker mode="time" :value="appointmentTime" @change="onAppointmentTimeOnlyChange" class="c-time-half">
<view class="field-label">预约时间</view> <view class="c-input c-picker">
<picker mode="time" :value="appointmentTime" @change="onAppointmentTimeOnlyChange"> <text :class="{ 'c-placeholder': !appointmentTime }">{{ appointmentTime || '时间' }}</text>
<view class="van-field picker-field">{{ appointmentTime || '请选择时间' }}</view> </view>
</picker> </picker>
</view>
<view class="field-label">备注可选</view> </view>
<input v-model="newAppt.remark" class="van-field" placeholder="可选" /> <view class="c-field">
<text class="c-label">备注可选</text>
<button class="btn-block" style="margin-top: 24px;" :loading="creatingAppt" @click="confirmNewAppt">提交预约</button> <input v-model="newAppt.remark" class="c-input" placeholder="可选" />
</view>
<button class="c-submit" :loading="creatingAppt" @click="confirmNewAppt">提交预约</button>
</view> </view>
</view> </view>
<!-- B端视角订单管理 --> <!-- B端看板新建预约改为右下角悬浮按钮避免与小程序胶囊重叠 -->
<view v-else> <view v-else>
<!-- 顶部导航 --> <view class="home-nav home-nav--center nav-gradient" :style="navSafeStyle">
<view class="home-nav nav-gradient" :style="navSafeStyle"> <text class="nav-title">{{ storeInfo.name || '宠伴生活馆' }}</text>
<text class="nav-title">宠伴生活馆</text>
<button class="btn-primary" @click="showNewAppt = true">新建预约</button>
</view>
<view class="page-section home-hero">
<view class="hero-title">今日服务看板</view>
<view class="hero-sub">待确认进行中已完成实时同步支持一键开始服务与快速填报</view>
</view> </view>
<!-- 状态 Tab --> <!-- 状态 Tab -->
<view class="page-section custom-tabs"> <view class="tabs-wrap">
<view <view
v-for="tab in statusTabs" v-for="tab in statusTabs"
:key="tab.name" :key="tab.name"
:class="['tab-item', { active: currentStatus === tab.name }]" :class="['tab', { 'tab-active': currentStatus === tab.name }]"
@click="currentStatus = tab.name" @click="currentStatus = tab.name"
> >
{{ tab.title }} <text class="tab-text">{{ tab.title }}</text>
<span v-if="tab.name === 'new' && newApptCount > 0" class="tab-badge">{{ newApptCount }}</span> <view v-if="tab.name === 'new' && newApptCount > 0" class="tab-dot"></view>
</view> </view>
</view> </view>
<div class="page-section list-content"> <!-- 列表 -->
<!-- 时间轴 --> <view class="order-list">
<div v-if="filteredOrders.length > 0" class="timeline"> <view v-for="item in filteredOrders" :key="item.id" class="order-card" @click="goAppointmentDetail(item)">
<div v-for="item in filteredOrders" :key="item.id" class="timeline-item"> <view class="order-top">
<div class="timeline-dot" :class="`dot-${item.status}`"> <view class="order-pet">
<span class="dot-inner"></span> <text class="order-pet-name">{{ item.petName }}</text>
</div> <text class="order-svc">{{ item.serviceType }}</text>
<div class="timeline-line"></div> </view>
<div class="order-card" :class="`card-${item.status}`"> <view :class="['order-status', `status-${item.status}`]">{{ item.statusText }}</view>
<div class="card-header"> </view>
<div class="pet-info"> <view class="order-time">{{ item.time }}</view>
<span class="pet-emoji"><AppIcon name="profile" :size="14" /></span> <view class="order-actions">
<span class="pet-name">{{ item.petName }}</span> <view v-if="item.status === 'new'" class="order-btns">
</div> <button class="btn-sm btn-fill" @click.stop="startService(item)">开始服务</button>
<view :class="tagClass(item.status)">{{ item.statusText }}</view> <button class="btn-sm btn-outline" @click.stop="cancelService(item)">取消</button>
</div> </view>
<div class="service-row"> <button v-else-if="item.status === 'doing'" class="btn-sm btn-fill" @click.stop="goReport(item)">填写报告</button>
<span class="service-tag" :class="`tag-${item.status}`">{{ item.serviceType }}</span> </view>
</div> </view>
<div class="card-body">
<div class="card-time"><AppIcon name="orders" :size="13" />{{ item.time }}</div>
</div>
<div class="card-footer">
<div v-if="item.status === 'new'" class="action-btns">
<button class="btn-primary" @click="startService(item)">开始服务</button>
<button class="btn-secondary" @click="cancelService(item)">取消</button>
</div>
<button v-else-if="item.status === 'doing'" class="btn-secondary" @click="goReport(item)">填写报告</button>
<span v-else class="done-label">{{ item.status === 'cancel' ? '已取消' : '已完成' }}</span>
</div>
</div>
</div>
</div>
<view v-if="filteredOrders.length === 0" class="empty"><text>暂无数据</text></view> <view v-if="filteredOrders.length === 0" class="empty"><text>暂无数据</text></view>
</div> </view>
<!-- 新建预约弹窗自定义 popup --> <view class="fab-appt" @click="showNewAppt = true" hover-class="fab-appt--hover">
<text class="fab-appt-icon">+</text>
</view>
<!-- 新建弹窗 -->
<view v-if="showNewAppt" class="popup-mask" @click="showNewAppt = false"> <view v-if="showNewAppt" class="popup-mask" @click="showNewAppt = false">
<view class="popup-content" @click.stop> <view class="popup-content" @click.stop>
<view class="popup-header"> <view class="popup-header">
@ -113,48 +110,60 @@
<text class="popup-close" @click="showNewAppt = false"></text> <text class="popup-close" @click="showNewAppt = false"></text>
</view> </view>
<view class="popup-body"> <view class="popup-body">
<view class="popup-desc">填写基础信息后即可创建预约后续可在列表中继续处理</view> <view class="c-field">
<view class="field-label">宠物名字</view> <text class="c-label">宠物名字</text>
<input v-model="newAppt.petName" class="van-field" placeholder="请输入" /> <input v-model="newAppt.petName" class="c-input" placeholder="请输入" />
<view class="field-label">宠物类型</view> </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"> <picker mode="selector" :range="petTypes" range-key="label" @change="e => newAppt.petType = petTypes[e.detail.value].value">
<view class="van-field picker-field"> <view class="c-input c-picker">
{{ newAppt.petType || '请选择' }} <text :class="{ 'c-placeholder': !newAppt.petType }">{{ newAppt.petType || '请选择' }}</text>
<text class="c-arrow"></text>
</view> </view>
</picker> </picker>
<view class="field-label">服务类型</view> </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"> <picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value">
<view class="van-field picker-field"> <view class="c-input c-picker">
{{ newAppt.serviceType || '请选择' }} <text :class="{ 'c-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
<text class="c-arrow"></text>
</view> </view>
</picker> </picker>
<view class="field-label">预约日期</view> </view>
<picker mode="date" :value="appointmentDate" @change="onAppointmentDateChange"> <view class="c-field">
<view class="van-field picker-field"> <text class="c-label">预约时间</text>
{{ appointmentDate || '请选择日期' }} <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> </view>
</picker> </picker>
<view class="field-label">预约时间</view> <picker mode="time" :value="appointmentTime" @change="onAppointmentTimeOnlyChange" class="c-time-half">
<picker mode="time" :value="appointmentTime" @change="onAppointmentTimeOnlyChange"> <view class="c-input c-picker">
<view class="van-field picker-field"> <text :class="{ 'c-placeholder': !appointmentTime }">{{ appointmentTime || '时间' }}</text>
{{ appointmentTime || '请选择时间' }}
</view> </view>
</picker> </picker>
<view class="field-label">备注可选</view> </view>
<textarea v-model="newAppt.remark" class="van-field remark-textarea" placeholder="可选" /> </view>
<view class="c-field">
<text class="c-label">备注可选</text>
<textarea v-model="newAppt.remark" class="c-input c-textarea" placeholder="可选" />
</view>
</view> </view>
<view class="popup-footer"> <view class="popup-footer">
<view class="popup-actions"> <view class="popup-actions">
<button class="btn-ghost" @click="showNewAppt = false">取消</button> <button class="btn-sm btn-outline" style="flex:0 0 80px" @click="showNewAppt = false">取消</button>
<button class="btn-block" :loading="creatingAppt" @click="confirmNewAppt">确认创建</button> <button class="c-submit" style="flex:1;margin:0" :loading="creatingAppt" @click="confirmNewAppt">确认创建</button>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</view> </view>
</div>
<TabBar current-page="home" @change="goPage" /> <TabBar current-page="home" @change="goPage" />
</view>
</template> </template>
<script setup> <script setup>
@ -167,11 +176,8 @@ 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, getAppointmentTagClass } from '../../utils/appointment.js'
const emit = defineEmits(['change-page'])
const userInfo = getUserSession() const userInfo = getUserSession()
const storeInfo = getStoreSession() const storeInfo = getStoreSession()
const currentUserId = userInfo.id
const { goPage, navigateTo } = useNavigator() const { goPage, navigateTo } = useNavigator()
const navSafeStyle = (() => { const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20 const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
@ -185,7 +191,6 @@ const navSafeStyle = (() => {
})() })()
const currentStatus = ref('doing') const currentStatus = ref('doing')
const statusTabs = [ const statusTabs = [
{ title: '待确认', name: 'new' }, { title: '待确认', name: 'new' },
{ title: '进行中', name: 'doing' }, { title: '进行中', name: 'doing' },
@ -222,10 +227,6 @@ const filteredOrders = computed(() => orders.value.filter(o => {
return true return true
})) }))
const tagClass = (status) => {
return getAppointmentTagClass(status)
}
const onAppointmentDateChange = (e) => { const onAppointmentDateChange = (e) => {
const date = e?.detail?.value || '' const date = e?.detail?.value || ''
const time = appointmentTime.value || '00:00' const time = appointmentTime.value || '00:00'
@ -235,10 +236,7 @@ const onAppointmentDateChange = (e) => {
const onAppointmentTimeOnlyChange = (e) => { const onAppointmentTimeOnlyChange = (e) => {
const time = e?.detail?.value || '' const time = e?.detail?.value || ''
const date = appointmentDate.value const date = appointmentDate.value
if (!date) { if (!date) { uni.showToast({ title: '请先选择日期', icon: 'none' }); return }
uni.showToast({ title: '请先选择日期', icon: 'none' })
return
}
newAppt.value.appointmentTime = `${date}T${time}` newAppt.value.appointmentTime = `${date}T${time}`
} }
@ -259,9 +257,7 @@ const fetchAppointments = async () => {
appointmentTime: appt.appointmentTime appointmentTime: appt.appointmentTime
})) }))
const newCount = orders.value.filter(o => o.status === 'new').length const newCount = orders.value.filter(o => o.status === 'new').length
if (newCount > 0) { if (newCount > 0) uni.showToast({ title: `${newCount} 个待确认`, icon: 'none' })
uni.showToast({ title: `${newCount} 个待确认预约`, icon: 'none' })
}
} }
} }
@ -269,21 +265,14 @@ const loadServiceTypes = async () => {
if (!storeInfo.id) return if (!storeInfo.id) return
const res = await getServiceTypeList(storeInfo.id) const res = await getServiceTypeList(storeInfo.id)
if (res.code === 200) { if (res.code === 200) {
serviceTypes.value = res.data.map(s => ({ serviceTypes.value = res.data.map(s => ({ label: s.name, value: s.name }))
label: s.name,
value: s.name
}))
} }
} }
const startService = async (item) => { const startService = async (item) => {
const res = await startAppointment(item.id, userInfo.id) const res = await startAppointment(item.id, userInfo.id)
if (res.code === 200) { if (res.code === 200) { uni.showToast({ title: '已开始服务', icon: 'success' }); fetchAppointments() }
uni.showToast({ title: '已开始服务', icon: 'success' }) else uni.showToast({ title: res.message || '操作失败', icon: 'none' })
fetchAppointments()
} else {
uni.showToast({ title: res.message || '操作失败', icon: 'none' })
}
} }
const cancelService = async (item) => { const cancelService = async (item) => {
@ -293,16 +282,17 @@ const cancelService = async (item) => {
success: async (res) => { success: async (res) => {
if (!res.confirm) return if (!res.confirm) return
const r = await cancelAppointment(item.id) const r = await cancelAppointment(item.id)
if (r.code === 200) { if (r.code === 200) { uni.showToast({ title: '已取消', icon: 'success' }); fetchAppointments() }
uni.showToast({ title: '已取消', icon: 'success' }) else uni.showToast({ title: r.message || '操作失败', icon: 'none' })
fetchAppointments()
} else {
uni.showToast({ title: r.message || '操作失败', icon: 'none' })
}
} }
}) })
} }
const goAppointmentDetail = (item) => {
if (!item?.id) return
uni.navigateTo({ url: `/pages/appointment/AppointmentDetail?id=${item.id}` })
}
const goReport = (item) => { const goReport = (item) => {
navigateTo('report') navigateTo('report')
uni.setStorageSync('petstore_report_prefill', JSON.stringify({ uni.setStorageSync('petstore_report_prefill', JSON.stringify({
@ -323,7 +313,7 @@ const confirmNewAppt = async () => {
const res = await createAppointment({ ...a, storeId: storeInfo.id, userId: userInfo.id }) const res = await createAppointment({ ...a, storeId: storeInfo.id, userId: userInfo.id })
creatingAppt.value = false creatingAppt.value = false
if (res.code === 200) { if (res.code === 200) {
uni.showToast({ title: '预约创建成功', icon: 'success' }) uni.showToast({ title: '预约成功', icon: 'success' })
newAppt.value = { petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' } newAppt.value = { petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' }
showNewAppt.value = false showNewAppt.value = false
fetchAppointments() fetchAppointments()
@ -332,16 +322,15 @@ const confirmNewAppt = async () => {
} }
} }
onMounted(() => { onMounted(() => { fetchAppointments(); loadServiceTypes() })
fetchAppointments()
loadServiceTypes()
})
</script> </script>
<style scoped> <style scoped>
.home-page { padding-bottom: 140rpx; } .home-page { padding-bottom: calc(76px + env(safe-area-inset-bottom)); }
/* 导航 */
.home-nav { .home-nav {
padding: 0 16px 10px; padding: 0 16px 12px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@ -349,288 +338,225 @@ onMounted(() => {
top: 0; top: 0;
z-index: 10; z-index: 10;
} }
.nav-title { font-size: 18px; font-weight: 700; color: #fff; letter-spacing: 0.5px; } .home-nav--center {
.home-hero { justify-content: center;
margin-top: 12px;
padding: 14px 16px;
border: 1px solid #dcefe3;
border-radius: 14px;
background: linear-gradient(135deg, #f3fff7 0%, #ecfbf3 100%);
} }
.hero-title { font-size: 16px; font-weight: 700; color: #166534; } .nav-title {
.hero-sub { margin-top: 4px; font-size: 12px; color: #4b5563; line-height: 1.45; }
.list-content { padding: 0; }
.c-header {
background: linear-gradient(135deg, #07c160 0%, #10b76f 100%);
padding: 20px 20px 40px;
text-align: center; text-align: center;
color: #fff; max-width: 70%;
border-radius: 0 0 24px 24px; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.c-title { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
.c-sub { font-size: 14px; opacity: 0.8; } /* B 端:新建预约悬浮按钮(须高于 TabBar 的 z-index:999否则会被底栏盖住下半截 */
.c-booking-card { .fab-appt {
position: fixed;
right: 20px;
/* 底栏总高约 56px + 安全区,再多留 20px 避免与底栏视觉重叠 */
bottom: calc(56px + constant(safe-area-inset-bottom) + 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-appt--hover {
opacity: 0.92;
transform: scale(0.96);
}
.fab-appt-icon {
color: #fff;
font-size: 32px;
font-weight: 300;
line-height: 1;
margin-top: -2px;
}
/* C端 */
.c-home { background: #fafaf8; min-height: 100vh; }
.c-nav-title {
font-size: 17px;
font-weight: 700;
color: #1a1a1a;
}
.c-hero {
padding: 24px 20px 32px;
}
.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; background: #fff;
border-radius: 16px; border-radius: 16px;
margin: -20px 16px 0;
padding: 24px 20px; padding: 24px 20px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
} }
.c-booking-title { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 20px; text-align: center; }
/* 按钮 */ .c-field { margin-bottom: 16px; }
.btn-primary { .c-field:last-of-type { margin-bottom: 0; }
display: inline-flex; .c-label {
align-items: center; display: block;
justify-content: center; font-size: 13px;
height: 34px;
padding: 0 16px;
font-size: 14px;
border-radius: 10px;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: #fff;
border: none;
cursor: pointer;
font-weight: 600; font-weight: 600;
box-shadow: 0 6px 14px rgba(34, 197, 94, 0.22); color: #666660;
margin-bottom: 6px;
} }
.btn-secondary { .c-input {
display: inline-flex; background: #f5f5f2;
align-items: center; border: 1.5px solid transparent;
justify-content: center; border-radius: 12px;
height: 34px; padding: 11px 14px;
padding: 0 14px;
font-size: 14px;
border-radius: 10px;
background: #f8fafc;
color: #334155;
border: 1px solid #dbe3ee;
cursor: pointer;
}
.btn-block {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 1;
height: 44px;
font-size: 16px;
border-radius: 8px;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
color: #fff;
border: none;
cursor: pointer;
}
.btn-ghost {
display: inline-flex;
align-items: center;
justify-content: center;
width: 96px;
height: 44px;
border-radius: 8px;
border: 1px solid #dbe3ee;
background: #fff;
color: #64748b;
font-size: 15px; font-size: 15px;
} color: #1a1a1a;
.remark-textarea {
min-height: 84px;
line-height: 1.45;
}
/* 自定义 Tabs */
.custom-tabs {
display: flex;
background: #fff;
border: 1px solid #e8edf4;
border-radius: 14px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
margin: 12px 0 0;
padding: 0 12px;
}
.tab-item {
flex: 1;
text-align: center;
padding: 12px 0;
font-size: 15px;
color: #999;
cursor: pointer;
position: relative;
}
.tab-item.active {
color: #16a34a;
font-weight: 600;
}
.tab-item.active::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 3px;
background: #16a34a;
border-radius: 2px;
}
.tab-badge {
position: absolute;
top: 6px;
right: 12px;
background: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 0 5px;
height: 16px;
line-height: 16px;
border-radius: 8px;
min-width: 8px;
text-align: center;
}
/* 标签 */
.tag-warning {
display: inline-flex;
align-items: center;
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: #fff3e8;
color: #ff6b35;
font-weight: 500;
}
.tag-primary {
display: inline-flex;
align-items: center;
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: #e8f7ef;
color: #07c160;
font-weight: 500;
}
.tag-success {
display: inline-flex;
align-items: center;
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: #f0f0f0;
color: #888;
font-weight: 500;
}
.tag-default {
display: inline-flex;
align-items: center;
font-size: 12px;
padding: 2px 8px;
border-radius: 10px;
background: #f5f5f5;
color: #999;
font-weight: 500;
}
/* 字段 */
.van-field {
display: flex;
align-items: center;
background: #f5f5f5;
border-radius: 8px;
padding: 8px 12px;
color: #333;
min-height: 40px;
font-size: 14px;
width: 100%; width: 100%;
box-sizing: border-box; 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-textarea { min-height: 80px; line-height: 1.5; }
/* 时间轴 */ .c-submit {
.timeline { padding: 4px 0; } width: 100%;
.timeline-item { height: 48px;
display: flex; border-radius: 14px;
align-items: flex-start; background: #1a1a1a;
margin-bottom: 4px; color: #fff;
position: relative; font-size: 16px;
font-weight: 700;
border: none;
margin-top: 20px;
letter-spacing: 0.3px;
} }
.timeline-dot { .c-submit:active { opacity: 0.85; }
/* B端 Tab */
.tabs-wrap {
display: flex; display: flex;
flex-direction: column; margin: 12px 16px 0;
align-items: center;
width: 24px;
flex-shrink: 0;
padding-top: 14px;
}
.dot-inner {
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff; background: #fff;
z-index: 1; border-radius: 12px;
padding: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
} }
.dot-new .dot-inner { background: #ff6b35; } .tab {
.dot-doing .dot-inner { background: #07c160; }
.dot-done .dot-inner { background: #c0c0c0; }
.dot-cancel .dot-inner { background: #d0d0d0; }
.timeline-line {
position: absolute;
left: 11px;
top: 28px;
bottom: -4px;
width: 2px;
background: #e8e0d8;
}
.timeline-item:last-child .timeline-line { display: none; }
/* 卡片 */
.order-card {
flex: 1; flex: 1;
text-align: center;
padding: 10px 0;
border-radius: 10px;
position: relative;
transition: background 0.2s;
}
.tab-active {
background: #1a1a1a;
}
.tab-text {
font-size: 14px;
font-weight: 600;
color: #999993;
}
.tab-active .tab-text { color: #fff; }
.tab-dot {
position: absolute;
top: 6px;
right: 16px;
width: 7px;
height: 7px;
background: #e5534b;
border-radius: 50%;
}
/* B端列表底部留白避免最后一条被悬浮钮挡住 */
.order-list { padding: 12px 16px 88px; }
.order-card {
background: #fff; background: #fff;
border-radius: 14px; border-radius: 14px;
padding: 14px 16px; padding: 16px;
margin-left: 12px; margin-bottom: 10px;
margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.03);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
border-left: 4px solid;
border: 1px solid #e8edf4;
} }
.card-new { border-left-color: #ff6b35; } .order-top {
.card-doing { border-left-color: #07c160; } display: flex;
.card-done { border-left-color: #c0c0c0; } justify-content: space-between;
.card-cancel { border-left-color: #d8d8d8; opacity: 0.7; } align-items: flex-start;
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } margin-bottom: 6px;
.pet-info { display: flex; align-items: center; gap: 8px; } }
.pet-emoji { .order-pet-name {
width: 20px; font-size: 16px;
height: 20px; font-weight: 700;
color: #1a1a1a;
}
.order-svc {
display: block;
font-size: 12px;
color: #999993;
margin-top: 2px;
}
.order-status {
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 6px; border-radius: 6px;
background: #eef2f7; flex-shrink: 0;
}
.status-new { background: #fef5ec; color: #f5913e; }
.status-doing { background: #eaf8f0; color: #2db96d; }
.status-done { background: #f5f5f2; color: #999993; }
.status-cancel { background: #f5f5f2; color: #bbbbb5; }
.order-time {
font-size: 13px;
color: #bbbbb5;
margin-bottom: 10px;
}
.order-actions { display: flex; justify-content: flex-end; }
.order-btns { display: flex; gap: 8px; }
.btn-sm {
height: 34px;
padding: 0 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: none;
} }
.pet-name { font-weight: 600; color: #333; font-size: 15px; } .btn-fill { background: #1a1a1a; color: #fff; }
.service-row { margin-bottom: 8px; } .btn-fill:active { opacity: 0.85; }
.service-tag { font-size: 12px; background: #fdf6f0; color: #8b5e3c; padding: 2px 8px; border-radius: 10px; font-weight: 500; } .btn-outline {
.tag-new { background: #fff3e8; color: #ff6b35; } background: transparent;
.tag-doing { background: #e8f7ef; color: #07c160; } color: #999993;
.tag-done { background: #f0f0f0; color: #888; } border: 1.5px solid #ebebea;
.card-body { margin-bottom: 10px; }
.card-time {
font-size: 13px;
color: #6b7280;
display: inline-flex;
align-items: center;
gap: 4px;
} }
.card-footer { display: flex; justify-content: flex-end; align-items: center; }
.action-btns { display: flex; gap: 8px; }
.done-label { font-size: 13px; color: #999; }
/* 弹窗 */ /* 弹窗 */
.popup-mask { .popup-mask {
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); background: rgba(0,0,0,0.35);
z-index: 3000; z-index: 3000;
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@ -639,57 +565,28 @@ onMounted(() => {
.popup-content { .popup-content {
background: #fff; background: #fff;
border-radius: 20px 20px 0 0; border-radius: 20px 20px 0 0;
width: 430px; width: 100%;
max-width: 100%; max-width: 430px;
height: calc(100vh - 24px); max-height: 90vh;
max-height: calc(100vh - 24px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
position: relative;
} }
.popup-header { .popup-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 16px 20px; padding: 20px 20px 16px;
border-bottom: 1px solid #f0f0f0;
} }
.popup-title { font-size: 16px; font-weight: 600; } .popup-title { font-size: 17px; font-weight: 700; }
.popup-close { font-size: 18px; color: #999; } .popup-close { font-size: 20px; color: #bbbbb5; }
.popup-body { padding: 16px 20px 120px; overflow-y: auto; flex: 1; } .popup-body { padding: 0 20px 20px; overflow-y: auto; flex: 1; }
.popup-footer { .popup-footer {
position: absolute; padding: 12px 20px max(env(safe-area-inset-bottom), 16px);
left: 0; border-top: 1px solid #f0f0ee;
right: 0;
bottom: 0;
background: #fff;
border-top: 1px solid #eef2f7;
padding: 10px 20px max(env(safe-area-inset-bottom), 16px);
z-index: 2;
}
.popup-desc {
font-size: 12px;
color: #6b7280;
line-height: 1.45;
margin-bottom: 10px;
} }
.popup-actions { .popup-actions {
display: flex; display: flex;
align-items: center;
gap: 10px; gap: 10px;
}
.field-label { font-size: 14px; color: #666; margin-bottom: 6px; margin-top: 12px; }
.picker-field {
display: flex;
align-items: center; align-items: center;
background: #f5f5f5;
border-radius: 8px;
padding: 8px 12px;
color: #333;
min-height: 40px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
} }
</style> </style>

View File

@ -1,89 +1,76 @@
<template> <template>
<div class="login-page"> <view class="login-page">
<!-- Logo区 --> <view class="login-brand">
<div class="login-logo"> <text class="brand-name">宠伴生活馆</text>
<div class="login-logo-text">宠伴生活馆</div> <text class="brand-sub">宠物服务让爱更专业</text>
<div class="login-logo-sub">宠物服务让爱更专业</div>
</div>
<!-- 老板注册成功 -->
<div v-if="bossRegistered" class="form-card">
<div class="success-icon">🎉</div>
<div class="success-title">入驻成功</div>
<div class="success-sub">欢迎加入宠伴生活馆</div>
<div class="success-info">
<div class="info-row"><span class="label">店铺名称</span><span class="value">{{ regResult?.store?.name }}</span></div>
<div class="info-row"><span class="label">您的账号</span><span class="value">{{ regResult?.user?.phone }}</span></div>
<div class="info-row"><span class="label">初始密码</span><span class="value">{{ regResult?.user?.password }}</span></div>
<div class="info-row"><span class="label">员工邀请码</span><span class="value">{{ regResult?.store?.inviteCode }}</span></div>
</div>
<button class="van-button van-button--primary van-button--block" @click="copyCode">复制员工邀请码</button>
<button class="van-button van-button--block top-gap-sm" @click="goLogin">前往登录 </button>
</div>
<!-- 员工注册成功 -->
<div v-else-if="staffRegistered" class="form-card">
<div class="success-icon"></div>
<div class="success-title">注册成功</div>
<div class="success-sub">您已成功加入 {{ regResult?.store?.name }} 团队</div>
<div class="success-info">
<div class="info-row"><span class="label">所属店铺</span><span class="value">{{ regResult?.store?.name }}</span></div>
<div class="info-row"><span class="label">您的账号</span><span class="value">{{ regResult?.user?.phone }}</span></div>
</div>
<button class="van-button van-button--primary van-button--block" @click="goLogin">前往登录 </button>
</div>
<!-- 登录表单默认视图 -->
<div v-else-if="view === 'login'" class="form-card">
<div class="form-title">手机验证码登录</div>
<input v-model="loginForm.phone" type="tel" class="van-field" placeholder="请输入手机号" maxlength="11" />
<view class="sms-field">
<input v-model="loginForm.code" type="digit" class="van-field sms-input" placeholder="短信验证码" maxlength="6" />
<button class="van-button van-button--small" :disabled="smsCountdown > 0" @click="handleSendSms">{{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }}</button>
</view> </view>
<button class="van-button van-button--primary van-button--block" :loading="loginLoading" @click="handleLogin">登录</button>
<div class="login-divider">其他登录方式</div>
<button class="van-button van-button--block" @click="handleWechatLogin"><span class="wechat-icon">📱</span> 微信授权登录</button>
<div class="links">
<span class="link" @click="view = 'staff-reg'">员工注册</span>
<span class="link" @click="view = 'boss-reg'">商家入驻</span>
</div>
</div>
<!-- 注册老板 --> <!-- 登录 -->
<div v-else-if="view === 'boss-reg'" class="form-card"> <view v-if="view === 'login'" class="card">
<div class="form-title">商家入驻</div> <text class="card-title">登录</text>
<input v-model="bossForm.storeName" class="van-field" placeholder="店铺名称" /> <input v-model="loginForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" />
<input v-model="bossForm.bossName" class="van-field" placeholder="您的姓名" /> <view class="sms-row">
<input v-model="bossForm.phone" type="tel" class="van-field" placeholder="手机号" maxlength="11" /> <input v-model="loginForm.code" type="digit" class="field sms-input" placeholder="验证码" maxlength="6" />
<input v-model="bossForm.password" type="password" class="van-field" placeholder="登录密码至少6位" /> <button class="sms-btn" :disabled="smsCountdown > 0" @click="handleSendSms">{{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }}</button>
<button class="van-button van-button--primary van-button--block" :loading="regLoading" @click="handleRegisterBoss">提交申请</button> </view>
<div class="links top-gap-md"> <button class="primary-btn" :disabled="loginLoading" @click="handleLogin">{{ loginLoading ? '登录中' : '登录' }}</button>
<span class="link" @click="view = 'login'">返回登录</span>
</div>
</div>
<!-- 注册员工 --> <view class="divider">
<div v-else-if="view === 'staff-reg'" class="form-card"> <view class="divider-line"></view>
<div class="form-title">员工注册</div> <text class="divider-text"></text>
<div class="invite-hint">请输入店长提供的邀请码加入团队</div> <view class="divider-line"></view>
<input v-model="staffForm.inviteCode" class="van-field" placeholder="请输入8位邀请码" maxlength="8" /> </view>
<input v-model="staffForm.name" class="van-field" placeholder="您的姓名" />
<input v-model="staffForm.phone" type="tel" class="van-field" placeholder="手机号" maxlength="11" /> <!-- #ifdef MP-WEIXIN -->
<input v-model="staffForm.password" type="password" class="van-field" placeholder="登录密码至少6位" /> <button
<button class="van-button van-button--primary van-button--block" :loading="regLoading" @click="handleRegisterStaff">注册</button> class="ghost-btn"
<div class="links top-gap-md"> open-type="getPhoneNumber"
<span class="link" @click="view = 'login'">返回登录</span> :disabled="loginLoading"
</div> @getphonenumber="onWxGetPhoneNumber"
</div> >{{ loginLoading ? '登录中…' : '微信授权登录' }}</button>
</div> <!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<button class="ghost-btn" @click="onWechatLoginNotInMp">微信授权登录</button>
<!-- #endif -->
<view class="footer-links">
<text class="link-btn" @click="view = 'staff-reg'">员工注册</text>
<view class="link-dot"></view>
<text class="link-btn" @click="view = 'boss-reg'">商家入驻</text>
</view>
</view>
<!-- 商家入驻 -->
<view v-else-if="view === 'boss-reg'" class="card">
<text class="card-title">商家入驻</text>
<input v-model="bossForm.storeName" class="field" placeholder="店铺名称" />
<input v-model="bossForm.bossName" class="field" placeholder="您的姓名" />
<input v-model="bossForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" />
<input v-model="bossForm.password" type="password" class="field" placeholder="登录密码至少6位" />
<button class="primary-btn" :disabled="regLoading" @click="handleRegisterBoss">{{ regLoading ? '提交中' : '提交申请' }}</button>
<text class="back-link" @click="backToLoginForm"> 返回登录</text>
</view>
<!-- 员工注册 -->
<view v-else-if="view === 'staff-reg'" class="card">
<text class="card-title">员工注册</text>
<view class="hint-box">请输入店长提供的邀请码加入团队</view>
<input v-model="staffForm.inviteCode" class="field" placeholder="8位邀请码" maxlength="8" />
<input v-model="staffForm.name" class="field" placeholder="您的姓名" />
<input v-model="staffForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" />
<input v-model="staffForm.password" type="password" class="field" placeholder="登录密码至少6位" />
<button class="primary-btn" :disabled="regLoading" @click="handleRegisterStaff">{{ regLoading ? '提交中' : '注册' }}</button>
<text class="back-link" @click="backToLoginForm"> 返回登录</text>
</view>
</view>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onUnmounted } from 'vue' import { ref, reactive, onUnmounted } from 'vue'
import { sendSms, login, registerBoss, registerStaff } from '../../api/index.js' import { onLoad } from '@dcloudio/uni-app'
import { sendSms, login, wxPhoneLogin, registerBoss, registerStaff } from '../../api/index.js'
import { setStoreSession, setUserSession } from '../../utils/session.js' import { setStoreSession, setUserSession } from '../../utils/session.js'
import { navigateTo } from '../../utils/globalState.js' import { openAppPage } from '../../utils/globalState.js'
const view = ref('login') const view = ref('login')
const loginForm = reactive({ phone: '13800138001', code: '123456' }) const loginForm = reactive({ phone: '13800138001', code: '123456' })
@ -92,12 +79,53 @@ const staffForm = reactive({ inviteCode: '', name: '', phone: '', password: '' }
const loginLoading = ref(false) const loginLoading = ref(false)
const regLoading = ref(false) const regLoading = ref(false)
const smsCountdown = ref(0) const smsCountdown = ref(0)
const bossRegistered = ref(false)
const staffRegistered = ref(false)
const regResult = ref({ store: {}, user: {} })
let smsTimer = null let smsTimer = null
const parseBossRegData = (data) => {
if (!data || typeof data !== 'object') return null
const st = data.store
const u = data.user
if (!st || typeof st !== 'object' || Array.isArray(st)) return null
if (!u || typeof u !== 'object' || Array.isArray(u)) return null
return {
storeName: uString(st.name),
inviteCode: uString(st.inviteCode),
phone: uString(u.phone),
password: uString(u.password)
}
}
const parseStaffRegData = (data) => {
if (!data || typeof data !== 'object') return null
const st = data.store
const u = data.user
if (!st || typeof st !== 'object' || Array.isArray(st)) return null
if (!u || typeof u !== 'object' || Array.isArray(u)) return null
return {
storeName: uString(st.name),
phone: uString(u.phone)
}
}
const uString = (v) => {
if (v == null) return ''
if (typeof v === 'boolean') return ''
if (typeof v === 'string' || typeof v === 'number') return String(v)
return ''
}
const safeLine = (s, fallback = '—') => {
const t = (s == null ? '' : String(s)).trim()
return t || fallback
}
const isHttpOk = (res) => res && (res.code === 200 || res.code === '200')
onLoad(() => { view.value = 'login' })
const backToLoginForm = () => { view.value = 'login' }
const showToast = (msg) => uni.showToast({ title: msg, icon: 'none' }) const showToast = (msg) => uni.showToast({ title: msg, icon: 'none' })
const handleSendSms = async () => { const handleSendSms = async () => {
@ -124,7 +152,7 @@ const handleLogin = async () => {
if (res.code === 200) { if (res.code === 200) {
setUserSession(res.data.user) setUserSession(res.data.user)
setStoreSession(res.data.store) setStoreSession(res.data.store)
navigateTo('home') openAppPage('home')
} else { } else {
showToast(res.message || '登录失败') showToast(res.message || '登录失败')
} }
@ -135,14 +163,42 @@ const handleLogin = async () => {
} }
} }
const handleWechatLogin = () => { /** 非微信小程序:无法调起手机号授权 */
showToast('跳转到微信授权...') const onWechatLoginNotInMp = () => {
setTimeout(() => { showToast('微信授权登录仅支持在微信小程序内使用,请使用验证码登录')
const demoUser = { id: 99, name: '微信用户', phone: '', role: 'staff' } }
setUserSession(demoUser)
setStoreSession({ id: 2, name: '宠伴生活馆测试店' }) /**
navigateTo('home') * 微信小程序用户同意授权后携带 code服务端换手机号并登录
}, 1500) * @see https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html
*/
const onWxGetPhoneNumber = async (e) => {
const d = e.detail || {}
const errMsg = d.errMsg || ''
if (errMsg.includes('deny') || errMsg.includes('cancel')) {
showToast('需要授权手机号才能完成登录')
return
}
const code = d.code
if (!code) {
showToast('未获取到授权,请使用验证码登录')
return
}
loginLoading.value = true
try {
const res = await wxPhoneLogin(code)
if (res.code === 200) {
setUserSession(res.data.user)
setStoreSession(res.data.store)
openAppPage('home')
} else {
showToast(res.message || '登录失败')
}
} catch (err) {
showToast('网络异常,请检查服务是否启动')
} finally {
loginLoading.value = false
}
} }
const handleRegisterBoss = async () => { const handleRegisterBoss = async () => {
@ -154,9 +210,29 @@ const handleRegisterBoss = async () => {
regLoading.value = true regLoading.value = true
const res = await registerBoss(f) const res = await registerBoss(f)
regLoading.value = false regLoading.value = false
if (res.code === 200) { if (isHttpOk(res)) {
regResult.value = res.data const parsed = parseBossRegData(res.data)
bossRegistered.value = true if (parsed) {
const n = safeLine(parsed.storeName, '—')
const p = safeLine(parsed.phone, '—')
const pw = safeLine(parsed.password, '—')
const code = safeLine(parsed.inviteCode, '—')
const content = `店铺:${n}\n账号${p}\n密码${pw}\n邀请码${code}\n\n请妥善保存邀请码。`
uni.showModal({
title: '入驻成功',
content,
cancelText: '前往登录',
confirmText: '复制邀请码',
success: (r) => {
if (r.confirm && code && code !== '—') {
uni.setClipboardData({ data: code, success: () => uni.showToast({ title: '已复制', icon: 'none' }) })
}
backToLoginForm()
}
})
} else {
showToast('注册成功,请用手机号登录')
}
} else { } else {
showToast(res.message || '注册失败') showToast(res.message || '注册失败')
} }
@ -171,61 +247,197 @@ const handleRegisterStaff = async () => {
regLoading.value = true regLoading.value = true
const res = await registerStaff(f) const res = await registerStaff(f)
regLoading.value = false regLoading.value = false
if (res.code === 200) { if (isHttpOk(res)) {
regResult.value = res.data const parsed = parseStaffRegData(res.data)
staffRegistered.value = true if (parsed) {
const shop = safeLine(parsed.storeName, '未知店铺')
const phone = safeLine(parsed.phone, '—')
uni.showModal({
title: '注册成功',
content: `已加入「${shop}\n账号${phone}`,
showCancel: false,
confirmText: '前往登录',
complete: () => backToLoginForm()
})
} else {
showToast('注册成功,请用手机号登录')
}
} else { } else {
showToast(res.message || '注册失败') showToast(res.message || '注册失败')
} }
} }
const copyCode = () => { onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
uni.setClipboardData({
data: regResult.value?.store?.inviteCode || '',
success: () => uni.showToast({ title: '邀请码已复制', icon: 'none' })
})
}
const goLogin = () => {
view.value = 'login'
bossRegistered.value = false
staffRegistered.value = false
}
onUnmounted(() => {
if (smsTimer) clearInterval(smsTimer)
})
</script> </script>
<style scoped> <style scoped>
.login-page { .login-page {
min-height: 100vh; min-height: 100vh;
background: radial-gradient(circle at top left, #22c55e 0%, #16a34a 46%, #15803d 100%); background: #fafaf8;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 60px 32px 32px; padding: 0 24px;
}
/* 品牌 */
.login-brand {
padding: 80px 0 40px;
text-align: center;
}
.brand-name {
display: block;
font-size: 28px;
font-weight: 800;
color: #1a1a1a;
letter-spacing: 1px;
}
.brand-sub {
display: block;
font-size: 13px;
color: #999993;
margin-top: 6px;
}
/* 卡片 */
.card {
background: #fff;
border-radius: 20px;
padding: 28px 24px;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
}
.card-title {
display: block;
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 20px;
}
/*
* 小程序 input 需固定 height + line-height padding 配合否则文字易被上下裁切 */
.field {
display: block;
background: #f5f5f2;
border: 1.5px solid transparent;
border-radius: 12px;
padding: 0 14px;
font-size: 16px;
color: #1a1a1a;
width: 100%;
box-sizing: border-box;
margin-bottom: 12px;
height: 48px;
min-height: 48px;
line-height: 48px;
}
.field:focus { border-color: #2db96d; background: #fff; }
/* 验证码行 */
.sms-row {
display: flex;
align-items: stretch;
gap: 10px;
margin-bottom: 12px;
}
.sms-input {
flex: 1;
margin-bottom: 0;
min-width: 0;
}
.sms-btn {
flex-shrink: 0;
align-self: center;
height: 48px;
line-height: 48px;
padding: 0 16px;
border-radius: 12px;
background: #f5f5f2;
color: #2db96d;
font-size: 14px;
font-weight: 600;
border: none;
white-space: nowrap;
}
.sms-btn[disabled] { color: #bbbbb5; }
/* 主按钮 */
.primary-btn {
width: 100%;
height: 48px;
border-radius: 14px;
background: #1a1a1a;
color: #fff;
font-size: 16px;
font-weight: 700;
border: none;
margin-top: 4px;
}
.primary-btn:active { opacity: 0.85; }
.primary-btn[disabled] { background: #ddddd8; color: #999993; }
/* 分割线 */
.divider {
display: flex;
align-items: center;
margin: 20px 0;
gap: 12px;
}
.divider-line { flex: 1; height: 1px; background: #ebebea; }
.divider-text { font-size: 12px; color: #bbbbb5; }
/* 幽灵按钮 */
.ghost-btn {
width: 100%;
height: 48px;
border-radius: 14px;
background: #f5f5f2;
color: #1a1a1a;
font-size: 15px;
font-weight: 600;
border: none;
}
.ghost-btn:active { opacity: 0.85; }
/* 底部链接 */
.footer-links {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #f0f0ee;
}
.link-btn {
font-size: 14px;
color: #2db96d;
font-weight: 600;
padding: 8px 4px;
}
.link-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: #ddddd8;
}
/* 返回 */
.back-link {
display: block;
text-align: center;
margin-top: 20px;
font-size: 14px;
color: #999993;
padding: 8px;
}
/* 提示框 */
.hint-box {
background: #eaf8f0;
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
color: #2db96d;
margin-bottom: 14px;
} }
.login-logo { text-align: center; margin-bottom: 44px; }
.login-logo-text { font-size: 30px; font-weight: 800; color: #fff; letter-spacing: 2px; text-shadow: 0 4px 12px rgba(0,0,0,0.18); }
.login-logo-sub { font-size: 13px; color: rgba(255,255,255,0.82); margin-top: 6px; }
.form-title { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 20px; text-align: center; }
.login-divider { text-align: center; margin: 16px 0 12px; color: #999; font-size: 13px; position: relative; }
.login-divider::before, .login-divider::after { content: ''; position: absolute; top: 50%; width: 28%; height: 1px; background: #eee; }
.login-divider::before { left: 0; }
.login-divider::after { right: 0; }
.links { display: flex; justify-content: space-between; font-size: 13px; }
.link { color: #16a34a; font-weight: 600; cursor: pointer; }
.invite-hint { background: #f0f9f4; border: 1px solid #b7eb8f; border-radius: 8px; padding: 12px; font-size: 13px; color: #52c41a; margin-bottom: 12px; }
.wechat-icon { margin-right: 6px; }
.success-icon { font-size: 60px; text-align: center; margin-bottom: 16px; }
.success-title { font-size: 20px; font-weight: 600; text-align: center; color: #333; margin-bottom: 8px; }
.success-sub { font-size: 14px; color: #999; text-align: center; margin-bottom: 24px; }
.success-info { background: #f9f9f9; border-radius: 12px; padding: 16px; margin-bottom: 20px; }
.info-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
.info-row .label { color: #999; }
.info-row .value { color: #333; font-weight: 500; }
.sms-field { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.sms-input { flex: 1; }
.top-gap-sm { margin-top: 12px; }
.top-gap-md { margin-top: 20px; }
</style> </style>

View File

@ -1,77 +1,88 @@
<template> <template>
<div class="mine-page"> <view class="mine-page">
<!-- 用户卡片点击进入个人信息页 --> <!-- 用户卡片 -->
<div class="user-card" :style="userCardSafeStyle" @click="navigateTo('profile')"> <view class="user-card" :style="userCardSafeStyle" @click="openProfile">
<div class="user-info"> <view class="user-row">
<div class="user-avatar" :style="avatarStyle"> <view class="user-avatar" :style="avatarBg">
<img v-if="userInfo.avatar" :src="userInfo.avatar" class="avatar-img" /> <image v-if="userInfo.avatar" :src="userInfo.avatar" class="avatar-img" mode="aspectFill" />
<span v-else class="avatar-initials">{{ initials }}</span> <text v-else class="avatar-letter">{{ initials }}</text>
</div> </view>
<div class="user-info-content"> <view class="user-meta">
<div class="user-name">{{ userInfo.name }}</div> <text class="user-name">{{ userInfo.name || '未设置' }}</text>
<div class="user-phone">{{ userInfo.phone }}</div> <text class="user-phone">{{ userInfo.phone || '' }}</text>
<div class="role-pill">{{ userInfo.role === 'boss' ? '店长' : userInfo.role === 'customer' ? '客户' : '员工' }}</div> </view>
</div> <view class="role-tag">{{ roleText }}</view>
</div> </view>
</div> </view>
<!-- 老板菜单 --> <!-- 店铺管理 -->
<div v-if="userInfo.role === 'boss'" class="menu-section"> <view v-if="userInfo.role === 'boss'" class="menu-group">
<div class="menu-card"> <text class="menu-label">店铺管理</text>
<div class="menu-title">店铺管理</div> <view class="menu-card">
<div class="menu-item" @click="navigateTo('staff')"> <view class="menu-item" @click="goStaffPage">
<div class="menu-left"><span class="menu-icon"><AppIcon name="staff" :size="15" /></span><span class="menu-text">员工管理</span></div> <view class="menu-icon-wrap"><AppIcon name="staff" :size="16" color="#666660" /></view>
<span class="menu-arrow"></span> <text class="menu-text">员工管理</text>
</div> <text class="menu-arrow"></text>
<div class="menu-item" @click="navigateTo('serviceType')"> </view>
<div class="menu-left"><span class="menu-icon"><AppIcon name="service" :size="15" /></span><span class="menu-text">服务类型</span></div> <view class="menu-item" @click="goServiceTypePage">
<span class="menu-arrow"></span> <view class="menu-icon-wrap"><AppIcon name="service" :size="16" color="#666660" /></view>
</div> <text class="menu-text">服务类型</text>
<div class="menu-item" @click="navigateTo('store')"> <text class="menu-arrow"></text>
<div class="menu-left"><span class="menu-icon"><AppIcon name="store" :size="15" /></span><span class="menu-text">店铺设置</span></div> </view>
<span class="menu-arrow"></span> <view class="menu-item" @click="goStorePage">
</div> <view class="menu-icon-wrap"><AppIcon name="store" :size="16" color="#666660" /></view>
</div> <text class="menu-text">店铺设置</text>
</div> <text class="menu-arrow"></text>
</view>
</view>
</view>
<!-- 通用菜单 --> <!-- 个人 -->
<div class="menu-section"> <view class="menu-group">
<div class="menu-card"> <text class="menu-label">个人中心</text>
<div class="menu-title">个人中心</div> <view class="menu-card">
<div class="menu-item" @click="navigateTo('profile')"> <view class="menu-item" @click="openProfile">
<div class="menu-left"><span class="menu-icon"><AppIcon name="profile" :size="15" /></span><span class="menu-text">个人信息</span></div> <view class="menu-icon-wrap"><AppIcon name="profile" :size="16" color="#666660" /></view>
<span class="menu-arrow"></span> <text class="menu-text">个人信息</text>
</div> <text class="menu-arrow"></text>
<div v-if="userInfo.role !== 'customer'" class="menu-item" @click="navigateTo('myReports')"> </view>
<div class="menu-left"><span class="menu-icon"><AppIcon name="report" :size="15" /></span><span class="menu-text">我的报告</span></div> <view v-if="userInfo.role !== 'customer'" class="menu-item" @click="goMyReportsPage">
<span class="menu-arrow"></span> <view class="menu-icon-wrap"><AppIcon name="report" :size="16" color="#666660" /></view>
</div> <text class="menu-text">我的报告</text>
<div class="menu-item" @click="navigateTo('myOrders')"> <text class="menu-arrow"></text>
<div class="menu-left"><span class="menu-icon"><AppIcon name="orders" :size="15" /></span><span class="menu-text">我的订单</span></div> </view>
<span class="menu-arrow"></span> <view class="menu-item" @click="goMyOrdersPage">
</div> <view class="menu-icon-wrap"><AppIcon name="orders" :size="16" color="#666660" /></view>
</div> <text class="menu-text">我的订单</text>
</div> <text class="menu-arrow"></text>
</view>
<!-- 退出登录 --> </view>
<div class="menu-section"> </view>
<button class="logout-btn" @click="logout"><AppIcon name="logout" :size="15" color="#dc2626" />退出登录</button>
</div>
</div>
<view class="logout-wrap">
<button class="logout-btn" @click="logout">退出登录</button>
</view>
<TabBar current-page="mine" @change="goPage" /> <TabBar current-page="mine" @change="goPage" />
</view>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue' import { computed } from 'vue'
import TabBar from '../../components/TabBar.vue' import TabBar from '../../components/TabBar.vue'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
import { clearSession, getUserSession } from '../../utils/session.js' import { clearSession, getUserSession } from '../../utils/session.js'
import { useNavigator } from '../../composables/useNavigator.js' import { useNavigator } from '../../composables/useNavigator.js'
const emit = defineEmits(['change-page'])
const userInfo = getUserSession() const userInfo = getUserSession()
const { goPage, navigateTo } = useNavigator() const { goPage, openAppPage } = useNavigator()
const goStaffPage = () => uni.navigateTo({ url: '/pages/mine/Staff' })
const goServiceTypePage = () => uni.navigateTo({ url: '/pages/mine/ServiceType' })
const goStorePage = () => uni.navigateTo({ url: '/pages/mine/Store' })
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 userCardSafeStyle = (() => { const userCardSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20 const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let safeTop = statusBarHeight + 12 let safeTop = statusBarHeight + 12
@ -87,13 +98,19 @@ const initials = computed(() => {
return userInfo.name.slice(0, 1).toUpperCase() return userInfo.name.slice(0, 1).toUpperCase()
}) })
const avatarStyle = computed(() => { const avatarBg = computed(() => {
if (userInfo.avatar) return {} if (userInfo.avatar) return {}
const colors = ['#ff7c43', '#07c160', '#8b6914', '#e06040', '#5090d0'] const colors = ['#f5913e', '#2db96d', '#5090d0', '#e06050', '#8b6914']
const idx = (userInfo.name?.charCodeAt(0) || 0) % colors.length const idx = (userInfo.name?.charCodeAt(0) || 0) % colors.length
return { background: colors[idx] } return { background: colors[idx] }
}) })
const roleText = computed(() => {
if (userInfo.role === 'boss') return '店长'
if (userInfo.role === 'customer') return '客户'
return '员工'
})
const logout = () => { const logout = () => {
uni.showModal({ uni.showModal({
title: '提示', title: '提示',
@ -101,7 +118,7 @@ const logout = () => {
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
clearSession() clearSession()
navigateTo('login') openAppPage('login')
} }
} }
}) })
@ -109,90 +126,127 @@ const logout = () => {
</script> </script>
<style scoped> <style scoped>
.mine-page { padding-bottom: 140rpx; background: #f5f7fb; min-height: 100vh; } .mine-page {
.user-card { padding-bottom: calc(76px + env(safe-area-inset-bottom));
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%); background: #fafaf8;
border-radius: 0 0 24px 24px; min-height: 100vh;
padding: 24px 16px 22px;
color: #fff;
cursor: pointer;
box-shadow: 0 10px 24px rgba(34, 197, 94, 0.25);
}
.user-info { display: flex; align-items: center; gap: 12px; }
.user-info-content { flex: 1; }
.user-avatar {
width: 58px; height: 58px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 24px;
overflow: hidden;
flex-shrink: 0;
border: 2px solid rgba(255,255,255,0.3);
}
.avatar-img { width: 100%; height: 100%; object-fit: cover; }
.avatar-initials { color: #fff; font-size: 22px; font-weight: 700; }
.user-name { font-size: 21px; font-weight: 700; color: #fff; line-height: 1.1; }
.user-phone { font-size: 14px; opacity: 0.92; margin-top: 4px; }
.role-pill {
margin-top: 8px;
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
background: rgba(255, 255, 255, 0.92);
color: #166534;
}
.menu-section { padding: 0 14px; margin-top: 12px; }
.menu-card {
background: #fff;
border: 1px solid #e6ecf4;
border-radius: 16px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.05);
overflow: hidden;
}
.menu-title {
font-size: 12px;
color: #94a3b8;
font-weight: 700;
padding: 12px 14px 8px;
}
.menu-item {
min-height: 54px;
padding: 0 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.menu-item + .menu-item { border-top: 1px solid #eef2f7; }
.menu-left { display: flex; align-items: center; gap: 10px; }
.menu-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: #f1f5f9;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.menu-text { font-size: 16px; color: #1f2937; font-weight: 600; }
.menu-arrow { color: #c4cfdd; font-size: 18px; }
.logout-btn {
width: 100%;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 12px;
border: 1px solid #fecaca;
background: #fff1f2;
color: #dc2626;
font-size: 15px;
font-weight: 700;
} }
/* 用户卡片 */
.user-card {
padding: 20px 20px 24px;
background: #fff;
border-radius: 0 0 20px 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.user-row {
display: flex;
align-items: center;
gap: 14px;
}
.user-avatar {
width: 52px; height: 52px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.avatar-img { width: 100%; height: 100%; }
.avatar-letter {
color: #fff;
font-size: 22px;
font-weight: 700;
}
.user-meta { flex: 1; min-width: 0; }
.user-name {
font-size: 18px;
font-weight: 700;
color: #1a1a1a;
}
.user-phone {
font-size: 13px;
color: #999993;
margin-top: 2px;
}
.role-tag {
padding: 4px 12px;
border-radius: 8px;
font-size: 12px;
font-weight: 700;
background: #eaf8f0;
color: #2db96d;
flex-shrink: 0;
}
/* 菜单组 */
.menu-group {
padding: 0 16px;
margin-top: 20px;
}
.menu-label {
display: block;
font-size: 12px;
font-weight: 700;
color: #bbbbb5;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
padding-left: 4px;
}
.menu-card {
background: #fff;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
.menu-item {
display: flex;
align-items: center;
padding: 14px 16px;
gap: 12px;
}
.menu-item + .menu-item {
border-top: 1px solid #f5f5f2;
}
.menu-icon-wrap {
width: 32px; height: 32px;
border-radius: 10px;
background: #f5f5f2;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.menu-text {
flex: 1;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.menu-arrow {
color: #ddddd8;
font-size: 18px;
font-weight: 300;
}
/* 退出 */
.logout-wrap {
padding: 24px 16px;
}
.logout-btn {
width: 100%;
height: 46px;
border-radius: 14px;
background: #fff;
color: #e5534b;
font-size: 15px;
font-weight: 600;
border: 1.5px solid #f0e0de;
display: flex;
align-items: center;
justify-content: center;
}
.logout-btn:active { opacity: 0.85; }
</style> </style>

View File

@ -2,7 +2,7 @@
<div class="page-shell orders-page"> <div class="page-shell orders-page">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<view class="orders-nav nav-gradient" :style="navSafeStyle"> <view class="orders-nav nav-gradient" :style="navSafeStyle">
<text class="nav-back" @click="navigateTo('mine')"><AppIcon name="back" :size="18" color="#ffffff" /></text> <view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">我的订单</text> <text class="nav-title">我的订单</text>
<view class="nav-placeholder"></view> <view class="nav-placeholder"></view>
</view> </view>
@ -25,7 +25,7 @@
<!-- 订单列表 --> <!-- 订单列表 -->
<view class="page-section section-gap"> <view class="page-section section-gap">
<div v-for="item in filteredOrders" :key="item.id" class="order-item"> <div v-for="item in filteredOrders" :key="item.id" class="order-item" @click="goAppointmentDetail(item)">
<div class="order-head"> <div class="order-head">
<div class="order-title">{{ item.title }}</div> <div class="order-title">{{ item.title }}</div>
<view :class="`van-tag van-tag--${statusTagBg(item.status)}`">{{ item.statusText }}</view> <view :class="`van-tag van-tag--${statusTagBg(item.status)}`">{{ item.statusText }}</view>
@ -42,14 +42,14 @@
</div> </div>
<div v-if="userInfo.role !== 'customer'"> <div v-if="userInfo.role !== 'customer'">
<div v-if="item.status === 'new'" class="action-btns"> <div v-if="item.status === 'new'" class="action-btns">
<button class="van-button van-button--small van-button--primary" @click="startService(item)">开始服务</button> <button class="van-button van-button--small van-button--primary" @click.stop="startService(item)">开始服务</button>
<button class="van-button van-button--small" @click="cancelService(item)">取消</button> <button class="van-button van-button--small" @click.stop="cancelService(item)">取消</button>
</div> </div>
<button v-else-if="item.status === 'doing'" class="van-button van-button--small btn-mt" @click="goReport(item)">填写报告</button> <button v-else-if="item.status === 'doing'" class="van-button van-button--small btn-mt" @click.stop="goReport(item)">填写报告</button>
</div> </div>
<div v-else> <div v-else>
<div v-if="item.status === 'new'" class="action-btns"> <div v-if="item.status === 'new'" class="action-btns">
<button class="van-button van-button--small" @click="cancelService(item)">取消预约</button> <button class="van-button van-button--small" @click.stop="cancelService(item)">取消预约</button>
</div> </div>
</div> </div>
</div> </div>
@ -71,6 +71,15 @@ import { getAppointmentStatusText, getAppointmentTagType } from '../../utils/app
const emit = defineEmits(['change-page']) const emit = defineEmits(['change-page'])
const userInfo = getUserSession() const userInfo = getUserSession()
const currentUserId = userInfo.id const currentUserId = userInfo.id
const goBackToMine = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('mine')
}
const navSafeStyle = (() => { const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20 const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44 let navHeight = statusBarHeight + 44
@ -146,6 +155,11 @@ const cancelService = async (item) => {
}) })
} }
const goAppointmentDetail = (item) => {
if (!item?.id) return
uni.navigateTo({ url: `/pages/appointment/AppointmentDetail?id=${item.id}` })
}
const goReport = (item) => { 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
@ -176,7 +190,7 @@ onMounted(() => fetchOrders())
} }
.nav-back { font-size: 20px; color: #fff; } .nav-back { font-size: 20px; color: #fff; }
.nav-title { font-size: 18px; font-weight: 700; color: #fff; } .nav-title { font-size: 18px; font-weight: 700; color: #fff; }
.order-item { background: #fff; margin-bottom: 12px; border-radius: 14px; padding: 16px; border: 1px solid #e8edf4; box-shadow: 0 6px 16px rgba(15, 23, 42, 0.05); } .order-item { background: #fff; margin-bottom: 12px; border-radius: 14px; padding: 16px; border: 1px solid #e8edf4; box-shadow: 0 6px 16px rgba(15, 23, 42, 0.05); cursor: pointer; }
.order-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .order-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.order-title { font-size: 15px; font-weight: 700; color: #1f2937; margin-bottom: 6px; } .order-title { font-size: 15px; font-weight: 700; color: #1f2937; margin-bottom: 6px; }
.order-desc { font-size: 13px; color: #6b7280; margin-bottom: 10px; display: flex; align-items: center; gap: 6px; } .order-desc { font-size: 13px; color: #6b7280; margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }

View File

@ -2,7 +2,7 @@
<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 nav-gradient" :style="navSafeStyle">
<text class="nav-back nav-home-btn" @click="goHome"><AppIcon name="home" :size="16" color="#ffffff" /></text> <view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">我的报告</text> <text class="nav-title">我的报告</text>
<view class="nav-placeholder"></view> <view class="nav-placeholder"></view>
</view> </view>
@ -80,8 +80,13 @@ const loadReports = async () => {
if (res.code === 200) reportList.value = res.data if (res.code === 200) reportList.value = res.data
} }
const goHome = () => { const goBackToMine = () => {
navigateTo('home') const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('mine')
} }
const viewReport = (r) => { const viewReport = (r) => {
@ -104,10 +109,6 @@ onMounted(() => loadReports())
<style scoped> <style scoped>
.my-reports-page { padding: 0 0 120rpx; } .my-reports-page { padding: 0 0 120rpx; }
.nav-placeholder { width: 32px; } .nav-placeholder { width: 32px; }
.nav-home-btn {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.35);
}
.reports-hero { .reports-hero {
margin-top: 12px; margin-top: 12px;
padding: 14px 16px; padding: 14px 16px;
@ -117,7 +118,7 @@ onMounted(() => loadReports())
} }
.hero-title { font-size: 16px; font-weight: 700; color: #166534; } .hero-title { font-size: 16px; font-weight: 700; color: #166534; }
.hero-sub { margin-top: 4px; font-size: 12px; color: #4b5563; line-height: 1.45; } .hero-sub { margin-top: 4px; font-size: 12px; color: #4b5563; line-height: 1.45; }
.hero-count { font-weight: 700; color: #16a34a; } .hero-count { font-weight: 700; color: #2db96d; }
.placeholder-icon { .placeholder-icon {
width: 36px; width: 36px;
height: 36px; height: 36px;

View File

@ -2,7 +2,7 @@
<div class="page-shell profile-page"> <div class="page-shell profile-page">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<view class="profile-nav nav-gradient" :style="navSafeStyle"> <view class="profile-nav nav-gradient" :style="navSafeStyle">
<text class="nav-back" @click="navigateTo('mine')"><AppIcon name="back" :size="18" color="#ffffff" /></text> <view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">个人信息</text> <text class="nav-title">个人信息</text>
<view class="nav-placeholder"></view> <view class="nav-placeholder"></view>
</view> </view>
@ -99,7 +99,7 @@
<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" /> <input v-model="editForm.code" type="digit" class="van-field sms-input" placeholder="请输入验证码" maxlength="6" />
<button class="van-button van-button--small" :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>
</view> </view>
@ -120,6 +120,15 @@ import { updateUser, sendSms as sendSmsApi, imgUrl, BASE_URL } from '../../api/i
import { getUserSession, setUserSession } from '../../utils/session.js' import { getUserSession, setUserSession } from '../../utils/session.js'
const emit = defineEmits(['change-page']) const emit = defineEmits(['change-page'])
const goBackToMine = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('mine')
}
const navSafeStyle = (() => { const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20 const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44 let navHeight = statusBarHeight + 44
@ -147,7 +156,7 @@ const initials = computed(() => {
return userInfo.value.name.slice(0, 1).toUpperCase() return userInfo.value.name.slice(0, 1).toUpperCase()
}) })
const COLORS = ['#ff7c43', '#07c160', '#8b6914', '#e06040', '#5090d0'] const COLORS = ['#f5913e', '#2db96d', '#8b6914', '#e06050', '#5090d0']
const avatarBg = computed(() => { const avatarBg = computed(() => {
const idx = (userInfo.value.name?.charCodeAt(0) || 0) % COLORS.length const idx = (userInfo.value.name?.charCodeAt(0) || 0) % COLORS.length
return { background: COLORS[idx] } return { background: COLORS[idx] }

View File

@ -2,7 +2,7 @@
<div class="page-shell st-page"> <div class="page-shell st-page">
<!-- 顶部导航 --> <!-- 顶部导航 -->
<view class="st-nav nav-gradient" :style="navSafeStyle"> <view class="st-nav nav-gradient" :style="navSafeStyle">
<text class="nav-back" @click="navigateTo('mine')"><AppIcon name="back" :size="18" color="#ffffff" /></text> <view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">服务类型</text> <text class="nav-title">服务类型</text>
<view class="nav-placeholder"></view> <view class="nav-placeholder"></view>
</view> </view>
@ -21,7 +21,14 @@
:class="{ 'is-open': swipedId === s.id && !!s.storeId }" :class="{ 'is-open': swipedId === s.id && !!s.storeId }"
> >
<view <view
class="st-swipe-track" v-if="s.storeId"
class="st-swipe-delete"
@click.stop="deleteST(s.id)"
>
删除
</view>
<view
class="st-swipe-main"
@touchstart="onSwipeStart($event, s)" @touchstart="onSwipeStart($event, s)"
@touchmove.stop="onSwipeMove($event)" @touchmove.stop="onSwipeMove($event)"
@touchend="onSwipeEnd($event, s)" @touchend="onSwipeEnd($event, s)"
@ -33,13 +40,6 @@
<view v-if="!s.storeId" class="van-tag van-tag--success van-tag--small system-tag">系统默认</view> <view v-if="!s.storeId" class="van-tag van-tag--success van-tag--small system-tag">系统默认</view>
</view> </view>
</view> </view>
<view
v-if="s.storeId"
class="swipe-delete-btn"
@click.stop="deleteST(s.id)"
>
删除
</view>
</view> </view>
</view> </view>
</view> </view>
@ -79,6 +79,15 @@ import { getStoreSession } from '../../utils/session.js'
const emit = defineEmits(['change-page']) const emit = defineEmits(['change-page'])
const storeInfo = getStoreSession() const storeInfo = getStoreSession()
const goBackToMine = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('mine')
}
const navSafeStyle = (() => { const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20 const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44 let navHeight = statusBarHeight + 44
@ -185,32 +194,41 @@ onMounted(() => load())
} }
.st-name { font-size: 15px; font-weight: 600; color: #1f2937; } .st-name { font-size: 15px; font-weight: 600; color: #1f2937; }
.system-tag { margin-left: 8px; } .system-tag { margin-left: 8px; }
/* 与员工管理页一致:删除区绝对贴底,主内容盖住;左滑才露出 */
.st-swipe-wrap { .st-swipe-wrap {
position: relative;
overflow: hidden; overflow: hidden;
border-radius: 12px; border-radius: 12px;
background: #fff;
} }
.st-swipe-wrap + .st-swipe-wrap { .st-swipe-wrap + .st-swipe-wrap {
border-top: 1px solid #eef2f7; border-top: 1px solid #eef2f7;
} }
.st-swipe-track { .st-swipe-delete {
display: flex; position: absolute;
width: calc(100% + 76px); right: 0;
transform: translateX(0); top: 0;
transition: transform 0.2s ease; bottom: 0;
}
.st-swipe-wrap.is-open .st-swipe-track {
transform: translateX(-76px);
}
.swipe-delete-btn {
width: 76px; width: 76px;
z-index: 1;
background: #ef4444; background: #ef4444;
color: #fff; color: #fff;
display: inline-flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
flex-shrink: 0; }
.st-swipe-main {
position: relative;
z-index: 2;
width: 100%;
background: #fff;
transform: translateX(0);
transition: transform 0.2s ease;
}
.st-swipe-wrap.is-open .st-swipe-main {
transform: translateX(-76px);
} }
.st-nav { .st-nav {
padding: 14px 16px; padding: 14px 16px;

View File

@ -1,21 +1,19 @@
<template> <template>
<div class="page-shell staff-page"> <view class="page-shell staff-page">
<!-- 顶部导航 -->
<view class="staff-nav nav-gradient" :style="navSafeStyle"> <view class="staff-nav nav-gradient" :style="navSafeStyle">
<text class="nav-back" @click="navigateTo('mine')"><AppIcon name="back" :size="18" color="#ffffff" /></text> <view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">员工管理</text> <view class="nav-title">员工管理</view>
<view class="nav-placeholder"></view> <view class="nav-placeholder"></view>
</view> </view>
<!-- 邀请码 --> <!-- 邀请码标题与码+按钮分行同一行内码与复制垂直居中 -->
<view class="van-cell-group page-section card-section invite-card"> <view class="page-section invite-wrap">
<view class="van-cell"> <text class="invite-kicker">员工邀请码</text>
<view class="van-cell__title">员工邀请码</view> <view class="invite-code-row">
<view class="van-cell__value invite-code-cell"> <text class="invite-code-text" selectable>{{ inviteCodeText }}</text>
<text class="invite-code">{{ storeInfo.inviteCode }}</text> <button class="invite-copy-btn" hover-class="invite-copy-btn--hover" @click="copyCode">复制</button>
<button class="van-button van-button--small van-button--primary" @click="copyCode">复制</button>
</view>
</view> </view>
<text class="invite-tip">新员工注册时填写此码即可加入本店</text>
</view> </view>
<view class="page-section add-btn-wrap"> <view class="page-section add-btn-wrap">
@ -26,14 +24,14 @@
<view class="page-section section-gap"> <view class="page-section section-gap">
<view class="van-cell-group card-section"> <view class="van-cell-group card-section">
<view <view
v-for="s in staffList" v-for="(s, idx) in staffList"
:key="s.id" :key="s.id != null ? s.id : 'row-' + idx"
class="staff-swipe-wrap" class="staff-swipe-wrap"
:class="{ 'is-open': swipedId === s.id && s.role !== 'boss' }" :class="{ 'is-open': swipedId === s.id && s.role !== 'boss' }"
@click="onRowTap(s)" @click="onRowTap(s)"
> >
<view <view
v-if="s.role !== 'boss'" v-show="s.role !== 'boss'"
class="swipe-delete-btn" class="swipe-delete-btn"
@click.stop="deleteStaff(s.id)" @click.stop="deleteStaff(s.id)"
> >
@ -48,8 +46,8 @@
<view class="van-cell staff-cell"> <view class="van-cell staff-cell">
<view class="staff-info-row"> <view class="staff-info-row">
<view class="avatar" :style="avatarStyle(s)"> <view class="avatar" :style="avatarStyle(s)">
<img v-if="s.avatar" :src="s.avatar" class="avatar-img" /> <image v-if="s.avatar" :src="s.avatar" class="avatar-img" mode="aspectFill" />
<span v-else class="avatar-initials">{{ s.name ? s.name[0] : '?' }}</span> <text v-else class="avatar-initials">{{ staffInitial(s) }}</text>
</view> </view>
<view> <view>
<view class="staff-name">{{ s.name }}</view> <view class="staff-name">{{ s.name }}</view>
@ -65,12 +63,11 @@
<view v-if="staffList.length === 0" class="empty"><text>暂无员工</text></view> <view v-if="staffList.length === 0" class="empty"><text>暂无员工</text></view>
<!-- 新增员工弹窗 -->
<view v-if="showAddStaff" class="popup-mask" @click="showAddStaff = false"> <view v-if="showAddStaff" class="popup-mask" @click="showAddStaff = false">
<view class="popup-content" @click.stop> <view class="popup-content" @click.stop>
<view class="popup-header"> <view class="popup-header">
<text class="popup-title">新增员工</text> <view class="popup-title">新增员工</view>
<text class="popup-close" @click="showAddStaff = false"></text> <view class="popup-close" @click="showAddStaff = false"></view>
</view> </view>
<view class="popup-body"> <view class="popup-body">
<view class="popup-desc">创建后会自动生成初始密码员工可使用手机号登录</view> <view class="popup-desc">创建后会自动生成初始密码员工可使用手机号登录</view>
@ -87,20 +84,32 @@
</view> </view>
</view> </view>
</view> </view>
</div> </view>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
import { navigateTo } from '../../utils/globalState.js' import { openAppPage } from '../../utils/globalState.js'
import { getStaffList, createStaff, deleteStaff as delStaff } from '../../api/index.js' import { getStaffList, createStaff, deleteStaff as delStaff } from '../../api/index.js'
import { getStoreSession } from '../../utils/session.js' import { getStoreSession } from '../../utils/session.js'
const emit = defineEmits(['change-page']) const emit = defineEmits(['change-page'])
const storeInfo = getStoreSession() const storeInfo = getStoreSession()
const inviteCodeText = computed(() => (storeInfo && storeInfo.inviteCode) ? String(storeInfo.inviteCode) : '—')
const navSafeStyle = (() => { const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20 let statusBarHeight = 20
try {
const win = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
if (win && win.statusBarHeight != null) statusBarHeight = win.statusBarHeight
else {
const sys = uni.getSystemInfoSync()
statusBarHeight = sys.statusBarHeight || 20
}
} catch (_) {
statusBarHeight = 20
}
let navHeight = statusBarHeight + 44 let navHeight = statusBarHeight + 44
const menuRect = uni.getMenuButtonBoundingClientRect?.() const menuRect = uni.getMenuButtonBoundingClientRect?.()
if (menuRect && menuRect.top && menuRect.height) { if (menuRect && menuRect.top && menuRect.height) {
@ -110,6 +119,20 @@ const navSafeStyle = (() => {
return `padding-top:${statusBarHeight}px;height:${navHeight}px;` return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
})() })()
const goBackToMine = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
openAppPage('mine')
}
const staffInitial = (s) => {
const n = s && s.name
return (typeof n === 'string' && n.length > 0) ? n[0] : '?'
}
const staffList = ref([]) const staffList = ref([])
const showAddStaff = ref(false) const showAddStaff = ref(false)
const newStaff = ref({ name: '', phone: '' }) const newStaff = ref({ name: '', phone: '' })
@ -117,7 +140,7 @@ const swipedId = ref(null)
const touchStartX = ref(0) const touchStartX = ref(0)
const touchCurrentX = ref(0) const touchCurrentX = ref(0)
const COLORS = ['#ff7c43', '#07c160', '#8b6914', '#e06040', '#5090d0', '#9b59b6'] const COLORS = ['#f5913e', '#2db96d', '#8b6914', '#e06050', '#5090d0', '#9b59b6']
const avatarStyle = (s) => { const avatarStyle = (s) => {
if (s.avatar) return { background: 'transparent' } if (s.avatar) return { background: 'transparent' }
const idx = (s.name?.charCodeAt(0) || 0) % COLORS.length const idx = (s.name?.charCodeAt(0) || 0) % COLORS.length
@ -130,8 +153,10 @@ const loadStaff = async () => {
} }
const copyCode = () => { const copyCode = () => {
const code = inviteCodeText.value === '—' ? '' : inviteCodeText.value
if (!code) return
uni.setClipboardData({ uni.setClipboardData({
data: storeInfo.inviteCode, data: code,
success: () => uni.showToast({ title: '邀请码已复制', icon: 'none' }) success: () => uni.showToast({ title: '邀请码已复制', icon: 'none' })
}) })
} }
@ -212,10 +237,69 @@ onMounted(() => loadStaff())
.nav-placeholder { width: 32px; } .nav-placeholder { width: 32px; }
.add-btn-wrap { padding: 0; } .add-btn-wrap { padding: 0; }
.card-section { margin: 0; } .card-section { margin: 0; }
.invite-card { overflow: hidden; }
.staff-cell { min-height: 58px; } .staff-cell { min-height: 58px; }
.invite-code-cell { display: flex; align-items: center; gap: 8px; }
.invite-code { font-family: monospace; font-size: 15px; color: #16a34a; font-weight: 700; letter-spacing: 2px; } /* 邀请码卡片 */
.invite-wrap {
background: #fff;
border-radius: 14px;
padding: 16px 16px 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
border: 1px solid #f0f0ee;
}
.invite-kicker {
display: block;
font-size: 12px;
font-weight: 700;
color: #999993;
letter-spacing: 0.5px;
margin-bottom: 10px;
}
.invite-code-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 44px;
}
.invite-code-text {
flex: 1;
min-width: 0;
font-size: 20px;
font-weight: 700;
font-family: ui-monospace, Menlo, Monaco, Consolas, monospace;
color: #1a1a1a;
letter-spacing: 0.12em;
line-height: 1.3;
}
.invite-copy-btn {
flex-shrink: 0;
margin: 0;
padding: 0 18px;
height: 36px;
line-height: 36px;
font-size: 14px;
font-weight: 600;
color: #2db96d;
background: #eaf8f0;
border: none;
border-radius: 10px;
box-sizing: border-box;
}
.invite-copy-btn::after {
border: none;
}
.invite-copy-btn--hover {
opacity: 0.88;
}
.invite-tip {
display: block;
margin-top: 10px;
font-size: 12px;
color: #bbbbb5;
line-height: 1.4;
}
.staff-info-row { display: flex; align-items: center; gap: 12px; flex: 1; } .staff-info-row { display: flex; align-items: center; gap: 12px; flex: 1; }
.staff-name { font-weight: 700; font-size: 15px; color: #1f2937; } .staff-name { font-weight: 700; font-size: 15px; color: #1f2937; }
.staff-meta { font-size: 12px; color: #6b7280; } .staff-meta { font-size: 12px; color: #6b7280; }
@ -234,6 +318,9 @@ onMounted(() => loadStaff())
transform: translateX(0); transform: translateX(0);
transition: transform 0.2s ease; transition: transform 0.2s ease;
background: #fff; background: #fff;
width: 100%;
min-width: 100%;
box-sizing: border-box;
} }
.staff-swipe-wrap.is-open .staff-main { .staff-swipe-wrap.is-open .staff-main {
transform: translateX(-76px); transform: translateX(-76px);

View File

@ -1,7 +1,7 @@
<template> <template>
<div class="page-shell store-page"> <div class="page-shell store-page">
<view class="store-nav nav-gradient" :style="navSafeStyle"> <view class="store-nav nav-gradient" :style="navSafeStyle">
<text class="nav-back" @click="navigateTo('mine')"><AppIcon name="back" :size="18" color="#ffffff" /></text> <view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">店铺设置</text> <text class="nav-title">店铺设置</text>
<view class="nav-placeholder"></view> <view class="nav-placeholder"></view>
</view> </view>
@ -91,6 +91,15 @@ import { getStoreSession, setStoreSession } from '../../utils/session.js'
defineEmits(['change-page']) defineEmits(['change-page'])
const storeInfo = getStoreSession() const storeInfo = getStoreSession()
const goBackToMine = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('mine')
}
const navSafeStyle = (() => { const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20 const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44 let navHeight = statusBarHeight + 44
@ -297,7 +306,7 @@ onMounted(() => {
padding: 0 14px; padding: 0 14px;
min-height: 44px; min-height: 44px;
border-radius: 12px; border-radius: 12px;
border: 1px solid #16a34a; border: 1px solid #2db96d;
background: #f0fdf4; background: #f0fdf4;
color: #15803d; color: #15803d;
font-size: 14px; font-size: 14px;
@ -315,7 +324,7 @@ onMounted(() => {
.geo-hint { .geo-hint {
margin-top: 8px; margin-top: 8px;
font-size: 12px; font-size: 12px;
color: #16a34a; color: #2db96d;
line-height: 1.4; line-height: 1.4;
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,76 +1,85 @@
<template> <template>
<div class="page-shell report-page"> <view class="page-shell report-page">
<!-- 顶部导航 -->
<view class="report-nav nav-gradient" :style="navSafeStyle"> <view class="report-nav nav-gradient" :style="navSafeStyle">
<text class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></text> <view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">洗护美容报告</text> <text class="nav-title">填写报告</text>
<view class="nav-placeholder"></view> <view style="width:32px"></view>
</view> </view>
<!-- 填写表单 --> <view class="report-body">
<div class="report-form"> <!-- 基础信息 -->
<view class="page-section report-hero"> <view class="section">
<view class="hero-title">填写服务记录</view> <view class="section-head">
<view class="hero-sub">完善本次服务细节提交后可生成分享链接与二维码</view> <text class="section-label">基础信息</text>
</view> </view>
<view class="form-row">
<view class="van-cell-group page-section form-section"> <text class="form-label">宠物名字</text>
<view class="module-title section-title"> <input v-model="report.petName" class="form-input" placeholder="请输入宠物名字" />
<span class="section-icon"><AppIcon name="profile" :size="12" /></span>
<text>基础信息</text>
</view> </view>
<view class="section-body"> <view class="form-row">
<view class="form-field-wrap"> <text class="form-label">服务类型</text>
<view class="field-label">宠物名字</view>
<input v-model="report.petName" class="van-field" placeholder="请输入宠物名字" />
</view>
<view class="form-field-wrap">
<view class="field-label">服务类型</view>
<picker mode="selector" :range="serviceColumns" range-key="label" @change="onServiceConfirm"> <picker mode="selector" :range="serviceColumns" range-key="label" @change="onServiceConfirm">
<view class="van-field picker-field">{{ report.serviceType || '请选择' }}</view> <view class="form-input form-picker">
<text :class="{ placeholder: !report.serviceType }">{{ report.serviceType || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker> </picker>
</view> </view>
<view class="form-field-wrap"> <view class="form-row">
<view class="field-label">服务时间</view> <text class="form-label">服务时间</text>
<input v-model="report.appointmentTime" type="datetime-local" class="van-field" /> <view class="time-row">
<picker mode="date" :value="serviceDate" @change="onServiceDateChange" class="time-picker">
<view class="form-input form-picker">
<text :class="{ placeholder: !serviceDate }">{{ serviceDate || '日期' }}</text>
</view>
</picker>
<picker mode="time" :value="serviceTimePart" @change="onServiceTimeChange" class="time-picker">
<view class="form-input form-picker">
<text :class="{ placeholder: !serviceTimePart }">{{ serviceTimePart || '时间' }}</text>
</view>
</picker>
</view> </view>
</view> </view>
</view> </view>
<!-- 服务照片 --> <!-- 服务照片 核心区域 -->
<view class="van-cell-group page-section form-section"> <view class="section">
<view class="module-title section-title"> <view class="section-head">
<span class="section-icon"><AppIcon name="camera" :size="12" /></span> <text class="section-label">服务前后对比</text>
<text>服务照片</text> <text class="section-hint">上传照片让宠主看到变化</text>
</view> </view>
<view class="section-body">
<view class="photo-row"> <view class="photo-compare">
<view class="photo-col"> <view class="photo-side">
<view class="field-label">服务前</view> <view class="photo-side-label">
<view class="upload-wrap"> <view class="dot dot-before"></view>
<view v-for="(img, idx) in report.before" :key="idx" class="upload-preview"> <text>服务前</text>
<img :src="img" class="preview-img" />
<view class="preview-remove" @click="removePhoto('before', idx)"></view>
</view> </view>
<view v-if="report.before.length < 9" class="upload-box" @click="uploadPhoto('before')"> <view class="photo-grid">
<span class="upload-icon"><AppIcon name="camera" :size="16" color="#94a3b8" /></span> <view v-for="(img, idx) in report.before" :key="'b'+idx" class="photo-thumb">
<span class="upload-tip">上传照片</span> <image :src="img" class="thumb-img" mode="aspectFill" />
<view class="thumb-del" @click="removePhoto('before', idx)"></view>
</view>
<view v-if="report.before.length < 4" class="photo-add" @click="uploadPhoto('before')">
<AppIcon name="camera" :size="20" color="#bbbbb5" />
</view> </view>
</view> </view>
</view> </view>
<view class="photo-divider"></view>
<view class="photo-side">
<view class="photo-side-label">
<view class="dot dot-after"></view>
<text>服务后</text>
</view> </view>
<view class="photo-row" style="margin-top: 16px;"> <view class="photo-grid">
<view class="photo-col"> <view v-for="(img, idx) in report.after" :key="'a'+idx" class="photo-thumb">
<view class="field-label">服务后</view> <image :src="img" class="thumb-img" mode="aspectFill" />
<view class="upload-wrap"> <view class="thumb-del" @click="removePhoto('after', idx)"></view>
<view v-for="(img, idx) in report.after" :key="idx" class="upload-preview">
<img :src="img" class="preview-img" />
<view class="preview-remove" @click="removePhoto('after', idx)"></view>
</view>
<view v-if="report.after.length < 9" class="upload-box" @click="uploadPhoto('after')">
<span class="upload-icon"><AppIcon name="camera" :size="16" color="#94a3b8" /></span>
<span class="upload-tip">上传照片</span>
</view> </view>
<view v-if="report.after.length < 4" class="photo-add" @click="uploadPhoto('after')">
<AppIcon name="camera" :size="20" color="#bbbbb5" />
</view> </view>
</view> </view>
</view> </view>
@ -78,31 +87,27 @@
</view> </view>
<!-- 备注 --> <!-- 备注 -->
<view class="van-cell-group page-section form-section"> <view class="section">
<view class="module-title section-title"> <view class="section-head">
<span class="section-icon"><AppIcon name="orders" :size="12" /></span> <text class="section-label">备注</text>
<text>服务备注</text>
</view>
<view class="section-body">
<view class="form-field-wrap">
<view class="field-label">备注</view>
<textarea v-model="report.remark" class="van-field remark-textarea" placeholder="输入本次服务过程、状态、注意事项..." />
</view>
</view> </view>
<textarea v-model="report.remark" class="form-textarea" placeholder="服务细节、注意事项(可选)" />
</view> </view>
<view class="page-section form-submit-wrap"> <button class="submit-btn" :disabled="submitting" @click="submitReport">
<button class="van-button van-button--primary van-button--block report-submit-btn" @click="submitReport">提交并生成分享链接</button> {{ submitting ? '提交中…' : '生成报告' }}
</button>
</view> </view>
</div>
<!-- Loading 遮罩 -->
<view v-if="submitting" class="loading-mask"> <view v-if="submitting" class="loading-mask">
<view class="loading-box">提交中...</view> <view class="loading-box">
<view class="loading-spinner"></view>
<text>正在生成</text>
</view>
</view> </view>
</div>
<TabBar current-page="report" @change="goPage" /> <TabBar current-page="report" @change="goPage" />
</view>
</template> </template>
<script setup> <script setup>
@ -113,8 +118,6 @@ 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'
const emit = defineEmits(['change-page'])
const userInfo = getUserSession() const userInfo = getUserSession()
const storeInfo = getStoreSession() const storeInfo = getStoreSession()
const { goPage, navigateTo } = useNavigator() const { goPage, navigateTo } = useNavigator()
@ -135,10 +138,7 @@ const currentAppointmentId = ref(null)
const submitting = ref(false) const submitting = ref(false)
const serviceColumns = computed(() => const serviceColumns = computed(() =>
serviceTypes.value.map(s => ({ serviceTypes.value.map(s => ({ label: s.name, value: s.name }))
label: s.name,
value: s.name
}))
) )
const loadServiceTypes = async () => { const loadServiceTypes = async () => {
@ -149,18 +149,14 @@ const loadServiceTypes = async () => {
const uploadPhoto = (field) => { const uploadPhoto = (field) => {
const currentCount = report.value[field].length const currentCount = report.value[field].length
if (currentCount >= 9) return uni.showToast({ title: '最多上传9张', icon: 'none' }) if (currentCount >= 4) return uni.showToast({ title: '最多上传4张', icon: 'none' })
uni.chooseImage({ uni.chooseImage({
count: 9 - currentCount, count: 4 - currentCount,
success: (res) => { success: (res) => {
const tempPaths = res.tempFilePaths const tempPaths = res.tempFilePaths
uni.showLoading({ title: '上传中...' }) uni.showLoading({ title: '上传中...' })
let successCount = 0
let failCount = 0 let failCount = 0
const tasks = tempPaths.map(tempPath => {
const uploadTasks = tempPaths.map(tempPath => {
return new Promise((resolve) => { return new Promise((resolve) => {
uni.uploadFile({ uni.uploadFile({
url: `${BASE_URL}/upload/image`, url: `${BASE_URL}/upload/image`,
@ -171,45 +167,55 @@ const uploadPhoto = (field) => {
const data = JSON.parse(uploadRes.data) const data = JSON.parse(uploadRes.data)
if (data.code === 200) { if (data.code === 200) {
report.value[field].push(imgUrl(data.data.url)) report.value[field].push(imgUrl(data.data.url))
successCount++ } else { failCount++ }
} else { } catch (e) { failCount++ }
failCount++
}
} catch (e) {
failCount++
}
resolve() resolve()
}, },
fail: () => { fail: () => { failCount++; resolve() }
failCount++
resolve()
}
}) })
}) })
}) })
Promise.all(tasks).then(() => {
Promise.all(uploadTasks).then(() => {
uni.hideLoading() uni.hideLoading()
if (failCount > 0) { if (failCount > 0) uni.showToast({ title: `${failCount}张上传失败`, icon: 'none' })
uni.showToast({ title: `${failCount}张上传失败`, icon: 'none' })
}
}) })
} }
}) })
} }
const removePhoto = (field, idx) => { const removePhoto = (field, idx) => { report.value[field].splice(idx, 1) }
report.value[field].splice(idx, 1)
}
const onServiceConfirm = (e) => { const onServiceConfirm = (e) => {
const idx = e.detail.value report.value.serviceType = serviceColumns.value[e.detail.value].value
report.value.serviceType = serviceColumns.value[idx].value }
const serviceDate = computed(() => {
const raw = report.value.appointmentTime || ''
return raw.includes('T') ? raw.split('T')[0] : ''
})
const serviceTimePart = computed(() => {
const raw = report.value.appointmentTime || ''
if (!raw.includes('T')) return ''
return (raw.split('T')[1] || '').slice(0, 5)
})
const onServiceDateChange = (e) => {
const date = e?.detail?.value || ''
const time = serviceTimePart.value || '00:00'
report.value.appointmentTime = date ? `${date}T${time}` : ''
}
const onServiceTimeChange = (e) => {
const time = e?.detail?.value || ''
const date = serviceDate.value
if (!date) { uni.showToast({ title: '请先选择日期', icon: 'none' }); return }
report.value.appointmentTime = `${date}T${time}`
} }
const submitReport = async () => { const submitReport = async () => {
if (!report.value.petName) return uni.showToast({ title: '请输入宠物名字', icon: 'none' }) if (!report.value.petName) return uni.showToast({ title: '请输入宠物名字', icon: 'none' })
if (!report.value.serviceType) return uni.showToast({ title: '请选择服务类型', icon: 'none' }) if (!report.value.serviceType) return uni.showToast({ title: '请选择服务类型', icon: 'none' })
if (!report.value.appointmentTime) return uni.showToast({ title: '请选择服务时间', icon: 'none' })
submitting.value = true submitting.value = true
const payload = { const payload = {
appointmentId: currentAppointmentId.value || null, appointmentId: currentAppointmentId.value || null,
@ -225,12 +231,12 @@ const submitReport = async () => {
submitting.value = false submitting.value = false
if (res.code === 200) { if (res.code === 200) {
const token = res.data.reportToken const token = res.data.reportToken
uni.showToast({ title: '提交成功', icon: 'success' }) uni.showToast({ title: '已生成报告', icon: 'success' })
setTimeout(() => { setTimeout(() => {
report.value = { petName: '', serviceType: '', appointmentTime: '', before: [], after: [], remark: '' } report.value = { petName: '', serviceType: '', appointmentTime: '', before: [], after: [], remark: '' }
currentAppointmentId.value = null currentAppointmentId.value = null
uni.navigateTo({ url: `/pages/report-view/reportView?token=${token}` }) uni.navigateTo({ url: `/pages/report-view/reportView?token=${token}` })
}, 1000) }, 800)
} else { } else {
uni.showToast({ title: res.message || '提交失败', icon: 'none' }) uni.showToast({ title: res.message || '提交失败', icon: 'none' })
} }
@ -245,16 +251,26 @@ onMounted(async () => {
currentAppointmentId.value = prefill.appointmentId currentAppointmentId.value = prefill.appointmentId
report.value.petName = prefill.petName || '' report.value.petName = prefill.petName || ''
report.value.serviceType = prefill.serviceType || '' report.value.serviceType = prefill.serviceType || ''
report.value.appointmentTime = prefill.appointmentTime ? prefill.appointmentTime.slice(0, 16) : '' if (prefill.appointmentTime) {
const raw = String(prefill.appointmentTime)
if (raw.includes('T')) {
const [d, rest] = raw.split('T')
const hm = (rest || '').match(/^(\d{1,2}:\d{2})/)?.[1] || '00:00'
report.value.appointmentTime = `${d}T${hm}`
}
}
uni.removeStorageSync('petstore_report_prefill') uni.removeStorageSync('petstore_report_prefill')
} }
}) })
</script> </script>
<style scoped> <style scoped>
.report-page { padding-bottom: calc(140rpx + env(safe-area-inset-bottom)); } .report-page {
padding-bottom: calc(76px + env(safe-area-inset-bottom));
background: #fafaf8;
}
.report-nav { .report-nav {
padding: 14px 16px; padding: 0 16px 12px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -262,103 +278,209 @@ onMounted(async () => {
top: 0; top: 0;
z-index: 10; z-index: 10;
} }
.nav-back { font-size: 20px; color: #fff; }
.nav-title { font-size: 18px; font-weight: 700; color: #fff; } .report-body {
.nav-placeholder { width: 32px; } padding: 12px 16px 32px;
.report-url-input { margin-top: 8px; }
.btn-ghost { background: #fff !important; color: #666 !important; border: 1px solid #e0e0e0 !important; box-shadow: none !important; }
.notice-bar { padding: 10px 16px; font-size: 13px; }
.notice-bar.success { background: #f8fafc; color: #334155; border-left: 3px solid #16a34a; }
.result-wrap { padding: 0; }
.result-content { padding: 0; }
.result-link-row { display: flex; justify-content: space-between; align-items: center; }
.qr-wrap { text-align: center; padding: 20px; background: #f8fafc; border: 1px solid #e8edf4; border-radius: 14px; margin: 16px 0; }
.qr-img { width: 180px; height: 180px; }
.report-hero {
margin-top: 12px;
padding: 12px 14px;
border: 1px solid #e5ebf3;
border-radius: 14px;
background: #ffffff;
} }
.hero-title { font-size: 16px; font-weight: 700; color: #1f2937; }
.hero-sub { margin-top: 4px; font-size: 12px; color: #6b7280; line-height: 1.45; } /* 区块 */
.report-form .form-section { .section {
background: #fff; background: #fff;
border: 1px solid #e5ebf3;
border-radius: 16px; border-radius: 16px;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04); padding: 20px;
overflow: hidden; margin-bottom: 12px;
} }
.section-title { .section-head {
margin-bottom: 16px;
}
.section-label {
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
}
.section-hint {
display: block;
font-size: 12px;
color: #999993;
margin-top: 2px;
}
/* 表单 */
.form-row {
margin-bottom: 14px;
}
.form-row:last-child { margin-bottom: 0; }
.form-label {
font-size: 13px;
font-weight: 600;
color: #666660;
margin-bottom: 6px;
display: block;
}
.form-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;
}
.form-input:focus {
border-color: #2db96d;
background: #fff;
}
.form-picker {
display: flex;
align-items: center;
justify-content: space-between;
}
.placeholder { color: #bbbbb5; }
.picker-arrow { color: #bbbbb5; font-size: 18px; font-weight: 300; }
.time-row {
display: flex;
gap: 10px;
}
.time-picker { flex: 1; }
/* 照片对比区 */
.photo-compare {
display: flex;
gap: 0;
}
.photo-side {
flex: 1;
min-width: 0;
}
.photo-divider {
width: 1px;
background: #ebebea;
margin: 0 12px;
align-self: stretch;
}
.photo-side-label {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 12px 14px 4px; margin-bottom: 10px;
font-size: 13px; font-size: 13px;
color: #64748b; font-weight: 600;
font-weight: 700; color: #666660;
} }
.section-icon { .dot {
width: 18px; width: 8px;
height: 18px; height: 8px;
border-radius: 6px; border-radius: 50%;
background: #f8fafc; }
display: inline-flex; .dot-before { background: #f5913e; }
.dot-after { background: #2db96d; }
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.photo-thumb {
position: relative;
width: calc(50% - 4px);
aspect-ratio: 1;
border-radius: 10px;
overflow: hidden;
}
.thumb-img {
width: 100%;
height: 100%;
}
.thumb-del {
position: absolute;
top: 4px;
right: 4px;
width: 20px;
height: 20px;
background: rgba(0,0,0,0.5);
color: #fff;
border-radius: 50%;
font-size: 11px;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px solid #e2e8f0;
} }
.section-body { padding: 0 14px 10px; } .photo-add {
.form-field-wrap { padding: 0 0 10px; } width: calc(50% - 4px);
.field-label { aspect-ratio: 1;
margin: 0 0 6px; border: 1.5px dashed #ddddd8;
font-size: 13px; border-radius: 10px;
color: #64748b; display: flex;
font-weight: 600; align-items: center;
justify-content: center;
background: #fafaf8;
} }
.photo-row { display: flex; gap: 10px; }
.photo-col { flex: 1; min-width: 0; } /* 备注 */
.upload-wrap { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; margin-top: 6px; padding-top: 6px; } .form-textarea {
.upload-box { background: #f5f5f2;
width: 100px; border: 1.5px solid transparent;
height: 100px; border-radius: 12px;
border: 1px dashed #d8e0ea; padding: 12px 14px;
font-size: 14px;
color: #1a1a1a;
width: 100%;
box-sizing: border-box;
min-height: 100px;
line-height: 1.6;
}
.form-textarea:focus {
border-color: #2db96d;
background: #fff;
}
/* 提交按钮 */
.submit-btn {
width: 100%;
height: 48px;
border-radius: 14px; border-radius: 14px;
background: #1a1a1a;
color: #fff;
font-size: 16px;
font-weight: 700;
border: none;
margin-top: 8px;
letter-spacing: 0.5px;
transition: opacity 0.15s;
}
.submit-btn:active { opacity: 0.85; }
.submit-btn[disabled] { background: #ddddd8; color: #999993; }
/* Loading */
.loading-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.loading-box {
background: #fff;
padding: 24px 36px;
border-radius: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; gap: 12px;
color: #94a3b8; font-size: 14px;
cursor: pointer; color: #666660;
background: #fbfdff;
} }
.upload-icon { .loading-spinner {
width: 28px; width: 28px;
height: 28px; height: 28px;
border-radius: 8px; border: 2.5px solid #ebebea;
background: #f1f5f9; border-top-color: #2db96d;
display: inline-flex; border-radius: 50%;
align-items: center; animation: spin 0.7s linear infinite;
justify-content: center;
border: 1px solid #e2e8f0;
}
.upload-preview { position: relative; width: 100px; height: 100px; margin-top: 4px; margin-right: 4px; }
.preview-img { width: 100%; height: 100%; border-radius: 14px; object-fit: cover; border: 1px solid #e8edf4; }
.preview-remove { position: absolute; top: -8px; right: -8px; width: 22px; height: 22px; background: rgba(0,0,0,0.6); color: #fff; border-radius: 50%; font-size: 12px; display: flex; align-items: center; justify-content: center; z-index: 10; }
.loading-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 999; }
.loading-box { background: #fff; padding: 20px 40px; border-radius: 12px; font-size: 15px; }
.picker-field { display: flex; align-items: center; background: #fff; border-radius: 12px; padding: 8px 12px; color: #333; min-height: 44px; }
.form-section { margin: 12px 0 0; }
.form-submit-wrap { padding: 0 0 16px; }
.field-label-tight { margin-bottom: 0; }
.upload-tip { font-size: 12px; margin-top: 6px; color: #64748b; }
.remark-textarea {
min-height: 120px;
line-height: 1.5;
}
.report-submit-btn { margin-top: 6px !important; }
.result-content .van-button--primary {
box-shadow: 0 6px 14px rgba(34, 197, 94, 0.2) !important;
} }
@keyframes spin { to { transform: rotate(360deg); } }
</style> </style>

View File

@ -14,7 +14,10 @@ const SUB_ROUTE_MAP = {
myOrders: '/pages/mine/MyOrders' myOrders: '/pages/mine/MyOrders'
} }
export const navigateTo = (page) => { /**
* 应用内路由跳转勿与 uni.navigateTo 混淆此处按 page 名映射
*/
export const openAppPage = (page) => {
const rootUrl = ROOT_ROUTE_MAP[page] const rootUrl = ROOT_ROUTE_MAP[page]
if (rootUrl) { if (rootUrl) {
uni.reLaunch({ url: rootUrl }) uni.reLaunch({ url: rootUrl })
@ -26,3 +29,6 @@ export const navigateTo = (page) => {
uni.navigateTo({ url: subUrl }) uni.navigateTo({ url: subUrl })
} }
} }
/** @deprecated 与微信 API 同名易混,请优先使用 openAppPage */
export const navigateTo = openAppPage

View File

@ -1,173 +1,255 @@
/** 洗美报告分享海报绘制H5 / 小程序 canvas 2d 共用逻辑) */ /**
* 洗美报告分享海报 简约风格 v2
* H5 / 小程序 canvas 2d 共用
*/
export const POSTER_W = 750 export const POSTER_W = 750
export const POSTER_H = 1100 export const POSTER_H = 1200
export function roundRect(ctx, x, y, w, h, r) { const FONT = '"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif'
function roundRect(ctx, x, y, w, h, r) {
const rr = Math.min(r, w / 2, h / 2)
ctx.beginPath() ctx.beginPath()
ctx.moveTo(x + r, y) ctx.moveTo(x + rr, y)
ctx.lineTo(x + w - r, y) ctx.arcTo(x + w, y, x + w, y + h, rr)
ctx.quadraticCurveTo(x + w, y, x + w, y + r) ctx.arcTo(x + w, y + h, x, y + h, rr)
ctx.lineTo(x + w, y + h - r) ctx.arcTo(x, y + h, x, y, rr)
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h) ctx.arcTo(x, y, x + w, y, rr)
ctx.lineTo(x + r, y + h)
ctx.quadraticCurveTo(x, y + h, x, y + h - r)
ctx.lineTo(x, y + r)
ctx.quadraticCurveTo(x, y, x + r, y)
ctx.closePath() ctx.closePath()
} }
function fillRR(ctx, x, y, w, h, r, fill) {
ctx.fillStyle = fill
roundRect(ctx, x, y, w, h, r)
ctx.fill()
}
function wrapText(ctx, text, x, y, maxW, lineH, maxLines) {
if (!text) return y
const chars = String(text).split('')
let line = ''
let cy = y
let n = 0
for (let i = 0; i < chars.length; i++) {
const test = line + chars[i]
if (ctx.measureText(test).width > maxW && line) {
n++
if (maxLines && n >= maxLines) {
ctx.fillText(line + '…', x, cy)
return cy + lineH
}
ctx.fillText(line, x, cy)
line = chars[i]
cy += lineH
} else {
line = test
}
}
if (line) { ctx.fillText(line, x, cy); cy += lineH }
return cy
}
/** /**
* @param {CanvasRenderingContext2D} ctx * @param {CanvasRenderingContext2D} ctx
* @param {{
* storeName: string
* storePhone: string
* storeAddr: string
* petName: string
* serviceType: string
* timeStr: string
* staffName: string
* remark: string
* beforeImg: any | null
* afterImg: any | null
* }} data
*/ */
export function drawReportPoster(ctx, data) { export function drawReportPoster(ctx, data) {
const { const {
storeName, storeName, storePhone, storeAddr,
storePhone, petName, serviceType, timeStr, staffName,
storeAddr, remark, beforeImg, afterImg, qrImg,
petName, beforeCount, afterCount
serviceType,
timeStr,
staffName,
remark,
beforeImg,
afterImg
} = data } = data
ctx.fillStyle = '#ffffff' const name = storeName || '宠伴生活馆'
const PAD = 40
// ── 背景 ──
ctx.fillStyle = '#fafaf8'
ctx.fillRect(0, 0, POSTER_W, POSTER_H) ctx.fillRect(0, 0, POSTER_W, POSTER_H)
const gradient = ctx.createLinearGradient(0, 0, POSTER_W, 300) // ── 顶部品牌条 ──
gradient.addColorStop(0, '#07c160') const barH = 100
gradient.addColorStop(1, '#10b76f') ctx.fillStyle = '#1a1a1a'
ctx.fillStyle = gradient ctx.fillRect(0, 0, POSTER_W, barH)
ctx.fillRect(0, 0, POSTER_W, 300)
const name = storeName || '宠伴生活馆' ctx.textAlign = 'center'
ctx.fillStyle = '#ffffff' ctx.fillStyle = '#ffffff'
ctx.font = 'bold 36px sans-serif' ctx.font = `bold 30px ${FONT}`
ctx.textAlign = 'center' ctx.fillText(name, POSTER_W / 2, 60)
ctx.fillText(name, POSTER_W / 2, 70)
ctx.font = '20px sans-serif'
ctx.globalAlpha = 0.7
ctx.fillText('宠物服务,让爱更专业', POSTER_W / 2, 105)
ctx.globalAlpha = 1
if (storePhone || storeAddr) { // ── 照片对比区 ──
ctx.font = '18px sans-serif' const photoY = barH
ctx.globalAlpha = 0.85 const photoH = 360
const contactLine = [storePhone, storeAddr].filter(Boolean).join(' | ') const halfW = POSTER_W / 2
ctx.fillText(contactLine, POSTER_W / 2, 138)
ctx.globalAlpha = 1
}
ctx.fillStyle = '#333333'
ctx.font = 'bold 36px sans-serif'
ctx.fillText('服务报告', POSTER_W / 2, 220)
ctx.fillStyle = '#f8f6f3'
ctx.beginPath()
roundRect(ctx, 40, 260, 670, 220, 20)
ctx.fill()
const infoItems = [
['宠物名字', petName || '-'],
['服务项目', serviceType || '-'],
['服务时间', timeStr || '-'],
['服务技师', staffName || '-']
]
let y = 310
ctx.textAlign = 'left'
infoItems.forEach(([label, val]) => {
ctx.fillStyle = '#999999'
ctx.font = '22px sans-serif'
ctx.fillText(label, 80, y)
ctx.fillStyle = '#333333'
ctx.font = 'bold 24px sans-serif'
ctx.fillText(String(val), 220, y)
y += 48
})
ctx.fillStyle = '#f8f6f3'
ctx.beginPath()
roundRect(ctx, 40, 500, 670, 360, 20)
ctx.fill()
ctx.fillStyle = '#333333'
ctx.font = 'bold 24px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('服务前后对比', POSTER_W / 2, 545)
const imgY = 575
const imgH = 260
const imgW = 300
ctx.fillStyle = '#e0e0e0'
ctx.beginPath()
roundRect(ctx, 60, imgY, imgW, imgH, 16)
ctx.fill()
ctx.fillStyle = '#999999'
ctx.font = '20px sans-serif'
ctx.fillText('服务前', 210, imgY + imgH / 2)
ctx.fillStyle = '#e0e0e0'
ctx.beginPath()
roundRect(ctx, 390, imgY, imgW, imgH, 16)
ctx.fill()
ctx.fillStyle = '#999999'
ctx.fillText('服务后', 540, imgY + imgH / 2)
if (remark) {
ctx.fillStyle = '#f8f6f3'
ctx.beginPath()
roundRect(ctx, 40, 880, 670, 100, 20)
ctx.fill()
ctx.fillStyle = '#666666'
ctx.font = '22px sans-serif'
ctx.textAlign = 'left'
if (remark.length > 30) {
ctx.fillText(remark.substring(0, 30), 70, 920)
ctx.fillText(remark.substring(30), 70, 955)
} else {
ctx.fillText(remark, 70, 930)
}
}
ctx.fillStyle = '#0f172a'
ctx.font = '20px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('长按识别小程序 · 欢迎再次预约', POSTER_W / 2, 1020)
ctx.fillStyle = '#07c160'
ctx.font = 'bold 22px sans-serif'
ctx.fillText(`${name}`, POSTER_W / 2, 1055)
if (beforeImg) { if (beforeImg) {
ctx.save() ctx.save()
ctx.beginPath() ctx.beginPath()
roundRect(ctx, 60, imgY, imgW, imgH, 16) ctx.rect(0, photoY, halfW - 1.5, photoH)
ctx.clip() ctx.clip()
ctx.drawImage(beforeImg, 60, imgY, imgW, imgH) const bw = beforeImg.width || halfW
const bh = beforeImg.height || photoH
const scale = Math.max(halfW / bw, photoH / bh)
const dw = bw * scale
const dh = bh * scale
ctx.drawImage(beforeImg, (halfW - dw) / 2, photoY + (photoH - dh) / 2, dw, dh)
ctx.restore() ctx.restore()
} else {
ctx.fillStyle = '#f0f0ee'
ctx.fillRect(0, photoY, halfW - 1.5, photoH)
ctx.fillStyle = '#bbbbb5'
ctx.font = `14px ${FONT}`
ctx.textAlign = 'center'
ctx.fillText('暂无照片', halfW / 2, photoY + photoH / 2 + 5)
} }
if (afterImg) { if (afterImg) {
ctx.save() ctx.save()
ctx.beginPath() ctx.beginPath()
roundRect(ctx, 390, imgY, imgW, imgH, 16) ctx.rect(halfW + 1.5, photoY, halfW - 1.5, photoH)
ctx.clip() ctx.clip()
ctx.drawImage(afterImg, 390, imgY, imgW, imgH) const aw = afterImg.width || halfW
const ah = afterImg.height || photoH
const scale = Math.max(halfW / aw, photoH / ah)
const dw = aw * scale
const dh = ah * scale
ctx.drawImage(afterImg, halfW + 1.5 + (halfW - dw) / 2, photoY + (photoH - dh) / 2, dw, dh)
ctx.restore() ctx.restore()
} else {
ctx.fillStyle = '#f0f0ee'
ctx.fillRect(halfW + 1.5, photoY, halfW - 1.5, photoH)
ctx.fillStyle = '#bbbbb5'
ctx.font = `14px ${FONT}`
ctx.textAlign = 'center'
ctx.fillText('暂无照片', halfW + halfW / 2, photoY + photoH / 2 + 5)
} }
// 白色中间分隔
ctx.fillStyle = '#ffffff'
ctx.fillRect(halfW - 1.5, photoY, 3, photoH)
// Before / After 标签
const tagW = 90
const tagH = 28
const tagY = photoY + photoH - 12 - tagH
fillRR(ctx, 12, tagY, tagW, tagH, 6, 'rgba(245, 145, 62, 0.9)')
ctx.fillStyle = '#fff'
ctx.font = `bold 13px ${FONT}`
ctx.textAlign = 'center'
ctx.fillText('BEFORE', 12 + tagW / 2, tagY + 19)
fillRR(ctx, halfW + 12, tagY, tagW, tagH, 6, 'rgba(45, 185, 109, 0.9)')
ctx.fillStyle = '#fff'
ctx.fillText('AFTER', halfW + 12 + tagW / 2, tagY + 19)
// 中间对比提示
fillRR(ctx, POSTER_W / 2 - 38, photoY + photoH / 2 - 16, 76, 32, 16, 'rgba(26, 26, 26, 0.82)')
ctx.fillStyle = '#fff'
ctx.font = `bold 12px ${FONT}`
ctx.fillText('前后对比', POSTER_W / 2, photoY + photoH / 2 + 5)
// 数量信息
const metaY = photoY + photoH + 18
const metaW = (POSTER_W - PAD * 2 - 12) / 2
const metaRightX = PAD + metaW + 12
fillRR(ctx, PAD, metaY, metaW, 42, 12, '#fff7f0')
fillRR(ctx, metaRightX, metaY, metaW, 42, 12, '#f3fbf6')
ctx.font = `bold 14px ${FONT}`
ctx.fillStyle = '#f5913e'
ctx.fillText(`服务前 ${beforeCount || 0}`, PAD + metaW / 2, metaY + 27)
ctx.fillStyle = '#2db96d'
ctx.fillText(`服务后 ${afterCount || 0}`, metaRightX + metaW / 2, metaY + 27)
// ── 白色内容卡 ──
const cardY = metaY + 58
const cardX = PAD
const cardW = POSTER_W - PAD * 2
const cardH = POSTER_H - cardY - 100
fillRR(ctx, cardX, cardY, cardW, cardH, 20, '#ffffff')
// 标题与宠物信息
ctx.textAlign = 'left'
let cy = cardY + 36
ctx.fillStyle = '#1a1a1a'
ctx.font = `bold 32px ${FONT}`
ctx.fillText(petName || '—', cardX + 28, cy)
cy += 12
ctx.fillStyle = '#999993'
ctx.font = `14px ${FONT}`
ctx.fillText(serviceType || '', cardX + 28, cy + 20)
cy += 48
// 信息卡标题
ctx.fillStyle = '#999993'
ctx.font = `bold 12px ${FONT}`
ctx.fillText('服务详情', cardX + 28, cy - 6)
// 分隔线
ctx.fillStyle = '#f0f0ee'
ctx.fillRect(cardX + 28, cy, cardW - 56, 1)
cy += 20
// 信息行
const infoRows = [
['时间', timeStr || '—'],
['技师', staffName || '—']
]
ctx.font = `14px ${FONT}`
infoRows.forEach(([k, v]) => {
ctx.fillStyle = '#bbbbb5'
ctx.fillText(k, cardX + 28, cy)
ctx.fillStyle = '#1a1a1a'
ctx.font = `bold 15px ${FONT}`
ctx.fillText(String(v), cardX + 100, cy)
ctx.font = `14px ${FONT}`
cy += 36
})
// 备注
if (remark && String(remark).trim()) {
cy += 8
ctx.fillStyle = '#f5f5f2'
roundRect(ctx, cardX + 20, cy - 6, cardW - 40, 80, 12)
ctx.fill()
ctx.fillStyle = '#666660'
ctx.font = `13px ${FONT}`
wrapText(ctx, String(remark).trim(), cardX + 34, cy + 16, cardW - 72, 20, 3)
cy += 92
}
// 二维码(可选)
if (qrImg) {
const qrSize = 120
const qx = POSTER_W / 2 - qrSize / 2
const qy = cy + 10
ctx.save()
roundRect(ctx, qx, qy, qrSize, qrSize, 10)
ctx.clip()
ctx.drawImage(qrImg, qx, qy, qrSize, qrSize)
ctx.restore()
cy = qy + qrSize + 10
ctx.fillStyle = '#bbbbb5'
ctx.font = `12px ${FONT}`
ctx.textAlign = 'center'
ctx.fillText('扫码查看报告详情', POSTER_W / 2, cy + 4)
}
// ── 底部品牌 ──
const footY = POSTER_H - 56
ctx.textAlign = 'center'
ctx.fillStyle = '#ebebea'
ctx.fillRect(POSTER_W / 2 - 16, footY, 32, 2)
ctx.fillStyle = '#1a1a1a'
ctx.font = `bold 16px ${FONT}`
ctx.fillText(name, POSTER_W / 2, footY + 26)
ctx.fillStyle = '#bbbbb5'
ctx.font = `11px ${FONT}`
ctx.fillText('宠物服务 · 让爱更专业', POSTER_W / 2, footY + 44)
} }