first commit

This commit is contained in:
2026-03-17 14:52:07 +08:00
parent a23b829323
commit ec25e6e617
104 changed files with 35840 additions and 0 deletions

View File

@@ -0,0 +1,379 @@
<template>
<view v-if="visible" class="coupon-popup-wrapper">
<!-- 遮罩层 -->
<view class="popup-overlay" @tap="handleClose"></view>
<!-- 弹窗内容 -->
<view class="coupon-popup" @tap.stop>
<!-- 标题栏 -->
<view class="popup-header">
<text class="popup-title">选择优惠券</text>
<view class="close-btn" @tap="handleClose">
<text class="close-icon"></text>
</view>
</view>
<!-- 选项卡 -->
<view class="tabs-container">
<view
class="tab-item"
:class="{ active: activeTab === 'available' }"
@tap="switchTab('available')"
>
<text class="tab-text">可用优惠券</text>
<view class="tab-indicator" v-if="activeTab === 'available'"></view>
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'unavailable' }"
@tap="switchTab('unavailable')"
>
<text class="tab-text">不可用优惠券</text>
<view class="tab-indicator" v-if="activeTab === 'unavailable'"></view>
</view>
</view>
<!-- 优惠券列表 -->
<view class="coupon-list">
<view
v-for="(coupon, index) in currentCoupons"
:key="index"
class="coupon-item"
:class="{ disabled: activeTab === 'unavailable', expired: coupon.type === 'expired' }"
@tap="selectCoupon(coupon)"
>
<view class="coupon-left">
<view class="coupon-amount">
<text class="currency">¥</text>
<text class="amount">{{ coupon.amount }}</text>
</view>
<text class="coupon-condition">{{ coupon.condition }}</text>
</view>
<view class="coupon-right">
<view class="coupon-info">
<text class="coupon-title">{{ coupon.title }}</text>
<text class="coupon-desc">{{ coupon.desc }}</text>
<text class="coupon-expire">有效期至 {{ coupon.expireDate }}</text>
</view>
<view class="use-btn" v-if="activeTab === 'available'">
<text class="use-text">去使用</text>
</view>
</view>
<!-- 过期印章 -->
<view class="expired-stamp" v-if="coupon.type === 'expired'">
<image src="/static/icons/Expired.png" class="expired-image" mode="aspectFit" />
</view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'CouponPopup',
props: {
visible: {
type: Boolean,
default: false
}
},
data() {
return {
activeTab: 'available',
availableCoupons: [
{
amount: 10,
condition: '满100使用',
title: '新人10元大礼包',
desc: '仅限首次预约使用',
expireDate: '2026.11.10'
},
{
amount: 10,
condition: '满100使用',
title: '新人10元大礼包',
desc: '仅限首次预约使用',
expireDate: '2026.11.10'
}
],
unavailableCoupons: [
{
amount: 20,
condition: '满200使用',
title: '专享20元优惠券',
desc: '本次订单不满足使用条件',
expireDate: '2026.12.31',
type: 'unavailable'
},
{
amount: 15,
condition: '满150使用',
title: '限时15元优惠券',
desc: '优惠券已过期',
expireDate: '2025.12.31',
type: 'expired'
}
]
}
},
computed: {
currentCoupons() {
return this.activeTab === 'available' ? this.availableCoupons : this.unavailableCoupons
}
},
methods: {
switchTab(tab) {
this.activeTab = tab
},
selectCoupon(coupon) {
if (this.activeTab === 'available') {
this.$emit('select', coupon)
this.handleClose()
}
},
handleClose() {
this.$emit('close')
}
}
}
</script>
<style lang="scss" scoped>
.coupon-popup-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(39, 39, 42, 0.5);
}
.coupon-popup {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #f8f8f8;
border-radius: 40rpx 40rpx 0 0;
height: 70vh;
overflow: hidden;
display: flex;
flex-direction: column;
// 标题栏
.popup-header {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 40rpx 20rpx;
position: relative;
.popup-title {
font-size: 36rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #000000;
line-height: 1.22;
}
.close-btn {
position: absolute;
right: 40rpx;
top: 50%;
transform: translateY(-50%);
width: 54rpx;
height: 54rpx;
background-color: #f2f2f2;
border-radius: 27rpx;
display: flex;
align-items: center;
justify-content: center;
.close-icon {
font-size: 32rpx;
color: #3f3f46;
line-height: 1;
}
}
}
// 选项卡
.tabs-container {
display: flex;
justify-content: center;
gap: 80rpx;
padding: 0 40rpx 40rpx;
.tab-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
.tab-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #71717a;
line-height: 1.375;
}
.tab-indicator {
width: 56rpx;
height: 6rpx;
background-color: #9d5d00;
border-radius: 3rpx;
}
&.active {
.tab-text {
color: #09090b;
font-weight: normal;
}
}
}
}
// 优惠券列表
.coupon-list {
flex: 1;
padding: 0 40rpx 40rpx;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 20rpx;
.coupon-item {
background-color: #ffffff;
border-radius: 20rpx;
padding: 32rpx;
display: flex;
align-items: center;
gap: 24rpx;
box-shadow: 0 8rpx 16rpx rgba(244, 243, 239, 0.25);
position: relative;
&.disabled {
opacity: 0.6;
}
&.expired {
opacity: 0.6;
}
.coupon-left {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
.coupon-amount {
display: flex;
align-items: baseline;
.currency {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #ff4444;
line-height: 1;
}
.amount {
font-size: 48rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: bold;
color: #ff4444;
line-height: 1;
}
}
.coupon-condition {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #ff4444;
line-height: 1.5;
}
}
.coupon-right {
flex: 1;
display: flex;
align-items: center;
justify-content: space-between;
.coupon-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 8rpx;
.coupon-title {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #18181b;
line-height: 1.25;
}
.coupon-desc {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #71717a;
line-height: 1.5;
}
.coupon-expire {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #a1a1aa;
line-height: 1.5;
}
}
.use-btn {
border: 1rpx solid #ff786f;
border-radius: 20rpx;
padding: 8rpx 18rpx;
.use-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #fb3123;
line-height: 1.14;
}
}
}
}
// 过期印章
.expired-stamp {
position: absolute;
bottom: 0;
right: 0;
z-index: 10;
.expired-image {
width: 120rpx;
height: 120rpx;
}
}
}
}
</style>

View File

@@ -0,0 +1,300 @@
<template>
<view v-if="visible" class="edit-popup-wrapper">
<!-- 遮罩层 -->
<view class="popup-overlay" @tap="handleClose"></view>
<!-- 弹窗内容 -->
<view class="edit-popup" @tap.stop>
<!-- 标题栏 -->
<view class="popup-header">
<text class="popup-title">
{{ editType === 'name' ? '修改姓名' : editType === 'phone' ? '修改手机号' : '修改年龄' }}
</text>
<view class="close-btn" @tap="handleClose">
<u-icon name="close" color="#3f3f46" size="16"></u-icon>
</view>
</view>
<!-- 输入区域 -->
<view class="input-section">
<view class="input-item">
<text class="input-label">
{{ editType === 'name' ? '姓名' : editType === 'phone' ? '手机号' : '年龄' }}
</text>
<input
class="input-field"
v-model="inputValue"
:type="editType === 'phone' || editType === 'age' ? 'number' : 'text'"
:maxlength="editType === 'phone' ? 11 : editType === 'age' ? 3 : 20"
:placeholder="editType === 'name' ? '请输入您的姓名' : editType === 'phone' ? '请输入您的手机号' : '请输入您的年龄'"
placeholder-class="input-placeholder"
@input="handleInput"
/>
</view>
<!-- 错误提示 -->
<view class="error-tip" v-if="errorMessage">
<text class="error-text">{{ errorMessage }}</text>
</view>
</view>
<!-- 底部按钮 -->
<view class="button-section">
<view class="cancel-btn" @tap="handleClose">
<text class="cancel-text">取消</text>
</view>
<view class="confirm-btn" @tap="handleConfirm">
<text class="confirm-text">确定</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'EditInfoPopup',
props: {
visible: {
type: Boolean,
default: false
},
editType: {
type: String,
default: 'name', // 'name' | 'phone' | 'age'
validator: value => ['name', 'phone', 'age'].includes(value)
},
currentValue: {
type: String,
default: ''
}
},
data() {
return {
inputValue: '',
errorMessage: ''
}
},
watch: {
visible(newVal) {
if (newVal) {
this.inputValue = this.currentValue
this.errorMessage = ''
}
},
currentValue(newVal) {
if (this.visible) {
this.inputValue = newVal
}
}
},
methods: {
handleInput() {
this.errorMessage = ''
},
validateInput() {
if (!this.inputValue || this.inputValue.trim() === '') {
this.errorMessage = this.editType === 'name' ? '请输入姓名' :
this.editType === 'phone' ? '请输入手机号' : '请输入年龄'
return false
}
if (this.editType === 'phone') {
const phoneReg = /^1[3-9]\d{9}$/
if (!phoneReg.test(this.inputValue)) {
this.errorMessage = '请输入正确的手机号码'
return false
}
} else if (this.editType === 'name') {
if (this.inputValue.trim().length < 2) {
this.errorMessage = '姓名至少需要2个字符'
return false
}
} else if (this.editType === 'age') {
const age = parseInt(this.inputValue)
if (isNaN(age) || age < 1 || age > 150) {
this.errorMessage = '请输入正确的年龄数字'
return false
}
}
return true
},
handleConfirm() {
if (this.validateInput()) {
this.$emit('confirm', {
type: this.editType,
value: this.inputValue.trim()
})
this.handleClose()
}
},
handleClose() {
this.inputValue = ''
this.errorMessage = ''
this.$emit('close')
}
}
}
</script>
<style lang="scss" scoped>
.edit-popup-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 10001;
}
.edit-popup {
background-color: #ffffff;
border-radius: 24rpx;
width: 600rpx;
max-width: 90vw;
overflow: hidden;
position: relative;
z-index: 10002;
// 标题栏
.popup-header {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 40rpx 20rpx;
position: relative;
border-bottom: 1rpx solid #f4f4f5;
.popup-title {
font-size: 36rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #18181b;
line-height: 1.22;
}
.close-btn {
position: absolute;
right: 40rpx;
top: 50%;
transform: translateY(-50%);
width: 48rpx;
height: 48rpx;
background-color: #f4f4f5;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
// 输入区域
.input-section {
padding: 40rpx;
.input-item {
display: flex;
flex-direction: column;
gap: 16rpx;
.input-label {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #27272a;
line-height: 1.43;
}
.input-field {
height: 88rpx;
background-color: #f8f8f8;
border-radius: 12rpx;
padding: 0 24rpx;
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #18181b;
line-height: 1.25;
border: 2rpx solid transparent;
transition: border-color 0.2s ease;
&:focus {
border-color: #9d5d00;
background-color: #ffffff;
}
}
.input-placeholder {
color: #a1a1aa;
}
}
.error-tip {
margin-top: 16rpx;
.error-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #ef4444;
line-height: 1.5;
}
}
}
// 底部按钮
.button-section {
display: flex;
gap: 24rpx;
padding: 0 40rpx 40rpx;
.cancel-btn,
.confirm-btn {
flex: 1;
height: 88rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
}
.cancel-btn {
background-color: #f4f4f5;
.cancel-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #71717a;
line-height: 1.25;
}
}
.confirm-btn {
background-color: #18181b;
.confirm-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #ffffff;
line-height: 1.25;
}
}
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<view class="empty-view">
<image v-if="image" :src="image" class="empty-image" mode="aspectFit" />
<text class="empty-text">{{ text }}</text>
<view v-if="showButton" class="empty-button" @click="handleClick">
{{ buttonText }}
</view>
</view>
</template>
<script>
export default {
name: 'EmptyView',
props: {
image: {
type: String,
default: ''
},
text: {
type: String,
default: '暂无数据'
},
showButton: {
type: Boolean,
default: false
},
buttonText: {
type: String,
default: '重新加载'
}
},
emits: ['click'],
methods: {
handleClick() {
this.$emit('click')
}
}
}
</script>
<style lang="scss" scoped>
.empty-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
.empty-image {
width: 300rpx;
height: 300rpx;
margin-bottom: 40rpx;
}
.empty-text {
font-size: 28rpx;
color: #999;
margin-bottom: 40rpx;
}
.empty-button {
padding: 20rpx 60rpx;
background-color: $primary-color;
color: #fff;
font-size: 28rpx;
border-radius: 8rpx;
}
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<view class="error-view">
<image src="/static/images/error.png" class="error-image" mode="aspectFit" />
<text class="error-text">{{ message }}</text>
<view class="error-button" @click="handleRetry">
重试
</view>
</view>
</template>
<script>
export default {
name: 'ErrorView',
props: {
message: {
type: String,
default: '加载失败,请重试'
}
},
emits: ['retry'],
methods: {
handleRetry() {
this.$emit('retry')
}
}
}
</script>
<style lang="scss" scoped>
.error-view {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 100rpx 40rpx;
.error-image {
width: 300rpx;
height: 300rpx;
margin-bottom: 40rpx;
}
.error-text {
font-size: 28rpx;
color: #999;
margin-bottom: 40rpx;
}
.error-button {
padding: 20rpx 60rpx;
background-color: $primary-color;
color: #fff;
font-size: 28rpx;
border-radius: 8rpx;
}
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<view v-if="visible" class="loading-overlay">
<view class="loading-box">
<view class="loading-icon">
<view class="spinner"></view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'LoadingOverlay',
props: {
visible: {
type: Boolean,
default: false
}
}
}
</script>
<style lang="scss" scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
.loading-box {
width: 174rpx;
height: 174rpx;
background-color: rgba(39, 39, 42, 0.71);
border-radius: 30rpx;
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx;
.loading-icon {
width: 72rpx;
height: 72rpx;
position: relative;
.spinner {
width: 100%;
height: 100%;
border: 4rpx solid #f2f2f2;
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,40 @@
<template>
<u-overlay :show="visible" :z-index="9999">
<view class="loading-wrapper">
<view class="loading-box">
<u-loading-icon mode="spinner" size="36" color="#ffffff"></u-loading-icon>
</view>
</view>
</u-overlay>
</template>
<script>
export default {
name: 'LoadingOverlayUview',
props: {
visible: {
type: Boolean,
default: false
}
}
}
</script>
<style lang="scss" scoped>
.loading-wrapper {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
.loading-box {
width: 174rpx;
height: 174rpx;
background-color: rgba(39, 39, 42, 0.71);
border-radius: 30rpx;
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<view v-if="visible" class="loading-view">
<view class="loading-content">
<view class="loading-spinner" />
<text class="loading-text">{{ text }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'LoadingView',
props: {
visible: {
type: Boolean,
default: false
},
text: {
type: String,
default: '加载中...'
}
}
}
</script>
<style lang="scss" scoped>
.loading-view {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx;
background-color: rgba(0, 0, 0, 0.7);
border-radius: 16rpx;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text {
margin-top: 20rpx;
font-size: 28rpx;
color: #fff;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,146 @@
<!--
* Banner组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="banner">
<swiper
v-if="banners.length > 0"
class="banner-swiper"
:indicator-dots="false"
:autoplay="autoplay"
:interval="interval"
:duration="duration"
:circular="circular"
@change="handleChange"
>
<swiper-item v-for="(item, index) in banners" :key="index">
<view class="banner-item" @click="handleBannerClick(item)">
<image :src="item.image" class="banner-image" mode="aspectFill" />
</view>
</swiper-item>
</swiper>
<!-- 无数据时显示占位 -->
<view v-else class="banner-placeholder">
<!-- 纯白色占位卡片 -->
</view>
<view v-if="showDots && banners.length > 1" class="custom-dots">
<view
v-for="(item, index) in banners"
:key="index"
class="dot"
:class="{ active: currentIndex === index }"
/>
</view>
</view>
</template>
<script>
export default {
name: 'Banner',
props: {
banners: {
type: Array,
default: () => []
},
indicatorDots: {
type: Boolean,
default: false
},
autoplay: {
type: Boolean,
default: true
},
interval: {
type: Number,
default: 3000
},
duration: {
type: Number,
default: 500
},
circular: {
type: Boolean,
default: true
},
showDots: {
type: Boolean,
default: true
}
},
data() {
return {
currentIndex: 0
}
},
methods: {
handleChange(e) {
this.currentIndex = e.detail.current
this.$emit('change', this.currentIndex)
},
handleBannerClick(item) {
this.$emit('click', item)
}
}
}
</script>
<style lang="scss" scoped>
.banner {
position: relative;
width: 686rpx;
height: 228rpx;
margin: 0 auto;
border-radius: 32rpx;
overflow: hidden;
.banner-swiper {
width: 100%;
height: 100%;
.banner-item {
width: 100%;
height: 100%;
.banner-image {
width: 100%;
height: 100%;
}
}
}
.banner-placeholder {
width: 100%;
height: 100%;
background-color: #feffffff;
border: 2rpx solid #fffdf9;
border-radius: 32rpx;
}
.custom-dots {
position: absolute;
bottom: 20rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 12rpx;
.dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
transition: all 0.3s;
&.active {
width: 24rpx;
border-radius: 6rpx;
background-color: #fff;
}
}
}
}
</style>

View File

@@ -0,0 +1,835 @@
<template>
<view v-if="visible" class="booking-popup-wrapper">
<!-- 遮罩层 -->
<view class="popup-overlay" @tap="handleClose"></view>
<!-- 弹窗内容 -->
<view class="booking-popup" @tap.stop>
<!-- 标题栏 -->
<view class="popup-header">
<text class="popup-title">填写预约信息</text>
<view class="close-btn" @tap="handleClose">
<text class="close-icon"></text>
</view>
</view>
<!-- 咨询师信息和选择咨询方式 -->
<view class="consultant-section">
<!-- 装饰性背景图案 -->
<view class="bg-decoration">
<view class="decoration-shape"></view>
</view>
<!-- 咨询师信息卡片 -->
<view class="consultant-info">
<image :src="consultant.avatar" class="avatar" mode="aspectFill" />
<view class="info-text">
<text class="info-label">当前预约心理咨询师</text>
<view class="name-price">
<text class="name">{{ consultant.name }}</text>
<text class="price">¥{{ consultant.price }}/小时</text>
</view>
</view>
</view>
<!-- 选择咨询方式 -->
<view class="consult-methods-card">
<view class="section-title">
<view class="title-bar"></view>
<text class="title-text">选择咨询方式</text>
</view>
<view class="methods-list">
<view
v-for="(method, index) in consultMethods"
:key="index"
class="method-item"
:class="{ active: selectedMethod === method.value }"
@tap="selectMethod(method.value)"
>
<view class="method-icon">
<image :src="method.icon" mode="aspectFit" />
</view>
<view class="method-info">
<text class="method-name">{{ method.label }}</text>
<text class="method-price">¥{{ consultant.price }}</text>
</view>
</view>
</view>
</view>
</view>
<!-- 选择预约日期和时间 -->
<view class="datetime-section">
<!-- 选择预约日期 -->
<view class="date-section">
<view class="section-title">
<view class="title-bar"></view>
<text class="title-text">选择预约日期</text>
</view>
<view class="date-list">
<view
v-for="(date, index) in dateList"
:key="index"
class="date-item"
:class="{ active: selectedDate === date.value }"
@tap="selectDate(date.value)"
>
<text class="date-month">{{ date.month }}</text>
<text class="date-week">{{ date.week }}</text>
<view class="date-day-wrapper">
<text class="date-day">{{ date.day }}</text>
</view>
</view>
</view>
</view>
<!-- 选择预约时间 -->
<view class="time-section">
<view class="section-title">
<view class="title-bar"></view>
<text class="title-text">选择预约时间</text>
</view>
<view class="time-grid">
<view
v-for="(time, index) in timeList"
:key="index"
class="time-item"
:class="{ active: selectedTime === time }"
@tap="selectTime(time)"
>
<text class="time-text">{{ time }}</text>
</view>
</view>
</view>
</view>
<!-- 填写个人信息 -->
<view class="info-section">
<view class="section-title">
<view class="title-bar"></view>
<text class="title-text">填写信息</text>
</view>
<view class="form-list">
<view class="form-item">
<text class="form-label required">姓名</text>
<input
class="form-input"
v-model="formData.name"
placeholder="请填写您的姓名"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-item">
<text class="form-label required">电话</text>
<input
class="form-input"
v-model="formData.phone"
type="number"
maxlength="11"
placeholder="请填写您的电话"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-item">
<text class="form-label">年龄</text>
<input
class="form-input"
v-model="formData.age"
type="number"
maxlength="3"
placeholder="请填写您的年龄"
placeholder-class="input-placeholder"
/>
</view>
<view class="form-item">
<text class="form-label">备注信息</text>
<input
class="form-input"
v-model="formData.remark"
maxlength="200"
placeholder="请填写您的备注信息"
placeholder-class="input-placeholder"
/>
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="footer-section">
<view class="submit-btn" @tap="handleSubmit">
<text class="submit-text">开始预约</text>
</view>
</view>
</view>
<!-- 加载动画 -->
<LoadingOverlay :visible="isLoading" />
</view>
</template>
<script>
import LoadingOverlay from '../common/LoadingOverlay.vue'
export default {
name: 'BookingPopup',
components: {
LoadingOverlay
},
props: {
visible: {
type: Boolean,
default: false
},
consultant: {
type: Object,
default: () => ({
avatar: '',
name: '',
price: 0
})
}
},
data() {
return {
consultMethods: [
{ label: '线下咨询', value: 'offline', icon: '/static/icons/Offline-Message.png' },
{ label: '视频咨询', value: 'video', icon: '/static/icons/Video-Message.png' },
{ label: '语音咨询', value: 'audio', icon: '/static/icons/Voice-Message.png' }
],
selectedMethod: 'video',
dateList: [],
selectedDate: '',
timeList: [
'09:00-10:30',
'10:30-11:30',
'13:30-15:00',
'15:00-16:30',
'16:30-17:30'
],
selectedTime: '10:30-11:30',
formData: {
name: '',
phone: '',
age: '',
remark: ''
},
isLoading: false
}
},
mounted() {
this.initDateList()
},
methods: {
initDateList() {
const dates = []
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const months = ['01月', '02月', '03月', '04月', '05月', '06月', '07月', '08月', '09月', '10月', '11月', '12月']
const today = new Date()
for (let i = 0; i < 7; i++) {
const date = new Date(today)
date.setDate(today.getDate() + i)
dates.push({
month: months[date.getMonth()],
week: weekDays[date.getDay()],
day: date.getDate(),
value: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
})
}
this.dateList = dates
this.selectedDate = dates[1]?.value || ''
},
selectMethod(value) {
this.selectedMethod = value
},
selectDate(value) {
this.selectedDate = value
},
selectTime(value) {
this.selectedTime = value
},
handleClose() {
this.$emit('close')
},
handleSubmit() {
// 验证必填项
if (!this.formData.name) {
uni.showToast({
title: '请填写姓名',
icon: 'none'
})
return
}
if (!this.formData.phone) {
uni.showToast({
title: '请填写电话',
icon: 'none'
})
return
}
// 验证电话号码格式
const phoneReg = /^1[3-9]\d{9}$/
if (!phoneReg.test(this.formData.phone)) {
uni.showToast({
title: '请输入正确的手机号码',
icon: 'none'
})
return
}
// 验证年龄格式(如果填写了)
if (this.formData.age && this.formData.age.trim() !== '') {
const age = parseInt(this.formData.age)
if (isNaN(age) || age < 1 || age > 150) {
uni.showToast({
title: '请输入正确的年龄数字',
icon: 'none'
})
return
}
}
// 验证备注信息长度(如果填写了)
if (this.formData.remark && this.formData.remark.trim().length > 200) {
uni.showToast({
title: '备注信息不能超过200个字',
icon: 'none'
})
return
}
// 显示加载动画
this.isLoading = true
// 构建预约信息
const bookingData = {
consultantId: this.consultant.id,
consultantName: this.consultant.name,
consultantAvatar: this.consultant.avatar,
method: this.selectedMethod,
methodLabel: this.consultMethods.find(m => m.value === this.selectedMethod)?.label || '',
date: this.selectedDate,
time: this.selectedTime,
price: this.consultant.price,
name: this.formData.name,
phone: this.formData.phone,
age: this.formData.age && this.formData.age.trim() !== '' ? parseInt(this.formData.age) : null,
remark: this.formData.remark
}
console.log('预约信息:', bookingData)
// 模拟网络请求延迟
setTimeout(() => {
this.isLoading = false
// 关闭弹窗
this.handleClose()
// 跳转到预约确认页面
uni.navigateTo({
url: `/pages/booking/confirm?data=${encodeURIComponent(JSON.stringify(bookingData))}`
})
}, 800)
// TODO: 或者调用预约接口后再跳转
// this.$emit('submit', bookingData)
}
}
}
</script>
<style lang="scss" scoped>
.booking-popup-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
}
.popup-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
}
.booking-popup {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #f8f8f8;
border-radius: 32rpx 32rpx 0 0;
max-height: 85vh;
overflow-y: auto;
display: flex;
flex-direction: column;
padding-bottom: 160rpx; // 为底部按钮预留空间
// 标题栏
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
.popup-title {
font-size: 40rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #000000;
line-height: 1.1;
}
.close-btn {
width: 54rpx;
height: 54rpx;
background-color: #f2f2f2;
border-radius: 27rpx;
display: flex;
align-items: center;
justify-content: center;
.close-icon {
font-size: 32rpx;
color: #3f3f46;
line-height: 1;
}
}
}
// 咨询师信息和选择咨询方式
.consultant-section {
background: linear-gradient(260deg, #ffddb1 0%, #fff3e4 100%);
border: 1rpx solid #ffffff;
border-radius: 20rpx;
margin: 0 32rpx 20rpx;
padding: 0;
position: relative;
overflow: visible;
min-height: 476rpx;
// 装饰性背景图案
.bg-decoration {
position: absolute;
top: -72rpx;
right: 0;
width: 304rpx;
height: 304rpx;
opacity: 0.8;
pointer-events: none;
z-index: 0;
.decoration-shape {
width: 100%;
height: 100%;
background: linear-gradient(180deg, #fff9f6 0%, rgba(255, 240, 230, 0) 100%);
border-radius: 50%;
transform: rotate(-15deg);
}
}
// 咨询师信息
.consultant-info {
display: flex;
align-items: center;
gap: 28rpx;
padding: 26rpx 34rpx 20rpx;
position: relative;
z-index: 1;
.avatar {
width: 102rpx;
height: 102rpx;
border-radius: 50%;
border: 2.5rpx solid #fff8f2;
flex-shrink: 0;
}
.info-text {
flex: 1;
.info-label {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #8c7e6b;
line-height: 1.5;
display: block;
margin-bottom: 8rpx;
}
.name-price {
display: flex;
align-items: center;
justify-content: space-between;
.name {
font-size: 40rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #4e3107;
line-height: 1.3;
}
.price {
font-size: 36rpx;
font-family: 'MiSans VF', 'PingFang SC', sans-serif;
font-weight: normal;
color: #9d5d00;
line-height: 1.22;
}
}
}
}
// 选择咨询方式卡片
.consult-methods-card {
background-color: #ffffff;
border-radius: 20rpx;
padding: 32rpx;
margin: 0;
position: relative;
z-index: 1;
.section-title {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 20rpx;
.title-bar {
width: 4rpx;
height: 20rpx;
background-color: #9d5d00;
border-radius: 16rpx;
}
.title-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #18181b;
line-height: 1.25;
}
}
.methods-list {
display: flex;
gap: 8rpx;
.method-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #fafafa;
border-radius: 20rpx;
padding: 20rpx 10rpx;
border: 1rpx solid transparent;
.method-icon {
width: 64rpx;
height: 64rpx;
background-color: #ffffff;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12rpx;
image {
width: 48rpx;
height: 48rpx;
}
}
.method-info {
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
.method-name {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #52525b;
line-height: 1.43;
}
.method-price {
font-size: 28rpx;
font-family: 'MiSans VF', 'PingFang SC', sans-serif;
font-weight: normal;
color: #9d5d00;
line-height: 1.43;
}
}
&.active {
background-color: #f9f7f4;
border-color: #3f3f46;
}
}
}
}
}
// 选择预约日期和时间
.datetime-section {
background-color: #ffffff;
border-radius: 20rpx;
margin: 0 32rpx 20rpx;
padding: 40rpx 32rpx;
.date-section {
margin-bottom: 40rpx;
}
.section-title {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 32rpx;
.title-bar {
width: 4rpx;
height: 20rpx;
background-color: #9d5d00;
border-radius: 16rpx;
}
.title-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #18181b;
line-height: 1.25;
}
}
// 日期列表
.date-list {
display: flex;
gap: 10rpx;
.date-item {
width: 80rpx;
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
flex-shrink: 0;
.date-month {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #a1a1aa;
line-height: 1.5;
text-align: center;
display: block;
}
.date-week {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #a1a1aa;
line-height: 1.5;
text-align: center;
display: block;
}
.date-day-wrapper {
width: 100%;
height: 88rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid transparent;
box-sizing: border-box;
.date-day {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #a1a1aa;
line-height: 1.375;
text-align: center;
}
}
&.active {
.date-month,
.date-week {
color: #3f3f46;
font-weight: 500;
}
.date-day-wrapper {
background-color: #f9f7f4;
border-color: #3f3f46;
.date-day {
color: #3f3f46;
font-weight: 500;
}
}
}
}
}
// 时间网格
.time-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10rpx;
.time-item {
height: 80rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid transparent;
.time-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #a1a1aa;
line-height: 1.67;
text-align: center;
}
&.active {
background-color: #f9f7f4;
border-color: #3f3f46;
.time-text {
color: #70553e;
}
}
}
}
}
// 填写个人信息
.info-section {
background-color: #ffffff;
border-radius: 20rpx;
margin: 0 32rpx 20rpx;
padding: 40rpx 32rpx;
.section-title {
display: flex;
align-items: center;
gap: 8rpx;
margin-bottom: 32rpx;
.title-bar {
width: 4rpx;
height: 20rpx;
background-color: #9d5d00;
border-radius: 16rpx;
}
.title-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #18181b;
line-height: 1.25;
}
}
.form-list {
display: flex;
flex-direction: column;
gap: 32rpx;
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
.form-label {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #27272a;
line-height: 1.25;
flex-shrink: 0;
&.required::before {
content: '*';
color: #ef4444;
margin-right: 4rpx;
}
}
.form-input {
flex: 1;
text-align: right;
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #18181b;
line-height: 1.43;
margin-left: 20rpx;
}
.input-placeholder {
color: #a1a1aa;
}
}
}
}
// 底部按钮
.footer-section {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background-color: #ffffff;
padding: 32rpx;
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
box-shadow: 0 -2rpx 16rpx rgba(0, 0, 0, 0.05);
z-index: 10;
.submit-btn {
width: 100%;
height: 96rpx;
background-color: #18181b;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
.submit-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: normal;
color: #ffffff;
line-height: 1.25;
}
}
}
}
</style>

View File

@@ -0,0 +1,87 @@
<!--
* 分类标签组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="category-tabs">
<view
v-for="(item, index) in categories"
:key="index"
class="tab-item"
:class="{ active: currentIndex === index }"
@click="handleTabClick(index)"
>
<text class="tab-text">{{ item }}</text>
</view>
</view>
</template>
<script>
export default {
name: 'CategoryTabs',
props: {
categories: {
type: Array,
default: () => ['焦虑抑郁', '婚姻关系', '原生家庭', '职业困扰', '成长迷茫']
},
current: {
type: Number,
default: 0
}
},
data() {
return {
currentIndex: this.current
}
},
watch: {
current(val) {
this.currentIndex = val
}
},
methods: {
handleTabClick(index) {
this.currentIndex = index
this.$emit('change', index, this.categories[index])
}
}
}
</script>
<style lang="scss" scoped>
.category-tabs {
display: flex;
gap: 17rpx;
overflow-x: auto;
overflow-y: hidden;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
// 隐藏滚动条
&::-webkit-scrollbar {
display: none;
}
.tab-item {
flex-shrink: 0;
padding: 8rpx 16rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
.tab-text {
font-size: 24rpx;
color: #a1a1aa;
white-space: nowrap;
}
&.active {
background-color: #fff;
.tab-text {
color: #70553e;
}
}
}
}
</style>

View File

@@ -0,0 +1,636 @@
<!--
* 城市筛选组件三级联动
* @author AI
* @date 2026-03-06
-->
<template>
<view v-if="show" class="city-filter-mask" @tap="handleMaskClick">
<view class="city-filter-panel" @tap.stop>
<!-- 热门城市 -->
<view class="hot-cities">
<view class="section-title">热门城市</view>
<view class="hot-city-list">
<view
v-for="city in hotCities"
:key="city.id"
class="hot-city-item"
:class="{ active: selectedProvince === city.province && selectedCity === city.city }"
@tap="handleHotCitySelect(city)"
>
<text class="city-text">{{ city.name }}</text>
</view>
</view>
</view>
<!-- 三级联动选择器 -->
<view class="cascader-container">
<!-- 省份列表 -->
<scroll-view class="cascader-column" scroll-y>
<view
v-for="province in provinces"
:key="province.id"
class="cascader-item"
:class="{ active: selectedProvince === province.id }"
@tap="handleProvinceSelect(province)"
>
<text class="item-text">{{ province.name }}</text>
</view>
</scroll-view>
<!-- 城市列表 -->
<scroll-view class="cascader-column" scroll-y>
<view
v-for="city in cities"
:key="city.id"
class="cascader-item"
:class="{ active: selectedCity === city.id }"
@tap="handleCitySelect(city)"
>
<text class="item-text">{{ city.name }}</text>
</view>
</scroll-view>
<!-- 区域列表 -->
<scroll-view class="cascader-column" scroll-y>
<view
v-for="district in districts"
:key="district.id"
class="cascader-item"
:class="{ active: selectedDistrict === district.id }"
@tap="handleDistrictSelect(district)"
>
<text class="item-text">{{ district.name }}</text>
</view>
</scroll-view>
</view>
<!-- 底部按钮 -->
<view class="footer-buttons">
<view class="btn-reset" @tap="handleReset">
<text class="btn-text">重置</text>
</view>
<view class="btn-confirm" @tap="handleConfirm">
<text class="btn-text">确定</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'CityFilter',
props: {
show: {
type: Boolean,
default: false
},
value: {
type: Object,
default: () => ({})
}
},
data() {
return {
selectedProvince: '',
selectedCity: '',
selectedDistrict: '',
// 热门城市
hotCities: [
{ id: 1, name: '全部', province: '', city: '', district: '' },
{ id: 2, name: '北京市', province: 'beijing', city: 'beijing', district: '' },
{ id: 3, name: '海外', province: 'overseas', city: 'overseas', district: '' }
],
// 省份数据
provinces: [
{ id: 'beijing', name: '北京市' },
{ id: 'shanghai', name: '上海市' },
{ id: 'tianjin', name: '天津市' },
{ id: 'chongqing', name: '重庆市' },
{ id: 'hebei', name: '河北省' },
{ id: 'shanxi', name: '山西省' },
{ id: 'liaoning', name: '辽宁省' },
{ id: 'jilin', name: '吉林省' },
{ id: 'heilongjiang', name: '黑龙江省' },
{ id: 'jiangsu', name: '江苏省' },
{ id: 'zhejiang', name: '浙江省' },
{ id: 'anhui', name: '安徽省' },
{ id: 'fujian', name: '福建省' },
{ id: 'jiangxi', name: '江西省' },
{ id: 'shandong', name: '山东省' },
{ id: 'henan', name: '河南省' },
{ id: 'hubei', name: '湖北省' },
{ id: 'hunan', name: '湖南省' },
{ id: 'guangdong', name: '广东省' },
{ id: 'guangxi', name: '广西壮族自治区' },
{ id: 'hainan', name: '海南省' },
{ id: 'sichuan', name: '四川省' },
{ id: 'guizhou', name: '贵州省' },
{ id: 'yunnan', name: '云南省' },
{ id: 'shaanxi', name: '陕西省' },
{ id: 'gansu', name: '甘肃省' },
{ id: 'qinghai', name: '青海省' },
{ id: 'taiwan', name: '台湾省' },
{ id: 'neimenggu', name: '内蒙古自治区' },
{ id: 'xinjiang', name: '新疆维吾尔自治区' },
{ id: 'xizang', name: '西藏自治区' },
{ id: 'ningxia', name: '宁夏回族自治区' },
{ id: 'hongkong', name: '香港特别行政区' },
{ id: 'macao', name: '澳门特别行政区' },
{ id: 'overseas', name: '海外' }
],
// 城市数据(根据省份动态变化)
cityData: {
beijing: [
{ id: 'beijing_city', name: '北京市', provinceId: 'beijing' }
],
shanghai: [
{ id: 'shanghai_city', name: '上海市', provinceId: 'shanghai' }
],
tianjin: [
{ id: 'tianjin_city', name: '天津市', provinceId: 'tianjin' }
],
chongqing: [
{ id: 'chongqing_city', name: '重庆市', provinceId: 'chongqing' }
],
zhejiang: [
{ id: 'hangzhou', name: '杭州市', provinceId: 'zhejiang' },
{ id: 'ningbo', name: '宁波市', provinceId: 'zhejiang' },
{ id: 'wenzhou', name: '温州市', provinceId: 'zhejiang' },
{ id: 'jiaxing', name: '嘉兴市', provinceId: 'zhejiang' },
{ id: 'huzhou', name: '湖州市', provinceId: 'zhejiang' },
{ id: 'shaoxing', name: '绍兴市', provinceId: 'zhejiang' },
{ id: 'jinhua', name: '金华市', provinceId: 'zhejiang' },
{ id: 'quzhou', name: '衢州市', provinceId: 'zhejiang' },
{ id: 'zhoushan', name: '舟山市', provinceId: 'zhejiang' },
{ id: 'taizhou_zj', name: '台州市', provinceId: 'zhejiang' },
{ id: 'lishui', name: '丽水市', provinceId: 'zhejiang' }
],
guangdong: [
{ id: 'guangzhou', name: '广州市', provinceId: 'guangdong' },
{ id: 'shenzhen', name: '深圳市', provinceId: 'guangdong' },
{ id: 'zhuhai', name: '珠海市', provinceId: 'guangdong' },
{ id: 'shantou', name: '汕头市', provinceId: 'guangdong' },
{ id: 'foshan', name: '佛山市', provinceId: 'guangdong' },
{ id: 'shaoguan', name: '韶关市', provinceId: 'guangdong' },
{ id: 'zhanjiang', name: '湛江市', provinceId: 'guangdong' },
{ id: 'zhaoqing', name: '肇庆市', provinceId: 'guangdong' },
{ id: 'jiangmen', name: '江门市', provinceId: 'guangdong' },
{ id: 'maoming', name: '茂名市', provinceId: 'guangdong' },
{ id: 'huizhou', name: '惠州市', provinceId: 'guangdong' },
{ id: 'meizhou', name: '梅州市', provinceId: 'guangdong' },
{ id: 'shanwei', name: '汕尾市', provinceId: 'guangdong' },
{ id: 'heyuan', name: '河源市', provinceId: 'guangdong' },
{ id: 'yangjiang', name: '阳江市', provinceId: 'guangdong' },
{ id: 'qingyuan', name: '清远市', provinceId: 'guangdong' },
{ id: 'dongguan', name: '东莞市', provinceId: 'guangdong' },
{ id: 'zhongshan', name: '中山市', provinceId: 'guangdong' },
{ id: 'chaozhou', name: '潮州市', provinceId: 'guangdong' },
{ id: 'jieyang', name: '揭阳市', provinceId: 'guangdong' },
{ id: 'yunfu', name: '云浮市', provinceId: 'guangdong' }
],
jiangsu: [
{ id: 'nanjing', name: '南京市', provinceId: 'jiangsu' },
{ id: 'wuxi', name: '无锡市', provinceId: 'jiangsu' },
{ id: 'xuzhou', name: '徐州市', provinceId: 'jiangsu' },
{ id: 'changzhou', name: '常州市', provinceId: 'jiangsu' },
{ id: 'suzhou', name: '苏州市', provinceId: 'jiangsu' },
{ id: 'nantong', name: '南通市', provinceId: 'jiangsu' },
{ id: 'lianyungang', name: '连云港市', provinceId: 'jiangsu' },
{ id: 'huaian', name: '淮安市', provinceId: 'jiangsu' },
{ id: 'yancheng', name: '盐城市', provinceId: 'jiangsu' },
{ id: 'yangzhou', name: '扬州市', provinceId: 'jiangsu' },
{ id: 'zhenjiang', name: '镇江市', provinceId: 'jiangsu' },
{ id: 'taizhou_js', name: '泰州市', provinceId: 'jiangsu' },
{ id: 'suqian', name: '宿迁市', provinceId: 'jiangsu' }
],
anhui: [
{ id: 'hefei', name: '合肥市', provinceId: 'anhui' },
{ id: 'wuhu', name: '芜湖市', provinceId: 'anhui' },
{ id: 'bengbu', name: '蚌埠市', provinceId: 'anhui' },
{ id: 'huainan', name: '淮南市', provinceId: 'anhui' },
{ id: 'maanshan', name: '马鞍山市', provinceId: 'anhui' },
{ id: 'huaibei', name: '淮北市', provinceId: 'anhui' },
{ id: 'tongling', name: '铜陵市', provinceId: 'anhui' },
{ id: 'anqing', name: '安庆市', provinceId: 'anhui' },
{ id: 'huangshan', name: '黄山市', provinceId: 'anhui' },
{ id: 'chuzhou', name: '滁州市', provinceId: 'anhui' },
{ id: 'fuyang', name: '阜阳市', provinceId: 'anhui' },
{ id: 'suzhou_ah', name: '宿州市', provinceId: 'anhui' },
{ id: 'luan', name: '六安市', provinceId: 'anhui' },
{ id: 'bozhou', name: '亳州市', provinceId: 'anhui' },
{ id: 'chizhou', name: '池州市', provinceId: 'anhui' },
{ id: 'xuancheng', name: '宣城市', provinceId: 'anhui' }
],
shandong: [
{ id: 'jinan', name: '济南市', provinceId: 'shandong' },
{ id: 'qingdao', name: '青岛市', provinceId: 'shandong' },
{ id: 'zibo', name: '淄博市', provinceId: 'shandong' },
{ id: 'zaozhuang', name: '枣庄市', provinceId: 'shandong' },
{ id: 'dongying', name: '东营市', provinceId: 'shandong' },
{ id: 'yantai', name: '烟台市', provinceId: 'shandong' },
{ id: 'weifang', name: '潍坊市', provinceId: 'shandong' },
{ id: 'jining', name: '济宁市', provinceId: 'shandong' },
{ id: 'taian', name: '泰安市', provinceId: 'shandong' },
{ id: 'weihai', name: '威海市', provinceId: 'shandong' },
{ id: 'rizhao', name: '日照市', provinceId: 'shandong' },
{ id: 'linyi', name: '临沂市', provinceId: 'shandong' },
{ id: 'dezhou', name: '德州市', provinceId: 'shandong' },
{ id: 'liaocheng', name: '聊城市', provinceId: 'shandong' },
{ id: 'binzhou', name: '滨州市', provinceId: 'shandong' },
{ id: 'heze', name: '菏泽市', provinceId: 'shandong' }
],
overseas: [
{ id: 'overseas_city', name: '海外', provinceId: 'overseas' }
]
},
// 区域数据(根据城市动态变化)
districtData: {
beijing_city: [
{ id: 'dongcheng', name: '东城区', cityId: 'beijing_city' },
{ id: 'xicheng', name: '西城区', cityId: 'beijing_city' },
{ id: 'chaoyang', name: '朝阳区', cityId: 'beijing_city' },
{ id: 'fengtai', name: '丰台区', cityId: 'beijing_city' },
{ id: 'shijingshan', name: '石景山区', cityId: 'beijing_city' },
{ id: 'haidian', name: '海淀区', cityId: 'beijing_city' },
{ id: 'mentougou', name: '门头沟区', cityId: 'beijing_city' },
{ id: 'fangshan', name: '房山区', cityId: 'beijing_city' },
{ id: 'tongzhou', name: '通州区', cityId: 'beijing_city' },
{ id: 'shunyi', name: '顺义区', cityId: 'beijing_city' },
{ id: 'changping', name: '昌平区', cityId: 'beijing_city' },
{ id: 'daxing', name: '大兴区', cityId: 'beijing_city' },
{ id: 'huairou', name: '怀柔区', cityId: 'beijing_city' },
{ id: 'pinggu', name: '平谷区', cityId: 'beijing_city' },
{ id: 'miyun', name: '密云区', cityId: 'beijing_city' },
{ id: 'yanqing', name: '延庆区', cityId: 'beijing_city' }
],
shanghai_city: [
{ id: 'huangpu', name: '黄浦区', cityId: 'shanghai_city' },
{ id: 'xuhui', name: '徐汇区', cityId: 'shanghai_city' },
{ id: 'changning', name: '长宁区', cityId: 'shanghai_city' },
{ id: 'jingan', name: '静安区', cityId: 'shanghai_city' },
{ id: 'putuo', name: '普陀区', cityId: 'shanghai_city' },
{ id: 'hongkou', name: '虹口区', cityId: 'shanghai_city' },
{ id: 'yangpu', name: '杨浦区', cityId: 'shanghai_city' },
{ id: 'minhang', name: '闵行区', cityId: 'shanghai_city' },
{ id: 'baoshan', name: '宝山区', cityId: 'shanghai_city' },
{ id: 'jiading', name: '嘉定区', cityId: 'shanghai_city' },
{ id: 'pudong', name: '浦东新区', cityId: 'shanghai_city' },
{ id: 'jinshan', name: '金山区', cityId: 'shanghai_city' },
{ id: 'songjiang', name: '松江区', cityId: 'shanghai_city' },
{ id: 'qingpu', name: '青浦区', cityId: 'shanghai_city' },
{ id: 'fengxian', name: '奉贤区', cityId: 'shanghai_city' },
{ id: 'chongming', name: '崇明区', cityId: 'shanghai_city' }
],
hangzhou: [
{ id: 'shangcheng', name: '上城区', cityId: 'hangzhou' },
{ id: 'gongshu', name: '拱墅区', cityId: 'hangzhou' },
{ id: 'xihu', name: '西湖区', cityId: 'hangzhou' },
{ id: 'binjiang', name: '滨江区', cityId: 'hangzhou' },
{ id: 'xiaoshan', name: '萧山区', cityId: 'hangzhou' },
{ id: 'yuhang', name: '余杭区', cityId: 'hangzhou' },
{ id: 'fuyang', name: '富阳区', cityId: 'hangzhou' },
{ id: 'linan', name: '临安区', cityId: 'hangzhou' },
{ id: 'linping', name: '临平区', cityId: 'hangzhou' },
{ id: 'qiantang', name: '钱塘区', cityId: 'hangzhou' },
{ id: 'tonglu', name: '桐庐县', cityId: 'hangzhou' },
{ id: 'chunan', name: '淳安县', cityId: 'hangzhou' },
{ id: 'jiande', name: '建德市', cityId: 'hangzhou' }
],
guangzhou: [
{ id: 'yuexiu', name: '越秀区', cityId: 'guangzhou' },
{ id: 'liwan', name: '荔湾区', cityId: 'guangzhou' },
{ id: 'haizhu', name: '海珠区', cityId: 'guangzhou' },
{ id: 'tianhe', name: '天河区', cityId: 'guangzhou' },
{ id: 'baiyun', name: '白云区', cityId: 'guangzhou' },
{ id: 'huangpu_gz', name: '黄埔区', cityId: 'guangzhou' },
{ id: 'panyu', name: '番禺区', cityId: 'guangzhou' },
{ id: 'huadu', name: '花都区', cityId: 'guangzhou' },
{ id: 'nansha', name: '南沙区', cityId: 'guangzhou' },
{ id: 'conghua', name: '从化区', cityId: 'guangzhou' },
{ id: 'zengcheng', name: '增城区', cityId: 'guangzhou' }
],
shenzhen: [
{ id: 'luohu', name: '罗湖区', cityId: 'shenzhen' },
{ id: 'futian', name: '福田区', cityId: 'shenzhen' },
{ id: 'nanshan', name: '南山区', cityId: 'shenzhen' },
{ id: 'bao_an', name: '宝安区', cityId: 'shenzhen' },
{ id: 'longgang', name: '龙岗区', cityId: 'shenzhen' },
{ id: 'yantian', name: '盐田区', cityId: 'shenzhen' },
{ id: 'longhua', name: '龙华区', cityId: 'shenzhen' },
{ id: 'pingshan', name: '坪山区', cityId: 'shenzhen' },
{ id: 'guangming', name: '光明区', cityId: 'shenzhen' },
{ id: 'dapeng', name: '大鹏新区', cityId: 'shenzhen' }
],
nanjing: [
{ id: 'xuanwu', name: '玄武区', cityId: 'nanjing' },
{ id: 'qinhuai', name: '秦淮区', cityId: 'nanjing' },
{ id: 'jianye', name: '建邺区', cityId: 'nanjing' },
{ id: 'gulou', name: '鼓楼区', cityId: 'nanjing' },
{ id: 'pukou', name: '浦口区', cityId: 'nanjing' },
{ id: 'qixia', name: '栖霞区', cityId: 'nanjing' },
{ id: 'yuhuatai', name: '雨花台区', cityId: 'nanjing' },
{ id: 'jiangning', name: '江宁区', cityId: 'nanjing' },
{ id: 'liuhe', name: '六合区', cityId: 'nanjing' },
{ id: 'lishui', name: '溧水区', cityId: 'nanjing' },
{ id: 'gaochun', name: '高淳区', cityId: 'nanjing' }
],
suzhou: [
{ id: 'gusu', name: '姑苏区', cityId: 'suzhou' },
{ id: 'wuzhong', name: '吴中区', cityId: 'suzhou' },
{ id: 'xiangcheng', name: '相城区', cityId: 'suzhou' },
{ id: 'huqiu', name: '虎丘区', cityId: 'suzhou' },
{ id: 'wujiang', name: '吴江区', cityId: 'suzhou' },
{ id: 'changshu', name: '常熟市', cityId: 'suzhou' },
{ id: 'zhangjiagang', name: '张家港市', cityId: 'suzhou' },
{ id: 'kunshan', name: '昆山市', cityId: 'suzhou' },
{ id: 'taicang', name: '太仓市', cityId: 'suzhou' }
]
}
}
},
computed: {
cities() {
if (!this.selectedProvince) return []
return this.cityData[this.selectedProvince] || []
},
districts() {
if (!this.selectedCity) return []
return this.districtData[this.selectedCity] || []
}
},
watch: {
value: {
handler(val) {
if (val) {
this.selectedProvince = val.province || ''
this.selectedCity = val.city || ''
this.selectedDistrict = val.district || ''
}
},
immediate: true
}
},
methods: {
/**
* 选择热门城市
*/
handleHotCitySelect(city) {
this.selectedProvince = city.province
this.selectedCity = city.city
this.selectedDistrict = city.district
},
/**
* 选择省份
*/
handleProvinceSelect(province) {
this.selectedProvince = province.id
this.selectedCity = ''
this.selectedDistrict = ''
},
/**
* 选择城市
*/
handleCitySelect(city) {
this.selectedCity = city.id
this.selectedDistrict = ''
},
/**
* 选择区域
*/
handleDistrictSelect(district) {
this.selectedDistrict = district.id
},
/**
* 重置
*/
handleReset() {
this.selectedProvince = ''
this.selectedCity = ''
this.selectedDistrict = ''
},
/**
* 确定
*/
handleConfirm() {
console.log('CityFilter - handleConfirm 被调用')
const result = {
province: this.selectedProvince,
city: this.selectedCity,
district: this.selectedDistrict,
// 获取选中的名称
provinceName: this.getProvinceName(),
cityName: this.getCityName(),
districtName: this.getDistrictName()
}
console.log('CityFilter - 发出 confirm 事件:', result)
this.$emit('confirm', result)
// 发出 update:show 事件关闭弹窗
console.log('CityFilter - 发出 update:show 事件: false')
this.$emit('update:show', false)
},
/**
* 获取省份名称
*/
getProvinceName() {
const province = this.provinces.find(p => p.id === this.selectedProvince)
return province ? province.name : ''
},
/**
* 获取城市名称
*/
getCityName() {
const city = this.cities.find(c => c.id === this.selectedCity)
return city ? city.name : ''
},
/**
* 获取区域名称
*/
getDistrictName() {
const district = this.districts.find(d => d.id === this.selectedDistrict)
return district ? district.name : ''
},
/**
* 点击遮罩关闭
*/
handleMaskClick() {
this.$emit('update:show', false)
}
}
}
</script>
<style lang="scss" scoped>
.city-filter-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
.city-filter-panel {
background-color: #fff;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideDown 0.3s ease-out;
margin-top: calc(var(--status-bar-height) + 88rpx);
// 热门城市
.hot-cities {
padding: 32rpx 32rpx 24rpx;
border-bottom: 1rpx solid #f4f4f5;
.section-title {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #d97706;
margin-bottom: 24rpx;
}
.hot-city-list {
display: flex;
gap: 24rpx;
.hot-city-item {
flex: 1;
padding: 16rpx 24rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
text-align: center;
.city-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #52525b;
}
&.active {
background-color: #fff5eb;
.city-text {
color: #d97706;
font-weight: 500;
}
}
}
}
}
// 三级联动容器
.cascader-container {
flex: 1;
display: flex;
overflow: hidden;
.cascader-column {
flex: 1;
height: 50vh;
background-color: #fff;
&:nth-child(1) {
background-color: #f9fafb;
}
&:nth-child(2) {
background-color: #f4f4f5;
}
&:nth-child(3) {
background-color: #fff;
}
.cascader-item {
padding: 24rpx 32rpx;
border-bottom: 1rpx solid #f4f4f5;
.item-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #52525b;
}
&.active {
background-color: #fff;
.item-text {
color: #d97706;
font-weight: 500;
}
}
}
}
}
// 底部按钮
.footer-buttons {
display: flex;
gap: 24rpx;
padding: 24rpx 32rpx;
background-color: #fff;
border-top: 1rpx solid #f4f4f5;
.btn-reset,
.btn-confirm {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
.btn-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
}
}
.btn-reset {
background-color: #fff;
border: 2rpx solid #e5e5e5;
.btn-text {
color: #52525b;
}
}
.btn-confirm {
background-color: #18181b;
.btn-text {
color: #fff;
}
}
}
}
}
// 下拉动画
@keyframes slideDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,379 @@
<!--
* 咨询师卡片组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="consultant-card" @click="handleClick">
<!-- 头像带加载动画 -->
<view class="avatar-wrapper">
<image
:src="avatar"
class="avatar"
:class="{ 'avatar-loaded': avatarLoaded }"
mode="aspectFill"
@load="handleAvatarLoad"
@error="handleAvatarError"
/>
<view v-if="!avatarLoaded" class="avatar-loading">
<view class="loading-circle"></view>
</view>
</view>
<!-- 内容区 -->
<view class="content">
<!-- 第一行姓名 + 等级徽章 + 时间状态 -->
<view class="row-1">
<view class="name-badge">
<text class="name">{{ name }}</text>
<!-- 等级徽章图标 -->
<image
v-if="levelIcon"
:src="levelIcon"
class="level-badge"
mode="aspectFit"
/>
</view>
<view class="time-status">
<text class="time">明天{{ availableTime }}</text>
<view class="divider"></view>
<text class="status">{{ status }}</text>
</view>
</view>
<!-- 第二行职称描述单行超出省略 -->
<view class="row-2">
<text class="title-text">{{ title }}</text>
</view>
<!-- 第三行擅长领域单行超出省略 -->
<view class="row-3">
<text class="specialty-text">擅长{{ specialties }}</text>
</view>
<!-- 第四行标签列表最多5个超出隐藏 -->
<view class="row-4">
<view v-for="(tag, index) in displayTags" :key="index" class="tag">
<text class="tag-text">{{ tag }}</text>
</view>
</view>
<!-- 第五行地点 + 价格 -->
<view class="row-5">
<view class="location">
<image
src="/static/icons/map-pin-line.png"
class="location-icon"
mode="aspectFit"
/>
<text class="location-text">{{ city }}</text>
</view>
<text class="price">¥{{ price }}/</text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'ConsultantCard',
props: {
id: {
type: [String, Number],
required: true
},
avatar: {
type: String,
default: ''
},
name: {
type: String,
required: true
},
levelIcon: {
type: String,
default: ''
},
availableTime: {
type: String,
default: ''
},
status: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
specialties: {
type: String,
default: ''
},
tags: {
type: Array,
default: () => []
},
city: {
type: String,
default: ''
},
price: {
type: Number,
required: true
}
},
data() {
return {
avatarLoaded: false
}
},
computed: {
displayTags() {
// 最多显示4个标签
return this.tags.slice(0, 4)
}
},
methods: {
handleClick() {
this.$emit('click', this.id)
},
handleAvatarLoad() {
this.avatarLoaded = true
},
handleAvatarError() {
this.avatarLoaded = true
console.error('头像加载失败:', this.avatar)
}
}
}
</script>
<style lang="scss" scoped>
.consultant-card {
display: flex;
gap: 24rpx;
padding: 24rpx;
background-color: #fff;
border-radius: 20rpx;
// 头像容器
.avatar-wrapper {
position: relative;
width: 140rpx;
height: 140rpx;
flex-shrink: 0;
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
opacity: 0;
transition: opacity 0.3s ease-in-out;
&.avatar-loaded {
opacity: 1;
}
}
// 加载动画
.avatar-loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
.loading-circle {
width: 40rpx;
height: 40rpx;
border: 4rpx solid #e5e5e5;
border-top-color: #a78e78;
border-radius: 50%;
animation: avatar-spin 0.8s linear infinite;
}
}
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
min-width: 0;
// 第一行:姓名 + 等级 + 时间状态
.row-1 {
display: flex;
justify-content: space-between;
align-items: center;
.name-badge {
display: flex;
align-items: center;
gap: 8rpx;
.name {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #09090b;
line-height: 1.375;
}
// 等级徽章(高级名师图标)
.level-badge {
width: 110rpx;
height: 28rpx;
flex-shrink: 0;
}
}
.time-status {
display: flex;
align-items: center;
gap: 8rpx;
padding: 4rpx 16rpx;
background-color: #f9f7f4;
border-radius: 6rpx;
flex-shrink: 0;
.time {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #70553e;
line-height: 1.5;
}
.divider {
width: 1rpx;
height: 20rpx;
background-color: #e3dad3;
}
.status {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #70553e;
line-height: 1.5;
}
}
}
// 第二行:职称(单行省略)
.row-2 {
.title-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #6b7280;
line-height: 1.83;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
}
// 第三行:擅长(单行省略)
.row-3 {
.specialty-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #6b7280;
line-height: 1.83;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-all;
}
}
// 第四行标签最多5个
.row-4 {
display: flex;
flex-wrap: nowrap;
gap: 14rpx;
overflow: hidden;
.tag {
padding: 2rpx 12rpx;
background-color: rgba(216, 207, 199, 0.2);
border: 2rpx solid #d8cfc7;
border-radius: 8rpx;
flex-shrink: 0;
backdrop-filter: blur(21.7px);
height: 32rpx;
display: flex;
align-items: center;
justify-content: center;
.tag-text {
font-size: 18rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #a78e78;
line-height: 1.56;
white-space: nowrap;
}
}
}
// 第五行:地点 + 价格
.row-5 {
display: flex;
justify-content: space-between;
align-items: center;
.location {
display: flex;
align-items: center;
gap: 4rpx;
.location-icon {
width: 24rpx;
height: 24rpx;
}
.location-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #52525b;
line-height: 1.57;
}
}
.price {
font-size: 28rpx;
font-family: 'MiSans VF', 'PingFang SC', sans-serif;
font-weight: 500;
color: #6f4522;
line-height: 1.57;
}
}
}
}
// 头像加载动画
@keyframes avatar-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,152 @@
<!--
* 咨询师卡片骨架屏组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="consultant-card-skeleton">
<!-- 头像骨架 -->
<view class="skeleton-avatar skeleton-animate"></view>
<!-- 内容区骨架 -->
<view class="skeleton-content">
<!-- 第一行 -->
<view class="skeleton-row-1">
<view class="skeleton-name skeleton-animate"></view>
<view class="skeleton-time skeleton-animate"></view>
</view>
<!-- 第二行 -->
<view class="skeleton-row-2">
<view class="skeleton-line skeleton-animate"></view>
</view>
<!-- 第三行 -->
<view class="skeleton-row-3">
<view class="skeleton-line skeleton-animate"></view>
</view>
<!-- 第四行 -->
<view class="skeleton-row-4">
<view class="skeleton-tag skeleton-animate"></view>
<view class="skeleton-tag skeleton-animate"></view>
<view class="skeleton-tag skeleton-animate"></view>
</view>
<!-- 第五行 -->
<view class="skeleton-row-5">
<view class="skeleton-location skeleton-animate"></view>
<view class="skeleton-price skeleton-animate"></view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'ConsultantCardSkeleton'
}
</script>
<style lang="scss" scoped>
.consultant-card-skeleton {
display: flex;
gap: 24rpx;
padding: 24rpx;
background-color: #fff;
border-radius: 20rpx;
.skeleton-avatar {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
background-color: #f5f5f5;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 12rpx;
.skeleton-row-1 {
display: flex;
justify-content: space-between;
align-items: center;
.skeleton-name {
width: 120rpx;
height: 40rpx;
background-color: #f5f5f5;
border-radius: 4rpx;
}
.skeleton-time {
width: 140rpx;
height: 32rpx;
background-color: #f5f5f5;
border-radius: 6rpx;
}
}
.skeleton-row-2,
.skeleton-row-3 {
.skeleton-line {
width: 100%;
height: 28rpx;
background-color: #f5f5f5;
border-radius: 4rpx;
}
}
.skeleton-row-4 {
display: flex;
gap: 14rpx;
.skeleton-tag {
width: 100rpx;
height: 32rpx;
background-color: #f5f5f5;
border-radius: 4rpx;
}
}
.skeleton-row-5 {
display: flex;
justify-content: space-between;
align-items: center;
.skeleton-location {
width: 80rpx;
height: 32rpx;
background-color: #f5f5f5;
border-radius: 4rpx;
}
.skeleton-price {
width: 100rpx;
height: 32rpx;
background-color: #f5f5f5;
border-radius: 4rpx;
}
}
}
}
// 骨架屏动画
.skeleton-animate {
animation: skeleton-loading 1.5s ease-in-out infinite;
background: linear-gradient(90deg, #f5f5f5 25%, #e5e5e5 50%, #f5f5f5 75%);
background-size: 200% 100%;
}
@keyframes skeleton-loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View File

@@ -0,0 +1,89 @@
<!--
* 筛选栏组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="filter-bar">
<view
v-for="item in filters"
:key="item.key"
class="filter-item"
:class="{ active: item.active }"
@click="handleFilterClick(item)"
>
<text class="filter-text">{{ item.displayLabel || item.label }}</text>
<view class="filter-icon">
<!-- 预留下拉箭头图标位置 -->
<text class="icon-placeholder"></text>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'FilterBar',
props: {
filters: {
type: Array,
default: () => [
{ key: 'city', label: '城市' },
{ key: 'issue', label: '困扰' },
{ key: 'price', label: '价格' },
{ key: 'more', label: '更多' },
{ key: 'sort', label: '排序' }
]
}
},
methods: {
handleFilterClick(item) {
this.$emit('filter-click', item.key)
}
}
}
</script>
<style lang="scss" scoped>
.filter-bar {
display: flex;
justify-content: space-between;
align-items: center;
.filter-item {
display: flex;
align-items: center;
gap: 4rpx;
.filter-text {
font-size: 28rpx;
font-weight: 500;
color: #71717a;
transition: color 0.3s;
}
.filter-icon {
display: flex;
align-items: center;
justify-content: center;
.icon-placeholder {
font-size: 20rpx;
color: #71717a;
transition: color 0.3s;
}
}
// 激活状态(有筛选条件时)
&.active {
.filter-text {
color: #d97706;
}
.icon-placeholder {
color: #d97706;
}
}
}
}
</style>

View File

@@ -0,0 +1,243 @@
<!--
* 通用筛选弹窗组件
* @author AI
* @date 2026-03-06
-->
<template>
<view v-if="show" class="filter-popup-mask" @tap="handleMaskClick">
<view class="filter-popup-panel" @tap.stop>
<!-- 标题 -->
<view v-if="title" class="panel-title">
<text class="title-text">{{ title }}</text>
</view>
<!-- 选项列表 -->
<view class="options-list">
<view
v-for="option in options"
:key="option.id"
class="option-item"
:class="{ active: isSelected(option.value) }"
@tap="handleOptionSelect(option)"
>
<text class="option-text">{{ option.label }}</text>
</view>
</view>
<!-- 底部按钮 -->
<view class="footer-buttons">
<view class="btn-reset" @tap="handleReset">
<text class="btn-text">重置</text>
</view>
<view class="btn-confirm" @tap="handleConfirm">
<text class="btn-text">确定</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'FilterPopup',
props: {
show: {
type: Boolean,
default: false
},
title: {
type: String,
default: ''
},
options: {
type: Array,
default: () => []
},
value: {
type: [String, Number, Array],
default: ''
},
multiple: {
type: Boolean,
default: false
}
},
data() {
return {
selectedValue: this.multiple ? (Array.isArray(this.value) ? [...this.value] : []) : this.value
}
},
watch: {
value(val) {
this.selectedValue = this.multiple ? (Array.isArray(val) ? [...val] : []) : val
}
},
methods: {
/**
* 判断是否选中
*/
isSelected(value) {
if (this.multiple) {
return this.selectedValue.includes(value)
}
return this.selectedValue === value
},
/**
* 选择选项
*/
handleOptionSelect(option) {
if (this.multiple) {
const index = this.selectedValue.indexOf(option.value)
if (index > -1) {
this.selectedValue.splice(index, 1)
} else {
this.selectedValue.push(option.value)
}
} else {
this.selectedValue = option.value
}
},
/**
* 重置
*/
handleReset() {
this.selectedValue = this.multiple ? [] : ''
},
/**
* 确定
*/
handleConfirm() {
this.$emit('confirm', this.selectedValue)
// 发出 update:show 事件关闭弹窗
this.$emit('update:show', false)
},
/**
* 点击遮罩关闭
*/
handleMaskClick() {
this.$emit('update:show', false)
}
}
}
</script>
<style lang="scss" scoped>
.filter-popup-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
.filter-popup-panel {
background-color: #fff;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideDown 0.3s ease-out;
margin-top: calc(var(--status-bar-height) + 88rpx);
.panel-title {
padding: 32rpx 32rpx 24rpx;
border-bottom: 1rpx solid #f4f4f5;
.title-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #18181b;
}
}
.options-list {
padding: 32rpx;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx 32rpx;
max-height: 60vh;
overflow-y: auto;
.option-item {
padding: 16rpx 24rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
text-align: center;
.option-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #52525b;
}
&.active {
background-color: #fff5eb;
border: 1rpx solid #d97706;
.option-text {
color: #d97706;
font-weight: 500;
}
}
}
}
.footer-buttons {
display: flex;
gap: 24rpx;
padding: 24rpx 32rpx;
background-color: #fff;
border-top: 1rpx solid #f4f4f5;
.btn-reset,
.btn-confirm {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
.btn-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
}
}
.btn-reset {
background-color: #fff;
border: 2rpx solid #e5e5e5;
.btn-text {
color: #52525b;
}
}
.btn-confirm {
background-color: #18181b;
.btn-text {
color: #fff;
}
}
}
}
}
// 下拉动画
@keyframes slideDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,481 @@
<!--
* 困扰筛选组件多级分类
* @author AI
* @date 2026-03-06
-->
<template>
<view v-if="show" class="issue-filter-mask" @tap="handleMaskClick">
<view class="issue-filter-panel" @tap.stop>
<!-- 标题 -->
<view class="panel-header">
<text class="header-title">请选择您的困扰多选</text>
</view>
<!-- 主体内容 -->
<view class="panel-body">
<!-- 左侧一级分类 -->
<scroll-view class="category-list" scroll-y>
<view
v-for="category in categories"
:key="category.id"
class="category-item"
:class="{ active: currentCategory === category.id }"
@tap="handleCategorySelect(category.id)"
>
<text class="category-text">{{ category.name }}</text>
<view v-if="getCategorySelectedCount(category.id) > 0" class="category-badge">
<text class="badge-text">{{ getCategorySelectedCount(category.id) }}</text>
</view>
</view>
</scroll-view>
<!-- 右侧二级标签 -->
<scroll-view class="tags-container" scroll-y>
<view class="tags-header">
<text class="tags-title">{{ currentCategoryName }}</text>
<text class="expand-text" @tap="toggleExpand">{{ isExpanded ? '收起' : '展开' }}</text>
</view>
<view class="tags-list">
<view
v-for="tag in currentTags"
:key="tag.id"
class="tag-item"
:class="{ active: isTagSelected(tag.id) }"
@tap="handleTagSelect(tag)"
>
<text class="tag-text">{{ tag.name }}</text>
</view>
</view>
</scroll-view>
</view>
<!-- 底部按钮 -->
<view class="footer-buttons">
<view class="btn-reset" @tap="handleReset">
<text class="btn-text">重置</text>
</view>
<view class="btn-confirm" @tap="handleConfirm">
<text class="btn-text">确定</text>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'IssueFilter',
props: {
show: {
type: Boolean,
default: false
},
value: {
type: Array,
default: () => []
}
},
data() {
return {
currentCategory: 'mental',
isExpanded: true,
selectedTags: [],
// 分类数据
categories: [
{ id: 'mental', name: '心理健康' },
{ id: 'relationship', name: '婚烟/亲密关系' },
{ id: 'family', name: '家庭/养育' },
{ id: 'career', name: '学业/发展' },
{ id: 'work', name: '职场相关' },
{ id: 'interpersonal', name: '人际关系' },
{ id: 'growth', name: '个人成长' },
{ id: 'life', name: '生活事件' }
],
// 标签数据
tagsData: {
mental: [
{ id: 'mental_all', name: '全部', categoryId: 'mental' },
{ id: 'mental_depression', name: '抑郁', categoryId: 'mental' },
{ id: 'mental_anxiety', name: '焦虑', categoryId: 'mental' },
{ id: 'mental_loss', name: '丧失与哀伤', categoryId: 'mental' },
{ id: 'mental_sleep', name: '睡眠问题', categoryId: 'mental' },
{ id: 'mental_bipolar', name: '双相情感障碍', categoryId: 'mental' },
{ id: 'mental_compulsive', name: '强迫症状及相关问题', categoryId: 'mental' }
],
relationship: [
{ id: 'rel_all', name: '全部', categoryId: 'relationship' },
{ id: 'rel_crisis', name: '亲密关系危机', categoryId: 'relationship' },
{ id: 'rel_communication', name: '伴侣沟通问题', categoryId: 'relationship' },
{ id: 'rel_breakup', name: '失恋', categoryId: 'relationship' },
{ id: 'rel_sex', name: '性议题', categoryId: 'relationship' }
],
family: [
{ id: 'fam_all', name: '全部', categoryId: 'family' },
{ id: 'fam_conflict', name: '家庭冲突', categoryId: 'family' },
{ id: 'fam_communication', name: '亲子沟通', categoryId: 'family' },
{ id: 'fam_child', name: '童男轻女', categoryId: 'family' },
{ id: 'fam_pressure', name: '熟龄者压力', categoryId: 'family' },
{ id: 'fam_parenting', name: '产后抚育', categoryId: 'family' },
{ id: 'fam_adolescent', name: '青儿分歧', categoryId: 'family' },
{ id: 'fam_violence', name: '家庭暴力', categoryId: 'family' },
{ id: 'fam_violence2', name: '家庭暴力', categoryId: 'family' }
],
career: [
{ id: 'career_all', name: '全部', categoryId: 'career' },
{ id: 'career_planning', name: '职业规划', categoryId: 'career' },
{ id: 'career_stress', name: '学业压力', categoryId: 'career' }
],
work: [
{ id: 'work_all', name: '全部', categoryId: 'work' },
{ id: 'work_stress', name: '工作压力', categoryId: 'work' },
{ id: 'work_burnout', name: '职业倦怠', categoryId: 'work' }
],
interpersonal: [
{ id: 'inter_all', name: '全部', categoryId: 'interpersonal' },
{ id: 'inter_social', name: '社交困难', categoryId: 'interpersonal' }
],
growth: [
{ id: 'growth_all', name: '全部', categoryId: 'growth' },
{ id: 'growth_self', name: '自我探索', categoryId: 'growth' }
],
life: [
{ id: 'life_all', name: '全部', categoryId: 'life' },
{ id: 'life_trauma', name: '创伤事件', categoryId: 'life' }
]
}
}
},
computed: {
currentCategoryName() {
const category = this.categories.find(c => c.id === this.currentCategory)
return category ? category.name : ''
},
currentTags() {
return this.tagsData[this.currentCategory] || []
}
},
watch: {
value: {
handler(val) {
this.selectedTags = Array.isArray(val) ? [...val] : []
},
immediate: true
}
},
methods: {
/**
* 选择一级分类
*/
handleCategorySelect(categoryId) {
this.currentCategory = categoryId
},
/**
* 获取分类下已选择的标签数量
*/
getCategorySelectedCount(categoryId) {
const tags = this.tagsData[categoryId] || []
return tags.filter(tag => this.selectedTags.includes(tag.id) && tag.id !== `${categoryId}_all`).length
},
/**
* 判断标签是否选中
*/
isTagSelected(tagId) {
return this.selectedTags.includes(tagId)
},
/**
* 选择标签
*/
handleTagSelect(tag) {
const index = this.selectedTags.indexOf(tag.id)
// 如果是"全部"标签
if (tag.id.endsWith('_all')) {
if (index > -1) {
// 取消选中"全部",清空该分类下的所有选择
this.selectedTags = this.selectedTags.filter(id => {
const selectedTag = this.findTagById(id)
return selectedTag && selectedTag.categoryId !== tag.categoryId
})
} else {
// 选中"全部",清空该分类下的其他选择,只保留"全部"
this.selectedTags = this.selectedTags.filter(id => {
const selectedTag = this.findTagById(id)
return selectedTag && selectedTag.categoryId !== tag.categoryId
})
this.selectedTags.push(tag.id)
}
} else {
// 普通标签
if (index > -1) {
// 取消选中
this.selectedTags.splice(index, 1)
} else {
// 选中,同时取消该分类的"全部"
const allTagId = `${tag.categoryId}_all`
const allIndex = this.selectedTags.indexOf(allTagId)
if (allIndex > -1) {
this.selectedTags.splice(allIndex, 1)
}
this.selectedTags.push(tag.id)
}
}
},
/**
* 根据ID查找标签
*/
findTagById(tagId) {
for (const categoryId in this.tagsData) {
const tag = this.tagsData[categoryId].find(t => t.id === tagId)
if (tag) return tag
}
return null
},
/**
* 切换展开/收起
*/
toggleExpand() {
this.isExpanded = !this.isExpanded
},
/**
* 重置
*/
handleReset() {
this.selectedTags = []
},
/**
* 确定
*/
handleConfirm() {
this.$emit('confirm', this.selectedTags)
this.$emit('update:show', false)
},
/**
* 点击遮罩关闭
*/
handleMaskClick() {
this.$emit('update:show', false)
}
}
}
</script>
<style lang="scss" scoped>
.issue-filter-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
.issue-filter-panel {
background-color: #fff;
max-height: 80vh;
display: flex;
flex-direction: column;
animation: slideDown 0.3s ease-out;
margin-top: calc(var(--status-bar-height) + 88rpx);
// 标题
.panel-header {
padding: 32rpx 32rpx 24rpx;
border-bottom: 1rpx solid #f4f4f5;
.header-title {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #18181b;
}
}
// 主体内容
.panel-body {
flex: 1;
display: flex;
overflow: hidden;
// 左侧分类列表
.category-list {
width: 230rpx;
background-color: #f9fafb;
.category-item {
position: relative;
padding: 32rpx 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
.category-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #52525b;
line-height: 1.43;
}
.category-badge {
width: 32rpx;
height: 32rpx;
background-color: #d97706;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
.badge-text {
font-size: 20rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #fff;
}
}
&.active {
background-color: #fff;
.category-text {
color: #d97706;
font-weight: 500;
}
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background-color: #d97706;
}
}
}
}
// 右侧标签容器
.tags-container {
flex: 1;
background-color: #fff;
.tags-header {
padding: 24rpx 32rpx;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1rpx solid #f4f4f5;
.tags-title {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #18181b;
}
.expand-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #a1a1aa;
}
}
.tags-list {
padding: 24rpx 32rpx;
display: flex;
flex-wrap: wrap;
gap: 20rpx;
.tag-item {
padding: 12rpx 24rpx;
background-color: #f4f4f5;
border-radius: 8rpx;
.tag-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #52525b;
line-height: 1.43;
}
&.active {
background-color: #fff5eb;
border: 1rpx solid #d97706;
.tag-text {
color: #d97706;
font-weight: 500;
}
}
}
}
}
}
// 底部按钮
.footer-buttons {
display: flex;
gap: 24rpx;
padding: 24rpx 32rpx;
background-color: #fff;
border-top: 1rpx solid #f4f4f5;
.btn-reset,
.btn-confirm {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
display: flex;
align-items: center;
justify-content: center;
.btn-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
}
}
.btn-reset {
background-color: #fff;
border: 2rpx solid #e5e5e5;
.btn-text {
color: #52525b;
}
}
.btn-confirm {
background-color: #18181b;
.btn-text {
color: #fff;
}
}
}
}
}
// 下拉动画
@keyframes slideDown {
from {
transform: translateY(-100%);
}
to {
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,341 @@
<template>
<view class="schedule-calendar">
<!-- 标题 -->
<view class="calendar-header">
<view class="header-indicator"></view>
<text class="header-title">可约时间</text>
</view>
<!-- 日期列表 - 横向滚动 -->
<scroll-view class="date-scroll" scroll-x :show-scrollbar="false">
<view class="date-list">
<view
v-for="(item, index) in scheduleData"
:key="index"
class="date-item"
:class="{ 'has-available': item.available }"
@tap="selectDate(item)"
>
<!-- 日期头部月份 + 星期 -->
<view class="date-header">
<text class="month-text">{{ item.month }}</text>
<text class="weekday-text">{{ item.weekday }}</text>
</view>
<!-- 日期内容日期 + 状态 -->
<view
class="date-content"
:class="{
'is-today': item.isToday,
'is-available': item.available,
'is-full': !item.available
}"
>
<text class="day-text">{{ item.day }}</text>
<text class="status-text">{{ item.statusText }}</text>
</view>
</view>
</view>
</scroll-view>
<!-- 空状态 -->
<view v-if="scheduleData.length === 0" class="empty-state">
<text class="empty-text">暂无可预约时间</text>
</view>
</view>
</template>
<script>
export default {
name: 'ScheduleCalendar',
props: {
consultantId: {
type: String,
default: ''
}
},
data() {
return {
scheduleData: []
}
},
mounted() {
this.loadScheduleData()
},
methods: {
/**
* 加载可预约时间数据
*/
async loadScheduleData() {
try {
// TODO: 调用真实接口
// const res = await getConsultantSchedule(this.consultantId)
// this.scheduleData = this.formatScheduleData(res.data)
// Mock 数据
const today = new Date()
this.scheduleData = [
{
month: '02月',
weekday: '周五',
day: '今',
statusText: '满',
isToday: true,
available: false,
date: this.formatDate(today)
},
{
month: '02月',
weekday: '周六',
day: '28',
statusText: '剩1',
isToday: false,
available: true,
date: this.formatDate(new Date(today.getTime() + 86400000))
},
{
month: '03月',
weekday: '周天',
day: '01',
statusText: '满',
isToday: false,
available: false,
date: this.formatDate(new Date(today.getTime() + 86400000 * 2))
},
{
month: '03月',
weekday: '周一',
day: '02',
statusText: '满',
isToday: false,
available: false,
date: this.formatDate(new Date(today.getTime() + 86400000 * 3))
},
{
month: '03月',
weekday: '周二',
day: '03',
statusText: '剩2',
isToday: false,
available: true,
date: this.formatDate(new Date(today.getTime() + 86400000 * 4))
},
{
month: '03月',
weekday: '周三',
day: '04',
statusText: '剩1',
isToday: false,
available: true,
date: this.formatDate(new Date(today.getTime() + 86400000 * 5))
},
{
month: '03月',
weekday: '周四',
day: '05',
statusText: '满',
isToday: false,
available: false,
date: this.formatDate(new Date(today.getTime() + 86400000 * 6))
}
]
} catch (error) {
console.error('加载可预约时间失败:', error)
this.scheduleData = []
}
},
/**
* 格式化日期
*/
formatDate(date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
},
/**
* 选择日期
*/
selectDate(dateItem) {
if (!dateItem.available) {
uni.showToast({
title: '该日期已约满',
icon: 'none'
})
return
}
this.$emit('time-select', {
date: dateItem.date,
month: dateItem.month,
weekday: dateItem.weekday,
day: dateItem.day
})
}
}
}
</script>
<style lang="scss" scoped>
.schedule-calendar {
display: flex;
flex-direction: column;
gap: 32rpx;
// 标题
.calendar-header {
display: flex;
align-items: center;
gap: 8rpx;
.header-indicator {
width: 4rpx;
height: 20rpx;
background-color: #9d5d00;
border-radius: 16rpx;
transform: rotate(180deg);
}
.header-title {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #18181b;
line-height: 1.25;
}
}
// 横向滚动容器
.date-scroll {
width: 100%;
white-space: nowrap;
}
// 日期列表
.date-list {
display: inline-flex;
gap: 12rpx;
padding-right: 32rpx;
.date-item {
display: inline-flex;
flex-direction: column;
gap: 8rpx;
width: 80rpx;
flex-shrink: 0;
// 日期头部
.date-header {
display: flex;
flex-direction: column;
align-items: center;
.month-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 500;
color: #a1a1aa;
line-height: 1.5;
text-align: center;
}
.weekday-text {
font-size: 24rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #a1a1aa;
line-height: 1.5;
text-align: center;
}
}
// 日期内容
.date-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 88rpx;
border-radius: 8rpx;
background-color: #f4f4f5;
padding: 4rpx 16rpx;
.day-text {
font-size: 32rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #a1a1aa;
line-height: 1.375;
text-align: center;
}
.status-text {
font-size: 22rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #a1a1aa;
line-height: 1.545;
text-align: center;
}
// 今天 - 灰色不可用
&.is-today {
background-color: #f4f4f5;
.day-text,
.status-text {
color: #a1a1aa;
}
}
// 可用状态 - 米黄色
&.is-available {
background-color: #f9f7f4;
.day-text,
.status-text {
color: #70553e;
}
}
// 已满状态 - 灰色
&.is-full {
background-color: #f4f4f5;
.day-text,
.status-text {
color: #a1a1aa;
}
}
}
// 可用日期的头部文字颜色
&.has-available {
.date-header {
.month-text,
.weekday-text {
color: #3f3f46;
}
}
}
}
}
// 空状态
.empty-state {
padding: 80rpx 0;
display: flex;
align-items: center;
justify-content: center;
.empty-text {
font-size: 28rpx;
font-family: 'PingFang SC', sans-serif;
font-weight: 400;
color: #a1a1aa;
}
}
}
</style>

View File

@@ -0,0 +1,125 @@
<!--
* 搜索栏组件
* @author AI
* @date 2026-03-06
-->
<template>
<view class="search-bar">
<view class="search-input-wrapper">
<view class="search-icon">
<!-- 预留搜索图标位置可以使用 image iconfont -->
<view class="icon-search-placeholder"></view>
</view>
<input
v-model="searchValue"
class="search-input"
:placeholder="placeholder"
@input="handleInput"
@confirm="handleConfirm"
/>
</view>
<view v-if="showButton" class="search-button" @click="handleSearch">
<text class="button-text">搜索</text>
</view>
</view>
</template>
<script>
export default {
name: 'SearchBar',
props: {
placeholder: {
type: String,
default: '搜索咨询师'
},
showButton: {
type: Boolean,
default: true
},
value: {
type: String,
default: ''
}
},
data() {
return {
searchValue: this.value
}
},
watch: {
value(val) {
this.searchValue = val
}
},
methods: {
handleInput(e) {
this.searchValue = e.detail.value
this.$emit('input', this.searchValue)
},
handleConfirm() {
this.$emit('confirm', this.searchValue)
},
handleSearch() {
this.$emit('search', this.searchValue)
}
}
}
</script>
<style lang="scss" scoped>
.search-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 20rpx 32rpx;
background-color: #fff;
border-radius: 64rpx;
border: 2rpx solid #fff;
.search-input-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 20rpx;
.search-icon {
width: 32rpx;
height: 32rpx;
display: flex;
align-items: center;
justify-content: center;
// 预留搜索图标位置
.icon-search-placeholder {
width: 32rpx;
height: 32rpx;
// 可以使用 background-image 或 image 标签填充图标
// background-image: url('/static/icons/search.png');
// background-size: contain;
}
}
.search-input {
flex: 1;
font-size: 28rpx;
color: #18181b;
&::placeholder {
color: #a1a1aa;
}
}
}
.search-button {
padding: 8rpx 20rpx;
background-color: #5f504a;
border-radius: 34rpx;
.button-text {
font-size: 28rpx;
color: #fff;
}
}
}
</style>