| | |
| | | <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) |
| | |
| | | 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 |
| | | } |
| | |
| | | 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 openScenicSelector = () => { |
| | | selectedScenicSpotId.value = null |
| | | if (!availableScenicOptions.value.length) { |
| | | ElMessage.info('暂无可添加的启用景区') |
| | | ElMessage.info(user.value.teamId ? '所属讲解队暂无可添加的启用景区' : '暂无可添加的启用景区') |
| | | return |
| | | } |
| | | scenicPopoverVisible.value = true |
| | |
| | | }) |
| | | |
| | | onMounted(() => Promise.all([loadDetail(), loadScenicOptions()])) |
| | | onBeforeUnmount(revokeDocumentPreviews) |
| | | </script> |
| | | |
| | | <template> |
| | |
| | | <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> |
| | |
| | | <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"> |
| | |
| | | <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> |
| | |
| | | <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" /> |
| | |
| | | .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; } |