Files
guoju0808/ry-vue3/src/views/auth/RegisterSponsor.vue
T
郭庆泰 5a0a892574 feat: 邀请函邮件发送 + 首次设密/密码为空 + 姓名单一可信源
邀请发送 (菜单暂隐藏, 待确认参数见 发送邀请-待确认参数.md):
- 后端: BizInvite / BizInviteRecipient + Controller/Service/Mapper/XML
- 邮件: InviteMailSender (spring-boot-starter-mail SMTP) + application*.yml 邮件配置
- 上传进度: UploadProgressRegistry + UploadProgressController
- 前端: InviteList / InviteNew / InviteDetail / InviteView + api/business/invite.js
- 原型: proto/html/components/invite-detail / new-invitation / send-invitation

登录/账号:
- 首次设密: /getInfo 返回 isPasswordEmpty, SysProfileController 密码为空时跳过旧密码校验, ForcePasswordDialog 强制弹窗
- 姓名单一可信源 resolveDisplayName: doctor→biz_expert.name, sponsor/executor→biz_person.name, 其余回退 nick_name
- OA compliance 门禁改为按手机号查 ecology 视图 (不再限定 manager/leader)

其它:
- OSS zip 在线查看 (列清单+取单文件, 公开只读) + SecurityConfig permitAll
- doctor 项目详情 ProjectDetail.vue
- 数据库/测试/设计文档 (md) 入库
2026-09-10 20:40:49 +08:00

316 lines
11 KiB
Vue

<template>
<PortalShell>
<div class="page-wrap">
<div class="page-card">
<h2 class="page-title">项目支持单位注册</h2>
<el-steps :active="step" finish-status="success" simple class="steps">
<el-step title="基本信息" />
<el-step title="注册成功" />
</el-steps>
<div class="step-body">
<!-- ============ Step 0: 基本信息 (字段与 register-executor 完全一致, orgType 不同) ============ -->
<template v-if="step === 0">
<el-form ref="formRef" :model="form" :rules="rules" label-width="130px" label-position="right">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入登录用户名(4-20位字母数字)" maxlength="20" autocomplete="off" />
</el-form-item>
<el-form-item label="企业名称" prop="orgId">
<el-select
v-model="form.orgId"
filterable
remote
:remote-method="searchOrg"
:loading="orgLoading"
placeholder="输入企业名称搜索并选择"
style="width:100%"
>
<el-option
v-for="o in orgOptions"
:key="o.orgId"
:label="o.orgName"
:value="o.orgId"
/>
</el-select>
</el-form-item>
<el-form-item label="联系人姓名" prop="realName">
<el-input v-model="form.realName" placeholder="请输入联系人姓名" maxlength="30" />
</el-form-item>
<el-form-item label="手机号码" prop="phone">
<el-input v-model="form.phone" placeholder="请输入手机号码" maxlength="11" autocomplete="off" />
</el-form-item>
<el-form-item label="短信验证码" prop="smsCode">
<div class="sms-row">
<el-input v-model="form.smsCode" placeholder="请输入收到的验证码" maxlength="6" autocomplete="one-time-code" />
<el-button class="sms-btn" :disabled="smsLocked || smsCountdown > 0 || !form.phone" @click="sendSms">
{{ smsLocked ? '发送中...' : smsCountdown > 0 ? `${smsCountdown}s 后重试` : '获取验证码' }}
</el-button>
</div>
</el-form-item>
<el-form-item label="登录密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="请输入登录密码(6-20位)" autocomplete="new-password" />
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="请再次输入登录密码" autocomplete="new-password" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="agreed">我已阅读并同意
<a href="#/article/agreement" target="_blank">用户协议</a>
<a href="#/article/privacy" target="_blank">隐私政策</a>
</el-checkbox>
</el-form-item>
<el-form-item>
<el-button type="primary" :disabled="!agreed" :loading="submitting" @click="onSubmit" style="width:100%;">
同意协议并注册
</el-button>
</el-form-item>
</el-form>
</template>
<!-- ============ Step 1: 注册成功 ============ -->
<template v-else>
<el-result icon="success" title="注册成功">
<template #extra>
<el-button type="primary" @click="$router.push('/login')">前往登录</el-button>
</template>
</el-result>
</template>
</div>
<div class="login-link">
已有账号? <a href="javascript:void(0)" @click="$router.push('/login')">立即登录</a>
</div>
</div>
</div>
</PortalShell>
</template>
<script setup>
import { reactive, ref, onUnmounted, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import request from '@/utils/request'
import { registerSponsor, sponsorOrgOptions } from '@/api/public'
import PortalShell from '@/components/PortalShell.vue'
import { useAsyncLock } from '@/utils/useAsyncLock'
const { locked: smsLocked, run: sendSmsOnce } = useAsyncLock()
const step = ref(0)
const formRef = ref()
const submitting = ref(false)
const agreed = ref(false)
const form = reactive({
username: '',
orgId: null,
realName: '',
phone: '',
smsCode: '',
password: '',
confirmPassword: ''
})
const rules = {
username: [
{ required: true, message: '请输入登录用户名', trigger: 'blur' },
{ min: 4, max: 20, message: '用户名长度 4-20 位', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9_]+$/, message: '只能包含字母/数字/下划线', trigger: 'blur' }
],
orgId: [{ required: true, message: '请选择企业', trigger: 'change' }],
realName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }],
phone: [
{ required: true, message: '请输入手机号码', trigger: 'blur' },
{ pattern: /^1\d{10}$/, message: '手机号格式错误', trigger: 'blur' }
],
smsCode: [{ required: true, message: '请输入短信验证码', trigger: 'blur' }],
password: [
{ required: true, message: '请输入登录密码', trigger: 'blur' },
{ min: 6, max: 20, message: '密码长度 6-20 位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请再次输入登录密码', trigger: 'blur' },
{
validator: (rule, value, cb) => {
if (value !== form.password) cb(new Error('两次密码输入不一致'))
else cb()
},
trigger: 'blur'
}
]
}
// ===== 企业远程搜索 (biz_org sponsor 类型) =====
const orgOptions = ref([])
const orgLoading = ref(false)
function searchOrg(query) {
orgLoading.value = true
sponsorOrgOptions({ orgName: query || '' })
.then(res => { orgOptions.value = res.data || [] })
.catch(() => { orgOptions.value = [] })
.finally(() => { orgLoading.value = false })
}
onMounted(() => searchOrg(''))
// ===== 短信验证码 =====
const smsCountdown = ref(0)
let smsTimer = null
const smsUuid = ref('')
async function sendSms() {
if (!/^1\d{10}$/.test(form.phone)) {
return ElMessage.warning('请先输入正确的手机号')
}
await sendSmsOnce(async () => {
try {
const resp = await request({
url: '/business/auth/registerSendSms',
method: 'post',
data: { phone: form.phone }
})
smsUuid.value = resp?.data || ''
ElMessage.success('验证码已发送')
smsCountdown.value = 60
smsTimer = setInterval(() => {
smsCountdown.value--
if (smsCountdown.value <= 0) {
clearInterval(smsTimer)
smsTimer = null
}
}, 1000)
} catch (e) {
ElMessage.warning(e?.msg || '验证码发送失败')
}
})
}
// ===== 提交 =====
async function onSubmit() {
try {
await formRef.value.validate()
} catch {
return
}
submitting.value = true
try {
await registerSponsor({ ...form, uuid: smsUuid.value })
ElMessage.success('注册成功')
step.value = 1
} catch (e) {
// 错误提示已由 utils/request.js 拦截器统一弹 (ElMessage.error), 这里只 log
console.warn('registerSponsor failed', e?.message || e)
} finally {
submitting.value = false
}
}
onUnmounted(() => { if (smsTimer) clearInterval(smsTimer) })
</script>
<style scoped>
.page-card { max-width: 1200px; margin: 0 auto; }
.page-title { text-align: center; font-size: 24px; font-weight: 600; color: #303133; margin: 24px 0 0; border-left: none; padding-left: 0; }
.steps { max-width: 720px; margin: 24px auto 0; }
.step-body { max-width: 640px; margin: 32px auto 0; }
.sms-row { display: flex; gap: 12px; width: 100%; }
.sms-row .el-input { flex: 1; }
.sms-btn { flex-shrink: 0; }
.agreement-content { line-height: 1.8; color: #606266; }
.agreement-content p { margin-bottom: 12px; }
.login-link { text-align: center; margin-top: 16px; font-size: 14px; color: #606266; }
.login-link a { color: var(--brand-primary); text-decoration: none; margin-left: 4px; }
.login-link a:hover { text-decoration: underline; }
/* ========================================
移动端适配 (≤768px)
- 卡片 padding 收窄
- 工具栏横向滚动
- filter-form: label 与输入框横向
- 表格字号收紧
======================================== */
@media (max-width: 768px) {
/* 卡片 padding */
.page-card { padding: 12px !important; border-radius: 4px !important; }
.breadcrumb { font-size: 12px !important; margin-bottom: 8px !important; }
/* 工具栏: 横向滚动 */
.toolbar {
flex-wrap: nowrap !important;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: 6px;
margin-bottom: 8px !important;
scrollbar-width: thin;
}
.toolbar::-webkit-scrollbar { height: 4px; }
.toolbar::-webkit-scrollbar-thumb { background: var(--brand-slate-200); border-radius: 2px; }
.toolbar :deep(.action-btn),
.toolbar :deep(.el-button) {
flex-shrink: 0;
font-size: 12px !important;
padding: 0 10px !important;
height: 30px !important;
}
/* filter-form: 横向 (label 左 + input 右) */
.filter-form {
display: flex !important;
flex-direction: column !important;
align-items: stretch !important;
gap: 10px !important;
}
.filter-form :deep(.el-form-item) {
display: flex !important;
align-items: center !important;
margin-right: 0 !important;
margin-bottom: 0 !important;
}
.filter-form :deep(.el-form-item__label) {
float: none !important;
width: auto !important;
min-width: 80px !important;
text-align: right !important;
padding: 0 8px 0 0 !important;
font-size: 13px !important;
color: var(--el-text-color-regular) !important;
line-height: 32px !important;
height: 32px !important;
}
.filter-form :deep(.el-form-item__content) {
margin-left: 0 !important;
line-height: 32px !important;
flex: 1 !important;
min-width: 0 !important;
}
/* 强制所有控件全宽 */
.filter-form :deep(.el-select),
.filter-form :deep(.el-input),
.filter-form :deep(.el-date-editor),
.filter-form :deep(.el-button),
.filter-form :deep(.el-cascader) {
width: 100% !important;
min-width: 0 !important;
margin-left: 0 !important;
margin-right: 0 !important;
display: block !important;
}
.filter-form :deep(.el-button + .el-button) {
margin-top: 8px !important;
}
/* 表格字号收紧 */
:deep(.el-table) { font-size: 12px !important; }
}
</style>