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": [],
"include": []
},
"appid": "wx4fe9ae1e083c1c4f",
"appid": "wx8ca2dfa89af72edf",
"editorSetting": {}
}

View File

@ -41,7 +41,8 @@ page {
margin: 0;
padding: 0;
box-sizing: border-box;
background: #f5f5f5;
background: #fafaf8;
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #1a1a1a;
}
</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 })
/** 微信小程序getPhoneNumber 返回的 detail.code */
export const wxPhoneLogin = (phoneCode) => post('/user/wx-phone-login', { phoneCode })
// 注册老板
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 getAppointmentDetail = (id) => get('/appointment/detail', { id })
// 创建预约
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 {
margin: 0;
padding: 0;
box-sizing: border-box;
background: linear-gradient(180deg, #f7f9fc 0%, #f3f6fa 100%);
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', Helvetica, Arial, sans-serif;
color: #1f2937;
background: var(--c-bg);
font-family: var(--font);
color: var(--c-text);
font-size: 15px;
-webkit-font-smoothing: antialiased;
}
/* ---- 输入框 ---- */
.van-field {
background: #ffffff !important;
border: 1px solid #dfe5ee !important;
border-radius: 12px !important;
padding: 10px 14px !important;
background: var(--c-surface-alt) !important;
border: 1.5px solid transparent !important;
border-radius: var(--radius) !important;
padding: 11px 14px !important;
font-size: 15px !important;
color: #1f2937 !important;
color: var(--c-text) !important;
min-height: 44px !important;
width: 100% !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 {
border-color: #22c55e !important;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.14) !important;
border-color: var(--c-brand) !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 {
border-radius: 12px !important;
border-radius: var(--radius) !important;
font-size: 15px !important;
height: 44px !important;
line-height: 44px !important;
height: 46px !important;
line-height: 46px !important;
font-weight: 600 !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
transition: opacity 0.15s, transform 0.15s;
box-sizing: border-box !important;
}
.van-button--primary {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%) !important;
border: 1px solid #16a34a !important;
color: #fff !important;
box-shadow: 0 8px 20px rgba(34, 197, 94, 0.28) !important;
}
.van-button--primary:active { transform: translateY(1px); }
.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;
.van-button:active { opacity: 0.85; transform: scale(0.98); }
.van-button--primary,
button.van-button--primary {
background-color: #2db96d !important;
background: #2db96d !important;
border: none !important;
color: #ffffff !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 {
background: #fff !important;
border-radius: 18px !important;
background: var(--c-surface) !important;
border-radius: var(--radius-lg) !important;
overflow: hidden;
margin: 0 !important;
border: 1px solid #e7edf5;
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.04);
border: none;
box-shadow: var(--c-shadow-sm);
}
.van-cell {
padding: 14px 16px !important;
background: #fff !important;
background: var(--c-surface) !important;
font-size: 15px !important;
min-height: 48px !important;
}
.van-cell::after {
left: 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__title { color: #111827 !important; font-size: 15px !important; }
.van-cell__value { color: #6b7280 !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-cell--link:active { background: var(--c-surface-alt) !important; }
.van-cell__title { color: var(--c-text) !important; font-size: 15px !important; }
.van-cell__value { color: var(--c-text-2) !important; font-size: 14px !important; }
/* ---- 标签 ---- */
.van-tag {
border-radius: 6px !important;
font-size: 12px !important;
padding: 2px 8px !important;
font-weight: 500;
}
.van-tag--warning { background: #fff3e8 !important; color: #ff6b35 !important; }
.van-tag--primary { background: #e8f7ef !important; color: #07c160 !important; }
.van-tag--success { background: #f0f0f0 !important; color: #888 !important; }
.van-tag--default { background: #f5f5f5 !important; color: #999 !important; }
.van-tag--warning { background: var(--c-orange-light) !important; color: var(--c-orange) !important; }
.van-tag--primary { background: var(--c-brand-light) !important; color: var(--c-brand) !important; }
.van-tag--success { background: var(--c-surface-alt) !important; color: var(--c-text-3) !important; }
.van-tag--default { background: var(--c-surface-alt) !important; color: var(--c-text-4) !important; }
/* ---- 通用卡片容器 ---- */
/* ---- 卡片 ---- */
.card-section {
background: #fff;
border-radius: 16px;
background: var(--c-surface);
border-radius: var(--radius-lg);
overflow: hidden;
margin: 0 16px;
}
/* ---- 通用弹窗 ---- */
/* ---- 弹窗 ---- */
.popup-mask {
position: fixed;
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;
display: flex;
align-items: flex-end;
justify-content: center;
}
.popup-content {
background: #fff;
border-radius: 20px 20px 0 0;
background: var(--c-surface);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
width: 100%;
max-height: 85vh;
display: flex;
@ -137,80 +185,57 @@ button::after { border: none !important; }
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 20px 14px;
border-bottom: 1px solid #f0f0f0;
padding: 20px 20px 16px;
flex-shrink: 0;
}
.popup-title { font-size: 17px; font-weight: 600; color: #1a1a1a; }
.popup-close { font-size: 20px; color: #bbb; }
.popup-body { padding: 16px 20px; overflow-y: auto; flex: 1; }
.popup-footer { padding: 8px 20px max(env(safe-area-inset-bottom), 20px); flex-shrink: 0; }
.popup-title { font-size: 17px; font-weight: 700; color: var(--c-text); }
.popup-close { font-size: 20px; color: var(--c-text-4); }
.popup-body { padding: 0 20px 20px; overflow-y: auto; flex: 1; }
.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 {
font-size: 14px;
color: #666;
margin-bottom: 8px;
font-size: 13px;
color: var(--c-text-2);
margin-bottom: 6px;
margin-top: 14px;
font-weight: 500;
font-weight: 600;
letter-spacing: 0.2px;
}
.field-label:first-child { margin-top: 0; }
/* ---- 表单卡片(用于登录/注册页) ---- */
/* ---- 表单卡片 ---- */
.form-card {
background: #fff;
border-radius: 22px;
background: var(--c-surface);
border-radius: var(--radius-xl);
padding: 28px 24px;
border: 1px solid #e8edf4;
box-shadow: 0 14px 40px rgba(15, 23, 42, 0.12);
box-shadow: var(--c-shadow);
}
.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;
}
/* ---- 主色调 ---- */
: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 样式(顶部状态栏) ---- */
/* ---- Tabs ---- */
.van-tabs {
display: flex;
background: #fff;
border-bottom: 1px solid #f0f0f0;
background: var(--c-surface);
padding: 0 16px;
}
.van-tabs__tab {
flex: 1;
text-align: center;
padding: 12px 0;
font-size: 15px;
color: #999;
padding: 13px 0;
font-size: 14px;
color: var(--c-text-3);
cursor: pointer;
position: relative;
font-weight: 500;
}
.van-tabs__tab.active {
color: #07c160;
font-weight: 600;
color: var(--c-text);
font-weight: 700;
}
.van-tabs__tab.active::after {
content: '';
@ -218,103 +243,84 @@ button::after { border: none !important; }
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 40px;
height: 3px;
background: #07c160;
width: 20px;
height: 2.5px;
background: var(--c-brand);
border-radius: 2px;
}
/* ---- 通用页面头部 ---- */
/* ---- 导航头 ---- */
[class*='-nav'] {
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
border-bottom-left-radius: var(--radius-lg);
border-bottom-right-radius: var(--radius-lg);
box-sizing: border-box;
}
.nav-title {
letter-spacing: 0.3px;
font-size: 17px;
font-weight: 700;
color: #fff;
letter-spacing: 0.2px;
}
.nav-back {
font-weight: 700;
width: 28px;
height: 28px;
border-radius: 8px;
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: 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;
}
.nav-back:active {
transform: scale(0.94);
opacity: 0.9;
background: rgba(255, 255, 255, 0.15);
.nav-back:active { opacity: 0.7; }
.nav-gradient {
background-color: #2db96d !important;
background: #2db96d !important;
}
/* ---- 空状态统一 ---- */
/* ---- 空状态 ---- */
.empty {
margin: 24px 16px;
padding: 22px 16px;
background: #ffffff;
border: 1px solid var(--pet-border);
border-radius: 14px;
color: var(--pet-subtext);
padding: 32px 16px;
background: var(--c-surface);
border-radius: var(--radius-lg);
color: var(--c-text-3);
text-align: center;
font-size: 14px;
}
/* ---- 页面布局工具类 ---- */
/* ---- 页面布局 ---- */
.page-shell {
min-height: 100vh;
background: var(--pet-bg);
background: var(--c-bg);
padding-bottom: 120rpx;
}
.page-section {
margin: var(--space-16);
}
.section-gap {
margin-top: var(--space-12);
}
.page-section { margin: 16px; }
.section-gap { margin-top: 12px; }
/* ---- 常用内容间距 ---- */
/* ---- 文本 ---- */
.module-title {
font-size: 12px;
color: #94a3b8;
color: var(--c-text-3);
font-weight: 700;
margin-bottom: var(--space-8);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
}
.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,
.order-item,
.gallery-item,
.menu-card,
.card-section,
.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;
}
.van-button:active,
@ -323,6 +329,6 @@ button::after { border: none !important; }
.menu-card:active,
.card-section:active,
.user-card:active {
transform: translateY(1px) scale(0.995);
opacity: 0.97;
transform: scale(0.98);
opacity: 0.9;
}

View File

@ -1,17 +1,6 @@
<template>
<view class="app-icon" :style="{ width: `${size}px`, height: `${size}px` }">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<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>
<!-- 小程序对内联 <svg> 支持差易显示为灰块 data URI 背景图统一绘制 -->
<view class="app-icon" :style="iconWrapStyle" />
</template>
<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>
<style scoped>
.app-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
}
.app-icon svg {
width: 100%;
height: 100%;
vertical-align: middle;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,89 +1,76 @@
<template>
<div class="login-page">
<!-- Logo区 -->
<div class="login-logo">
<div class="login-logo-text">宠伴生活馆</div>
<div class="login-logo-sub">宠物服务让爱更专业</div>
</div>
<view class="login-page">
<view class="login-brand">
<text class="brand-name">宠伴生活馆</text>
<text class="brand-sub">宠物服务让爱更专业</text>
</view>
<!-- 老板注册成功 -->
<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 v-if="view === 'login'" class="card">
<text class="card-title">登录</text>
<input v-model="loginForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" />
<view class="sms-row">
<input v-model="loginForm.code" type="digit" class="field sms-input" placeholder="验证码" maxlength="6" />
<button class="sms-btn" :disabled="smsCountdown > 0" @click="handleSendSms">{{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }}</button>
</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>
<button class="primary-btn" :disabled="loginLoading" @click="handleLogin">{{ loginLoading ? '登录中' : '登录' }}</button>
<!-- 注册老板 -->
<div v-else-if="view === 'boss-reg'" class="form-card">
<div class="form-title">商家入驻</div>
<input v-model="bossForm.storeName" class="van-field" placeholder="店铺名称" />
<input v-model="bossForm.bossName" class="van-field" placeholder="您的姓名" />
<input v-model="bossForm.phone" type="tel" class="van-field" placeholder="手机号" maxlength="11" />
<input v-model="bossForm.password" type="password" class="van-field" placeholder="登录密码至少6位" />
<button class="van-button van-button--primary van-button--block" :loading="regLoading" @click="handleRegisterBoss">提交申请</button>
<div class="links top-gap-md">
<span class="link" @click="view = 'login'">返回登录</span>
</div>
</div>
<view class="divider">
<view class="divider-line"></view>
<text class="divider-text"></text>
<view class="divider-line"></view>
</view>
<!-- 注册员工 -->
<div v-else-if="view === 'staff-reg'" class="form-card">
<div class="form-title">员工注册</div>
<div class="invite-hint">请输入店长提供的邀请码加入团队</div>
<input v-model="staffForm.inviteCode" class="van-field" placeholder="请输入8位邀请码" maxlength="8" />
<input v-model="staffForm.name" class="van-field" placeholder="您的姓名" />
<input v-model="staffForm.phone" type="tel" class="van-field" placeholder="手机号" maxlength="11" />
<input v-model="staffForm.password" type="password" class="van-field" placeholder="登录密码至少6位" />
<button class="van-button van-button--primary van-button--block" :loading="regLoading" @click="handleRegisterStaff">注册</button>
<div class="links top-gap-md">
<span class="link" @click="view = 'login'">返回登录</span>
</div>
</div>
</div>
<!-- #ifdef MP-WEIXIN -->
<button
class="ghost-btn"
open-type="getPhoneNumber"
:disabled="loginLoading"
@getphonenumber="onWxGetPhoneNumber"
>{{ loginLoading ? '登录中…' : '微信授权登录' }}</button>
<!-- #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>
<script setup>
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 { navigateTo } from '../../utils/globalState.js'
import { openAppPage } from '../../utils/globalState.js'
const view = ref('login')
const loginForm = reactive({ phone: '13800138001', code: '123456' })
@ -92,12 +79,53 @@ const staffForm = reactive({ inviteCode: '', name: '', phone: '', password: '' }
const loginLoading = ref(false)
const regLoading = ref(false)
const smsCountdown = ref(0)
const bossRegistered = ref(false)
const staffRegistered = ref(false)
const regResult = ref({ store: {}, user: {} })
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 handleSendSms = async () => {
@ -124,7 +152,7 @@ const handleLogin = async () => {
if (res.code === 200) {
setUserSession(res.data.user)
setStoreSession(res.data.store)
navigateTo('home')
openAppPage('home')
} else {
showToast(res.message || '登录失败')
}
@ -135,14 +163,42 @@ const handleLogin = async () => {
}
}
const handleWechatLogin = () => {
showToast('跳转到微信授权...')
setTimeout(() => {
const demoUser = { id: 99, name: '微信用户', phone: '', role: 'staff' }
setUserSession(demoUser)
setStoreSession({ id: 2, name: '宠伴生活馆测试店' })
navigateTo('home')
}, 1500)
/** 非微信小程序:无法调起手机号授权 */
const onWechatLoginNotInMp = () => {
showToast('微信授权登录仅支持在微信小程序内使用,请使用验证码登录')
}
/**
* 微信小程序用户同意授权后携带 code服务端换手机号并登录
* @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 () => {
@ -154,9 +210,29 @@ const handleRegisterBoss = async () => {
regLoading.value = true
const res = await registerBoss(f)
regLoading.value = false
if (res.code === 200) {
regResult.value = res.data
bossRegistered.value = true
if (isHttpOk(res)) {
const parsed = parseBossRegData(res.data)
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 {
showToast(res.message || '注册失败')
}
@ -171,61 +247,197 @@ const handleRegisterStaff = async () => {
regLoading.value = true
const res = await registerStaff(f)
regLoading.value = false
if (res.code === 200) {
regResult.value = res.data
staffRegistered.value = true
if (isHttpOk(res)) {
const parsed = parseStaffRegData(res.data)
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 {
showToast(res.message || '注册失败')
}
}
const copyCode = () => {
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)
})
onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
</script>
<style scoped>
.login-page {
min-height: 100vh;
background: radial-gradient(circle at top left, #22c55e 0%, #16a34a 46%, #15803d 100%);
background: #fafaf8;
display: flex;
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>

View File

@ -1,77 +1,88 @@
<template>
<div class="mine-page">
<!-- 用户卡片点击进入个人信息页 -->
<div class="user-card" :style="userCardSafeStyle" @click="navigateTo('profile')">
<div class="user-info">
<div class="user-avatar" :style="avatarStyle">
<img v-if="userInfo.avatar" :src="userInfo.avatar" class="avatar-img" />
<span v-else class="avatar-initials">{{ initials }}</span>
</div>
<div class="user-info-content">
<div class="user-name">{{ userInfo.name }}</div>
<div class="user-phone">{{ userInfo.phone }}</div>
<div class="role-pill">{{ userInfo.role === 'boss' ? '店长' : userInfo.role === 'customer' ? '客户' : '员工' }}</div>
</div>
</div>
</div>
<view class="mine-page">
<!-- 用户卡片 -->
<view class="user-card" :style="userCardSafeStyle" @click="openProfile">
<view class="user-row">
<view class="user-avatar" :style="avatarBg">
<image v-if="userInfo.avatar" :src="userInfo.avatar" class="avatar-img" mode="aspectFill" />
<text v-else class="avatar-letter">{{ initials }}</text>
</view>
<view class="user-meta">
<text class="user-name">{{ userInfo.name || '未设置' }}</text>
<text class="user-phone">{{ userInfo.phone || '' }}</text>
</view>
<view class="role-tag">{{ roleText }}</view>
</view>
</view>
<!-- 老板菜单 -->
<div v-if="userInfo.role === 'boss'" class="menu-section">
<div class="menu-card">
<div class="menu-title">店铺管理</div>
<div class="menu-item" @click="navigateTo('staff')">
<div class="menu-left"><span class="menu-icon"><AppIcon name="staff" :size="15" /></span><span class="menu-text">员工管理</span></div>
<span class="menu-arrow"></span>
</div>
<div class="menu-item" @click="navigateTo('serviceType')">
<div class="menu-left"><span class="menu-icon"><AppIcon name="service" :size="15" /></span><span class="menu-text">服务类型</span></div>
<span class="menu-arrow"></span>
</div>
<div class="menu-item" @click="navigateTo('store')">
<div class="menu-left"><span class="menu-icon"><AppIcon name="store" :size="15" /></span><span class="menu-text">店铺设置</span></div>
<span class="menu-arrow"></span>
</div>
</div>
</div>
<!-- 店铺管理 -->
<view v-if="userInfo.role === 'boss'" class="menu-group">
<text class="menu-label">店铺管理</text>
<view class="menu-card">
<view class="menu-item" @click="goStaffPage">
<view class="menu-icon-wrap"><AppIcon name="staff" :size="16" color="#666660" /></view>
<text class="menu-text">员工管理</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goServiceTypePage">
<view class="menu-icon-wrap"><AppIcon name="service" :size="16" color="#666660" /></view>
<text class="menu-text">服务类型</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goStorePage">
<view class="menu-icon-wrap"><AppIcon name="store" :size="16" color="#666660" /></view>
<text class="menu-text">店铺设置</text>
<text class="menu-arrow"></text>
</view>
</view>
</view>
<!-- 通用菜单 -->
<div class="menu-section">
<div class="menu-card">
<div class="menu-title">个人中心</div>
<div class="menu-item" @click="navigateTo('profile')">
<div class="menu-left"><span class="menu-icon"><AppIcon name="profile" :size="15" /></span><span class="menu-text">个人信息</span></div>
<span class="menu-arrow"></span>
</div>
<div v-if="userInfo.role !== 'customer'" class="menu-item" @click="navigateTo('myReports')">
<div class="menu-left"><span class="menu-icon"><AppIcon name="report" :size="15" /></span><span class="menu-text">我的报告</span></div>
<span class="menu-arrow"></span>
</div>
<div class="menu-item" @click="navigateTo('myOrders')">
<div class="menu-left"><span class="menu-icon"><AppIcon name="orders" :size="15" /></span><span class="menu-text">我的订单</span></div>
<span class="menu-arrow"></span>
</div>
</div>
</div>
<!-- 个人 -->
<view class="menu-group">
<text class="menu-label">个人中心</text>
<view class="menu-card">
<view class="menu-item" @click="openProfile">
<view class="menu-icon-wrap"><AppIcon name="profile" :size="16" color="#666660" /></view>
<text class="menu-text">个人信息</text>
<text class="menu-arrow"></text>
</view>
<view v-if="userInfo.role !== 'customer'" class="menu-item" @click="goMyReportsPage">
<view class="menu-icon-wrap"><AppIcon name="report" :size="16" color="#666660" /></view>
<text class="menu-text">我的报告</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goMyOrdersPage">
<view class="menu-icon-wrap"><AppIcon name="orders" :size="16" color="#666660" /></view>
<text class="menu-text">我的订单</text>
<text class="menu-arrow"></text>
</view>
</view>
</view>
<!-- 退出登录 -->
<div class="menu-section">
<button class="logout-btn" @click="logout"><AppIcon name="logout" :size="15" color="#dc2626" />退出登录</button>
</div>
</div>
<TabBar current-page="mine" @change="goPage" />
<view class="logout-wrap">
<button class="logout-btn" @click="logout">退出登录</button>
</view>
<TabBar current-page="mine" @change="goPage" />
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { computed } from 'vue'
import TabBar from '../../components/TabBar.vue'
import AppIcon from '../../components/AppIcon.vue'
import { clearSession, getUserSession } from '../../utils/session.js'
import { useNavigator } from '../../composables/useNavigator.js'
const emit = defineEmits(['change-page'])
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 statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let safeTop = statusBarHeight + 12
@ -87,13 +98,19 @@ const initials = computed(() => {
return userInfo.name.slice(0, 1).toUpperCase()
})
const avatarStyle = computed(() => {
const avatarBg = computed(() => {
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
return { background: colors[idx] }
})
const roleText = computed(() => {
if (userInfo.role === 'boss') return '店长'
if (userInfo.role === 'customer') return '客户'
return '员工'
})
const logout = () => {
uni.showModal({
title: '提示',
@ -101,7 +118,7 @@ const logout = () => {
success: (res) => {
if (res.confirm) {
clearSession()
navigateTo('login')
openAppPage('login')
}
}
})
@ -109,90 +126,127 @@ const logout = () => {
</script>
<style scoped>
.mine-page { padding-bottom: 140rpx; background: #f5f7fb; min-height: 100vh; }
.user-card {
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
border-radius: 0 0 24px 24px;
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;
.mine-page {
padding-bottom: calc(76px + env(safe-area-inset-bottom));
background: #fafaf8;
min-height: 100vh;
}
/* 用户卡片 */
.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>

View File

@ -2,7 +2,7 @@
<div class="page-shell orders-page">
<!-- 顶部导航 -->
<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>
<view class="nav-placeholder"></view>
</view>
@ -25,7 +25,7 @@
<!-- 订单列表 -->
<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-title">{{ item.title }}</div>
<view :class="`van-tag van-tag--${statusTagBg(item.status)}`">{{ item.statusText }}</view>
@ -42,14 +42,14 @@
</div>
<div v-if="userInfo.role !== 'customer'">
<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" @click="cancelService(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.stop="cancelService(item)">取消</button>
</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 v-else>
<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>
@ -71,6 +71,15 @@ import { getAppointmentStatusText, getAppointmentTagType } from '../../utils/app
const emit = defineEmits(['change-page'])
const userInfo = getUserSession()
const currentUserId = userInfo.id
const goBackToMine = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('mine')
}
const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
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) => {
uni.setStorageSync('petstore_report_prefill', JSON.stringify({
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-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-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; }

View File

@ -2,7 +2,7 @@
<div class="page-shell my-reports-page">
<!-- 顶部导航 -->
<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>
<view class="nav-placeholder"></view>
</view>
@ -80,8 +80,13 @@ const loadReports = async () => {
if (res.code === 200) reportList.value = res.data
}
const goHome = () => {
navigateTo('home')
const goBackToMine = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('mine')
}
const viewReport = (r) => {
@ -104,10 +109,6 @@ onMounted(() => loadReports())
<style scoped>
.my-reports-page { padding: 0 0 120rpx; }
.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 {
margin-top: 12px;
padding: 14px 16px;
@ -117,7 +118,7 @@ onMounted(() => loadReports())
}
.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-count { font-weight: 700; color: #16a34a; }
.hero-count { font-weight: 700; color: #2db96d; }
.placeholder-icon {
width: 36px;
height: 36px;

View File

@ -2,7 +2,7 @@
<div class="page-shell profile-page">
<!-- 顶部导航 -->
<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>
<view class="nav-placeholder"></view>
</view>
@ -99,7 +99,7 @@
<view class="field-label field-label-gap">验证码</view>
<view class="sms-row">
<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' : '获取验证码' }}
</button>
</view>
@ -120,6 +120,15 @@ import { updateUser, sendSms as sendSmsApi, imgUrl, BASE_URL } from '../../api/i
import { getUserSession, setUserSession } from '../../utils/session.js'
const emit = defineEmits(['change-page'])
const goBackToMine = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
return
}
navigateTo('mine')
}
const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44
@ -147,7 +156,7 @@ const initials = computed(() => {
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 idx = (userInfo.value.name?.charCodeAt(0) || 0) % COLORS.length
return { background: COLORS[idx] }

View File

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

View File

@ -1,21 +1,19 @@
<template>
<div class="page-shell staff-page">
<!-- 顶部导航 -->
<view class="page-shell staff-page">
<view class="staff-nav nav-gradient" :style="navSafeStyle">
<text class="nav-back" @click="navigateTo('mine')"><AppIcon name="back" :size="18" color="#ffffff" /></text>
<text class="nav-title">员工管理</text>
<view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<view class="nav-title">员工管理</view>
<view class="nav-placeholder"></view>
</view>
<!-- 邀请码 -->
<view class="van-cell-group page-section card-section invite-card">
<view class="van-cell">
<view class="van-cell__title">员工邀请码</view>
<view class="van-cell__value invite-code-cell">
<text class="invite-code">{{ storeInfo.inviteCode }}</text>
<button class="van-button van-button--small van-button--primary" @click="copyCode">复制</button>
</view>
<!-- 邀请码标题与码+按钮分行同一行内码与复制垂直居中 -->
<view class="page-section invite-wrap">
<text class="invite-kicker">员工邀请码</text>
<view class="invite-code-row">
<text class="invite-code-text" selectable>{{ inviteCodeText }}</text>
<button class="invite-copy-btn" hover-class="invite-copy-btn--hover" @click="copyCode">复制</button>
</view>
<text class="invite-tip">新员工注册时填写此码即可加入本店</text>
</view>
<view class="page-section add-btn-wrap">
@ -26,14 +24,14 @@
<view class="page-section section-gap">
<view class="van-cell-group card-section">
<view
v-for="s in staffList"
:key="s.id"
v-for="(s, idx) in staffList"
:key="s.id != null ? s.id : 'row-' + idx"
class="staff-swipe-wrap"
:class="{ 'is-open': swipedId === s.id && s.role !== 'boss' }"
@click="onRowTap(s)"
>
<view
v-if="s.role !== 'boss'"
v-show="s.role !== 'boss'"
class="swipe-delete-btn"
@click.stop="deleteStaff(s.id)"
>
@ -48,8 +46,8 @@
<view class="van-cell staff-cell">
<view class="staff-info-row">
<view class="avatar" :style="avatarStyle(s)">
<img v-if="s.avatar" :src="s.avatar" class="avatar-img" />
<span v-else class="avatar-initials">{{ s.name ? s.name[0] : '?' }}</span>
<image v-if="s.avatar" :src="s.avatar" class="avatar-img" mode="aspectFill" />
<text v-else class="avatar-initials">{{ staffInitial(s) }}</text>
</view>
<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="showAddStaff" class="popup-mask" @click="showAddStaff = false">
<view class="popup-content" @click.stop>
<view class="popup-header">
<text class="popup-title">新增员工</text>
<text class="popup-close" @click="showAddStaff = false"></text>
<view class="popup-title">新增员工</view>
<view class="popup-close" @click="showAddStaff = false"></view>
</view>
<view class="popup-body">
<view class="popup-desc">创建后会自动生成初始密码员工可使用手机号登录</view>
@ -87,20 +84,32 @@
</view>
</view>
</view>
</div>
</view>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from '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 { getStoreSession } from '../../utils/session.js'
const emit = defineEmits(['change-page'])
const storeInfo = getStoreSession()
const inviteCodeText = computed(() => (storeInfo && storeInfo.inviteCode) ? String(storeInfo.inviteCode) : '—')
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
const menuRect = uni.getMenuButtonBoundingClientRect?.()
if (menuRect && menuRect.top && menuRect.height) {
@ -110,6 +119,20 @@ const navSafeStyle = (() => {
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 showAddStaff = ref(false)
const newStaff = ref({ name: '', phone: '' })
@ -117,7 +140,7 @@ const swipedId = ref(null)
const touchStartX = 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) => {
if (s.avatar) return { background: 'transparent' }
const idx = (s.name?.charCodeAt(0) || 0) % COLORS.length
@ -130,8 +153,10 @@ const loadStaff = async () => {
}
const copyCode = () => {
const code = inviteCodeText.value === '—' ? '' : inviteCodeText.value
if (!code) return
uni.setClipboardData({
data: storeInfo.inviteCode,
data: code,
success: () => uni.showToast({ title: '邀请码已复制', icon: 'none' })
})
}
@ -212,10 +237,69 @@ onMounted(() => loadStaff())
.nav-placeholder { width: 32px; }
.add-btn-wrap { padding: 0; }
.card-section { margin: 0; }
.invite-card { overflow: hidden; }
.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-name { font-weight: 700; font-size: 15px; color: #1f2937; }
.staff-meta { font-size: 12px; color: #6b7280; }
@ -234,6 +318,9 @@ onMounted(() => loadStaff())
transform: translateX(0);
transition: transform 0.2s ease;
background: #fff;
width: 100%;
min-width: 100%;
box-sizing: border-box;
}
.staff-swipe-wrap.is-open .staff-main {
transform: translateX(-76px);

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,76 +1,85 @@
<template>
<div class="page-shell report-page">
<!-- 顶部导航 -->
<view class="page-shell report-page">
<view class="report-nav nav-gradient" :style="navSafeStyle">
<text class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></text>
<text class="nav-title">洗护美容报告</text>
<view class="nav-placeholder"></view>
<view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">填写报告</text>
<view style="width:32px"></view>
</view>
<!-- 填写表单 -->
<div class="report-form">
<view class="page-section report-hero">
<view class="hero-title">填写服务记录</view>
<view class="hero-sub">完善本次服务细节提交后可生成分享链接与二维码</view>
</view>
<view class="van-cell-group page-section form-section">
<view class="module-title section-title">
<span class="section-icon"><AppIcon name="profile" :size="12" /></span>
<text>基础信息</text>
<view class="report-body">
<!-- 基础信息 -->
<view class="section">
<view class="section-head">
<text class="section-label">基础信息</text>
</view>
<view class="section-body">
<view class="form-field-wrap">
<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">
<view class="van-field picker-field">{{ report.serviceType || '请选择' }}</view>
<view class="form-row">
<text class="form-label">宠物名字</text>
<input v-model="report.petName" class="form-input" placeholder="请输入宠物名字" />
</view>
<view class="form-row">
<text class="form-label">服务类型</text>
<picker mode="selector" :range="serviceColumns" range-key="label" @change="onServiceConfirm">
<view class="form-input form-picker">
<text :class="{ placeholder: !report.serviceType }">{{ report.serviceType || '请选择' }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="form-row">
<text class="form-label">服务时间</text>
<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 class="form-field-wrap">
<view class="field-label">服务时间</view>
<input v-model="report.appointmentTime" type="datetime-local" class="van-field" />
</view>
</view>
</view>
<!-- 服务照片 -->
<view class="van-cell-group page-section form-section">
<view class="module-title section-title">
<span class="section-icon"><AppIcon name="camera" :size="12" /></span>
<text>服务照片</text>
<!-- 服务照片 核心区域 -->
<view class="section">
<view class="section-head">
<text class="section-label">服务前后对比</text>
<text class="section-hint">上传照片让宠主看到变化</text>
</view>
<view class="section-body">
<view class="photo-row">
<view class="photo-col">
<view class="field-label">服务前</view>
<view class="upload-wrap">
<view v-for="(img, idx) in report.before" :key="idx" class="upload-preview">
<img :src="img" class="preview-img" />
<view class="preview-remove" @click="removePhoto('before', idx)"></view>
</view>
<view v-if="report.before.length < 9" class="upload-box" @click="uploadPhoto('before')">
<span class="upload-icon"><AppIcon name="camera" :size="16" color="#94a3b8" /></span>
<span class="upload-tip">上传照片</span>
</view>
<view class="photo-compare">
<view class="photo-side">
<view class="photo-side-label">
<view class="dot dot-before"></view>
<text>服务前</text>
</view>
<view class="photo-grid">
<view v-for="(img, idx) in report.before" :key="'b'+idx" class="photo-thumb">
<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 class="photo-row" style="margin-top: 16px;">
<view class="photo-col">
<view class="field-label">服务后</view>
<view class="upload-wrap">
<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 class="photo-divider"></view>
<view class="photo-side">
<view class="photo-side-label">
<view class="dot dot-after"></view>
<text>服务后</text>
</view>
<view class="photo-grid">
<view v-for="(img, idx) in report.after" :key="'a'+idx" class="photo-thumb">
<image :src="img" class="thumb-img" mode="aspectFill" />
<view class="thumb-del" @click="removePhoto('after', idx)"></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>
@ -78,31 +87,27 @@
</view>
<!-- 备注 -->
<view class="van-cell-group page-section form-section">
<view class="module-title section-title">
<span class="section-icon"><AppIcon name="orders" :size="12" /></span>
<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 class="section">
<view class="section-head">
<text class="section-label">备注</text>
</view>
<textarea v-model="report.remark" class="form-textarea" placeholder="服务细节、注意事项(可选)" />
</view>
<view class="page-section form-submit-wrap">
<button class="van-button van-button--primary van-button--block report-submit-btn" @click="submitReport">提交并生成分享链接</button>
</view>
</div>
<!-- Loading 遮罩 -->
<view v-if="submitting" class="loading-mask">
<view class="loading-box">提交中...</view>
<button class="submit-btn" :disabled="submitting" @click="submitReport">
{{ submitting ? '提交中…' : '生成报告' }}
</button>
</view>
</div>
<TabBar current-page="report" @change="goPage" />
<view v-if="submitting" class="loading-mask">
<view class="loading-box">
<view class="loading-spinner"></view>
<text>正在生成</text>
</view>
</view>
<TabBar current-page="report" @change="goPage" />
</view>
</template>
<script setup>
@ -113,8 +118,6 @@ import AppIcon from '../../components/AppIcon.vue'
import { getUserSession, getStoreSession } from '../../utils/session.js'
import { useNavigator } from '../../composables/useNavigator.js'
const emit = defineEmits(['change-page'])
const userInfo = getUserSession()
const storeInfo = getStoreSession()
const { goPage, navigateTo } = useNavigator()
@ -135,10 +138,7 @@ const currentAppointmentId = ref(null)
const submitting = ref(false)
const serviceColumns = computed(() =>
serviceTypes.value.map(s => ({
label: s.name,
value: s.name
}))
serviceTypes.value.map(s => ({ label: s.name, value: s.name }))
)
const loadServiceTypes = async () => {
@ -149,18 +149,14 @@ const loadServiceTypes = async () => {
const uploadPhoto = (field) => {
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({
count: 9 - currentCount,
count: 4 - currentCount,
success: (res) => {
const tempPaths = res.tempFilePaths
uni.showLoading({ title: '上传中...' })
let successCount = 0
let failCount = 0
const uploadTasks = tempPaths.map(tempPath => {
const tasks = tempPaths.map(tempPath => {
return new Promise((resolve) => {
uni.uploadFile({
url: `${BASE_URL}/upload/image`,
@ -171,45 +167,55 @@ const uploadPhoto = (field) => {
const data = JSON.parse(uploadRes.data)
if (data.code === 200) {
report.value[field].push(imgUrl(data.data.url))
successCount++
} else {
failCount++
}
} catch (e) {
failCount++
}
} else { failCount++ }
} catch (e) { failCount++ }
resolve()
},
fail: () => {
failCount++
resolve()
}
fail: () => { failCount++; resolve() }
})
})
})
Promise.all(uploadTasks).then(() => {
Promise.all(tasks).then(() => {
uni.hideLoading()
if (failCount > 0) {
uni.showToast({ title: `${failCount}张上传失败`, icon: 'none' })
}
if (failCount > 0) uni.showToast({ title: `${failCount}张上传失败`, icon: 'none' })
})
}
})
}
const removePhoto = (field, idx) => {
report.value[field].splice(idx, 1)
}
const removePhoto = (field, idx) => { report.value[field].splice(idx, 1) }
const onServiceConfirm = (e) => {
const idx = e.detail.value
report.value.serviceType = serviceColumns.value[idx].value
report.value.serviceType = serviceColumns.value[e.detail.value].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 () => {
if (!report.value.petName) 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
const payload = {
appointmentId: currentAppointmentId.value || null,
@ -225,12 +231,12 @@ const submitReport = async () => {
submitting.value = false
if (res.code === 200) {
const token = res.data.reportToken
uni.showToast({ title: '提交成功', icon: 'success' })
uni.showToast({ title: '已生成报告', icon: 'success' })
setTimeout(() => {
report.value = { petName: '', serviceType: '', appointmentTime: '', before: [], after: [], remark: '' }
currentAppointmentId.value = null
uni.navigateTo({ url: `/pages/report-view/reportView?token=${token}` })
}, 1000)
}, 800)
} else {
uni.showToast({ title: res.message || '提交失败', icon: 'none' })
}
@ -245,16 +251,26 @@ onMounted(async () => {
currentAppointmentId.value = prefill.appointmentId
report.value.petName = prefill.petName || ''
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')
}
})
</script>
<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 {
padding: 14px 16px;
padding: 0 16px 12px;
display: flex;
align-items: center;
justify-content: space-between;
@ -262,103 +278,209 @@ onMounted(async () => {
top: 0;
z-index: 10;
}
.nav-back { font-size: 20px; color: #fff; }
.nav-title { font-size: 18px; font-weight: 700; color: #fff; }
.nav-placeholder { width: 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;
.report-body {
padding: 12px 16px 32px;
}
.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;
border: 1px solid #e5ebf3;
border-radius: 16px;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.04);
overflow: hidden;
padding: 20px;
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;
align-items: center;
gap: 6px;
padding: 12px 14px 4px;
margin-bottom: 10px;
font-size: 13px;
color: #64748b;
font-weight: 700;
font-weight: 600;
color: #666660;
}
.section-icon {
width: 18px;
height: 18px;
border-radius: 6px;
background: #f8fafc;
display: inline-flex;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.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;
justify-content: center;
border: 1px solid #e2e8f0;
}
.section-body { padding: 0 14px 10px; }
.form-field-wrap { padding: 0 0 10px; }
.field-label {
margin: 0 0 6px;
font-size: 13px;
color: #64748b;
font-weight: 600;
.photo-add {
width: calc(50% - 4px);
aspect-ratio: 1;
border: 1.5px dashed #ddddd8;
border-radius: 10px;
display: flex;
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; }
.upload-box {
width: 100px;
height: 100px;
border: 1px dashed #d8e0ea;
/* 备注 */
.form-textarea {
background: #f5f5f2;
border: 1.5px solid transparent;
border-radius: 12px;
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;
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;
flex-direction: column;
align-items: center;
justify-content: center;
color: #94a3b8;
cursor: pointer;
background: #fbfdff;
gap: 12px;
font-size: 14px;
color: #666660;
}
.upload-icon {
.loading-spinner {
width: 28px;
height: 28px;
border-radius: 8px;
background: #f1f5f9;
display: inline-flex;
align-items: center;
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;
border: 2.5px solid #ebebea;
border-top-color: #2db96d;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

@ -14,7 +14,10 @@ const SUB_ROUTE_MAP = {
myOrders: '/pages/mine/MyOrders'
}
export const navigateTo = (page) => {
/**
* 应用内路由跳转勿与 uni.navigateTo 混淆此处按 page 名映射
*/
export const openAppPage = (page) => {
const rootUrl = ROOT_ROUTE_MAP[page]
if (rootUrl) {
uni.reLaunch({ url: rootUrl })
@ -26,3 +29,6 @@ export const navigateTo = (page) => {
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_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.moveTo(x + r, y)
ctx.lineTo(x + w - r, y)
ctx.quadraticCurveTo(x + w, y, x + w, y + r)
ctx.lineTo(x + w, y + h - r)
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h)
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.moveTo(x + rr, y)
ctx.arcTo(x + w, y, x + w, y + h, rr)
ctx.arcTo(x + w, y + h, x, y + h, rr)
ctx.arcTo(x, y + h, x, y, rr)
ctx.arcTo(x, y, x + w, y, rr)
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 {{
* 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) {
const {
storeName,
storePhone,
storeAddr,
petName,
serviceType,
timeStr,
staffName,
remark,
beforeImg,
afterImg
storeName, storePhone, storeAddr,
petName, serviceType, timeStr, staffName,
remark, beforeImg, afterImg, qrImg,
beforeCount, afterCount
} = data
ctx.fillStyle = '#ffffff'
const name = storeName || '宠伴生活馆'
const PAD = 40
// ── 背景 ──
ctx.fillStyle = '#fafaf8'
ctx.fillRect(0, 0, POSTER_W, POSTER_H)
const gradient = ctx.createLinearGradient(0, 0, POSTER_W, 300)
gradient.addColorStop(0, '#07c160')
gradient.addColorStop(1, '#10b76f')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, POSTER_W, 300)
// ── 顶部品牌条 ──
const barH = 100
ctx.fillStyle = '#1a1a1a'
ctx.fillRect(0, 0, POSTER_W, barH)
const name = storeName || '宠伴生活馆'
ctx.textAlign = 'center'
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 36px sans-serif'
ctx.textAlign = 'center'
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
ctx.font = `bold 30px ${FONT}`
ctx.fillText(name, POSTER_W / 2, 60)
if (storePhone || storeAddr) {
ctx.font = '18px sans-serif'
ctx.globalAlpha = 0.85
const contactLine = [storePhone, storeAddr].filter(Boolean).join(' | ')
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)
// ── 照片对比区 ──
const photoY = barH
const photoH = 360
const halfW = POSTER_W / 2
if (beforeImg) {
ctx.save()
ctx.beginPath()
roundRect(ctx, 60, imgY, imgW, imgH, 16)
ctx.rect(0, photoY, halfW - 1.5, photoH)
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()
} 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) {
ctx.save()
ctx.beginPath()
roundRect(ctx, 390, imgY, imgW, imgH, 16)
ctx.rect(halfW + 1.5, photoY, halfW - 1.5, photoH)
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()
} 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)
}