Files
yxe-mp/pages/visit/index.vue
T
2026-09-29 15:17:13 +08:00

749 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="visit-page">
<view class="page-hero">
<AppNavbar title="客户拜访" show-back/>
<view class="intro-card">
<view class="card-title">
<view class="title-bar"></view>
<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>
<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>
</view>
<view class="form-row">
<text>拜访人员</text>
<input v-model="visitForm.name" maxlength="30" placeholder="请输入拜访人员"/>
</view>
<view class="form-row textarea-row">
<text>沟通内容</text>
<textarea
v-model="visitForm.content"
class="textarea-input"
maxlength="500"
auto-height
placeholder="请输入沟通内容"
/>
</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>
<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>
<textarea
v-model="visitForm.summary"
class="textarea-input"
maxlength="300"
auto-height
placeholder="请输入活动小结"
/>
</view>
<view class="primary-btn submit" @tap="submitVisit">提交拜访</view>
</view>
</view>
<view class="card block">
<view class="section-title">历史记录</view>
<view v-if="loading" class="empty">加载中...</view>
<view v-else-if="visitHistory.length === 0" class="empty">暂无记录</view>
<view v-else>
<view class="history" v-for="item in visitHistory" :key="item.id">
<view class="history-head" @tap="toggleHistoryDetail(item.id)">
<view class="history-main">
<view class="history-title">{{ item.name || '未命名拜访' }}</view>
<view class="history-meta">{{ item.createdAt || '暂无时间' }}</view>
</view>
<view class="history-side">
<view class="history-status" :class="item.statusClass">{{ item.status }}</view>
<u-icon name="arrow-right" size="24" color="#9ca3af" :class="['history-arrow', item.expanded ? 'open' : '']"/>
</view>
</view>
<view v-if="item.expanded" class="history-detail">
<view class="history-line">定位:{{ item.location || '-' }}</view>
<view class="history-line">纬度:{{ item.lat || '-' }}</view>
<view class="history-line">经度:{{ item.lng || '-' }}</view>
<view class="history-line">连锁:{{ item.chain || '-' }}</view>
<view class="history-line">沟通内容:{{ item.content || '-' }}</view>
<view class="history-line">活动小结:{{ item.summary || '-' }}</view>
<view class="history-line">照片:</view>
<view v-if="item.photos.length" class="history-photo-list">
<view class="history-photo-item" v-for="(photo, photoIndex) in item.photos" :key="`${item.id}-${photoIndex}`">
<image
class="history-photo-image"
:src="photo"
mode="aspectFill"
@tap="previewHistoryPhoto(item.photos, photoIndex)"
/>
</view>
</view>
<view v-else class="history-line">-</view>
</view>
</view>
</view>
</view>
<u-picker
:show="showChainPicker"
:columns="[chainOptions]"
:loading="chainLoading"
:defaultIndex="chainPickerIndex"
keyName="text"
@confirm="onChainConfirm"
@cancel="closeChainPicker"
@close="closeChainPicker"
/>
</view>
</template>
<script>
import AppNavbar from "@/components/AppNavbar.vue";
import {chooseImg, getLocationOnly} from "@/util";
import {visitAddReq, visitListReq} from "@/api/visit";
import {merchantGroupByChainReq} from "@/api/shop";
export default {
components: {AppNavbar},
data() {
return {
visitForm: this.buildForm(),
photos: [],
visitHistory: [],
chainOptions: [],
chainLoading: false,
showChainPicker: false,
loading: false,
locating: false,
}
},
computed: {
chainPickerIndex() {
if (!this.chainOptions.length) return [0]
const idx = this.chainOptions.findIndex(item => String(item.id) === String(this.visitForm.chainId))
return [idx < 0 ? 0 : idx]
}
},
async onShow() {
await Promise.all([
this.fetchLocation(true),
this.fetchHistory(),
this.fetchChainOptions()
])
},
methods: {
buildForm() {
return {
lat: '',
lng: '',
location: '',
chainId: '',
chain: '',
name: '',
content: '',
summary: ''
}
},
async fetchChainOptions() {
this.chainLoading = true
try {
const {data} = await merchantGroupByChainReq()
const list = this.unwrapList(data)
this.chainOptions = list.map((item, index) => this.normalizeChainOption(item, index))
.filter(item => item.id !== undefined && item.id !== null && String(item.id).trim() !== '')
if (this.visitForm.chainId && !this.chainOptions.find(item => String(item.id) === String(this.visitForm.chainId))) {
this.visitForm.chainId = ''
this.visitForm.chain = ''
}
} catch (err) {
this.chainOptions = []
} finally {
this.chainLoading = false
}
},
openChainPicker() {
this.showChainPicker = true
},
closeChainPicker() {
this.showChainPicker = false
},
onChainConfirm(event) {
const selected = event && event.value && event.value.length ? event.value[0] : null
this.visitForm.chainId = selected ? selected.id : ''
this.visitForm.chain = selected ? selected.text : ''
this.showChainPicker = false
},
formatCoordinate(label, value) {
if (value === '' || value === null || value === undefined) return `${label}:-`
return `${label}:${value}`
},
async fetchLocation(silent = false, force = false) {
if (this.locating) return
if (!force && this.visitForm.location && this.visitForm.lat && this.visitForm.lng) return
this.locating = true
if (!silent) {
uni.showLoading({
title: '定位中'
})
}
try {
const res = await getLocationOnly()
this.visitForm.lat = res.latitude
this.visitForm.lng = res.longitude
this.visitForm.location = res.full || [res.province_str, res.city_str, res.area_str].filter(Boolean).join(' ')
} catch (err) {
if (!silent) {
uni.showToast({
title: '定位失败',
icon: 'none'
})
}
} finally {
if (!silent) uni.hideLoading()
this.locating = false
}
},
async uploadPhotos() {
const remain = 9 - this.photos.length
if (remain <= 0) {
uni.showToast({
title: '最多上传9张',
icon: 'none'
})
return
}
const res = await chooseImg(remain, ['camera'])
this.photos = [
...this.photos,
...res.map(item => item.path).filter(Boolean)
]
},
removePhoto(index) {
this.photos.splice(index, 1)
},
previewPhoto(index) {
if (!this.photos.length) return
uni.previewImage({
urls: this.photos,
current: this.photos[index] || this.photos[0]
})
},
async submitVisit() {
if (!this.visitForm.lat || !this.visitForm.lng || !this.visitForm.location) {
uni.showToast({
title: '请先获取定位',
icon: 'none'
})
return
}
if (!this.visitForm.chainId || !this.visitForm.chain) {
uni.showToast({
title: '请选择连锁',
icon: 'none'
})
return
}
if (!this.visitForm.name || !this.visitForm.content || !this.photos.length || !this.visitForm.summary) {
uni.showToast({
title: '请填写完整信息',
icon: 'none'
})
return
}
try {
await visitAddReq({
lat: this.visitForm.lat,
lng: this.visitForm.lng,
location: this.visitForm.location,
chainId: this.visitForm.chainId,
chain: this.visitForm.chain,
name: this.visitForm.name,
content: this.visitForm.content,
photos: JSON.stringify(this.photos),
summary: this.visitForm.summary
})
uni.showToast({
title: '提交成功',
icon: 'success'
})
const locationSnapshot = {
lat: this.visitForm.lat,
lng: this.visitForm.lng,
location: this.visitForm.location,
chainId: this.visitForm.chainId,
chain: this.visitForm.chain
}
this.visitForm = {
...this.buildForm(),
...locationSnapshot
}
this.photos = []
await this.fetchHistory()
} catch (err) {
uni.showToast({
title: '提交失败',
icon: 'none'
})
}
},
async fetchHistory() {
this.loading = true
try {
const {data} = await visitListReq()
const list = Array.isArray(data) ? data : (data && data.list ? data.list : [])
this.visitHistory = list.map(this.normalizeHistory)
} catch (err) {
uni.showToast({
title: '记录加载失败',
icon: 'none'
})
} finally {
this.loading = false
}
},
normalizeHistory(item) {
const statusValue = Number(item.status)
let status = '待处理'
let statusClass = 'pending'
if (statusValue === 0) {
status = '审核中'
statusClass = 'pending'
} else if (statusValue === 1) {
status = '已通过'
statusClass = 'success'
} else if (statusValue === 2 || statusValue === 3) {
status = '已驳回'
statusClass = 'reject'
} else if (item.statusText || item.status_text) {
status = item.statusText || item.status_text
statusClass = ''
}
return {
id: item.id,
name: item.name || item.visitName || '',
content: item.content || item.remark || '',
summary: item.summary || item.result || '',
location: item.location || item.address || '',
lat: item.lat || item.latitude || '',
lng: item.lng || item.longitude || '',
chainId: item.chainId || item.chain_id || item.chainCode || item.chain_code || '',
chain: item.chain || item.chain_name || item.chain || item.chainAbb || '',
photos: this.parsePhotos(item.photos || item.images || item.image),
createdAt: item.createTime || item.createdAt || item.create_date || '',
status,
statusClass,
expanded: false
}
},
parsePhotos(value) {
if (value === null || value === undefined || value === '') return []
if (Array.isArray(value)) {
return value.map(item => {
if (!item) return ''
if (typeof item === 'string') return item
return item.url || item.path || item.image || item.img || ''
}).filter(Boolean)
}
if (typeof value === 'object') {
const url = value.url || value.path || value.image || value.img
return url ? [url] : []
}
const text = String(value).trim()
if (!text) return []
if (text.startsWith('[') || text.startsWith('{')) {
try {
return this.parsePhotos(JSON.parse(text))
} catch (err) {
}
}
if (text.includes(',')) {
return text.split(',').map(item => item.trim()).filter(Boolean)
}
return [text]
},
toggleHistoryDetail(id) {
this.visitHistory = this.visitHistory.map(item => {
if (item.id !== id) return item
return {
...item,
expanded: !item.expanded
}
})
},
previewHistoryPhoto(list = [], index = 0) {
if (!Array.isArray(list) || !list.length) return
uni.previewImage({
urls: list,
current: list[index] || list[0]
})
},
normalizeChainOption(item, index) {
const text = item.chain || item.chain || item.chainAbb || item.name || item.title || item.text || `连锁${index + 1}`
const rawId = item.chainId ?? item.chain_id ?? item.id ?? item.chainCode ?? item.chain_code ?? ''
const id = rawId === undefined || rawId === null || String(rawId).trim() === ''
? `chain_${index}`
: rawId
return {
id,
text
}
},
unwrapList(data) {
if (Array.isArray(data)) return data
if (!data || typeof data !== 'object') return []
const keys = ['list', 'records', 'rows', 'items', 'data', 'result', 'chainList']
for (let i = 0; i < keys.length; i++) {
const value = data[keys[i]]
if (Array.isArray(value)) return value
if (value && typeof value === 'object') {
const nested = this.unwrapList(value)
if (nested.length) return nested
}
}
return [data]
}
}
}
</script>
<style lang="scss" scoped>
.visit-page {
min-height: 100vh;
background: #f5f5f5;
}
.page-hero {
background: url('https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/462bfa04cf0eff8698884c38b9d17519.png') no-repeat top center / 100% auto;
}
.intro-card {
margin: 18rpx 20rpx 0;
padding: 20rpx 18rpx;
border-radius: 20rpx;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 10rpx 20rpx rgba(0, 0, 0, 0.08);
}
.card-title {
display: flex;
align-items: center;
gap: 12rpx;
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;
color: #6b7280;
line-height: 1.6;
}
.block {
margin: 18rpx 20rpx 0;
}
.form {
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;
}
.form-row input {
flex: 1;
text-align: right;
color: var(--text-main);
}
.selector-picker {
flex: 1;
}
.selector-picker .picker-value {
text-align: right;
}
.location-row {
align-items: flex-start;
}
.location-box {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8rpx;
}
.picker-value {
color: var(--text-main);
font-weight: 600;
}
.picker-value.placeholder {
color: var(--text-sub);
}
.location-text {
width: 100%;
text-align: right;
line-height: 1.5;
word-break: break-all;
}
.location-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4rpx;
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 {
flex: 1;
min-height: 120rpx;
text-align: right;
font-size: 26rpx;
color: var(--text-main);
}
.upload {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12rpx 0;
border-bottom: 1rpx solid #edf1f7;
}
.upload-title {
font-weight: 700;
}
.upload-meta {
color: var(--text-sub);
font-size: 24rpx;
}
.photo-list {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 10rpx;
}
.photo-item {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
overflow: hidden;
background: #f8fafc;
}
.photo-image {
width: 100%;
height: 100%;
display: block;
}
.photo-remove {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 34rpx;
height: 34rpx;
background: rgba(0, 0, 0, 0.6);
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
line-height: 1;
}
.submit {
margin-top: 12rpx;
padding: 14rpx;
text-align: center;
}
.history {
padding: 12rpx 0;
border-bottom: 1rpx solid #edf1f7;
}
.history:last-child {
border-bottom: none;
}
.history-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
}
.history-main {
flex: 1;
}
.history-title {
font-weight: 700;
color: #1f2937;
}
.history-meta {
color: var(--text-sub);
font-size: 24rpx;
margin-top: 6rpx;
}
.history-side {
display: flex;
align-items: center;
gap: 8rpx;
}
.history-status {
font-weight: 700;
color: #ff8354;
}
.history-status.success {
color: #22c55e;
}
.history-status.reject {
color: #ff3b30;
}
.history-arrow {
transform: rotate(0deg);
transition: transform 0.2s ease;
}
.history-arrow.open {
transform: rotate(90deg);
}
.history-detail {
margin-top: 10rpx;
padding: 12rpx;
border-radius: 12rpx;
background: #f8fafc;
}
.history-line {
margin-top: 6rpx;
font-size: 24rpx;
color: #4b5563;
line-height: 1.6;
word-break: break-all;
}
.history-line:first-child {
margin-top: 0;
}
.history-photo-list {
margin-top: 10rpx;
display: flex;
flex-wrap: wrap;
gap: 10rpx;
}
.history-photo-item {
width: 140rpx;
height: 140rpx;
border-radius: 10rpx;
overflow: hidden;
background: #ffffff;
}
.history-photo-image {
width: 100%;
height: 100%;
display: block;
}
.empty {
color: var(--text-sub);
text-align: center;
padding: 16rpx 0;
}
</style>