chore(ui):优化用户注册功能
This commit is contained in:
1 parent
1dc120cfee
commit
534cdc18d9
10 files changed
+234
-37
No files matched your search
@@ -48,6 +48,7 @@ type ITenantV1 interface {
|
||||
TenantSendCode(ctx context.Context, req *v1.TenantSendCodeReq) (res *v1.TenantSendCodeRes, err error)
|
||||
TenantResetPassword(ctx context.Context, req *v1.TenantResetPasswordReq) (res *v1.TenantResetPasswordRes, err error)
|
||||
TenantChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (res *v1.TenantChangeEmailRes, err error)
|
||||
TenantSendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (res *v1.TenantSendChangeEmailCodeRes, err error)
|
||||
FeedbackCreate(ctx context.Context, req *v1.FeedbackCreateReq) (res *v1.FeedbackCreateRes, err error)
|
||||
FeedbackList(ctx context.Context, req *v1.FeedbackListReq) (res *v1.FeedbackListRes, err error)
|
||||
FeedbackGet(ctx context.Context, req *v1.FeedbackGetReq) (res *v1.FeedbackGetRes, err error)
|
||||
|
||||
@@ -33,3 +33,11 @@ type TenantChangeEmailReq struct {
|
||||
}
|
||||
|
||||
type TenantChangeEmailRes struct{}
|
||||
|
||||
// TenantSendChangeEmailCodeReq 设置/修改邮箱时发送验证码(需登录,发送前校验租户内邮箱唯一性)
|
||||
type TenantSendChangeEmailCodeReq struct {
|
||||
g.Meta `path:"/email/send-change-email-code" method:"post" mime:"json" tags:"租户控制台-邮箱" summary:"发送设置邮箱验证码"`
|
||||
NewEmail string `json:"new_email" v:"required|email#请输入新邮箱|邮箱格式不正确" dc:"新邮箱"`
|
||||
}
|
||||
|
||||
type TenantSendChangeEmailCodeRes struct{}
|
||||
@@ -98,7 +98,7 @@ type TenantMemberJoinReq struct {
|
||||
Code string `json:"code" v:"required#请提供邀请码" dc:"邀请码"`
|
||||
Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"`
|
||||
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"`
|
||||
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
|
||||
Email string `json:"email" v:"email#邮箱格式不正确" dc:"邮箱"`
|
||||
DisplayName string `json:"display_name" dc:"显示名称"`
|
||||
}
|
||||
|
||||
|
||||
@@ -16,3 +16,6 @@ func (c *ControllerV1) TenantResetPassword(ctx context.Context, req *v1.TenantRe
|
||||
func (c *ControllerV1) TenantChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (res *v1.TenantChangeEmailRes, err error) {
|
||||
return service.Tenant().ChangeEmail(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) TenantSendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (res *v1.TenantSendChangeEmailCodeRes, err error) {
|
||||
return service.Tenant().SendChangeEmailCode(ctx, req)
|
||||
}
|
||||
@@ -199,3 +199,31 @@ func (s *sTenant) ChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq)
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// SendChangeEmailCode sends a verification code for setting/changing email.
|
||||
// Authenticated (unlike the public send-code): pre-checks that newEmail is not
|
||||
// already taken by another member in the same tenant before sending, so we don't
|
||||
// waste a code (and an email) on an address the user can't actually use.
|
||||
func (s *sTenant) SendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (*v1.TenantSendChangeEmailCodeRes, error) {
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
userID := middleware.GetUserID(ctx)
|
||||
newEmail := strings.TrimSpace(strings.ToLower(req.NewEmail))
|
||||
|
||||
// Pre-check: reject early if the email is already used by another member in this tenant.
|
||||
count, err := dao.TntUsers.Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("email", newEmail).
|
||||
Where("id <> ?", userID).
|
||||
Count()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, common.NewBadRequestError("该邮箱已被其他成员使用")
|
||||
}
|
||||
|
||||
if err := common.SendVerifyCode(ctx, newEmail, common.VerifyPurposeChangeEmail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -177,6 +177,16 @@ func buildInviteURL(ctx context.Context, code string) string {
|
||||
return fmt.Sprintf("%s/#/tenant/join?code=%s", base, code)
|
||||
}
|
||||
|
||||
// nullableEmail converts an empty email to nil so an optional email is stored as
|
||||
// SQL NULL (tnt_users.email is nullable) rather than "" — the latter would occupy
|
||||
// the UNIQUE(tenant_id, email) slot and block other email-less members in the tenant.
|
||||
func nullableEmail(email string) any {
|
||||
if email == "" {
|
||||
return nil
|
||||
}
|
||||
return email
|
||||
}
|
||||
|
||||
// JoinByInvite handles a user joining a tenant via invitation link.
|
||||
func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq) (*v1.TenantMemberJoinRes, error) {
|
||||
// Find invitation
|
||||
@@ -268,16 +278,18 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
|
||||
return common.NewBusinessError(consts.CodeUsernameExists, consts.MsgUsernameExists)
|
||||
}
|
||||
|
||||
// Check email uniqueness within tenant
|
||||
count, err = tx.Model("tnt_users").Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("email", email).
|
||||
Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
// Check email uniqueness within tenant (email is optional — skip when empty)
|
||||
if email != "" {
|
||||
count, err = tx.Model("tnt_users").Ctx(ctx).
|
||||
Where("tenant_id", tenantID).
|
||||
Where("email", email).
|
||||
Count()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return common.NewBusinessError(consts.CodeEmailExists, consts.MsgEmailExists)
|
||||
}
|
||||
}
|
||||
|
||||
// Create user
|
||||
@@ -289,7 +301,7 @@ func (s *sTenant) JoinByInvite(ctx context.Context, req *v1.TenantMemberJoinReq)
|
||||
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
|
||||
TenantId: tenantID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
Email: nullableEmail(email),
|
||||
PasswordHash: passwordHash,
|
||||
DisplayName: displayName,
|
||||
Role: invitation.Role,
|
||||
@@ -454,7 +466,7 @@ func (s *sTenant) CreateMember(ctx context.Context, req *v1.TenantMemberCreateRe
|
||||
userResult, err := tx.Model("tnt_users").Ctx(ctx).Data(do.TntUsers{
|
||||
TenantId: tenantID,
|
||||
Username: username,
|
||||
Email: email,
|
||||
Email: nullableEmail(email),
|
||||
PasswordHash: passwordHash,
|
||||
DisplayName: displayName,
|
||||
Role: req.Role,
|
||||
|
||||
@@ -94,6 +94,11 @@ type (
|
||||
ResetPassword(ctx context.Context, req *v1.TenantResetPasswordReq) (*v1.TenantResetPasswordRes, error)
|
||||
// ChangeEmail handles email change for a tenant user.
|
||||
ChangeEmail(ctx context.Context, req *v1.TenantChangeEmailReq) (*v1.TenantChangeEmailRes, error)
|
||||
// SendChangeEmailCode sends a verification code for setting/changing email.
|
||||
// Authenticated (unlike the public send-code): pre-checks that newEmail is not
|
||||
// already taken by another member in the same tenant before sending, so we don't
|
||||
// waste a code (and an email) on an address the user can't actually use.
|
||||
SendChangeEmailCode(ctx context.Context, req *v1.TenantSendChangeEmailCodeReq) (*v1.TenantSendChangeEmailCodeRes, error)
|
||||
// CreateFeedback 提交反馈
|
||||
CreateFeedback(ctx context.Context, req *v1.FeedbackCreateReq) (*v1.FeedbackCreateRes, error)
|
||||
// ListFeedbacks 我的反馈列表
|
||||
|
||||
@@ -65,22 +65,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">邮箱 <span class="text-red-500">*</span></label>
|
||||
<div class="relative">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5 text-gray-400">
|
||||
<Icon name="mail" size="sm" />
|
||||
</div>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="请输入邮箱"
|
||||
class="input pl-11"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">密码 <span class="text-red-500">*</span></label>
|
||||
<div class="relative">
|
||||
@@ -192,7 +176,6 @@ const joinResult = ref({
|
||||
const form = ref({
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
@@ -252,7 +235,6 @@ async function handleJoin() {
|
||||
code: route.query.code as string,
|
||||
username: form.value.username,
|
||||
display_name: form.value.display_name || undefined,
|
||||
email: form.value.email,
|
||||
password: form.value.password,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function goToPlayground() {
|
||||
router.push({ name: 'TenantPlayground' })
|
||||
}
|
||||
|
||||
interface PricingTierItem {
|
||||
min_tokens: number
|
||||
max_tokens: number | null
|
||||
@@ -193,7 +200,14 @@ onMounted(fetchModels)
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">可用模型</h1>
|
||||
<p class="page-description">查看当前组织可使用的 AI 模型及其配置信息</p>
|
||||
<p class="page-description">
|
||||
查看当前组织可使用的 AI 模型及其配置信息。如果需要体验模型,请前往
|
||||
<a
|
||||
class="font-medium text-primary-600 hover:text-primary-700 underline underline-offset-2 transition-colors cursor-pointer"
|
||||
@click="goToPlayground"
|
||||
>在线体验</a>
|
||||
功能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import request from '@/utils/request'
|
||||
@@ -13,8 +13,6 @@ const { settings } = usePublicSettings()
|
||||
// OAuth state
|
||||
const oauthProviders = ref<any[]>([])
|
||||
const oauthLoading = ref(false)
|
||||
const linkProvider = ref('')
|
||||
const linkCode = ref('')
|
||||
const linkLoading = ref(false)
|
||||
const linkError = ref('')
|
||||
|
||||
@@ -33,6 +31,18 @@ const passwordSaving = ref(false)
|
||||
const passwordSuccess = ref(false)
|
||||
const showPasswordModal = ref(false)
|
||||
|
||||
// Email (set / change) modal state
|
||||
const showEmailModal = ref(false)
|
||||
const emailForm = reactive({
|
||||
new_email: '',
|
||||
code: '',
|
||||
})
|
||||
const emailSending = ref(false)
|
||||
const emailSaving = ref(false)
|
||||
const emailCooldown = ref(0)
|
||||
const emailError = ref('')
|
||||
let emailCooldownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const roleLabel: Record<string, string> = {
|
||||
owner: '所有者',
|
||||
admin: '管理员',
|
||||
@@ -129,6 +139,75 @@ async function handleChangePassword() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEmailModal() {
|
||||
emailForm.new_email = userInfo.value?.email || ''
|
||||
emailForm.code = ''
|
||||
emailError.value = ''
|
||||
emailCooldown.value = 0
|
||||
if (emailCooldownTimer) {
|
||||
clearInterval(emailCooldownTimer)
|
||||
emailCooldownTimer = null
|
||||
}
|
||||
showEmailModal.value = true
|
||||
}
|
||||
|
||||
async function sendEmailCode() {
|
||||
emailError.value = ''
|
||||
if (!emailForm.new_email.trim()) {
|
||||
emailError.value = '请输入邮箱'
|
||||
return
|
||||
}
|
||||
emailSending.value = true
|
||||
try {
|
||||
await request.post('/tenant/email/send-change-email-code', {
|
||||
new_email: emailForm.new_email,
|
||||
})
|
||||
emailCooldown.value = 60
|
||||
emailCooldownTimer = setInterval(() => {
|
||||
emailCooldown.value--
|
||||
if (emailCooldown.value <= 0 && emailCooldownTimer) {
|
||||
clearInterval(emailCooldownTimer)
|
||||
emailCooldownTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (e: any) {
|
||||
emailError.value = e?.response?.data?.message || '验证码发送失败'
|
||||
} finally {
|
||||
emailSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangeEmail() {
|
||||
emailError.value = ''
|
||||
if (!emailForm.new_email.trim()) {
|
||||
emailError.value = '请输入邮箱'
|
||||
return
|
||||
}
|
||||
if (!emailForm.code.trim()) {
|
||||
emailError.value = '请输入验证码'
|
||||
return
|
||||
}
|
||||
emailSaving.value = true
|
||||
try {
|
||||
await request.post('/tenant/email/change-email', {
|
||||
new_email: emailForm.new_email,
|
||||
code: emailForm.code,
|
||||
})
|
||||
if (userInfo.value) {
|
||||
userInfo.value.email = emailForm.new_email
|
||||
}
|
||||
showEmailModal.value = false
|
||||
if (emailCooldownTimer) {
|
||||
clearInterval(emailCooldownTimer)
|
||||
emailCooldownTimer = null
|
||||
}
|
||||
} catch (e: any) {
|
||||
emailError.value = e?.response?.data?.message || '设置失败'
|
||||
} finally {
|
||||
emailSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeSession(sessionId: number) {
|
||||
try {
|
||||
await request.delete(`/tenant/auth/sessions/${sessionId}`)
|
||||
@@ -152,6 +231,13 @@ onMounted(() => {
|
||||
fetchOAuthProviders()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (emailCooldownTimer) {
|
||||
clearInterval(emailCooldownTimer)
|
||||
emailCooldownTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchOAuthProviders() {
|
||||
oauthLoading.value = true
|
||||
try {
|
||||
@@ -181,7 +267,7 @@ async function handleOAuthLink(provider: string) {
|
||||
linkError.value = ''
|
||||
try {
|
||||
const res: any = await request.get('/tenant/oauth/authorize', { params: { provider } })
|
||||
const { authorize_url, state } = res.data?.data || {}
|
||||
const { authorize_url } = res.data?.data || {}
|
||||
if (authorize_url) {
|
||||
// Open OAuth in popup window
|
||||
const width = 600
|
||||
@@ -324,7 +410,16 @@ async function handleOAuthUnlink(provider: string) {
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<p class="input-label">邮箱</p>
|
||||
<span class="text-gray-900 font-medium">{{ userInfo.email || '--' }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-gray-900 font-medium">{{ userInfo.email || '未设置' }}</span>
|
||||
<button
|
||||
@click="openEmailModal"
|
||||
class="text-primary-600 hover:text-primary-500 transition-colors"
|
||||
title="设置/修改邮箱"
|
||||
>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
@@ -485,5 +580,54 @@ async function handleOAuthUnlink(provider: string) {
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<!-- Email Modal -->
|
||||
<BaseModal :show="showEmailModal" :title="userInfo?.email ? '修改邮箱' : '设置邮箱'" width="narrow" @close="showEmailModal = false">
|
||||
<form @submit.prevent="handleChangeEmail" class="space-y-4">
|
||||
<div>
|
||||
<label class="input-label">新邮箱</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="emailForm.new_email"
|
||||
type="email"
|
||||
placeholder="请输入邮箱"
|
||||
class="input flex-1"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary whitespace-nowrap"
|
||||
:disabled="emailSending || emailCooldown > 0"
|
||||
@click="sendEmailCode"
|
||||
>
|
||||
{{ emailCooldown > 0 ? `${emailCooldown}s` : (emailSending ? '发送中...' : '发送验证码') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="input-label">验证码</label>
|
||||
<input
|
||||
v-model="emailForm.code"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="请输入6位验证码"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="emailError" class="input-error-text">{{ emailError }}</p>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showEmailModal = false">取消</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="emailSaving"
|
||||
@click="handleChangeEmail"
|
||||
>
|
||||
{{ emailSaving ? '设置中...' : '确认' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
Reference in new issue
Block a user