基于与OAuth2实现登陆

This commit is contained in:
jingrow 2026-01-04 04:30:23 +08:00
parent d0835a1eb2
commit 67019666b9
13 changed files with 2422 additions and 514 deletions

View File

@ -16,6 +16,12 @@ const router = createRouter({
component: () => import('../../views/auth/Signup.vue'),
meta: { requiresAuth: false }
},
{
path: '/oauth/callback',
name: 'OAuthCallback',
component: () => import('../../views/OAuthCallback.vue'),
meta: { requiresAuth: false }
},
{
path: '',
name: 'HomePage',
@ -95,7 +101,20 @@ const router = createRouter({
{
path: 'settings',
name: 'Settings',
component: () => import('../../views/settings/Settings.vue')
component: () => import('../../views/settings/Settings.vue'),
redirect: { name: 'SettingsProfile' },
children: [
{
path: 'profile',
name: 'SettingsProfile',
component: () => import('../../views/settings/ProfileSettings.vue')
},
{
path: 'developer',
name: 'SettingsDeveloper',
component: () => import('../../views/settings/DeveloperSettings.vue')
}
]
},
{
path: 'tools',

View File

@ -21,6 +21,78 @@
"Agent Detail": "智能体详情",
"Flow Builder": "流程编排",
"Profile": "个人资料",
"Developer": "开发者",
"Edit": "编辑",
"Marketplace Developer": "应用市场开发者",
"Become a Developer": "成为开发者",
"Developers can publish their apps on the marketplace for users to subscribe to, either paid or free.": "开发者可以在应用市场发布自己的应用,供用户付费或免费订阅。",
"Reset Password": "重置密码",
"Change your account login password": "更改您的账户登录密码",
"Disable Account": "禁用账户",
"Disable your account and stop billing": "禁用您的账户并停止计费",
"Disable": "禁用",
"Update Profile Information": "更新个人资料",
"Save Changes": "保存更改",
"Current Password": "当前密码",
"New Password": "新密码",
"After confirming this action:": "确认此操作后:",
"Your account will be disabled": "您的账户将被禁用",
"Your account billing will stop": "您的账户计费将停止",
"You can log in later to re-enable your account. Do you want to continue?": "您可以稍后登录以重新启用您的账户。是否继续?",
"Become a Marketplace Developer?": "成为应用市场开发者?",
"After confirmation, you will be able to publish apps to our marketplace.": "确认后,您将能够在我们的应用市场发布应用。",
"Confirm": "确认",
"Cancel": "取消",
"You can now publish apps to our marketplace": "您现在可以在我们的应用市场发布应用了",
"Failed to mark you as a developer": "标记为开发者失败",
"Your profile has been updated successfully": "您的个人资料已成功更新",
"Failed to update profile": "更新个人资料失败",
"Password reset successfully": "密码重置成功",
"Failed to reset password": "重置密码失败",
"Your account has been disabled successfully": "您的账户已成功禁用",
"Failed to disable account": "禁用账户失败",
"API Access": "API访问",
"API key and API secret can be used to access": "API密钥和API密钥可用于访问",
"Jingrow API": "Jingrow API",
"You don't have an API key yet. Click the button above to create one.": "您还没有API密钥。请点击上方按钮创建一个。",
"Please copy the API key immediately. You will not be able to view it again!": "请立即复制API密钥。您将无法再次查看",
"Regenerate API Key": "重新生成API密钥",
"Create New API Key": "创建新的API密钥",
"API key regenerated successfully": "API密钥重新生成成功",
"API key created successfully": "API密钥创建成功",
"Failed to create API key": "创建API密钥失败",
"SSH Keys": "SSH密钥",
"Add SSH Key": "添加SSH密钥",
"Add a new SSH key to your account": "向您的账户添加新的SSH密钥",
"SSH key is required": "需要SSH密钥",
"Invalid SSH key format": "SSH密钥格式无效",
"SSH key added successfully": "SSH密钥添加成功",
"Failed to add SSH key": "添加SSH密钥失败",
"SSH key deleted successfully": "SSH密钥删除成功",
"Failed to delete SSH key": "删除SSH密钥失败",
"SSH key updated successfully": "SSH密钥更新成功",
"Failed to set SSH key as default": "设置默认SSH密钥失败",
"Set as Default": "设为默认",
"Default": "默认",
"No SSH keys": "没有SSH密钥",
"Add New SSH Key": "添加新的SSH密钥",
"Starts with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'": "以 'ssh-rsa'、'ecdsa-sha2-nistp256'、'ecdsa-sha2-nistp384'、'ecdsa-sha2-nistp521'、'ssh-ed25519'、'sk-ecdsa-sha2-nistp256@openssh.com' 或 'sk-ssh-ed25519@openssh.com' 开头",
"Copied to clipboard": "已复制到剪贴板",
"Copy": "复制",
"Copied": "已复制",
"Copy failed, please copy manually": "复制失败,请手动复制",
"SSH Fingerprint": "SSH指纹",
"Added Time": "添加时间",
"Actions": "操作",
"Are you sure you want to delete this SSH key?": "您确定要删除此SSH密钥吗",
"Phone": "手机",
"Email": "邮箱",
"First Name": "名",
"Last Name": "姓",
"Enable Account": "启用账户",
"Enable your account and resume billing": "启用您的账户并恢复计费",
"Your account has been enabled successfully": "您的账户已成功启用",
"Failed to enable account": "启用账户失败",
"Logout": "退出登录",
"Logged out": "已退出登录",
"AI Agent Workflow Platform": "AI Agent 工作流平台",

366
src/shared/api/account.ts Normal file
View File

@ -0,0 +1,366 @@
/**
* Account API - API
*/
import axios from 'axios'
import { getBearerTokenHeaders } from './oauth2'
/**
* URL使使 URL
*/
const getBackendUrl = () => {
// 在开发环境中,使用相对路径通过 Vite 代理,避免 CORS 问题
// 在生产环境中,使用绝对 URL
if (import.meta.env.DEV) {
// 开发环境返回空字符串使用相对路径Vite 代理会转发到后端
return ''
} else {
// 生产环境:使用绝对 URL
const backendUrl = import.meta.env.VITE_JINGROW_BACKEND_URL || 'https://cloud.jingrow.com'
// 确保 URL 不以 /dashboard 结尾API 路径应该直接在根路径下)
return backendUrl.replace(/\/dashboard\/?$/, '')
}
}
/**
*
*/
export interface UserProfile {
user: string
username?: string
email?: string
mobile_no?: string
phone?: string
first_name?: string
last_name?: string
user_type?: string
user_image?: string
is_developer?: boolean
api_key?: string
}
/**
*
*/
export async function getAccountInfo(): Promise<{ success: boolean; data?: any; message?: string }> {
try {
const response = await axios.get(
`${getBackendUrl()}/api/action/jcloud.api.account.get`,
{
headers: getBearerTokenHeaders()
}
)
return {
success: true,
data: response.data.message || response.data
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '获取账户信息失败'
}
}
}
/**
*
*/
export async function updateProfile(data: {
first_name?: string
last_name?: string
email?: string
username?: string
mobile_no?: string
}): Promise<{ success: boolean; data?: any; message?: string }> {
try {
const response = await axios.post(
`${getBackendUrl()}/api/action/jcloud.api.account.update_profile`,
data,
{
headers: getBearerTokenHeaders()
}
)
return {
success: true,
data: response.data.message || response.data,
message: '用户资料更新成功'
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '更新用户资料失败'
}
}
}
/**
*
*/
export async function updateProfilePicture(file: File): Promise<{ success: boolean; data?: any; message?: string }> {
try {
const formData = new FormData()
formData.append('file', file)
const response = await axios.post(
`${getBackendUrl()}/api/action/jcloud.api.account.update_profile_picture`,
formData,
{
headers: {
...getBearerTokenHeaders(),
'Content-Type': 'multipart/form-data'
}
}
)
return {
success: true,
data: response.data.message || response.data,
message: '头像更新成功'
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '更新头像失败'
}
}
}
/**
* API Key
*/
export async function createApiSecret(): Promise<{ success: boolean; data?: { api_key: string; api_secret: string }; message?: string }> {
try {
const response = await axios.post(
`${getBackendUrl()}/api/action/jcloud.api.account.create_api_secret`,
{},
{
headers: getBearerTokenHeaders()
}
)
return {
success: true,
data: response.data.message || response.data,
message: 'API Key 创建成功'
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '创建 API Key 失败'
}
}
}
/**
* SSH
*/
export async function getUserSSHKeys(): Promise<{ success: boolean; data?: any[]; message?: string }> {
try {
const response = await axios.get(
`${getBackendUrl()}/api/action/jcloud.api.account.get_user_ssh_keys`,
{
headers: getBearerTokenHeaders()
}
)
return {
success: true,
data: response.data.message || response.data
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '获取 SSH 密钥失败'
}
}
}
/**
* SSH
*/
export async function addSSHKey(sshKey: string): Promise<{ success: boolean; data?: any; message?: string }> {
try {
const response = await axios.post(
`${getBackendUrl()}/api/action/jcloud.api.client.insert`,
{
pagetype: 'SSH Key',
ssh_key: sshKey
},
{
headers: getBearerTokenHeaders()
}
)
return {
success: true,
data: response.data.message || response.data,
message: 'SSH 密钥添加成功'
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '添加 SSH 密钥失败'
}
}
}
/**
* SSH
*/
export async function deleteSSHKey(keyName: string): Promise<{ success: boolean; message?: string }> {
try {
await axios.post(
`${getBackendUrl()}/api/action/jcloud.api.client.delete`,
{
pagetype: 'SSH Key',
name: keyName
},
{
headers: getBearerTokenHeaders()
}
)
return {
success: true,
message: 'SSH 密钥删除成功'
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '删除 SSH 密钥失败'
}
}
}
/**
* SSH
*/
export async function setSSHKeyAsDefault(keyName: string): Promise<{ success: boolean; message?: string }> {
try {
await axios.post(
`${getBackendUrl()}/api/action/jcloud.api.account.mark_key_as_default`,
{
name: keyName
},
{
headers: getBearerTokenHeaders()
}
)
return {
success: true,
message: 'SSH 密钥已设置为默认'
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '设置默认 SSH 密钥失败'
}
}
}
/**
*
*/
export async function resetPassword(oldPassword: string, newPassword: string): Promise<{ success: boolean; message?: string }> {
try {
const response = await axios.post(
`${getBackendUrl()}/api/action/jcloud.api.account.reset_password`,
{
old_password: oldPassword,
new_password: newPassword
},
{
headers: getBearerTokenHeaders()
}
)
return {
success: true,
message: response.data.message || '密码重置成功'
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '重置密码失败'
}
}
}
/**
*
*/
export async function disableAccount(totpCode?: string): Promise<{ success: boolean; message?: string }> {
try {
const response = await axios.post(
`${getBackendUrl()}/api/action/jcloud.api.account.disable_account`,
{
totp_code: totpCode
},
{
headers: getBearerTokenHeaders()
}
)
return {
success: true,
message: response.data.message || '账户已禁用'
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '禁用账户失败'
}
}
}
/**
*
*/
export async function enableAccount(): Promise<{ success: boolean; message?: string }> {
try {
const response = await axios.post(
`${getBackendUrl()}/api/action/jcloud.api.account.enable_account`,
{},
{
headers: getBearerTokenHeaders()
}
)
return {
success: true,
message: response.data.message || '账户已启用'
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '启用账户失败'
}
}
}
/**
*
*/
export async function becomeDeveloper(): Promise<{ success: boolean; message?: string }> {
try {
const response = await axios.post(
`${getBackendUrl()}/api/action/jcloud.api.account.become_developer`,
{},
{
headers: getBearerTokenHeaders()
}
)
return {
success: true,
message: response.data.message || '已成为开发者'
}
} catch (error: any) {
return {
success: false,
message: error.response?.data?.message || error.message || '成为开发者失败'
}
}
}

297
src/shared/api/oauth2.ts Normal file
View File

@ -0,0 +1,297 @@
/**
* OAuth2 API
*/
import axios from 'axios'
import {
generatePKCE,
storeAccessToken,
storeRefreshToken,
getAccessToken,
getRefreshToken,
clearTokens,
storeCodeVerifier,
getCodeVerifier,
clearCodeVerifier,
type TokenInfo
} from '../utils/oauth2'
// 从环境变量或配置获取 OAuth2 配置
const getOAuth2Config = () => {
// 从环境变量或配置中获取
let backendUrl = import.meta.env.VITE_JINGROW_BACKEND_URL || 'https://cloud.jingrow.com'
// 确保 URL 不以 /dashboard 结尾API 路径应该直接在根路径下)
// 如果用户设置了 /dashboard自动移除
backendUrl = backendUrl.replace(/\/dashboard\/?$/, '')
const clientId = import.meta.env.VITE_OAUTH2_CLIENT_ID || 'i679osfadt'
const redirectUri = `${window.location.origin}/oauth/callback`
return {
backendUrl,
clientId,
redirectUri,
scopes: 'all openid desk:read desk:write'
}
}
/**
* API URL使使 URL
*/
const getApiUrl = (apiPath: string): string => {
const config = getOAuth2Config()
// 在开发环境中,使用相对路径通过 Vite 代理,避免 CORS 问题
// 在生产环境中,使用绝对 URL
if (import.meta.env.DEV) {
// 开发环境使用相对路径Vite 代理会转发到后端
return apiPath
} else {
// 生产环境:使用绝对 URL
return `${config.backendUrl}${apiPath}`
}
}
/**
* URL
*/
export async function getAuthorizationUrl(state?: string): Promise<string> {
const config = getOAuth2Config()
const { codeVerifier, codeChallenge } = await generatePKCE()
// 存储 code verifier 用于后续交换 token
storeCodeVerifier(codeVerifier)
// 调试日志:输出 redirect_uri 以便检查后端配置
console.log('OAuth2 授权 URL 配置:', {
backendUrl: config.backendUrl,
clientId: config.clientId,
redirectUri: config.redirectUri,
scopes: config.scopes,
currentOrigin: window.location.origin
})
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.redirectUri,
response_type: 'code',
scope: config.scopes,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
...(state && { state })
})
// 确保 API 路径正确(不应该包含 /dashboard
const apiPath = '/api/action/jingrow.integrations.oauth2.authorize'
const authUrl = `${config.backendUrl}${apiPath}?${params.toString()}`
console.log('OAuth2 授权 URL 配置:', {
backendUrl: config.backendUrl,
apiPath,
fullUrl: authUrl,
redirectUri: config.redirectUri,
clientId: config.clientId
})
console.log('期望的重定向 URI:', config.redirectUri)
console.log('请确保后端 OAuth Client 的 redirect_uris 包含:', config.redirectUri)
return authUrl
}
/**
* access token
*/
export async function exchangeCodeForToken(code: string): Promise<TokenInfo> {
const config = getOAuth2Config()
const codeVerifier = getCodeVerifier()
if (!codeVerifier) {
throw new Error('Code verifier not found. Please restart the authorization flow.')
}
// 调试日志:确认使用的 redirect_uri
console.log('OAuth2 Token 交换配置:', {
redirectUri: config.redirectUri,
clientId: config.clientId,
backendUrl: config.backendUrl
})
const params = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
code_verifier: codeVerifier
})
try {
// 使用 getApiUrl 获取正确的 API URL开发环境通过代理生产环境使用绝对 URL
const apiUrl = getApiUrl('/api/action/jingrow.integrations.oauth2.get_token')
const response = await axios.post(
apiUrl,
params.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
}
}
)
const tokenInfo: TokenInfo = response.data
// 存储 token
storeAccessToken(tokenInfo.access_token, tokenInfo.expires_in)
if (tokenInfo.refresh_token) {
storeRefreshToken(tokenInfo.refresh_token)
}
// 清除 code verifier
clearCodeVerifier()
return tokenInfo
} catch (error: any) {
clearCodeVerifier()
throw new Error(
error.response?.data?.error_description ||
error.response?.data?.error ||
error.message ||
'Failed to exchange authorization code for token'
)
}
}
/**
* access token
*/
export async function refreshAccessToken(): Promise<TokenInfo> {
const config = getOAuth2Config()
const refreshToken = getRefreshToken()
if (!refreshToken) {
throw new Error('No refresh token available')
}
const params = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: config.clientId
})
try {
// 使用 getApiUrl 获取正确的 API URL开发环境通过代理生产环境使用绝对 URL
const apiUrl = getApiUrl('/api/action/jingrow.integrations.oauth2.get_token')
const response = await axios.post(
apiUrl,
params.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
}
}
)
const tokenInfo: TokenInfo = response.data
// 更新存储的 token
storeAccessToken(tokenInfo.access_token, tokenInfo.expires_in)
if (tokenInfo.refresh_token) {
storeRefreshToken(tokenInfo.refresh_token)
}
return tokenInfo
} catch (error: any) {
// 刷新失败,清除所有 token
clearTokens()
throw new Error(
error.response?.data?.error_description ||
error.response?.data?.error ||
error.message ||
'Failed to refresh access token'
)
}
}
/**
* token
*/
export async function revokeToken(token?: string, tokenTypeHint: 'access_token' | 'refresh_token' = 'access_token'): Promise<void> {
const config = getOAuth2Config()
const tokenToRevoke = token || (tokenTypeHint === 'access_token' ? getAccessToken() : getRefreshToken())
if (!tokenToRevoke) {
return
}
const params = new URLSearchParams({
token: tokenToRevoke,
token_type_hint: tokenTypeHint
})
try {
// 使用 getApiUrl 获取正确的 API URL开发环境通过代理生产环境使用绝对 URL
const apiUrl = getApiUrl('/api/action/jingrow.integrations.oauth2.revoke_token')
await axios.post(
apiUrl,
params.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
)
// 清除本地存储的 token
clearTokens()
} catch (error: any) {
// 即使撤销失败,也清除本地 token
clearTokens()
console.error('Failed to revoke token:', error)
}
}
/**
* OpenID Connect userinfo
*/
export async function getUserInfo(): Promise<any> {
const config = getOAuth2Config()
const accessToken = getAccessToken()
if (!accessToken) {
throw new Error('No access token available')
}
// 使用 getApiUrl 获取正确的 API URL开发环境通过代理生产环境使用绝对 URL
const apiUrl = getApiUrl('/api/action/jingrow.integrations.oauth2.openid_profile')
const response = await axios.get(
apiUrl,
{
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json'
}
}
)
return response.data
}
/**
* Bearer token
*/
export function getBearerTokenHeaders(): Record<string, string> {
const accessToken = getAccessToken()
if (!accessToken) {
return {
'Content-Type': 'application/json',
'Accept': 'application/json'
}
}
return {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
}
}

View File

@ -0,0 +1,86 @@
<template>
<div class="relative rounded-lg border-2 border-gray-200 bg-gray-100 p-3">
<div class="select-all break-all text-xs text-gray-800">
<pre
:class="{
'whitespace-pre-wrap': breakLines,
'overflow-x-auto': !breakLines
}"
:style="
!breakLines
? 'scrollbar-width: none; -ms-overflow-style: none; -webkit-scrollbar: none;'
: ''
"
>{{ textContent }}</pre>
</div>
<button
class="absolute right-2 top-2 rounded-sm border border-gray-200 bg-white p-1 text-xs text-gray-600 hover:bg-gray-50"
@click="copyTextContentToClipboard"
>
{{ copied ? t('Copied') : t('Copy') }}
</button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useMessage } from 'naive-ui'
import { t } from '../i18n'
interface Props {
textContent: string
breakLines?: boolean
}
const props = withDefaults(defineProps<Props>(), {
breakLines: true
})
const message = useMessage()
const copied = ref(false)
async function copyTextContentToClipboard() {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(props.textContent)
showCopySuccess()
} else {
fallbackCopyTextToClipboard(props.textContent)
}
} catch (error) {
fallbackCopyTextToClipboard(props.textContent)
}
}
function fallbackCopyTextToClipboard(text: string) {
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
showCopySuccess()
} else {
message.error(t('Copy failed, please copy manually'))
}
} catch (err) {
message.error(t('Copy failed, please copy manually'))
} finally {
document.body.removeChild(textArea)
}
}
function showCopySuccess() {
copied.value = true
setTimeout(() => {
copied.value = false
}, 4000)
message.success(t('Copied to clipboard'))
}
</script>

View File

@ -2,6 +2,9 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { loginApi, getUserInfoApi, logoutApi, isCookieExpired, getSessionUser } from '../api/auth'
import { setInitializingAuth } from '../utils/fetchInterceptor'
import { getAccessToken, isTokenExpired, clearTokens } from '../utils/oauth2'
import { refreshAccessToken } from '../api/oauth2'
import { getAccountInfo } from '../api/account'
export interface User {
user: string
@ -15,7 +18,10 @@ export const useAuthStore = defineStore('auth', () => {
const loading = ref(false)
const isAuthenticated = ref(false)
const isLoggedIn = computed(() => isAuthenticated.value && !!user.value)
const isLoggedIn = computed(() => {
const hasToken = !!getAccessToken()
return isAuthenticated.value && !!user.value && hasToken
})
// 判断是否是认证错误
const isAuthError = (error: any): boolean => {
@ -68,11 +74,29 @@ export const useAuthStore = defineStore('auth', () => {
user.value = null
isAuthenticated.value = false
clearUserFromStorage()
// 清除 OAuth2 token
clearTokens()
}
// 验证并更新用户信息
const validateAndUpdateUser = async (): Promise<boolean> => {
try {
// 优先使用 OAuth2 token 获取账户信息
const accessToken = getAccessToken()
if (accessToken) {
const accountInfo = await getAccountInfo()
if (accountInfo.success && accountInfo.data) {
const userData = accountInfo.data.user || accountInfo.data
const userInfo: User = {
user: userData.name || userData.user || '',
user_type: userData.user_type || 'System User'
}
setUserState(userInfo)
return true
}
}
// 如果没有 OAuth2 token尝试使用 Cookie 认证(向后兼容)
const userInfo = await getUserInfoApi()
setUserState(userInfo)
return true
@ -85,7 +109,91 @@ export const useAuthStore = defineStore('auth', () => {
}
}
const login = async (username: string, password: string) => {
// OAuth2 登录:重定向到授权页面
const login = async (state?: string) => {
try {
const { getAuthorizationUrl } = await import('../api/oauth2')
const authUrl = await getAuthorizationUrl(state)
window.location.href = authUrl
} catch (error: any) {
console.error('OAuth2 登录错误:', error)
throw new Error(error.message || '启动 OAuth2 登录失败')
}
}
// 处理 OAuth2 回调
const handleOAuthCallback = async (): Promise<{ success: boolean; error?: string }> => {
try {
const { extractAuthorizationCode, extractOAuthError } = await import('../utils/oauth2')
const { exchangeCodeForToken } = await import('../api/oauth2')
// 调试:输出当前 URL
console.log('OAuth2 回调处理 - 当前 URL:', window.location.href)
console.log('OAuth2 回调处理 - URL 参数:', window.location.search)
// 检查是否有错误
const error = extractOAuthError()
if (error) {
console.error('OAuth2 授权错误:', error)
return {
success: false,
error: error.error_description || error.error || '授权失败'
}
}
// 获取授权码
const code = extractAuthorizationCode()
console.log('提取的授权码:', code ? '已找到' : '未找到')
if (!code) {
// 检查是否还在授权页面(可能还没有完成授权)
if (window.location.href.includes('/api/action/jingrow.integrations.oauth2.authorize')) {
console.warn('仍在授权页面,等待用户完成授权...')
return {
success: false,
error: '请先完成授权确认。如果已授权但仍停留在此页面,请检查后端 OAuth Client 的 redirect_uris 配置。'
}
}
// 检查是否有其他错误信息
const urlParams = new URLSearchParams(window.location.search)
const allParams = Object.fromEntries(urlParams.entries())
console.log('URL 中的所有参数:', allParams)
return {
success: false,
error: `未找到授权码。当前 URL: ${window.location.href}。请确保后端 OAuth Client 的 redirect_uris 包含: http://localhost:3100/oauth/callback`
}
}
// 用授权码换取 token
console.log('开始用授权码换取 token...')
await exchangeCodeForToken(code)
console.log('Token 交换成功')
// 验证并获取用户信息
console.log('开始验证并获取用户信息...')
const success = await validateAndUpdateUser()
if (success) {
console.log('用户信息获取成功')
return { success: true }
} else {
return {
success: false,
error: '获取用户信息失败'
}
}
} catch (error: any) {
console.error('处理 OAuth2 回调错误:', error)
return {
success: false,
error: error.message || '处理授权回调失败'
}
}
}
// 传统用户名密码登录(向后兼容)
const loginWithPassword = async (username: string, password: string) => {
loading.value = true
try {
const response = await loginApi(username, password)
@ -106,6 +214,16 @@ export const useAuthStore = defineStore('auth', () => {
const logout = async () => {
try {
// 尝试撤销 OAuth2 token
try {
const { revokeToken } = await import('../api/oauth2')
await revokeToken()
} catch (error) {
// OAuth2 撤销失败,继续执行登出
console.warn('撤销 OAuth2 token 失败:', error)
}
// 尝试使用 Cookie 登出(向后兼容)
await logoutApi()
} catch (error) {
console.error('登出错误:', error)
@ -118,20 +236,44 @@ export const useAuthStore = defineStore('auth', () => {
setInitializingAuth(true)
try {
// 检查cookie状态
const userId = getSessionUser()
const hasSessionCookie = !isCookieExpired()
const hasCookie = userId || hasSessionCookie
// 如果有cookie尝试验证并获取用户信息
if (hasCookie) {
// 优先检查 OAuth2 access token
const accessToken = getAccessToken()
if (accessToken) {
// 检查 token 是否过期,如果过期则尝试刷新
if (isTokenExpired()) {
try {
await refreshAccessToken()
} catch (error) {
console.error('刷新 token 失败:', error)
clearUserState()
setInitializingAuth(false)
return
}
}
// 使用 OAuth2 token 验证并获取用户信息
const success = await validateAndUpdateUser()
if (success) {
setInitializingAuth(false)
return
}
}
// 如果cookie验证失败或没有cookie尝试从localStorage恢复
// 如果没有 OAuth2 token尝试使用 Cookie 认证(向后兼容)
const userId = getSessionUser()
const hasSessionCookie = !isCookieExpired()
const hasCookie = userId || hasSessionCookie
if (hasCookie) {
const success = await validateAndUpdateUser()
if (success) {
setInitializingAuth(false)
return
}
}
// 如果都没有尝试从localStorage恢复
const storedUser = loadUserFromStorage()
if (storedUser) {
user.value = storedUser
@ -141,7 +283,7 @@ export const useAuthStore = defineStore('auth', () => {
// 静默失败保持localStorage中的状态
})
} else {
// 既没有cookie也没有localStorage清除认证状态
// 既没有token也没有cookie也没有localStorage清除认证状态
if (isAuthenticated.value) {
clearUserState()
}
@ -171,6 +313,8 @@ export const useAuthStore = defineStore('auth', () => {
isAuthenticated,
isLoggedIn,
login,
loginWithPassword,
handleOAuthCallback,
logout,
initAuth,
updateUserInfo

View File

@ -9,9 +9,59 @@ export function setInitializingAuth(value: boolean) {
isInitializingAuth = value
}
// 包装fetch函数添加401/403错误处理
// 包装fetch函数添加 Bearer token 和 401/403 错误处理
window.fetch = async function(...args: Parameters<typeof fetch>): Promise<Response> {
const response = await originalFetch(...args)
// 添加 Bearer token 到请求头
const [url, options = {}] = args
const headers = new Headers(options.headers)
// 如果是 API 请求且没有 Authorization 头,尝试添加 Bearer token
// 支持相对路径 (/api/) 和完整 URL (https://.../api/)
const isApiRequest = typeof url === 'string' && (
url.startsWith('/api/') ||
url.includes('/api/action/')
)
if (isApiRequest && !headers.has('Authorization')) {
try {
const { getAccessToken, isTokenExpired, refreshAccessToken } = await import('../api/oauth2')
const accessToken = getAccessToken()
if (accessToken) {
// 检查 token 是否过期,如果过期则尝试刷新
if (isTokenExpired()) {
try {
await refreshAccessToken()
const newToken = getAccessToken()
if (newToken) {
headers.set('Authorization', `Bearer ${newToken}`)
}
} catch (error) {
// 刷新失败,继续使用旧 token让后端返回 401
headers.set('Authorization', `Bearer ${accessToken}`)
}
} else {
headers.set('Authorization', `Bearer ${accessToken}`)
}
}
// 确保 Content-Type 和 Accept 头存在
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
if (!headers.has('Accept')) {
headers.set('Accept', 'application/json')
}
} catch (error) {
// 导入失败,忽略(可能是首次加载)
}
}
// 使用修改后的选项调用原始 fetch
const response = await originalFetch(url, {
...options,
headers
})
// 检查响应状态码仅在401/403时处理
if (response.status === 401 || response.status === 403) {
@ -28,7 +78,7 @@ window.fetch = async function(...args: Parameters<typeof fetch>): Promise<Respon
// 如果用户已登录,执行登出操作
if (authStore.isLoggedIn) {
console.warn('检测到401/403错误Cookie已过期,自动退出登录')
console.warn('检测到401/403错误Token已过期,自动退出登录')
await authStore.logout()
// 跳转到登录页(避免重复跳转)

173
src/shared/utils/oauth2.ts Normal file
View File

@ -0,0 +1,173 @@
/**
* OAuth2
* PKCE (Proof Key for Code Exchange) token
*/
// 获取 crypto 对象(兼容不同环境)
const getCrypto = () => {
if (typeof window !== 'undefined' && window.crypto) {
return window.crypto
}
if (typeof globalThis !== 'undefined' && globalThis.crypto) {
return globalThis.crypto
}
if (typeof crypto !== 'undefined') {
return crypto
}
throw new Error('crypto is not available in this environment')
}
// 生成随机字符串
function generateRandomString(length: number): string {
const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
const crypto = getCrypto()
const values = crypto.getRandomValues(new Uint8Array(length))
return values.reduce((acc, x) => acc + possible[x % possible.length], '')
}
// Base64 URL 编码
function base64URLEncode(str: string): string {
return btoa(str)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '')
}
// SHA-256 哈希
async function sha256(plain: string): Promise<string> {
const crypto = getCrypto()
// 检查 crypto.subtle 是否可用(需要 HTTPS 或 localhost
if (!crypto.subtle) {
throw new Error('crypto.subtle is not available. Please use HTTPS or localhost.')
}
const encoder = new TextEncoder()
const data = encoder.encode(plain)
const hashBuffer = await crypto.subtle.digest('SHA-256', data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return String.fromCharCode(...hashArray)
}
/**
* PKCE code verifier code challenge
*/
export async function generatePKCE(): Promise<{ codeVerifier: string; codeChallenge: string }> {
const codeVerifier = generateRandomString(128)
const codeChallenge = base64URLEncode(await sha256(codeVerifier))
return { codeVerifier, codeChallenge }
}
/**
* Token
*/
const ACCESS_TOKEN_KEY = 'oauth2_access_token'
const REFRESH_TOKEN_KEY = 'oauth2_refresh_token'
const TOKEN_EXPIRES_AT_KEY = 'oauth2_token_expires_at'
const CODE_VERIFIER_KEY = 'oauth2_code_verifier'
/**
* Token
*/
export interface TokenInfo {
access_token: string
refresh_token?: string
expires_in?: number
token_type?: string
scope?: string
}
/**
* access token
*/
export function storeAccessToken(token: string, expiresIn?: number): void {
sessionStorage.setItem(ACCESS_TOKEN_KEY, token)
if (expiresIn) {
const expiresAt = Date.now() + expiresIn * 1000
sessionStorage.setItem(TOKEN_EXPIRES_AT_KEY, expiresAt.toString())
}
}
/**
* access token
*/
export function getAccessToken(): string | null {
return sessionStorage.getItem(ACCESS_TOKEN_KEY)
}
/**
* refresh token
*/
export function storeRefreshToken(token: string): void {
localStorage.setItem(REFRESH_TOKEN_KEY, token)
}
/**
* refresh token
*/
export function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_TOKEN_KEY)
}
/**
* token
*/
export function isTokenExpired(): boolean {
const expiresAt = sessionStorage.getItem(TOKEN_EXPIRES_AT_KEY)
if (!expiresAt) return false // 如果没有过期时间,假设未过期
return Date.now() >= parseInt(expiresAt, 10)
}
/**
* token
*/
export function clearTokens(): void {
sessionStorage.removeItem(ACCESS_TOKEN_KEY)
sessionStorage.removeItem(TOKEN_EXPIRES_AT_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
sessionStorage.removeItem(CODE_VERIFIER_KEY)
}
/**
* code verifier PKCE
*/
export function storeCodeVerifier(verifier: string): void {
sessionStorage.setItem(CODE_VERIFIER_KEY, verifier)
}
/**
* code verifier
*/
export function getCodeVerifier(): string | null {
return sessionStorage.getItem(CODE_VERIFIER_KEY)
}
/**
* code verifier
*/
export function clearCodeVerifier(): void {
sessionStorage.removeItem(CODE_VERIFIER_KEY)
}
/**
* URL
*/
export function extractAuthorizationCode(): string | null {
const params = new URLSearchParams(window.location.search)
return params.get('code')
}
/**
* URL
*/
export function extractOAuthError(): { error: string; error_description?: string } | null {
const params = new URLSearchParams(window.location.search)
const error = params.get('error')
if (error) {
return {
error,
error_description: params.get('error_description') || undefined
}
}
return null
}

100
src/views/OAuthCallback.vue Normal file
View File

@ -0,0 +1,100 @@
<template>
<div class="oauth-callback-container">
<n-spin :show="loading" size="large">
<div class="callback-content">
<n-result
v-if="!loading && success"
status="success"
title="授权成功"
description="正在跳转..."
/>
<n-result
v-else-if="!loading && error"
status="error"
title="授权失败"
:description="error"
>
<template #footer>
<n-button type="primary" @click="goToLogin">返回登录</n-button>
</template>
</n-result>
<div v-else class="loading-text">
<n-icon size="48" :depth="3">
<Icon icon="tabler:loader-2" />
</n-icon>
<p>正在处理授权...</p>
</div>
</div>
</n-spin>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { NSpin, NResult, NButton, NIcon, useMessage } from 'naive-ui'
import { Icon } from '@iconify/vue'
import { useAuthStore } from '../shared/stores/auth'
const router = useRouter()
const message = useMessage()
const authStore = useAuthStore()
const loading = ref(true)
const success = ref(false)
const error = ref<string | null>(null)
const goToLogin = () => {
router.push('/login')
}
onMounted(async () => {
try {
const result = await authStore.handleOAuthCallback()
if (result.success) {
success.value = true
message.success('登录成功')
//
setTimeout(() => {
router.push('/')
}, 1000)
} else {
error.value = result.error || '授权失败'
loading.value = false
}
} catch (err: any) {
console.error('OAuth 回调处理错误:', err)
error.value = err.message || '处理授权回调时发生错误'
loading.value = false
}
})
</script>
<style scoped>
.oauth-callback-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #f5f5f5;
}
.callback-content {
width: 100%;
max-width: 500px;
padding: 24px;
}
.loading-text {
text-align: center;
padding: 48px;
}
.loading-text p {
margin-top: 16px;
font-size: 16px;
color: #666;
}
</style>

View File

@ -101,22 +101,12 @@ const rules = {
const handleLogin = async () => {
try {
await formRef.value?.validate()
loading.value = true
const result = await authStore.login(formData.username, formData.password)
if (result.success) {
message.success(t('Login successful'))
router.push('/')
} else {
message.error(result.error || t('Login failed'))
}
} catch (error) {
// 使 OAuth2
await authStore.login()
// login
} catch (error: any) {
console.error('Login error:', error)
message.error(t('Login failed, please check username and password'))
} finally {
loading.value = false
message.error(error.message || t('Login failed, please check username and password'))
}
}

View File

@ -0,0 +1,393 @@
<template>
<div class="developer-settings">
<n-space vertical :size="24">
<!-- API Access 卡片 -->
<n-card :title="t('API Access')" class="settings-card">
<n-space vertical :size="20">
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="text-base text-gray-700">
{{ t('API key and API secret can be used to access') }}
<a href="/docs/api" class="text-primary underline" target="_blank">
{{ t('Jingrow API') }}
</a>
</div>
<n-button type="primary" @click="showCreateSecretDialog = true" :size="buttonSize">
{{ apiKeyButtonLabel }}
</n-button>
</div>
<div v-if="apiKey">
<ClickToCopyField :textContent="apiKey" />
</div>
<div v-else class="text-base text-gray-600">
{{ t("You don't have an API key yet. Click the button above to create one.") }}
</div>
</n-space>
</n-card>
<!-- SSH Keys 卡片 -->
<n-card :title="t('SSH Keys')" class="settings-card">
<n-space vertical :size="16">
<n-button type="primary" @click="showAddSSHKeyDialog = true" :size="buttonSize">
<template #icon>
<n-icon><Icon icon="tabler:plus" /></n-icon>
</template>
{{ t('Add SSH Key') }}
</n-button>
<n-data-table
v-if="sshKeys.length > 0"
:columns="sshKeyColumns"
:data="sshKeys"
:loading="loadingSSHKeys"
:pagination="false"
/>
<n-empty v-else :description="t('No SSH keys')" />
</n-space>
</n-card>
</n-space>
<!-- API Key 创建/重新生成弹窗 -->
<n-modal
v-model:show="showCreateSecretDialog"
preset="card"
:title="t('API Access')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
>
<template #header>
<span class="text-lg font-semibold">{{ t('API Access') }}</span>
</template>
<n-space vertical :size="20">
<div v-if="apiSecretData">
<n-alert type="warning" class="mb-4">
{{ t('Please copy the API key immediately. You will not be able to view it again!') }}
</n-alert>
<n-form-item :label="t('API Key')">
<ClickToCopyField :textContent="apiSecretData.api_key" />
</n-form-item>
<n-form-item :label="t('API Secret')">
<ClickToCopyField :textContent="apiSecretData.api_secret" />
</n-form-item>
</div>
<div v-else class="text-base text-gray-700">
{{ t('API key and API secret can be used to access') }}
<a href="/docs/api" class="text-primary underline" target="_blank">
{{ t('Jingrow API') }}
</a>.
</div>
</n-space>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button @click="handleCloseCreateSecretDialog" :block="isMobile" :size="buttonSize">
{{ t('Cancel') }}
</n-button>
<n-button
type="primary"
:loading="creatingSecret"
:disabled="!!apiSecretData"
@click="handleCreateSecret"
:block="isMobile"
:size="buttonSize"
>
{{ apiKey ? t('Regenerate API Key') : t('Create New API Key') }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- SSH Key 添加弹窗 -->
<n-modal
v-model:show="showAddSSHKeyDialog"
preset="card"
:title="t('Add New SSH Key')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
>
<template #header>
<span class="text-lg font-semibold">{{ t('Add New SSH Key') }}</span>
</template>
<n-space vertical :size="20">
<p class="text-base text-gray-700">{{ t('Add a new SSH key to your account') }}</p>
<n-form-item :label="t('SSH Key')" :required="true">
<n-input
v-model:value="sshKeyValue"
type="textarea"
:placeholder="sshKeyPlaceholder"
:rows="6"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-alert v-if="sshKeyError" type="error" :title="sshKeyError" />
</n-space>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button @click="showAddSSHKeyDialog = false" :block="isMobile" :size="buttonSize">
{{ t('Cancel') }}
</n-button>
<n-button
type="primary"
:loading="addingSSHKey"
@click="handleAddSSHKey"
:block="isMobile"
:size="buttonSize"
>
{{ t('Add SSH Key') }}
</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'
import {
NCard,
NSpace,
NButton,
NModal,
NFormItem,
NInput,
NAlert,
NIcon,
NDataTable,
NEmpty,
useMessage,
useDialog,
type DataTableColumns
} from 'naive-ui'
import { Icon } from '@iconify/vue'
import { t } from '../../shared/i18n'
import ClickToCopyField from '../../shared/components/ClickToCopyField.vue'
import {
getAccountInfo,
createApiSecret,
getUserSSHKeys,
addSSHKey,
deleteSSHKey,
setSSHKeyAsDefault
} from '../../shared/api/account'
const message = useMessage()
const dialog = useDialog()
const apiKey = ref('')
const apiSecretData = ref<{ api_key: string; api_secret: string } | null>(null)
const sshKeys = ref<any[]>([])
const sshKeyValue = ref('')
const sshKeyError = ref('')
const showCreateSecretDialog = ref(false)
const showAddSSHKeyDialog = ref(false)
const creatingSecret = ref(false)
const addingSSHKey = ref(false)
const loadingSSHKeys = ref(false)
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value <= 768)
const modalStyle = computed(() => ({
width: isMobile.value ? '95vw' : '700px',
maxWidth: isMobile.value ? '95vw' : '90vw'
}))
const inputSize = computed(() => isMobile.value ? 'medium' : 'large')
const buttonSize = computed(() => isMobile.value ? 'medium' : 'medium')
const apiKeyButtonLabel = computed(() => {
return apiKey.value ? t('Regenerate API Key') : t('Create New API Key')
})
const sshKeyPlaceholder = computed(() => {
return t("Starts with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'")
})
const sshKeyColumns: DataTableColumns<any> = [
{
title: t('SSH Fingerprint'),
key: 'ssh_fingerprint',
render: (row) => {
return h('span', { class: 'font-mono' }, `SHA256:${row.ssh_fingerprint}`)
}
},
{
title: t('Added Time'),
key: 'creation',
width: 200,
render: (row) => {
return new Date(row.creation).toLocaleDateString()
}
},
{
title: t('Actions'),
key: 'actions',
width: 200,
render: (row) => {
return h('div', { class: 'flex gap-2' }, [
!row.is_default ? h(NButton, {
size: 'small',
onClick: () => handleSetDefault(row.name)
}, { default: () => t('Set as Default') }) : null,
h(NButton, {
size: 'small',
type: 'error',
onClick: () => handleDeleteSSHKey(row.name)
}, { default: () => t('Delete') })
])
}
}
]
const loadAccountInfo = async () => {
try {
const result = await getAccountInfo()
if (result.success && result.data) {
apiKey.value = result.data.user?.api_key || result.data.team?.user_info?.api_key || ''
}
} catch (error: any) {
console.error('加载账户信息失败:', error)
}
}
const loadSSHKeys = async () => {
loadingSSHKeys.value = true
try {
const result = await getUserSSHKeys()
if (result.success && result.data) {
sshKeys.value = result.data || []
}
} catch (error: any) {
console.error('加载 SSH 密钥失败:', error)
message.error(error.message || t('Failed to load SSH keys'))
} finally {
loadingSSHKeys.value = false
}
}
const handleCreateSecret = async () => {
creatingSecret.value = true
try {
const result = await createApiSecret()
if (result.success && result.data) {
apiSecretData.value = {
api_key: result.data.api_key,
api_secret: result.data.api_secret
}
apiKey.value = result.data.api_key
message.success(apiKey.value ? t('API key regenerated successfully') : t('API key created successfully'))
await loadAccountInfo()
} else {
message.error(result.message || t('Failed to create API key'))
}
} catch (error: any) {
message.error(error.message || t('Failed to create API key'))
} finally {
creatingSecret.value = false
}
}
const handleCloseCreateSecretDialog = () => {
showCreateSecretDialog.value = false
apiSecretData.value = null
}
const handleAddSSHKey = async () => {
if (!sshKeyValue.value) {
sshKeyError.value = t('SSH key is required')
return
}
// SSH
const sshKeyPatterns = [
/^ssh-rsa /,
/^ecdsa-sha2-nistp256 /,
/^ecdsa-sha2-nistp384 /,
/^ecdsa-sha2-nistp521 /,
/^ssh-ed25519 /,
/^sk-ecdsa-sha2-nistp256@openssh\.com /,
/^sk-ssh-ed25519@openssh\.com /
]
const isValid = sshKeyPatterns.some(pattern => pattern.test(sshKeyValue.value))
if (!isValid) {
sshKeyError.value = t('Invalid SSH key format')
return
}
sshKeyError.value = ''
addingSSHKey.value = true
try {
const result = await addSSHKey(sshKeyValue.value)
if (result.success) {
message.success(result.message || t('SSH key added successfully'))
showAddSSHKeyDialog.value = false
sshKeyValue.value = ''
await loadSSHKeys()
} else {
sshKeyError.value = result.message || t('Failed to add SSH key')
}
} catch (error: any) {
sshKeyError.value = error.message || t('Failed to add SSH key')
} finally {
addingSSHKey.value = false
}
}
const handleSetDefault = async (keyName: string) => {
try {
const result = await setSSHKeyAsDefault(keyName)
if (result.success) {
message.success(result.message || t('SSH key updated successfully'))
await loadSSHKeys()
} else {
message.error(result.message || t('Failed to set SSH key as default'))
}
} catch (error: any) {
message.error(error.message || t('Failed to set SSH key as default'))
}
}
const handleDeleteSSHKey = (keyName: string) => {
dialog.warning({
title: t('Delete SSH Key'),
content: t('Are you sure you want to delete this SSH key?'),
positiveText: t('Delete'),
negativeText: t('Cancel'),
positiveButtonProps: { type: 'error' },
onPositiveClick: async () => {
try {
const result = await deleteSSHKey(keyName)
if (result.success) {
message.success(result.message || t('SSH key deleted successfully'))
await loadSSHKeys()
} else {
message.error(result.message || t('Failed to delete SSH key'))
}
} catch (error: any) {
message.error(error.message || t('Failed to delete SSH key'))
}
}
})
}
onMounted(() => {
loadAccountInfo()
loadSSHKeys()
window.addEventListener('resize', () => {
windowWidth.value = window.innerWidth
})
})
</script>
<style scoped>
.developer-settings {
width: 100%;
}
.settings-card {
margin-bottom: 24px;
}
</style>

View File

@ -0,0 +1,665 @@
<template>
<div class="profile-settings">
<n-space vertical :size="24">
<!-- 个人资料卡片 -->
<n-card :title="t('Profile')" class="profile-card">
<n-space vertical :size="20">
<div class="profile-header">
<div class="profile-avatar-wrapper">
<n-avatar
:size="avatarSize"
:src="userProfile?.user_image"
round
class="profile-avatar"
>
{{ userInitial }}
</n-avatar>
<n-upload
:show-file-list="false"
:custom-request="handleAvatarUpload"
accept="image/*"
>
<n-button
tertiary
circle
size="small"
class="avatar-edit-btn"
:loading="uploadingAvatar"
>
<template #icon>
<n-icon><Icon icon="tabler:edit" /></n-icon>
</template>
</n-button>
</n-upload>
</div>
<div class="profile-info">
<h2 class="profile-name">{{ displayName }}</h2>
<div class="profile-details">
<div class="profile-detail-item">
<span class="profile-detail-label">{{ t('Username') }}:</span>
<span class="profile-detail-value">{{ userProfile?.username || userProfile?.user || '-' }}</span>
</div>
<div class="profile-detail-item">
<span class="profile-detail-label">{{ t('Phone') }}:</span>
<span class="profile-detail-value">{{ userPhone }}</span>
</div>
<div class="profile-detail-item">
<span class="profile-detail-label">{{ t('Email') }}:</span>
<span class="profile-detail-value">{{ userEmail }}</span>
</div>
</div>
</div>
<div class="profile-actions">
<n-button type="primary" @click="showProfileEditDialog = true" :block="isMobile">
<template #icon>
<n-icon><Icon icon="tabler:edit" /></n-icon>
</template>
{{ t('Edit') }}
</n-button>
</div>
</div>
</n-space>
</n-card>
<!-- 功能设置卡片 -->
<n-card class="settings-card">
<n-list>
<n-list-item v-if="!userProfile?.is_developer">
<n-thing>
<template #header>
<span class="text-base font-medium">{{ t('Marketplace Developer') }}</span>
</template>
<template #description>
<span class="text-sm text-gray-600">
{{ t('Developers can publish their apps on the marketplace for users to subscribe to, either paid or free.') }}
</span>
</template>
<template #action>
<n-button type="primary" @click="handleBecomeDeveloper" :loading="becomingDeveloper">
{{ t('Become a Developer') }}
</n-button>
</template>
</n-thing>
</n-list-item>
<n-list-item>
<n-thing>
<template #header>
<span class="text-base font-medium">{{ t('Reset Password') }}</span>
</template>
<template #description>
<span class="text-sm text-gray-600">{{ t('Change your account login password') }}</span>
</template>
<template #action>
<n-button @click="showResetPasswordDialog = true">
{{ t('Reset Password') }}
</n-button>
</template>
</n-thing>
</n-list-item>
<n-list-item>
<n-thing>
<template #header>
<span class="text-base font-medium">{{ accountStatusTitle }}</span>
</template>
<template #description>
<span class="text-sm text-gray-600">{{ accountStatusSubtitle }}</span>
</template>
<template #action>
<n-button
:type="accountEnabled ? 'error' : 'primary'"
@click="handleAccountStatus"
:loading="changingAccountStatus"
>
{{ accountStatusButtonLabel }}
</n-button>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-card>
</n-space>
<!-- 编辑资料对话框 -->
<n-modal
v-model:show="showProfileEditDialog"
preset="card"
:title="t('Update Profile Information')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
>
<template #header>
<span class="text-lg font-semibold">{{ t('Update Profile Information') }}</span>
</template>
<n-form :model="profileForm" label-placement="top" class="mt-4">
<n-space vertical :size="20">
<n-form-item :label="t('First Name')">
<n-input
v-model:value="profileForm.first_name"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-form-item :label="t('Last Name')">
<n-input
v-model:value="profileForm.last_name"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-form-item :label="t('Username')">
<n-input
v-model:value="profileForm.username"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-form-item :label="t('Phone')">
<n-input
v-model:value="profileForm.mobile_no"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-form-item :label="t('Email')">
<n-input
v-model:value="profileForm.email"
type="email"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-alert
v-if="updateError"
type="error"
:title="updateError"
/>
</n-space>
</n-form>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button
@click="showProfileEditDialog = false"
:block="isMobile"
:size="buttonSize"
>
{{ t('Cancel') }}
</n-button>
<n-button
type="primary"
@click="handleSaveProfile"
:loading="updatingProfile"
:block="isMobile"
:size="buttonSize"
>
{{ t('Save Changes') }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- 重置密码对话框 -->
<n-modal
v-model:show="showResetPasswordDialog"
preset="card"
:title="t('Reset Password')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
>
<template #header>
<span class="text-lg font-semibold">{{ t('Reset Password') }}</span>
</template>
<n-form :model="passwordForm" label-placement="top" class="mt-4">
<n-space vertical :size="20">
<n-form-item :label="t('Current Password')">
<n-input
v-model:value="passwordForm.old_password"
type="password"
show-password-on="click"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-form-item :label="t('New Password')">
<n-input
v-model:value="passwordForm.new_password"
type="password"
show-password-on="click"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-alert
v-if="passwordError"
type="error"
:title="passwordError"
/>
</n-space>
</n-form>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button
@click="showResetPasswordDialog = false"
:block="isMobile"
:size="buttonSize"
>
{{ t('Cancel') }}
</n-button>
<n-button
type="primary"
@click="handleResetPassword"
:loading="resettingPassword"
:block="isMobile"
:size="buttonSize"
>
{{ t('Reset Password') }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- 禁用账户对话框 -->
<n-modal
v-model:show="showDisableAccountDialog"
preset="dialog"
:title="t('Disable Account')"
:positive-text="t('Disable')"
:positive-button-props="{ type: 'error' }"
:loading="changingAccountStatus"
:mask-closable="true"
:close-on-esc="true"
@positive-click="handleDisableAccount"
>
<div class="py-4">
<p class="text-base mb-4">{{ t('After confirming this action:') }}</p>
<ul class="list-disc list-inside space-y-2 text-sm text-gray-700 mb-4">
<li>{{ t('Your account will be disabled') }}</li>
<li>{{ t('Your account billing will stop') }}</li>
</ul>
<p class="text-base mb-4">{{ t('You can log in later to re-enable your account. Do you want to continue?') }}</p>
<n-alert
v-if="accountError"
type="error"
class="mt-4"
:title="accountError"
/>
</div>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
NCard,
NSpace,
NButton,
NAvatar,
NList,
NListItem,
NThing,
NModal,
NForm,
NFormItem,
NInput,
NAlert,
NIcon,
NUpload,
useMessage,
useDialog
} from 'naive-ui'
import { Icon } from '@iconify/vue'
import { t } from '../../shared/i18n'
import { useAuthStore } from '../../shared/stores/auth'
import {
getAccountInfo,
updateProfile,
updateProfilePicture,
resetPassword,
disableAccount,
enableAccount,
becomeDeveloper,
type UserProfile
} from '../../shared/api/account'
const message = useMessage()
const dialog = useDialog()
const authStore = useAuthStore()
const userProfile = ref<UserProfile | null>(null)
const profileForm = ref({
first_name: '',
last_name: '',
username: '',
email: '',
mobile_no: ''
})
const passwordForm = ref({
old_password: '',
new_password: ''
})
const showProfileEditDialog = ref(false)
const showResetPasswordDialog = ref(false)
const showDisableAccountDialog = ref(false)
const updatingProfile = ref(false)
const resettingPassword = ref(false)
const changingAccountStatus = ref(false)
const becomingDeveloper = ref(false)
const uploadingAvatar = ref(false)
const updateError = ref('')
const passwordError = ref('')
const accountError = ref('')
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value <= 768)
const avatarSize = computed(() => isMobile.value ? 80 : 96)
const modalStyle = computed(() => ({
width: isMobile.value ? '95vw' : '800px',
maxWidth: isMobile.value ? '95vw' : '90vw'
}))
const inputSize = computed(() => isMobile.value ? 'medium' : 'large')
const buttonSize = computed(() => isMobile.value ? 'medium' : 'medium')
const displayName = computed(() => {
if (userProfile.value?.first_name || userProfile.value?.last_name) {
return `${userProfile.value.first_name || ''} ${userProfile.value.last_name || ''}`.trim()
}
return userProfile.value?.username || userProfile.value?.user || 'User'
})
const userInitial = computed(() => {
return displayName.value.charAt(0).toUpperCase()
})
const userPhone = computed(() => {
return userProfile.value?.mobile_no || userProfile.value?.phone || '-'
})
const userEmail = computed(() => {
return userProfile.value?.email || '-'
})
const accountEnabled = ref(true)
const accountStatusTitle = computed(() => {
return accountEnabled.value ? t('Disable Account') : t('Enable Account')
})
const accountStatusSubtitle = computed(() => {
return accountEnabled.value
? t('Disable your account and stop billing')
: t('Enable your account and resume billing')
})
const accountStatusButtonLabel = computed(() => {
return accountEnabled.value ? t('Disable') : t('Enable')
})
const loadUserProfile = async () => {
try {
const result = await getAccountInfo()
if (result.success && result.data) {
const userData = result.data.user || result.data
userProfile.value = {
user: userData.name || userData.user || '',
username: userData.username,
email: userData.email,
mobile_no: userData.mobile_no,
first_name: userData.first_name,
last_name: userData.last_name,
user_type: userData.user_type,
user_image: userData.user_image,
is_developer: result.data.team?.is_developer
}
profileForm.value = {
first_name: userData.first_name || '',
last_name: userData.last_name || '',
username: userData.username || '',
email: userData.email || '',
mobile_no: userData.mobile_no || ''
}
accountEnabled.value = result.data.team?.enabled !== false
}
} catch (error: any) {
console.error('加载用户资料失败:', error)
message.error(error.message || t('Failed to load user profile'))
}
}
const handleSaveProfile = async () => {
updateError.value = ''
updatingProfile.value = true
try {
const result = await updateProfile(profileForm.value)
if (result.success) {
message.success(result.message || t('Your profile has been updated successfully'))
showProfileEditDialog.value = false
await loadUserProfile()
await authStore.updateUserInfo()
} else {
updateError.value = result.message || t('Failed to update profile')
}
} catch (error: any) {
updateError.value = error.message || t('Failed to update profile')
} finally {
updatingProfile.value = false
}
}
const handleResetPassword = async () => {
passwordError.value = ''
if (!passwordForm.value.old_password || !passwordForm.value.new_password) {
passwordError.value = t('Please enter both current and new password')
return
}
resettingPassword.value = true
try {
const result = await resetPassword(
passwordForm.value.old_password,
passwordForm.value.new_password
)
if (result.success) {
message.success(result.message || t('Password reset successfully'))
showResetPasswordDialog.value = false
passwordForm.value = { old_password: '', new_password: '' }
} else {
passwordError.value = result.message || t('Failed to reset password')
}
} catch (error: any) {
passwordError.value = error.message || t('Failed to reset password')
} finally {
resettingPassword.value = false
}
}
const handleAccountStatus = () => {
if (accountEnabled.value) {
showDisableAccountDialog.value = true
} else {
handleEnableAccount()
}
}
const handleDisableAccount = async () => {
accountError.value = ''
changingAccountStatus.value = true
try {
const result = await disableAccount()
if (result.success) {
message.success(result.message || t('Your account has been disabled successfully'))
accountEnabled.value = false
showDisableAccountDialog.value = false
} else {
accountError.value = result.message || t('Failed to disable account')
}
} catch (error: any) {
accountError.value = error.message || t('Failed to disable account')
} finally {
changingAccountStatus.value = false
}
}
const handleEnableAccount = async () => {
changingAccountStatus.value = true
try {
const result = await enableAccount()
if (result.success) {
message.success(result.message || t('Your account has been enabled successfully'))
accountEnabled.value = true
} else {
message.error(result.message || t('Failed to enable account'))
}
} catch (error: any) {
message.error(error.message || t('Failed to enable account'))
} finally {
changingAccountStatus.value = false
}
}
const handleBecomeDeveloper = () => {
dialog.warning({
title: t('Become a Marketplace Developer?'),
content: t('After confirmation, you will be able to publish apps to our marketplace.'),
positiveText: t('Confirm'),
negativeText: t('Cancel'),
onPositiveClick: async () => {
becomingDeveloper.value = true
try {
const result = await becomeDeveloper()
if (result.success) {
message.success(result.message || t('You can now publish apps to our marketplace'))
await loadUserProfile()
} else {
message.error(result.message || t('Failed to mark you as a developer'))
}
} catch (error: any) {
message.error(error.message || t('Failed to mark you as a developer'))
} finally {
becomingDeveloper.value = false
}
}
})
}
const handleAvatarUpload = async (options: { file: File }) => {
uploadingAvatar.value = true
try {
const result = await updateProfilePicture(options.file)
if (result.success) {
message.success(result.message || t('Profile picture updated successfully'))
await loadUserProfile()
await authStore.updateUserInfo()
} else {
message.error(result.message || t('Failed to update profile picture'))
}
} catch (error: any) {
message.error(error.message || t('Failed to update profile picture'))
} finally {
uploadingAvatar.value = false
}
}
onMounted(() => {
loadUserProfile()
window.addEventListener('resize', () => {
windowWidth.value = window.innerWidth
})
})
</script>
<style scoped>
.profile-settings {
width: 100%;
}
.profile-header {
display: flex;
align-items: flex-start;
gap: 24px;
flex-wrap: wrap;
}
.profile-avatar-wrapper {
position: relative;
flex-shrink: 0;
}
.profile-avatar {
border: 2px solid #e5e7eb;
}
.avatar-edit-btn {
position: absolute;
bottom: 0;
right: 0;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.profile-info {
flex: 1;
min-width: 200px;
}
.profile-name {
font-size: 24px;
font-weight: 600;
color: #1f2937;
margin: 0 0 16px 0;
}
.profile-details {
display: flex;
flex-direction: column;
gap: 8px;
}
.profile-detail-item {
display: flex;
gap: 8px;
}
.profile-detail-label {
font-weight: 500;
color: #6b7280;
min-width: 80px;
}
.profile-detail-value {
color: #1f2937;
}
.profile-actions {
flex-shrink: 0;
}
.settings-card {
margin-top: 24px;
}
@media (max-width: 768px) {
.profile-header {
flex-direction: column;
align-items: center;
text-align: center;
}
.profile-info {
width: 100%;
}
.profile-actions {
width: 100%;
}
}
</style>

View File

@ -4,413 +4,53 @@
<h1 class="page-title">{{ t('Settings') }}</h1>
</div>
<n-grid :cols="2" :x-gap="24" :y-gap="24">
<!-- 左栏系统设置 -->
<n-grid-item>
<n-card :title="t('System Settings')">
<n-form :model="systemSettings" label-placement="left" label-width="120px">
<n-form-item :label="t('App Name')">
<n-input v-model:value="systemSettings.appName" :placeholder="t('Enter app name')" />
</n-form-item>
<n-form-item :label="t('Interface Language')">
<n-select
v-model:value="systemSettings.language"
:options="languageOptions"
style="width: 200px"
@update:value="changeLanguage"
/>
</n-form-item>
<n-form-item :label="t('Items Per Page')">
<n-select
v-model:value="systemSettings.itemsPerPage"
:options="pageSizeOptions"
style="width: 120px"
/>
</n-form-item>
<n-form-item :label="t('Timezone')">
<n-alert v-if="timezoneError" type="error" style="margin-bottom: 8px">
{{ timezoneError }}
</n-alert>
<n-select
v-model:value="systemSettings.timezone"
:options="timezoneOptions"
style="width: 250px"
filterable
:placeholder="t('Select timezone')"
:disabled="timezoneOptions.length === 0"
/>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="start">
<n-button type="primary" class="save-btn-brand" @click="saveSystemSettings">
<template #icon>
<n-icon><Icon icon="tabler:check" /></n-icon>
</template>
{{ t('Save') }}
</n-button>
</n-space>
</template>
</n-card>
</n-grid-item>
<!-- 右栏环境配置仅系统管理员可见 -->
<n-grid-item v-if="isAdmin">
<n-card :title="t('Environment Configuration')">
<n-alert type="warning" style="margin-bottom: 16px">
{{ t('Only system administrators can view and edit environment configuration') }}
</n-alert>
<n-form
:model="envConfig"
label-placement="left"
label-width="180px"
:loading="envConfigLoading"
>
<n-collapse>
<n-collapse-item name="jingrow" :title="t('Jingrow API Configuration')">
<n-form-item :label="t('Jingrow Server URL')">
<n-input v-model:value="envConfig.jingrow_server_url" placeholder="https://example.jingrow.com" />
</n-form-item>
<n-form-item :label="t('Jingrow API Key')">
<n-input v-model:value="envConfig.jingrow_api_key" type="password" show-password-on="click" />
</n-form-item>
<n-form-item :label="t('Jingrow API Secret')">
<n-input v-model:value="envConfig.jingrow_api_secret" type="password" show-password-on="click" />
</n-form-item>
</n-collapse-item>
<n-collapse-item name="cloud" :title="t('Jingrow Cloud Configuration')">
<n-form-item :label="t('Cloud URL')">
<n-input v-model:value="envConfig.jingrow_cloud_url" />
</n-form-item>
<n-form-item :label="t('Cloud API URL')">
<n-input v-model:value="envConfig.jingrow_cloud_api_url" />
</n-form-item>
<n-form-item :label="t('Cloud API Key')">
<n-input v-model:value="envConfig.jingrow_cloud_api_key" type="password" show-password-on="click" />
</n-form-item>
<n-form-item :label="t('Cloud API Secret')">
<n-input v-model:value="envConfig.jingrow_cloud_api_secret" type="password" show-password-on="click" />
</n-form-item>
</n-collapse-item>
<n-collapse-item v-if="isLocalMode" name="database" :title="t('Database Configuration')">
<n-form-item :label="t('DB Host')">
<n-input v-model:value="envConfig.jingrow_db_host" />
</n-form-item>
<n-form-item :label="t('DB Port')">
<n-input v-model:value="envConfig.jingrow_db_port" />
</n-form-item>
<n-form-item :label="t('DB Name')">
<n-input v-model:value="envConfig.jingrow_db_name" />
</n-form-item>
<n-form-item :label="t('DB User')">
<n-input v-model:value="envConfig.jingrow_db_user" />
</n-form-item>
<n-form-item :label="t('DB Password')">
<n-input v-model:value="envConfig.jingrow_db_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item :label="t('DB Type')">
<n-select v-model:value="envConfig.jingrow_db_type" :options="dbTypeOptions" style="width: 200px" />
</n-form-item>
</n-collapse-item>
<n-collapse-item name="backend" :title="t('Backend Configuration')">
<n-form-item :label="t('Backend Host')">
<n-input v-model:value="envConfig.backend_host" />
</n-form-item>
<n-form-item :label="t('Backend Port')">
<n-input-number v-model:value="envConfig.backend_port" :min="1" :max="65535" />
</n-form-item>
<n-form-item :label="t('Backend Reload')">
<n-switch v-model:value="envConfig.backend_reload" />
</n-form-item>
</n-collapse-item>
<n-collapse-item name="dramatiq" :title="t('Dramatiq')">
<n-form-item :label="t('Worker Processes')">
<n-input-number v-model:value="envConfig.worker_processes" :min="1" :max="32" />
</n-form-item>
<n-form-item :label="t('Worker Threads')">
<n-input-number v-model:value="envConfig.worker_threads" :min="1" :max="32" />
</n-form-item>
<n-form-item :label="t('Watch')">
<n-switch v-model:value="envConfig.watch" />
</n-form-item>
</n-collapse-item>
<n-collapse-item name="qdrant" :title="t('Qdrant Configuration')">
<n-form-item :label="t('Qdrant Host')">
<n-input v-model:value="envConfig.qdrant_host" />
</n-form-item>
<n-form-item :label="t('Qdrant Port')">
<n-input-number v-model:value="envConfig.qdrant_port" :min="1" :max="65535" />
</n-form-item>
</n-collapse-item>
<n-collapse-item name="runtime" :title="t('Other')">
<n-form-item :label="t('Run Mode')">
<n-select v-model:value="envConfig.run_mode" :options="runModeOptions" style="width: 200px" />
</n-form-item>
<n-form-item :label="t('Environment')">
<n-select v-model:value="envConfig.environment" :options="environmentOptions" style="width: 200px" />
</n-form-item>
<n-form-item :label="t('Log Level')">
<n-select v-model:value="envConfig.log_level" :options="logLevelOptions" style="width: 200px" />
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-form>
<template #footer>
<n-space justify="start">
<n-button type="default" @click="() => loadEnvironmentConfig()" :loading="envConfigLoading">
<template #icon>
<n-icon><Icon icon="tabler:refresh" /></n-icon>
</template>
{{ t('Refresh') }}
</n-button>
<n-button type="warning" :loading="envConfigRestarting" @click="handleRestartEnvironment">
<template #icon>
<n-icon><Icon icon="ix:restart" /></n-icon>
</template>
{{ t('Restart Environment') }}
</n-button>
<n-button type="primary" class="save-btn-brand" :loading="envConfigSaving" @click="saveEnvironmentConfig">
<template #icon>
<n-icon><Icon icon="tabler:check" /></n-icon>
</template>
{{ t('Save') }}
</n-button>
</n-space>
</template>
</n-card>
</n-grid-item>
</n-grid>
<n-tabs v-model:value="activeTab" type="line" animated @update:value="handleTabChange">
<n-tab-pane name="profile">
<template #tab>
<n-icon style="margin-right: 4px"><Icon icon="tabler:user" /></n-icon>
{{ t('Profile') }}
</template>
<router-view />
</n-tab-pane>
<n-tab-pane name="developer">
<template #tab>
<n-icon style="margin-right: 4px"><Icon icon="tabler:code" /></n-icon>
{{ t('Developer') }}
</template>
<router-view />
</n-tab-pane>
</n-tabs>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, computed, ref } from 'vue'
import {
NGrid,
NGridItem,
NCard,
NForm,
NFormItem,
NInput,
NButton,
NInputNumber,
NSelect,
NSwitch,
NAlert,
NCollapse,
NCollapseItem,
NSpace,
NIcon,
useMessage,
useDialog
} from 'naive-ui'
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NTabs, NTabPane, NIcon } from 'naive-ui'
import { Icon } from '@iconify/vue'
import { getCurrentLocale, setLocale, locales, initLocale, t } from '../../shared/i18n'
import { useAuthStore } from '../../shared/stores/auth'
import { getEnvironmentConfig, updateEnvironmentConfig, restartEnvironment, type EnvironmentConfig } from '../../shared/api/system'
import { getCurrentTimezone, getGroupedTimezoneOptions } from '../../shared/utils/timezone'
import { t } from '../../shared/i18n'
const message = useMessage()
const dialog = useDialog()
const authStore = useAuthStore()
const route = useRoute()
const router = useRouter()
//
const isAdmin = computed(() => {
const user = authStore.user
return user?.username === 'Administrator' || user?.id === 'Administrator'
const activeTab = computed({
get: () => {
if (route.name === 'SettingsProfile') return 'profile'
if (route.name === 'SettingsDeveloper') return 'developer'
return 'profile' //
},
set: (value: string) => {
if (value === 'profile') {
router.push({ name: 'SettingsProfile' })
} else if (value === 'developer') {
router.push({ name: 'SettingsDeveloper' })
}
}
})
// local
const isLocalMode = computed(() => {
return envConfig.run_mode === 'local'
})
//
const envConfig = reactive<Partial<EnvironmentConfig>>({})
const envConfigLoading = ref(false)
const envConfigSaving = ref(false)
const envConfigRestarting = ref(false)
const systemSettings = reactive({
appName: localStorage.getItem('appName') || 'Jingrow',
language: getCurrentLocale(),
itemsPerPage: parseInt(localStorage.getItem('itemsPerPage') || '10'),
timezone: getCurrentTimezone()
})
//
const languageOptions = locales.map(locale => ({
label: `${locale.flag} ${locale.name}`,
value: locale.code
}))
//
const pageSizeOptions = [
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 },
{ label: '100', value: 100 }
]
// - 使 IANA UTC
const timezoneOptions = ref<Array<{ type: string; label: string; key: string; children: Array<{ label: string; value: string }> }>>([])
const timezoneError = ref<string | null>(null)
//
const dbTypeOptions = [
{ label: 'MariaDB', value: 'mariadb' },
{ label: 'MySQL', value: 'mysql' },
{ label: 'PostgreSQL', value: 'postgresql' }
]
//
const runModeOptions = [
{ label: 'API', value: 'api' },
{ label: 'Local', value: 'local' }
]
//
const environmentOptions = [
{ label: 'Development', value: 'development' },
{ label: 'Production', value: 'production' }
]
//
const logLevelOptions = [
{ label: 'DEBUG', value: 'DEBUG' },
{ label: 'INFO', value: 'INFO' },
{ label: 'WARNING', value: 'WARNING' },
{ label: 'ERROR', value: 'ERROR' },
{ label: 'CRITICAL', value: 'CRITICAL' }
]
const changeLanguage = (locale: string) => {
setLocale(locale)
message.success(t('Language updated'))
const handleTabChange = (value: string) => {
activeTab.value = value
}
const saveSystemSettings = () => {
//
localStorage.setItem('appName', systemSettings.appName)
//
localStorage.setItem('itemsPerPage', systemSettings.itemsPerPage.toString())
//
localStorage.setItem('timezone', systemSettings.timezone)
message.success(t('System settings saved'))
//
setTimeout(() => {
window.location.reload()
}, 1000)
}
//
const loadEnvironmentConfig = async (showMessage = true) => {
if (!isAdmin.value) {
return
}
envConfigLoading.value = true
try {
const result = await getEnvironmentConfig()
if (result.success && result.data) {
Object.assign(envConfig, result.data)
if (showMessage) {
message.success(t('Environment configuration loaded'))
}
} else {
if (showMessage) {
message.error(result.message || t('Failed to load environment configuration'))
}
}
} catch (error: any) {
if (showMessage) {
message.error(error.message || t('Failed to load environment configuration'))
}
} finally {
envConfigLoading.value = false
}
}
//
const saveEnvironmentConfig = async () => {
if (!isAdmin.value) {
message.error(t('Only system administrators can edit environment configuration'))
return
}
envConfigSaving.value = true
try {
const result = await updateEnvironmentConfig(envConfig)
if (result.success) {
message.success(result.message || t('Environment configuration saved'))
//
await loadEnvironmentConfig(false)
} else {
message.error(result.message || t('Failed to save environment configuration'))
}
} catch (error: any) {
message.error(error.message || t('Failed to save environment configuration'))
} finally {
envConfigSaving.value = false
}
}
//
const handleRestartEnvironment = () => {
if (!isAdmin.value) {
message.error(t('Only system administrators can restart environment'))
return
}
//
dialog.warning({
title: t('Restart Environment'),
content: t('Are you sure you want to restart the environment? This operation may cause service interruption.'),
positiveText: t('Restart'),
negativeText: t('Cancel'),
onPositiveClick: async () => {
envConfigRestarting.value = true
try {
const result = await restartEnvironment()
if (result.success) {
message.success(result.message || t('Environment restart request submitted. The system will restart shortly.'))
} else {
message.error(result.message || t('Failed to restart environment'))
}
} catch (error: any) {
message.error(error.message || t('Failed to restart environment'))
} finally {
envConfigRestarting.value = false
}
}
})
}
onMounted(async () => {
initLocale()
systemSettings.language = getCurrentLocale()
// 使
try {
timezoneOptions.value = getGroupedTimezoneOptions()
} catch (error) {
timezoneError.value = error instanceof Error ? error.message : String(error)
message.error(t('Failed to load timezone options') + ': ' + timezoneError.value)
console.error('Failed to load timezone options:', error)
}
//
if (isAdmin.value) {
await loadEnvironmentConfig(false)
}
})
</script>
<style scoped>
@ -430,86 +70,7 @@ onMounted(async () => {
margin: 0;
}
/* 保存按钮 - 使用柔和的品牌色系,与 pagetype 详情页保存按钮一致 */
.save-btn-brand {
background: #e6f8f0 !important;
border: 1px solid #1fc76f !important;
color: #0d684b !important;
}
.save-btn-brand :deep(.n-button__border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand:hover {
background: #dcfce7 !important;
border-color: #1fc76f !important;
border: 1px solid #1fc76f !important;
color: #166534 !important;
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15) !important;
}
.save-btn-brand:hover :deep(.n-button__border),
.save-btn-brand:hover :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand:focus {
background: #dcfce7 !important;
border-color: #1fc76f !important;
border: 1px solid #1fc76f !important;
color: #166534 !important;
box-shadow: 0 0 0 2px rgba(31, 199, 111, 0.2) !important;
}
.save-btn-brand:focus :deep(.n-button__border),
.save-btn-brand:focus :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand:active {
background: #1fc76f !important;
border-color: #1fc76f !important;
border: 1px solid #1fc76f !important;
color: white !important;
box-shadow: 0 1px 4px rgba(31, 199, 111, 0.2) !important;
}
.save-btn-brand:active :deep(.n-button__border),
.save-btn-brand:active :deep(.n-button__state-border) {
border-color: transparent !important;
}
.save-btn-brand:disabled {
background: #f1f5f9 !important;
border: 1px solid #e2e8f0 !important;
border-color: #e2e8f0 !important;
color: #94a3b8 !important;
opacity: 0.6 !important;
cursor: not-allowed !important;
}
.save-btn-brand:disabled :deep(.n-button__border),
.save-btn-brand:disabled :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.settings-page :deep(.n-grid) {
grid-template-columns: 1fr !important;
}
}
@media (max-width: 768px) {
.settings-page {
padding: 0 12px;
@ -522,10 +83,6 @@ onMounted(async () => {
.page-title {
font-size: 24px;
}
.page-description {
font-size: 14px;
}
}
@media (max-width: 480px) {
@ -536,9 +93,5 @@ onMounted(async () => {
.page-title {
font-size: 20px;
}
.page-description {
font-size: 13px;
}
}
</style>