517 lines
16 KiB
Vue
517 lines
16 KiB
Vue
<template>
|
||
<div class="space-y-4">
|
||
<a-page-header title="账号信息" :sub-title="pageSubTitle" />
|
||
|
||
<a-spin :spinning="loading" tip="加载中...">
|
||
<a-row :gutter="[16, 16]">
|
||
<a-col :xs="24" :lg="12">
|
||
<a-card :bordered="false" class="card" title="基本资料">
|
||
<div class="flex items-center gap-4">
|
||
<!-- 可点击更换头像 -->
|
||
<div class="avatar-wrapper" @click="triggerAvatarUpload" :title="'点击更换头像'">
|
||
<a-avatar :size="56" :src="avatarPreview || avatarUrl">
|
||
<template v-if="!avatarPreview && !avatarUrl" #icon>
|
||
<UserOutlined />
|
||
</template>
|
||
</a-avatar>
|
||
<div class="avatar-overlay">
|
||
<a-spin v-if="avatarUploading" :size="'small'" />
|
||
<CameraOutlined v-else />
|
||
</div>
|
||
<input
|
||
ref="avatarInputRef"
|
||
type="file"
|
||
accept="image/*"
|
||
style="display: none"
|
||
@change="onAvatarFileChange"
|
||
/>
|
||
</div>
|
||
<div class="min-w-0">
|
||
<div class="text-base font-semibold text-gray-900">
|
||
{{ user?.nickname || user?.username || '未命名用户' }}
|
||
</div>
|
||
<div class="text-gray-500">
|
||
{{ user?.phone || (user as any)?.mobile || user?.email || '' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<a-divider />
|
||
|
||
<a-descriptions :column="1" size="small" bordered>
|
||
<a-descriptions-item label="用户ID">{{ user?.userId ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="账号">{{ user?.username ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="昵称">{{ user?.nickname ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="手机号">{{ user?.phone || (user as any)?.mobile || '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="邮箱">{{ user?.email ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="租户ID">{{ user?.tenantId ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="租户名称">{{ user?.tenantName ?? '-' }}</a-descriptions-item>
|
||
</a-descriptions>
|
||
|
||
<div class="mt-6 flex justify-end gap-2">
|
||
<a-button @click="reload" :loading="loading">刷新</a-button>
|
||
<a-button type="primary" :disabled="!user" @click="openEditUser">编辑</a-button>
|
||
</div>
|
||
</a-card>
|
||
</a-col>
|
||
|
||
<a-col :xs="24" :lg="12" v-if="isCompanyUser">
|
||
<a-card :bordered="false" class="card" title="企业信息">
|
||
<a-descriptions :column="1" size="small" bordered>
|
||
<a-descriptions-item label="企业ID">{{ company?.companyId ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="企业简称">{{ company?.shortName ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="企业全称">{{ company?.companyName ?? company?.tenantName ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="绑定域名">{{ company?.domain ?? company?.freeDomain ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="联系电话">{{ company?.phone ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="邮箱">{{ company?.email ?? '-' }}</a-descriptions-item>
|
||
<a-descriptions-item label="地址">
|
||
{{ companyAddress || '-' }}
|
||
</a-descriptions-item>
|
||
<a-descriptions-item label="实名认证">
|
||
<a-tag v-if="company?.authentication" color="green">已认证</a-tag>
|
||
<a-tag v-else color="default">未认证</a-tag>
|
||
</a-descriptions-item>
|
||
</a-descriptions>
|
||
|
||
<div class="mt-6 flex justify-end gap-2">
|
||
<a-button @click="reload" :loading="loading">刷新</a-button>
|
||
<a-button type="primary" :disabled="!company" @click="openEditCompany">编辑</a-button>
|
||
</div>
|
||
</a-card>
|
||
</a-col>
|
||
</a-row>
|
||
|
||
</a-spin>
|
||
|
||
<a-modal
|
||
v-model:open="editUserOpen"
|
||
title="编辑基本资料"
|
||
:confirm-loading="savingUser"
|
||
ok-text="保存"
|
||
cancel-text="取消"
|
||
@ok="submitUser"
|
||
>
|
||
<a-form ref="userFormRef" layout="vertical" :model="userForm" :rules="userRules">
|
||
<a-form-item label="头像" name="avatarUrl">
|
||
<div class="modal-avatar-wrap">
|
||
<div class="modal-avatar-btn" @click="triggerAvatarUpload">
|
||
<a-avatar :size="64" :src="avatarPreview || avatarUrl">
|
||
<template v-if="!avatarPreview && !avatarUrl" #icon><UserOutlined /></template>
|
||
</a-avatar>
|
||
<div class="modal-avatar-overlay">
|
||
<a-spin v-if="avatarUploading" :size="'small'" />
|
||
<CameraOutlined v-else />
|
||
</div>
|
||
</div>
|
||
<span class="modal-avatar-hint">点击头像更换图片</span>
|
||
</div>
|
||
</a-form-item>
|
||
<a-form-item label="昵称" name="nickname">
|
||
<a-input v-model:value="userForm.nickname" placeholder="请输入昵称" />
|
||
</a-form-item>
|
||
<a-form-item label="邮箱" name="email">
|
||
<a-input v-model:value="userForm.email" placeholder="例如:name@example.com" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
|
||
<a-modal
|
||
v-model:open="editCompanyOpen"
|
||
title="编辑企业信息"
|
||
:confirm-loading="savingCompany"
|
||
ok-text="保存"
|
||
cancel-text="取消"
|
||
@ok="submitCompany"
|
||
>
|
||
<a-form ref="companyFormRef" layout="vertical" :model="companyForm" :rules="companyRules">
|
||
<a-form-item label="企业简称" name="shortName">
|
||
<a-input v-model:value="companyForm.shortName" placeholder="例如:某某科技" />
|
||
</a-form-item>
|
||
<a-form-item label="企业全称" name="companyName">
|
||
<a-input v-model:value="companyForm.companyName" placeholder="例如:某某科技有限公司" />
|
||
</a-form-item>
|
||
<a-form-item label="绑定域名" name="domain">
|
||
<a-input v-model:value="companyForm.domain" placeholder="例如:example.com" />
|
||
</a-form-item>
|
||
<a-form-item label="联系电话" name="phone">
|
||
<a-input v-model:value="companyForm.phone" placeholder="请输入联系电话" />
|
||
</a-form-item>
|
||
<a-form-item label="邮箱" name="email">
|
||
<a-input v-model:value="companyForm.email" placeholder="例如:service@example.com" />
|
||
</a-form-item>
|
||
<a-form-item label="地址" name="address">
|
||
<a-textarea v-model:value="companyForm.address" :auto-size="{ minRows: 2, maxRows: 4 }" />
|
||
</a-form-item>
|
||
<a-form-item label="发票抬头" name="invoiceHeader">
|
||
<a-input v-model:value="companyForm.invoiceHeader" placeholder="用于开票" />
|
||
</a-form-item>
|
||
</a-form>
|
||
</a-modal>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { message, type FormInstance } from 'ant-design-vue'
|
||
import { UserOutlined, CameraOutlined } from '@ant-design/icons-vue'
|
||
import { getTenantInfo, getUserInfo, updateLoginUser } from '@/api/layout'
|
||
import { updateUser } from '@/api/system/user'
|
||
import { uploadOssAvatar } from '@/api/system/file'
|
||
import { updateCompany } from '@/api/system/company'
|
||
import type { Company } from '@/api/system/company/model'
|
||
import type { User } from '@/api/system/user/model'
|
||
|
||
definePageMeta({ layout: 'console', ssr: false })
|
||
|
||
const loading = ref(false)
|
||
const savingUser = ref(false)
|
||
const savingCompany = ref(false)
|
||
|
||
const user = ref<User | null>(null)
|
||
const company = ref<Company | null>(null)
|
||
|
||
// ===== 头像上传 =====
|
||
const avatarInputRef = ref<HTMLInputElement | null>(null)
|
||
const avatarUploading = ref(false)
|
||
const avatarPreview = ref('') // 本地预览 / 上传后的新 URL
|
||
|
||
function triggerAvatarUpload() {
|
||
avatarInputRef.value?.click()
|
||
}
|
||
|
||
async function onAvatarFileChange(e: Event) {
|
||
const input = e.target as HTMLInputElement
|
||
const file = input.files?.[0]
|
||
if (!file) return
|
||
|
||
// 类型校验
|
||
if (!file.type.startsWith('image/')) {
|
||
message.error('请选择图片文件')
|
||
return
|
||
}
|
||
// 大小校验 5MB
|
||
if (file.size > 5 * 1024 * 1024) {
|
||
message.error('图片大小不能超过 5MB')
|
||
return
|
||
}
|
||
|
||
// 本地预览
|
||
const reader = new FileReader()
|
||
reader.onload = (ev) => {
|
||
avatarPreview.value = ev.target?.result as string
|
||
}
|
||
reader.readAsDataURL(file)
|
||
|
||
// 上传
|
||
avatarUploading.value = true
|
||
try {
|
||
const userId = user.value?.userId
|
||
const ext = file.name.split('.').pop() || 'jpg'
|
||
const fileName = `avatar_${userId}_${Date.now()}.${ext}`
|
||
const record = await uploadOssAvatar(file, fileName)
|
||
const url = (record?.url || record?.downloadUrl || '').trim()
|
||
if (!url) throw new Error('上传成功但未返回文件地址')
|
||
|
||
// 立即保存到用户信息
|
||
await updateUser({ userId, avatarUrl: url } as User)
|
||
avatarPreview.value = url
|
||
userForm.avatarUrl = url
|
||
if (user.value) user.value.avatarUrl = url
|
||
message.success('头像更新成功')
|
||
} catch (e) {
|
||
avatarPreview.value = ''
|
||
message.error(e instanceof Error ? e.message : '上传失败,请重试')
|
||
} finally {
|
||
avatarUploading.value = false
|
||
// 清空 input,允许重复选同一文件
|
||
if (avatarInputRef.value) avatarInputRef.value.value = ''
|
||
}
|
||
}
|
||
|
||
// 是否为企业或开发者用户(非普通用户)
|
||
const isCompanyUser = computed(() => {
|
||
const type = user.value?.type
|
||
return type === 1 || type === 2
|
||
})
|
||
|
||
// 动态副标题
|
||
const pageSubTitle = computed(() => {
|
||
if (isCompanyUser.value) {
|
||
return '基本资料与企业信息'
|
||
}
|
||
return '基本资料'
|
||
})
|
||
|
||
const avatarUrl = computed(() => {
|
||
const candidate =
|
||
user.value?.avatarUrl ||
|
||
user.value?.avatar ||
|
||
user.value?.merchantAvatar ||
|
||
user.value?.logo ||
|
||
''
|
||
if (typeof candidate !== 'string') return ''
|
||
const normalized = candidate.trim()
|
||
if (!normalized || normalized === 'null' || normalized === 'undefined') return ''
|
||
return normalized
|
||
})
|
||
|
||
const companyAddress = computed(() => {
|
||
const parts = [company.value?.province, company.value?.city, company.value?.region, company.value?.address]
|
||
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
||
.filter(Boolean)
|
||
return parts.join(' ')
|
||
})
|
||
|
||
async function load() {
|
||
loading.value = true
|
||
try {
|
||
const [uRes, cRes] = await Promise.allSettled([getUserInfo(), getTenantInfo()])
|
||
if (uRes.status === 'fulfilled') {
|
||
user.value = uRes.value
|
||
} else {
|
||
console.error(uRes.reason)
|
||
message.error(uRes.reason instanceof Error ? uRes.reason.message : '获取用户信息失败')
|
||
}
|
||
|
||
if (cRes.status === 'fulfilled') {
|
||
company.value = cRes.value
|
||
} else {
|
||
console.error(cRes.reason)
|
||
message.error(cRes.reason instanceof Error ? cRes.reason.message : '获取企业信息失败')
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function reload() {
|
||
await load()
|
||
}
|
||
|
||
const editUserOpen = ref(false)
|
||
const userFormRef = ref<FormInstance>()
|
||
const userForm = reactive<{ nickname?: string; email?: string; avatarUrl?: string }>({
|
||
nickname: '',
|
||
email: '',
|
||
avatarUrl: ''
|
||
})
|
||
const userRules = reactive({
|
||
nickname: [{ required: true, message: '请输入昵称', type: 'string' }],
|
||
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }]
|
||
})
|
||
|
||
function openEditUser() {
|
||
if (!user.value) return
|
||
userForm.nickname = user.value.nickname ?? ''
|
||
userForm.email = user.value.email ?? ''
|
||
userForm.avatarUrl = user.value.avatarUrl ?? avatarUrl.value ?? ''
|
||
editUserOpen.value = true
|
||
}
|
||
|
||
async function submitUser() {
|
||
if (!user.value) return
|
||
try {
|
||
await userFormRef.value?.validate()
|
||
} catch {
|
||
return
|
||
}
|
||
const nickname = (userForm.nickname ?? '').trim()
|
||
if (!nickname) {
|
||
message.error('请输入昵称')
|
||
return
|
||
}
|
||
const email = (userForm.email ?? '').trim()
|
||
const avatar = (userForm.avatarUrl ?? '').trim()
|
||
savingUser.value = true
|
||
try {
|
||
await updateLoginUser({
|
||
userId: user.value.userId,
|
||
nickname,
|
||
email: email || undefined,
|
||
avatarUrl: avatar || undefined
|
||
} as User)
|
||
message.success('保存成功')
|
||
editUserOpen.value = false
|
||
await load()
|
||
} catch (e: unknown) {
|
||
console.error(e)
|
||
message.error(e instanceof Error ? e.message : '保存失败')
|
||
} finally {
|
||
savingUser.value = false
|
||
}
|
||
}
|
||
|
||
const editCompanyOpen = ref(false)
|
||
const companyFormRef = ref<FormInstance>()
|
||
const companyForm = reactive<{
|
||
companyId?: number
|
||
shortName?: string
|
||
companyName?: string
|
||
domain?: string
|
||
phone?: string
|
||
email?: string
|
||
address?: string
|
||
invoiceHeader?: string
|
||
}>({
|
||
companyId: undefined,
|
||
shortName: '',
|
||
companyName: '',
|
||
domain: '',
|
||
phone: '',
|
||
email: '',
|
||
address: '',
|
||
invoiceHeader: ''
|
||
})
|
||
|
||
const companyRules = reactive({
|
||
companyName: [{ required: true, message: '请输入企业全称', type: 'string' }],
|
||
email: [{ type: 'email', message: '邮箱格式不正确', trigger: 'blur' }],
|
||
phone: [
|
||
{
|
||
validator: (_rule: unknown, value: unknown) => {
|
||
const normalized = typeof value === 'string' ? value.trim() : ''
|
||
if (!normalized) return Promise.resolve()
|
||
const mobileReg = /^1[3-9]\d{9}$/
|
||
if (mobileReg.test(normalized)) return Promise.resolve()
|
||
return Promise.reject(new Error('手机号格式不正确'))
|
||
},
|
||
trigger: 'blur'
|
||
}
|
||
]
|
||
})
|
||
|
||
function openEditCompany() {
|
||
if (!company.value) return
|
||
companyForm.companyId = company.value.companyId
|
||
companyForm.shortName = company.value.shortName ?? ''
|
||
companyForm.companyName = company.value.companyName ?? company.value.tenantName ?? ''
|
||
companyForm.domain = company.value.domain ?? ''
|
||
companyForm.phone = company.value.phone ?? ''
|
||
companyForm.email = company.value.email ?? ''
|
||
companyForm.address = company.value.address ?? ''
|
||
companyForm.invoiceHeader = company.value.invoiceHeader ?? ''
|
||
editCompanyOpen.value = true
|
||
}
|
||
|
||
async function submitCompany() {
|
||
if (!company.value) return
|
||
try {
|
||
await companyFormRef.value?.validate()
|
||
} catch {
|
||
return
|
||
}
|
||
if (!companyForm.companyId) {
|
||
message.error('企业ID缺失,无法保存')
|
||
return
|
||
}
|
||
const companyName = (companyForm.companyName ?? '').trim()
|
||
if (!companyName) {
|
||
message.error('请输入企业全称')
|
||
return
|
||
}
|
||
savingCompany.value = true
|
||
try {
|
||
const domain = (companyForm.domain ?? '').trim()
|
||
const phone = (companyForm.phone ?? '').trim()
|
||
const email = (companyForm.email ?? '').trim()
|
||
const address = (companyForm.address ?? '').trim()
|
||
const invoiceHeader = (companyForm.invoiceHeader ?? '').trim()
|
||
await updateCompany({
|
||
companyId: companyForm.companyId,
|
||
shortName: (companyForm.shortName ?? '').trim() || undefined,
|
||
companyName,
|
||
domain: domain || undefined,
|
||
phone: phone || undefined,
|
||
email: email || undefined,
|
||
address: address || undefined,
|
||
invoiceHeader: invoiceHeader || undefined
|
||
} as Company)
|
||
message.success('保存成功')
|
||
editCompanyOpen.value = false
|
||
await load()
|
||
} catch (e: unknown) {
|
||
console.error(e)
|
||
message.error(e instanceof Error ? e.message : '保存失败')
|
||
} finally {
|
||
savingCompany.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(load)
|
||
</script>
|
||
|
||
<style scoped>
|
||
.card {
|
||
border-radius: 12px;
|
||
}
|
||
|
||
/* ===== 头像上传 ===== */
|
||
.avatar-wrapper {
|
||
position: relative;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
width: 56px;
|
||
height: 56px;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.avatar-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-size: 18px;
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.avatar-wrapper:hover .avatar-overlay {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* 弹窗内头像 */
|
||
.modal-avatar-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.modal-avatar-btn {
|
||
position: relative;
|
||
cursor: pointer;
|
||
width: 64px;
|
||
height: 64px;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.modal-avatar-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-size: 20px;
|
||
opacity: 0;
|
||
transition: opacity 0.2s;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.modal-avatar-btn:hover .modal-avatar-overlay {
|
||
opacity: 1;
|
||
}
|
||
|
||
.modal-avatar-hint {
|
||
font-size: 13px;
|
||
color: rgba(0, 0, 0, 0.45);
|
||
}
|
||
</style>
|