Add user registration with jingrow cloud integration

This commit is contained in:
jingrow 2025-11-23 00:33:06 +08:00
parent 03d6b988e2
commit d599021dd5
6 changed files with 593 additions and 11 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,84 @@ 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})`
}
}
}
// 如果响应不成功,提取错误消息
if (!response.ok) {
// FastAPI HTTPException 返回格式: {"detail": "错误消息"}
const errorMsg = result.detail || `注册请求失败 (HTTP ${response.status})`
return { success: false, error: errorMsg }
}
// 检查业务逻辑错误success 为 false
if (result.success === false) {
const errorMsg = result.error || result.message || '注册失败'
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,333 @@
<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'))
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 {
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
}
}
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
@ -28,16 +41,14 @@ class UserInfoResponse(BaseModel):
user_info: Optional[dict] = None
message: Optional[str] = None
# Cookie配置常量
COOKIE_CONFIG = {
"httponly": True,
"samesite": "lax",
"secure": False, # 开发环境可以设为False生产环境建议设为True
"secure": False,
"path": "/",
"max_age": 7 * 24 * 60 * 60 # 7天过期时间
"max_age": 7 * 24 * 60 * 60
}
# 需要清除的cookie列表
COOKIES_TO_CLEAR = ["sid", "user_id", "user_image", "full_name", "system_user"]
def get_session_cookie(request: Request) -> Optional[str]:
@ -69,7 +80,7 @@ def create_response_clear_cookies(data: dict) -> JSONResponse:
for cookie_name in COOKIES_TO_CLEAR:
cookie_kwargs = COOKIE_CONFIG.copy()
if cookie_name == "sid":
cookie_kwargs.pop("secure", None) # delete_cookie不需要secure参数
cookie_kwargs.pop("secure", None)
response.delete_cookie(key=cookie_name, **cookie_kwargs)
return response
@ -111,6 +122,78 @@ 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:
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)
if response.status_code != 200:
raise HTTPException(status_code=400, detail=f"注册请求失败 (HTTP {response.status_code})")
try:
result = response.json()
except Exception as e:
raise HTTPException(status_code=400, detail="注册失败:无法解析服务器响应")
message_obj = result.get("message")
if not isinstance(message_obj, dict):
raise HTTPException(status_code=400, detail="注册失败:服务器响应格式错误")
success_value = message_obj.get("success")
success_message = message_obj.get("message", "注册成功")
if success_value is False:
raise HTTPException(status_code=400, detail=success_message)
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:
pass
except Exception as e:
pass
response_data = {
"success": True,
"message": success_message,
"user": user_info
}
if session_cookie:
return create_response_with_cookie(response_data, session_cookie)
else:
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)):
"""获取用户信息路由"""