8816dd2867
根因:定位链路用区县「中文名」查 sys_area,重名区县(白云区=广州/贵阳、
朝阳区=北京/长春、南山区=深圳/鹤岗…)命中多条,后端 getOne 默认
throwEx=true 抛 TooManyResultsException,页面表现为「有时能定位、有时不能」
——取决于用户当前所在区县名是否唯一。
改动:
- 新增 util/geo.js(纯函数):buildGeocodeUrl / normalizeAdcode /
parseGeocodeResult / formatLocationText
· 天地图 postStr 补 URL 编码:实测未编码时接口返回 HTTP 400
「参数格式错误」(admin 端 merchantEdit.vue 已是正确写法)
· 严格校验响应结构,限流(429)/参数错误(400) 一律返回 null
· 补直辖市 city 为空时用省名兜底(天地图对上海/北京/天津/重庆返回 city:"")
- util/index.js:抽出 locateOnce / fetchGeocode / resolveAreaInfo 共用
· 清除两处模块作用域的 this.areaId 误用(严格模式下抛 TypeError 会让
Promise 永久悬空,locating 再也回不到 false,即「反复点没反应」)
· 优先按天地图 county_code(去 156 前缀 = sys_area.id)精确查询,
从根上绕开重名;按中文名仅作降级,并注释其歧义风险
· 行政区划查不到不再阻断定位:返回 null,仍给出经纬度与地址
(area_id/city_id/province_id 全仓库无消费方)
- api/request.js:get/del 不再吞掉非 2xx 的 reject,避免调用方状态永久悬空
- pages/visit/index.vue:fetchLocation 加 15s 超时兜底 + 运行号丢弃迟到结果,
保证失败后仍可重试;静默失败也给出可见提示
- 新增 util/geo.test.mjs(10 个单测),package.json 补 test 脚本
验证:前端单测 10/10;真实天地图 + 生产后端端到端 5 个重名区县全部可正常
定位(后端修复尚未部署即已生效)。
1010 lines
29 KiB
Vue
1010 lines
29 KiB
Vue
<template>
|
||
<view class="visit-page">
|
||
<view class="page-hero">
|
||
<AppNavbar title="客户拜访" show-back/>
|
||
<view class="intro-card">
|
||
<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>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 我的信息 -->
|
||
<view class="card block">
|
||
<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="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="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="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="field-textarea"
|
||
:maxlength="contentMaxLength"
|
||
placeholder="请输入沟通内容..."
|
||
placeholder-class="field-placeholder"
|
||
/>
|
||
<text class="counter">{{ (visitForm.content || '').length }}/{{ contentMaxLength }}</text>
|
||
</view>
|
||
</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>
|
||
<view v-if="photos.length" class="photo-list">
|
||
<view class="photo-item" v-for="(photo, index) in photos" :key="photo">
|
||
<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="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="field-textarea"
|
||
maxlength="300"
|
||
placeholder="请输入活动小结"
|
||
placeholder-class="field-placeholder"
|
||
/>
|
||
<text class="counter">{{ (visitForm.summary || '').length }}/300</text>
|
||
</view>
|
||
</view>
|
||
</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="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>
|
||
<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.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>
|
||
<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="photo">
|
||
<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"
|
||
/>
|
||
<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: 'map'},
|
||
{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,
|
||
// 定位运行号:用于丢弃超时后迟到的旧定位结果(见 fetchLocation)
|
||
locateRun: 0,
|
||
}
|
||
},
|
||
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]
|
||
},
|
||
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.fetchSubmitterName()
|
||
])
|
||
},
|
||
methods: {
|
||
buildForm() {
|
||
return {
|
||
lat: '',
|
||
lng: '',
|
||
location: '',
|
||
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 {
|
||
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
|
||
},
|
||
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}`
|
||
},
|
||
async fetchLocation(silent = false, force = false) {
|
||
// 兜底:即使底层定位 Promise 因意外情况迟迟不结算,也不会把 locating
|
||
// 永久卡在 true(那正是「反复点定位没反应」的成因)。超时后释放标志,
|
||
// 用户可再次点击重试。
|
||
if (this.locating) return
|
||
if (!force && this.visitForm.location && this.visitForm.lat && this.visitForm.lng) return
|
||
this.locating = true
|
||
// 每次定位分配一个运行号:超时释放后用户可能立刻重试,此时上一次调用
|
||
// 仍在后台跑(Promise.race 无法取消它)。用运行号丢弃迟到结果,
|
||
// 避免旧结果覆盖新定位、或重复弹出失败提示。
|
||
const runId = ++this.locateRun
|
||
if (!silent) {
|
||
uni.showLoading({
|
||
title: '定位中'
|
||
})
|
||
}
|
||
let timeoutId = null
|
||
try {
|
||
const res = await Promise.race([
|
||
getLocationOnly(),
|
||
new Promise((resolve, reject) => {
|
||
timeoutId = setTimeout(() => reject(new Error('定位超时')), 15000)
|
||
})
|
||
])
|
||
if (runId !== this.locateRun) return
|
||
if (!res) throw new Error('定位结果为空')
|
||
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 (runId !== this.locateRun) return
|
||
// 静默失败(进页面自动定位)也要给出可见提示,否则用户只看到空白
|
||
// 定位栏,不知道发生了什么、也不知道可以点。
|
||
uni.showToast({
|
||
title: silent ? '定位失败,请点击定位信息重试' : '定位失败,请点击重试',
|
||
icon: 'none'
|
||
})
|
||
} finally {
|
||
if (timeoutId) clearTimeout(timeoutId)
|
||
if (!silent) uni.hideLoading()
|
||
// 仅在仍是当前运行号时释放,避免把新一轮定位的 locating 提前清掉
|
||
if (runId === this.locateRun) 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 (!String(this.visitForm.submitterName || '').trim()) {
|
||
uni.showToast({
|
||
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
|
||
}
|
||
if (!this.visitForm.chainId || !this.visitForm.chain) {
|
||
uni.showToast({
|
||
title: '请选择连锁',
|
||
icon: 'none'
|
||
})
|
||
return
|
||
}
|
||
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'
|
||
})
|
||
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,
|
||
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
|
||
})
|
||
uni.showToast({
|
||
title: '提交成功',
|
||
icon: 'success'
|
||
})
|
||
const snapshot = {
|
||
lat: this.visitForm.lat,
|
||
lng: this.visitForm.lng,
|
||
location: this.visitForm.location,
|
||
chainId: this.visitForm.chainId,
|
||
chain: this.visitForm.chain,
|
||
submitterName: this.visitForm.submitterName
|
||
}
|
||
this.visitForm = {
|
||
...this.buildForm(),
|
||
...snapshot
|
||
}
|
||
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 || '',
|
||
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 || '',
|
||
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;
|
||
padding-bottom: 40rpx;
|
||
}
|
||
|
||
.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: 22rpx 20rpx;
|
||
border-radius: 20rpx;
|
||
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;
|
||
}
|
||
|
||
.tip-icon {
|
||
flex-shrink: 0;
|
||
margin-top: 2rpx;
|
||
}
|
||
|
||
.tip-body {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8rpx;
|
||
}
|
||
|
||
.tip-title {
|
||
font-size: 28rpx;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.tip-text {
|
||
font-size: 25rpx;
|
||
color: #6b7280;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.block {
|
||
margin: 18rpx 20rpx 0;
|
||
}
|
||
|
||
.form-card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14rpx;
|
||
padding-bottom: 20rpx;
|
||
margin-bottom: 6rpx;
|
||
border-bottom: 1rpx solid #eef2f7;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.field-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20rpx;
|
||
padding: 14rpx 0;
|
||
}
|
||
|
||
.field-row.textarea-row {
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.field-label {
|
||
flex-shrink: 0;
|
||
width: 158rpx;
|
||
font-size: 28rpx;
|
||
color: var(--text-main);
|
||
}
|
||
|
||
/* 注意:不要用 .required 作类名 —— 全局 common.scss 里的 .required
|
||
(经 uni.scss 全局引入)@extend 了 .pos-a,会带上 position:absolute /
|
||
left:-9px / top:15px,导致标签脱离文档流堆到卡片左上角。 */
|
||
.field-label.is-required {
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.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%;
|
||
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;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.type-tabs {
|
||
flex: 1;
|
||
display: flex;
|
||
gap: 12rpx;
|
||
}
|
||
|
||
.type-tab {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6rpx;
|
||
height: 80rpx;
|
||
border-radius: 16rpx;
|
||
background: #f5f7fa;
|
||
color: var(--text-sub);
|
||
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: 6rpx 0 6rpx 178rpx;
|
||
}
|
||
|
||
.photo-item {
|
||
position: relative;
|
||
width: 150rpx;
|
||
height: 150rpx;
|
||
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-wrap {
|
||
margin: 24rpx 20rpx 0;
|
||
}
|
||
|
||
.submit {
|
||
gap: 10rpx;
|
||
padding: 24rpx 32rpx;
|
||
font-size: 32rpx;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.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>
|