dev #3

Merged
jingrow merged 96 commits from dev into main 2026-01-13 22:47:33 +08:00
411 changed files with 13565 additions and 7532 deletions

View File

@ -1,6 +1,6 @@
# Dashboard
Dashboard is a VueJS application that is the face of 今果 Jingrow. This is what the end users (tenants) see and manage their FC stuff in. The tenants does not have access to the desk, so, this is their dashboard for managing sites, apps, updates etc.
Dashboard is a VueJS application that is the face of Jingrow. This is what the end users (tenants) see and manage their FC stuff in. The tenants does not have access to the desk, so, this is their dashboard for managing sites, apps, updates etc.
Technologies at the heart of dashboard:

View File

@ -1,17 +1,17 @@
<!DOCTYPE html>
<html class="h-full overflow-hidden" lang="zh">
<html class="h-full overflow-hidden">
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<title>今果 Jingrow</title>
<title>Jingrow</title>
<link rel="icon" href="/favicon.png" type="image/x-icon" />
</head>
<body class="h-full">
<noscript>
<strong>
今果 Jingrow Dashboard doesn't work properly without JavaScript enabled.
Jingrow Dashboard doesn't work properly without JavaScript enabled.
Please enable it to continue.
</strong>
</noscript>
@ -29,6 +29,6 @@
{% endfor %}
</script>
<!-- <script type="module" src="/src/main.js"></script> -->
<script type="module" src="/src2/main.js"></script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@ -1,5 +1,5 @@
{
"include": ["./src/**/*", "src2/components/AddressableErrorDialog.vue"],
"include": ["./src/**/*"],
"compilerOptions": {
"baseUrl": ".",
"paths": {

View File

@ -36,6 +36,7 @@
"lodash": "^4.17.19",
"luxon": "^1.22.0",
"markdown-it": "^12.3.2",
"naive-ui": "^2.38.1",
"papaparse": "^5.4.1",
"qrcode": "^1.5.4",
"register-service-worker": "^1.6.2",

View File

@ -1,74 +1,451 @@
<template>
<div class="text-gray-900 antialiased">
<div class="flex h-screen overflow-hidden">
<n-config-provider :theme-overrides="themeOverrides" class="h-full">
<div class="relative flex h-full flex-col">
<n-layout class="h-full" has-sider>
<!-- 移动端遮罩层 -->
<div
class="flex flex-1 overflow-y-auto"
:class="{
'sm:bg-gray-50':
$route.meta.isLoginPage && $route.fullPath.indexOf('/checkout') < 0
}"
v-if="isMobile && !sidebarCollapsed && !isSignupFlow && !isHideSidebar && session.user"
class="mobile-overlay"
@click="sidebarCollapsed = true"
></div>
<n-layout-sider
v-if="!isSignupFlow && !isHideSidebar && session.user"
bordered
collapse-mode="width"
:collapsed-width="64"
:width="240"
v-model:collapsed="sidebarCollapsed"
:show-trigger="!isMobile"
:responsive="true"
:breakpoint="768"
class="app-sidebar-sider"
@collapse="sidebarCollapsed = true"
@expand="sidebarCollapsed = false"
>
<div class="flex-1">
<Navbar class="sm:hidden" v-if="!$route.meta.isLoginPage" />
<div class="mx-auto flex flex-row justify-start">
<Sidebar
class="sticky top-0 hidden w-64 flex-shrink-0 sm:flex"
v-if="$auth.isLoggedIn && !$route.meta.hideSidebar"
/>
<router-view v-slot="{ Component }" class="w-full sm:mr-0">
<keep-alive
:include="[
'Sites',
'Benches',
'Servers',
'Site',
'Bench',
'Server',
'Marketplace',
'Account',
'MarketplaceApp'
]"
>
<component :is="Component" />
</keep-alive>
</router-view>
</div>
<AppSidebar :collapsed="sidebarCollapsed" @menu-select="handleMenuSelect" />
</n-layout-sider>
<n-layout class="h-full">
<!-- 移动端顶部导航栏 -->
<div
v-if="isMobile && !isSignupFlow && !isHideSidebar && session.user"
class="mobile-header"
>
<div class="mobile-header-content">
<JLogo class="mobile-logo" @click="goToHome" />
<n-dropdown
:options="mobileDropdownOptions"
:show-arrow="true"
trigger="click"
v-model:show="isMobileDropdownOpen"
@select="handleMobileDropdownSelect"
placement="bottom-start"
>
<div class="mobile-user-info-wrapper" :class="{ 'mobile-user-info-wrapper--active': isMobileDropdownOpen }">
<div class="mobile-user-info">
{{ teamUserText }}
</div>
<n-icon class="mobile-chevron-icon">
<ChevronDown />
</n-icon>
</div>
</n-dropdown>
<n-button
quaternary
circle
@click="toggleSidebar"
class="mobile-menu-button"
>
<template #icon>
<n-icon><MenuIcon /></n-icon>
</template>
</n-button>
</div>
</div>
<div class="w-full h-full overflow-auto" id="scrollContainer">
<div
v-if="
!isSignupFlow &&
!isSiteLogin &&
!$session.user &&
!$route.meta.isLoginPage
"
class="border bg-red-200 px-5 py-3 text-base text-red-900"
>
{{ $t('You are not logged in.') }}
<router-link to="/login" class="underline">{{ $t('Login') }}</router-link> {{ $t('to access dashboard.') }}
</div>
<router-view />
</div>
</n-layout>
</n-layout>
<Toaster position="top-right" />
<component v-for="dialog in dialogs" :is="dialog" :key="dialog.id" />
<!-- 切换团队对话框 -->
<SwitchTeamDialog v-model="showTeamSwitcher" />
</div>
<NotificationToasts />
<UserPrompts v-if="$auth.isLoggedIn" />
<ConfirmDialogs />
</div>
</n-config-provider>
</template>
<script>
import Sidebar from '@/components/Sidebar.vue';
import Navbar from '@/components/Navbar.vue';
import UserPrompts from '@/views/onboarding/UserPrompts.vue';
import ConfirmDialogs from '@/components/ConfirmDialogs.vue';
import NotificationToasts from '@/components/NotificationToasts.vue';
export default {
name: 'App',
components: {
Sidebar,
Navbar,
UserPrompts,
ConfirmDialogs,
NotificationToasts
<script setup>
import { defineAsyncComponent, computed, watch, ref, provide, onMounted, onUnmounted } from 'vue';
import { NLayout, NLayoutSider, NConfigProvider, NButton, NIcon, NDropdown } from 'naive-ui';
import { Toaster } from 'vue-sonner';
import { dialogs } from './utils/components';
import { useRoute, useRouter } from 'vue-router';
import { getTeam } from './data/team';
import { session } from './data/session.js';
import { useI18n } from './composables/useI18n';
import JLogo from '@/components/icons/JLogo.vue';
import MenuIcon from '~icons/lucide/menu';
import ChevronDown from '~icons/lucide/chevron-down';
const AppSidebar = defineAsyncComponent(
() => import('./components/AppSidebar.vue'),
);
const SwitchTeamDialog = defineAsyncComponent(
() => import('./components/SwitchTeamDialog.vue'),
);
const route = useRoute();
const router = useRouter();
const team = getTeam();
const { t } = useI18n();
const showTeamSwitcher = ref(false);
const isMobileDropdownOpen = ref(false);
// Naive UI - 使 tooltip menu
const themeOverrides = {
Tooltip: {
color: '#fafafa',
textColor: '#4a5568',
padding: '6px 12px',
borderRadius: '8px',
},
data() {
return {
viewportWidth: 0
};
Menu: {
// 使
itemTextColor: '#4a5568',
itemTextColorHover: '#4a5568',
itemTextColorActive: '#18a058',
itemTextColorChildActive: '#18a058',
itemColorHover: 'rgba(0, 0, 0, 0.04)',
itemColorActive: 'rgba(24, 160, 88, 0.1)',
itemColorActiveHover: 'rgba(24, 160, 88, 0.15)',
itemBorderRadius: '8px',
itemPadding: '10px 12px',
itemIconColor: '#8b8e95',
itemIconColorHover: '#18a058',
itemIconColorActive: '#18a058',
itemIconColorChildActive: '#18a058',
itemIconSize: '18px',
fontSize: '14px',
},
provide: {
viewportWidth: Math.max(
document.documentElement.clientWidth || 0,
window.innerWidth || 0
)
};
//
const isMobile = ref(false);
//
const sidebarCollapsed = ref(false);
//
const teamUserText = computed(() => {
if (team?.get?.loading) {
return t('Loading...');
}
return team?.pg?.user || '';
});
//
const checkMobile = () => {
const wasMobile = isMobile.value;
isMobile.value = window.innerWidth < 768;
//
if (!wasMobile && isMobile.value) {
sidebarCollapsed.value = true;
}
};
onMounted(() => {
checkMobile();
window.addEventListener('resize', checkMobile);
});
onUnmounted(() => {
window.removeEventListener('resize', checkMobile);
});
// -
const handleMenuSelect = () => {
if (isMobile.value) {
sidebarCollapsed.value = true;
}
};
//
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value;
};
//
const goToHome = () => {
router.push('/').catch((err) => {
if (err.name !== 'NavigationDuplicated') {
console.error('Navigation error:', err);
}
});
};
//
const mobileDropdownOptions = computed(() => {
const options = [];
const teamData = team?.pg;
if (
teamData &&
(teamData.valid_teams?.length > 1 || teamData.is_desk_user)
) {
options.push({
label: t('Switch Team'),
key: 'switch-team',
});
}
options.push({
label: t('Logout'),
key: 'logout',
});
return options;
});
//
const handleMobileDropdownSelect = (key) => {
if (key === 'switch-team') {
showTeamSwitcher.value = true;
} else if (key === 'logout') {
if (session) {
session.logout.submit();
}
}
};
const isHideSidebar = computed(() => {
const alwaysHideSidebarRoutes = [
'Site Login',
'SignupLoginToSite',
'SignupSetup',
];
const alwaysHideSidebarPaths = ['/dashboard/site-login'];
if (!session.user) return false;
if (
alwaysHideSidebarRoutes.includes(route.name) ||
alwaysHideSidebarPaths.includes(window.location.pathname)
)
return true;
return (
route.meta.hideSidebar && session.user && team?.pg?.hide_sidebar === true
);
});
const isSignupFlow = ref(
window.location.pathname.startsWith('/dashboard/create-site') ||
window.location.pathname.startsWith('/dashboard/setup-account') ||
window.location.pathname.startsWith('/dashboard/site-login') ||
window.location.pathname.startsWith('/dashboard/signup'),
);
const isSiteLogin = ref(window.location.pathname.endsWith('/site-login'));
watch(
() => route.name,
() => {
isSignupFlow.value =
window.location.pathname.startsWith('/dashboard/create-site') ||
window.location.pathname.startsWith('/dashboard/setup-account') ||
window.location.pathname.startsWith('/dashboard/site-login') ||
window.location.pathname.startsWith('/dashboard/signup');
},
);
provide('team', team);
provide('session', session);
</script>
<style src="./assets/style.css"></style>
<style>
.app-sidebar-sider {
background: #fafafa !important;
border-right: 1px solid rgba(0, 0, 0, 0.06) !important;
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
z-index: 1000 !important;
}
.app-sidebar-sider .n-layout-sider-trigger {
background: #fff !important;
border: 1px solid rgba(0, 0, 0, 0.08) !important;
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
width: 32px !important;
height: 32px !important;
border-radius: 50% !important;
right: -16px !important;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
.app-sidebar-sider .n-layout-sider-trigger:hover {
background: #f5f5f5 !important;
}
.app-sidebar-sider .n-layout-sider-trigger:active {
background: #eeeeee !important;
}
.app-sidebar-sider .n-layout-sider-trigger .n-base-icon {
color: #666;
font-size: 14px;
transition: color 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.app-sidebar-sider .n-layout-sider-scroll-container {
height: 100%;
overflow: hidden;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
n-config-provider {
height: 100%;
display: flex;
flex-direction: column;
}
.app-sidebar-sider {
height: 100% !important;
min-height: 100vh !important;
}
.mobile-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 998;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.mobile-header {
position: sticky;
top: 0;
z-index: 999;
background: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.mobile-header-content {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 12px 16px;
gap: 12px;
}
.mobile-logo {
width: 28px;
height: 28px;
flex-shrink: 0;
cursor: pointer;
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.mobile-logo:hover {
opacity: 0.8;
}
.mobile-user-info-wrapper {
display: inline-flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 6px 8px;
border-radius: 6px;
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 1;
min-width: 0;
max-width: calc(100% - 80px);
}
.mobile-user-info-wrapper:active {
background: rgba(0, 0, 0, 0.06);
}
.mobile-user-info-wrapper--active {
background: rgba(0, 0, 0, 0.04);
}
.mobile-user-info {
font-size: 14px;
font-weight: 500;
color: #1a1a1a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
min-width: 0;
}
.mobile-chevron-icon {
font-size: 16px;
color: #8b8e95;
flex-shrink: 0;
transition: color 0.2s ease, transform 0.2s ease;
}
.mobile-user-info-wrapper:hover .mobile-chevron-icon {
color: #4a5568;
}
.mobile-menu-button {
flex-shrink: 0;
margin-left: auto;
}
@media (max-width: 767px) {
.app-sidebar-sider {
position: fixed !important;
top: 0;
left: 0;
height: 100vh !important;
z-index: 1000 !important;
width: 280px !important;
max-width: 85vw;
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
.app-sidebar-sider:not(.n-layout-sider--collapsed) {
transform: translateX(0) !important;
}
.n-layout {
margin-left: 0 !important;
}
}
@media (min-width: 768px) {
.app-sidebar-sider {
position: relative !important;
transform: none !important;
}
.mobile-header {
display: none;
}
.mobile-overlay {
display: none;
}
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<div>
<AlertBanner
title="请添加您的账单地址以完成您的 今果 Jingrow 个人资料。"
title="请添加您的账单地址以完成您的 Jingrow 个人资料。"
type="warning"
>
<Button

View File

@ -0,0 +1,396 @@
<template>
<div class="sidebar-container">
<!-- 用户信息区域 -->
<div class="sidebar-header" :class="{ 'sidebar-header-collapsed': collapsed }">
<n-dropdown
:options="dropdownOptions"
:show-arrow="true"
trigger="click"
@select="handleDropdownSelect"
>
<div
class="user-info"
:class="{ 'user-info-collapsed': collapsed }"
>
<JLogo class="logo-icon" @click.stop="goToHome" />
<div v-if="!collapsed" class="user-info-text">
<div class="brand-name">Jingrow</div>
<div class="user-name">
{{ teamUserText }}
</div>
</div>
<n-icon v-if="!collapsed" class="chevron-icon">
<ChevronDown />
</n-icon>
</div>
</n-dropdown>
</div>
<!-- 导航菜单 -->
<div class="sidebar-menu">
<NavigationItems>
<template v-slot="{ navigation }">
<n-menu
:collapsed="collapsed"
:collapsed-width="64"
:collapsed-icon-size="20"
:options="convertToMenuOptions(navigation)"
:value="activeKey"
:router="false"
@update:value="handleMenuSelect"
class="app-sidebar-menu"
/>
</template>
</NavigationItems>
</div>
</div>
<!-- 切换团队对话框 -->
<SwitchTeamDialog2 v-model="showTeamSwitcher" />
</template>
<script>
import { defineAsyncComponent, computed, ref, h, getCurrentInstance } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { NMenu, NDropdown, NIcon } from 'naive-ui';
import NavigationItems from './NavigationItems.vue';
import JLogo from '@/components/icons/JLogo.vue';
import ChevronDown from '~icons/lucide/chevron-down';
import { t } from '../utils/i18n';
export default {
name: 'AppSidebar',
components: {
NMenu,
NDropdown,
NIcon,
NavigationItems,
JLogo,
ChevronDown,
SwitchTeamDialog2: defineAsyncComponent(() =>
import('./SwitchTeamDialog.vue')
),
},
props: {
collapsed: {
type: Boolean,
default: false,
},
},
emits: ['update:collapsed', 'menu-select'],
setup(props, { emit }) {
const route = useRoute();
const router = useRouter();
const instance = getCurrentInstance();
const showTeamSwitcher = ref(false);
// 使 props
const collapsed = computed(() => props.collapsed);
//
const dropdownOptions = computed(() => {
const options = [];
// getCurrentInstance 访
const team = instance?.appContext.config.globalProperties.$team?.pg;
if (
team &&
(team.valid_teams?.length > 1 || team.is_desk_user)
) {
options.push({
label: t('Switch Team'),
key: 'switch-team',
});
}
options.push({
label: t('Logout'),
key: 'logout',
});
return options;
});
//
const handleDropdownSelect = (key) => {
if (key === 'switch-team') {
showTeamSwitcher.value = true;
} else if (key === 'logout') {
const session = instance?.appContext.config.globalProperties.$session;
if (session) {
session.logout.submit();
}
}
};
//
const convertToMenuOptions = (items) => {
if (!items || !Array.isArray(items)) {
console.warn('[AppSidebar] convertToMenuOptions: invalid items', items);
return [];
}
return items
.filter((item) => item.condition !== false)
.map((item) => {
// 使
// Naive UI NMenu icon
let iconComponent = undefined;
if (item.icon) {
iconComponent = () => {
try {
const iconVNode = item.icon();
// NIcon
return h(NIcon, { size: 18 }, { default: () => iconVNode });
} catch (e) {
console.error(`[AppSidebar] Icon error for "${item.name}":`, e);
return null;
}
};
}
// NavigationItems route /dashboard/ '/sites'
// base '/dashboard/'使 item.route
let routePath = item.route || item.name;
const option = {
label: item.name,
key: routePath,
icon: iconComponent,
disabled: item.disabled,
};
//
if (item.children && item.children.length > 0) {
option.children = convertToMenuOptions(item.children);
}
//
if (item.badge) {
try {
// item.badge
const badgeResult = item.badge();
if (badgeResult) {
option.label = () => h('div', {
class: 'flex items-center justify-between w-full'
}, [
h('span', item.name),
badgeResult,
]);
}
} catch (e) {
// 使
console.warn('Badge rendering failed:', e);
}
}
return option;
});
};
//
const activeKey = computed(() => {
// base '/dashboard/'route.path base
// 访 /dashboard/sitesroute.path '/sites'
let path = route.path;
// /
if (!path.startsWith('/')) {
path = '/' + path;
}
// /dashboard /
if (path === '/dashboard' || path === '/') {
return path;
}
return path;
});
//
// base '/dashboard/'使 router="true"
//
const handleMenuSelect = (key) => {
// base '/dashboard/'key base '/sites'
// 使 router.pushVue Router base
router.push(key).catch((err) => {
//
if (err.name !== 'NavigationDuplicated') {
console.error('Navigation error:', err);
}
});
//
emit('menu-select');
};
//
const teamUserText = computed(() => {
const team = instance?.appContext.config.globalProperties.$team;
if (team?.get?.loading) {
return t('Loading...');
}
return team?.pg?.user || '';
});
//
const goToHome = () => {
router.push('/').catch((err) => {
if (err.name !== 'NavigationDuplicated') {
console.error('Navigation error:', err);
}
});
};
return {
showTeamSwitcher,
collapsed,
dropdownOptions,
activeKey,
teamUserText,
handleDropdownSelect,
handleMenuSelect,
convertToMenuOptions,
goToHome,
};
},
};
</script>
<style scoped>
.sidebar-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100%;
background: #fafafa;
opacity: 1;
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-header {
padding: 16px 12px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
transition: padding 0.25s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-header-collapsed {
padding: 16px 8px;
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
padding 0.25s cubic-bezier(0.4, 0, 0.2, 1),
gap 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.user-info:hover {
background: rgba(0, 0, 0, 0.04);
}
.user-info:active {
background: rgba(0, 0, 0, 0.06);
}
.user-info-collapsed {
justify-content: center;
padding: 8px;
gap: 0;
}
.user-info-collapsed .logo-icon {
margin: 0 auto;
}
.logo-icon {
width: 32px;
height: 32px;
flex-shrink: 0;
cursor: pointer;
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.logo-icon:hover {
opacity: 0.8;
}
.user-info-text {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
opacity: 1;
transition: opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.25s cubic-bezier(0.4, 0, 0.2, 1);
max-width: 200px;
overflow: hidden;
}
.user-info-collapsed .user-info-text {
opacity: 0;
max-width: 0;
transition: opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1),
max-width 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.brand-name {
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.2;
transition: opacity 0.2s ease;
}
.user-name {
font-size: 13px;
color: #666;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: opacity 0.2s ease;
}
.chevron-icon {
font-size: 16px;
color: #8b8e95;
margin-left: auto;
flex-shrink: 0;
transition: color 0.2s ease;
}
.user-info:hover .chevron-icon {
color: #4a5568;
}
.sidebar-menu {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 8px 0;
}
:deep(.app-sidebar-menu .n-icon) {
color: #8b8e95;
}
.sidebar-menu::-webkit-scrollbar {
width: 4px;
}
.sidebar-menu::-webkit-scrollbar-track {
background: transparent;
}
.sidebar-menu::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.1);
border-radius: 2px;
}
.sidebar-menu::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.15);
}
</style>

View File

@ -1,7 +1,7 @@
<template>
<Dialog
:options="{
title: '立即订阅',
title: $t('Subscribe Now'),
size: '2xl'
}"
v-model="showDialog"
@ -9,7 +9,7 @@
<template v-slot:body-content>
<div class="border-0">
<p class="text-base text-gray-800">
您距离创建第一个网站仅几步之遥
{{ $t('You are just a few steps away from creating your first website.') }}
</p>
<div class="mt-6 space-y-6">
<!-- 第一步选择计划 -->
@ -17,7 +17,7 @@
<div v-if="step == 1">
<div class="flex items-center space-x-2">
<TextInsideCircle>1</TextInsideCircle>
<span class="text-base font-medium"> 选择网站计划 </span>
<span class="text-base font-medium">{{ $t('Select Website Plan') }}</span>
</div>
<div class="pl-7">
<SitePlansCards
@ -32,7 +32,7 @@
variant="solid"
@click="confirmPlan"
>
确认计划
{{ $t('Confirm Plan') }}
</Button>
</div>
</div>
@ -42,7 +42,7 @@
<div class="flex items-center space-x-2">
<TextInsideCircle>1</TextInsideCircle>
<span class="text-base font-medium">
已选择网站计划 ({{ selectedPlan.name }})
{{ $t('Website Plan Selected ({name})', { name: selectedPlan.name }) }}
</span>
</div>
<div
@ -62,7 +62,7 @@
<div class="flex items-center space-x-2">
<TextInsideCircle>2</TextInsideCircle>
<span class="text-base font-medium">
更新账单信息
{{ $t('Update Billing Information') }}
</span>
</div>
<div class="pl-7" v-if="step == 2">
@ -76,7 +76,7 @@
<div class="flex items-center space-x-2">
<TextInsideCircle>2</TextInsideCircle>
<span class="text-base font-medium">
账单信息已更新
{{ $t('Billing Information Updated') }}
</span>
</div>
<div
@ -95,7 +95,7 @@
<div v-if="step <= 3">
<div class="flex items-center space-x-2">
<TextInsideCircle>3</TextInsideCircle>
<span class="text-base font-medium"> 添加支付方式 </span>
<span class="text-base font-medium">{{ $t('Add Payment Method') }}</span>
</div>
<div class="mt-4 pl-7" v-if="step == 3">
@ -110,7 +110,7 @@
}"
@click="isAutomatedBilling = true"
>
自动扣款
{{ $t('Automated Billing') }}
</div>
<div
class="w-1/2 cursor-pointer rounded-sm py-1.5 text-center transition-all"
@ -119,7 +119,7 @@
}"
@click="isAutomatedBilling = false"
>
充值
{{ $t('Recharge') }}
</div>
</div>
<div class="mt-2 w-full">
@ -151,13 +151,13 @@
class="text-base font-medium"
v-if="$team.pg.payment_mode === 'Card'"
>
自动计费设置已完成
{{ $t('Automated Billing Setup Completed') }}
</span>
<span
class="text-base font-medium"
v-if="$team.pg.payment_mode === 'Prepaid Credits'"
>
余额支付已开通
{{ $t('Prepaid Credits Payment Enabled') }}
</span>
</div>
<div
@ -170,7 +170,7 @@
class="mt-1.5 pl-7 text-p-base text-gray-800"
v-if="$team.pg.payment_mode === 'Prepaid Credits'"
>
账户余额: {{ $format.userCurrency($team.pg.balance) }}
{{ $t('Account Balance') }}: {{ $format.userCurrency($team.pg.balance) }}
</div>
</div>
</div>
@ -184,7 +184,7 @@
<div class="flex items-center space-x-2">
<TextInsideCircle>4</TextInsideCircle>
<div class="text-base font-medium">
订阅确认
{{ $t('Subscription Confirmation') }}
</div>
</div>
</div>
@ -193,12 +193,12 @@
<div class="flex items-center justify-between space-x-2">
<div class="flex items-center space-x-2">
<TextInsideCircle>4</TextInsideCircle>
<div class="text-base font-medium">正在更新站点计划</div>
<div class="text-base font-medium">{{ $t('Updating Site Plan') }}</div>
</div>
</div>
<div class="mt-3 pl-7">
<Button :loading="true" loadingText="正在更新站点计划...">
站点计划已更新
<Button :loading="true" :loadingText="$t('Updating site plan...')">
{{ $t('Site Plan Updated') }}
</Button>
</div>
</div>
@ -207,7 +207,7 @@
<div class="flex items-center space-x-2">
<TextInsideCircle>4</TextInsideCircle>
<span class="text-base font-medium">
🎉 订阅已确认
🎉 {{ $t('Subscription Confirmed') }}
</span>
</div>
<div
@ -219,8 +219,8 @@
<div class="mt-1.5 pl-7">
<div class="flex items-center space-x-2">
<span class="text-p-base text-gray-800">
您的订阅已确认<br />
如果您的任何站点已被禁用它很快就会重新启用
{{ $t('Your subscription has been confirmed.') }}<br />
{{ $t('If any of your sites have been disabled, they will be re-enabled soon.') }}
</span>
</div>
<div class="flex w-full justify-end">
@ -230,7 +230,7 @@
iconRight="arrow-right"
@click="showDialog = false"
>
返回仪表板
{{ $t('Return to Dashboard') }}
</Button>
</div>
</div>
@ -258,7 +258,7 @@ SitePlansCards: defineAsyncComponent(() => import('./SitePlansCards.vue')),
import('./UpdateBillingDetailsForm.vue')
),
CardWithDetails: defineAsyncComponent(() =>
import('../../src/components/CardWithDetails.vue')
import('@/components/CardWithDetails.vue')
),
BuyPrepaidCreditsForm: defineAsyncComponent(() =>
import('./BuyPrepaidCreditsForm.vue')
@ -293,7 +293,7 @@ SitePlansCards: defineAsyncComponent(() => import('./SitePlansCards.vue')),
methods: {
confirmPlan() {
if (!this.selectedPlan) {
toast.error('请选择一个计划');
toast.error(this.$t('Please select a plan'));
return;
}
this.step = 2;
@ -338,7 +338,7 @@ SitePlansCards: defineAsyncComponent(() => import('./SitePlansCards.vue')),
},
onError: () => {
this.isChangingPlan = false;
toast.error('更改计划失败,请稍后重试。');
toast.error(this.$t('Failed to change plan, please try again later.'));
this.showDialog = false;
}
});

View File

@ -29,26 +29,26 @@
class="text-base"
:class="error ? 'text-red-500' : 'text-gray-600'"
>
{{
uploading
? `上传中 ${progress}%`
: success
? formatBytes(fileObj.size)
: error
? error
: file.description
}}
{{
uploading
? $t('Uploading {progress}%', { progress })
: success
? formatBytes(fileObj.size)
: error
? error
: file.description
}}
</span>
</template>
<template #actions>
<Button
:loading="uploading"
loadingText="上传中..."
@click="openFileSelector()"
v-if="!success"
>
上传
</Button>
<Button
:loading="uploading"
:loadingText="$t('Uploading...')"
@click="openFileSelector()"
v-if="!success"
>
{{ $t('Upload') }}
</Button>
<GreenCheckIcon class="w-5" v-if="success" />
</template>
</ListItem>
@ -72,36 +72,32 @@ export default {
icon: '<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.33325 9.33333V22.6667C5.33325 25.6133 10.1093 28 15.9999 28C21.8906 28 26.6666 25.6133 26.6666 22.6667V9.33333M5.33325 9.33333C5.33325 12.28 10.1093 14.6667 15.9999 14.6667C21.8906 14.6667 26.6666 12.28 26.6666 9.33333M5.33325 9.33333C5.33325 6.38667 10.1093 4 15.9999 4C21.8906 4 26.6666 6.38667 26.6666 9.33333M26.6666 16C26.6666 18.9467 21.8906 21.3333 15.9999 21.3333C10.1093 21.3333 5.33325 18.9467 5.33325 16" stroke="#1F272E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
type: 'database',
ext: 'application/x-gzip,application/sql,.sql',
title: '数据库备份',
description:
'上传数据库备份文件。通常文件名以 .sql.gz 或 .sql 结尾',
title: this.$t('Database Backup'),
description: this.$t('Upload database backup file. Usually the filename ends with .sql.gz or .sql'),
file: null
},
{
icon: '<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.39111 6.3913H26.3476V22.2174C26.3476 25.9478 23.2955 29 19.565 29H9.39111V6.3913Z" stroke="#1F272E" stroke-width="1.5" stroke-miterlimit="10"/><path d="M13.9131 13.1739H21.8261" stroke="#1F272E" stroke-width="1.5" stroke-miterlimit="10"/><path d="M13.9131 17.6957H21.8261" stroke="#1F272E" stroke-width="1.5" stroke-miterlimit="10"/><path d="M13.9131 22.2173H19.8479" stroke="#1F272E" stroke-width="1.5" stroke-miterlimit="10"/><path d="M22.9565 6.3913V3H6V25.6087H9.3913" stroke="#1F272E" stroke-width="1.5" stroke-miterlimit="10"/></svg>',
type: 'public',
ext: 'application/x-tar',
title: '公共文件',
description:
'上传公共文件备份。通常文件名以 -files.tar 结尾',
title: this.$t('Public Files'),
description: this.$t('Upload public files backup. Usually the filename ends with -files.tar'),
file: null
},
{
icon: '<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.39111 6.3913H25.3476V22.2174C25.3476 25.9478 22.2955 29 18.565 29H8.39111V6.3913Z" stroke="#1F272E" stroke-width="1.5" stroke-miterlimit="10"/><path d="M21.9565 6.3913V3H5V25.6087H8.3913" stroke="#1F272E" stroke-width="1.5" stroke-miterlimit="10"/></svg>',
type: 'private',
ext: 'application/x-tar',
title: '私有文件',
description:
'上传私有文件备份。通常文件名以 -private-files.tar 结尾',
title: this.$t('Private Files'),
description: this.$t('Upload private files backup. Usually the filename ends with -private-files.tar'),
file: null
},
{
icon: '<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8.39111 6.3913H25.3476V22.2174C25.3476 25.9478 22.2955 29 18.565 29H8.39111V6.3913Z" stroke="#1F272E" stroke-width="1.5" stroke-miterlimit="10"/><path d="M21.9565 6.3913V3H5V25.6087H8.3913" stroke="#1F272E" stroke-width="1.5" stroke-miterlimit="10"/></svg>',
type: 'config',
ext: 'application/json',
title: '站点配置(如备份已加密则必需)',
description:
'上传站点配置文件。通常文件名以 -site_config_backup.json 结尾',
title: this.$t('Site Config (required if backup is encrypted)'),
description: this.$t('Upload site config file. Usually the filename ends with -site_config_backup.json'),
file: null
}
]
@ -118,7 +114,7 @@ export default {
// valid strings are "database.sql.gz", "database.sql", "database.sql (1).gz", "database.sql (2).gz"
if (!/\.sql( \(\d\))?\.gz$|\.sql$/.test(file.name)) {
throw new Error(
'数据库备份文件应以"database.sql.gz"或"database.sql"结尾'
this.$t('Database backup file should end with "database.sql.gz" or "database.sql"')
);
}
if (
@ -128,17 +124,18 @@ export default {
'application/sql'
].includes(file.type)
) {
throw new Error('无效的数据库备份文件');
throw new Error(this.$t('Invalid database backup file'));
}
}
if (['public', 'private'].includes(type)) {
if (file.type != 'application/x-tar') {
throw new Error(`无效的${type === 'public' ? '公共' : '私有'}文件备份文件`);
const fileType = type === 'public' ? this.$t('public') : this.$t('private');
throw new Error(this.$t('Invalid {fileType} files backup file', { fileType }));
}
}
if (type === 'config') {
if (file.type != 'application/json') {
throw new Error(`无效的站点配置文件`);
throw new Error(this.$t('Invalid site config file'));
}
}
}

View File

@ -11,7 +11,7 @@
:loading="$resources.createAlipayOrder.loading || loading"
@click="processAlipayPayment"
>
使用支付宝支付
{{ $t('Pay with Alipay') }}
</Button>
</div>
</div>
@ -49,18 +49,18 @@ export default {
},
validate() {
if (this.amount <= 0) {
throw new DashboardError('支付金额必须大于0');
throw new DashboardError(this.$t('Payment amount must be greater than 0'));
}
if (this.amount < this.minimumAmount) {
throw new DashboardError('金额低于最低要求金额');
throw new DashboardError(this.$t('Amount is below the minimum required amount'));
}
},
onSuccess(response) {
window.open(response.payment_url, '_blank');
toast.success('支付页面已在新窗口打开');
toast.success(this.$t('Payment page opened in new window'));
//
// Optional: check payment status
// this.checkPaymentStatus(response.order_id);
}
};
@ -71,7 +71,7 @@ export default {
this.$resources.createAlipayOrder.submit();
},
//
// Optional: method to check payment status
checkPaymentStatus(orderId) {
const checkInterval = setInterval(() => {
this.$call('jcloud.api.billing.check_payment_status', {
@ -80,15 +80,15 @@ export default {
}).then(result => {
if (result && result.status === 'Success') {
clearInterval(checkInterval);
toast.success('支付成功!账户已充值');
toast.success(this.$t('Payment successful! Account has been recharged'));
this.$emit('payment-success');
}
}).catch(err => {
console.error('检查支付状态出错:', err);
console.error('Error checking payment status:', err);
});
}, 3000);
// 5
// Stop checking after 5 minutes
setTimeout(() => {
clearInterval(checkInterval);
}, 300000);

View File

@ -0,0 +1,388 @@
<template>
<div>
<FormControl
:label="$t('Payment Amount')"
class="mb-3"
v-model.number="creditsToBuy"
name="amount"
autocomplete="off"
type="number"
:min="minimumAmount"
:error="amountError"
>
<template #prefix>
<div class="grid w-4 place-items-center text-sm text-gray-700">
{{ $team.pg.currency === 'CNY' ? '¥' : '$' }}
</div>
</template>
</FormControl>
</div>
<div class="mt-4">
<div class="text-xs text-gray-600">{{ $t('Payment Method') }}</div>
<div class="mt-1.5 grid grid-cols-1 gap-2 sm:grid-cols-3">
<label
class="payment-option"
:class="{'payment-option-active': paymentGateway === 'Alipay'}"
v-if="isChineseLocale"
>
<div class="payment-icon">
<AlipayLogo />
</div>
<input type="radio" v-model="paymentGateway" value="Alipay" class="hidden-radio">
<span class="checkmark-circle" v-if="paymentGateway === 'Alipay'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="checkmark-icon">
<path fill="currentColor" d="M9.55 18l-5.7-5.7 1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>
</svg>
</span>
</label>
<label
class="payment-option"
:class="{'payment-option-active': paymentGateway === 'WeChatPay'}"
v-if="isChineseLocale"
>
<div class="payment-icon">
<WeChatPayLogo />
</div>
<input type="radio" v-model="paymentGateway" value="WeChatPay" class="hidden-radio">
<span class="checkmark-circle" v-if="paymentGateway === 'WeChatPay'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="checkmark-icon">
<path fill="currentColor" d="M9.55 18l-5.7-5.7 1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>
</svg>
</span>
</label>
<label
class="payment-option"
:class="{'payment-option-active': paymentGateway === 'Stripe'}"
v-if="!isChineseLocale"
>
<div class="payment-icon">
<StripeLogo />
</div>
<input type="radio" v-model="paymentGateway" value="Stripe" class="hidden-radio">
<span class="checkmark-circle" v-if="paymentGateway === 'Stripe'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="checkmark-icon">
<path fill="currentColor" d="M9.55 18l-5.7-5.7 1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>
</svg>
</span>
</label>
<label
class="payment-option"
:class="{'payment-option-active': paymentGateway === 'PayPal'}"
v-if="!isChineseLocale"
>
<div class="payment-icon">
<!-- PayPal SVG Logo -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 119 28" class="paypal-svg">
<path d="M10.49,27.45c0-0.08,0.02-0.16,0.03-0.24c0.22-0.9,1.1-1.59,2.02-1.59h3.13c0.81,0,1.56,0.45,1.93,1.17
c0.04,0.06,0.09,0.12,0.14,0.17c0.36,0.43,0.55,0.96,0.55,1.52c0,0.08-0.01,0.16-0.03,0.24C19.03,27.79,18.89,28,18.73,28
c-0.36,0-0.72-0.15-0.99-0.43c-0.07-0.08-0.14-0.16-0.22-0.24c-0.06-0.07-0.11-0.13-0.17-0.19c-0.2-0.18-0.47-0.28-0.75-0.28h-0.87
c-0.92,0-1.66-0.69-1.76-1.59C12.96,27.3,11.89,27.45,10.49,27.45z" fill="#003087"></path>
<path d="M9.94,22.55c0.02-0.89,0.8-1.59,1.68-1.59h3.98c0.92,0,1.66,0.7,1.68,1.59c0,0.89-0.74,1.59-1.68,1.59h-3.98
C10.68,24.14,9.92,23.44,9.94,22.55z" fill="#003087"></path>
<path d="M21.63,22.55c0.02-0.89,0.8-1.59,1.68-1.59h3.22c0.92,0,1.66,0.7,1.68,1.59c0,0.89-0.74,1.59-1.68,1.59h-3.22
C22.37,24.14,21.61,23.44,21.63,22.55z" fill="#003087"></path>
<path d="M28.63,22.55c0.02-0.89,0.8-1.59,1.68-1.59h3.69c0.92,0,1.66,0.7,1.68,1.59c0,0.89-0.74,1.59-1.68,1.59h-3.69
C29.37,24.14,28.61,23.44,28.63,22.55z" fill="#003087"></path>
<path d="M36.16,22.55c0.02-0.89,0.8-1.59,1.68-1.59h4.42c0.92,0,1.66,0.7,1.68,1.59c0,0.89-0.74,1.59-1.68,1.59h-4.42
C36.9,24.14,36.14,23.44,36.16,22.55z" fill="#003087"></path>
<path d="M42.71,22.55c0.02-0.89,0.8-1.59,1.68-1.59h4.11c0.92,0,1.66,0.7,1.68,1.59c0,0.89-0.74,1.59-1.68,1.59h-4.11
C43.45,24.14,42.69,23.44,42.71,22.55z" fill="#003087"></path>
<path d="M50.07,22.55c0.02-0.89,0.8-1.59,1.68-1.59h4.34c0.92,0,1.66,0.7,1.68,1.59c0,0.89-0.74,1.59-1.68,1.59h-4.34
C50.81,24.14,50.05,23.44,50.07,22.55z" fill="#003087"></path>
<path d="M113.45,9.46h-3.11c-0.7,0-1.28,0.59-1.28,1.3c0,0.66,0.51,1.19,1.14,1.29l3.24,0.53c0.76,0.12,1.32,0.81,1.32,1.59
c0,0.83-0.68,1.5-1.53,1.5c-0.49,0-0.94-0.22-1.23-0.58c-0.04-0.05-0.08-0.11-0.12-0.16c-0.16-0.2-0.27-0.43-0.34-0.68h-2.69
c0,0.59,0.11,1.16,0.34,1.68c0.33,0.74,0.96,1.26,1.75,1.26c0.25,0,0.5-0.05,0.72-0.14c0.14-0.06,0.27-0.12,0.4-0.2
c0.06-0.04,0.12-0.08,0.17-0.12c0.42-0.29,0.64-0.78,0.64-1.32c0-0.21-0.04-0.42-0.12-0.62c-0.05-0.12-0.11-0.24-0.18-0.35
c-0.04-0.06-0.08-0.12-0.13-0.18l-0.19-0.22c-0.06-0.07-0.12-0.13-0.19-0.2c-0.03-0.03-0.05-0.05-0.08-0.08
c-0.18-0.17-0.41-0.28-0.67-0.28h2.04c0.43,0,0.78-0.35,0.78-0.78c0-0.43-0.35-0.78-0.78-0.78h-0.98c-0.69,0-1.25-0.56-1.25-1.25
c0-0.65,0.52-1.18,1.16-1.28l3.71-0.52C113.13,8.86,113.45,9.18,113.45,9.46z" fill="#009CDE"></path>
<path d="M92.72,10.34c0-0.18-0.04-0.36-0.12-0.52c-0.36-0.63-0.99-1.01-1.74-1.01c-0.27,0-0.53,0.05-0.78,0.15
c-0.25,0.1-0.47,0.23-0.68,0.4c-0.1-0.14-0.22-0.28-0.35-0.4c-0.25-0.1-0.51-0.15-0.78-0.15c-0.75,0-1.38,0.38-1.74,1.01
c-0.08,0.16-0.12,0.34-0.12,0.52c0,0.19,0.04,0.37,0.12,0.53c0.36,0.63,0.99,1.02,1.74,1.02c0.28,0,0.54-0.05,0.78-0.15
c0.25-0.1,0.47-0.23,0.68-0.4c0.1,0.14,0.22,0.28,0.35,0.4c0.24,0.1,0.51,0.15,0.78,0.15c0.75,0,1.38-0.39,1.74-1.02
C92.68,10.71,92.72,10.53,92.72,10.34z M90.05,10.34c0,0.7-0.57,1.27-1.27,1.27c-0.7,0-1.27-0.57-1.27-1.27
c0-0.7,0.57-1.27,1.27-1.27C89.48,9.07,90.05,9.64,90.05,10.34z" fill="#003087"></path>
<path d="M73.94,10.34c0-0.18-0.04-0.36-0.12-0.52c-0.36-0.63-0.99-1.01-1.74-1.01c-0.27,0-0.53,0.05-0.78,0.15
c-0.25,0.1-0.47,0.23-0.68,0.4c-0.1-0.14-0.22-0.28-0.35-0.4c-0.25-0.1-0.51-0.15-0.78-0.15c-0.75,0-1.38,0.38-1.74,1.01
c-0.08,0.16-0.12,0.34-0.12,0.52c0,0.19,0.04,0.37,0.12,0.53c0.36,0.63,0.99,1.02,1.74,1.02c0.28,0,0.54-0.05,0.78-0.15
c0.25-0.1,0.47-0.23,0.68-0.4c0.1,0.14,0.22,0.28,0.35,0.4c0.24,0.1,0.51,0.15,0.78,0.15c0.75,0,1.38-0.39,1.74-1.02
C73.9,10.71,73.94,10.53,73.94,10.34z M71.27,10.34c0,0.7-0.57,1.27-1.27,1.27c-0.7,0-1.27-0.57-1.27-1.27
c0-0.7,0.57-1.27,1.27-1.27C70.7,9.07,71.27,9.64,71.27,10.34z" fill="#003087"></path>
<path d="M63.29,10.34c0-0.18-0.04-0.36-0.12-0.52c-0.36-0.63-0.99-1.01-1.74-1.01c-0.27,0-0.53,0.05-0.78,0.15
c-0.25,0.1-0.47,0.23-0.68,0.4c-0.1-0.14-0.22-0.28-0.35-0.4c-0.25-0.1-0.51-0.15-0.78-0.15c-0.75,0-1.38,0.38-1.74,1.01
c-0.08,0.16-0.12,0.34-0.12,0.52c0,0.19,0.04,0.37,0.12,0.53c0.36,0.63,0.99,1.02,1.74,1.02c0.28,0,0.54-0.05,0.78-0.15
c0.25-0.1,0.47-0.23,0.68-0.4c0.1,0.14,0.22,0.28,0.35,0.4c0.24,0.1,0.51,0.15,0.78,0.15c0.75,0,1.38-0.39,1.74-1.02
C63.25,10.71,63.29,10.53,63.29,10.34z M60.62,10.34c0,0.7-0.57,1.27-1.27,1.27c-0.7,0-1.27-0.57-1.27-1.27
c0-0.7,0.57-1.27,1.27-1.27C60.05,9.07,60.62,9.64,60.62,10.34z" fill="#003087"></path>
<path d="M42.32,9.46H36.97c0,0.59,0.11,1.16,0.34,1.68c0.33,0.74,0.96,1.26,1.75,1.26c0.81,0,1.49-0.54,1.74-1.27l-0.01,0.02
c-0.01,0.08-0.02,0.16-0.03,0.24c-0.22,0.9-1.1,1.59-2.02,1.59h-3.13c-0.92,0-1.66-0.69-1.76-1.59c-0.02,0.08-0.03,0.16-0.03,0.24
c0,0.69,0.56,1.25,1.25,1.25c0.25,0,0.5-0.05,0.72-0.14c0.14-0.06,0.27-0.12,0.4-0.2c0.06-0.04,0.12-0.08,0.17-0.12
c0.42-0.29,0.64-0.78,0.64-1.32c0-0.21-0.04-0.42-0.12-0.62c-0.05-0.12-0.11-0.24-0.18-0.35c-0.04-0.06-0.08-0.12-0.13-0.18
l-0.19-0.22c-0.06-0.07-0.12-0.13-0.19-0.2c-0.03-0.03-0.05-0.05-0.08-0.08c-0.18-0.17-0.41-0.28-0.67-0.28h2.04
c0.43,0,0.78-0.35,0.78-0.78c0-0.43-0.35-0.78-0.78-0.78H37c-0.69,0-1.25-0.56-1.25-1.25c0-0.65,0.52-1.18,1.16-1.28l3.71-0.52
c0.51-0.07,0.93-0.46,0.99-0.97c0.01-0.16,0.02-0.32,0.02-0.48c0-0.75-0.62-1.36-1.39-1.36h-3.51c-0.7,0-1.28,0.59-1.28,1.3
c0,0.66,0.51,1.19,1.14,1.29l3.24,0.53c0.76,0.12,1.32,0.81,1.32,1.59c0,0.83-0.68,1.5-1.53,1.5c-0.49,0-0.94-0.22-1.23-0.58
c-0.04-0.05-0.08-0.11-0.12-0.16c-0.16-0.2-0.27-0.43-0.34-0.68h-2.69c0,0.37,0.06,0.73,0.17,1.06c0.19,0.61,0.61,1.06,1.18,1.06
c0.45,0,0.85-0.26,1.07-0.68C41.92,10.39,42.32,10.01,42.32,9.46z M27.71,10.34c0-0.18-0.04-0.36-0.12-0.52
c-0.36-0.63-0.99-1.01-1.74-1.01c-0.27,0-0.53,0.05-0.78,0.15c-0.25,0.1-0.47,0.23-0.68,0.4c-0.1-0.14-0.22-0.28-0.35-0.4
c-0.25-0.1-0.51-0.15-0.78-0.15c-0.75,0-1.38,0.38-1.74,1.01c-0.08,0.16-0.12,0.34-0.12,0.52c0,0.19,0.04,0.37,0.12,0.53
c0.36,0.63,0.99,1.02,1.74,1.02c0.28,0,0.54-0.05,0.78-0.15c0.25-0.1,0.47-0.23,0.68-0.4c0.1,0.14,0.22,0.28,0.35,0.4
c0.24,0.1,0.51,0.15,0.78,0.15c0.75,0,1.38-0.39,1.74-1.02C27.67,10.71,27.71,10.53,27.71,10.34z M25.04,10.34c0,0.7-0.57,1.27-1.27,1.27
c-0.7,0-1.27-0.57-1.27-1.27c0-0.7,0.57-1.27,1.27-1.27C24.47,9.07,25.04,9.64,25.04,10.34z" fill="#003087"></path>
</svg>
</div>
<input type="radio" v-model="paymentGateway" value="PayPal" class="hidden-radio">
<span class="checkmark-circle" v-if="paymentGateway === 'PayPal'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="checkmark-icon">
<path fill="currentColor" d="M9.55 18l-5.7-5.7 1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z"/>
</svg>
</span>
</label>
</div>
</div>
<div class="text-xs text-gray-500 mt-2 mb-4" v-if="$team.pg.currency === 'CNY'">
{{ $t('Pay with {method}, fast and convenient. Balance will be credited immediately after successful payment.', { method: paymentGateway === 'Alipay' ? $t('Alipay') : paymentGateway === 'WeChatPay' ? $t('WeChat Pay') : paymentGateway === 'Stripe' ? $t('Stripe') : $t('PayPal') }) }}
</div>
<BuyPrepaidCreditsAlipay
v-if="paymentGateway === 'Alipay'"
:amount="creditsToBuy"
:minimumAmount="minimumAmount"
:isOnboarding="isOnboarding"
@success="onSuccess"
@cancel="onCancel"
/>
<BuyPrepaidCreditsWeChatPay
v-if="paymentGateway === 'WeChatPay'"
:amount="creditsToBuy"
:minimumAmount="minimumAmount"
:isOnboarding="isOnboarding"
@success="onSuccess"
@cancel="onCancel"
/>
<BuyPrepaidCreditsStripe
v-if="paymentGateway === 'Stripe'"
:amount="creditsToBuy"
:minimumAmount="minimumAmount"
@success="onSuccess"
/>
<BuyPrepaidCreditsPayPal
v-if="paymentGateway === 'PayPal'"
:amount="creditsToBuy"
:minimumAmount="minimumAmount"
:isOnboarding="isOnboarding"
@success="onSuccess"
@cancel="onCancel"
/>
</template>
<script>
import BuyPrepaidCreditsAlipay from './BuyPrepaidCreditsAlipay.vue';
import BuyPrepaidCreditsWeChatPay from './BuyPrepaidCreditsWeChatPay.vue';
import BuyPrepaidCreditsStripe from './BuyPrepaidCreditsStripe.vue';
import BuyPrepaidCreditsPayPal from './BuyPrepaidCreditsPayPal.vue';
import AlipayLogo from '../logo/AlipayLogo.vue';
import WeChatPayLogo from '../logo/WeChatPayLogo.vue';
import StripeLogo from './StripeLogo.vue';
import { getLocale } from '../utils/i18n';
export default {
name: 'BuyPrepaidCreditsForm',
components: {
BuyPrepaidCreditsAlipay,
BuyPrepaidCreditsWeChatPay,
BuyPrepaidCreditsStripe,
BuyPrepaidCreditsPayPal,
AlipayLogo,
WeChatPayLogo,
StripeLogo
},
data() {
return {
_paymentGateway: null,
creditsToBuy: this.minimumAmount || 0,
amountError: ''
};
},
computed: {
isChineseLocale() {
return getLocale() === 'zh';
},
defaultPaymentGateway() {
return this.isChineseLocale ? 'Alipay' : 'Stripe';
},
paymentGateway: {
get() {
return this._paymentGateway || this.defaultPaymentGateway;
},
set(value) {
this._paymentGateway = value;
}
}
},
props: {
modelValue: {
default: false
},
minimumAmount: {
type: Number,
default: 0
},
isOnboarding: {
type: Boolean,
default: false
}
},
emits: ['success', 'cancel'],
methods: {
onSuccess() {
this.$emit('success');
},
onCancel() {
this.$emit('cancel');
}
}
};
</script>
<style scoped>
.payment-option {
position: relative;
display: flex;
align-items: center;
justify-content: flex-start;
flex: 1;
border: 1px solid #ddd;
border-radius: 6px;
padding: 12px;
cursor: pointer;
transition: all 0.2s;
}
.payment-option:hover {
border-color: #ccc;
background-color: #f9fafb;
}
.payment-option-active,
.payment-option-active:has(input[value="Alipay"]:checked) {
border-color: #1677ff;
border-width: 2px;
box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.1);
}
.payment-option-active:has(input[value="WeChatPay"]:checked) {
border-color: #22AC38;
box-shadow: 0 0 0 1px rgba(34, 172, 56, 0.1);
}
.payment-option-active:has(input[value="Stripe"]:checked) {
border-color: #6772e5;
box-shadow: 0 0 0 1px rgba(103, 114, 229, 0.1);
}
.payment-option-active:has(input[value="PayPal"]:checked) {
border-color: #003087;
box-shadow: 0 0 0 1px rgba(0, 48, 135, 0.1);
}
.payment-icon {
display: flex;
align-items: center;
justify-content: center;
height: auto;
width: 90px;
max-width: 90px;
min-width: 60px;
flex-shrink: 0;
}
.alipay-svg, .wechat-svg, .stripe-svg, .paypal-svg, .payment-icon svg {
width: 100%;
height: auto;
max-height: 30px;
}
.payment-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ml-2 {
margin-left: 8px;
}
.hidden-radio {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.checkmark-circle {
position: absolute;
top: 12px;
right: 12px;
width: 18px;
height: 18px;
border-radius: 50%;
background-color: #1677ff;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.checkmark-icon {
width: 14px;
height: 14px;
}
.payment-option-active:has(input[value="WeChatPay"]:checked) .checkmark-circle {
background-color: #22AC38;
}
.payment-option-active:has(input[value="PayPal"]:checked) .checkmark-circle {
background-color: #003087;
}
/* Responsive design */
@media (max-width: 640px) {
.payment-options {
flex-direction: column;
}
.payment-option {
padding: 10px;
}
.payment-icon {
width: 70px;
min-width: 50px;
}
.alipay-svg, .wechat-svg, .stripe-svg, .paypal-svg {
max-height: 24px;
}
.checkmark-circle {
top: 50%;
transform: translateY(-50%);
right: 10px;
width: 16px;
height: 16px;
}
.checkmark-icon {
width: 12px;
height: 12px;
}
}
</style>

View File

@ -0,0 +1,110 @@
<template>
<div>
<ErrorMessage
class="mt-3"
:message="$resources.createPayPalOrder.error || error"
/>
<div class="mt-4 flex w-full justify-end">
<Button
variant="solid"
class="paypal-btn"
:loading="$resources.createPayPalOrder.loading || loading"
@click="processPayPalPayment"
>
{{ $t('Pay with PayPal') }}
</Button>
</div>
</div>
</template>
<script>
import { toast } from 'vue-sonner';
import { DashboardError } from '../utils/error';
export default {
name: 'BuyPrepaidCreditsPayPal',
props: {
amount: {
default: 0
},
minimumAmount: {
default: 0
},
isOnboarding: {
default: false
}
},
emits: ['success', 'cancel'],
data() {
return {
loading: false,
error: null
};
},
resources: {
createPayPalOrder() {
return {
url: 'jcloud.api.billing.create_paypal_order_for_recharge',
params: {
amount: this.amount
},
validate() {
if (this.amount <= 0) {
throw new DashboardError(this.$t('Payment amount must be greater than 0'));
}
if (this.amount < this.minimumAmount) {
throw new DashboardError(this.$t('Amount is below the minimum required amount'));
}
},
onSuccess(response) {
window.open(response.payment_url, '_blank');
toast.success(this.$t('Payment page opened in new window'));
// Check payment status periodically
this.checkPaymentStatus(response.order_id);
}
};
}
},
methods: {
processPayPalPayment() {
this.$resources.createPayPalOrder.submit();
},
// Optional: method to check payment status
checkPaymentStatus(orderId) {
const checkInterval = setInterval(() => {
this.$call('jcloud.api.billing.check_payment_status', {
order_id: orderId,
payment_type: 'paypal'
}).then(result => {
if (result && result.status === 'Success') {
clearInterval(checkInterval);
toast.success(this.$t('Payment successful! Account has been recharged'));
this.$emit('success');
}
}).catch(err => {
console.error('Error checking payment status:', err);
});
}, 3000);
// Stop checking after 5 minutes
setTimeout(() => {
clearInterval(checkInterval);
}, 300000);
}
}
};
</script>
<style scoped>
.paypal-btn {
background-color: #00457C;
color: white;
}
.paypal-btn:hover {
background-color: #003057;
}
</style>

View File

@ -91,7 +91,7 @@ export default {
const options = {
key: data.key_id,
order_id: data.order_id,
name: '今果 Jingrow',
name: 'Jingrow',
image: '/assets/jcloud/images/jingrow-cloud-logo.png',
prefill: {
email: this.$team.pg.user

View File

@ -11,11 +11,11 @@
:loading="$resources.createWeChatPayOrder.loading || loading"
@click="processWeChatPayment"
>
使用微信支付
{{ $t('Pay with WeChat Pay') }}
</Button>
</div>
<!-- 使用Teleport将弹窗移到body下 -->
<!-- Use Teleport to move modal to body -->
<Teleport to="body">
<div v-if="showQRCode" class="wechat-qrcode-overlay">
<div class="wechat-qrcode-modal">
@ -31,20 +31,20 @@
</div>
<div class="text-center mb-5">
<div class="text-sm text-gray-600 mb-2">扫一扫付款</div>
<div class="text-3xl font-bold text-[#FF0036]">{{ amount }} </div>
<div class="text-sm text-gray-600 mb-2">{{ $t('Scan to Pay (CNY)') }}</div>
<div class="text-3xl font-bold text-[#FF0036]">{{ amount }} {{ $t('CNY') }}</div>
</div>
<div class="flex justify-center">
<div v-if="qrCodeImage" class="qrcode-container">
<img :src="qrCodeImage" alt="微信支付二维码" class="qrcode-image" />
<img :src="qrCodeImage" :alt="$t('WeChat Pay QR Code')" class="qrcode-image" />
</div>
<div v-else class="qrcode-loading"></div>
</div>
<div class="mt-5 text-center text-sm">
<div class="text-gray-600">请使用微信扫描二维码完成支付</div>
<div class="text-gray-500 mt-1">二维码有效期 15 分钟</div>
<div class="text-gray-600">{{ $t('Please scan the QR code with WeChat to complete payment') }}</div>
<div class="text-gray-500 mt-1">{{ $t('QR code valid for 15 minutes') }}</div>
</div>
</div>
</div>
@ -55,7 +55,7 @@
<script>
import { toast } from 'vue-sonner';
import { DashboardError } from '../utils/error';
import { Teleport } from 'vue'; // Teleport
import { Teleport } from 'vue'; // Ensure Teleport is imported
import WeChatPayLogo from '../logo/WeChatPayLogo.vue';
export default {
@ -94,14 +94,14 @@ export default {
},
validate() {
if (this.amount <= 0) {
throw new DashboardError('支付金额必须大于0');
throw new DashboardError(this.$t('Payment amount must be greater than 0'));
}
if (this.amount < this.minimumAmount) {
throw new DashboardError('金额低于最低要求金额');
throw new DashboardError(this.$t('Amount is below the minimum required amount'));
}
},
onSuccess(response) {
console.log('微信支付响应:', response);
console.log('WeChat Pay response:', response);
this.orderId = response.order_id;
this.qrCodeUrl = response.qr_code_url;
this.qrCodeImage = response.qr_code_image;
@ -118,11 +118,12 @@ export default {
payment_type: 'wechatpay'
},
onSuccess(data) {
// Check for success status (handles both English and Chinese status values from backend)
if (data && (data.status === 'Success' || data.status === '已支付' || data.status === '交易成功')) {
this.stopPaymentCheck();
this.closeQRCode();
this.$emit('payment-success');
toast.success('支付成功');
toast.success(this.$t('Payment successful'));
}
}
};
@ -181,7 +182,7 @@ export default {
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
}
/* 全新的弹窗样式 */
/* Modal overlay styles */
.wechat-qrcode-overlay {
position: fixed;
top: 0;
@ -192,7 +193,7 @@ export default {
display: flex;
align-items: center;
justify-content: center;
z-index: 99999; /* 极高的z-index确保在最上层 */
z-index: 99999; /* Very high z-index to ensure it's on top */
}
.wechat-qrcode-modal {

View File

@ -41,7 +41,7 @@
class="mt-4"
type="textarea"
variant="outline"
placeholder="我离开 今果 Jingrow 的原因是..."
placeholder="我离开 Jingrow 的原因是..."
size="md"
v-model="note"
/>
@ -52,7 +52,7 @@
<script>
import FormControl from 'jingrow-ui/src/components/FormControl.vue';
import StarRatingInput from '../../src/components/StarRatingInput.vue';
import StarRatingInput from './StarRatingInput.vue';
import { DashboardError } from '../utils/error';
export default {

View File

@ -19,7 +19,7 @@
variant="outline"
@click="copyTextContentToClipboard"
>
{{ copied ? 'copied' : 'copy' }}
{{ copied ? $t('Copied') : $t('Copy') }}
</button>
</div>
</template>
@ -44,16 +44,51 @@ export default {
};
},
methods: {
copyTextContentToClipboard() {
const clipboard = window.navigator.clipboard;
clipboard.writeText(this.textContent).then(() => {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 4000);
toast.success('Copied to clipboard!');
});
async copyTextContentToClipboard() {
try {
// 使 Clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(this.textContent);
this.showCopySuccess();
} else {
// 使 execCommand
this.fallbackCopyTextToClipboard(this.textContent);
}
} catch (error) {
// Clipboard API
this.fallbackCopyTextToClipboard(this.textContent);
}
},
fallbackCopyTextToClipboard(text) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
this.showCopySuccess();
} else {
toast.error(this.$t('Copy failed, please copy manually'));
}
} catch (err) {
toast.error(this.$t('Copy failed, please copy manually'));
} finally {
document.body.removeChild(textArea);
}
},
showCopySuccess() {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 4000);
toast.success(this.$t('Copied to clipboard!'));
}
}
};
</script>
</script>

View File

@ -6,7 +6,7 @@
<div class="flex gap-2">
<TextInput
v-model="searchQuery"
placeholder="搜索所有者姓名、单位名称..."
:placeholder="$t('Search owner name, company name...')"
class="flex-1"
>
<template #prefix>
@ -17,9 +17,9 @@
v-model="selectedStatus"
class="rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
>
<option value="">全部状态</option>
<option value="1">已实名认证</option>
<option value="0">未实名认证</option>
<option value="">{{ $t('All Status') }}</option>
<option value="1">{{ $t('Real-name Verified') }}</option>
<option value="0">{{ $t('Not Real-name Verified') }}</option>
</select>
</div>
</div>
@ -28,7 +28,7 @@
@click="createDomainOwner"
variant="solid"
iconLeft="plus"
label="新建所有者模板"
:label="$t('Create Owner Template')"
/>
</div>
</div>
@ -37,16 +37,16 @@
<div class="rounded-lg border border-gray-200 bg-white my-4">
<!-- 表头 -->
<div class="grid grid-cols-12 gap-4 border-b border-gray-200 bg-gray-50 px-6 py-3 text-sm font-medium text-gray-700">
<div class="col-span-4">所有者名称</div>
<div class="col-span-3">实名状态</div>
<div class="col-span-3">联系信息</div>
<div class="col-span-2">操作</div>
<div class="col-span-4">{{ $t('Owner Name') }}</div>
<div class="col-span-3">{{ $t('Real-name Status') }}</div>
<div class="col-span-3">{{ $t('Contact Information') }}</div>
<div class="col-span-2">{{ $t('Actions') }}</div>
</div>
<!-- 加载状态 -->
<div v-if="isLoading" class="flex items-center justify-center py-12">
<Loader2Icon class="h-6 w-6 animate-spin text-gray-400" />
<span class="ml-2 text-gray-600">加载中...</span>
<span class="ml-2 text-gray-600">{{ $t('Loading...') }}</span>
</div>
<!-- 错误状态 -->
@ -58,8 +58,8 @@
<!-- 空状态 -->
<div v-else-if="filteredOwners.length === 0" class="flex flex-col items-center justify-center py-12 text-gray-500">
<UsersIcon class="mb-4 h-12 w-12 text-gray-300" />
<p class="text-lg font-medium">暂无所有者模板</p>
<p class="text-sm">点击上方按钮创建第一个模板</p>
<p class="text-lg font-medium">{{ $t('No owner templates yet') }}</p>
<p class="text-sm">{{ $t('Click the button above to create your first template') }}</p>
</div>
<!-- 数据列表 -->
@ -93,7 +93,7 @@
variant="ghost"
size="sm"
class="text-blue-600 hover:text-blue-700"
title="查看详情"
:title="$t('View Details')"
>
<EyeIcon class="h-4 w-4" />
</Button>
@ -102,7 +102,7 @@
variant="ghost"
size="sm"
class="text-gray-600 hover:text-gray-700"
title="编辑"
:title="$t('Edit')"
>
<EditIcon class="h-4 w-4" />
</Button>
@ -116,7 +116,7 @@
? 'text-gray-400 cursor-not-allowed'
: 'text-red-600 hover:text-red-700'
]"
:title="isTemplateUsed(owner) ? '该模板已关联域名,无法删除' : '删除'"
:title="isTemplateUsed(owner) ? $t('This template is associated with domains and cannot be deleted') : $t('Delete')"
>
<Trash2Icon class="h-4 w-4" />
</Button>
@ -128,9 +128,9 @@
<!-- 分页 -->
<div v-if="pagination.total > pagination.limit" class="flex flex-col items-center space-y-4">
<div class="text-sm text-gray-700">
显示第 {{ (pagination.pageno - 1) * pagination.limit + 1 }} -
{{ Math.min(pagination.pageno * pagination.limit, pagination.total) }}
{{ pagination.total }} 条记录
{{ $t('Showing') }} {{ (pagination.pageno - 1) * pagination.limit + 1 }} -
{{ Math.min(pagination.pageno * pagination.limit, pagination.total) }} {{ $t('of') }}
{{ pagination.total }} {{ $t('records') }}
</div>
<div class="flex items-center space-x-2">
<Button
@ -185,7 +185,7 @@
<!-- 标题栏 -->
<div class="p-4 border-b border-gray-200 bg-white rounded-t-lg flex-shrink-0">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">所有者详细信息</h3>
<h3 class="text-lg font-medium text-gray-900">{{ $t('Owner Details') }}</h3>
<button
@click="closeDetailDialog"
class="text-gray-400 hover:text-gray-600 transition-colors duration-200"
@ -202,22 +202,22 @@
<div class="space-y-6">
<!-- 基本信息 -->
<div>
<h4 class="text-base font-medium text-gray-900 mb-4">基本信息</h4>
<h4 class="text-base font-medium text-gray-900 mb-4">{{ $t('Basic Information') }}</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">所有者类型</label>
<label class="block text-sm font-medium text-gray-700">{{ $t('Owner Type') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ getOwnerTypeText(selectedOwner.c_regtype) }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">实名认证状态</label>
<label class="block text-sm font-medium text-gray-700">{{ $t('Real-name Status') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ getRealNameStatusText(selectedOwner.r_status) }}</p>
</div>
<div v-if="selectedOwner.c_regtype === 'E'">
<label class="block text-sm font-medium text-gray-700">单位名称</label>
<label class="block text-sm font-medium text-gray-700">{{ $t('Company Name') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedOwner.c_org_m || '-' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">姓名</label>
<label class="block text-sm font-medium text-gray-700">{{ $t('Name') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ getDisplayName(selectedOwner) }}</p>
</div>
</div>
@ -225,26 +225,26 @@
<!-- 联系信息 -->
<div>
<h4 class="text-base font-medium text-gray-900 mb-4">联系信息</h4>
<h4 class="text-base font-medium text-gray-900 mb-4">{{ $t('Contact Information') }}</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">电子邮箱</label>
<label class="block text-sm font-medium text-gray-700">{{ $t('Email') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedOwner.c_em || '-' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">手机号码</label>
<label class="block text-sm font-medium text-gray-700">{{ $t('Phone Number') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedOwner.c_ph || '-' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">省份</label>
<label class="block text-sm font-medium text-gray-700">{{ $t('Province') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedOwner.c_st_m || '-' }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">城市</label>
<label class="block text-sm font-medium text-gray-700">{{ $t('City') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedOwner.c_ct_m || '-' }}</p>
</div>
<div class="col-span-2">
<label class="block text-sm font-medium text-gray-700">通讯地址</label>
<label class="block text-sm font-medium text-gray-700">{{ $t('Address') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedOwner.c_adr_m || '-' }}</p>
</div>
</div>
@ -252,14 +252,14 @@
<!-- 证件信息 -->
<div>
<h4 class="text-base font-medium text-gray-900 mb-4">证件信息</h4>
<h4 class="text-base font-medium text-gray-900 mb-4">{{ $t('ID Information') }}</h4>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">证件类型</label>
<label class="block text-sm font-medium text-gray-700">{{ $t('ID Type') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ getCertificateTypeName(selectedOwner.c_idtype_gswl) }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">证件号码</label>
<label class="block text-sm font-medium text-gray-700">{{ $t('ID Number') }}</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedOwner.c_idnum_gswl || '-' }}</p>
</div>
</div>
@ -274,13 +274,13 @@
@click="closeDetailDialog"
variant="outline"
>
关闭
{{ $t('Close') }}
</Button>
<Button
@click="editOwner(selectedOwner)"
variant="solid"
>
编辑
{{ $t('Edit') }}
</Button>
</div>
</div>
@ -293,7 +293,7 @@
<!-- 标题栏 -->
<div class="p-4 border-b border-gray-200 bg-white rounded-t-lg flex-shrink-0">
<div class="flex items-center justify-between">
<h3 class="text-lg font-medium text-gray-900">编辑域名所有者</h3>
<h3 class="text-lg font-medium text-gray-900">{{ $t('Edit Domain Owner') }}</h3>
<button
@click="closeEditDialog"
class="text-gray-400 hover:text-gray-600 transition-colors duration-200"
@ -310,7 +310,7 @@
<form @submit.prevent="handleEditSubmit" class="space-y-6">
<!-- 所有者类型 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">所有者类型</label>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ $t('Owner Type') }}</label>
<div class="flex gap-4">
<label class="flex items-center">
<input
@ -319,7 +319,7 @@
value="I"
class="mr-2"
>
<span class="text-sm">个人</span>
<span class="text-sm">{{ $t('Individual') }}</span>
</label>
<label class="flex items-center">
<input
@ -328,7 +328,7 @@
value="E"
class="mr-2"
>
<span class="text-sm">企业/组织</span>
<span class="text-sm">{{ $t('Enterprise/Organization') }}</span>
</label>
</div>
</div>
@ -337,33 +337,33 @@
<div class="space-y-4">
<!-- 单位名称企业时显示 -->
<div v-if="editFormData.c_regtype === 'E'" class="w-full">
<label class="block text-sm font-medium text-gray-700 mb-2">所有者单位名称</label>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ $t('Owner Company Name') }}</label>
<input
v-model="editFormData.c_org_m"
type="text"
class="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入单位名称"
:placeholder="$t('Please enter company name')"
>
</div>
<!-- 姓名部分 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"></label>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ $t('Last Name') }}</label>
<input
v-model="editFormData.c_ln_m"
type="text"
class="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入姓"
:placeholder="$t('Please enter last name')"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"></label>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ $t('First Name') }}</label>
<input
v-model="editFormData.c_fn_m"
type="text"
class="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入名"
:placeholder="$t('Please enter first name')"
>
</div>
</div>
@ -371,21 +371,21 @@
<!-- 联系信息 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">电子邮箱</label>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ $t('Email') }}</label>
<input
v-model="editFormData.c_em"
type="email"
class="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入电子邮箱"
:placeholder="$t('Please enter email')"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">手机号码</label>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ $t('Phone Number') }}</label>
<input
v-model="editFormData.c_ph"
type="tel"
class="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入手机号码"
:placeholder="$t('Please enter phone number')"
>
</div>
</div>
@ -393,41 +393,41 @@
<!-- 地址信息 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">省份</label>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ $t('Province') }}</label>
<input
v-model="editFormData.c_st_m"
type="text"
class="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入省份"
:placeholder="$t('Please enter province')"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">城市</label>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ $t('City') }}</label>
<input
v-model="editFormData.c_ct_m"
type="text"
class="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入城市"
:placeholder="$t('Please enter city')"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">邮编</label>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ $t('Postal Code') }}</label>
<input
v-model="editFormData.c_pc"
type="text"
class="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入邮编"
:placeholder="$t('Please enter postal code')"
>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">通讯地址</label>
<label class="block text-sm font-medium text-gray-700 mb-2">{{ $t('Address') }}</label>
<input
v-model="editFormData.c_adr_m"
type="text"
class="w-full border rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入通讯地址"
:placeholder="$t('Please enter address')"
>
</div>
</div>
@ -441,14 +441,14 @@
@click="closeEditDialog"
variant="outline"
>
取消
{{ $t('Cancel') }}
</Button>
<Button
@click="handleEditSubmit"
:loading="editLoading"
variant="solid"
>
保存
{{ $t('Save') }}
</Button>
</div>
</div>
@ -559,29 +559,29 @@ export default {
//
getRealNameStatusText(status) {
if (!status || status === '') {
return '未实名认证';
return this.$t('Not Real-name Verified');
}
// r_status
// 0: , 1: , 2: , 3: , 4: , 5:
const statusMap = {
'0': '未实名认证',
'1': '已实名认证',
'2': '实名认证中',
'3': '实名认证失败',
'4': '实名认证过期',
'5': '待审核'
'0': this.$t('Not Real-name Verified'),
'1': this.$t('Real-name Verified'),
'2': this.$t('Real-name Verification in Progress'),
'3': this.$t('Real-name Verification Failed'),
'4': this.$t('Real-name Verification Expired'),
'5': this.$t('Pending Review')
};
return statusMap[status] || '未实名认证';
return statusMap[status] || this.$t('Not Real-name Verified');
},
//
getOwnerTypeText(type) {
if (!type || type === '') return '未知';
if (!type || type === '') return this.$t('Unknown');
const typeMap = {
'I': '个人',
'E': '企业/组织'
'I': this.$t('Individual'),
'E': this.$t('Enterprise/Organization')
};
return typeMap[type] || '未知';
return typeMap[type] || this.$t('Unknown');
},
//
@ -597,25 +597,25 @@ export default {
getCertificateTypeName(type) {
const typeMap = {
//
'SFZ': '身份证',
'HZ': '护照',
'GAJMTX': '港澳居民来往内地通行证',
'TWJMTX': '台湾居民来往大陆通行证',
'WJLSFZ': '外国人永久居留身份证',
'GAJZZ': '港澳台居民居住证',
'ORG': '组织机构代码证',
'YYZZ': '工商营业执照',
'TYDM': '统一社会信用代码',
'SFZ': this.$t('ID Card'),
'HZ': this.$t('Passport'),
'GAJMTX': this.$t('Mainland Travel Permit for Hong Kong and Macao Residents'),
'TWJMTX': this.$t('Mainland Travel Permit for Taiwan Residents'),
'WJLSFZ': this.$t('Permanent Residence ID Card for Foreigners'),
'GAJZZ': this.$t('Residence Permit for Hong Kong, Macao and Taiwan Residents'),
'ORG': this.$t('Organization Code Certificate'),
'YYZZ': this.$t('Business License'),
'TYDM': this.$t('Unified Social Credit Code'),
//
'1': '身份证',
'5': '护照',
'6': '港澳居民来往内地通行证',
'11': '台湾居民来往大陆通行证',
'12': '外国人永久居留身份证',
'30': '港澳台居民居住证',
'2': '组织机构代码证',
'3': '工商营业执照',
'4': '统一社会信用代码'
'1': this.$t('ID Card'),
'5': this.$t('Passport'),
'6': this.$t('Mainland Travel Permit for Hong Kong and Macao Residents'),
'11': this.$t('Mainland Travel Permit for Taiwan Residents'),
'12': this.$t('Permanent Residence ID Card for Foreigners'),
'30': this.$t('Residence Permit for Hong Kong, Macao and Taiwan Residents'),
'2': this.$t('Organization Code Certificate'),
'3': this.$t('Business License'),
'4': this.$t('Unified Social Credit Code')
};
return typeMap[type] || type || '-';
},

View File

@ -28,7 +28,7 @@
v-if="rows.length === 0"
class="text-center text-sm leading-10 text-gray-500"
>
未找到结果
{{ $t('No results found') }}
</div>
<ListRow v-for="(row, i) in rows" :row="row" :key="row.name">
<template v-slot="{ column, item }">

View File

@ -6,32 +6,32 @@
</div>
<div class="flex gap-2">
<Button
@click="refreshRecords"
:loading="$resources.dnsRecords.loading"
variant="outline"
size="sm"
>
<RefreshCwIcon class="h-4 w-4 mr-1" />
刷新
</Button>
@click="refreshRecords"
:loading="$resources.dnsRecords.loading"
variant="outline"
size="sm"
>
<RefreshCwIcon class="h-4 w-4 mr-1" />
{{ $t('Refresh') }}
</Button>
<Button
v-if="selectedRecords.length > 0"
@click="batchDeleteRecords"
variant="outline"
size="sm"
class="text-red-600 hover:text-red-700"
>
<Trash2Icon class="h-4 w-4 mr-1" />
删除选中 ({{ selectedRecords.length }})
</Button>
v-if="selectedRecords.length > 0"
@click="batchDeleteRecords"
variant="outline"
size="sm"
class="text-red-600 hover:text-red-700"
>
<Trash2Icon class="h-4 w-4 mr-1" />
{{ $t('Delete Selected') }} ({{ selectedRecords.length }})
</Button>
<Button
@click="addNewRow"
variant="solid"
size="sm"
>
<PlusIcon class="h-4 w-4 mr-1" />
添加记录
</Button>
@click="addNewRow"
variant="solid"
size="sm"
>
<PlusIcon class="h-4 w-4 mr-1" />
{{ $t('Add Record') }}
</Button>
</div>
</div>
@ -39,7 +39,7 @@
<div v-if="$resources.dnsRecords.loading" class="flex items-center justify-center py-12">
<div class="flex items-center space-x-2">
<Loader2Icon class="h-5 w-5 animate-spin text-gray-500" />
<span class="text-gray-500">正在加载DNS记录...</span>
<span class="text-gray-500">{{ $t('Loading DNS records...') }}</span>
</div>
</div>
@ -48,7 +48,7 @@
<div class="flex items-center">
<AlertCircleIcon class="h-5 w-5 text-red-400 mr-2" />
<div>
<h3 class="text-sm font-medium text-red-800">加载失败</h3>
<h3 class="text-sm font-medium text-red-800">{{ $t('Load failed') }}</h3>
<p class="text-sm text-red-700 mt-1">{{ $resources.dnsRecords.error }}</p>
</div>
</div>
@ -70,31 +70,31 @@
/>
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
编号
{{ $t('Number') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
主机名 *
{{ $t('Hostname') }} *
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
类型 *
{{ $t('Type') }} *
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
线路
{{ $t('Line') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
对应值 *
{{ $t('Value') }} *
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
TTL
{{ $t('TTL') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
优先级
{{ $t('Priority') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
状态
{{ $t('Status') }}
</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
{{ $t('Actions') }}
</th>
</tr>
</thead>
@ -121,7 +121,7 @@
type="text"
class="w-24 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
:class="{ 'bg-gray-100 text-gray-500 cursor-not-allowed': !record.isNew }"
placeholder="主机名"
:placeholder="$t('Hostname')"
:disabled="!record.isNew"
/>
</div>
@ -160,7 +160,7 @@
'bg-orange-100 text-orange-800': record.type === 'SRV',
'bg-gray-100 text-gray-800': !record.type
}">
{{ record.type || '未知' }}
{{ record.type || $t('Unknown') }}
</span>
</div>
</td>
@ -171,12 +171,12 @@
v-model="record.line"
class="w-20 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">默认</option>
<option value="LTEL">电信</option>
<option value="LCNC">联通</option>
<option value="LMOB">移动</option>
<option value="LEDU">教育网</option>
<option value="LSEO">搜索引擎</option>
<option value="">{{ $t('Default') }}</option>
<option value="LTEL">{{ $t('Telecom') }}</option>
<option value="LCNC">{{ $t('Unicom') }}</option>
<option value="LMOB">{{ $t('Mobile') }}</option>
<option value="LEDU">{{ $t('Education Network') }}</option>
<option value="LSEO">{{ $t('Search Engine') }}</option>
</select>
</div>
<div v-else class="text-sm text-gray-500">
@ -190,7 +190,7 @@
v-model="record.value"
type="text"
class="w-32 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="记录值"
:placeholder="$t('Record Value')"
/>
</div>
<div v-else class="text-sm text-gray-900 max-w-xs truncate" :title="record.value">
@ -229,7 +229,7 @@
</td>
<td class="px-4 py-4 whitespace-nowrap text-sm">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
正常
{{ $t('Normal') }}
</span>
</td>
<td class="px-4 py-4 whitespace-nowrap text-sm">
@ -241,7 +241,7 @@
size="sm"
class="text-blue-600 hover:text-blue-700"
>
编辑
{{ $t('Edit') }}
</Button>
<Button
v-if="record.editing"
@ -250,7 +250,7 @@
size="sm"
class="text-green-600 hover:text-green-700"
>
保存
{{ $t('Save') }}
</Button>
<Button
v-if="record.editing"
@ -259,7 +259,7 @@
size="sm"
class="text-gray-600 hover:text-gray-700"
>
取消
{{ $t('Cancel') }}
</Button>
<Button
v-if="!record.editing && !record.isNew"
@ -268,7 +268,7 @@
size="sm"
class="text-red-600 hover:text-red-700"
>
删除
{{ $t('Delete') }}
</Button>
</div>
</td>
@ -280,9 +280,9 @@
<!-- 分页 -->
<div v-if="pagination.total > pagination.limit" class="flex flex-col items-center space-y-4">
<div class="text-sm text-gray-700">
显示第 {{ (pagination.pageno - 1) * pagination.limit + 1 }} -
{{ Math.min(pagination.pageno * pagination.limit, pagination.total) }}
{{ pagination.total }} 条记录
{{ $t('Showing') }} {{ (pagination.pageno - 1) * pagination.limit + 1 }} -
{{ Math.min(pagination.pageno * pagination.limit, pagination.total) }} {{ $t('of') }}
{{ pagination.total }} {{ $t('records') }}
</div>
<div class="flex items-center space-x-2">
<Button
@ -326,12 +326,12 @@
<!-- 空状态 -->
<div v-else-if="!$resources.dnsRecords.loading && !$resources.dnsRecords.error && dnsRecords.length === 0" class="text-center py-12">
<GlobeIcon class="mx-auto h-12 w-12 text-gray-400" />
<h3 class="mt-2 text-sm font-medium text-gray-900">暂无DNS记录</h3>
<p class="mt-1 text-sm text-gray-500">开始添加您的第一个DNS解析记录</p>
<h3 class="mt-2 text-sm font-medium text-gray-900">{{ $t('No DNS records yet') }}</h3>
<p class="mt-1 text-sm text-gray-500">{{ $t('Start adding your first DNS record') }}</p>
<div class="mt-6">
<Button @click="addNewRow" variant="solid">
<PlusIcon class="h-4 w-4 mr-1" />
添加记录
{{ $t('Add Record') }}
</Button>
</div>
</div>
@ -450,12 +450,12 @@ export default {
// 线
getLineDisplayName(line) {
const lineMap = {
'LTEL': '电信',
'LCNC': '联通',
'LMOB': '移动',
'LEDU': '教育网',
'LSEO': '搜索引擎',
'': '默认'
'LTEL': this.$t('Telecom'),
'LCNC': this.$t('Unicom'),
'LMOB': this.$t('Mobile'),
'LEDU': this.$t('Education Network'),
'LSEO': this.$t('Search Engine'),
'': this.$t('Default')
};
return lineMap[line] || line;
},

View File

@ -19,7 +19,7 @@
<svg class="mr-2 h-4 w-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
实名认证
{{ $t('Real-name Verified') }}
</div>
<button
@click="showRealNameInfo"
@ -28,7 +28,7 @@
<svg class="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
查看认证信息
{{ $t('View Verification Information') }}
</button>
</div>
<div v-else class="flex items-center space-x-3">
@ -36,13 +36,13 @@
<svg class="mr-2 h-4 w-4 text-amber-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
</svg>
待实名认证
{{ $t('Pending Real-name Verification') }}
</div>
<button
@click="showUploadRealName"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 rounded-lg transition-all duration-200 shadow-sm hover:shadow-md"
>
上传实名资料
{{ $t('Upload Real-name Documents') }}
</button>
</div>
</div>
@ -51,7 +51,7 @@
<div v-if="$domain.pg.end_date" class="mt-2 inline-flex items-center rounded-full bg-amber-50 px-4 py-2 text-sm font-medium text-amber-800">
<ClockIcon class="mr-1.5 h-4 w-4 text-amber-500" />
到期时间{{ $format.date($domain.pg.end_date) }}
{{ $t('Expiration Date') }}: {{ $format.date($domain.pg.end_date) }}
</div>
</div>
<div class="flex gap-2">
@ -60,7 +60,7 @@
:loading="$domain.renew?.loading"
class="px-5 !bg-[#1fc76f] !hover:bg-[#1bb85f] !text-white"
>
续费
{{ $t('Renew') }}
</Button>
</div>
</div>
@ -70,7 +70,7 @@
<!-- 域名信息 -->
<div class="rounded-md border">
<div class="h-12 border-b px-5 py-4">
<h2 class="text-lg font-medium text-gray-900">域名信息</h2>
<h2 class="text-lg font-medium text-gray-900">{{ $t('Domain Information') }}</h2>
</div>
<div>
<div
@ -78,7 +78,7 @@
:key="info.label"
class="flex items-center px-5 py-3 last:pb-5 even:bg-gray-50/70"
>
<div class="w-1/3 text-base text-gray-600">{{ info.label }}</div>
<div class="w-1/3 text-base text-gray-600">{{ $t(info.label) }}</div>
<div
class="flex w-2/3 items-center space-x-2 text-base text-gray-900"
>
@ -106,7 +106,7 @@
<!-- 操作 -->
<div class="rounded-md border">
<div class="h-12 border-b px-5 py-4">
<h2 class="text-lg font-medium text-gray-900">操作</h2>
<h2 class="text-lg font-medium text-gray-900">{{ $t('Actions') }}</h2>
</div>
<div class="p-5">
<div class="flex flex-wrap gap-2">
@ -116,7 +116,7 @@
variant="outline"
class="bg-gray-100 text-gray-700 hover:bg-gray-200"
>
修改DNS服务器
{{ $t('Modify DNS Servers') }}
</Button>
<!-- <Button
@click="toggleAutoRenew"
@ -133,32 +133,32 @@
<!-- DNS服务器信息 -->
<div class="rounded-md border">
<div class="h-12 border-b px-5 py-4">
<h2 class="text-lg font-medium text-gray-900">DNS服务器</h2>
<h2 class="text-lg font-medium text-gray-900">{{ $t('DNS Servers') }}</h2>
</div>
<div class="p-5">
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-gray-600">主DNS:</span>
<span class="text-gray-600">{{ $t('Primary DNS') }}:</span>
<span class="font-mono text-gray-900">{{ $domain.pg.dns_host1 }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">辅DNS:</span>
<span class="text-gray-600">{{ $t('Secondary DNS') }}:</span>
<span class="font-mono text-gray-900">{{ $domain.pg.dns_host2 }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">DNS3:</span>
<span class="text-gray-600">{{ $t('DNS3') }}:</span>
<span class="font-mono text-gray-900">{{ $domain.pg.dns_host3 }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">DNS4:</span>
<span class="text-gray-600">{{ $t('DNS4') }}:</span>
<span class="font-mono text-gray-900">{{ $domain.pg.dns_host4 }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">DNS5:</span>
<span class="text-gray-600">{{ $t('DNS5') }}:</span>
<span class="font-mono text-gray-900">{{ $domain.pg.dns_host5 }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">DNS6:</span>
<span class="text-gray-600">{{ $t('DNS6') }}:</span>
<span class="font-mono text-gray-900">{{ $domain.pg.dns_host6 }}</span>
</div>
</div>
@ -202,12 +202,12 @@ export default {
methods: {
getStatusText(status) {
const statusMap = {
'ok': '正常',
'clienthold': '锁定',
'clientupdateprohibited': '更新锁定',
'clienttransferprohibited': '转移锁定',
'clientdeleteprohibited': '删除锁定',
'clientrenewprohibited': '续费锁定'
'ok': this.$t('Normal'),
'clienthold': this.$t('Locked'),
'clientupdateprohibited': this.$t('Update Locked'),
'clienttransferprohibited': this.$t('Transfer Locked'),
'clientdeleteprohibited': this.$t('Delete Locked'),
'clientrenewprohibited': this.$t('Renew Locked')
};
return statusMap[status] || status;
},
@ -472,19 +472,19 @@ export default {
domainInformation() {
return [
{
label: '状态',
label: 'Status',
value: this.getStatusText(this.$domain.pg?.status),
},
},
{
label: '域名',
label: 'Domain',
value: this.$domain.pg?.domain,
},
{
label: '注册时间',
label: 'Registration Date',
value: this.$domain.pg?.registration_date,
},
{
label: '所有者',
label: 'Owner',
value: this.getOwnerDisplayName(),
},
];

View File

@ -8,7 +8,7 @@
<!-- 当前套餐卡片 -->
<div class="rounded-md border">
<div class="h-12 border-b px-5 py-4">
<h2 class="text-lg font-medium text-gray-900">当前套餐</h2>
<h2 class="text-lg font-medium text-gray-900">{{ $t('Current Plan') }}</h2>
</div>
<div class="p-5">
<div class="flex h-full flex-col sm:flex-row sm:items-center sm:justify-between">
@ -19,7 +19,7 @@
<div v-if="$jsiteServer.pg.end_date" class="mt-2 inline-flex items-center rounded-full bg-amber-50 px-4 py-2 text-sm font-medium text-amber-800">
<ClockIcon class="mr-1.5 h-4 w-4 text-amber-500" />
到期时间{{ $format.date($jsiteServer.pg.end_date) }}
{{ $t('Expiry Date') }}: {{ $format.date($jsiteServer.pg.end_date) }}
</div>
</div>
<div class="flex gap-2">
@ -28,14 +28,14 @@
:loading="$jsiteServer.renew?.loading"
class="px-5 !bg-[#1fc76f] !hover:bg-[#1bb85f] !text-white"
>
续费
{{ $t('Renew') }}
</Button>
<Button
@click="upgradeServer"
:loading="upgradeLoading"
class="px-5 !bg-[#3b82f6] !hover:bg-[#2563eb] !text-white"
>
升级
{{ $t('Upgrade') }}
</Button>
</div>
</div>
@ -45,25 +45,25 @@
<!-- 服务器配置 -->
<div class="rounded-md border">
<div class="h-12 border-b px-5 py-4">
<h2 class="text-lg font-medium text-gray-900">服务器配置</h2>
<h2 class="text-lg font-medium text-gray-900">{{ $t('Server Configuration') }}</h2>
</div>
<div class="p-5">
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-gray-600">CPU:</span>
<span class="text-lg font-semibold text-blue-600">{{ $jsiteServer.pg.cpu || '未知' }}</span>
<span class="text-gray-600">{{ $t('CPU') }}:</span>
<span class="text-lg font-semibold text-blue-600">{{ $jsiteServer.pg.cpu || $t('Unknown') }} {{ $t('cores') }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">内存:</span>
<span class="text-lg font-semibold text-green-600">{{ $jsiteServer.pg.memory || '未知' }}GB</span>
<span class="text-gray-600">{{ $t('Memory') }}:</span>
<span class="text-lg font-semibold text-green-600">{{ $jsiteServer.pg.memory || $t('Unknown') }}GB</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">系统盘:</span>
<span class="text-lg font-semibold text-purple-600">{{ $jsiteServer.pg.disk_size || '未知' }}GB</span>
<span class="text-gray-600">{{ $t('System Disk') }}:</span>
<span class="text-lg font-semibold text-purple-600">{{ $jsiteServer.pg.disk_size || $t('Unknown') }}GB</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">带宽:</span>
<span class="text-lg font-semibold text-orange-600">{{ $jsiteServer.pg.bandwidth || '未知' }}Mbps</span>
<span class="text-gray-600">{{ $t('Bandwidth') }}:</span>
<span class="text-lg font-semibold text-orange-600">{{ $jsiteServer.pg.bandwidth || $t('Unknown') }}Mbps</span>
</div>
</div>
</div>
@ -72,7 +72,7 @@
<!-- 服务器信息 -->
<div class="rounded-md border">
<div class="h-12 border-b px-5 py-4">
<h2 class="text-lg font-medium text-gray-900">服务器信息</h2>
<h2 class="text-lg font-medium text-gray-900">{{ $t('Server Information') }}</h2>
</div>
<div>
<div
@ -92,11 +92,11 @@
<Badge :label="info.value" />
</div>
<!-- 服务器名称特殊处理 - 支持内联编辑 -->
<div v-else-if="info.label === '服务器名称'" class="flex-1 min-w-0">
<div v-else-if="info.label === $t('Server Name')" class="flex-1 min-w-0">
<div v-if="!editingServerName"
@click="startEditServerName"
class="group flex items-center cursor-pointer rounded-md px-2 py-1 -mx-2 -my-1 hover:bg-gray-100 transition-colors duration-200"
:title="'点击编辑服务器名称'"
:title="$t('Click to edit server name')"
>
<span class="truncate">{{ info.value }}</span>
<svg class="ml-2 h-4 w-4 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -113,13 +113,13 @@
class="flex-1 px-2 py-1 text-sm border border-blue-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm"
:disabled="saveServerNameLoading"
maxlength="100"
placeholder="请输入服务器名称"
:placeholder="$t('Please enter server name')"
/>
<button
@click="saveServerName"
:disabled="saveServerNameLoading"
class="p-1 text-green-600 hover:text-green-700 disabled:opacity-50 transition-colors duration-200"
title="保存"
:title="$t('Save')"
>
<svg v-if="!saveServerNameLoading" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
@ -133,7 +133,7 @@
@click="cancelEditServerName"
:disabled="saveServerNameLoading"
class="p-1 text-gray-500 hover:text-gray-700 disabled:opacity-50 transition-colors duration-200"
title="取消"
:title="$t('Cancel')"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
@ -159,7 +159,7 @@
<!-- 操作 -->
<div class="rounded-md border">
<div class="h-12 border-b px-5 py-4">
<h2 class="text-lg font-medium text-gray-900">操作</h2>
<h2 class="text-lg font-medium text-gray-900">{{ $t('Actions') }}</h2>
</div>
<div class="p-5">
<div class="flex flex-wrap gap-2">
@ -169,7 +169,7 @@
variant="outline"
class="bg-gray-100 text-gray-700 hover:bg-gray-200"
>
重启
{{ $t('Restart') }}
</Button>
<Button
@click="forceRestartServer"
@ -177,7 +177,7 @@
variant="outline"
class="bg-orange-50 text-orange-700 hover:bg-orange-100 border-orange-200"
>
强制重启
{{ $t('Force Restart') }}
</Button>
<Button
@click="resetPassword"
@ -185,7 +185,7 @@
variant="outline"
class="bg-gray-100 text-gray-700 hover:bg-gray-200"
>
重置密码
{{ $t('Reset Password') }}
</Button>
<Button
@click="resetKeyPair"
@ -193,7 +193,7 @@
variant="outline"
class="bg-gray-100 text-gray-700 hover:bg-gray-200"
>
重置密钥对
{{ $t('Reset Key Pair') }}
</Button>
<Button
@click="deleteKeyPair"
@ -201,7 +201,7 @@
variant="outline"
class="bg-red-50 text-red-700 hover:bg-red-100 border-red-200"
>
删除密钥对
{{ $t('Delete Key Pair') }}
</Button>
<Button
@click="resetSystem"
@ -209,7 +209,7 @@
variant="outline"
class="bg-red-50 text-red-700 hover:bg-red-100 border-red-200"
>
重置系统
{{ $t('Reset System') }}
</Button>
</div>
</div>
@ -218,29 +218,29 @@
<!-- SSH连接信息 -->
<div class="rounded-md border">
<div class="h-12 border-b px-5 py-4">
<h2 class="text-lg font-medium text-gray-900">SSH连接</h2>
<h2 class="text-lg font-medium text-gray-900">{{ $t('SSH Connection') }}</h2>
</div>
<div class="p-5">
<div class="space-y-3">
<div class="flex justify-between">
<span class="text-gray-600">SSH端口:</span>
<span class="text-gray-600">{{ $t('SSH Port') }}:</span>
<span class="font-mono text-gray-900">{{ $jsiteServer.pg.ssh_port || '22' }}</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600">SSH用户:</span>
<span class="text-gray-600">{{ $t('SSH User') }}:</span>
<span class="font-mono text-gray-900">{{ $jsiteServer.pg.ssh_user || 'root' }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">服务器密码:</span>
<span class="text-gray-600">{{ $t('Server Password') }}:</span>
<div class="flex items-center space-x-2">
<span class="font-mono text-gray-900">
{{ showPassword ? decryptedPassword : ($jsiteServer.pg.password || '未设置') }}
{{ showPassword ? decryptedPassword : ($jsiteServer.pg.password || $t('Not Set')) }}
</span>
<button
v-if="$jsiteServer.pg.password"
@click="togglePassword"
class="text-gray-500 hover:text-gray-700 transition-colors"
:title="showPassword ? '隐藏密码' : '显示密码'"
:title="showPassword ? $t('Hide Password') : $t('Show Password')"
>
<EyeIcon v-if="!showPassword" class="h-4 w-4" />
<EyeOffIcon v-else class="h-4 w-4" />
@ -248,22 +248,22 @@
</div>
</div>
<div class="flex justify-between">
<span class="text-gray-600">密钥对名称:</span>
<span class="font-mono text-gray-900">{{ $jsiteServer.pg.key_pair_name || '未设置' }}</span>
<span class="text-gray-600">{{ $t('Key Pair Name') }}:</span>
<span class="font-mono text-gray-900">{{ $jsiteServer.pg.key_pair_name || $t('Not Set') }}</span>
</div>
<div class="flex flex-col">
<span class="text-gray-600 mb-2">私钥:</span>
<span class="text-gray-600 mb-2">{{ $t('Private Key') }}:</span>
<div v-if="$jsiteServer.pg.private_key" class="bg-gray-50 p-3 rounded border relative">
<button
@click="copyPrivateKey"
class="absolute top-2 right-2 p-1 text-gray-500 hover:text-gray-700 transition-colors rounded"
:title="copySuccess ? '已复制' : '复制私钥'"
:title="copySuccess ? $t('Copied!') : $t('Copy Private Key')"
>
<CopyIcon class="h-4 w-4" />
</button>
<pre class="font-mono text-xs text-gray-900 whitespace-pre-wrap break-all pr-8">{{ $jsiteServer.pg.private_key }}</pre>
</div>
<span v-else class="font-mono text-gray-900">未设置</span>
<span v-else class="font-mono text-gray-900">{{ $t('Not Set') }}</span>
</div>
</div>
</div>
@ -316,14 +316,14 @@ export default {
methods: {
getStatusText(status) {
const statusMap = {
'Pending': '准备中',
'Starting': '启动中',
'Running': '运行中',
'Stopping': '停止中',
'Stopped': '已停止',
'Resetting': '重置中',
'Upgrading': '升级中',
'Disabled': '已禁用'
'Pending': this.$t('Pending'),
'Starting': this.$t('Starting'),
'Running': this.$t('Running'),
'Stopping': this.$t('Stopping'),
'Stopped': this.$t('Stopped'),
'Resetting': this.$t('Resetting'),
'Upgrading': this.$t('Upgrading'),
'Disabled': this.$t('Disabled')
};
return statusMap[status] || status;
},
@ -342,32 +342,32 @@ export default {
},
getRegionText(region) {
const regionMap = {
'cn-qingdao': '华北1青岛',
'cn-beijing': '华北2北京',
'cn-zhangjiakou': '华北3张家口',
'cn-huhehaote': '华北5呼和浩特',
'cn-hangzhou': '华东1杭州',
'cn-shanghai': '华东2上海',
'cn-shenzhen': '华南1深圳',
'cn-heyuan': '华南2河源',
'cn-chengdu': '西南1成都',
'cn-guangzhou': '华南3广州',
'cn-wulanchabu': '华北6乌兰察布',
'cn-nanjing': '华东5南京',
'cn-fuzhou': '华东6福州',
'cn-wuhan-lr': '华中1武汉',
'cn-hongkong': '中国香港',
'ap-southeast-1': '新加坡',
'ap-southeast-3': '马来西亚(吉隆坡)',
'ap-southeast-5': '印度尼西亚(雅加达)',
'ap-northeast-1': '日本(东京)',
'us-west-1': '美国(硅谷)',
'us-east-1': '美国(弗吉尼亚)',
'eu-central-1': '德国(法兰克福)',
'eu-west-1': '英国(伦敦)',
'ap-southeast-6': '菲律宾(马尼拉)',
'ap-southeast-7': '泰国(曼谷)',
'ap-northeast-2': '韩国(首尔)'
'cn-qingdao': this.$t('North China 1 (Qingdao)'),
'cn-beijing': this.$t('North China 2 (Beijing)'),
'cn-zhangjiakou': this.$t('North China 3 (Zhangjiakou)'),
'cn-huhehaote': this.$t('North China 5 (Hohhot)'),
'cn-hangzhou': this.$t('East China 1 (Hangzhou)'),
'cn-shanghai': this.$t('East China 2 (Shanghai)'),
'cn-shenzhen': this.$t('South China 1 (Shenzhen)'),
'cn-heyuan': this.$t('South China 2 (Heyuan)'),
'cn-chengdu': this.$t('Southwest China 1 (Chengdu)'),
'cn-guangzhou': this.$t('South China 3 (Guangzhou)'),
'cn-wulanchabu': this.$t('North China 6 (Ulanqab)'),
'cn-nanjing': this.$t('East China 5 (Nanjing)'),
'cn-fuzhou': this.$t('East China 6 (Fuzhou)'),
'cn-wuhan-lr': this.$t('Central China 1 (Wuhan)'),
'cn-hongkong': this.$t('Hong Kong'),
'ap-southeast-1': this.$t('Singapore'),
'ap-southeast-3': this.$t('Malaysia (Kuala Lumpur)'),
'ap-southeast-5': this.$t('Indonesia (Jakarta)'),
'ap-northeast-1': this.$t('Japan (Tokyo)'),
'us-west-1': this.$t('US (Silicon Valley)'),
'us-east-1': this.$t('US (Virginia)'),
'eu-central-1': this.$t('Germany (Frankfurt)'),
'eu-west-1': this.$t('UK (London)'),
'ap-southeast-6': this.$t('Philippines (Manila)'),
'ap-southeast-7': this.$t('Thailand (Bangkok)'),
'ap-northeast-2': this.$t('South Korea (Seoul)')
};
return regionMap[region] || region;
},
@ -391,18 +391,18 @@ export default {
},
async restartServer() {
if (!this.$jsiteServer.pg.instance_id) {
toast.error('服务器实例ID不存在');
toast.error(this.$t('Server instance ID does not exist'));
return;
}
confirmDialog({
title: '重启',
message: '确定要重启服务器吗?重启过程中服务器将暂时不可用。',
title: this.$t('Restart'),
message: this.$t('Are you sure you want to restart the server? The server will be temporarily unavailable during the restart process.'),
primaryAction: {
label: '确定',
label: this.$t('Confirm'),
onClick: ({ hide }) => {
//
toast.success('重启请求已提交');
toast.success(this.$t('Restart request submitted'));
hide();
//
@ -428,17 +428,17 @@ export default {
},
async forceRestartServer() {
if (!this.$jsiteServer.pg.instance_id) {
toast.error('服务器实例ID不存在');
toast.error(this.$t('Server instance ID does not exist'));
return;
}
confirmDialog({
title: '强制重启',
message: '确定要强制重启服务器吗?该操作可能会导致未保存的数据丢失。',
title: this.$t('Force Restart'),
message: this.$t('Are you sure you want to force restart the server? This operation may cause unsaved data loss.'),
primaryAction: {
label: '确定',
label: this.$t('Confirm'),
onClick: ({ hide }) => {
toast.success('强制重启请求已提交');
toast.success(this.$t('Force restart request submitted'));
hide();
this.forceRestartLoading = true;
const req = createResource({
@ -462,7 +462,7 @@ export default {
},
async resetPassword() {
if (!this.$jsiteServer.pg.instance_id) {
toast.error('服务器实例ID不存在');
toast.error(this.$t('Server instance ID does not exist'));
return;
}
@ -470,11 +470,11 @@ export default {
const PasswordDialog = defineAsyncComponent(() => import('../dialogs/PasswordDialog.vue'));
renderDialog(h(PasswordDialog, {
title: '重置服务器密码',
description: '长度为 8 至 30 个字符,必须同时包含大小写英文字母、数字和特殊符号。',
title: this.$t('Reset Server Password'),
description: this.$t('Length must be between 8 and 30 characters, and must include uppercase and lowercase letters, numbers, and special characters.'),
onConfirm: (password) => {
//
toast.success('密码重置请求已提交');
toast.success(this.$t('Password reset request submitted'));
//
this.resetPasswordLoading = true;
@ -499,18 +499,18 @@ export default {
},
async resetKeyPair() {
if (!this.$jsiteServer.pg.instance_id) {
toast.error('服务器实例ID不存在');
toast.error(this.$t('Server instance ID does not exist'));
return;
}
confirmDialog({
title: '重置密钥对',
message: '确定要重置密钥对吗?这将删除旧的密钥对并创建新的密钥对。重置后需要使用新的私钥才能连接服务器。',
title: this.$t('Reset Key Pair'),
message: this.$t('Are you sure you want to reset the key pair? This will delete the old key pair and create a new one. After resetting, you will need to use the new private key to connect to the server.'),
primaryAction: {
label: '确定',
label: this.$t('Confirm'),
onClick: ({ hide }) => {
//
toast.success('密钥对重置请求已提交');
toast.success(this.$t('Key pair reset request submitted'));
hide();
//
@ -535,18 +535,18 @@ export default {
},
async deleteKeyPair() {
if (!this.$jsiteServer.pg.instance_id) {
toast.error('服务器实例ID不存在');
toast.error(this.$t('Server instance ID does not exist'));
return;
}
confirmDialog({
title: '删除密钥对',
message: '确定要删除密钥对吗?删除后将无法使用私钥连接服务器,建议先设置服务器密码。',
title: this.$t('Delete Key Pair'),
message: this.$t('Are you sure you want to delete the key pair? After deletion, you will not be able to connect to the server using the private key. It is recommended to set a server password first.'),
primaryAction: {
label: '确定',
label: this.$t('Confirm'),
onClick: ({ hide }) => {
//
toast.success('密钥对删除请求已提交');
toast.success(this.$t('Key pair deletion request submitted'));
hide();
//
@ -573,18 +573,18 @@ export default {
},
async resetSystem() {
if (!this.$jsiteServer.pg.instance_id) {
toast.error('服务器实例ID不存在');
toast.error(this.$t('Server instance ID does not exist'));
return;
}
confirmDialog({
title: '重置系统',
message: '确定要重置系统吗?这将清除所有数据并重新安装系统,操作不可逆!',
title: this.$t('Reset System'),
message: this.$t('Are you sure you want to reset the system? This will clear all data and reinstall the system. This operation is irreversible!'),
primaryAction: {
label: '确定',
label: this.$t('Confirm'),
onClick: ({ hide }) => {
//
toast.success('系统重置请求已提交');
toast.success(this.$t('System reset request submitted'));
hide();
//
@ -625,11 +625,11 @@ export default {
this.decryptedPassword = response;
this.showPassword = true;
} else {
toast.warning('当前没有保存的密码');
toast.warning(this.$t('No password is currently saved'));
}
},
onError: (error) => {
toast.error('获取密码失败');
toast.error(this.$t('Failed to get password'));
}
});
getPasswordRequest.submit();
@ -639,22 +639,22 @@ export default {
if (this.$jsiteServer.pg.private_key) {
navigator.clipboard.writeText(this.$jsiteServer.pg.private_key).then(() => {
this.copySuccess = true;
toast.success('私钥已复制到剪贴板');
toast.success(this.$t('Private key copied to clipboard'));
setTimeout(() => {
this.copySuccess = false;
}, 2000);
}).catch(() => {
toast.error('复制失败,请手动复制');
toast.error(this.$t('Copy failed, please copy manually'));
});
}
},
onRenewalSuccess(data) {
toast.success('服务器续费成功!');
toast.success(this.$t('Server renewal successful!'));
//
this.$jsiteServer.reload();
},
onUpgradeSuccess(data) {
toast.success('服务器升级成功!');
toast.success(this.$t('Server upgrade successful!'));
//
this.$jsiteServer.reload();
},
@ -688,17 +688,17 @@ export default {
const newName = this.editServerNameValue.trim();
if (!newName) {
toast.error('服务器名称不能为空');
toast.error(this.$t('Server name cannot be empty'));
return;
}
if (newName.length < 2) {
toast.error('服务器名称至少需要2个字符');
toast.error(this.$t('Server name must be at least 2 characters'));
return;
}
if (newName.length > 100) {
toast.error('服务器名称不能超过100个字符');
toast.error(this.$t('Server name cannot exceed 100 characters'));
return;
}
@ -714,7 +714,7 @@ export default {
{ title: newName },
{
onSuccess: () => {
toast.success('服务器名称已更新');
toast.success(this.$t('Server name updated'));
this.editingServerName = false;
this.editServerNameValue = '';
this.saveServerNameLoading = false;
@ -726,7 +726,7 @@ export default {
}
);
} catch (error) {
toast.error('更新失败,请重试');
toast.error(this.$t('Update failed, please try again'));
this.saveServerNameLoading = false;
}
},
@ -735,31 +735,31 @@ export default {
serverInformation() {
return [
{
label: '状态',
label: this.$t('Status'),
value: this.getStatusText(this.$jsiteServer.pg?.status),
},
{
label: '服务器名称',
label: this.$t('Server Name'),
value: this.$jsiteServer.pg?.title || this.$jsiteServer.pg?.name,
},
{
label: '实例ID',
label: this.$t('Instance ID'),
value: this.$jsiteServer.pg?.instance_id || '',
},
{
label: '公网IP',
label: this.$t('Public IP'),
value: this.$jsiteServer.pg?.public_ip || '',
},
{
label: '内网IP',
label: this.$t('Private IP'),
value: this.$jsiteServer.pg?.private_ip || '',
},
{
label: '区域',
label: this.$t('Region'),
value: this.getRegionText(this.$jsiteServer.pg?.region),
},
{
label: '系统',
label: this.$t('System'),
value: this.$jsiteServer.pg?.system || '',
},
];

View File

@ -1,23 +1,23 @@
<template>
<Card
class="md:col-span-2"
title="App Descriptions"
subtitle="Details about your app"
:title="$t('App Descriptions')"
:subtitle="$t('Details about your app')"
>
<div class="divide-y" v-if="app">
<ListItem title="Summary" :description="$sanitize(app.description)">
<ListItem :title="$t('Summary')" :description="$sanitize(app.description)">
<template #actions>
<Button icon-left="edit" @click="showEditSummaryDialog = true">
Edit
{{ $t('Edit') }}
</Button>
</template>
</ListItem>
<Dialog
:options="{
title: 'Update App Summary',
title: $t('Update App Summary'),
actions: [
{
label: 'Save Changes',
label: $t('Save Changes'),
variant: 'solid',
loading: $resources.updateAppSummary.loading,
onClick: () => $resources.updateAppSummary.submit()
@ -28,7 +28,7 @@
>
<template v-slot:body-content>
<FormControl
label="Summary of the app"
:label="$t('Summary of the app')"
type="textarea"
v-model="app.description"
/>
@ -39,10 +39,10 @@
</template>
</Dialog>
<div class="py-3">
<ListItem title="Long Description">
<ListItem :title="$t('Long Description')">
<template #actions>
<Button icon-left="edit" @click="showEditDescriptionDialog = true">
Edit
{{ $t('Edit') }}
</Button>
</template>
</ListItem>
@ -53,11 +53,11 @@
></div>
<Dialog
:options="{
title: 'Update App Description',
title: $t('Update App Description'),
size: '5xl',
actions: [
{
label: 'Save Changes',
label: $t('Save Changes'),
variant: 'solid',
loading: $resources.updateAppDescription.loading,
onClick: () => $resources.updateAppDescription.submit()
@ -91,7 +91,7 @@
:loading="$resources.fetchReadme.loading"
@click="$resources.fetchReadme.submit()"
>
Fetch Readme
{{ $t('Fetch Readme') }}
</Button>
</template>
</Card>
@ -122,7 +122,7 @@ export default {
summary: description
},
onSuccess() {
this.notifySuccess('App Summary Updated!');
this.notifySuccess(this.$t('App Summary Updated!'));
this.showEditSummaryDialog = false;
}
};
@ -136,7 +136,7 @@ export default {
description: long_description
},
onSuccess() {
this.notifySuccess('App Description Updated!');
this.notifySuccess(this.$t('App Description Updated!'));
this.showEditDescriptionDialog = false;
}
};
@ -147,8 +147,8 @@ export default {
params: { name: this.app.name },
onSuccess() {
notify({
title: 'Successfully fetched latest readme',
message: 'Long description updated!',
title: this.$t('Successfully fetched latest readme'),
message: this.$t('Long description updated!'),
icon: 'check',
color: 'green'
});

View File

@ -3,18 +3,18 @@
<div>
<div class="flex justify-between border-b pb-4">
<div>
<h2 class="text-lg font-medium text-gray-900">应用资料</h2>
<h2 class="text-lg font-medium text-gray-900">{{ $t('App Profile') }}</h2>
<p class="mt-1 text-sm leading-6 text-gray-600">
这些信息将公开显示在市场上请确保输入正确的信息没有损坏的链接和图片
{{ $t('This information will be publicly displayed on the marketplace. Please ensure you enter correct information, with no broken links and images.') }}
</p>
</div>
<Button :variant="editing ? 'solid' : 'subtle'" @click="updateListing"
>保存</Button
>{{ $t('Save') }}</Button
>
</div>
<div class="grid grid-cols-1 gap-x-5 border-b py-6 md:grid-cols-2">
<div class="border-r pr-6">
<span class="text-base font-medium">资料</span>
<span class="text-base font-medium">{{ $t('Profile') }}</span>
<div class="group relative my-4 flex">
<div class="flex flex-col">
<Avatar
@ -25,7 +25,7 @@
/>
</div>
<FileUploader
@success="() => imageAddSuccess('资料照片已更新')"
@success="() => imageAddSuccess($t('Profile photo updated'))"
@failure="imageAddFailure"
fileTypes="image/*"
:upload-args="{
@ -44,7 +44,7 @@
:class="{ 'opacity-50': uploading }"
>
<span v-if="uploading">{{ progress }}%</span>
<span v-else>编辑</span>
<span v-else>{{ $t('Edit') }}</span>
</button>
</div>
</template>
@ -53,18 +53,18 @@
<div class="pb-8 sm:col-span-4">
<FormControl
class="mt-4"
label="标题"
:label="$t('Title')"
type="text"
@input="editing = true"
v-model="marketplaceApp.title"
/>
</div>
<div class="sm:col-span-4">
<span class="text-base font-medium">链接</span>
<span class="text-base font-medium">{{ $t('Links') }}</span>
<div>
<FormControl
class="mt-4"
label="文档"
:label="$t('Documentation')"
type="text"
@blur="validateLink('documentation')"
@input="editing = true"
@ -72,7 +72,7 @@
/>
<FormControl
class="mt-4"
label="网站"
:label="$t('Website')"
type="text"
@blur="validateLink('website')"
@input="editing = true"
@ -80,7 +80,7 @@
/>
<FormControl
class="mt-4"
label="支持"
:label="$t('Support')"
type="text"
@blur="validateLink('support')"
@input="editing = true"
@ -88,7 +88,7 @@
/>
<FormControl
class="mt-4"
label="服务条款"
:label="$t('Terms of Service')"
type="text"
@blur="validateLink('terms_of_service')"
@input="editing = true"
@ -96,7 +96,7 @@
/>
<FormControl
class="mt-4"
label="隐私政策"
:label="$t('Privacy Policy')"
type="text"
@blur="validateLink('privacy_policy')"
@input="editing = true"
@ -107,10 +107,10 @@
</div>
<div class="hidden md:block">
<div class="flex w-full">
<span class="text-base font-medium">截图和视频</span>
<span class="text-base font-medium">{{ $t('Screenshots and Videos') }}</span>
<FileUploader
class="ml-auto"
@success="() => imageAddSuccess('已添加截图')"
@success="() => imageAddSuccess($t('Screenshot added'))"
@failure="imageAddFailure"
fileTypes="image/*"
:upload-args="{
@ -126,7 +126,7 @@
:loading="uploading"
@click="openFileSelector()"
icon-left="plus"
label="添加"
:label="$t('Add')"
>
</Button>
</template>
@ -152,16 +152,16 @@
</div>
</div>
<div class="mt-6">
<span class="text-base font-medium">描述</span>
<span class="text-base font-medium">{{ $t('Description') }}</span>
<FormControl
class="mt-4"
label="摘要"
:label="$t('Summary')"
type="textarea"
@input="editing = true"
v-model="marketplaceApp.description"
/>
<div class="mt-4">
<span class="text-xs text-gray-600">描述</span>
<span class="text-xs text-gray-600">{{ $t('Description') }}</span>
<TextEditor
class="mt-1 block w-full rounded border border-gray-100 bg-gray-100 px-2 py-1.5 text-base text-gray-800 placeholder-gray-500 transition-colors hover:border-gray-200 hover:bg-gray-200 focus:border-gray-500 focus:bg-white focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400"
ref="textEditor"
@ -258,21 +258,21 @@ export default {
toast.promise(this.$resources.updateListing.submit(), {
success: () => {
this.editing = false;
return '更新成功';
return this.$t('Listing updated successfully');
},
loading: '正在更新列表...',
loading: this.$t('Updating listing...'),
error: (err) => {
return err.messages?.length
? err.messages.join('\n')
: err.message || '更新列表失败';
: err.message || this.$t('Failed to update listing');
},
});
},
dropdownOptions(image) {
return [
{ label: '查看', onClick: () => window.open(image) },
{ label: this.$t('View'), onClick: () => window.open(image) },
{
label: '删除',
label: this.$t('Delete'),
onClick: () => {
toast.promise(
this.$resources.removeScreenshot.submit({
@ -280,15 +280,15 @@ onClick: () => {
file: image,
}),
{
loading: '正在删除截图...',
loading: this.$t('Deleting screenshot...'),
success: () => {
this.$resources.listingData.reload();
return '截图删除成功';
return this.$t('Screenshot deleted successfully');
},
error: (err) => {
return err.messages?.length
? err.messages.join('\n')
: err.message || '删除截图失败';
: err.message || this.$t('Failed to delete screenshot');
},
},
);

View File

@ -1,5 +1,5 @@
<template>
<Card title="App Profile" subtitle="Your app's primary profile">
<Card :title="$t('App Profile')" :subtitle="$t('Your app's primary profile')">
<div class="flex items-center border-b pb-6">
<div class="group relative">
<Avatar
@ -26,12 +26,12 @@
:class="{ 'opacity-50': uploading }"
>
<span v-if="uploading">{{ progress }}%</span>
<span v-else>Edit</span>
<span v-else>{{ $t('Edit') }}</span>
</button>
<button
class="absolute bottom-0 left-0 grid w-full place-items-center rounded-md bg-gray-900 text-xs font-semibold text-white text-opacity-70 opacity-80 group-hover:opacity-0"
>
<span>Edit</span>
<span>{{ $t('Edit') }}</span>
</button>
</div>
</template>
@ -45,12 +45,12 @@
</div>
<div class="ml-auto">
<Button icon-left="edit" @click="showAppProfileEditDialog = true">
Edit
{{ $t('Edit') }}
</Button>
</div>
</div>
<div class="mt-8 flex justify-between">
<p class="text-lg font-semibold">Published Versions</p>
<p class="text-lg font-semibold">{{ $t('Published Versions') }}</p>
<Button
icon-left="plus"
@click="
@ -59,7 +59,7 @@
}
"
>
Add
{{ $t('Add') }}
</Button>
</div>
<div class="divide-y" v-if="app">
@ -83,10 +83,10 @@
<Dialog
:options="{
title: 'Update App Title',
title: $t('Update App Title'),
actions: [
{
label: 'Save Changes',
label: $t('Save Changes'),
variant: 'solid',
loading: $resources.updateAppTitle.loading,
onClick: () => $resources.updateAppTitle.submit()
@ -96,7 +96,7 @@
v-model="showAppProfileEditDialog"
>
<template v-slot:body-content>
<FormControl label="App Title" v-model="app.title" />
<FormControl :label="$t('App Title')" v-model="app.title" />
<ErrorMessage class="mt-4" :message="$resources.updateAppTitle.error" />
</template>
@ -206,7 +206,7 @@ export default {
dropdownItems(source) {
return [
{
label: 'Change Branch',
label: this.$t('Change Branch'),
onClick: () => {
this.selectedSource = source.source;
this.selectedVersion = source.version;
@ -215,7 +215,7 @@ export default {
}
},
{
label: 'Remove',
label: this.$t('Remove'),
onClick: () => {
this.$resources.removeVersion.submit({
name: this.app.name,
@ -227,7 +227,7 @@ export default {
},
notifySuccess() {
notify({
title: 'App Profile Updated!',
title: this.$t('App Profile Updated!'),
icon: 'check',
color: 'green'
});

View File

@ -14,12 +14,12 @@
class="ml-auto"
:options="[
{
label: '切换团队',
label: $t('Switch Team'),
icon: 'command',
onClick: () => (showTeamSwitcher = true)
},
{
label: '注销',
label: $t('Logout'),
icon: 'log-out',
onClick: $session.logout.submit
}
@ -30,7 +30,7 @@
<template #suffix>
<i-lucide-chevron-down class="h-3.5 w-3.5 text-gray-700" />
</template>
{{ $team?.get.loading ? '加载中...' : $team?.pg?.user }}
{{ $team?.get.loading ? $t('Loading...') : $team?.pg?.user }}
</Button>
</template>
</Dropdown>

View File

@ -39,14 +39,14 @@ export default {
return [
{
name: '欢迎',
name: this.$t('Welcome'),
icon: () => h(DoorOpen),
route: '/welcome',
isActive: routeName === 'Welcome',
condition: !onboardingComplete,
},
{
name: '通知',
name: this.$t('Notifications'),
icon: () => h(Notification),
route: '/notifications',
isActive: routeName === 'Jcloud Notification List',
@ -67,7 +67,7 @@ export default {
disabled: enforce2FA,
},
{
name: '站点',
name: this.$t('Sites'),
icon: () => h(PanelTopInactive),
route: '/sites',
isActive:
@ -76,7 +76,7 @@ export default {
disabled: enforce2FA,
},
{
name: '工作台',
name: this.$t('Benches'),
icon: () => h(Package),
route: '/benches',
isActive: routeName.startsWith('Bench'),
@ -84,7 +84,7 @@ export default {
disabled: !onboardingComplete || enforce2FA,
},
{
name: '站点分组',
name: this.$t('Release Group'),
icon: () => h(Boxes),
route: '/groups',
isActive:
@ -100,7 +100,7 @@ export default {
disabled: enforce2FA,
},
{
name: 'Jingrow服务器',
name: this.$t('Jingrow Servers'),
icon: () => h(Server),
route: '/servers',
isActive:
@ -110,7 +110,7 @@ export default {
disabled: enforce2FA,
},
{
name: '域名',
name: this.$t('Domain'),
icon: () => h(Globe),
route: '/domains',
isActive:
@ -119,7 +119,7 @@ export default {
disabled: enforce2FA,
},
{
name: '服务器',
name: this.$t('Servers'),
icon: () => h(Server),
route: '/jsite-servers',
isActive:
@ -128,7 +128,7 @@ export default {
disabled: enforce2FA,
},
{
name: '应用市场',
name: this.$t('Marketplace'),
icon: () => h(App),
route: '/apps',
isActive: routeName.startsWith('Marketplace'),
@ -138,26 +138,26 @@ export default {
disabled: enforce2FA,
},
{
name: '开发工具',
name: this.$t('Developer Tools'),
icon: () => h(Code),
route: '/devtools',
condition: onboardingComplete && !isSaasUser && this.$team.pg.is_developer && this.$team.pg.is_pro,
disabled: enforce2FA,
children: [
{
name: 'SQL 实验室',
name: this.$t('SQL Playground'),
icon: () => h(DatabaseZap),
route: '/sql-playground',
isActive: routeName === 'SQL Playground',
},
{
name: '日志浏览器',
name: this.$t('Log Browser'),
icon: () => h(Logs),
route: '/log-browser',
isActive: routeName === 'Log Browser',
},
{
name: '数据库分析器',
name: this.$t('Database Analyzer'),
icon: () => h(Activity),
route: '/database-analyzer',
isActive: routeName === 'DB Analyzer',
@ -169,7 +169,7 @@ export default {
disabled: enforce2FA,
},
{
name: '充值',
name: this.$t('Recharge'),
icon: () => h(CreditCard),
route: '/recharge',
isActive: routeName.startsWith('RechargeCredits'),
@ -178,7 +178,7 @@ export default {
disabled: enforce2FA,
},
{
name: '账单',
name: this.$t('Billing'),
icon: () => h(WalletCards),
route: '/billing',
isActive: routeName.startsWith('Billing'),
@ -187,7 +187,7 @@ export default {
disabled: enforce2FA,
},
{
name: '合作伙伴门户',
name: this.$t('Partner Portal'),
icon: () => h(Globe),
route: '/partners',
isActive: routeName.startsWith('Partner'),
@ -195,7 +195,7 @@ export default {
disabled: enforce2FA,
},
{
name: '设置',
name: this.$t('Settings'),
icon: () => h(Settings),
route: '/settings',
isActive: routeName.startsWith('Settings'),

View File

@ -14,7 +14,7 @@
<slot name="header-left" v-bind="context">
<div v-if="showControls" class="flex items-center space-x-2">
<TextInput
placeholder="搜索"
:placeholder="$t('Search')"
class="max-w-[20rem]"
:debounce="500"
v-model="searchQuery"
@ -427,7 +427,7 @@ this.$socket.emit('pagetype_unsubscribe', pagetype);
);
},
emptyStateMessage() {
return this.options.emptyStateMessage || '未找到结果';
return this.options.emptyStateMessage || this.$t('No results found');
},
banner() {
if (this.options.banner) {

View File

@ -1,13 +1,13 @@
<template>
<div class="mx-auto max-w-2xl rounded-lg border-0 px-2 sm:border sm:p-8">
<div class="prose prose-sm max-w-none">
<h1 class="text-2xl font-semibold">欢迎来到 今果 Jingrow</h1>
<h1 class="text-2xl font-semibold">{{ $t('Welcome to Jingrow') }}</h1>
<p>
Jingrow是一站式通用数字化平台"一切皆页面"的革新理念帮助您快速构建从个人工作到团队协作的一站式数字化解决方案通过AI Agent智能体可视化工作流编排零代码可视化数据建模自动化任务团队协作角色与权限管理文件库知识库笔记会议待办事项通知智能报表等模块引领数字化协作新趋势
{{ $t('Jingrow is a one-stop universal digital platform that helps you quickly build comprehensive digital solutions from personal work to team collaboration through innovative concepts like &quot;Everything is a Page&quot;. Through modules such as AI Agent, visual workflow orchestration, zero-code visual data modeling, automated tasks, team collaboration, role and permission management, file library, knowledge base, notes, meetings, to-do items, notifications, and intelligent reports, leading the new trend of digital collaboration.') }}
</p>
</div>
<p class="mt-6 text-base text-gray-800">
从这里开始让一切数字化系统化智能化自动化
{{ $t('Start here to make everything digital, systematic, intelligent, and automated.') }}
</p>
<div class="mt-6 space-y-6">
<!-- 步骤 1 - 账户已创建 -->
@ -16,7 +16,7 @@
<div class="flex items-center justify-between space-x-2">
<div class="flex items-center space-x-2">
<TextInsideCircle>1</TextInsideCircle>
<span class="text-base font-medium"> 账户已创建 </span>
<span class="text-base font-medium">{{ $t('Account Created') }}</span>
</div>
<div
class="grid h-4 w-4 place-items-center rounded-full bg-green-500/90"
@ -32,7 +32,7 @@
<div v-if="!$team.pg.payment_mode">
<div class="flex items-center space-x-2">
<TextInsideCircle>2</TextInsideCircle>
<span class="text-base font-medium"> 余额充值 </span>
<span class="text-base font-medium">{{ $t('Account Recharge') }}</span>
</div>
<div class="mt-4 pl-7">
@ -55,13 +55,13 @@
class="text-base font-medium"
v-if="$team.pg.payment_mode === 'Card'"
>
自动扣费设置已完成
{{ $t('Automated Billing Setup Completed') }}
</span>
<span
class="text-base font-medium"
v-if="$team.pg.payment_mode === 'Prepaid Credits'"
>
余额支付已开通
{{ $t('Prepaid Credits Payment Enabled') }}
</span>
</div>
<div
@ -74,7 +74,7 @@
class="mt-1.5 pl-7 text-p-base text-gray-800"
v-if="$team.pg.payment_mode === 'Prepaid Credits'"
>
账户余额: {{ $format.userCurrency($team.pg.balance) }}
{{ $t('Account Balance') }}: {{ $format.userCurrency($team.pg.balance) }}
</div>
</div>
</div>
@ -88,22 +88,22 @@
class="text-base font-medium"
v-if="pendingSiteRequest.status == 'Error'"
>
创建您的 {{ pendingSiteRequest.title }} 试用站点时出错
{{ $t('Error creating your {title} trial site', { title: pendingSiteRequest.title }) }}
</span>
<span class="text-base font-medium" v-else>
创建您的 {{ pendingSiteRequest.title }} 试用站点
{{ $t('Create your {title} trial site', { title: pendingSiteRequest.title }) }}
</span>
</div>
</div>
<div class="mt-2 pl-7" v-if="pendingSiteRequest.status == 'Error'">
<p class="mt-2 text-p-base text-gray-800">
请点击下方按钮联系 今果 Jingrow支持团队
{{ $t('Please click the button below to contact Jingrow support team.') }}
</p>
<Button class="mt-2" link="/support"> 联系支持 </Button>
<Button class="mt-2" link="/support">{{ $t('Contact Support') }}</Button>
</div>
<div class="mt-2 pl-7" v-else>
<p class="mt-2 text-p-base text-gray-800">
您可以点击下方按钮免费试用 {{ pendingSiteRequest.title }} 应用程序
{{ $t('You can click the button below to try the {title} application for free.', { title: pendingSiteRequest.title }) }}
</p>
<Button
class="mt-2"
@ -115,7 +115,7 @@
},
}"
>
继续
{{ $t('Continue') }}
</Button>
</div>
</div>
@ -124,7 +124,7 @@
<div class="flex items-center space-x-2">
<TextInsideCircle>3</TextInsideCircle>
<span class="text-base font-medium">
您的试用站点已准备就绪
{{ $t('Your trial site is ready') }}
</span>
</div>
<div
@ -145,21 +145,21 @@
</a>
</div>
<p class="mt-2 text-p-base text-gray-800">
您的试用将于
{{ $t('Your trial will expire on') }}
<span class="font-medium">
{{ $format.date(trialSite.trial_end_date, 'LL') }} </span
> 到期立即设置账单以确保您的站点访问不受影响
>. {{ $t('Set up billing now to ensure your site access is not affected.') }}
</p>
</div>
</div>
<div v-else class="rounded-md">
<div class="flex items-center space-x-2">
<TextInsideCircle>3</TextInsideCircle>
<div class="text-base font-medium">创建您的第一个数字化平台</div>
<div class="text-base font-medium">{{ $t('Create your first digital platform') }}</div>
</div>
<Button class="ml-7 mt-4" :route="'/sites/new'">
创建
{{ $t('Create') }}
</Button>
</div>
</div>

View File

@ -195,7 +195,7 @@ export default {
const options = {
key: data.key_id,
order_id: data.order_id,
name: '今果 Jingrow',
name: 'Jingrow',
image: '/assets/jcloud/images/jingrow-cloud-logo.png',
prefill: {
email: this.$account.team.user

View File

@ -10,7 +10,7 @@
<JLogo class="h-8 w-8 rounded" />
<div class="ml-2 flex flex-col">
<div class="text-base font-medium leading-none text-gray-900">
今果 Jingrow
Jingrow
</div>
<div
v-if="$account.user"
@ -37,7 +37,7 @@
<span class="mr-1.5">
<FeatherIcon name="search" class="h-5 w-5 text-gray-700" />
</span>
<span class="text-sm">Search</span>
<span class="text-sm">{{ $t('Search') }}</span>
<span class="ml-auto text-sm text-gray-500">
<template v-if="$platform === 'mac'">K</template>
<template v-else>Ctrl+K</template>
@ -58,7 +58,7 @@
<span class="mr-1.5">
<FeatherIcon name="inbox" class="h-4.5 w-4.5 text-gray-700" />
</span>
<span class="text-sm">Notifications </span>
<span class="text-sm">{{ $t('Notifications') }} </span>
<span
v-if="unreadNotificationsCount > 0"
class="ml-auto rounded bg-gray-400 px-1.5 py-0.5 text-xs text-white"
@ -124,26 +124,29 @@ export default {
data() {
return {
showCommandPalette: false,
showTeamSwitcher: false,
dropdownItems: [
showTeamSwitcher: false
};
},
computed: {
dropdownItems() {
return [
{
label: 'Switch Team',
label: this.$t('Switch Team'),
icon: 'command',
onClick: () => (this.showTeamSwitcher = true)
},
{
label: 'Support & Docs',
label: this.$t('Support & Docs'),
icon: 'help-circle',
onClick: () => (window.location.href = '/support')
},
{
label: 'Logout',
label: this.$t('Logout'),
icon: 'log-out',
onClick: () => this.$auth.logout()
}
]
};
},
];
},
mounted() {
window.addEventListener('keydown', e => {
if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {

View File

@ -1,10 +1,10 @@
<template>
<Dialog
:options="{
title: '恢复',
title: $t('Restore'),
actions: [
{
label: '恢复',
label: $t('Restore'),
variant: 'solid',
theme: 'red',
loading: $resources.restoreBackup.loading,
@ -19,19 +19,19 @@
>
<template v-slot:body-content>
<div class="space-y-4">
<p class="text-base">使用之前的备份恢复您的数据库</p>
<p class="text-base">{{ $t('Restore your database using a previous backup.') }}</p>
<div
class="flex items-center rounded border border-gray-200 bg-gray-100 p-4 text-sm text-gray-600"
>
<i-lucide-alert-triangle class="mr-4 inline-block h-6 w-6" />
<div>
此操作将用备份中的<b>数据</b><b>应用</b>替换您站点中的所有内容
{{ $t('This operation will replace all content in your site with the <b>data</b> and <b>apps</b> from the backup.') }}
</div>
</div>
<BackupFilesUploader v-model:backupFiles="selectedFiles" />
</div>
<div class="mt-3">
<!-- 跳过失败复选框 -->
<!-- Skip failing patches checkbox -->
<input
id="skip-failing"
type="checkbox"
@ -39,7 +39,7 @@
v-model="skipFailingPatches"
/>
<label for="skip-failing" class="ml-2 text-sm text-gray-900">
跳过失败的补丁如果有任何补丁失败
{{ $t('Skip failing patches (if any patch fails)') }}
</label>
</div>
<ErrorMessage class="mt-2" :message="$resources.restoreBackup.error" />
@ -81,7 +81,7 @@ export default {
validate() {
if (!this.filesUploaded) {
throw new DashboardError(
'请上传数据库、公共和私有文件以进行恢复。'
this.$t('Please upload database, public and private files to restore.')
);
}
},

View File

@ -10,7 +10,7 @@
<div v-show="!tryingMicroCharge">
<label class="block">
<span class="block text-xs text-gray-600">
Credit or Debit Card
信用卡或借记卡
</span>
<div
class="form-input mt-2 block h-[unset] w-full py-2 pl-3"
@ -20,7 +20,7 @@
</label>
<FormControl
class="mt-4"
label="Name on Card"
label="持卡人姓名"
type="text"
v-model="billingInformation.cardHolderName"
/>
@ -32,23 +32,41 @@
/>
</div>
<div class="mt-3" v-show="tryingMicroCharge">
<p class="text-lg text-gray-800">
We are attempting to charge your card with
<strong>{{ formattedMicroChargeAmount }}</strong> to make sure the
card works. This amount will be <strong>refunded</strong> back to your
account.
<div class="mt-3 space-y-4" v-show="tryingMicroCharge">
<p class="text-base text-gray-700">
我们正在尝试向您的卡收取
<strong>{{ formattedMicroChargeAmount }}</strong> 以确保该卡有效此金额将<strong>退还</strong>到您的账户
</p>
<Button class="mt-2" :loading="true">Attempting Test Charge</Button>
<Button
:loading="!microChargeCompleted"
:loadingText="'正在验证卡片'"
>
卡片已验证
<template #prefix>
<GreenCheckIcon class="h-4 w-4" />
</template>
</Button>
</div>
<ErrorMessage class="mt-2" :message="errorMessage" />
<div class="mt-6 flex items-center justify-between">
<StripeLogo />
<Button variant="solid" @click="submit" :loading="addingCard">
Save Card
<Button
@click="clearForm"
v-if="showAddAnotherCardButton"
iconLeft="plus"
>
添加另一张卡
</Button>
<Button
v-else-if="!tryingMicroCharge"
variant="solid"
@click="submit"
:loading="addingCard"
>
验证并保存卡片
</Button>
</div>
</div>
@ -56,9 +74,10 @@
</template>
<script>
import AddressForm from '../../src2/components/AddressForm.vue';
import AddressForm from './AddressForm.vue';
import StripeLogo from '@/components/StripeLogo.vue';
import { loadStripe } from '@stripe/stripe-js';
import { toast } from 'vue-sonner';
export default {
name: 'StripeCard',
@ -81,17 +100,59 @@ export default {
},
gstNotApplicable: false,
addingCard: false,
tryingMicroCharge: false
tryingMicroCharge: false,
showAddAnotherCardButton: false,
microChargeCompleted: false
};
},
async mounted() {
this.setupCard();
let { first_name, last_name = '' } = this.$account.user;
let fullname = first_name + ' ' + last_name;
this.billingInformation.cardHolderName = fullname.trimEnd();
await this.setupStripeIntent();
},
resources: {
setupIntent() {
return {
url: 'jcloud.api.billing.get_publishable_key_and_setup_intent',
async onSuccess(data) {
//window.posthog.capture('init_client_add_card', 'fc_signup');
let { publishable_key, setup_intent } = data;
this.setupIntent = setup_intent;
this.stripe = await loadStripe(publishable_key);
this.elements = this.stripe.elements();
let theme = this.$theme;
let style = {
base: {
color: theme.colors.black,
fontFamily: theme.fontFamily.sans.join(', '),
fontSmoothing: 'antialiased',
fontSize: '13px',
'::placeholder': {
color: theme.colors.gray['400']
}
},
invalid: {
color: theme.colors.red['600'],
iconColor: theme.colors.red['600']
}
};
this.card = this.elements.create('card', {
hidePostalCode: true,
style: style,
classes: {
complete: '',
focus: 'bg-gray-100'
}
});
this.card.mount(this.$refs['card-element']);
this.card.addEventListener('change', event => {
this.cardErrorMessage = event.error?.message || null;
});
this.card.addEventListener('ready', () => {
this.ready = true;
});
}
};
},
countryList: 'jcloud.api.account.country_list',
billingAddress() {
return {
@ -108,50 +169,36 @@ export default {
this.billingInformation.postal_code = data?.pincode;
}
};
},
setupIntentSuccess() {
return {
url: 'jcloud.api.billing.setup_intent_success',
makeParams({ setupIntent }) {
return {
setup_intent: setupIntent,
address: this.withoutAddress ? null : this.billingInformation
};
}
};
},
verifyCardWithMicroCharge() {
return {
url: 'jcloud.api.billing.create_payment_intent_for_micro_debit',
makeParams({ paymentMethodName }) {
return {
payment_method_name: paymentMethodName
};
}
};
}
},
methods: {
async setupCard() {
let result = await this.$call(
'jcloud.api.billing.get_publishable_key_and_setup_intent'
);
//window.posthog.capture('init_client_add_card', 'fc_signup');
let { publishable_key, setup_intent } = result;
this.setupIntent = setup_intent;
this.stripe = await loadStripe(publishable_key);
this.elements = this.stripe.elements();
let theme = this.$theme;
let style = {
base: {
color: theme.colors.black,
fontFamily: theme.fontFamily.sans.join(', '),
fontSmoothing: 'antialiased',
fontSize: '13px',
'::placeholder': {
color: theme.colors.gray['400']
}
},
invalid: {
color: theme.colors.red['600'],
iconColor: theme.colors.red['600']
}
};
this.card = this.elements.create('card', {
hidePostalCode: true,
style: style,
classes: {
complete: '',
focus: 'bg-gray-100'
}
});
this.card.mount(this.$refs['card-element']);
async setupStripeIntent() {
await this.$resources.setupIntent.submit();
this.card.addEventListener('change', event => {
this.cardErrorMessage = event.error?.message || null;
});
this.card.addEventListener('ready', () => {
this.ready = true;
});
let { first_name, last_name = '' } = this.$team.pg.user_info;
let fullname = first_name + ' ' + last_name;
this.billingInformation.cardHolderName = fullname.trimEnd();
},
async submit() {
this.addingCard = true;
@ -189,86 +236,104 @@ export default {
if (error) {
this.addingCard = false;
let declineCode = error.decline_code;
let errorMessage = error.message;
// fix for duplicate error message
if (errorMessage != 'Your card number is incomplete.') {
if (declineCode === 'do_not_honor') {
this.errorMessage =
"您的卡被拒绝了。可能是由于余额不足或您可能已超过每日限额。请尝试使用另一张卡或联系您的银行。";
this.showAddAnotherCardButton = true;
} else if (declineCode === 'transaction_not_allowed') {
this.errorMessage =
'您的卡被拒绝了。可能是由于您的卡的限制,如国际交易或在线支付。请尝试使用另一张卡或联系您的银行。';
this.showAddAnotherCardButton = true;
}
//
else if (errorMessage != '您的卡号不完整。') {
this.errorMessage = errorMessage;
}
} else {
if (setupIntent.status === 'succeeded') {
try {
const { payment_method_name } = await this.$call(
'jcloud.api.billing.setup_intent_success',
{
setup_intent: setupIntent,
address: this.withoutAddress ? null : this.billingInformation
if (setupIntent?.status === 'succeeded') {
this.$resources.setupIntentSuccess.submit(
{
setupIntent
},
{
onSuccess: async ({ payment_method_name }) => {
await this.verifyWithMicroChargeIfApplicable(
payment_method_name
);
this.addingCard = false;
toast.success('卡片添加成功');
},
onError: error => {
console.error(error);
this.addingCard = false;
this.errorMessage = error.messages.join('\n');
toast.error(this.errorMessage);
}
);
//window.posthog.capture('completed_client_add_card', 'fc_signup');
await this.verifyWithMicroChargeIfApplicable(payment_method_name);
this.addingCard = false;
} catch (error) {
console.error(error);
this.addingCard = false;
this.errorMessage = error.messages.join('\n');
}
}
);
}
}
},
async verifyWithMicroChargeIfApplicable(paymentMethodName) {
const teamCurrency = this.$account.team.currency;
const verifyCardsWithMicroCharge =
this.$account.feature_flags.verify_cards_with_micro_charge;
const teamCurrency = this.$team.pg.currency;
const verifyCardsWithMicroCharge = window.verify_cards_with_micro_charge;
const isMicroChargeApplicable =
verifyCardsWithMicroCharge === 'Both CNY and USD' ||
(verifyCardsWithMicroCharge == 'Only CNY' && teamCurrency === 'CNY') ||
(verifyCardsWithMicroCharge === 'Only USD' && teamCurrency === 'USD');
if (isMicroChargeApplicable) {
if (isMicroChargeApplicable) {
await this._verifyWithMicroCharge(paymentMethodName);
} else {
this.$emit('complete');
}
},
async _verifyWithMicroCharge(paymentMethodName) {
_verifyWithMicroCharge(paymentMethodName) {
this.tryingMicroCharge = true;
const paymentIntent = await this.$call(
'jcloud.api.billing.create_payment_intent_for_micro_debit',
return this.$resources.verifyCardWithMicroCharge.submit(
{ paymentMethodName },
{
payment_method_name: paymentMethodName
onSuccess: async paymentIntent => {
let { client_secret } = paymentIntent;
let payload = await this.stripe.confirmCardPayment(client_secret, {
payment_method: { card: this.card }
});
if (payload.paymentIntent?.status === 'succeeded') {
this.microChargeCompleted = true;
this.$emit('complete');
}
},
onError: error => {
console.error(error);
this.tryingMicroCharge = false;
this.errorMessage = error.messages.join('\n');
}
}
);
let { client_secret: clientSecret } = paymentIntent;
let payload = await this.stripe.confirmCardPayment(clientSecret, {
payment_method: {
card: this.card
}
});
if (payload.paymentIntent.status === 'succeeded') {
this.$emit('complete');
}
this.tryingMicroCharge = false;
},
getCountryCode(country) {
let code = this.$resources.countryList.data.find(
d => d.name === country
).code;
return code.toUpperCase();
},
async clearForm() {
this.ready = false;
this.errorMessage = null;
this.showAddAnotherCardButton = false;
this.card = null;
this.setupStripeIntent();
}
},
computed: {
formattedMicroChargeAmount() {
const isCNY = this.$account.team.currency === 'CNY';
return isCNY ? '¥100' : '$1';
return this.$format.userCurrency(
this.$team.pg.billing_info.micro_debit_charge_amount
);
},
browserTimezone() {
if (!window.Intl) {
@ -278,4 +343,4 @@ export default {
}
}
};
</script>
</script>

Some files were not shown because too many files have changed in this diff Show More