liujie
2026-09-09 39f6fca28e3f78abe48f08e474ec011abb990b36
frontend/src/views/GuideUserDetailView.vue
@@ -1,23 +1,31 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
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,
  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)
@@ -35,13 +43,50 @@
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))
  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
  }
@@ -52,6 +97,52 @@
  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 ? '已加入自动禁用白名单' : '已移出自动禁用白名单')
@@ -60,7 +151,7 @@
const openScenicSelector = () => {
  selectedScenicSpotId.value = null
  if (!availableScenicOptions.value.length) {
    ElMessage.info('暂无可添加的启用景区')
    ElMessage.info(user.value.teamId ? '所属讲解队暂无可添加的启用景区' : '暂无可添加的启用景区')
    return
  }
  scenicPopoverVisible.value = true
@@ -137,6 +228,7 @@
})
onMounted(() => Promise.all([loadDetail(), loadScenicOptions()]))
onBeforeUnmount(revokeDocumentPreviews)
</script>
<template>
@@ -152,16 +244,26 @@
        <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" />
            <el-image v-if="user.avatar" :src="user.avatar" fit="cover" :preview-src-list="[user.avatar]" preview-teleported />
            <span v-else>暂无</span>
          </div>
          <el-input v-model="user.avatar" class="image-url-input" clearable placeholder="请输入头像图片地址" />
          <el-button type="primary" plain :icon="Upload" @click="ElMessage.info('图片上传服务接入后可直接上传,当前请填写图片地址')">上传</el-button>
          <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"><span class="field-label">导游手机号*</span><el-input v-model="user.mobile" /></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>
@@ -169,11 +271,9 @@
            <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><el-date-picker v-model="user.birthDate" value-format="YYYY-MM-DD" /></div>
          <div class="field-item"><span class="field-label">年龄(非必填)</span><div class="field-value">{{ displayValue(user.age) }}</div></div>
          <div class="field-item"><span class="field-label">工作年限<br>(非必填)</span><el-input-number v-model="user.workYears" :min="0" :max="100" controls-position="right" /></div>
          <div class="field-item"><span class="field-label">身份证号*</span><el-input v-model="user.idCardNo" maxlength="18" /></div>
          <div class="field-item"><span class="field-label">导游证号</span><el-input v-model="user.certificateNo" /></div>
        </div>
        <div class="document-grid">
@@ -181,12 +281,26 @@
            <span class="field-label">{{ item.label }}</span>
            <div class="document-control">
              <div class="image-box">
                <el-image v-if="user[item.key]" :src="user[item.key]" fit="cover" :preview-src-list="[user[item.key]]" />
                <span v-else>暂无</span>
                <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="user[item.key]" type="primary" :href="user[item.key]" target="_blank">查看大图</el-link>
                <el-button type="primary" plain :icon="Upload" @click="ElMessage.info('图片上传服务接入后可直接上传')">上传</el-button>
                <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>
@@ -205,7 +319,7 @@
        <div class="scenic-config">
          <div class="scenic-heading">
            <span class="field-label">景区配置</span>
            <small>导游有配置讲解队时,景区配置会以讲解队配置为优先</small>
            <small>导游属于讲解队时,仅可配置该讲解队下的景区</small>
          </div>
          <el-table :data="user.scenicConfigs" border class="scenic-table">
            <el-table-column prop="scenicName" label="景区名称" align="center" />
@@ -280,6 +394,7 @@
.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; }