feat: 新增预约页面及多项UI/逻辑更新
This commit is contained in:
parent
06d2e48741
commit
6640f734bf
@ -20,6 +20,6 @@
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"appid": "wx4fe9ae1e083c1c4f",
|
||||
"appid": "wx8ca2dfa89af72edf",
|
||||
"editorSetting": {}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
202
src/pages/appointment/AppointmentDetail.vue
Normal file
202
src/pages/appointment/AppointmentDetail.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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] }
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user