Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce0dc6c232 | |||
| a135223579 | |||
| 67019666b9 |
@ -16,6 +16,12 @@ const router = createRouter({
|
|||||||
component: () => import('../../views/auth/Signup.vue'),
|
component: () => import('../../views/auth/Signup.vue'),
|
||||||
meta: { requiresAuth: false }
|
meta: { requiresAuth: false }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/oauth/callback',
|
||||||
|
name: 'OAuthCallback',
|
||||||
|
component: () => import('../../views/OAuthCallback.vue'),
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
name: 'HomePage',
|
name: 'HomePage',
|
||||||
@ -95,7 +101,20 @@ const router = createRouter({
|
|||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: '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',
|
path: 'tools',
|
||||||
|
|||||||
@ -21,6 +21,78 @@
|
|||||||
"Agent Detail": "智能体详情",
|
"Agent Detail": "智能体详情",
|
||||||
"Flow Builder": "流程编排",
|
"Flow Builder": "流程编排",
|
||||||
"Profile": "个人资料",
|
"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": "退出登录",
|
"Logout": "退出登录",
|
||||||
"Logged out": "已退出登录",
|
"Logged out": "已退出登录",
|
||||||
"AI Agent Workflow Platform": "AI Agent 工作流平台",
|
"AI Agent Workflow Platform": "AI Agent 工作流平台",
|
||||||
|
|||||||
365
src/shared/api/account.ts
Normal file
365
src/shared/api/account.ts
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
/**
|
||||||
|
* 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'
|
||||||
|
return backendUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户资料接口
|
||||||
|
*/
|
||||||
|
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 || '成为开发者失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
289
src/shared/api/oauth2.ts
Normal file
289
src/shared/api/oauth2.ts
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* OAuth2 API
|
||||||
|
*/
|
||||||
|
import axios from 'axios'
|
||||||
|
import {
|
||||||
|
generatePKCE,
|
||||||
|
storeAccessToken,
|
||||||
|
storeRefreshToken,
|
||||||
|
getAccessToken,
|
||||||
|
getRefreshToken,
|
||||||
|
clearTokens,
|
||||||
|
storeCodeVerifier,
|
||||||
|
getCodeVerifier,
|
||||||
|
clearCodeVerifier,
|
||||||
|
type TokenInfo
|
||||||
|
} from '../utils/oauth2'
|
||||||
|
|
||||||
|
// 从环境变量或配置获取 OAuth2 配置
|
||||||
|
const getOAuth2Config = () => {
|
||||||
|
// 从环境变量或配置中获取
|
||||||
|
const backendUrl = import.meta.env.VITE_JINGROW_BACKEND_URL || 'https://cloud.jingrow.com'
|
||||||
|
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 })
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/shared/components/ClickToCopyField.vue
Normal file
86
src/shared/components/ClickToCopyField.vue
Normal 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>
|
||||||
@ -2,6 +2,9 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { loginApi, getUserInfoApi, logoutApi, isCookieExpired, getSessionUser } from '../api/auth'
|
import { loginApi, getUserInfoApi, logoutApi, isCookieExpired, getSessionUser } from '../api/auth'
|
||||||
import { setInitializingAuth } from '../utils/fetchInterceptor'
|
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 {
|
export interface User {
|
||||||
user: string
|
user: string
|
||||||
@ -15,7 +18,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const isAuthenticated = 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 => {
|
const isAuthError = (error: any): boolean => {
|
||||||
@ -68,11 +74,29 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
user.value = null
|
user.value = null
|
||||||
isAuthenticated.value = false
|
isAuthenticated.value = false
|
||||||
clearUserFromStorage()
|
clearUserFromStorage()
|
||||||
|
// 清除 OAuth2 token
|
||||||
|
clearTokens()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证并更新用户信息
|
// 验证并更新用户信息
|
||||||
const validateAndUpdateUser = async (): Promise<boolean> => {
|
const validateAndUpdateUser = async (): Promise<boolean> => {
|
||||||
try {
|
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()
|
const userInfo = await getUserInfoApi()
|
||||||
setUserState(userInfo)
|
setUserState(userInfo)
|
||||||
return true
|
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
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await loginApi(username, password)
|
const response = await loginApi(username, password)
|
||||||
@ -106,6 +214,16 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
|
// 尝试撤销 OAuth2 token
|
||||||
|
try {
|
||||||
|
const { revokeToken } = await import('../api/oauth2')
|
||||||
|
await revokeToken()
|
||||||
|
} catch (error) {
|
||||||
|
// OAuth2 撤销失败,继续执行登出
|
||||||
|
console.warn('撤销 OAuth2 token 失败:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试使用 Cookie 登出(向后兼容)
|
||||||
await logoutApi()
|
await logoutApi()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('登出错误:', error)
|
console.error('登出错误:', error)
|
||||||
@ -118,20 +236,44 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
setInitializingAuth(true)
|
setInitializingAuth(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 检查cookie状态
|
// 优先检查 OAuth2 access token
|
||||||
const userId = getSessionUser()
|
const accessToken = getAccessToken()
|
||||||
const hasSessionCookie = !isCookieExpired()
|
|
||||||
const hasCookie = userId || hasSessionCookie
|
|
||||||
|
|
||||||
// 如果有cookie,尝试验证并获取用户信息
|
if (accessToken) {
|
||||||
if (hasCookie) {
|
// 检查 token 是否过期,如果过期则尝试刷新
|
||||||
|
if (isTokenExpired()) {
|
||||||
|
try {
|
||||||
|
await refreshAccessToken()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('刷新 token 失败:', error)
|
||||||
|
clearUserState()
|
||||||
|
setInitializingAuth(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 OAuth2 token 验证并获取用户信息
|
||||||
const success = await validateAndUpdateUser()
|
const success = await validateAndUpdateUser()
|
||||||
if (success) {
|
if (success) {
|
||||||
|
setInitializingAuth(false)
|
||||||
return
|
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()
|
const storedUser = loadUserFromStorage()
|
||||||
if (storedUser) {
|
if (storedUser) {
|
||||||
user.value = storedUser
|
user.value = storedUser
|
||||||
@ -141,7 +283,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
// 静默失败,保持localStorage中的状态
|
// 静默失败,保持localStorage中的状态
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 既没有cookie也没有localStorage,清除认证状态
|
// 既没有token也没有cookie也没有localStorage,清除认证状态
|
||||||
if (isAuthenticated.value) {
|
if (isAuthenticated.value) {
|
||||||
clearUserState()
|
clearUserState()
|
||||||
}
|
}
|
||||||
@ -171,6 +313,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isLoggedIn,
|
isLoggedIn,
|
||||||
login,
|
login,
|
||||||
|
loginWithPassword,
|
||||||
|
handleOAuthCallback,
|
||||||
logout,
|
logout,
|
||||||
initAuth,
|
initAuth,
|
||||||
updateUserInfo
|
updateUserInfo
|
||||||
|
|||||||
@ -9,9 +9,59 @@ export function setInitializingAuth(value: boolean) {
|
|||||||
isInitializingAuth = value
|
isInitializingAuth = value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 包装fetch函数,添加401/403错误处理
|
// 包装fetch函数,添加 Bearer token 和 401/403 错误处理
|
||||||
window.fetch = async function(...args: Parameters<typeof fetch>): Promise<Response> {
|
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时处理)
|
// 检查响应状态码(仅在401/403时处理)
|
||||||
if (response.status === 401 || response.status === 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) {
|
if (authStore.isLoggedIn) {
|
||||||
console.warn('检测到401/403错误,Cookie已过期,自动退出登录')
|
console.warn('检测到401/403错误,Token已过期,自动退出登录')
|
||||||
await authStore.logout()
|
await authStore.logout()
|
||||||
|
|
||||||
// 跳转到登录页(避免重复跳转)
|
// 跳转到登录页(避免重复跳转)
|
||||||
|
|||||||
173
src/shared/utils/oauth2.ts
Normal file
173
src/shared/utils/oauth2.ts
Normal 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
|
||||||
|
}
|
||||||
@ -9,7 +9,6 @@ import { signupApi } from '@/shared/api/auth'
|
|||||||
import AppHeader from '@/app/layouts/AppHeader.vue'
|
import AppHeader from '@/app/layouts/AppHeader.vue'
|
||||||
import AppSidebar from '@/app/layouts/AppSidebar.vue'
|
import AppSidebar from '@/app/layouts/AppSidebar.vue'
|
||||||
import { compressImageFile } from '@/shared/utils/imageResize'
|
import { compressImageFile } from '@/shared/utils/imageResize'
|
||||||
import axios from 'axios'
|
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@ -17,30 +16,10 @@ const appName = computed(() => localStorage.getItem('appName') || 'Jingrow')
|
|||||||
const currentYear = computed(() => new Date().getFullYear())
|
const currentYear = computed(() => new Date().getFullYear())
|
||||||
const logoUrl = computed(() => '/logo.svg')
|
const logoUrl = computed(() => '/logo.svg')
|
||||||
|
|
||||||
// 登录/注册弹窗状态
|
// 注册弹窗状态
|
||||||
const showLoginModal = ref(false)
|
|
||||||
const showSignupModal = ref(false)
|
const showSignupModal = ref(false)
|
||||||
const loginFormRef = ref()
|
|
||||||
const signupFormRef = ref()
|
const signupFormRef = ref()
|
||||||
const loginLoading = ref(false)
|
|
||||||
const signupLoading = ref(false)
|
const signupLoading = ref(false)
|
||||||
const showSignupLink = ref(true)
|
|
||||||
|
|
||||||
// 登录表单
|
|
||||||
const loginFormData = reactive({
|
|
||||||
username: '',
|
|
||||||
password: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
const loginRules = {
|
|
||||||
username: [
|
|
||||||
{ required: true, message: t('Please enter username'), 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' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 注册表单
|
// 注册表单
|
||||||
const signupFormData = reactive({
|
const signupFormData = reactive({
|
||||||
@ -134,36 +113,21 @@ const signupRules = computed(() => {
|
|||||||
return rules
|
return rules
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleLogin = () => {
|
const handleLogin = async () => {
|
||||||
showLoginModal.value = true
|
try {
|
||||||
|
// 直接跳转到 OAuth2 授权页面
|
||||||
|
await authStore.login()
|
||||||
|
// login 方法会重定向到授权页面,这里不会继续执行
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Login error:', error)
|
||||||
|
message.error(error.message || t('Login failed, please try again'))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSignup = () => {
|
const handleSignup = () => {
|
||||||
showSignupModal.value = true
|
showSignupModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLoginSubmit = async () => {
|
|
||||||
try {
|
|
||||||
await loginFormRef.value?.validate()
|
|
||||||
loginLoading.value = true
|
|
||||||
|
|
||||||
const result = await authStore.login(loginFormData.username, loginFormData.password)
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
message.success(t('Login successful'))
|
|
||||||
showLoginModal.value = false
|
|
||||||
loginFormData.username = ''
|
|
||||||
loginFormData.password = ''
|
|
||||||
} else {
|
|
||||||
message.error(result.error || t('Login failed'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error)
|
|
||||||
message.error(t('Login failed, please check username and password'))
|
|
||||||
} finally {
|
|
||||||
loginLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSignupSubmit = async () => {
|
const handleSignupSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
@ -191,19 +155,18 @@ const handleSignupSubmit = async () => {
|
|||||||
signupFormData.email = ''
|
signupFormData.email = ''
|
||||||
signupFormData.phoneNumber = ''
|
signupFormData.phoneNumber = ''
|
||||||
} else {
|
} else {
|
||||||
const loginResult = await authStore.login(signupFormData.username, signupFormData.password)
|
// 注册成功后,提示用户登录
|
||||||
if (loginResult.success) {
|
showSignupModal.value = false
|
||||||
showSignupModal.value = false
|
signupFormData.username = ''
|
||||||
signupFormData.username = ''
|
signupFormData.password = ''
|
||||||
signupFormData.password = ''
|
signupFormData.confirmPassword = ''
|
||||||
signupFormData.confirmPassword = ''
|
signupFormData.email = ''
|
||||||
signupFormData.email = ''
|
signupFormData.phoneNumber = ''
|
||||||
signupFormData.phoneNumber = ''
|
message.success(t('Sign up successful, please login'))
|
||||||
} else {
|
// 延迟跳转到登录页面
|
||||||
message.warning(loginResult.error || t('Sign up successful, but auto login failed. Please login manually'))
|
setTimeout(() => {
|
||||||
showSignupModal.value = false
|
handleLogin()
|
||||||
showLoginModal.value = true
|
}, 1000)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const errorMsg = result.error || t('Sign up failed')
|
const errorMsg = result.error || t('Sign up failed')
|
||||||
@ -218,14 +181,10 @@ const handleSignupSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const switchToSignup = () => {
|
|
||||||
showLoginModal.value = false
|
|
||||||
showSignupModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const switchToLogin = () => {
|
const switchToLogin = () => {
|
||||||
showSignupModal.value = false
|
showSignupModal.value = false
|
||||||
showLoginModal.value = true
|
handleLogin()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录状态
|
// 登录状态
|
||||||
@ -651,7 +610,7 @@ const handleDownload = async () => {
|
|||||||
// 检查登录状态
|
// 检查登录状态
|
||||||
if (!isLoggedIn.value) {
|
if (!isLoggedIn.value) {
|
||||||
message.warning(t('Please login to download'))
|
message.warning(t('Please login to download'))
|
||||||
showLoginModal.value = true
|
handleLogin()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1464,74 +1423,6 @@ onUnmounted(() => {
|
|||||||
</n-layout>
|
</n-layout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 登录弹窗 -->
|
|
||||||
<n-modal
|
|
||||||
v-model:show="showLoginModal"
|
|
||||||
preset="card"
|
|
||||||
:title="appName"
|
|
||||||
size="large"
|
|
||||||
:bordered="false"
|
|
||||||
:mask-closable="true"
|
|
||||||
style="max-width: 500px;"
|
|
||||||
class="auth-modal"
|
|
||||||
>
|
|
||||||
<n-form
|
|
||||||
ref="loginFormRef"
|
|
||||||
:model="loginFormData"
|
|
||||||
:rules="loginRules"
|
|
||||||
size="medium"
|
|
||||||
:show-label="false"
|
|
||||||
@keyup.enter="handleLoginSubmit"
|
|
||||||
>
|
|
||||||
<n-form-item path="username">
|
|
||||||
<n-input
|
|
||||||
v-model:value="loginFormData.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="loginFormData.password"
|
|
||||||
type="password"
|
|
||||||
:placeholder="t('Password')"
|
|
||||||
:input-props="{ autocomplete: 'current-password' }"
|
|
||||||
show-password-on="click"
|
|
||||||
>
|
|
||||||
<template #prefix>
|
|
||||||
<Icon icon="tabler:lock" />
|
|
||||||
</template>
|
|
||||||
</n-input>
|
|
||||||
</n-form-item>
|
|
||||||
|
|
||||||
<n-form-item>
|
|
||||||
<n-button
|
|
||||||
type="primary"
|
|
||||||
size="large"
|
|
||||||
block
|
|
||||||
:loading="loginLoading"
|
|
||||||
@click="handleLoginSubmit"
|
|
||||||
class="brand-button"
|
|
||||||
>
|
|
||||||
{{ t('Login') }}
|
|
||||||
</n-button>
|
|
||||||
</n-form-item>
|
|
||||||
</n-form>
|
|
||||||
|
|
||||||
<div class="auth-footer" v-if="showSignupLink">
|
|
||||||
<n-text depth="3">
|
|
||||||
{{ t("Don't have an account?") }}
|
|
||||||
<a href="javascript:void(0)" class="auth-link" @click="switchToSignup">
|
|
||||||
{{ t('Sign up') }}
|
|
||||||
</a>
|
|
||||||
</n-text>
|
|
||||||
</div>
|
|
||||||
</n-modal>
|
|
||||||
|
|
||||||
<!-- 注册弹窗 -->
|
<!-- 注册弹窗 -->
|
||||||
<n-modal
|
<n-modal
|
||||||
|
|||||||
100
src/views/OAuthCallback.vue
Normal file
100
src/views/OAuthCallback.vue
Normal 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>
|
||||||
@ -101,22 +101,12 @@ const rules = {
|
|||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
try {
|
try {
|
||||||
await formRef.value?.validate()
|
// 使用 OAuth2 登录(会重定向到授权页面)
|
||||||
loading.value = true
|
await authStore.login()
|
||||||
|
// login 方法会重定向到授权页面,这里不会继续执行
|
||||||
const result = await authStore.login(formData.username, formData.password)
|
} catch (error: any) {
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
message.success(t('Login successful'))
|
|
||||||
router.push('/')
|
|
||||||
} else {
|
|
||||||
message.error(result.error || t('Login failed'))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Login error:', error)
|
console.error('Login error:', error)
|
||||||
message.error(t('Login failed, please check username and password'))
|
message.error(error.message || t('Login failed, please check username and password'))
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
393
src/views/settings/DeveloperSettings.vue
Normal file
393
src/views/settings/DeveloperSettings.vue
Normal 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>
|
||||||
665
src/views/settings/ProfileSettings.vue
Normal file
665
src/views/settings/ProfileSettings.vue
Normal 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>
|
||||||
@ -4,413 +4,53 @@
|
|||||||
<h1 class="page-title">{{ t('Settings') }}</h1>
|
<h1 class="page-title">{{ t('Settings') }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<n-grid :cols="2" :x-gap="24" :y-gap="24">
|
<n-tabs v-model:value="activeTab" type="line" animated @update:value="handleTabChange">
|
||||||
<!-- 左栏:系统设置 -->
|
<n-tab-pane name="profile">
|
||||||
<n-grid-item>
|
<template #tab>
|
||||||
<n-card :title="t('System Settings')">
|
<n-icon style="margin-right: 4px"><Icon icon="tabler:user" /></n-icon>
|
||||||
<n-form :model="systemSettings" label-placement="left" label-width="120px">
|
{{ t('Profile') }}
|
||||||
<n-form-item :label="t('App Name')">
|
</template>
|
||||||
<n-input v-model:value="systemSettings.appName" :placeholder="t('Enter app name')" />
|
<router-view />
|
||||||
</n-form-item>
|
</n-tab-pane>
|
||||||
<n-form-item :label="t('Interface Language')">
|
<n-tab-pane name="developer">
|
||||||
<n-select
|
<template #tab>
|
||||||
v-model:value="systemSettings.language"
|
<n-icon style="margin-right: 4px"><Icon icon="tabler:code" /></n-icon>
|
||||||
:options="languageOptions"
|
{{ t('Developer') }}
|
||||||
style="width: 200px"
|
</template>
|
||||||
@update:value="changeLanguage"
|
<router-view />
|
||||||
/>
|
</n-tab-pane>
|
||||||
</n-form-item>
|
</n-tabs>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, onMounted, computed, ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import {
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
NGrid,
|
import { NTabs, NTabPane, NIcon } from 'naive-ui'
|
||||||
NGridItem,
|
|
||||||
NCard,
|
|
||||||
NForm,
|
|
||||||
NFormItem,
|
|
||||||
NInput,
|
|
||||||
NButton,
|
|
||||||
NInputNumber,
|
|
||||||
NSelect,
|
|
||||||
NSwitch,
|
|
||||||
NAlert,
|
|
||||||
NCollapse,
|
|
||||||
NCollapseItem,
|
|
||||||
NSpace,
|
|
||||||
NIcon,
|
|
||||||
useMessage,
|
|
||||||
useDialog
|
|
||||||
} from 'naive-ui'
|
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
import { getCurrentLocale, setLocale, locales, initLocale, t } from '../../shared/i18n'
|
import { 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'
|
|
||||||
|
|
||||||
const message = useMessage()
|
const route = useRoute()
|
||||||
const dialog = useDialog()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
|
||||||
|
|
||||||
// 检查是否为系统管理员
|
const activeTab = computed({
|
||||||
const isAdmin = computed(() => {
|
get: () => {
|
||||||
const user = authStore.user
|
if (route.name === 'SettingsProfile') return 'profile'
|
||||||
return user?.username === 'Administrator' || user?.id === 'Administrator'
|
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 handleTabChange = (value: string) => {
|
||||||
const isLocalMode = computed(() => {
|
activeTab.value = value
|
||||||
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 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -430,86 +70,7 @@ onMounted(async () => {
|
|||||||
margin: 0;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.settings-page {
|
.settings-page {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
@ -522,10 +83,6 @@ onMounted(async () => {
|
|||||||
.page-title {
|
.page-title {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-description {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
@ -536,9 +93,5 @@ onMounted(async () => {
|
|||||||
.page-title {
|
.page-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-description {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user