feat(visit): 客户拜访页按新原型重构为多卡片表单

参照设计稿将 pages/visit/index 拆分为 4 张表单卡片 + 历史记录卡片:
- 我的信息:姓名,进页面自动带出账号 realName,可编辑
- 拜访对象:类型(门店/门店经理/其他 胶囊 tab)、姓名/门店、连锁
- 拜访信息:定位信息(点击重新定位 + 经纬度副行)、拜访日期(默认当天)
- 其他信息:沟通内容(500 字 + 实时计数)、拍照上传、活动小结

一并修复:
- 必填星号原用 ::before 伪元素,小程序对 <text> 伪元素支持不稳,改为
  显式 <text class="field-star">
- 必填标签原复用全局类名 .required,而 common.scss 中的 .required 经
  @extend .pos-a 带上 position:absolute / left:-9px / top:15px,导致标签
  脱离文档流堆叠到卡片左上角。改用 .is-required 并在样式处加注释说明。

配色沿用项目主题变量,未照搬设计稿颜色。历史记录展开详情增加拜访
类型与拜访日期两行。
This commit is contained in:
2026-10-11 20:47:31 +08:00
parent 876847ccbb
commit da5bf8733e
+391 -150
View File
@@ -3,88 +3,195 @@
<view class="page-hero">
<AppNavbar title="客户拜访" show-back/>
<view class="intro-card">
<view class="card-title">
<view class="title-bar"></view>
<text>培训拜访</text>
<view class="tip-icon">
<u-icon name="info-circle-fill" size="40rpx" :color="brandColor"/>
</view>
<view class="tip-body">
<text class="tip-title">温馨提示</text>
<text class="tip-text">请如实填写本次拜访信息,系统将自动记录您的姓名,拜访对象请填写实际到访人员或门店/经理。</text>
</view>
<text class="intro-text">记录培训拜访现场定位、沟通内容和照片,提交后可回看历史记录。</text>
</view>
</view>
<!-- 我的信息 -->
<view class="card block">
<view class="section-title">新增拜访</view>
<view class="form">
<view class="form-row location-row">
<text>定位信息</text>
<view class="location-box">
<view class="picker-value location-text" :class="{placeholder: !visitForm.location}">
{{ visitForm.location || '定位中,或点击右侧重新定位' }}
</view>
<view class="location-meta">
<text>{{ formatCoordinate('纬度', visitForm.lat) }}</text>
<text>{{ formatCoordinate('经度', visitForm.lng) }}</text>
</view>
</view>
<view class="refresh-btn" @tap="fetchLocation(false, true)">重新定位</view>
<view class="form-card-head">
<view class="head-icon">
<u-icon name="account-fill" size="32rpx" :color="brandColor"/>
</view>
<text class="head-title">我的信息</text>
</view>
<view class="field-row">
<text class="field-label is-required"><text class="field-star">*</text>姓名</text>
<view class="field-box">
<u-icon name="account" size="32rpx" color="#9aa7b8"/>
<input
v-model="visitForm.submitterName"
class="field-input"
type="text"
maxlength="30"
placeholder="请输入您的姓名"
placeholder-class="field-placeholder"
/>
</view>
</view>
</view>
<view class="form-row">
<text>连锁</text>
<view class="selector-picker" @tap="openChainPicker">
<view class="picker-value" :class="{placeholder: !visitForm.chain}">
{{ visitForm.chain || (chainLoading ? '连锁加载中...' : '请选择连锁') }}
</view>
<!-- 拜访对象 -->
<view class="card block">
<view class="form-card-head">
<view class="head-icon">
<u-icon name="account" size="32rpx" :color="brandColor"/>
</view>
<text class="head-title">拜访对象</text>
</view>
<view class="field-row">
<text class="field-label is-required"><text class="field-star">*</text>类型</text>
<view class="type-tabs">
<view
v-for="tab in visitTypeTabs"
:key="tab.value"
:class="['type-tab', visitForm.visitType === tab.value ? 'active' : '']"
@tap="visitForm.visitType = tab.value"
>
<u-icon :name="tab.icon" size="28rpx" :color="visitForm.visitType === tab.value ? brandColor : '#9aa7b8'"/>
<text>{{ tab.label }}</text>
</view>
</view>
</view>
<view class="form-row">
<text>拜访人员</text>
<input v-model="visitForm.name" maxlength="30" placeholder="请输入拜访人员"/>
<view class="field-row">
<text class="field-label is-required"><text class="field-star">*</text>姓名/门店</text>
<view class="field-box">
<input
v-model="visitForm.name"
class="field-input"
type="text"
maxlength="30"
:placeholder="targetPlaceholder"
placeholder-class="field-placeholder"
/>
<u-icon name="arrow-right" size="26rpx" color="#b6c2d1"/>
</view>
</view>
<view class="form-row textarea-row">
<text>沟通内容</text>
<view class="field-row">
<text class="field-label is-required"><text class="field-star">*</text>连锁</text>
<view class="field-box" @tap="openChainPicker">
<text class="field-text" :class="{placeholder: !visitForm.chain}">
{{ visitForm.chain || (chainLoading ? '连锁加载中...' : '请选择连锁') }}
</text>
<u-icon name="arrow-right" size="26rpx" color="#b6c2d1"/>
</view>
</view>
</view>
<!-- 拜访信息 -->
<view class="card block">
<view class="form-card-head">
<view class="head-icon">
<u-icon name="map-fill" size="32rpx" :color="brandColor"/>
</view>
<text class="head-title">拜访信息</text>
</view>
<view class="field-row">
<text class="field-label is-required"><text class="field-star">*</text>定位信息</text>
<view class="field-box" @tap="fetchLocation(false, true)">
<u-icon name="map" size="32rpx" color="#9aa7b8"/>
<text class="field-text" :class="{placeholder: !visitForm.location}">
{{ visitForm.location || '请点击获取当前位置' }}
</text>
<u-icon name="reload" size="30rpx" color="#b6c2d1"/>
</view>
</view>
<view v-if="visitForm.lat || visitForm.lng" class="field-meta">
<text>{{ formatCoordinate('纬度', visitForm.lat) }}</text>
<text>{{ formatCoordinate('经度', visitForm.lng) }}</text>
</view>
<view class="field-row">
<text class="field-label is-required"><text class="field-star">*</text>拜访日期</text>
<view class="field-box" @tap="showVisitDatePicker = true">
<u-icon name="calendar" size="32rpx" color="#9aa7b8"/>
<text class="field-text" :class="{placeholder: !visitForm.visitDate}">
{{ visitForm.visitDate || '请选择拜访日期' }}
</text>
<u-icon name="arrow-right" size="26rpx" color="#b6c2d1"/>
</view>
</view>
</view>
<!-- 其他信息 -->
<view class="card block">
<view class="form-card-head">
<view class="head-icon">
<u-icon name="edit-pen-fill" size="32rpx" :color="brandColor"/>
</view>
<text class="head-title">其他信息</text>
</view>
<view class="field-row textarea-row">
<text class="field-label is-required"><text class="field-star">*</text>沟通内容</text>
<view class="field-box textarea-box">
<textarea
v-model="visitForm.content"
class="textarea-input"
maxlength="500"
auto-height
placeholder="请输入沟通内容"
class="field-textarea"
:maxlength="contentMaxLength"
placeholder="请输入沟通内容..."
placeholder-class="field-placeholder"
/>
<text class="counter">{{ (visitForm.content || '').length }}/{{ contentMaxLength }}</text>
</view>
</view>
<view class="upload" @tap="uploadPhotos">
<view>
<view class="upload-title">拍照上传</view>
<view class="upload-meta">{{ photos.length ? `已上传 ${photos.length} 张,点击继续拍照` : '点击拍照上传,最多 9 张' }}</view>
</view>
<view class="chip">必填</view>
<view class="field-row">
<text class="field-label is-required"><text class="field-star">*</text>拍照上传</text>
<view class="field-box" @tap="uploadPhotos">
<u-icon name="camera-fill" size="32rpx" color="#9aa7b8"/>
<text class="field-text" :class="{placeholder: !photos.length}">
{{ photos.length ? `已上传 ${photos.length} 张,点击继续拍照` : '点击拍照上传,最多 9 张' }}
</text>
<u-icon name="arrow-right" size="26rpx" color="#b6c2d1"/>
</view>
<view v-if="photos.length" class="photo-list">
<view class="photo-item" v-for="(photo, index) in photos" :key="`${photo}-${index}`">
<image class="photo-image" :src="photo" mode="aspectFill" @tap="previewPhoto(index)"/>
<view class="photo-remove" @tap.stop="removePhoto(index)">x</view>
</view>
</view>
<view v-if="photos.length" class="photo-list">
<view class="photo-item" v-for="(photo, index) in photos" :key="`${photo}-${index}`">
<image class="photo-image" :src="photo" mode="aspectFill" @tap="previewPhoto(index)"/>
<view class="photo-remove" @tap.stop="removePhoto(index)">x</view>
</view>
</view>
<view class="form-row textarea-row">
<text>活动小结</text>
<view class="field-row textarea-row">
<text class="field-label is-required"><text class="field-star">*</text>活动小结</text>
<view class="field-box textarea-box">
<textarea
v-model="visitForm.summary"
class="textarea-input"
class="field-textarea"
maxlength="300"
auto-height
placeholder="请输入活动小结"
placeholder-class="field-placeholder"
/>
<text class="counter">{{ (visitForm.summary || '').length }}/300</text>
</view>
</view>
</view>
<view class="primary-btn submit" @tap="submitVisit">提交拜访</view>
<view class="submit-wrap">
<view class="primary-btn submit" @tap="submitVisit">
<text>提交拜访</text>
<u-icon name="arrow-right" size="30rpx" color="#ffffff"/>
</view>
</view>
<view class="card block">
<view class="section-title">历史记录</view>
<view class="form-card-head">
<view class="head-icon">
<u-icon name="list-dot" size="32rpx" :color="brandColor"/>
</view>
<text class="head-title">历史记录</text>
</view>
<view v-if="loading" class="empty">加载中...</view>
<view v-else-if="visitHistory.length === 0" class="empty">暂无记录</view>
<view v-else>
@@ -100,6 +207,8 @@
</view>
</view>
<view v-if="item.expanded" class="history-detail">
<view class="history-line">拜访类型:{{ item.visitType || '-' }}</view>
<view class="history-line">拜访日期:{{ item.visitDate || '-' }}</view>
<view class="history-line">定位:{{ item.location || '-' }}</view>
<view class="history-line">纬度:{{ item.lat || '-' }}</view>
<view class="history-line">经度:{{ item.lng || '-' }}</view>
@@ -133,25 +242,43 @@
@cancel="closeChainPicker"
@close="closeChainPicker"
/>
<u-datetime-picker
:show="showVisitDatePicker"
:value="visitDatePickerValue"
mode="date"
@confirm="onVisitDateConfirm"
@cancel="showVisitDatePicker = false"
@close="showVisitDatePicker = false"
/>
</view>
</template>
<script>
import dayjs from "dayjs";
import AppNavbar from "@/components/AppNavbar.vue";
import {chooseImg, getLocationOnly} from "@/util";
import {visitAddReq, visitListReq} from "@/api/visit";
import {merchantGroupByChainReq} from "@/api/shop";
import {userInfoReq} from "@/api/user";
export default {
components: {AppNavbar},
data() {
return {
brandColor: '#2fc6a7',
contentMaxLength: 500,
visitTypeTabs: [
{label: '门店', value: '门店', icon: 'home'},
{label: '门店经理', value: '门店经理', icon: 'account'},
{label: '其他', value: '其他', icon: 'more-dot-fill'}
],
visitForm: this.buildForm(),
photos: [],
visitHistory: [],
chainOptions: [],
chainLoading: false,
showChainPicker: false,
showVisitDatePicker: false,
loading: false,
locating: false,
}
@@ -161,13 +288,22 @@ export default {
if (!this.chainOptions.length) return [0]
const idx = this.chainOptions.findIndex(item => String(item.id) === String(this.visitForm.chainId))
return [idx < 0 ? 0 : idx]
},
visitDatePickerValue() {
return this.visitForm.visitDate ? dayjs(this.visitForm.visitDate).valueOf() : Date.now()
},
targetPlaceholder() {
if (this.visitForm.visitType === '门店经理') return '请输入门店经理姓名'
if (this.visitForm.visitType === '其他') return '请输入拜访人员姓名'
return '请输入拜访人员姓名 / 门店名称 / 经理姓名'
}
},
async onShow() {
await Promise.all([
this.fetchLocation(true),
this.fetchHistory(),
this.fetchChainOptions()
this.fetchChainOptions(),
this.fetchSubmitterName()
])
},
methods: {
@@ -179,10 +315,25 @@ export default {
chainId: '',
chain: '',
name: '',
visitType: '门店',
visitDate: dayjs().format('YYYY-MM-DD'),
submitterName: '',
content: '',
summary: ''
}
},
async fetchSubmitterName() {
if (this.visitForm.submitterName) return
try {
const {data} = await userInfoReq()
const info = data || {}
const name = info.realName || info.real_name || info.nickname || info.name || ''
if (name && !this.visitForm.submitterName) {
this.visitForm.submitterName = name
}
} catch (err) {
}
},
async fetchChainOptions() {
this.chainLoading = true
try {
@@ -212,6 +363,11 @@ export default {
this.visitForm.chain = selected ? selected.text : ''
this.showChainPicker = false
},
onVisitDateConfirm(event) {
const raw = event && event.value !== undefined ? event.value : Date.now()
this.visitForm.visitDate = dayjs(raw).format('YYYY-MM-DD')
this.showVisitDatePicker = false
},
formatCoordinate(label, value) {
if (value === '' || value === null || value === undefined) return `${label}:-`
return `${label}:${value}`
@@ -268,9 +424,23 @@ export default {
})
},
async submitVisit() {
if (!this.visitForm.lat || !this.visitForm.lng || !this.visitForm.location) {
if (!String(this.visitForm.submitterName || '').trim()) {
uni.showToast({
title: '请先获取定位',
title: '请填写您的姓名',
icon: 'none'
})
return
}
if (!this.visitForm.visitType) {
uni.showToast({
title: '请选择拜访对象类型',
icon: 'none'
})
return
}
if (!String(this.visitForm.name || '').trim()) {
uni.showToast({
title: '请填写拜访对象姓名/门店',
icon: 'none'
})
return
@@ -282,7 +452,21 @@ export default {
})
return
}
if (!this.visitForm.name || !this.visitForm.content || !this.photos.length || !this.visitForm.summary) {
if (!this.visitForm.lat || !this.visitForm.lng || !this.visitForm.location) {
uni.showToast({
title: '请先获取定位',
icon: 'none'
})
return
}
if (!this.visitForm.visitDate) {
uni.showToast({
title: '请选择拜访日期',
icon: 'none'
})
return
}
if (!this.visitForm.content || !this.photos.length || !this.visitForm.summary) {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
@@ -297,6 +481,9 @@ export default {
chainId: this.visitForm.chainId,
chain: this.visitForm.chain,
name: this.visitForm.name,
visitType: this.visitForm.visitType,
visitDate: this.visitForm.visitDate,
submitterName: this.visitForm.submitterName,
content: this.visitForm.content,
photos: JSON.stringify(this.photos),
summary: this.visitForm.summary
@@ -305,16 +492,17 @@ export default {
title: '提交成功',
icon: 'success'
})
const locationSnapshot = {
const snapshot = {
lat: this.visitForm.lat,
lng: this.visitForm.lng,
location: this.visitForm.location,
chainId: this.visitForm.chainId,
chain: this.visitForm.chain
chain: this.visitForm.chain,
submitterName: this.visitForm.submitterName
}
this.visitForm = {
...this.buildForm(),
...locationSnapshot
...snapshot
}
this.photos = []
await this.fetchHistory()
@@ -362,6 +550,8 @@ export default {
name: item.name || item.visitName || '',
content: item.content || item.remark || '',
summary: item.summary || item.result || '',
visitType: item.visitType || item.visit_type || '',
visitDate: item.visitDate || item.visit_date || '',
location: item.location || item.address || '',
lat: item.lat || item.latitude || '',
lng: item.lng || item.longitude || '',
@@ -449,6 +639,7 @@ export default {
.visit-page {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 40rpx;
}
.page-hero {
@@ -457,31 +648,35 @@ export default {
.intro-card {
margin: 18rpx 20rpx 0;
padding: 20rpx 18rpx;
padding: 22rpx 20rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.9);
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 10rpx 20rpx rgba(0, 0, 0, 0.08);
display: flex;
align-items: flex-start;
gap: 16rpx;
}
.card-title {
.tip-icon {
flex-shrink: 0;
margin-top: 2rpx;
}
.tip-body {
flex: 1;
display: flex;
align-items: center;
gap: 12rpx;
flex-direction: column;
gap: 8rpx;
}
.tip-title {
font-size: 28rpx;
font-weight: 700;
color: #1f2937;
}
.title-bar {
width: 6rpx;
height: 30rpx;
border-radius: 999rpx;
background: #2f78ff;
}
.intro-text {
margin-top: 12rpx;
font-size: 26rpx;
.tip-text {
font-size: 25rpx;
color: #6b7280;
line-height: 1.6;
}
@@ -490,124 +685,165 @@ export default {
margin: 18rpx 20rpx 0;
}
.form {
.form-card-head {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.form-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 20rpx;
padding: 12rpx 0;
border-bottom: 1rpx solid #edf1f7;
gap: 14rpx;
padding-bottom: 20rpx;
margin-bottom: 6rpx;
border-bottom: 1rpx solid #eef2f7;
}
.form-row input {
flex: 1;
text-align: right;
.head-icon {
width: 56rpx;
height: 56rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
background: rgba(47, 198, 167, 0.12);
}
.head-title {
font-size: 30rpx;
font-weight: 700;
color: var(--text-main);
}
.selector-picker {
flex: 1;
.field-row {
display: flex;
align-items: center;
gap: 20rpx;
padding: 14rpx 0;
}
.selector-picker .picker-value {
text-align: right;
}
.location-row {
.field-row.textarea-row {
align-items: flex-start;
}
.location-box {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8rpx;
}
.picker-value {
.field-label {
flex-shrink: 0;
width: 158rpx;
font-size: 28rpx;
color: var(--text-main);
font-weight: 600;
}
.picker-value.placeholder {
color: var(--text-sub);
/* 注意:不要用 .required 作类名 —— 全局 common.scss 里的 .required
(经 uni.scss 全局引入)@extend 了 .pos-a,会带上 position:absolute /
left:-9px / top:15px,导致标签脱离文档流堆到卡片左上角。 */
.field-label.is-required {
display: flex;
align-items: center;
}
.location-text {
.field-star {
color: #ff3b30;
margin-right: 6rpx;
font-size: 26rpx;
}
.field-box {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12rpx;
min-height: 80rpx;
padding: 0 20rpx;
border-radius: 16rpx;
background: #f5f7fa;
}
.textarea-box {
flex-direction: column;
align-items: stretch;
padding: 16rpx 20rpx;
}
.field-input {
flex: 1;
min-width: 0;
height: 80rpx;
font-size: 28rpx;
color: var(--text-main);
}
.field-textarea {
width: 100%;
text-align: right;
min-height: 180rpx;
font-size: 28rpx;
line-height: 1.6;
color: var(--text-main);
}
.field-text {
flex: 1;
min-width: 0;
font-size: 28rpx;
color: var(--text-main);
line-height: 1.5;
word-break: break-all;
}
.location-meta {
.field-text.placeholder,
.field-placeholder {
color: #9aa7b8;
}
.counter {
align-self: flex-end;
margin-top: 6rpx;
font-size: 24rpx;
color: #9aa7b8;
}
.field-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4rpx;
margin: -6rpx 0 6rpx 178rpx;
font-size: 22rpx;
color: var(--text-sub);
}
.refresh-btn {
flex-shrink: 0;
margin-top: 2rpx;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: #eef4ff;
color: #2f78ff;
font-size: 22rpx;
font-weight: 700;
}
.textarea-row {
align-items: flex-start;
padding-top: 16rpx;
}
.textarea-input {
.type-tabs {
flex: 1;
min-height: 120rpx;
text-align: right;
font-size: 26rpx;
color: var(--text-main);
}
.upload {
display: flex;
justify-content: space-between;
gap: 12rpx;
}
.type-tab {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
padding: 12rpx 0;
border-bottom: 1rpx solid #edf1f7;
}
.upload-title {
font-weight: 700;
}
.upload-meta {
justify-content: center;
gap: 6rpx;
height: 80rpx;
border-radius: 16rpx;
background: #f5f7fa;
color: var(--text-sub);
font-size: 24rpx;
font-size: 25rpx;
}
.type-tab.active {
background: rgba(47, 198, 167, 0.14);
color: var(--brand-primary);
font-weight: 700;
}
.photo-list {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 10rpx;
margin: 6rpx 0 6rpx 178rpx;
}
.photo-item {
position: relative;
width: 160rpx;
height: 160rpx;
width: 150rpx;
height: 150rpx;
border-radius: 12rpx;
overflow: hidden;
background: #f8fafc;
@@ -635,10 +871,15 @@ export default {
line-height: 1;
}
.submit-wrap {
margin: 24rpx 20rpx 0;
}
.submit {
margin-top: 12rpx;
padding: 14rpx;
text-align: center;
gap: 10rpx;
padding: 24rpx 32rpx;
font-size: 32rpx;
font-weight: 700;
}
.history {