<script setup>
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { useRoute, useRouter } from 'vue-router'
|
import { ArrowLeft, Check, Plus, Upload } from '@element-plus/icons-vue'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
import {
|
addGuideScenicConfig,
|
deleteGuideScenicConfig,
|
getGuideUserDocumentBlob,
|
getGuideUserDetail,
|
updateGuideUserDetail,
|
updateGuideWhitelist,
|
updateGuidePremiumPermission,
|
uploadGuideUserDocument,
|
uploadGuideUserAvatar,
|
} from '@/api/users'
|
import { getGuideTeamDetail } from '@/api/guideTeams'
|
import { getScenicSpots } from '@/api/scenicSpots'
|
|
const route = useRoute()
|
const router = useRouter()
|
const loading = ref(false)
|
const saving = ref(false)
|
const avatarUploading = ref(false)
|
const documentUploadingKey = ref('')
|
const documentPreviewUrls = ref({})
|
const user = ref(null)
|
const scenicOptions = ref([])
|
const teamScenicNames = ref(null)
|
const scenicPopoverVisible = ref(false)
|
const selectedScenicSpotId = ref(null)
|
const scenicSaving = ref(false)
|
const deletingScenicConfigId = ref(null)
|
|
const documentImages = computed(() => [
|
{ key: 'idCardFrontUrl', label: '身份证(正)*' },
|
{ key: 'idCardBackUrl', label: '身份证(反)*' },
|
{ key: 'guideCertificateUrl', label: '导游证*' },
|
{ key: 'qualificationCertificateUrl', label: '导游资质证书(非必填)' },
|
])
|
|
const formatDateTime = (value) => value ? value.replace('T', ' ').slice(0, 19) : '-'
|
const displayValue = (value) => value === null || value === undefined || value === '' ? '-' : value
|
const guideStatusLabel = (status) => ({ 0: '禁用', 1: '启用', 2: '待审核', 3: '已注销' }[status] || '未知')
|
const availableScenicOptions = computed(() => {
|
const configuredNames = new Set((user.value?.scenicConfigs || []).map((item) => item.scenicName))
|
return scenicOptions.value.filter((item) => (
|
!configuredNames.has(item.scenicName)
|
&& (!teamScenicNames.value || teamScenicNames.value.has(item.scenicName))
|
))
|
})
|
|
const revokeDocumentPreviews = () => {
|
Object.values(documentPreviewUrls.value).forEach((url) => {
|
if (url?.startsWith('blob:')) URL.revokeObjectURL(url)
|
})
|
documentPreviewUrls.value = {}
|
}
|
|
const loadDocumentPreview = async (documentType, documentUrl) => {
|
const previousUrl = documentPreviewUrls.value[documentType]
|
if (previousUrl?.startsWith('blob:')) URL.revokeObjectURL(previousUrl)
|
if (!documentUrl) {
|
delete documentPreviewUrls.value[documentType]
|
return
|
}
|
try {
|
const blob = await getGuideUserDocumentBlob(documentUrl)
|
documentPreviewUrls.value[documentType] = blob ? URL.createObjectURL(blob) : documentUrl
|
} catch (error) {
|
delete documentPreviewUrls.value[documentType]
|
}
|
}
|
|
const loadDocumentPreviews = async () => {
|
revokeDocumentPreviews()
|
await Promise.all(documentImages.value.map((item) => loadDocumentPreview(item.key, user.value?.[item.key])))
|
}
|
|
const loadDetail = async () => {
|
loading.value = true
|
try {
|
user.value = await getGuideUserDetail(route.params.id)
|
if (user.value.teamId) {
|
const team = await getGuideTeamDetail(user.value.teamId)
|
teamScenicNames.value = new Set((team.scenicConfigs || []).map((item) => item.scenicName))
|
} else {
|
teamScenicNames.value = null
|
}
|
await loadDocumentPreviews()
|
} finally {
|
loading.value = false
|
}
|
}
|
|
const loadScenicOptions = async () => {
|
const result = await getScenicSpots({ page: 1, size: 100, status: 1 })
|
scenicOptions.value = result.records || []
|
}
|
|
const validateImage = (file, type) => {
|
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
|
ElMessage.warning(`${type}仅支持 JPG、PNG 或 WebP 格式`)
|
return false
|
}
|
if (file.size > 5 * 1024 * 1024) {
|
ElMessage.warning(`${type}不能超过5MB`)
|
return false
|
}
|
return true
|
}
|
|
const beforeAvatarUpload = (file) => validateImage(file, '头像图片')
|
|
const uploadAvatar = async ({ file, onSuccess, onError }) => {
|
avatarUploading.value = true
|
try {
|
const updatedUser = await uploadGuideUserAvatar(user.value.id, file)
|
user.value.avatar = updatedUser.avatar
|
onSuccess?.(updatedUser)
|
ElMessage.success('头像上传成功')
|
} catch (error) {
|
onError?.(error)
|
} finally {
|
avatarUploading.value = false
|
}
|
}
|
|
const beforeDocumentUpload = (file) => validateImage(file, '证件图片')
|
|
const uploadDocument = async (documentType, { file, onSuccess, onError }) => {
|
if (documentUploadingKey.value) return
|
documentUploadingKey.value = documentType
|
try {
|
const result = await uploadGuideUserDocument(user.value.id, documentType, file)
|
user.value[documentType] = result.url
|
await loadDocumentPreview(documentType, result.url)
|
onSuccess?.(result)
|
ElMessage.success('证件图片上传成功')
|
} catch (error) {
|
onError?.(error)
|
} finally {
|
documentUploadingKey.value = ''
|
}
|
}
|
|
const updateWhitelist = async (enabled) => {
|
await updateGuideWhitelist(user.value.id, enabled)
|
ElMessage.success(enabled ? '已加入自动禁用白名单' : '已移出自动禁用白名单')
|
}
|
|
const updatePremiumPermission = async (enabled) => {
|
await updateGuidePremiumPermission(user.value.id, enabled)
|
ElMessage.success(enabled ? '已开通精讲接单权限' : '已关闭精讲接单权限')
|
}
|
|
const openScenicSelector = () => {
|
selectedScenicSpotId.value = null
|
if (!availableScenicOptions.value.length) {
|
ElMessage.info(user.value.teamId ? '所属讲解队暂无可添加的启用景区' : '暂无可添加的启用景区')
|
return
|
}
|
scenicPopoverVisible.value = true
|
}
|
|
const saveSelectedScenic = async (scenicSpotId) => {
|
if (!scenicSpotId || scenicSaving.value) return
|
scenicSaving.value = true
|
try {
|
user.value.scenicConfigs = await addGuideScenicConfig(user.value.id, scenicSpotId)
|
scenicPopoverVisible.value = false
|
selectedScenicSpotId.value = null
|
ElMessage.success('景区配置已保存')
|
} catch (error) {
|
selectedScenicSpotId.value = null
|
throw error
|
} finally {
|
scenicSaving.value = false
|
}
|
}
|
|
const removeScenic = async (config) => {
|
try {
|
await ElMessageBox.confirm(`确认删除“${config.scenicName}”配置吗?`, '删除景区配置', {
|
type: 'warning',
|
confirmButtonText: '删除',
|
cancelButtonText: '取消',
|
})
|
deletingScenicConfigId.value = config.id
|
try {
|
user.value.scenicConfigs = await deleteGuideScenicConfig(user.value.id, config.id)
|
ElMessage.success('景区配置已删除')
|
} finally {
|
deletingScenicConfigId.value = null
|
}
|
} catch (error) {
|
if (error !== 'cancel' && error !== 'close') throw error
|
}
|
}
|
|
const saveDetail = async () => {
|
saving.value = true
|
try {
|
user.value = await updateGuideUserDetail(user.value.id, {
|
realName: user.value.realName,
|
mobile: user.value.mobile,
|
avatar: user.value.avatar,
|
certificateNo: user.value.certificateNo,
|
gender: user.value.gender,
|
birthDate: user.value.birthDate,
|
workYears: user.value.workYears,
|
idCardNo: user.value.idCardNo,
|
idCardFrontUrl: user.value.idCardFrontUrl,
|
idCardBackUrl: user.value.idCardBackUrl,
|
guideCertificateUrl: user.value.guideCertificateUrl,
|
qualificationCertificateUrl: user.value.qualificationCertificateUrl,
|
teamId: user.value.teamId,
|
wechatOpenId: user.value.wechatOpenId,
|
})
|
ElMessage.success('导游详情保存成功')
|
} finally {
|
saving.value = false
|
}
|
}
|
|
const openGuideOrders = () => router.push({
|
path: '/orders',
|
query: { guideId: user.value.id, guideName: user.value.realName },
|
})
|
|
const openGuideReviews = () => router.push({
|
path: '/operations/reviews',
|
query: { guideId: user.value.id, guideName: user.value.realName },
|
})
|
|
onMounted(() => Promise.all([loadDetail(), loadScenicOptions()]))
|
onBeforeUnmount(revokeDocumentPreviews)
|
</script>
|
|
<template>
|
<section v-loading="loading" class="guide-detail-page">
|
<div class="detail-toolbar">
|
<el-button :icon="ArrowLeft" @click="router.push('/guides')">返回导游列表</el-button>
|
<el-button type="primary" :icon="Check" :loading="saving" @click="saveDetail">保存</el-button>
|
</div>
|
|
<el-card class="detail-card" shadow="never">
|
<section v-if="user" class="detail-section">
|
<h3>基本信息</h3>
|
<div class="avatar-row">
|
<span class="field-label">导游头像<br>(非必填)</span>
|
<div class="image-box">
|
<el-image v-if="user.avatar" :src="user.avatar" fit="cover" :preview-src-list="[user.avatar]" preview-teleported />
|
<span v-else>暂无</span>
|
</div>
|
<el-upload
|
accept="image/jpeg,image/png,image/webp"
|
:show-file-list="false"
|
:before-upload="beforeAvatarUpload"
|
:http-request="uploadAvatar"
|
>
|
<el-button type="primary" plain :icon="Upload" :loading="avatarUploading">上传图片</el-button>
|
</el-upload>
|
</div>
|
|
<div class="field-grid">
|
<div class="field-item"><span class="field-label">导游编号</span><div class="field-value muted">{{ user.id }}</div></div>
|
<div class="field-item mobile-field-item">
|
<span class="field-label">导游手机号*</span>
|
<el-input v-model="user.mobile" />
|
<small class="field-hint">修改会影响导游登录和游客联系</small>
|
</div>
|
<div class="field-item"><span class="field-label">注册时间</span><div class="field-value muted">{{ formatDateTime(user.createdAt) }}</div></div>
|
<div class="field-item"><span class="field-label">账号状态</span><div class="field-value muted">{{ guideStatusLabel(user.status) }}</div></div>
|
<div class="field-item"><span class="field-label">姓名*</span><el-input v-model="user.realName" /></div>
|
<div class="field-item">
|
<span class="field-label">性别(非必填)</span>
|
<el-select v-model="user.gender" clearable><el-option label="男" :value="1" /><el-option label="女" :value="2" /></el-select>
|
</div>
|
<div class="field-item"><span class="field-label">年龄(非必填)</span><div class="field-value">{{ displayValue(user.ageRange || (user.age === null || user.age === undefined ? null : `${user.age}岁`)) }}</div></div>
|
<div class="field-item"><span class="field-label">工作年限<br>(非必填)</span><div class="field-value">{{ displayValue(user.workYearRange || (user.workYears === null || user.workYears === undefined ? null : `${user.workYears}年`)) }}</div></div>
|
<div class="field-item"><span class="field-label">身份证号*</span><el-input v-model="user.idCardNo" maxlength="18" /></div>
|
</div>
|
|
<div class="document-grid">
|
<div v-for="item in documentImages" :key="item.key" class="document-item">
|
<span class="field-label">{{ item.label }}</span>
|
<div class="document-control">
|
<div class="image-box">
|
<el-image
|
v-if="documentPreviewUrls[item.key]"
|
:src="documentPreviewUrls[item.key]"
|
fit="cover"
|
:preview-src-list="[documentPreviewUrls[item.key]]"
|
preview-teleported
|
/>
|
<span v-else>{{ user[item.key] ? '加载失败' : '暂无' }}</span>
|
</div>
|
<div class="document-actions">
|
<el-link v-if="documentPreviewUrls[item.key]" type="primary" :href="documentPreviewUrls[item.key]" target="_blank">查看大图</el-link>
|
<el-upload
|
accept="image/jpeg,image/png,image/webp"
|
:show-file-list="false"
|
:disabled="documentUploadingKey !== ''"
|
:before-upload="beforeDocumentUpload"
|
:http-request="(options) => uploadDocument(item.key, options)"
|
>
|
<el-button type="primary" plain :icon="Upload" :loading="documentUploadingKey === item.key">上传图片</el-button>
|
</el-upload>
|
</div>
|
</div>
|
</div>
|
</div>
|
</section>
|
|
<section v-if="user" class="detail-section business-section">
|
<h3>业务配置</h3>
|
<div class="business-row">
|
<span class="field-label">所属讲解队</span>
|
<el-select :model-value="user.teamId" disabled placeholder="个人">
|
<el-option v-if="user.teamId" :label="user.teamName" :value="user.teamId" />
|
</el-select>
|
</div>
|
|
<div class="scenic-config">
|
<div class="scenic-heading">
|
<span class="field-label">景区配置</span>
|
<small>导游属于讲解队时,仅可配置该讲解队下的景区</small>
|
</div>
|
<el-table :data="user.scenicConfigs" border class="scenic-table">
|
<el-table-column prop="scenicName" label="景区名称" align="center" />
|
<el-table-column prop="cityName" label="城市" align="center" />
|
<el-table-column label="操作" align="center">
|
<template #default="{ row }">
|
<el-button
|
link
|
type="danger"
|
:loading="deletingScenicConfigId === row.id"
|
@click="removeScenic(row)"
|
>删除</el-button>
|
</template>
|
</el-table-column>
|
<template #empty><span class="empty-text">暂无景区配置</span></template>
|
</el-table>
|
<el-popover v-model:visible="scenicPopoverVisible" placement="bottom-end" :width="320" trigger="manual">
|
<el-select
|
v-model="selectedScenicSpotId"
|
class="scenic-selector"
|
filterable
|
placeholder="请选择景区,选择后自动保存"
|
:loading="scenicSaving"
|
@change="saveSelectedScenic"
|
>
|
<el-option
|
v-for="item in availableScenicOptions"
|
:key="item.id"
|
:label="`${item.scenicName}(${item.cityName || '未配置城市'})`"
|
:value="item.id"
|
/>
|
</el-select>
|
<template #reference>
|
<el-button class="add-scenic" type="primary" :icon="Plus" @click="openScenicSelector">添加</el-button>
|
</template>
|
</el-popover>
|
</div>
|
|
<div class="whitelist-row">
|
<span class="field-label">自动禁用白名单</span>
|
<el-switch v-model="user.autoDisableWhitelisted" @change="updateWhitelist" />
|
<small>开启后不参与自动禁用规则</small>
|
</div>
|
|
<div class="whitelist-row">
|
<span class="field-label">精讲接单权限</span>
|
<el-switch v-model="user.premiumEnabled" @change="updatePremiumPermission" />
|
<small>开启后可直接接取精讲服务,无需满足平台接单数和评分条件</small>
|
</div>
|
</section>
|
|
<section v-if="user" class="detail-section collection-section">
|
<div class="section-heading"><h3>收款信息</h3><small>配置导游个人微信openID</small></div>
|
<div class="business-row">
|
<span class="field-label">openID</span>
|
<el-input v-model="user.wechatOpenId" class="open-id" clearable placeholder="请输入微信 openID" />
|
</div>
|
<div class="guide-link-actions">
|
<el-button type="primary" @click="openGuideOrders">查看订单</el-button>
|
<el-button type="primary" @click="openGuideReviews">查看评论</el-button>
|
</div>
|
</section>
|
</el-card>
|
</section>
|
</template>
|
|
<style scoped>
|
.guide-detail-page { min-height: 720px; }
|
.detail-toolbar { margin-bottom: 16px; }
|
.detail-card { border: 0; border-radius: 4px; }
|
.detail-card :deep(.el-card__body) { padding: 0; }
|
.detail-section { padding: 24px 38px 28px; }
|
.detail-section + .detail-section { border-top: 10px solid #f4f7fb; }
|
.detail-section h3 { margin: 0 0 26px; color: #29476b; font-size: 14px; }
|
.avatar-row { display: flex; align-items: center; gap: 18px; margin-bottom: 24px; }
|
.field-label { min-width: 68px; color: #53657d; font-size: 13px; line-height: 1.55; }
|
.image-box { display: grid; place-items: center; width: 58px; height: 58px; overflow: hidden; color: #8a96a6; background: #e4e6e8; font-size: 12px; }
|
.image-box :deep(.el-image) { width: 100%; height: 100%; }
|
.field-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 26px 42px; }
|
.field-item { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 10px; }
|
.field-hint { grid-column: 1 / -1; color: #f05b61; font-size: 12px; line-height: 1.4; }
|
.field-value { min-height: 34px; padding: 8px 10px; color: #667085; background: #f5f7fa; }
|
.field-value.muted { background: #dedede; }
|
.document-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 28px 72px; margin-top: 32px; }
|
.document-item { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 10px; }
|
.document-control { display: flex; align-items: center; gap: 18px; }
|
.document-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
|
.business-section { min-height: 300px; }
|
.business-row { display: flex; align-items: center; gap: 18px; }
|
.business-row :deep(.el-select) { width: 175px; }
|
.scenic-config { width: 520px; margin-top: 24px; }
|
.scenic-heading { display: flex; align-items: center; gap: 18px; margin-bottom: 12px; }
|
.scenic-heading small, .whitelist-row small, .section-heading small { color: #f05b61; }
|
.scenic-table { margin-left: 86px; }
|
.empty-text { color: #9aa5b1; }
|
.add-scenic { display: block; margin: 12px 0 0 auto; }
|
.scenic-selector { width: 100%; }
|
.whitelist-row { display: flex; align-items: center; gap: 18px; margin-top: 22px; }
|
.collection-section { min-height: 150px; }
|
.section-heading { display: flex; align-items: baseline; gap: 24px; }
|
.open-id { width: 175px; }
|
.guide-link-actions { display: flex; justify-content: center; gap: 32px; margin-top: 48px; }
|
.guide-link-actions :deep(.el-button) { width: 125px; }
|
</style>
|