chore(ui):优化用户注册功能

This commit is contained in:
qian committed 2026-06-17 21:56:07 +08:00
1 parent 1dc120cfee
commit 534cdc18d9
10 files changed
+234 -37

No files matched your search

+1
View File
@@ -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)
+8
View File
@@ -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{}
+1 -1
View File
@@ -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)
}
+28
View File
@@ -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
}
+24 -12
View File
@@ -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,
+5
View File
@@ -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 我的反馈列表
-18
View File
@@ -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,
}),
})
+15 -1
View File
@@ -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 -->
+149 -5
View File
@@ -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>