Compare commits

..

14 Commits
dev ... main

Author SHA1 Message Date
f092728a04 change FRONTEND_PORT to 3001 2026-01-11 00:20:28 +08:00
8820fc8e06 添加站点统计代码 2026-01-09 18:13:34 +08:00
de92a1e63d 删除英文版首页的jingrow链接 2026-01-04 23:24:52 +08:00
c489d41941 fix: 修复手机端图片等比例缩小和拖拽按钮触摸支持
- 移除 .comparison-image img 的 min-width 和 min-height,修复手机端图片无法等比例缩小的问题
- 为拖拽分割线添加触摸事件支持(touchstart/touchmove/touchend),使手机端可以正常拖拽对比视图
2026-01-04 22:41:47 +08:00
ee2f59df81 refactor: 优化前端代码,准备生产环境部署
- 删除所有调试日志(console.error/console.log)
- 将所有中文注释改为英文注释
- 将硬编码的中文文本改为使用 t() 函数的英文,支持国际化
- 移除可能暴露项目信息的内容
- 添加新增翻译键的中文翻译(Portrait Sample, Product Sample, Animal Sample, Object Sample, Unable to get team information)

修改文件:
- src/views/HomePage.vue
- src/views/tools/remove_background/remove_background.vue
- src/views/settings/Settings.vue
- src/locales/zh-CN.json
2026-01-04 22:19:19 +08:00
36af166879 删除未使用的通知铃铛图标
- 移除右上角未使用的通知按钮(铃铛图标)
- 简化头部右侧布局,仅保留搜索框和用户菜单
2026-01-04 21:45:17 +08:00
6e6d822b06 统一卡片风格并补充缺失的中文翻译
- 统一推荐有礼和合作伙伴卡片的风格,使用与功能设置卡片一致的 n-list 和 n-list-item 结构
- 添加 API key created successfully 的中文翻译
- 添加 Failed to create API key 的中文翻译
- 添加 Partner removed successfully 的中文翻译
- 添加 Failed to remove partner 的中文翻译
- 添加 Delete SSH Key 的中文翻译
- 添加 Only system administrators can edit environment configuration 的中文翻译
2026-01-04 21:42:22 +08:00
dfad1a1d80 删除禁用账户功能及相关代码
- 删除个人资料标签页中的禁用/启用账户列表项
- 删除禁用账户对话框和启用账户对话框
- 删除反馈对话框(禁用账户后显示)
- 删除账户状态相关的状态变量和计算属性
- 删除 handleAccountStatus、handleDisableAccountConfirm、handleEnableAccountConfirm 等处理函数
- 删除反馈相关的状态变量和处理函数(feedbackRating、feedbackReason、feedbackNote 等)
- 从 API 模块中删除 disableAccount、enableAccount、submitFeedback 函数
- 删除账户状态对话框和星级评分的 CSS 样式
2026-01-04 21:34:33 +08:00
423e28c09c 删除 Dashboard 页面,将首页改为根目录
- 删除 Dashboard.vue 页面文件
- 修改路由配置:删除 Dashboard 路由,根路径重定向到 /tools
- 删除菜单配置中的 dashboard 菜单项
- 删除 AppHeader 中的 Dashboard 标题映射
- 修复禁用账户后跳转问题:反馈提交后跳转到根目录而非 /dashboard
2026-01-04 21:29:51 +08:00
f30502bf7d fix(settings): fix disable account API and add feedback dialog
- Fix disable account API parameter format (pass null instead of empty object)
- Add feedback dialog after account disable (aligned with jcloud dashboard)
- Implement star rating, reason selection, and feedback submission
- Add submitFeedback API function
- Add missing Chinese translations for account status messages
- Add feedback dialog translations and validation logic
2026-01-04 21:23:29 +08:00
79b92e7aae fix(settings): improve profile settings UI and i18n support
- Remove First Name and Last Name fields from Update Profile Information dialog
- Add i18n support for copy button messages (Copied to clipboard, Copy failed)
- Add Chinese translation for 'Update Profile Information'
- Fix QR code centering in 2FA dialog
- Fix disable account dialog formatting with proper line breaks
- Change disable account dialog icon type to warning for semantic correctness
2026-01-04 20:50:22 +08:00
911ae5e53b feat: 系统设置页面增加个人资料和开发者标签页
- 使用 naive-ui Tabs 组件实现标签页布局,对齐 jcloud dashboard 前端
- 个人资料标签页:显示和编辑用户信息(用户名、手机、邮箱)、邮箱通知设置
- 开发者标签页:API 访问(创建/重新生成 API Key)、SSH 密钥管理、功能标志配置
- 创建账户相关 API 接口文件(account.ts),使用 jcloud.api.account.get 获取用户信息
- 创建 ClickToCopyField 组件用于一键复制文本内容
- API 接口与 jcloud dashboard 保持一致,实现相同的功能
2026-01-04 19:54:02 +08:00
4b3ebaa7ed refactor: 移除登录信息在localStorage中的保存,改为仅使用cookies验证
- 移除 auth store 中所有 localStorage 相关代码(loadUserFromStorage、saveUserToStorage、clearUserFromStorage)
- 简化 setUserState 和 clearUserState,不再操作 localStorage
- 修改 initAuth 逻辑,只依赖 cookies 验证登录状态,不再从 localStorage 恢复
- 移除 HomePage.vue 和 Signup.vue 中注册成功后的 localStorage 保存
- 登录状态完全由后端 cookies 控制,提高安全性
2026-01-04 19:16:37 +08:00
eb70a0c6f6 fix: 修复开发环境登录后无法获取cookie的问题
- 为 /api/action 和 /api/data 代理添加 cookieDomainRewrite 和 cookiePathRewrite 配置
- 将 secure 设置为 false 以支持开发环境的证书验证
- 确保后端返回的 cookie 能在开发服务器正确传递和保存
2026-01-04 19:11:35 +08:00
21 changed files with 2918 additions and 2700 deletions

View File

@ -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>

View File

@ -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'),

View File

@ -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',

View File

@ -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": "无法获取团队信息"
}

View File

@ -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 || '禁用双因素认证失败'
}
}
}

View File

@ -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'
}
}

View File

@ -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>

View File

@ -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

View File

@ -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 },

View File

@ -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()
// 跳转到登录页(避免重复跳转)

View File

@ -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
}

View File

@ -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>

View File

@ -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;

View File

@ -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>

View File

@ -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
}
}

View File

@ -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)

View File

@ -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>

View File

@ -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

View File

@ -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'))
}
}

View File

@ -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,