refactor(sponsor/account): 重写为与 manager/Accounts.vue 完全一致

裁掉 sponsor 专属功能:
- 头像上传 (avatar-circle / uploadToOss)
- 修改手机号 dialog (短信验证 / phoneSmsUuid)
- 所属公司 (主账号/子账号/isOwner)
- Tab 切换 (改单页 form)
- EditPen icon / useAsyncLock / useUserStore

保留:
- breadcrumb '首页 / 账号管理'
- 姓名 + 手机号 (form.nickName / phonenumber)
- 原密码 + 新密码 + 确认密码
- 保存 + 取消
- '密码长度 6-20 位' 提示
- 调 /system/user/profile + /system/user/profile/updatePwd

UI 100% 对齐 manager/Accounts.vue:
- page-card max-width 1200px
- form class='account-form' max-width 600px
- filter-tip padding-left 120px 跟 el-input 对齐
This commit is contained in:
郭庆泰
2026-08-18 01:23:08 +08:00
parent dd39fdba5c
commit 7386168789
+51 -476
View File
@@ -1,508 +1,83 @@
<template>
<!--
1:1 doctor/Account.vue 容器 + 结构
容器: <div class="page-card"> + 面包屑 + page-title
Tab: 个人信息 + 修改密码
个人信息 tab 包含: 头像块 + 基本资料 (form-grid 2 )
修改密码 tab: 原密码 / 新密码 / 确认密码
修改手机号: 走独立 dialog + 短信验证
-->
<div class="page-card">
<!-- 面包屑 -->
<div class="breadcrumb">
<span @click="goHome" class="link">首页</span>
<span class="sep">/</span>
<span>账号信息</span>
</div>
<div class="page-card sponsor-account">
<div class="breadcrumb">首页 / 账号管理</div>
<!-- 页面标题 -->
<h1 class="page-title">账号信息</h1>
<el-form :model="form" label-width="120px" :rules="rules" ref="formRef" class="account-form">
<el-form-item label="姓名" prop="nickName"><el-input v-model="form.nickName" placeholder="请输入姓名" /></el-form-item>
<el-form-item label="手机号" prop="phonenumber"><el-input v-model="form.phonenumber" placeholder="请输入手机号" /></el-form-item>
<!-- Tab 导航 -->
<el-tabs v-model="tab" class="account-tabs">
<!-- 个人信息 -->
<el-tab-pane label="个人信息" name="profile">
<div class="card">
<!-- 头像 + 用户信息 -->
<div class="avatar-block">
<div class="avatar-circle" :class="{ 'has-file': avatarUrl, 'is-uploading': avatarUploading }" @click="onAvatarClick">
<img v-if="avatarUrl" :src="avatarUrl" class="avatar-img" />
<template v-else>{{ avatarChar }}</template>
<span class="avatar-tip">{{ avatarUploading ? '上传中...' : '点击更换' }}</span>
</div>
<div class="avatar-meta">
<div class="name">{{ store.user?.nickName || store.user?.userName || '支持方账号' }}</div>
<div class="role">{{ roleLabel }}</div>
<div class="phone-row">
<span class="phone-label">联系电话:</span>
<span class="phone-value">{{ form.phone || '未设置' }}</span>
<el-button class="btn-edit-phone" link type="primary" @click="openPhoneDialog">
<el-icon><EditPen /></el-icon> 修改
</el-button>
</div>
</div>
</div>
<!-- 基本资料 (2 ) -->
<div class="section-title">基本资料</div>
<el-form :model="form" class="form-grid" label-position="right" label-width="120px">
<el-form-item label="账号">
<el-input :model-value="store.user?.userName || '-'" disabled />
</el-form-item>
<el-form-item label="姓名" :required="true">
<el-input v-model="form.name" placeholder="请填写姓名" />
</el-form-item>
<el-form-item label="邮箱">
<el-input :model-value="store.user?.email || '-'" disabled />
</el-form-item>
<el-form-item label="所属公司">
<el-input
v-if="myCompany.isOwner === 1"
v-model="form.orgName"
placeholder="请填写所属公司"
/>
<el-input
v-else-if="myCompany.orgName"
:model-value="myCompany.orgName"
disabled
/>
<span v-else style="color:#909399;font-size:13px">尚未分配所属公司</span>
</el-form-item>
<el-form-item label="原密码" prop="oldPassword"><el-input v-model="form.oldPassword" type="password" show-password placeholder="请输入原密码" /></el-form-item>
<el-form-item label="新密码" prop="newPassword"><el-input v-model="form.newPassword" type="password" show-password placeholder="请输入新密码" /></el-form-item>
<el-form-item label="确认密码" prop="confirmPassword"><el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入新密码" /></el-form-item>
<div class="filter-tip">* 密码长度 6-20 ,支持数字字母特殊字符;留空表示不修改密码</div>
<el-form-item><el-button type="primary" :loading="saving" @click="onSave">保存</el-button><el-button @click="onCancel">取消</el-button></el-form-item>
</el-form>
<div class="form-error">{{ saveError }}</div>
<div class="form-actions">
<el-button type="primary" :loading="saving" @click="onSave">保存</el-button>
<el-button type="default" @click="onCancel">取消</el-button>
</div>
</div>
</el-tab-pane>
<!-- 修改密码 -->
<el-tab-pane label="修改密码" name="password">
<div class="card">
<el-form :model="pwd" label-position="right" label-width="120px" class="password-form">
<el-form-item label="原密码" :required="true">
<el-input v-model="pwd.old" type="password" show-password placeholder="请输入原密码" />
</el-form-item>
<el-form-item label="新密码" :required="true">
<el-input v-model="pwd.new" type="password" show-password placeholder="请输入新密码" />
</el-form-item>
<el-form-item label="确认密码" :required="true">
<el-input v-model="pwd.confirm" type="password" show-password placeholder="请再次输入新密码" />
</el-form-item>
<div class="form-error">{{ pwdError }}</div>
<div class="form-actions">
<el-button type="primary" @click="onChangePwd">保存</el-button>
<el-button type="default" @click="onCancelPwd">取消</el-button>
</div>
</el-form>
</div>
</el-tab-pane>
</el-tabs>
<!-- 隐藏的 avatar 上传 input -->
<input ref="avatarInput" type="file" hidden accept="image/*" @change="onAvatarFileChange" />
<!-- 修改手机号 dialog -->
<el-dialog v-model="phoneDialogVisible" title="修改联系电话" width="420px" :close-on-click-modal="false">
<el-form :model="phoneForm" label-width="100px">
<el-form-item label="新手机号" :required="true">
<el-input v-model="phoneForm.phone" placeholder="请输入新手机号" />
</el-form-item>
<el-form-item label="短信验证码" :required="true">
<div class="sms-row">
<el-input v-model="phoneForm.smsCode" placeholder="请输入验证码" />
<el-button class="btn-sms" :disabled="phoneSmsLocked || phoneSmsCountdown > 0" @click="sendSms">
{{ phoneSmsLocked ? '发送中...' : phoneSmsCountdown > 0 ? `${phoneSmsCountdown}s 后重试` : '发送验证码' }}
</el-button>
</div>
</el-form-item>
<div class="form-error">{{ phoneError }}</div>
<template #footer>
<el-button type="default" @click="phoneDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="phoneSaving" @click="onChangePhone">确认</el-button>
</template>
</el-form>
</el-dialog>
</div>
</template>
<script setup>
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
import { reactive, ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { EditPen } from '@element-plus/icons-vue'
import { useUserStore } from '@/store/user'
import request from '@/utils/request'
import { uploadToOss } from '@/utils/oss'
import { useAsyncLock } from '@/utils/useAsyncLock'
const { locked: phoneSmsLocked, run: sendPhoneSmsOnce } = useAsyncLock()
const store = useUserStore()
const tab = ref('profile')
// 角色 label
const roleLabel = computed(() => {
const r = store.user?.role
if (r === 'sponsor') return '支持方'
if (r === 'leader') return '项目主管'
if (r === 'expert') return '专家'
if (r === 'executor') return '执行方'
if (r === 'admin') return '系统管理员'
return r || '-'
})
// 短信验证码状态 (修改手机号 dialog 用)
const phoneDialogVisible = ref(false)
const phoneSmsCountdown = ref(0)
let phoneSmsTimer = null
const phoneSmsUuid = ref('')
const phoneForm = reactive({ phone: '', smsCode: '' })
const phoneError = ref('')
const phoneSaving = ref(false)
// 个人信息 form (sponsor 字段比 doctor 少, 没有科室/职称/银行等)
const form = reactive({
name: '',
phone: '',
orgName: '',
licenseCertUrl: '',
idCardFrontUrl: '',
idCardBackUrl: ''
})
const pwd = reactive({ old: '', new: '', confirm: '' })
const pwdError = ref('')
const saveError = ref('')
const form = reactive({ nickName: '', phonenumber: '', oldPassword: '', newPassword: '', confirmPassword: '' })
const profile = ref({})
const saving = ref(false)
// 当前登录 sponsor 的所属公司 (主账号可改, 子账号只读)
// 从 /business/org/myCompany 取: { orgId, orgName, isOwner: 1|0 }
const myCompany = reactive({ orgId: null, orgName: '', isOwner: 0 })
const formRef = ref(null)
const avatarUrl = ref(store.user?.avatar || '')
const avatarChar = computed(() => (form.name || store.user?.userName || '支').slice(0, 1))
const avatarUploading = ref(false)
const avatarInput = ref(null)
let snapshot = ref(null)
function onAvatarClick() {
if (avatarUploading.value) return
avatarInput.value?.click()
const rules = {
nickName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
phonenumber: [{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }],
oldPassword: [{ required: false }],
newPassword: [{ min: 6, max: 20, message: '密码长度 6-20 位', trigger: 'blur' }],
confirmPassword: [{
validator: (rule, val, cb) => {
if (form.newPassword && val !== form.newPassword) return cb(new Error('两次输入密码不一致'))
cb()
}, trigger: 'blur'
}],
}
async function onAvatarFileChange(e) {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
if (!file.type.startsWith('image/')) {
return ElMessage.warning('请选择图片文件')
}
if (file.size > 5 * 1024 * 1024) {
return ElMessage.warning('图片大小不能超过 5MB')
}
avatarUploading.value = true
async function loadProfile() {
try {
const url = await uploadToOss(file, 'ry8080/sponsor/avatar/')
await request({
url: '/system/user/profile/avatarUrl',
method: 'put',
data: { avatarUrl: url }
})
avatarUrl.value = url
if (store.user) {
store.user.avatar = url
localStorage.setItem('ry_user', JSON.stringify(store.user))
const { data } = await request({ url: '/system/user/profile', method: 'get' })
profile.value = data || {}
form.nickName = profile.value.nickName || ''
form.phonenumber = profile.value.phonenumber || ''
} catch { ElMessage.error('获取个人信息失败') }
}
ElMessage.success('头像更新成功')
} catch (err) {
ElMessage.error(err?.msg || '头像上传失败')
} finally {
avatarUploading.value = false
}
}
function goHome() {
if (window.parent && window.parent.document.getElementById('contentFrame')) {
window.parent.goHome?.()
}
}
async function loadUserProfile() {
try {
const res = await request({ url: '/system/user/profile', method: 'get' })
const u = res.data
if (!u) return
if (store.user) {
store.user.avatar = u.avatar || store.user.avatar
store.user.userName = u.userName || store.user.userName
store.user.nickName = u.nickName || store.user.nickName
store.user.phonenumber = u.phonenumber || store.user.phonenumber
store.user.email = u.email || store.user.email
store.user.createTime = u.createTime || store.user.createTime
localStorage.setItem('ry_user', JSON.stringify(store.user))
}
avatarUrl.value = u.avatar || avatarUrl.value
form.name = u.nickName || store.user?.userName || ''
form.phone = u.phonenumber || ''
form.licenseCertUrl = u.licenseCertUrl || ''
form.idCardFrontUrl = u.idCardFrontUrl || ''
form.idCardBackUrl = u.idCardBackUrl || ''
// 所属公司: 走 /business/org/myCompany, 主账号可改, 子账号只读
try {
const r = await request({ url: '/business/org/myCompany', method: 'get' })
const c = (r.data && (r.data.data || r.data)) || {}
myCompany.orgId = c.orgId || null
myCompany.orgName = c.orgName || ''
myCompany.isOwner = Number(c.isOwner) || 0
form.orgName = myCompany.orgName
} catch {
myCompany.orgId = null
myCompany.orgName = ''
myCompany.isOwner = 0
form.orgName = ''
}
} catch (e) {
console.warn('loadUserProfile:', e?.msg)
}
}
onMounted(async () => {
await loadUserProfile()
snapshot = ref(JSON.parse(JSON.stringify(form)))
})
async function onSave() {
saveError.value = ''
if (!(form.name || '').toString().trim()) {
saveError.value = '请填写姓名'
return ElMessage.warning(saveError.value)
}
const sysPayload = {
nickName: form.name,
licenseCertUrl: form.licenseCertUrl,
idCardFrontUrl: form.idCardFrontUrl,
idCardBackUrl: form.idCardBackUrl
if (form.newPassword && form.newPassword !== form.confirmPassword) {
ElMessage.error('两次输入密码不一致'); return
}
await formRef.value.validate()
saving.value = true
try {
await request({ url: '/system/user/profile', method: 'put', data: sysPayload })
// 主账号: 同步更新所属公司名称到 biz_org
if (myCompany.isOwner === 1 && myCompany.orgId && form.orgName !== myCompany.orgName) {
await request({
url: '/business/org',
method: 'put',
data: { orgId: myCompany.orgId, orgName: form.orgName }
})
myCompany.orgName = form.orgName
await request({ url: '/system/user/profile', method: 'put', data: { nickName: form.nickName, phonenumber: form.phonenumber, sex: profile.value.sex } })
if (form.newPassword) {
await request({ url: '/system/user/profile/updatePwd', method: 'put', data: { oldPassword: form.oldPassword, newPassword: form.newPassword } })
}
if (store.user) {
store.user.nickName = form.name
localStorage.setItem('ry_user', JSON.stringify(store.user))
}
snapshot.value = JSON.parse(JSON.stringify(form))
ElMessage.success('个人信息已保存')
ElMessage.success('保存成功')
form.oldPassword = ''; form.newPassword = ''; form.confirmPassword = ''
loadProfile()
} catch (e) {
saveError.value = e?.msg || '保存失败'
ElMessage.error(saveError.value)
} finally {
saving.value = false
ElMessage.error(e?.msg || '保存失败')
} finally { saving.value = false }
}
}
function onCancel() {
if (snapshot.value) {
Object.assign(form, JSON.parse(JSON.stringify(snapshot.value)))
}
ElMessage.info('已恢复修改前内容')
form.nickName = profile.value.nickName || ''
form.phonenumber = profile.value.phonenumber || ''
form.oldPassword = ''; form.newPassword = ''; form.confirmPassword = ''
}
function onChangePwd() {
pwdError.value = ''
if (!pwd.old) return (pwdError.value = '请先填写原密码')
if (pwd.new.length < 6) return (pwdError.value = '新密码长度不能少于 6 位')
if (pwd.new !== pwd.confirm) return (pwdError.value = '两次输入的新密码不一致')
request({
url: '/system/user/profile/updatePwd',
method: 'put',
data: { oldPassword: pwd.old, newPassword: pwd.new }
}).then(() => {
ElMessage.success('密码修改成功')
pwd.old = pwd.new = pwd.confirm = ''
}).catch(e => {
pwdError.value = e?.msg || '密码修改失败'
ElMessage.error(pwdError.value)
})
}
function onCancelPwd() {
pwd.old = pwd.new = pwd.confirm = ''
pwdError.value = ''
}
// 手机号 dialog
function openPhoneDialog() {
phoneForm.phone = ''
phoneForm.smsCode = ''
phoneError.value = ''
phoneDialogVisible.value = true
}
async function sendSms() {
const phone = (phoneForm.phone || '').trim()
if (!/^(1[0-9])\d{9}$/.test(phone)) {
return ElMessage.warning('请输入正确的手机号')
}
await sendPhoneSmsOnce(async () => {
try {
const res = await request({
url: '/system/user/profile/sendSmsCode',
method: 'post',
params: { phone }
})
phoneSmsUuid.value = res.uuid || ''
ElMessage.success('验证码已发送')
phoneSmsCountdown.value = 60
phoneSmsTimer = setInterval(() => {
phoneSmsCountdown.value--
if (phoneSmsCountdown.value <= 0) {
clearInterval(phoneSmsTimer)
phoneSmsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.error(e?.msg || '发送失败')
}
})
}
async function onChangePhone() {
phoneError.value = ''
if (!phoneForm.phone || !phoneForm.smsCode || !phoneSmsUuid.value) {
return (phoneError.value = '请填写完整信息')
}
phoneSaving.value = true
try {
await request({
url: '/system/user/profile/changePhone',
method: 'post',
data: {
phone: phoneForm.phone,
smsCode: phoneForm.smsCode,
uuid: phoneSmsUuid.value
}
})
form.phone = phoneForm.phone
if (store.user) store.user.phonenumber = phoneForm.phone
ElMessage.success('手机号修改成功')
phoneDialogVisible.value = false
} catch (e) {
phoneError.value = e?.msg || '修改失败'
} finally {
phoneSaving.value = false
}
}
onUnmounted(() => {
if (phoneSmsTimer) clearInterval(phoneSmsTimer)
})
onMounted(loadProfile)
</script>
<style scoped>
/* 1:1 抄 doctor/Account.vue */
.page-card { padding: 16px; background: #fff; border-radius: 4px; border: 1px solid #ebeef5; }
.breadcrumb { font-size: 13px; color: #909399; margin-bottom: 8px; }
.breadcrumb .link { color: var(--brand-primary); cursor: pointer; }
.breadcrumb .link:hover { color: var(--brand-primary-deep, var(--brand-primary)); text-decoration: underline; }
.breadcrumb .sep { margin: 0 6px; }
.page-title { font-size: 20px; font-weight: 600; color: #262626; margin: 0 0 16px; }
.account-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.account-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; }
.account-tabs :deep(.el-tabs__active-bar) { background-color: var(--brand-primary); }
.card {
background: #fff;
border-radius: 4px;
padding: 20px 24px;
border: 1px solid #ebeef5;
margin-top: 16px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #262626;
margin: 16px 0 12px;
padding-left: 8px;
border-left: 3px solid var(--brand-primary);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 24px;
}
.form-grid-single {
grid-template-columns: 1fr;
}
/* 头像块 */
.avatar-block { display: flex; align-items: center; gap: 20px; padding-bottom: 16px; border-bottom: 1px solid #ebeef5; }
.avatar-circle {
width: 80px; height: 80px; border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
font-size: 28px; font-weight: 600;
cursor: pointer; flex-shrink: 0;
overflow: hidden;
position: relative;
transition: transform 0.15s;
}
.avatar-circle:hover { transform: scale(1.04); }
.avatar-circle.is-uploading { cursor: wait; opacity: 0.75; }
.avatar-circle .avatar-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar-tip {
font-size: 11px; opacity: 0.85; margin-top: 4px;
position: absolute; bottom: 4px; left: 0; right: 0;
text-align: center;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.avatar-meta { display: flex; flex-direction: column; gap: 6px; flex: 1; }
.avatar-meta .name { font-size: 18px; font-weight: 600; color: #262626; }
.avatar-meta .role { font-size: 13px; color: #909399; }
.phone-row { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #606266; margin-top: 4px; }
.phone-label { color: #909399; }
.phone-value { color: #262626; font-weight: 500; }
.btn-edit-phone { padding: 0 4px; }
/* 错误提示 + 按钮区 */
.form-error { color: #f56c6c; font-size: 13px; margin: 4px 0 12px; min-height: 18px; }
.form-actions { margin-top: 20px; display: flex; gap: 12px; }
/* 密码 form 单列 */
.password-form { max-width: 480px; }
.password-form :deep(.el-form-item) { margin-bottom: 16px; }
/* 手机号 dialog 验证码 row */
.sms-row { display: flex; gap: 8px; align-items: center; width: 100%; }
.sms-row .el-input { flex: 1; }
.btn-sms { flex-shrink: 0; }
/* 主色统一 (跟项目 brand-primary 一致, 深海军蓝) */
:deep(.el-button--primary:not(.is-link)) { background: var(--brand-primary); border-color: var(--brand-primary); border-radius: 4px; color: #fff; }
:deep(.el-button--primary:not(.is-link):hover),
:deep(.el-button--primary:not(.is-link):focus) { background: var(--brand-primary-deep, var(--brand-primary)); border-color: var(--brand-primary-deep, var(--brand-primary)); color: #fff; }
/* link 文字按钮 + 链接色都用主色, 跟 button 主色保持一致 (不撞色) */
:deep(.el-button--primary.is-link),
:deep(.el-button.is-link),
:deep(.el-link.is-link),
:deep(.el-tabs__item.is-active) { color: var(--brand-primary); }
:deep(.el-button--primary.is-link:hover),
:deep(.el-button.is-link:hover) { color: var(--brand-primary-deep, var(--brand-primary)); }
:deep(.el-button) { border-radius: 4px; }
:deep(.el-input__wrapper), :deep(.el-textarea__inner) { border-radius: 4px; }
/* 整页面板 (与 manager/Accounts.vue 一致: max-width 1200px) */
.sponsor-account { padding: 16px; max-width: 1200px; }
.breadcrumb { font-size: 13px; color: #8c8c8c; margin-bottom: 12px; }
.account-form { max-width: 600px; }
.filter-tip { color: #909399; font-size: 12px; margin-bottom: 12px; padding-left: 120px; }
</style>