Files
yxe-mp/pages/visit/index.vue
T
weicw-dshsa 8816dd2867 fix(visit): 修复客户拜访页定位时好时坏
根因:定位链路用区县「中文名」查 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 个重名区县全部可正常
定位(后端修复尚未部署即已生效)。
2026-10-16 21:23:48 +08:00

1010 lines
29 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="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>