Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f092728a04 | |||
| 8820fc8e06 | |||
| de92a1e63d | |||
| c489d41941 | |||
| ee2f59df81 | |||
| 36af166879 | |||
| 6e6d822b06 | |||
| dfad1a1d80 | |||
| 423e28c09c | |||
| f30502bf7d | |||
| 79b92e7aae | |||
| 911ae5e53b | |||
| 4b3ebaa7ed | |||
| eb70a0c6f6 |
@ -9,5 +9,14 @@
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/app/main.ts"></script>
|
||||
<script>
|
||||
var _hmt = _hmt || [];
|
||||
(function() {
|
||||
var hm = document.createElement("script");
|
||||
hm.src = "https://hm.baidu.com/hm.js?37b6ba3388e71f7dfc30535b280eb067";
|
||||
var s = document.getElementsByTagName("script")[0];
|
||||
s.parentNode.insertBefore(hm, s);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -44,13 +44,6 @@
|
||||
</template>
|
||||
</n-input>
|
||||
|
||||
<!-- 通知 -->
|
||||
<n-button quaternary circle>
|
||||
<template #icon>
|
||||
<Icon icon="tabler:bell" />
|
||||
</template>
|
||||
</n-button>
|
||||
|
||||
<!-- 用户菜单 -->
|
||||
<UserMenu />
|
||||
</n-space>
|
||||
@ -181,7 +174,6 @@ const breadcrumbItems = computed(() => {
|
||||
} else {
|
||||
// 其他页面的标题映射(保留向后兼容)
|
||||
const map: Record<string, string> = {
|
||||
Dashboard: t('Dashboard'),
|
||||
AgentList: t('Agents'),
|
||||
AgentDetail: t('Agent Detail'),
|
||||
NodeList: t('Node Management'),
|
||||
|
||||
@ -16,12 +16,6 @@ const router = createRouter({
|
||||
component: () => import('../../views/auth/Signup.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '/oauth/callback',
|
||||
name: 'OAuthCallback',
|
||||
component: () => import('../../views/OAuthCallback.vue'),
|
||||
meta: { requiresAuth: false }
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
name: 'HomePage',
|
||||
@ -35,9 +29,9 @@ const router = createRouter({
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../../views/Dashboard.vue')
|
||||
path: '',
|
||||
name: 'Home',
|
||||
redirect: '/tools'
|
||||
},
|
||||
{
|
||||
path: 'local-jobs',
|
||||
@ -101,20 +95,7 @@ const router = createRouter({
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
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')
|
||||
}
|
||||
]
|
||||
component: () => import('../../views/settings/Settings.vue')
|
||||
},
|
||||
{
|
||||
path: 'tools',
|
||||
|
||||
@ -21,78 +21,7 @@
|
||||
"Agent Detail": "智能体详情",
|
||||
"Flow Builder": "流程编排",
|
||||
"Profile": "个人资料",
|
||||
"Developer": "开发者",
|
||||
"Edit": "编辑",
|
||||
"Marketplace Developer": "应用市场开发者",
|
||||
"Become a Developer": "成为开发者",
|
||||
"Developers can publish their apps on the marketplace for users to subscribe to, either paid or free.": "开发者可以在应用市场发布自己的应用,供用户付费或免费订阅。",
|
||||
"Reset Password": "重置密码",
|
||||
"Change your account login password": "更改您的账户登录密码",
|
||||
"Disable Account": "禁用账户",
|
||||
"Disable your account and stop billing": "禁用您的账户并停止计费",
|
||||
"Disable": "禁用",
|
||||
"Update Profile Information": "更新个人资料",
|
||||
"Save Changes": "保存更改",
|
||||
"Current Password": "当前密码",
|
||||
"New Password": "新密码",
|
||||
"After confirming this action:": "确认此操作后:",
|
||||
"Your account will be disabled": "您的账户将被禁用",
|
||||
"Your account billing will stop": "您的账户计费将停止",
|
||||
"You can log in later to re-enable your account. Do you want to continue?": "您可以稍后登录以重新启用您的账户。是否继续?",
|
||||
"Become a Marketplace Developer?": "成为应用市场开发者?",
|
||||
"After confirmation, you will be able to publish apps to our marketplace.": "确认后,您将能够在我们的应用市场发布应用。",
|
||||
"Confirm": "确认",
|
||||
"Cancel": "取消",
|
||||
"You can now publish apps to our marketplace": "您现在可以在我们的应用市场发布应用了",
|
||||
"Failed to mark you as a developer": "标记为开发者失败",
|
||||
"Your profile has been updated successfully": "您的个人资料已成功更新",
|
||||
"Failed to update profile": "更新个人资料失败",
|
||||
"Password reset successfully": "密码重置成功",
|
||||
"Failed to reset password": "重置密码失败",
|
||||
"Your account has been disabled successfully": "您的账户已成功禁用",
|
||||
"Failed to disable account": "禁用账户失败",
|
||||
"API Access": "API访问",
|
||||
"API key and API secret can be used to access": "API密钥和API密钥可用于访问",
|
||||
"Jingrow API": "Jingrow API",
|
||||
"You don't have an API key yet. Click the button above to create one.": "您还没有API密钥。请点击上方按钮创建一个。",
|
||||
"Please copy the API key immediately. You will not be able to view it again!": "请立即复制API密钥。您将无法再次查看!",
|
||||
"Regenerate API Key": "重新生成API密钥",
|
||||
"Create New API Key": "创建新的API密钥",
|
||||
"API key regenerated successfully": "API密钥重新生成成功",
|
||||
"API key created successfully": "API密钥创建成功",
|
||||
"Failed to create API key": "创建API密钥失败",
|
||||
"SSH Keys": "SSH密钥",
|
||||
"Add SSH Key": "添加SSH密钥",
|
||||
"Add a new SSH key to your account": "向您的账户添加新的SSH密钥",
|
||||
"SSH key is required": "需要SSH密钥",
|
||||
"Invalid SSH key format": "SSH密钥格式无效",
|
||||
"SSH key added successfully": "SSH密钥添加成功",
|
||||
"Failed to add SSH key": "添加SSH密钥失败",
|
||||
"SSH key deleted successfully": "SSH密钥删除成功",
|
||||
"Failed to delete SSH key": "删除SSH密钥失败",
|
||||
"SSH key updated successfully": "SSH密钥更新成功",
|
||||
"Failed to set SSH key as default": "设置默认SSH密钥失败",
|
||||
"Set as Default": "设为默认",
|
||||
"Default": "默认",
|
||||
"No SSH keys": "没有SSH密钥",
|
||||
"Add New SSH Key": "添加新的SSH密钥",
|
||||
"Starts with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'": "以 'ssh-rsa'、'ecdsa-sha2-nistp256'、'ecdsa-sha2-nistp384'、'ecdsa-sha2-nistp521'、'ssh-ed25519'、'sk-ecdsa-sha2-nistp256@openssh.com' 或 'sk-ssh-ed25519@openssh.com' 开头",
|
||||
"Copied to clipboard": "已复制到剪贴板",
|
||||
"Copy": "复制",
|
||||
"Copied": "已复制",
|
||||
"Copy failed, please copy manually": "复制失败,请手动复制",
|
||||
"SSH Fingerprint": "SSH指纹",
|
||||
"Added Time": "添加时间",
|
||||
"Actions": "操作",
|
||||
"Are you sure you want to delete this SSH key?": "您确定要删除此SSH密钥吗?",
|
||||
"Phone": "手机",
|
||||
"Email": "邮箱",
|
||||
"First Name": "名",
|
||||
"Last Name": "姓",
|
||||
"Enable Account": "启用账户",
|
||||
"Enable your account and resume billing": "启用您的账户并恢复计费",
|
||||
"Your account has been enabled successfully": "您的账户已成功启用",
|
||||
"Failed to enable account": "启用账户失败",
|
||||
"Update Profile Information": "更新个人资料信息",
|
||||
"Logout": "退出登录",
|
||||
"Logged out": "已退出登录",
|
||||
"AI Agent Workflow Platform": "AI Agent 工作流平台",
|
||||
@ -427,6 +356,8 @@
|
||||
"Unsaved": "未保存",
|
||||
"Saved": "已保存",
|
||||
"Copy failed, please copy manually": "复制失败,请手动复制",
|
||||
"Copied to clipboard": "已复制到剪贴板",
|
||||
"Copy failed": "复制失败",
|
||||
"All": "全部",
|
||||
"Recent": "最近使用",
|
||||
"Favorites": "收藏",
|
||||
@ -1038,6 +969,7 @@
|
||||
"Restart Environment": "重启环境",
|
||||
"Restart": "重启",
|
||||
"Only system administrators can restart environment": "仅系统管理员可以重启环境",
|
||||
"Only system administrators can edit environment configuration": "仅系统管理员可以查看和编辑环境配置",
|
||||
"Are you sure you want to restart the environment? This operation may cause service interruption.": "您确定要重启环境吗?此操作可能导致服务中断。",
|
||||
"Environment restart request submitted. The system will restart shortly.": "环境重启请求已提交,系统将在稍后重启。",
|
||||
"Failed to restart environment": "重启环境失败",
|
||||
@ -1309,5 +1241,155 @@
|
||||
"Online Support": "在线客服",
|
||||
"Feedback & Suggestions": "反馈建议",
|
||||
"Guangzhou Sunflower Network Information Technology Co., Ltd.": "广州向日葵网络信息技术有限公司",
|
||||
"All Rights Reserved": "版权所有"
|
||||
"All Rights Reserved": "版权所有",
|
||||
|
||||
"API Access": "API 访问",
|
||||
"API key and API secret can be used to access": "API 密钥和 API 密钥可用于访问",
|
||||
"API key and API secret pairs can be used to access the Jingrow API.": "API 密钥和 API 密钥对可用于访问 Jingrow API。",
|
||||
"Jingrow API": "Jingrow API",
|
||||
"Create New API Key": "创建新的 API 密钥",
|
||||
"Regenerate API Key": "重新生成 API 密钥",
|
||||
"API key created successfully": "API 密钥创建成功",
|
||||
"Failed to create API key": "创建 API 密钥失败",
|
||||
"You don't have an API key yet. Click the button above to create one.": "您还没有 API 密钥。请点击上方按钮创建一个。",
|
||||
"Please copy the API secret now. You won't be able to see it again!": "请立即复制 API 密钥。您将无法再次查看它!",
|
||||
"API Secret": "API 密钥",
|
||||
"SSH Keys": "SSH 密钥",
|
||||
"Add SSH Key": "添加 SSH 密钥",
|
||||
"Add New SSH Key": "添加新的 SSH 密钥",
|
||||
"SSH Key": "SSH 密钥",
|
||||
"SSH Fingerprint": "SSH 指纹",
|
||||
"Added Time": "添加时间",
|
||||
"Set as Default": "设为默认",
|
||||
"No SSH keys configured": "未配置 SSH 密钥",
|
||||
"Add a new SSH key to your account": "为您的账户添加新的 SSH 密钥",
|
||||
"SSH key is required": "SSH 密钥是必填项",
|
||||
"Link Partner Account": "关联合作伙伴账户",
|
||||
"Enter the partner code provided by your partner": "输入您的合作伙伴提供的合作伙伴代码",
|
||||
"Partner Code": "合作伙伴代码",
|
||||
"For example: rGjw3hJ81b": "例如:rGjw3hJ81b",
|
||||
"Submit": "提交",
|
||||
"Remove Partner": "移除合作伙伴",
|
||||
"This will remove the partner associated with your account. Are you sure you want to remove this partner?": "这将移除与您的账户关联的合作伙伴。确定要移除此合作伙伴吗?",
|
||||
"Partner removed successfully": "合作伙伴移除成功",
|
||||
"Failed to remove partner": "移除合作伙伴失败",
|
||||
"SSH key added successfully": "SSH 密钥添加成功",
|
||||
"Failed to add SSH key": "添加 SSH 密钥失败",
|
||||
"SSH key updated successfully": "SSH 密钥更新成功",
|
||||
"Failed to set SSH key as default": "设置默认 SSH 密钥失败",
|
||||
"SSH key deleted successfully": "SSH 密钥删除成功",
|
||||
"Failed to delete SSH key": "删除 SSH 密钥失败",
|
||||
"Are you sure you want to delete this SSH key?": "确定要删除此 SSH 密钥吗?",
|
||||
"Delete SSH Key": "删除 SSH 密钥",
|
||||
"Advanced Features": "高级功能",
|
||||
"Enable private benches": "启用私有工作台",
|
||||
"Enable security portal": "启用安全门户",
|
||||
"Save Changes": "保存更改",
|
||||
"Feature flags updated successfully": "功能标志更新成功",
|
||||
"Failed to update feature flags": "更新功能标志失败",
|
||||
"Marketplace Developer": "应用市场开发者",
|
||||
"Developers can publish their apps on the marketplace for users to subscribe to, either paid or free.": "开发者可以在应用市场发布自己的应用,供用户付费或免费订阅。",
|
||||
"Become a Developer": "成为开发者",
|
||||
"Become a Marketplace Developer?": "成为应用市场开发者?",
|
||||
"After confirmation, you will be able to publish apps to our marketplace.": "确认后,您将能够在我们的应用市场发布应用。",
|
||||
"Enable Two-Factor Authentication": "启用双因素认证",
|
||||
"Disable Two-Factor Authentication": "禁用双因素认证",
|
||||
"Enable two-factor authentication for your account to add an extra layer of security": "为您的账户启用双因素认证以增加额外的安全层",
|
||||
"Disable two-factor authentication for your account": "为您的账户禁用双因素认证",
|
||||
"Enable": "启用",
|
||||
"Disable": "禁用",
|
||||
"Reset Password": "重置密码",
|
||||
"Change your account login password": "更改您的账户登录密码",
|
||||
"Disable Account": "禁用账户",
|
||||
"Enable Account": "启用账户",
|
||||
"Your account has been disabled successfully": "您的账户已成功禁用",
|
||||
"Failed to disable account": "禁用账户失败",
|
||||
"Your account has been enabled successfully": "您的账户已成功启用",
|
||||
"Failed to enable account": "启用账户失败",
|
||||
"Disable your account and stop billing": "禁用您的账户并停止计费",
|
||||
"Enable your account and resume billing": "启用您的账户并恢复计费",
|
||||
"After confirming this action:": "确认此操作后:",
|
||||
"Your account will be disabled": "您的账户将被禁用",
|
||||
"Your activated sites will be suspended immediately and deleted after one week.": "您已激活的站点将立即暂停,并在一周后删除。",
|
||||
"Your account billing will stop": "您的账户计费将停止",
|
||||
"You can log in later to re-enable your account. Do you want to continue?": "您可以稍后登录以重新启用您的账户。是否继续?",
|
||||
"Your account will be enabled": "您的账户将被启用",
|
||||
"Your suspended sites will be reactivated": "您已暂停的站点将被重新激活",
|
||||
"Your account billing will resume": "您的账户计费将恢复",
|
||||
"Do you want to continue?": "是否继续?",
|
||||
"Referral Program": "推荐有礼",
|
||||
"Your exclusive referral link": "您的专属推荐链接",
|
||||
"Invite others to join Jingrow,": "邀请他人加入 Jingrow,",
|
||||
"when they register and spend at least ¥100, you will get ¥20": "当他们注册并消费至少¥100时,您将获得¥20",
|
||||
"Jingrow Partner": "Jingrow 合作伙伴",
|
||||
"Jingrow partner associated with your account": "与您的账户关联的 Jingrow 合作伙伴",
|
||||
"Add Partner Code": "添加合作伙伴代码",
|
||||
"Unlink Partner": "取消关联合作伙伴",
|
||||
"Have a Jingrow partner referral code? Click": "有 Jingrow 合作伙伴推荐代码吗?点击",
|
||||
"to associate with your partner team.": "以与您的合作伙伴团队关联。",
|
||||
"Feature coming soon": "功能即将推出",
|
||||
"is an invalid referral code": "是无效的推荐代码",
|
||||
"Current Password": "当前密码",
|
||||
"New Password": "新密码",
|
||||
"Please enter current password": "请输入当前密码",
|
||||
"Please enter new password": "请输入新密码",
|
||||
"Please re-enter new password": "请重新输入新密码",
|
||||
"New password cannot be the same as current password": "新密码不能与当前密码相同",
|
||||
"Password strength is good 👍": "密码强度良好 👍",
|
||||
"Tip: Password should contain symbols, numbers and uppercase letters": "提示:密码应包含符号、数字和大写字母",
|
||||
"Password updated successfully": "密码更新成功",
|
||||
"Your password has been updated": "您的密码已更新",
|
||||
"Profile updated successfully": "个人资料更新成功",
|
||||
"Current password is incorrect": "当前密码不正确",
|
||||
"Setting you as a developer...": "正在将您设置为开发者...",
|
||||
"You can now publish apps to our marketplace": "您现在可以在我们的应用市场发布应用了",
|
||||
"Failed to mark you as a developer": "将您设置为开发者失败",
|
||||
"Approval Request has already been sent to Partner": "批准请求已发送给合作伙伴",
|
||||
"Approval Request has been sent to Partner": "批准请求已发送给合作伙伴",
|
||||
"If you disable two-factor authentication, your account will become insecure": "如果您禁用双因素认证,您的账户将变得不安全",
|
||||
"Steps to Disable Two-Factor Authentication": "禁用双因素认证的步骤",
|
||||
"Open the authenticator app": "打开身份验证器应用",
|
||||
"Enter the code from the app below": "在下方输入应用中的代码",
|
||||
"Verify the code in the app to disable two-factor authentication": "输入应用中的验证码以禁用双因素认证",
|
||||
"Steps to Enable Two-Factor Authentication": "启用双因素认证的步骤",
|
||||
"Download an authenticator app on your phone, such as Alibaba Cloud APP, etc.": "在手机上下载身份验证器应用,例如阿里云 APP 等",
|
||||
"Scan the QR code": "扫描二维码",
|
||||
"Enter the code from the authenticator app": "输入身份验证器应用中的代码",
|
||||
"Enter the code from the authenticator app below": "在下方输入身份验证器应用中的代码",
|
||||
"Note": "注意",
|
||||
"If you cannot access the authenticator app, your account will be locked. Please ensure you back up your vault/key.": "如果您无法访问身份验证器应用,您的账户将被锁定。请确保您已备份您的密钥。",
|
||||
"Setup Key": "设置密钥",
|
||||
"Verify the code in the app to enable two-factor authentication": "输入应用中的验证码以启用双因素认证",
|
||||
"Please enter the code from the authenticator app": "请输入身份验证器应用中的代码",
|
||||
"Two-factor authentication enabled successfully": "双因素认证已成功启用",
|
||||
"Two-factor authentication disabled successfully": "双因素认证已成功禁用",
|
||||
"Invalid TOTP code, please try again": "无效的 TOTP 代码,请重试",
|
||||
"Failed to enable two-factor authentication": "启用双因素认证失败",
|
||||
"Failed to disable two-factor authentication": "禁用双因素认证失败",
|
||||
"Failed to load QR code": "加载二维码失败",
|
||||
"Enabling two-factor authentication...": "正在启用双因素认证...",
|
||||
"Disabling two-factor authentication...": "正在禁用双因素认证...",
|
||||
"Tell us why you are leaving": "告诉我们您离开的原因",
|
||||
"By sharing your thoughts, help us improve your experience.": "通过分享您的想法,帮助我们改善您的体验。",
|
||||
"Please rate your experience": "请评价您的体验",
|
||||
"Select a reason": "选择一个原因",
|
||||
"The reason I am leaving Jingrow is...": "我离开 Jingrow 的原因是...",
|
||||
"我要迁移到其他产品": "我要迁移到其他产品",
|
||||
"我只是在探索这个产品": "我只是在探索这个产品",
|
||||
"我更喜欢自己托管实例": "我更喜欢自己托管实例",
|
||||
"已将站点迁移到另一个Jingrow账户": "已将站点迁移到另一个Jingrow账户",
|
||||
"我不喜欢Jingrow的体验": "我不喜欢Jingrow的体验",
|
||||
"Jingrow对我来说太贵了": "Jingrow对我来说太贵了",
|
||||
"支付问题": "支付问题",
|
||||
"缺少功能": "缺少功能",
|
||||
"我的原因不在此列表中": "我的原因不在此列表中",
|
||||
"请选择一个原因": "请选择一个原因",
|
||||
"请评价您的体验": "请评价您的体验",
|
||||
"请简要说明原因": "请简要说明原因",
|
||||
"Your feedback has been submitted successfully": "您的反馈已成功提交",
|
||||
"Portrait Sample": "人物示例",
|
||||
"Product Sample": "产品示例",
|
||||
"Animal Sample": "动物示例",
|
||||
"Object Sample": "物品示例",
|
||||
"Unable to get team information": "无法获取团队信息"
|
||||
}
|
||||
|
||||
@ -1,365 +1,514 @@
|
||||
/**
|
||||
* Account API - 用户账户相关 API
|
||||
*/
|
||||
import axios from 'axios'
|
||||
import { getBearerTokenHeaders } from './oauth2'
|
||||
import { get_session_api_headers } from './auth'
|
||||
|
||||
/**
|
||||
* 获取后端 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
|
||||
// 创建或重新生成 API Secret
|
||||
export const createApiSecret = async (): Promise<{ success: boolean; data?: { api_key: string; api_secret: string }; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/api/action/jcloud.api.account.create_api_secret`,
|
||||
{},
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
const result = response.data
|
||||
if (result?.message) {
|
||||
return { success: true, data: result.message }
|
||||
}
|
||||
return { success: true, data: result }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '创建 API Secret 失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户资料接口
|
||||
*/
|
||||
export interface UserProfile {
|
||||
user: string
|
||||
username?: string
|
||||
email?: string
|
||||
mobile_no?: string
|
||||
phone?: string
|
||||
// 更新个人资料
|
||||
export interface UpdateProfileParams {
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
user_type?: string
|
||||
user_image?: string
|
||||
is_developer?: boolean
|
||||
api_key?: string
|
||||
username?: string
|
||||
mobile_no?: string
|
||||
email?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整账户信息
|
||||
*/
|
||||
export async function getAccountInfo(): Promise<{ success: boolean; data?: any; message?: string }> {
|
||||
export const updateProfile = async (params: UpdateProfileParams): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/api/action/jcloud.api.account.update_profile`,
|
||||
params,
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return { success: true, message: response.data?.message || '更新成功' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '更新个人资料失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取邮箱列表
|
||||
export const getEmails = async (): Promise<{ success: boolean; data?: Array<{ type: string; value: string }>; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${getBackendUrl()}/api/action/jcloud.api.account.get`,
|
||||
`/api/action/jcloud.api.account.get_emails`,
|
||||
{
|
||||
headers: getBearerTokenHeaders()
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.message || response.data
|
||||
}
|
||||
const result = response.data
|
||||
return { success: true, data: result?.message || result }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '获取账户信息失败'
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || 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 }> {
|
||||
// 更新邮箱
|
||||
export const updateEmails = async (data: Array<{ type: string; value: string }>): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${getBackendUrl()}/api/action/jcloud.api.account.update_profile`,
|
||||
data,
|
||||
`/api/action/jcloud.api.account.update_emails`,
|
||||
{ data: JSON.stringify(data) },
|
||||
{
|
||||
headers: getBearerTokenHeaders()
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.message || response.data,
|
||||
message: '用户资料更新成功'
|
||||
}
|
||||
return { success: true, message: response.data?.message || '更新成功' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '更新用户资料失败'
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '更新邮箱失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户头像
|
||||
*/
|
||||
export async function updateProfilePicture(file: File): Promise<{ success: boolean; data?: any; message?: string }> {
|
||||
// 获取用户 SSH 密钥列表
|
||||
export const getUserSSHKeys = async (): Promise<{ success: boolean; data?: Array<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,
|
||||
const response = await axios.get(
|
||||
`/api/action/jcloud.api.account.get_user_ssh_keys`,
|
||||
{
|
||||
headers: {
|
||||
...getBearerTokenHeaders(),
|
||||
'Content-Type': 'multipart/form-data'
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
const result = response.data
|
||||
return { success: true, data: result?.message || result || [] }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '获取 SSH 密钥列表失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加 SSH 密钥
|
||||
export const addSSHKey = async (sshPublicKey: string): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/api/action/jcloud.api.client.insert`,
|
||||
{
|
||||
pg: {
|
||||
pagetype: 'User SSH Key',
|
||||
ssh_public_key: sshPublicKey,
|
||||
user: '' // 后端会自动获取当前用户
|
||||
}
|
||||
},
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.message || response.data,
|
||||
message: '头像更新成功'
|
||||
}
|
||||
return { success: true, message: response.data?.message || '添加成功' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '更新头像失败'
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '添加 SSH 密钥失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或重新生成 API Key
|
||||
*/
|
||||
export async function createApiSecret(): Promise<{ success: boolean; data?: { api_key: string; api_secret: string }; message?: string }> {
|
||||
// 设置 SSH 密钥为默认
|
||||
export const markKeyAsDefault = async (keyName: string): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${getBackendUrl()}/api/action/jcloud.api.account.create_api_secret`,
|
||||
{},
|
||||
`/api/action/jcloud.api.account.mark_key_as_default`,
|
||||
{ key_name: keyName },
|
||||
{
|
||||
headers: getBearerTokenHeaders()
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.message || response.data,
|
||||
message: 'API Key 创建成功'
|
||||
}
|
||||
return { success: true, message: response.data?.message || '设置成功' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '创建 API Key 失败'
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '设置默认密钥失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户 SSH 密钥列表
|
||||
*/
|
||||
export async function getUserSSHKeys(): Promise<{ success: boolean; data?: any[]; message?: string }> {
|
||||
// 删除 SSH 密钥
|
||||
export const deleteSSHKey = async (keyName: string): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/api/action/jcloud.api.client.delete`,
|
||||
{
|
||||
pagetype: 'User SSH Key',
|
||||
name: keyName
|
||||
},
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return { success: true, message: response.data?.message || '删除成功' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '删除 SSH 密钥失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取功能标志
|
||||
export const getFeatureFlags = async (): Promise<{ success: boolean; data?: Record<string, boolean>; message?: string }> => {
|
||||
try {
|
||||
// 这里需要根据实际 API 调整
|
||||
const response = await axios.get(
|
||||
`/api/action/jcloud.api.account.get_feature_flags`,
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
const result = response.data
|
||||
return { success: true, data: result?.message || result }
|
||||
} catch (error: any) {
|
||||
// 如果 API 不存在,返回空对象
|
||||
return { success: false, data: {}, message: '功能标志 API 不可用' }
|
||||
}
|
||||
}
|
||||
|
||||
// 更新功能标志
|
||||
export const updateFeatureFlags = async (values: Record<string, boolean>): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/api/action/jcloud.api.account.update_feature_flags`,
|
||||
{ values },
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return { success: true, message: response.data?.message || '更新成功' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '更新功能标志失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户信息(包含 API Key)
|
||||
// 使用 jcloud.api.account.get API 获取账户信息
|
||||
export const getUserAccountInfo = async (): Promise<{ success: boolean; data?: any; team?: any; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${getBackendUrl()}/api/action/jcloud.api.account.get_user_ssh_keys`,
|
||||
`/api/action/jcloud.api.account.get`,
|
||||
{
|
||||
headers: getBearerTokenHeaders()
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.message || response.data
|
||||
const result = response.data?.message || response.data
|
||||
|
||||
if (result?.user) {
|
||||
return { success: true, data: result.user, team: result.team }
|
||||
}
|
||||
|
||||
return { success: false, message: 'API 返回的数据中未找到用户信息' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '获取 SSH 密钥失败'
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '获取用户信息失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 SSH 密钥
|
||||
*/
|
||||
export async function addSSHKey(sshKey: string): Promise<{ success: boolean; data?: any; message?: string }> {
|
||||
// 成为开发者(更新 Team 的 is_developer 字段)
|
||||
// 使用 jcloud.api.client.set_value API,与 jcloud dashboard 保持一致
|
||||
export const becomeDeveloper = async (teamName: string): Promise<{ success: boolean; data?: any; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${getBackendUrl()}/api/action/jcloud.api.client.insert`,
|
||||
`/api/action/jcloud.api.client.set_value`,
|
||||
{
|
||||
pagetype: 'SSH Key',
|
||||
ssh_key: sshKey
|
||||
pagetype: 'Team',
|
||||
name: teamName,
|
||||
fieldname: { is_developer: 1 }
|
||||
},
|
||||
{
|
||||
headers: getBearerTokenHeaders()
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data.message || response.data,
|
||||
message: 'SSH 密钥添加成功'
|
||||
}
|
||||
// set_value API 返回更新后的 Team 对象
|
||||
const result = response.data?.message || response.data
|
||||
return { success: true, data: result, message: '成为开发者成功' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '添加 SSH 密钥失败'
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '成为开发者失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 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 }> {
|
||||
// 更新密码
|
||||
export const updatePassword = async (params: {
|
||||
old_password: string
|
||||
new_password: string
|
||||
confirm_password: string
|
||||
logout_all_sessions?: number
|
||||
}): Promise<{ success: boolean; message?: string; redirectUrl?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${getBackendUrl()}/api/action/jcloud.api.account.reset_password`,
|
||||
`/api/action/jingrow.core.pagetype.user.user.update_password`,
|
||||
{
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword
|
||||
old_password: params.old_password,
|
||||
new_password: params.new_password,
|
||||
confirm_password: params.confirm_password,
|
||||
logout_all_sessions: params.logout_all_sessions || 1
|
||||
},
|
||||
{
|
||||
headers: getBearerTokenHeaders()
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: response.data.message || '密码重置成功'
|
||||
const result = response.data?.message || response.data
|
||||
return {
|
||||
success: true,
|
||||
message: '密码更新成功',
|
||||
redirectUrl: typeof result === 'string' ? result : undefined
|
||||
}
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '重置密码失败'
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '更新密码失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用账户
|
||||
*/
|
||||
export async function disableAccount(totpCode?: string): Promise<{ success: boolean; message?: string }> {
|
||||
// 测试密码强度
|
||||
export const testPasswordStrength = async (params: {
|
||||
old_password: string
|
||||
new_password: string
|
||||
}): Promise<{ success: boolean; data?: { score: number; feedback: any }; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${getBackendUrl()}/api/action/jcloud.api.account.disable_account`,
|
||||
`/api/action/jingrow.core.pagetype.user.user.test_password_strength`,
|
||||
{
|
||||
totp_code: totpCode
|
||||
old_password: params.old_password,
|
||||
new_password: params.new_password
|
||||
},
|
||||
{
|
||||
headers: getBearerTokenHeaders()
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: response.data.message || '账户已禁用'
|
||||
}
|
||||
const result = response.data?.message || response.data
|
||||
return { success: true, data: result }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '禁用账户失败'
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '测试密码强度失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用账户
|
||||
*/
|
||||
export async function enableAccount(): Promise<{ success: boolean; message?: string }> {
|
||||
// 验证合作伙伴代码
|
||||
export const validatePartnerCode = async (code: string): Promise<{ success: boolean; isValid: boolean; partnerName?: string; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${getBackendUrl()}/api/action/jcloud.api.account.enable_account`,
|
||||
`/api/action/jcloud.api.partner.validate_partner_code`,
|
||||
{ code },
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
const result = response.data?.message || response.data
|
||||
if (Array.isArray(result) && result.length >= 2) {
|
||||
return {
|
||||
success: true,
|
||||
isValid: result[0] === true,
|
||||
partnerName: result[1] || undefined
|
||||
}
|
||||
}
|
||||
return { success: true, isValid: false }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
isValid: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '验证合作伙伴代码失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加合作伙伴代码
|
||||
export const addPartnerCode = async (referralCode: string): Promise<{ success: boolean; message?: string; isAlreadySent?: boolean }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/api/action/jcloud.api.partner.add_partner`,
|
||||
{ referral_code: referralCode },
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
const result = response.data?.message || response.data
|
||||
if (result === 'Request already sent') {
|
||||
return { success: true, message: '请求已发送', isAlreadySent: true }
|
||||
}
|
||||
return { success: true, message: '请求已发送' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '添加合作伙伴代码失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除合作伙伴
|
||||
export const removePartner = async (): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/api/action/jcloud.api.partner.remove_partner`,
|
||||
{},
|
||||
{
|
||||
headers: getBearerTokenHeaders()
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: response.data.message || '账户已启用'
|
||||
}
|
||||
return { success: true, message: response.data?.message || '合作伙伴已移除' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '启用账户失败'
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '移除合作伙伴失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 成为开发者
|
||||
*/
|
||||
export async function becomeDeveloper(): Promise<{ success: boolean; message?: string }> {
|
||||
// 获取合作伙伴名称
|
||||
export const getPartnerName = async (partnerEmail: string): Promise<{ success: boolean; data?: string; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${getBackendUrl()}/api/action/jcloud.api.account.become_developer`,
|
||||
{},
|
||||
const response = await axios.get(
|
||||
`/api/action/jcloud.api.partner.get_partner_name`,
|
||||
{
|
||||
headers: getBearerTokenHeaders()
|
||||
params: { partner_email: partnerEmail },
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: response.data.message || '已成为开发者'
|
||||
}
|
||||
const result = response.data?.message || response.data
|
||||
return { success: true, data: result }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.message || error.message || '成为开发者失败'
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '获取合作伙伴名称失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 2FA QR 码 URL
|
||||
export const get2FAQRCodeUrl = async (): Promise<{ success: boolean; data?: string; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`/api/action/jcloud.api.account.get_2fa_qr_code_url`,
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
const result = response.data?.message || response.data
|
||||
return { success: true, data: result }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '获取 2FA QR 码失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 启用 2FA
|
||||
export const enable2FA = async (totpCode: string): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/api/action/jcloud.api.account.enable_2fa`,
|
||||
{ totp_code: totpCode },
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return { success: true, message: response.data?.message || '双因素认证已启用' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '启用双因素认证失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用 2FA
|
||||
export const disable2FA = async (totpCode: string): Promise<{ success: boolean; message?: string }> => {
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`/api/action/jcloud.api.account.disable_2fa`,
|
||||
{ totp_code: totpCode },
|
||||
{
|
||||
headers: get_session_api_headers(),
|
||||
withCredentials: true
|
||||
}
|
||||
)
|
||||
|
||||
return { success: true, message: response.data?.message || '双因素认证已禁用' }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
message: error.response?.data?.detail || error.response?.data?.message || error.message || '禁用双因素认证失败'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,289 +0,0 @@
|
||||
/**
|
||||
* 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'
|
||||
}
|
||||
}
|
||||
@ -1,86 +1,79 @@
|
||||
<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"
|
||||
<div class="click-to-copy-field">
|
||||
<n-input
|
||||
:value="textContent"
|
||||
readonly
|
||||
:placeholder="placeholder"
|
||||
class="copy-input"
|
||||
>
|
||||
{{ copied ? t('Copied') : t('Copy') }}
|
||||
</button>
|
||||
<template #suffix>
|
||||
<n-button
|
||||
quaternary
|
||||
size="small"
|
||||
@click="handleCopy"
|
||||
:loading="copying"
|
||||
class="copy-button"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<Icon :icon="copied ? 'tabler:check' : 'tabler:copy'" />
|
||||
</n-icon>
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
</n-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import { NInput, NButton, NIcon, useMessage } from 'naive-ui'
|
||||
import { Icon } from '@iconify/vue'
|
||||
import { t } from '../i18n'
|
||||
|
||||
interface Props {
|
||||
textContent: string
|
||||
breakLines?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
breakLines: true
|
||||
placeholder: ''
|
||||
})
|
||||
|
||||
const message = useMessage()
|
||||
const copying = ref(false)
|
||||
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()
|
||||
const handleCopy = async () => {
|
||||
if (!props.textContent) return
|
||||
|
||||
copying.value = true
|
||||
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'))
|
||||
await navigator.clipboard.writeText(props.textContent)
|
||||
copied.value = true
|
||||
message.success(t('Copied to clipboard'))
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
message.error(t('Copy failed'))
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
copying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showCopySuccess() {
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 4000)
|
||||
message.success(t('Copied to clipboard'))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.click-to-copy-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.copy-input {
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
margin-right: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -2,26 +2,18 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { loginApi, getUserInfoApi, logoutApi, isCookieExpired, getSessionUser } from '../api/auth'
|
||||
import { setInitializingAuth } from '../utils/fetchInterceptor'
|
||||
import { getAccessToken, isTokenExpired, clearTokens } from '../utils/oauth2'
|
||||
import { refreshAccessToken } from '../api/oauth2'
|
||||
import { getAccountInfo } from '../api/account'
|
||||
|
||||
export interface User {
|
||||
user: string
|
||||
user_type: string
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'auth_user'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const loading = ref(false)
|
||||
const isAuthenticated = ref(false)
|
||||
|
||||
const isLoggedIn = computed(() => {
|
||||
const hasToken = !!getAccessToken()
|
||||
return isAuthenticated.value && !!user.value && hasToken
|
||||
})
|
||||
const isLoggedIn = computed(() => isAuthenticated.value && !!user.value)
|
||||
|
||||
// 判断是否是认证错误
|
||||
const isAuthError = (error: any): boolean => {
|
||||
@ -31,72 +23,21 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
error?.message?.includes('Cookie已过期')
|
||||
}
|
||||
|
||||
// 从 localStorage 加载用户信息
|
||||
const loadUserFromStorage = (): User | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('从 localStorage 加载用户信息失败:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 保存用户信息到 localStorage
|
||||
const saveUserToStorage = (userInfo: User) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(userInfo))
|
||||
} catch (error) {
|
||||
console.error('保存用户信息到 localStorage 失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 清除 localStorage 中的用户信息
|
||||
const clearUserFromStorage = () => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch (error) {
|
||||
console.error('清除 localStorage 用户信息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置用户状态(统一的状态更新方法,保存到localStorage)
|
||||
// 设置用户状态(只更新内存状态,不保存到 localStorage)
|
||||
const setUserState = (userInfo: User) => {
|
||||
user.value = userInfo
|
||||
isAuthenticated.value = true
|
||||
saveUserToStorage(userInfo)
|
||||
}
|
||||
|
||||
// 清除用户状态
|
||||
const clearUserState = () => {
|
||||
user.value = null
|
||||
isAuthenticated.value = false
|
||||
clearUserFromStorage()
|
||||
// 清除 OAuth2 token
|
||||
clearTokens()
|
||||
}
|
||||
|
||||
// 验证并更新用户信息
|
||||
const validateAndUpdateUser = async (): Promise<boolean> => {
|
||||
try {
|
||||
// 优先使用 OAuth2 token 获取账户信息
|
||||
const accessToken = getAccessToken()
|
||||
if (accessToken) {
|
||||
const accountInfo = await getAccountInfo()
|
||||
if (accountInfo.success && accountInfo.data) {
|
||||
const userData = accountInfo.data.user || accountInfo.data
|
||||
const userInfo: User = {
|
||||
user: userData.name || userData.user || '',
|
||||
user_type: userData.user_type || 'System User'
|
||||
}
|
||||
setUserState(userInfo)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有 OAuth2 token,尝试使用 Cookie 认证(向后兼容)
|
||||
const userInfo = await getUserInfoApi()
|
||||
setUserState(userInfo)
|
||||
return true
|
||||
@ -109,91 +50,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
const login = async (username: string, password: string) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await loginApi(username, password)
|
||||
@ -214,16 +71,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
// 尝试撤销 OAuth2 token
|
||||
try {
|
||||
const { revokeToken } = await import('../api/oauth2')
|
||||
await revokeToken()
|
||||
} catch (error) {
|
||||
// OAuth2 撤销失败,继续执行登出
|
||||
console.warn('撤销 OAuth2 token 失败:', error)
|
||||
}
|
||||
|
||||
// 尝试使用 Cookie 登出(向后兼容)
|
||||
await logoutApi()
|
||||
} catch (error) {
|
||||
console.error('登出错误:', error)
|
||||
@ -236,58 +83,21 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
setInitializingAuth(true)
|
||||
|
||||
try {
|
||||
// 优先检查 OAuth2 access token
|
||||
const accessToken = getAccessToken()
|
||||
|
||||
if (accessToken) {
|
||||
// 检查 token 是否过期,如果过期则尝试刷新
|
||||
if (isTokenExpired()) {
|
||||
try {
|
||||
await refreshAccessToken()
|
||||
} catch (error) {
|
||||
console.error('刷新 token 失败:', error)
|
||||
clearUserState()
|
||||
setInitializingAuth(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 OAuth2 token 验证并获取用户信息
|
||||
const success = await validateAndUpdateUser()
|
||||
if (success) {
|
||||
setInitializingAuth(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有 OAuth2 token,尝试使用 Cookie 认证(向后兼容)
|
||||
// 检查cookie状态
|
||||
const userId = getSessionUser()
|
||||
const hasSessionCookie = !isCookieExpired()
|
||||
const hasCookie = userId || hasSessionCookie
|
||||
|
||||
// 如果有cookie,尝试验证并获取用户信息
|
||||
if (hasCookie) {
|
||||
const success = await validateAndUpdateUser()
|
||||
if (success) {
|
||||
setInitializingAuth(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 如果都没有,尝试从localStorage恢复
|
||||
const storedUser = loadUserFromStorage()
|
||||
if (storedUser) {
|
||||
user.value = storedUser
|
||||
isAuthenticated.value = true
|
||||
// 尝试在后台验证用户信息,失败也不影响当前状态
|
||||
validateAndUpdateUser().catch(() => {
|
||||
// 静默失败,保持localStorage中的状态
|
||||
})
|
||||
} else {
|
||||
// 既没有token也没有cookie也没有localStorage,清除认证状态
|
||||
if (isAuthenticated.value) {
|
||||
clearUserState()
|
||||
}
|
||||
}
|
||||
// 如果没有cookie或cookie验证失败,清除认证状态
|
||||
clearUserState()
|
||||
} finally {
|
||||
setInitializingAuth(false)
|
||||
}
|
||||
@ -313,8 +123,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
isAuthenticated,
|
||||
isLoggedIn,
|
||||
login,
|
||||
loginWithPassword,
|
||||
handleOAuthCallback,
|
||||
logout,
|
||||
initAuth,
|
||||
updateUserInfo
|
||||
|
||||
@ -50,7 +50,6 @@ function saveToStorage(items: AppMenuItem[]) {
|
||||
// - 非 System User 只能看到工具市场
|
||||
function getDefaultMenus(): AppMenuItem[] {
|
||||
return [
|
||||
{ id: 'dashboard', key: 'Dashboard', label: 'Dashboard', icon: 'tabler:dashboard', routeName: 'Dashboard', order: 1, type: 'route' },
|
||||
{ id: 'work', key: 'work', label: 'Work', icon: 'tabler:device-desktop', type: 'workspace', workspaceName: 'work', url: '/workspace/work', order: 2 },
|
||||
{ id: 'design', key: 'design', label: 'Design', icon: 'tabler:pencil', type: 'workspace', workspaceName: 'design', url: '/workspace/design', order: 3 },
|
||||
{ id: 'website', key: 'website', label: 'Website', icon: 'tabler:world', type: 'workspace', workspaceName: 'jsite', url: '/workspace/jsite', order: 4 },
|
||||
|
||||
@ -9,59 +9,9 @@ export function setInitializingAuth(value: boolean) {
|
||||
isInitializingAuth = value
|
||||
}
|
||||
|
||||
// 包装fetch函数,添加 Bearer token 和 401/403 错误处理
|
||||
// 包装fetch函数,添加401/403错误处理
|
||||
window.fetch = async function(...args: Parameters<typeof fetch>): Promise<Response> {
|
||||
// 添加 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
|
||||
})
|
||||
const response = await originalFetch(...args)
|
||||
|
||||
// 检查响应状态码(仅在401/403时处理)
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
@ -78,7 +28,7 @@ window.fetch = async function(...args: Parameters<typeof fetch>): Promise<Respon
|
||||
|
||||
// 如果用户已登录,执行登出操作
|
||||
if (authStore.isLoggedIn) {
|
||||
console.warn('检测到401/403错误,Token已过期,自动退出登录')
|
||||
console.warn('检测到401/403错误,Cookie已过期,自动退出登录')
|
||||
await authStore.logout()
|
||||
|
||||
// 跳转到登录页(避免重复跳转)
|
||||
|
||||
@ -1,173 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@ -1,213 +0,0 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">{{ t('Dashboard') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16" :responsive="'screen'" :item-responsive="true" class="stats-grid">
|
||||
<!-- 原来的4个统计 -->
|
||||
<n-grid-item>
|
||||
<n-card>
|
||||
<n-statistic :label="t('Total Agents')" :value="stats.agents" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card>
|
||||
<n-statistic :label="t('Total Nodes')" :value="stats.nodes" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card>
|
||||
<n-statistic :label="t('Task Queue')" :value="stats.taskQueue" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card>
|
||||
<n-statistic :label="t('Scheduled Tasks')" :value="stats.scheduledTasks" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
|
||||
<!-- 新增的5个统计 -->
|
||||
<n-grid-item>
|
||||
<n-card>
|
||||
<n-statistic :label="t('Knowledge Base')" :value="stats.knowledgeBase" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card>
|
||||
<n-statistic :label="t('Note')" :value="stats.note" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card>
|
||||
<n-statistic :label="t('Event')" :value="stats.event" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card>
|
||||
<n-statistic :label="t('ToDo')" :value="stats.todo" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
<n-grid-item>
|
||||
<n-card>
|
||||
<n-statistic :label="t('File')" :value="stats.file" />
|
||||
</n-card>
|
||||
</n-grid-item>
|
||||
</n-grid>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } from 'vue'
|
||||
import {
|
||||
NGrid,
|
||||
NGridItem,
|
||||
NCard,
|
||||
NStatistic
|
||||
} from 'naive-ui'
|
||||
import { t } from '../shared/i18n'
|
||||
import { getCount, getLocalJobCount } from '../shared/api/common'
|
||||
|
||||
const stats = reactive({
|
||||
agents: 0,
|
||||
nodes: 0,
|
||||
taskQueue: 0,
|
||||
scheduledTasks: 0,
|
||||
knowledgeBase: 0,
|
||||
note: 0,
|
||||
event: 0,
|
||||
todo: 0,
|
||||
file: 0
|
||||
})
|
||||
|
||||
// 加载统计数据
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
// 第一行:原来的4个统计
|
||||
// 获取智能体总数
|
||||
const agentsResult = await getCount('Local Ai Agent')
|
||||
if (agentsResult.success) {
|
||||
stats.agents = agentsResult.count || 0
|
||||
}
|
||||
|
||||
// 获取节点总数
|
||||
const nodesResult = await getCount('Local Ai Node')
|
||||
if (nodesResult.success) {
|
||||
stats.nodes = nodesResult.count || 0
|
||||
}
|
||||
|
||||
// 获取任务队列数量 - 使用Local Job (虚拟pagetype,使用专用API)
|
||||
const taskQueueResult = await getLocalJobCount()
|
||||
if (taskQueueResult.success) {
|
||||
stats.taskQueue = taskQueueResult.count || 0
|
||||
}
|
||||
|
||||
// 获取定时任务数量
|
||||
const scheduledTasksResult = await getCount('Local Scheduled Job')
|
||||
if (scheduledTasksResult.success) {
|
||||
stats.scheduledTasks = scheduledTasksResult.count || 0
|
||||
}
|
||||
|
||||
// 第二行:新增的5个统计
|
||||
// 获取知识库总数
|
||||
const knowledgeBaseResult = await getCount('Knowledge Base')
|
||||
if (knowledgeBaseResult.success) {
|
||||
stats.knowledgeBase = knowledgeBaseResult.count || 0
|
||||
}
|
||||
|
||||
// 获取笔记总数
|
||||
const noteResult = await getCount('Note')
|
||||
if (noteResult.success) {
|
||||
stats.note = noteResult.count || 0
|
||||
}
|
||||
|
||||
// 获取事件总数
|
||||
const eventResult = await getCount('Event')
|
||||
if (eventResult.success) {
|
||||
stats.event = eventResult.count || 0
|
||||
}
|
||||
|
||||
// 获取待办事项总数
|
||||
const todoResult = await getCount('ToDo')
|
||||
if (todoResult.success) {
|
||||
stats.todo = todoResult.count || 0
|
||||
}
|
||||
|
||||
// 获取文件总数
|
||||
const fileResult = await getCount('File')
|
||||
if (fileResult.success) {
|
||||
stats.file = fileResult.count || 0
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
// 加载统计数据
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 16px;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid {
|
||||
--n-grid-cols: 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-page {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
margin-bottom: 16px;
|
||||
--n-grid-cols: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.dashboard-page {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
--n-grid-cols: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -9,6 +9,7 @@ import { signupApi } from '@/shared/api/auth'
|
||||
import AppHeader from '@/app/layouts/AppHeader.vue'
|
||||
import AppSidebar from '@/app/layouts/AppSidebar.vue'
|
||||
import { compressImageFile } from '@/shared/utils/imageResize'
|
||||
import axios from 'axios'
|
||||
|
||||
const message = useMessage()
|
||||
const authStore = useAuthStore()
|
||||
@ -16,12 +17,32 @@ const appName = computed(() => localStorage.getItem('appName') || 'Jingrow')
|
||||
const currentYear = computed(() => new Date().getFullYear())
|
||||
const logoUrl = computed(() => '/logo.svg')
|
||||
|
||||
// 注册弹窗状态
|
||||
// Login/Signup modal state
|
||||
const showLoginModal = ref(false)
|
||||
const showSignupModal = ref(false)
|
||||
const loginFormRef = ref()
|
||||
const signupFormRef = ref()
|
||||
const loginLoading = ref(false)
|
||||
const signupLoading = ref(false)
|
||||
const showSignupLink = ref(true)
|
||||
|
||||
// 注册表单
|
||||
// Login form
|
||||
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' }
|
||||
]
|
||||
}
|
||||
|
||||
// Signup form
|
||||
const signupFormData = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
@ -55,13 +76,13 @@ const signupRules = computed(() => {
|
||||
]
|
||||
}
|
||||
|
||||
// 英文版:email必填,手机号可选
|
||||
// English version: email required, phone optional
|
||||
if (isEnglish.value) {
|
||||
rules.email = [
|
||||
{ required: true, message: t('Please enter email'), trigger: 'blur' },
|
||||
{
|
||||
validator: (_rule: any, value: string) => {
|
||||
// required规则已处理空值,这里只验证格式
|
||||
// Required rule handles empty values, only validate format here
|
||||
if (!value) return true
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(value)) {
|
||||
@ -86,7 +107,7 @@ const signupRules = computed(() => {
|
||||
}
|
||||
]
|
||||
} else {
|
||||
// 中文版:email可选,手机号必填
|
||||
// Chinese version: email optional, phone required
|
||||
rules.email = [
|
||||
{
|
||||
validator: (_rule: any, value: string) => {
|
||||
@ -113,21 +134,36 @@ const signupRules = computed(() => {
|
||||
return rules
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
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 handleLogin = () => {
|
||||
showLoginModal.value = true
|
||||
}
|
||||
|
||||
const handleSignup = () => {
|
||||
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 () => {
|
||||
try {
|
||||
@ -146,8 +182,6 @@ const handleSignupSubmit = async () => {
|
||||
if (result.user) {
|
||||
authStore.user = result.user
|
||||
authStore.isAuthenticated = true
|
||||
localStorage.setItem('jingrow_user', JSON.stringify(result.user))
|
||||
localStorage.setItem('jingrow_authenticated', 'true')
|
||||
showSignupModal.value = false
|
||||
signupFormData.username = ''
|
||||
signupFormData.password = ''
|
||||
@ -155,47 +189,50 @@ const handleSignupSubmit = async () => {
|
||||
signupFormData.email = ''
|
||||
signupFormData.phoneNumber = ''
|
||||
} else {
|
||||
// 注册成功后,提示用户登录
|
||||
showSignupModal.value = false
|
||||
signupFormData.username = ''
|
||||
signupFormData.password = ''
|
||||
signupFormData.confirmPassword = ''
|
||||
signupFormData.email = ''
|
||||
signupFormData.phoneNumber = ''
|
||||
message.success(t('Sign up successful, please login'))
|
||||
// 延迟跳转到登录页面
|
||||
setTimeout(() => {
|
||||
handleLogin()
|
||||
}, 1000)
|
||||
const loginResult = await authStore.login(signupFormData.username, signupFormData.password)
|
||||
if (loginResult.success) {
|
||||
showSignupModal.value = false
|
||||
signupFormData.username = ''
|
||||
signupFormData.password = ''
|
||||
signupFormData.confirmPassword = ''
|
||||
signupFormData.email = ''
|
||||
signupFormData.phoneNumber = ''
|
||||
} else {
|
||||
message.warning(loginResult.error || t('Sign up successful, but auto login failed. Please login manually'))
|
||||
showSignupModal.value = false
|
||||
showLoginModal.value = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const errorMsg = result.error || t('Sign up failed')
|
||||
console.error('注册失败:', errorMsg, result)
|
||||
message.error(errorMsg)
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('注册异常:', error)
|
||||
message.error(error.message || t('Sign up failed, please try again'))
|
||||
} finally {
|
||||
signupLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchToSignup = () => {
|
||||
showLoginModal.value = false
|
||||
showSignupModal.value = true
|
||||
}
|
||||
|
||||
const switchToLogin = () => {
|
||||
showSignupModal.value = false
|
||||
handleLogin()
|
||||
showLoginModal.value = true
|
||||
}
|
||||
|
||||
// 登录状态
|
||||
// Login state
|
||||
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
||||
|
||||
// Sidebar 折叠状态
|
||||
// Sidebar collapse state
|
||||
const SIDEBAR_COLLAPSE_KEY = 'app.sidebar.collapsed'
|
||||
const collapsed = ref(localStorage.getItem(SIDEBAR_COLLAPSE_KEY) === 'true')
|
||||
const isMobile = ref(false)
|
||||
|
||||
// 检测屏幕尺寸
|
||||
// Check screen size
|
||||
const checkIsMobile = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
if (isMobile.value) {
|
||||
@ -203,29 +240,29 @@ const checkIsMobile = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 切换侧边栏
|
||||
// Toggle sidebar
|
||||
const toggleSidebar = () => {
|
||||
collapsed.value = !collapsed.value
|
||||
}
|
||||
|
||||
// 侧边栏折叠事件
|
||||
// Sidebar collapse event
|
||||
const onSidebarCollapse = () => {
|
||||
collapsed.value = true
|
||||
}
|
||||
|
||||
// 侧边栏展开事件
|
||||
// Sidebar expand event
|
||||
const onSidebarExpand = () => {
|
||||
collapsed.value = false
|
||||
}
|
||||
|
||||
// 菜单选择事件 - 移动端自动关闭
|
||||
// Menu select event - auto close on mobile
|
||||
const onMenuSelect = () => {
|
||||
if (isMobile.value) {
|
||||
collapsed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 监听窗口大小变化
|
||||
// Handle window resize
|
||||
const handleWindowResize = () => {
|
||||
checkIsMobile()
|
||||
adjustContainerSize()
|
||||
@ -250,13 +287,13 @@ interface HistoryItem {
|
||||
}
|
||||
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
// urlInputRef 在模板中使用,lint 警告是误报
|
||||
// urlInputRef is used in template, lint warning is false positive
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const urlInputRef = ref<HTMLInputElement | null>(null)
|
||||
const uploadedImage = ref<File | null>(null)
|
||||
const uploadedImageUrl = ref<string>('')
|
||||
const resultImage = ref<string>('')
|
||||
const resultImageBlobUrl = ref<string>('') // 缓存的 blob URL,用于下载
|
||||
const resultImageBlobUrl = ref<string>('') // Cached blob URL for download
|
||||
const imageUrl = ref<string>('')
|
||||
const resultImageUrl = computed(() => {
|
||||
if (!resultImage.value) return ''
|
||||
@ -270,57 +307,57 @@ const dragCounter = ref(0)
|
||||
const processing = ref(false)
|
||||
const splitPosition = ref(0)
|
||||
|
||||
// 示例图片用于快速体验 - 使用适合抠图的图片
|
||||
// Sample images for quick experience - suitable for background removal
|
||||
const sampleImages = [
|
||||
{
|
||||
id: 'sample-1',
|
||||
url: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=1024&h=1024&fit=crop&q=80',
|
||||
name: '人物示例'
|
||||
name: t('Portrait Sample')
|
||||
},
|
||||
{
|
||||
id: 'sample-2',
|
||||
url: 'https://images.unsplash.com/photo-1529626455594-4ff0802cfb7e?w=1024&h=1024&fit=crop&q=80',
|
||||
name: '人物示例'
|
||||
name: t('Portrait Sample')
|
||||
},
|
||||
{
|
||||
id: 'sample-3',
|
||||
url: 'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=1024&h=1024&fit=crop&q=80',
|
||||
name: '产品示例'
|
||||
name: t('Product Sample')
|
||||
},
|
||||
{
|
||||
id: 'sample-4',
|
||||
url: 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=1024&h=1024&fit=crop&q=80',
|
||||
name: '产品示例'
|
||||
name: t('Product Sample')
|
||||
},
|
||||
{
|
||||
id: 'sample-5',
|
||||
url: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=1024&h=1024&fit=crop&q=80',
|
||||
name: '动物示例'
|
||||
name: t('Animal Sample')
|
||||
},
|
||||
{
|
||||
id: 'sample-6',
|
||||
url: 'https://images.unsplash.com/photo-1551963831-b3b1ca40c98e?w=1024&h=1024&fit=crop&q=80',
|
||||
name: '物品示例'
|
||||
name: t('Object Sample')
|
||||
},
|
||||
{
|
||||
id: 'sample-7',
|
||||
url: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=1024&h=1024&fit=crop&q=80',
|
||||
name: '人物示例'
|
||||
name: t('Portrait Sample')
|
||||
},
|
||||
{
|
||||
id: 'sample-8',
|
||||
url: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=1024&h=1024&fit=crop&q=80',
|
||||
name: '人物示例'
|
||||
name: t('Portrait Sample')
|
||||
},
|
||||
{
|
||||
id: 'sample-9',
|
||||
url: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=1024&h=1024&fit=crop&q=80',
|
||||
name: '人物示例'
|
||||
name: t('Portrait Sample')
|
||||
},
|
||||
{
|
||||
id: 'sample-10',
|
||||
url: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=1024&h=1024&fit=crop&q=80',
|
||||
name: '人物示例'
|
||||
name: t('Portrait Sample')
|
||||
}
|
||||
]
|
||||
const comparisonContainerRef = ref<HTMLElement | null>(null)
|
||||
@ -414,12 +451,11 @@ const processFile = async (file: File) => {
|
||||
}
|
||||
reader.readAsDataURL(compressedFile)
|
||||
} catch (error) {
|
||||
console.error('图片压缩失败:', error)
|
||||
message.error(t('Image processing failed, please try again'))
|
||||
message.error(t('Image processing failed, please try again'))
|
||||
}
|
||||
}
|
||||
|
||||
// 处理示例图片点击
|
||||
// Handle sample image click
|
||||
const handleSampleImageClick = async (imageUrl: string) => {
|
||||
if (processing.value) return
|
||||
|
||||
@ -433,7 +469,7 @@ const handleSampleImageClick = async (imageUrl: string) => {
|
||||
|
||||
const blob = await response.blob()
|
||||
|
||||
// 验证文件类型
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
if (!validTypes.includes(blob.type)) {
|
||||
message.warning(t('Unsupported image format. Please use JPG, PNG, or WebP'))
|
||||
@ -441,7 +477,7 @@ const handleSampleImageClick = async (imageUrl: string) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
// Validate file size
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (blob.size > maxSize) {
|
||||
message.warning(t('Image size exceeds 10MB limit'))
|
||||
@ -449,11 +485,10 @@ const handleSampleImageClick = async (imageUrl: string) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为File对象
|
||||
// Convert to File object
|
||||
const file = new File([blob], 'sample-image.jpg', { type: blob.type })
|
||||
await processFile(file)
|
||||
} catch (error: any) {
|
||||
console.error('加载示例图片失败:', error)
|
||||
let errorMessage = t('Failed to load sample image')
|
||||
|
||||
if (error.message?.includes('CORS')) {
|
||||
@ -476,7 +511,7 @@ const handleRemoveBackground = async () => {
|
||||
processing.value = true
|
||||
resultImage.value = ''
|
||||
|
||||
// 处理成功结果的辅助函数(文件内部使用)
|
||||
// Helper function to handle successful result (internal use)
|
||||
const handleSuccess = async (imageUrl: string): Promise<void> => {
|
||||
resultImage.value = imageUrl
|
||||
await cacheResultImage(imageUrl)
|
||||
@ -542,13 +577,13 @@ const handleRemoveBackground = async () => {
|
||||
message.error(result.error)
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse JSON:', parseError, 'Line:', line)
|
||||
// Failed to parse JSON, continue processing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后一行
|
||||
// Process last line
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const result = JSON.parse(buffer.trim())
|
||||
@ -559,7 +594,6 @@ const handleRemoveBackground = async () => {
|
||||
message.error(result.error || t('Failed to remove background'))
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse final JSON:', parseError)
|
||||
message.error(t('Failed to parse response'))
|
||||
}
|
||||
} else {
|
||||
@ -581,17 +615,17 @@ const handleRemoveBackground = async () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存结果图片为 blob URL,用于下载
|
||||
* Cache result image as blob URL for download
|
||||
*/
|
||||
const cacheResultImage = async (imageUrl: string) => {
|
||||
try {
|
||||
// 清理旧的 blob URL
|
||||
// Clean up old blob URL
|
||||
if (resultImageBlobUrl.value) {
|
||||
URL.revokeObjectURL(resultImageBlobUrl.value)
|
||||
resultImageBlobUrl.value = ''
|
||||
}
|
||||
|
||||
// 获取图片并转换为 blob URL
|
||||
// Fetch image and convert to blob URL
|
||||
const response = await fetch(imageUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status}`)
|
||||
@ -599,26 +633,25 @@ const cacheResultImage = async (imageUrl: string) => {
|
||||
const blob = await response.blob()
|
||||
resultImageBlobUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) {
|
||||
console.error('缓存图片失败:', error)
|
||||
// 缓存失败不影响显示,只是下载时需要重新获取
|
||||
// Cache failure doesn't affect display, only download needs to refetch
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!resultImage.value) return
|
||||
|
||||
// 检查登录状态
|
||||
// Check login status
|
||||
if (!isLoggedIn.value) {
|
||||
message.warning(t('Please login to download'))
|
||||
handleLogin()
|
||||
showLoginModal.value = true
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 优先使用缓存的 blob URL
|
||||
// Prefer cached blob URL
|
||||
let blobUrl = resultImageBlobUrl.value
|
||||
|
||||
// 如果没有缓存,则获取并缓存
|
||||
// If no cache, fetch and cache
|
||||
if (!blobUrl) {
|
||||
const response = await fetch(resultImage.value)
|
||||
if (!response.ok) {
|
||||
@ -633,9 +666,8 @@ const handleDownload = async () => {
|
||||
link.href = blobUrl
|
||||
link.download = `removed-background-${Date.now()}.png`
|
||||
link.click()
|
||||
// 不立即清理 blob URL,保留缓存供后续下载使用
|
||||
// Don't immediately clean up blob URL, keep cache for subsequent downloads
|
||||
} catch (error) {
|
||||
console.error('下载失败:', error)
|
||||
message.error(t('Failed to download image'))
|
||||
}
|
||||
}
|
||||
@ -644,7 +676,7 @@ const resetUpload = () => {
|
||||
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(uploadedImageUrl.value)
|
||||
}
|
||||
// 清理结果图片的 blob URL 缓存
|
||||
// Clean up result image blob URL cache
|
||||
if (resultImageBlobUrl.value) {
|
||||
URL.revokeObjectURL(resultImageBlobUrl.value)
|
||||
resultImageBlobUrl.value = ''
|
||||
@ -670,10 +702,10 @@ const resetUpload = () => {
|
||||
|
||||
const adjustContainerSize = async () => {
|
||||
await nextTick()
|
||||
// 等待多个渲染周期确保 DOM 完全更新
|
||||
// Wait for multiple render cycles to ensure DOM is fully updated
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
// 处理对比视图容器
|
||||
// Handle comparison view container
|
||||
const container = comparisonContainerRef.value
|
||||
if (container) {
|
||||
const img = originalImageRef.value || resultImageRef.value
|
||||
@ -707,7 +739,7 @@ const adjustContainerSize = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理单图视图容器
|
||||
// Handle single image view container
|
||||
const singleWrapper = singleImageWrapperRef.value
|
||||
if (singleWrapper) {
|
||||
const img = singleImageRef.value
|
||||
@ -748,27 +780,38 @@ const adjustContainerSize = async () => {
|
||||
|
||||
watch([uploadedImageUrl, resultImageUrl], adjustContainerSize, { immediate: true })
|
||||
|
||||
const handleSplitLineMouseDown = (e: MouseEvent) => {
|
||||
const handleSplitLineMouseDown = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault()
|
||||
isDraggingSplitLine.value = true
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const getClientX = (event: MouseEvent | TouchEvent): number => {
|
||||
if ('touches' in event && event.touches.length > 0) {
|
||||
return event.touches[0].clientX
|
||||
}
|
||||
return (event as MouseEvent).clientX
|
||||
}
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
|
||||
if (!comparisonContainerRef.value || !isDraggingSplitLine.value) return
|
||||
|
||||
const rect = comparisonContainerRef.value.getBoundingClientRect()
|
||||
const x = moveEvent.clientX - rect.left
|
||||
const x = getClientX(moveEvent) - rect.left
|
||||
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100))
|
||||
splitPosition.value = percentage
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const handleEnd = () => {
|
||||
isDraggingSplitLine.value = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.removeEventListener('mousemove', handleMove as EventListener)
|
||||
document.removeEventListener('mouseup', handleEnd)
|
||||
document.removeEventListener('touchmove', handleMove as EventListener)
|
||||
document.removeEventListener('touchend', handleEnd)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
document.addEventListener('mousemove', handleMove as EventListener)
|
||||
document.addEventListener('mouseup', handleEnd)
|
||||
document.addEventListener('touchmove', handleMove as EventListener, { passive: false })
|
||||
document.addEventListener('touchend', handleEnd)
|
||||
}
|
||||
|
||||
const handlePaste = async (event: ClipboardEvent) => {
|
||||
@ -867,13 +910,13 @@ const handleUrlSubmit = async () => {
|
||||
|
||||
const originalFile = new File([blob], 'image-from-url', { type: blob.type })
|
||||
|
||||
// 压缩图片到 1024x1024
|
||||
// Compress image to 1024x1024
|
||||
try {
|
||||
const compressedFile = await compressImageFile(originalFile, {
|
||||
maxWidth: 1024,
|
||||
maxHeight: 1024,
|
||||
quality: 0.92,
|
||||
mode: 'contain' // 等比缩放
|
||||
mode: 'contain' // Maintain aspect ratio
|
||||
})
|
||||
|
||||
uploadedImage.value = compressedFile
|
||||
@ -881,12 +924,10 @@ const handleUrlSubmit = async () => {
|
||||
|
||||
await handleRemoveBackground()
|
||||
} catch (error) {
|
||||
console.error('图片压缩失败:', error)
|
||||
message.error(t('Image processing failed, please try again'))
|
||||
processing.value = false
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载图片URL失败:', error)
|
||||
let errorMessage = t('Failed to load image from URL')
|
||||
|
||||
if (error.message?.includes('CORS')) {
|
||||
@ -910,7 +951,7 @@ const selectHistoryItem = async (index: number) => {
|
||||
splitPosition.value = 0
|
||||
uploadedImage.value = item.originalImageFile
|
||||
|
||||
// 缓存历史记录的结果图片
|
||||
// Cache result image from history
|
||||
if (item.resultImage) {
|
||||
await cacheResultImage(item.resultImage)
|
||||
}
|
||||
@ -947,11 +988,11 @@ onMounted(async () => {
|
||||
window.addEventListener('resize', handleWindowResize)
|
||||
window.addEventListener('paste', handlePaste)
|
||||
|
||||
// 初始化认证状态
|
||||
// Initialize auth state
|
||||
await authStore.initAuth()
|
||||
|
||||
|
||||
// 检测移动端
|
||||
// Detect mobile
|
||||
checkIsMobile()
|
||||
})
|
||||
|
||||
@ -961,7 +1002,7 @@ onUnmounted(() => {
|
||||
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(uploadedImageUrl.value)
|
||||
}
|
||||
// 清理结果图片的 blob URL 缓存
|
||||
// Clean up result image blob URL cache
|
||||
if (resultImageBlobUrl.value) {
|
||||
URL.revokeObjectURL(resultImageBlobUrl.value)
|
||||
}
|
||||
@ -970,7 +1011,7 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="home-page" @dragenter.prevent="handleDragEnter" @dragover.prevent="handleDragOver" @dragleave="handleDragLeave" @drop.prevent="handleDrop">
|
||||
<!-- 未登录状态:显示营销页面布局 -->
|
||||
<!-- Not logged in: show marketing page layout -->
|
||||
<template v-if="!isLoggedIn">
|
||||
<!-- Header -->
|
||||
<header class="marketing-header">
|
||||
@ -1058,7 +1099,7 @@ onUnmounted(() => {
|
||||
<div class="comparison-image result-image" :style="{ clipPath: `inset(0 0 0 ${splitPosition}%)` }">
|
||||
<img ref="resultImageRef" :src="resultImageUrl" :alt="t('Background Removed')" @load="adjustContainerSize" />
|
||||
</div>
|
||||
<div class="split-line" :class="{ dragging: isDraggingSplitLine }" :style="{ left: `${splitPosition}%` }" @mousedown.prevent.stop="handleSplitLineMouseDown">
|
||||
<div class="split-line" :class="{ dragging: isDraggingSplitLine }" :style="{ left: `${splitPosition}%` }" @mousedown.prevent.stop="handleSplitLineMouseDown" @touchstart.prevent.stop="handleSplitLineMouseDown">
|
||||
<div class="split-line-handle">
|
||||
<i class="fa fa-arrows-h"></i>
|
||||
</div>
|
||||
@ -1120,7 +1161,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 示例图片区块 -->
|
||||
<!-- Sample images section -->
|
||||
<div v-if="!uploadedImage" class="sample-images-section">
|
||||
<p class="sample-images-title">{{ t('Click image to try') }}</p>
|
||||
<div class="sample-images-container">
|
||||
@ -1149,7 +1190,7 @@ onUnmounted(() => {
|
||||
<footer class="marketing-footer">
|
||||
<div class="footer-container">
|
||||
<div class="footer-content">
|
||||
<!-- 左侧:Logo 和社交媒体 -->
|
||||
<!-- Left: Logo and social media -->
|
||||
<div class="footer-left">
|
||||
<div class="footer-logo">
|
||||
<router-link to="/" class="logo-link">
|
||||
@ -1173,7 +1214,7 @@ onUnmounted(() => {
|
||||
<Icon icon="ant-design:zhihu-square-filled" />
|
||||
</a>
|
||||
</template>
|
||||
<!-- 英文版社交图标 -->
|
||||
<!-- English version social icons -->
|
||||
<template v-else>
|
||||
<a href="#" class="social-icon" title="Twitter">
|
||||
<Icon icon="tabler:brand-twitter" />
|
||||
@ -1197,12 +1238,11 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:三列链接 -->
|
||||
<!-- Right: three column links -->
|
||||
<div class="footer-right">
|
||||
<div class="footer-column">
|
||||
<h3 class="footer-title">{{ t('Products & Services') }}</h3>
|
||||
<ul class="footer-links">
|
||||
<li><a href="https://jingrow.com" target="_blank">Jingrow</a></li>
|
||||
<li><a href="https://jingrowtools.com" target="_blank">{{ t('Jingrow Tools') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
@ -1230,10 +1270,10 @@ onUnmounted(() => {
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<!-- 已登录状态:显示应用布局(带 sidebar 和 header) -->
|
||||
<!-- Logged in: show app layout (with sidebar and header) -->
|
||||
<template v-else>
|
||||
<n-layout has-sider class="app-layout">
|
||||
<!-- 侧边栏 -->
|
||||
<!-- Sidebar -->
|
||||
<n-layout-sider
|
||||
bordered
|
||||
collapse-mode="width"
|
||||
@ -1249,14 +1289,14 @@ onUnmounted(() => {
|
||||
<AppSidebar :collapsed="collapsed" @menu-select="onMenuSelect" />
|
||||
</n-layout-sider>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<!-- Main content area -->
|
||||
<n-layout>
|
||||
<!-- 顶部导航 -->
|
||||
<!-- Top navigation -->
|
||||
<n-layout-header bordered>
|
||||
<AppHeader @toggle-sidebar="toggleSidebar" />
|
||||
</n-layout-header>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<!-- Content area -->
|
||||
<n-layout-content>
|
||||
<div class="content-wrapper">
|
||||
<div v-if="isDragging" class="global-drag-overlay">
|
||||
@ -1325,7 +1365,7 @@ onUnmounted(() => {
|
||||
<div class="comparison-image result-image" :style="{ clipPath: `inset(0 0 0 ${splitPosition}%)` }">
|
||||
<img ref="resultImageRef" :src="resultImageUrl" :alt="t('Background Removed')" @load="adjustContainerSize" />
|
||||
</div>
|
||||
<div class="split-line" :class="{ dragging: isDraggingSplitLine }" :style="{ left: `${splitPosition}%` }" @mousedown.prevent.stop="handleSplitLineMouseDown">
|
||||
<div class="split-line" :class="{ dragging: isDraggingSplitLine }" :style="{ left: `${splitPosition}%` }" @mousedown.prevent.stop="handleSplitLineMouseDown" @touchstart.prevent.stop="handleSplitLineMouseDown">
|
||||
<div class="split-line-handle">
|
||||
<i class="fa fa-arrows-h"></i>
|
||||
</div>
|
||||
@ -1387,7 +1427,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 示例图片区块 -->
|
||||
<!-- Sample images section -->
|
||||
<div v-if="!uploadedImage" class="sample-images-section">
|
||||
<p class="sample-images-title">{{ t('Click image to try') }}</p>
|
||||
<div class="sample-images-container">
|
||||
@ -1414,7 +1454,7 @@ onUnmounted(() => {
|
||||
</n-layout-content>
|
||||
</n-layout>
|
||||
|
||||
<!-- 移动端遮罩层 -->
|
||||
<!-- Mobile overlay -->
|
||||
<div
|
||||
v-if="isMobile && !collapsed"
|
||||
class="mobile-overlay"
|
||||
@ -1423,8 +1463,76 @@ onUnmounted(() => {
|
||||
</n-layout>
|
||||
</template>
|
||||
|
||||
<!-- Login modal -->
|
||||
<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>
|
||||
|
||||
<!-- Signup modal -->
|
||||
<n-modal
|
||||
v-model:show="showSignupModal"
|
||||
preset="card"
|
||||
@ -1535,7 +1643,7 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-page {
|
||||
min-height: 100vh; /* 使用 min-height 而不是固定 height */
|
||||
min-height: 100vh; /* Use min-height instead of fixed height */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
@ -1543,7 +1651,7 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 应用布局样式(登录后) */
|
||||
/* App layout styles (after login) */
|
||||
.app-layout {
|
||||
height: 100vh;
|
||||
}
|
||||
@ -1554,14 +1662,14 @@ onUnmounted(() => {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 使用Naive UI内置的sticky功能 */
|
||||
/* Use Naive UI built-in sticky functionality */
|
||||
:deep(.n-layout-header) {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* 移动端遮罩层 */
|
||||
/* Mobile overlay */
|
||||
.mobile-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@ -1572,9 +1680,9 @@ onUnmounted(() => {
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
/* 移动端样式 */
|
||||
/* Mobile styles */
|
||||
@media (max-width: 767px) {
|
||||
/* 移动端时完全隐藏侧边栏 */
|
||||
/* Completely hide sidebar on mobile */
|
||||
:deep(.n-layout-sider) {
|
||||
position: fixed !important;
|
||||
top: 0;
|
||||
@ -1587,18 +1695,18 @@ onUnmounted(() => {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
/* 移动端侧边栏打开时的样式 */
|
||||
/* Mobile sidebar open styles */
|
||||
:deep(.n-layout-sider:not(.n-layout-sider--collapsed)) {
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
|
||||
/* 移动端主内容区域占满全宽 */
|
||||
/* Mobile main content area takes full width */
|
||||
:deep(.n-layout) {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 桌面端保持原有样式 */
|
||||
/* Desktop maintains original styles */
|
||||
@media (min-width: 768px) {
|
||||
:deep(.n-layout-sider) {
|
||||
position: relative !important;
|
||||
@ -1860,7 +1968,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 600px; /* 设置最小高度确保容器足够大 */
|
||||
min-height: 600px; /* Set minimum height to ensure container is large enough */
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
@ -1869,10 +1977,10 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 600px; /* 设置最小高度确保容器足够大 */
|
||||
min-height: 600px; /* Set minimum height to ensure container is large enough */
|
||||
}
|
||||
|
||||
/* 示例图片区块 */
|
||||
/* Sample images section */
|
||||
.sample-images-section {
|
||||
margin-top: 24px;
|
||||
background: white;
|
||||
@ -1992,7 +2100,7 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 600px; /* 设置最小高度 */
|
||||
min-height: 600px; /* Set minimum height */
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
@ -2006,7 +2114,7 @@ onUnmounted(() => {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
min-height: 600px; /* 确保预览区域有足够高度 */
|
||||
min-height: 600px; /* Ensure preview area has sufficient height */
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@ -2233,8 +2341,6 @@ onUnmounted(() => {
|
||||
|
||||
img {
|
||||
display: block;
|
||||
min-width: 620px;
|
||||
min-height: 620px;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
<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,12 +101,22 @@ const rules = {
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
// 使用 OAuth2 登录(会重定向到授权页面)
|
||||
await authStore.login()
|
||||
// login 方法会重定向到授权页面,这里不会继续执行
|
||||
} catch (error: any) {
|
||||
await formRef.value?.validate()
|
||||
loading.value = true
|
||||
|
||||
const result = await authStore.login(formData.username, formData.password)
|
||||
|
||||
if (result.success) {
|
||||
message.success(t('Login successful'))
|
||||
router.push('/')
|
||||
} else {
|
||||
message.error(result.error || t('Login failed'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
message.error(error.message || t('Login failed, please check username and password'))
|
||||
message.error(t('Login failed, please check username and password'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -230,8 +230,6 @@ const handleSignup = async () => {
|
||||
if (result.user) {
|
||||
authStore.user = result.user
|
||||
authStore.isAuthenticated = true
|
||||
localStorage.setItem('jingrow_user', JSON.stringify(result.user))
|
||||
localStorage.setItem('jingrow_authenticated', 'true')
|
||||
router.push('/')
|
||||
} else {
|
||||
const loginResult = await authStore.login(formData.username, formData.password)
|
||||
|
||||
@ -1,393 +0,0 @@
|
||||
<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>
|
||||
@ -1,665 +0,0 @@
|
||||
<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>
|
||||
File diff suppressed because it is too large
Load Diff
@ -117,6 +117,7 @@
|
||||
:class="{ 'dragging': isDraggingSplitLine }"
|
||||
:style="{ left: `${splitPosition}%` }"
|
||||
@mousedown.prevent.stop="handleSplitLineMouseDown"
|
||||
@touchstart.prevent.stop="handleSplitLineMouseDown"
|
||||
>
|
||||
<div class="split-line-handle">
|
||||
<i class="fa fa-arrows-h"></i>
|
||||
@ -218,11 +219,11 @@ const urlInputRef = ref<HTMLInputElement | null>(null)
|
||||
const uploadedImage = ref<File | null>(null)
|
||||
const uploadedImageUrl = ref<string>('')
|
||||
const resultImage = ref<string>('')
|
||||
const resultImageBlobUrl = ref<string>('') // 缓存的 blob URL,用于下载
|
||||
const resultImageBlobUrl = ref<string>('') // Cached blob URL for download
|
||||
const imageUrl = ref<string>('')
|
||||
const resultImageUrl = computed(() => {
|
||||
if (!resultImage.value) return ''
|
||||
// 直接返回图片URL
|
||||
// Return image URL directly
|
||||
return resultImage.value
|
||||
})
|
||||
|
||||
@ -282,7 +283,7 @@ const handlePaste = async (event: ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
// 检查是否有图片数据
|
||||
// Check if there is image data
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
if (item.type.indexOf('image') !== -1) {
|
||||
@ -295,7 +296,7 @@ const handlePaste = async (event: ClipboardEvent) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有文本URL
|
||||
// Check if there is text URL
|
||||
const text = event.clipboardData?.getData('text')
|
||||
if (text && isValidImageUrl(text)) {
|
||||
event.preventDefault()
|
||||
@ -351,7 +352,7 @@ const handleUrlSubmit = async () => {
|
||||
currentHistoryIndex.value = -1
|
||||
|
||||
try {
|
||||
// 使用代理或直接加载图片
|
||||
// Use proxy or load image directly
|
||||
const response = await fetch(url, { mode: 'cors' })
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load image: ${response.status}`)
|
||||
@ -359,7 +360,7 @@ const handleUrlSubmit = async () => {
|
||||
|
||||
const blob = await response.blob()
|
||||
|
||||
// 验证文件类型
|
||||
// Validate file type
|
||||
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
|
||||
if (!validTypes.includes(blob.type)) {
|
||||
message.warning(t('Unsupported image format. Please use JPG, PNG, or WebP'))
|
||||
@ -367,7 +368,7 @@ const handleUrlSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
// Validate file size
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
if (blob.size > maxSize) {
|
||||
message.warning(t('Image size exceeds 10MB limit'))
|
||||
@ -375,10 +376,10 @@ const handleUrlSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 转换为File对象
|
||||
// Convert to File object
|
||||
const originalFile = new File([blob], 'image-from-url', { type: blob.type })
|
||||
|
||||
// 压缩图片到 1024x1024
|
||||
// Compress image to 1024x1024
|
||||
try {
|
||||
const compressedFile = await compressImageFile(originalFile, {
|
||||
maxWidth: 1024,
|
||||
@ -390,15 +391,13 @@ const handleUrlSubmit = async () => {
|
||||
uploadedImage.value = compressedFile
|
||||
uploadedImageUrl.value = URL.createObjectURL(compressedFile)
|
||||
|
||||
// 开始处理
|
||||
// Start processing
|
||||
await handleRemoveBackground()
|
||||
} catch (error) {
|
||||
console.error('图片压缩失败:', error)
|
||||
message.error('图片处理失败,请重试')
|
||||
message.error(t('Image processing failed, please try again'))
|
||||
processing.value = false
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('加载图片URL失败:', error)
|
||||
let errorMessage = t('Failed to load image from URL')
|
||||
|
||||
if (error.message?.includes('CORS')) {
|
||||
@ -420,11 +419,11 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('paste', handlePaste)
|
||||
// 清理对象URL
|
||||
// Clean up object URL
|
||||
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(uploadedImageUrl.value)
|
||||
}
|
||||
// 清理结果图片的 blob URL 缓存
|
||||
// Clean up result image blob URL cache
|
||||
if (resultImageBlobUrl.value) {
|
||||
URL.revokeObjectURL(resultImageBlobUrl.value)
|
||||
}
|
||||
@ -497,7 +496,7 @@ const processFile = async (file: File) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 压缩图片到 1024x1024
|
||||
// Compress image to 1024x1024
|
||||
try {
|
||||
const compressedFile = await compressImageFile(file, {
|
||||
maxWidth: 1024,
|
||||
@ -518,17 +517,16 @@ const processFile = async (file: File) => {
|
||||
}
|
||||
reader.readAsDataURL(compressedFile)
|
||||
} catch (error) {
|
||||
console.error('图片压缩失败:', error)
|
||||
message.error('图片处理失败,请重试')
|
||||
message.error(t('Image processing failed, please try again'))
|
||||
}
|
||||
}
|
||||
|
||||
const resetUpload = () => {
|
||||
// 清理对象URL
|
||||
// Clean up object URL
|
||||
if (uploadedImageUrl.value && uploadedImageUrl.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(uploadedImageUrl.value)
|
||||
}
|
||||
// 清理结果图片的 blob URL 缓存
|
||||
// Clean up result image blob URL cache
|
||||
if (resultImageBlobUrl.value) {
|
||||
URL.revokeObjectURL(resultImageBlobUrl.value)
|
||||
resultImageBlobUrl.value = ''
|
||||
@ -558,7 +556,7 @@ const selectHistoryItem = async (index: number) => {
|
||||
splitPosition.value = 0
|
||||
uploadedImage.value = item.originalImageFile
|
||||
|
||||
// 缓存历史记录的结果图片
|
||||
// Cache result image from history
|
||||
if (item.resultImage) {
|
||||
await cacheResultImage(item.resultImage)
|
||||
}
|
||||
@ -571,27 +569,38 @@ const getHistoryThumbnailUrl = (item: HistoryItem): string => {
|
||||
return item.originalImageUrl
|
||||
}
|
||||
|
||||
const handleSplitLineMouseDown = (e: MouseEvent) => {
|
||||
const handleSplitLineMouseDown = (e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault()
|
||||
isDraggingSplitLine.value = true
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const getClientX = (event: MouseEvent | TouchEvent): number => {
|
||||
if ('touches' in event && event.touches.length > 0) {
|
||||
return event.touches[0].clientX
|
||||
}
|
||||
return (event as MouseEvent).clientX
|
||||
}
|
||||
|
||||
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
|
||||
if (!comparisonContainerRef.value || !isDraggingSplitLine.value) return
|
||||
|
||||
const rect = comparisonContainerRef.value.getBoundingClientRect()
|
||||
const x = moveEvent.clientX - rect.left
|
||||
const x = getClientX(moveEvent) - rect.left
|
||||
const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100))
|
||||
splitPosition.value = percentage
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
const handleEnd = () => {
|
||||
isDraggingSplitLine.value = false
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
document.removeEventListener('mouseup', handleMouseUp)
|
||||
document.removeEventListener('mousemove', handleMove as EventListener)
|
||||
document.removeEventListener('mouseup', handleEnd)
|
||||
document.removeEventListener('touchmove', handleMove as EventListener)
|
||||
document.removeEventListener('touchend', handleEnd)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
document.addEventListener('mouseup', handleMouseUp)
|
||||
document.addEventListener('mousemove', handleMove as EventListener)
|
||||
document.addEventListener('mouseup', handleEnd)
|
||||
document.addEventListener('touchmove', handleMove as EventListener, { passive: false })
|
||||
document.addEventListener('touchend', handleEnd)
|
||||
}
|
||||
|
||||
const handleRemoveBackground = async () => {
|
||||
@ -608,7 +617,7 @@ const handleRemoveBackground = async () => {
|
||||
processing.value = true
|
||||
resultImage.value = ''
|
||||
|
||||
// 处理成功结果的辅助函数(文件内部使用)
|
||||
// Helper function to handle successful result (internal use)
|
||||
const handleSuccess = async (imageUrl: string): Promise<void> => {
|
||||
resultImage.value = imageUrl
|
||||
await cacheResultImage(imageUrl)
|
||||
@ -674,13 +683,13 @@ const handleRemoveBackground = async () => {
|
||||
message.error(result.error)
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse JSON:', parseError, 'Line:', line)
|
||||
// Failed to parse JSON, continue processing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理最后一行
|
||||
// Process last line
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
const result = JSON.parse(buffer.trim())
|
||||
@ -691,7 +700,6 @@ const handleRemoveBackground = async () => {
|
||||
message.error(result.error || t('Failed to remove background'))
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('Failed to parse final JSON:', parseError)
|
||||
message.error(t('Failed to parse response'))
|
||||
}
|
||||
} else {
|
||||
@ -713,17 +721,17 @@ const handleRemoveBackground = async () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 缓存结果图片为 blob URL,用于下载
|
||||
* Cache result image as blob URL for download
|
||||
*/
|
||||
const cacheResultImage = async (imageUrl: string) => {
|
||||
try {
|
||||
// 清理旧的 blob URL
|
||||
// Clean up old blob URL
|
||||
if (resultImageBlobUrl.value) {
|
||||
URL.revokeObjectURL(resultImageBlobUrl.value)
|
||||
resultImageBlobUrl.value = ''
|
||||
}
|
||||
|
||||
// 获取图片并转换为 blob URL
|
||||
// Fetch image and convert to blob URL
|
||||
const response = await fetch(imageUrl)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status}`)
|
||||
@ -731,8 +739,7 @@ const cacheResultImage = async (imageUrl: string) => {
|
||||
const blob = await response.blob()
|
||||
resultImageBlobUrl.value = URL.createObjectURL(blob)
|
||||
} catch (error) {
|
||||
console.error('缓存图片失败:', error)
|
||||
// 缓存失败不影响显示,只是下载时需要重新获取
|
||||
// Cache failure doesn't affect display, only download needs to refetch
|
||||
}
|
||||
}
|
||||
|
||||
@ -740,10 +747,10 @@ const handleDownload = async () => {
|
||||
if (!resultImage.value) return
|
||||
|
||||
try {
|
||||
// 优先使用缓存的 blob URL
|
||||
// Prefer cached blob URL
|
||||
let blobUrl = resultImageBlobUrl.value
|
||||
|
||||
// 如果没有缓存,则获取并缓存
|
||||
// If no cache, fetch and cache
|
||||
if (!blobUrl) {
|
||||
const response = await fetch(resultImage.value)
|
||||
if (!response.ok) {
|
||||
@ -754,7 +761,7 @@ const handleDownload = async () => {
|
||||
resultImageBlobUrl.value = blobUrl
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
// Create download link
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = `removed-background-${Date.now()}.png`
|
||||
@ -763,12 +770,11 @@ const handleDownload = async () => {
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
|
||||
// 清理:移除 DOM 元素,但不释放 blob URL(保留缓存供后续下载使用)
|
||||
// Clean up: remove DOM element but don't release blob URL (keep cache for subsequent downloads)
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(link)
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('下载图片失败:', error)
|
||||
message.error(t('Failed to download image'))
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ export default defineConfig(({ mode, command }) => {
|
||||
const BACKEND_URL = env.VITE_BACKEND_SERVER_URL || 'https://api.jingrow.com'
|
||||
const JCLOUD_URL = env.VITE_JCLOUD_SERVER_URL || 'https://cloud.jingrow.com'
|
||||
const FRONTEND_HOST = env.VITE_FRONTEND_HOST || '0.0.0.0'
|
||||
const FRONTEND_PORT = Number(env.VITE_FRONTEND_PORT) || 3100
|
||||
const FRONTEND_PORT = Number(env.VITE_FRONTEND_PORT) || 3001
|
||||
const ALLOWED_HOSTS = (env.VITE_ALLOWED_HOSTS || '').split(',').map((s) => s.trim()).filter(Boolean)
|
||||
|
||||
return {
|
||||
@ -68,12 +68,16 @@ export default defineConfig(({ mode, command }) => {
|
||||
'/api/action': {
|
||||
target: JCLOUD_URL,
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
secure: false,
|
||||
cookieDomainRewrite: { '*': '' },
|
||||
cookiePathRewrite: { '*': '/' },
|
||||
},
|
||||
'/api/data': {
|
||||
target: JCLOUD_URL,
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
secure: false,
|
||||
cookieDomainRewrite: { '*': '' },
|
||||
cookiePathRewrite: { '*': '/' },
|
||||
},
|
||||
'/api/v1/tools': {
|
||||
target: BACKEND_URL,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user