Add user registration with jingrow cloud integration

This commit is contained in:
jingrow 2025-11-23 00:33:06 +08:00
parent 03d6b988e2
commit 55a9024a1a
6 changed files with 654 additions and 6 deletions

View File

@ -10,6 +10,12 @@ const router = createRouter({
component: () => import('../../views/auth/Login.vue'),
meta: { requiresAuth: false }
},
{
path: '/signup',
name: 'Signup',
component: () => import('../../views/auth/Signup.vue'),
meta: { requiresAuth: false }
},
{
path: '/',
name: 'AppLayout',
@ -250,7 +256,7 @@ router.beforeEach(async (to, _from, next) => {
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
next('/login')
} else if (to.path === '/login' && authStore.isLoggedIn) {
} else if ((to.path === '/login' || to.path === '/signup') && authStore.isLoggedIn) {
next('/')
} else {
next()

View File

@ -28,13 +28,29 @@
"Username": "用户名",
"Password": "密码",
"Login": "登录",
"Sign up": "注册",
"Connect to Jingrow SaaS platform": "连接到 Jingrow SaaS 平台",
"Please enter username": "请输入用户名",
"Please enter password": "请输入密码",
"Password must be at least 6 characters": "密码长度不能少于6位",
"Username must be at least 3 characters": "用户名至少需要3个字符",
"Login successful": "登录成功",
"Login failed": "登录失败",
"Login failed, please check username and password": "登录失败,请检查用户名和密码",
"Create your account": "创建您的账户",
"Confirm Password": "确认密码",
"Please confirm password": "请确认密码",
"Passwords do not match": "两次输入的密码不一致",
"Email (Optional)": "邮箱(可选)",
"Phone Number": "手机号",
"Phone Number (Optional)": "手机号(可选)",
"Please enter a valid email address": "请输入有效的邮箱地址",
"Please enter phone number": "请输入手机号",
"Please enter a valid phone number": "请输入有效的手机号码",
"Sign up successful": "注册成功",
"Sign up failed": "注册失败",
"Sign up failed, please try again": "注册失败,请重试",
"Already have an account?": "已有账户?",
"System and personal settings": "系统配置和个人设置",
"Personal Settings": "个人设置",
"Email": "邮箱",

View File

@ -110,6 +110,110 @@ export const logoutApi = async (): Promise<void> => {
}
}
// 注册接口
export interface SignupRequest {
username: string
password: string
email?: string
phone_number?: string
}
export interface SignupResponse {
success: boolean
message?: string
error?: string
user?: UserInfo
}
export const signupApi = async (data: SignupRequest): Promise<SignupResponse> => {
try {
const response = await fetch(`/jingrow/signup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include',
body: JSON.stringify(data)
})
// 先克隆响应,以便可以多次读取(如果需要)
const responseClone = response.clone()
let result: any = {}
// 尝试解析响应体
try {
result = await response.json()
} catch (e) {
// 如果JSON解析失败尝试读取文本
try {
const text = await responseClone.text()
return {
success: false,
error: text || `注册请求失败 (HTTP ${response.status})`
}
} catch (textError) {
return {
success: false,
error: `注册请求失败 (HTTP ${response.status})`
}
}
}
// 辅助函数:提取错误消息
const extractErrorMessage = (data: any): string => {
// 如果 detail 是对象,提取其中的 message
if (data.detail && typeof data.detail === 'object') {
return data.detail.message || data.detail.detail || JSON.stringify(data.detail)
}
// 如果 detail 是字符串,直接使用
if (typeof data.detail === 'string') {
return data.detail
}
// 尝试其他字段
if (typeof data.message === 'string') {
return data.message
}
if (typeof data.error === 'string') {
return data.error
}
return '注册请求失败'
}
// 如果响应不成功,提取错误消息
if (!response.ok) {
// FastAPI HTTPException 返回格式: {"detail": "错误消息"} 或 {"detail": {message: "错误消息"}}
const errorMsg = extractErrorMessage(result) || `注册请求失败 (HTTP ${response.status})`
console.error('注册API错误:', {
status: response.status,
statusText: response.statusText,
result: result,
errorMsg: errorMsg
})
return { success: false, error: errorMsg }
}
// 检查业务逻辑错误success 为 false
if (!result.success) {
const errorMsg = extractErrorMessage(result) || '注册失败'
return { success: false, error: errorMsg }
}
return {
success: true,
message: result.message || '注册成功',
user: result.user || undefined
}
} catch (error: any) {
// 处理网络错误或其他异常
return {
success: false,
error: error.message || '网络错误,请检查网络连接后重试'
}
}
}
// 仅使用会话Cookie的最小鉴权头部不影响现有API Key逻辑
export function get_session_api_headers() {
return {

View File

@ -12,7 +12,8 @@
ref="formRef"
:model="formData"
:rules="rules"
size="large"
size="medium"
:show-label="false"
@keyup.enter="handleLogin"
>
<n-form-item path="username">
@ -48,14 +49,18 @@
block
:loading="loading"
@click="handleLogin"
class="brand-button"
>
{{ t('Login') }}
</n-button>
</n-form-item>
</n-form>
<div class="login-footer">
<div class="login-footer" v-if="showSignupLink">
<n-text depth="3">
<router-link to="/signup" class="signup-link">
{{ t('Sign up') }}
</router-link>
</n-text>
</div>
</div>
@ -76,6 +81,7 @@ const authStore = useAuthStore()
const formRef = ref()
const loading = ref(false)
const showSignupLink = ref(false)
const formData = reactive({
username: '',
@ -123,6 +129,20 @@ onMounted(async () => {
//
if (authStore.isLoggedIn) {
router.push('/')
return
}
//
try {
const response = await fetch('/jingrow/server-config')
if (response.ok) {
const data = await response.json()
if (data.success && data.jingrow_server_url === 'https://cloud.jingrow.com') {
showSignupLink.value = true
}
}
} catch (error) {
console.error('Failed to get server config:', error)
}
})
</script>
@ -139,7 +159,7 @@ onMounted(async () => {
.login-card {
width: 100%;
max-width: 400px;
max-width: 480px;
background: white;
border-radius: 16px;
padding: 40px;
@ -148,7 +168,26 @@ onMounted(async () => {
.login-header {
text-align: center;
margin-bottom: 32px;
margin-bottom: 24px;
}
:deep(.n-form-item) {
margin-bottom: 6px !important;
}
:deep(.n-form-item:last-child) {
margin-bottom: 0 !important;
}
:deep(.n-form-item:not(.n-form-item--error) .n-form-item__feedback-wrapper) {
min-height: 0 !important;
margin-top: 0 !important;
padding-top: 0 !important;
}
:deep(.n-form-item--error .n-form-item__feedback-wrapper) {
margin-top: 4px !important;
min-height: auto !important;
}
.logo {
@ -172,4 +211,31 @@ onMounted(async () => {
text-align: center;
margin-top: 24px;
}
.signup-link {
color: #18a058;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.signup-link:hover {
color: #36ad6a;
text-decoration: underline;
}
:deep(.brand-button) {
background-color: #18a058 !important;
border-color: #18a058 !important;
}
:deep(.brand-button:hover) {
background-color: #36ad6a !important;
border-color: #36ad6a !important;
}
:deep(.brand-button:focus) {
background-color: #18a058 !important;
border-color: #18a058 !important;
}
</style>

View File

@ -0,0 +1,337 @@
<template>
<div class="signup-container">
<div class="signup-card">
<div class="signup-header">
<div class="logo">
<img src="/logo.svg" :alt="appName" width="48" height="48" />
</div>
<h1 class="title">{{ appName }}</h1>
</div>
<n-form
ref="formRef"
:model="formData"
:rules="rules"
size="medium"
:show-label="false"
@keyup.enter="handleSignup"
>
<n-form-item path="username">
<n-input
v-model:value="formData.username"
:placeholder="t('Username')"
:input-props="{ autocomplete: 'username' }"
>
<template #prefix>
<Icon icon="tabler:user" />
</template>
</n-input>
</n-form-item>
<n-form-item path="password">
<n-input
v-model:value="formData.password"
type="password"
:placeholder="t('Password')"
:input-props="{ autocomplete: 'new-password' }"
show-password-on="click"
>
<template #prefix>
<Icon icon="tabler:lock" />
</template>
</n-input>
</n-form-item>
<n-form-item path="confirmPassword">
<n-input
v-model:value="formData.confirmPassword"
type="password"
:placeholder="t('Confirm Password')"
:input-props="{ autocomplete: 'new-password' }"
show-password-on="click"
>
<template #prefix>
<Icon icon="tabler:lock" />
</template>
</n-input>
</n-form-item>
<n-form-item path="email">
<n-input
v-model:value="formData.email"
:placeholder="t('Email (Optional)')"
:input-props="{ autocomplete: 'email', type: 'email' }"
>
<template #prefix>
<Icon icon="tabler:mail" />
</template>
</n-input>
</n-form-item>
<n-form-item path="phoneNumber">
<n-input
v-model:value="formData.phoneNumber"
:placeholder="t('Phone Number')"
:input-props="{ autocomplete: 'tel' }"
>
<template #prefix>
<Icon icon="tabler:phone" />
</template>
</n-input>
</n-form-item>
<n-form-item>
<n-button
type="primary"
size="medium"
block
:loading="loading"
@click="handleSignup"
class="brand-button"
>
{{ t('Sign up') }}
</n-button>
</n-form-item>
</n-form>
<div class="signup-footer">
<n-text depth="3">
{{ t('Already have an account?') }}
<router-link to="/login" class="login-link">
{{ t('Login') }}
</router-link>
</n-text>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { NForm, NFormItem, NInput, NButton, NText, useMessage } from 'naive-ui'
import { Icon } from '@iconify/vue'
import { useAuthStore } from '../../shared/stores/auth'
import { t } from '../../shared/i18n'
import { signupApi } from '../../shared/api/auth'
const router = useRouter()
const message = useMessage()
const authStore = useAuthStore()
const formRef = ref()
const loading = ref(false)
const formData = reactive({
username: '',
password: '',
confirmPassword: '',
email: '',
phoneNumber: ''
})
const validatePasswordMatch = (_rule: any, value: string) => {
if (value !== formData.password) {
return new Error(t('Passwords do not match'))
}
return true
}
const rules = {
username: [
{ required: true, message: t('Please enter username'), trigger: 'blur' },
{ min: 3, message: t('Username must be at least 3 characters'), trigger: 'blur' }
],
password: [
{ required: true, message: t('Please enter password'), trigger: 'blur' },
{ min: 6, message: t('Password must be at least 6 characters'), trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: t('Please confirm password'), trigger: 'blur' },
{ validator: validatePasswordMatch, trigger: 'blur' }
],
email: [
{
validator: (_rule: any, value: string) => {
if (!value) return true
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(value)) {
return new Error(t('Please enter a valid email address'))
}
return true
},
trigger: 'blur'
}
],
phoneNumber: [
{ required: true, message: t('Please enter phone number'), trigger: 'blur' },
{
pattern: /^1[3-9]\d{9}$/,
message: t('Please enter a valid phone number'),
trigger: 'blur'
}
]
}
const handleSignup = async () => {
try {
await formRef.value?.validate()
loading.value = true
const result = await signupApi({
username: formData.username,
password: formData.password,
email: formData.email || undefined,
phone_number: formData.phoneNumber
})
if (result.success) {
message.success(t('Sign up successful'))
// cookie user
// user 使 API
if (result.user) {
authStore.user = result.user
authStore.isAuthenticated = true
localStorage.setItem('jingrow_user', JSON.stringify(result.user))
localStorage.setItem('jingrow_authenticated', 'true')
router.push('/')
} else {
// user API
const loginResult = await authStore.login(formData.username, formData.password)
if (loginResult.success) {
router.push('/')
} else {
message.warning(loginResult.error || t('注册成功,但自动登录失败,请手动登录'))
router.push('/login')
}
}
} else {
const errorMsg = result.error || t('Sign up failed')
console.error('注册失败:', errorMsg, result)
message.error(errorMsg)
}
} catch (error: any) {
console.error('注册异常:', error)
message.error(error.message || t('Sign up failed, please try again'))
} finally {
loading.value = false
}
}
// localStorage Jingrow
const appName = computed(() => localStorage.getItem('appName') || 'Jingrow')
onMounted(async () => {
//
await authStore.initAuth()
//
if (authStore.isLoggedIn) {
router.push('/')
}
})
</script>
<style scoped>
.signup-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
padding: 20px;
}
.signup-card {
width: 100%;
max-width: 480px;
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
max-height: calc(100vh - 40px);
overflow-y: auto;
}
.signup-header {
text-align: center;
margin-bottom: 20px;
}
:deep(.n-form-item) {
margin-bottom: 6px !important;
}
:deep(.n-form-item:last-child) {
margin-bottom: 0 !important;
}
/* 只在有错误时才显示反馈区域,减少默认间距 */
:deep(.n-form-item:not(.n-form-item--error) .n-form-item__feedback-wrapper) {
min-height: 0 !important;
margin-top: 0 !important;
padding-top: 0 !important;
}
:deep(.n-form-item--error .n-form-item__feedback-wrapper) {
margin-top: 4px !important;
min-height: auto !important;
}
.logo {
margin-bottom: 12px;
}
.logo img {
width: 40px;
height: 40px;
}
.title {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin: 0 0 6px 0;
}
.subtitle {
font-size: 14px;
color: #6b7280;
margin: 0;
}
.signup-footer {
text-align: center;
margin-top: 16px;
padding-top: 16px;
}
.login-link {
color: #18a058;
text-decoration: none;
font-weight: 500;
margin-left: 4px;
transition: color 0.2s;
}
.login-link:hover {
color: #36ad6a;
text-decoration: underline;
}
:deep(.brand-button) {
background-color: #18a058 !important;
border-color: #18a058 !important;
}
:deep(.brand-button:hover) {
background-color: #36ad6a !important;
border-color: #36ad6a !important;
}
:deep(.brand-button:focus) {
background-color: #18a058 !important;
border-color: #18a058 !important;
}
</style>

View File

@ -4,7 +4,8 @@ from pydantic import BaseModel
from typing import Optional
import logging
from jingrow.utils.auth import login, logout, get_user_info, set_context
from jingrow.utils.auth import login, logout, get_user_info, set_context, get_jingrow_cloud_url, get_jingrow_cloud_api_headers
from jingrow.config import Config
logger = logging.getLogger(__name__)
router = APIRouter()
@ -13,6 +14,18 @@ class LoginRequest(BaseModel):
username: str
password: str
class SignupRequest(BaseModel):
username: str
password: str
email: Optional[str] = None
phone_number: Optional[str] = None
class SignupResponse(BaseModel):
success: bool
message: str
user: Optional[dict] = None
session_cookie: Optional[str] = None
class LoginResponse(BaseModel):
success: bool
message: str
@ -111,6 +124,112 @@ async def logout_route(session_cookie: Optional[str] = Depends(get_session_cooki
logger.error(f"登出异常: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"登出异常: {str(e)}")
@router.post("/jingrow/signup", response_model=SignupResponse)
async def signup_route(signup_data: SignupRequest):
"""注册路由"""
import requests
try:
# 调用 jcloud signup_with_username API
url = f"{get_jingrow_cloud_url()}/api/action/jcloud.api.account.signup_with_username"
data = {"username": signup_data.username, "password": signup_data.password}
if signup_data.email:
data["email"] = signup_data.email
if signup_data.phone_number:
data["phone_number"] = signup_data.phone_number
response = requests.post(url, headers=get_jingrow_cloud_api_headers(), json=data, timeout=30)
# 打印完整的响应信息用于调试
logger.info(f"=== 注册API调用详情 ===")
logger.info(f"URL: {url}")
logger.info(f"请求数据: {data}")
logger.info(f"HTTP状态码: {response.status_code}")
logger.info(f"响应头: {dict(response.headers)}")
logger.info(f"响应文本: {response.text}")
# 解析响应
if response.status_code != 200:
error_text = response.text[:500] if response.text else ""
logger.error(f"注册请求失败: HTTP {response.status_code}, URL: {url}, 响应: {error_text}")
raise HTTPException(status_code=400, detail=f"注册请求失败 (HTTP {response.status_code})")
try:
result = response.json()
logger.info(f"解析后的JSON结果: {result}")
except Exception as e:
error_text = response.text[:500] if response.text else ""
logger.error(f"注册失败:无法解析服务器响应: {str(e)}, 响应文本: {error_text}")
raise HTTPException(status_code=400, detail="注册失败:无法解析服务器响应")
# jcloud API 返回格式:{"message": {"success": bool, "message": str}, ...}
# 需要从 message 字段中提取 success 和 message
message_obj = result.get("message")
if isinstance(message_obj, dict):
# message 是对象,从中提取 success 和 message
success_value = message_obj.get("success")
success_message = message_obj.get("message", "注册成功")
else:
# message 是字符串,检查顶层的 success 字段
success_value = result.get("success")
success_message = result.get("message", message_obj if isinstance(message_obj, str) else "注册成功")
# 检查注册结果
if success_value is False:
logger.error(f"注册失败: {success_message}, 完整响应: {result}")
raise HTTPException(status_code=400, detail=success_message)
# 注册成功
logger.info(f"注册成功: {success_message}")
# 注册成功后,用户已在 jcloud 端登录,需要在 jingrow 端也建立会话
# 注意:用户刚注册可能还未完全同步,如果登录失败,返回成功但让前端处理
session_cookie = None
user_info = None
try:
login_result = login(signup_data.username, signup_data.password)
if login_result.get("success"):
session_cookie = login_result.get("session_cookie")
# 获取用户信息
if session_cookie:
user_info_result = get_user_info(session_cookie)
if user_info_result.get("success"):
user_info = user_info_result.get("user_info")
else:
# 登录失败,记录日志但不抛出异常(注册本身是成功的)
logger.warning(f"注册成功但获取会话失败: {login_result.get('message', '未知错误')}")
except Exception as e:
# 登录异常,记录日志但不抛出异常(注册本身是成功的)
logger.warning(f"注册成功但获取会话时发生异常: {str(e)}")
# 返回成功响应
# 如果有 session cookie设置 cookie如果没有前端会尝试调用登录 API
response_data = {
"success": True,
"message": success_message,
"user": user_info
}
if session_cookie:
return create_response_with_cookie(response_data, session_cookie)
else:
# 如果没有 session cookie返回普通响应不设置 cookie
# 前端会处理这种情况,尝试调用登录 API
return JSONResponse(content=response_data)
except HTTPException:
raise
except Exception as e:
logger.error(f"注册异常: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail=f"注册异常: {str(e)}")
@router.get("/jingrow/server-config")
async def get_server_config():
return {
"success": True,
"jingrow_server_url": Config.jingrow_server_url
}
@router.get("/jingrow/user-info", response_model=UserInfoResponse)
async def get_user_info_route(session_cookie: Optional[str] = Depends(get_session_cookie)):
"""获取用户信息路由"""