2025-12-30 00:01:51 +08:00

937 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div v-if="user" class="profile-container">
<n-space vertical :size="24">
<!-- 个人资料卡片 -->
<n-card :title="$t('Profile')" class="profile-card">
<n-space vertical :size="20">
<div class="profile-header">
<div class="profile-avatar-wrapper">
<n-avatar
:size="avatarSize"
:src="user.user_image"
round
class="profile-avatar"
>
{{ user.first_name?.[0]?.toUpperCase() || 'A' }}
</n-avatar>
<FileUploader
@success="onProfilePhotoChange"
fileTypes="image/*"
:upload-args="{
pagetype: 'User',
docname: user.name,
method: 'jcloud.api.account.update_profile_picture',
}"
>
<template v-slot="{ openFileSelector, uploading, progress, error }">
<n-button
tertiary
circle
size="small"
class="avatar-edit-btn"
:loading="uploading"
@click="openFileSelector()"
>
<template #icon>
<n-icon>
<EditIcon />
</n-icon>
</template>
</n-button>
</template>
</FileUploader>
</div>
<div class="profile-info">
<h2 class="profile-name">
{{ user.first_name }} {{ user.last_name }}
</h2>
<div class="profile-details">
<div class="profile-detail-item">
<span class="profile-detail-label">{{ $t('Username') }}:</span>
<span class="profile-detail-value">{{ user.username }}</span>
</div>
<div class="profile-detail-item">
<span class="profile-detail-label">{{ $t('Phone') }}:</span>
<span class="profile-detail-value">{{ user.mobile_no || '-' }}</span>
</div>
<div class="profile-detail-item">
<span class="profile-detail-label">{{ $t('Email') }}:</span>
<span class="profile-detail-value">{{ user.email }}</span>
</div>
</div>
</div>
<div class="profile-actions">
<n-button type="primary" @click="showProfileEditDialog = true" :block="isMobile">
<template #icon>
<n-icon><EditIcon /></n-icon>
</template>
{{ $t('Edit') }}
</n-button>
</div>
</div>
</n-space>
</n-card>
<!-- 功能设置卡片 -->
<n-card class="settings-card">
<n-list>
<n-list-item v-if="!$team.pg.is_developer">
<n-thing>
<template #header>
<span class="text-base font-medium">{{ $t('Marketplace Developer') }}</span>
</template>
<template #description>
<span class="text-sm text-gray-600">
{{ $t('Developers can publish their apps on the marketplace for users to subscribe to, either paid or free.') }}
</span>
</template>
<template #action>
<n-button type="primary" @click="confirmPublisherAccount">
{{ $t('Become a Developer') }}
</n-button>
</template>
</n-thing>
</n-list-item>
<n-list-item>
<n-thing>
<template #header>
<span class="text-base font-medium">{{ twoFactorAuthTitle }}</span>
</template>
<template #description>
<span class="text-sm text-gray-600">{{ twoFactorAuthSubtitle }}</span>
</template>
<template #action>
<n-button @click="show2FADialog = true">
{{ twoFactorAuthButtonLabel }}
</n-button>
</template>
</n-thing>
</n-list-item>
<n-list-item>
<n-thing>
<template #header>
<span class="text-base font-medium">{{ $t('Reset Password') }}</span>
</template>
<template #description>
<span class="text-sm text-gray-600">{{ $t('Change your account login password') }}</span>
</template>
<template #action>
<n-button @click="showResetPasswordDialog = true">
{{ $t('Reset Password') }}
</n-button>
</template>
</n-thing>
</n-list-item>
<n-list-item>
<n-thing>
<template #header>
<span class="text-base font-medium">{{ accountStatusTitle }}</span>
</template>
<template #description>
<span class="text-sm text-gray-600">{{ accountStatusSubtitle }}</span>
</template>
<template #action>
<n-button
:type="teamEnabled ? 'error' : 'primary'"
@click="
() => {
if (teamEnabled) {
showDisableAccountDialog = true;
} else {
showEnableAccountDialog = true;
}
}
"
>
{{ accountStatusButtonLabel }}
</n-button>
</template>
</n-thing>
</n-list-item>
</n-list>
</n-card>
<!-- 推荐有礼组件 -->
<AccountReferral />
<!-- Jingrow 合作伙伴组件 -->
<AccountPartner />
</n-space>
<!-- 编辑资料对话框 -->
<n-modal
v-model:show="showProfileEditDialog"
preset="card"
:title="$t('Update Profile Information')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="profile-edit-modal"
>
<template #header>
<span class="text-lg font-semibold">{{ $t('Update Profile Information') }}</span>
</template>
<n-form :model="user" label-placement="top" class="mt-4">
<n-space vertical :size="20">
<n-form-item :label="$t('First Name')">
<n-input
v-model:value="user.first_name"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-form-item :label="$t('Username')">
<n-input
v-model:value="user.username"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-form-item :label="$t('Phone')">
<n-input
v-model:value="user.mobile_no"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-form-item :label="$t('Email')">
<n-input
v-model:value="user.email"
type="email"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-alert
v-if="$resources.updateProfile.error"
type="error"
:title="$resources.updateProfile.error"
/>
</n-space>
</n-form>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button
@click="showProfileEditDialog = false"
:block="isMobile"
:size="buttonSize"
>
{{ $t('Cancel') }}
</n-button>
<n-button
type="primary"
@click="handleSaveProfile"
:loading="$resources.updateProfile.loading"
:block="isMobile"
:size="buttonSize"
>
{{ $t('Save Changes') }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- 禁用账户对话框 -->
<n-modal
v-model:show="showDisableAccountDialog"
preset="dialog"
:title="$t('Disable Account')"
:positive-text="$t('Disable Account')"
:positive-button-props="{ type: 'error' }"
:loading="$resources.disableAccount.loading"
:mask-closable="true"
:close-on-esc="true"
@positive-click="() => deactivateAccount(disableAccount2FACode)"
>
<div class="py-4">
<p class="text-base mb-4">{{ $t('After confirming this action:') }}</p>
<ul class="list-disc list-inside space-y-2 text-sm text-gray-700 mb-4">
<li>{{ $t('Your account will be disabled') }}</li>
<li>{{ $t('Your activated sites will be suspended immediately and deleted after one week.') }}</li>
<li>{{ $t('Your account billing will stop') }}</li>
</ul>
<p class="text-base mb-4">{{ $t('You can log in later to re-enable your account. Do you want to continue?') }}</p>
<n-form-item
v-if="user.is_2fa_enabled"
:label="$t('Enter your 2FA code to confirm')"
class="mt-4"
>
<n-input v-model:value="disableAccount2FACode" />
</n-form-item>
<n-alert
v-if="$resources.disableAccount.error"
type="error"
class="mt-4"
:title="$resources.disableAccount.error"
/>
</div>
</n-modal>
<!-- 启用账户对话框 -->
<n-modal
v-model:show="showEnableAccountDialog"
preset="dialog"
:title="$t('Enable Account')"
:positive-text="$t('Enable Account')"
:loading="$resources.enableAccount.loading"
:mask-closable="true"
:close-on-esc="true"
@positive-click="() => $resources.enableAccount.submit()"
>
<div class="py-4">
<p class="text-base mb-4">{{ $t('After confirming this action:') }}</p>
<ul class="list-disc list-inside space-y-2 text-sm text-gray-700 mb-4">
<li>{{ $t('Your account will be enabled') }}</li>
<li>{{ $t('Your suspended sites will be reactivated') }}</li>
<li>{{ $t('Your account billing will resume') }}</li>
</ul>
<p class="text-base">{{ $t('Do you want to continue?') }}</p>
<n-alert
v-if="$resources.enableAccount.error"
type="error"
class="mt-4"
:title="$resources.enableAccount.error"
/>
</div>
</n-modal>
<AddPrepaidCreditsDialog
:showMessage="showMessage"
v-if="showAddPrepaidCreditsDialog"
v-model="showAddPrepaidCreditsDialog"
@success="reloadAccount"
/>
<ResetPasswordDialog
v-if="showResetPasswordDialog"
v-model="showResetPasswordDialog"
/>
<TFADialog v-model="show2FADialog" />
</div>
</template>
<script>
import { toast } from 'vue-sonner';
import { defineAsyncComponent, h } from 'vue';
import {
NCard,
NSpace,
NButton,
NAvatar,
NList,
NListItem,
NThing,
NModal,
NForm,
NFormItem,
NFormItemGi,
NInput,
NGrid,
NAlert,
NIcon,
} from 'naive-ui';
import FileUploader from '@/components/FileUploader.vue';
import { confirmDialog, renderDialog } from '../../../utils/components';
import TFADialog from './TFADialog.vue';
import ResetPasswordDialog from './ResetPasswordDialog.vue';
import router from '../../../router';
import AddPrepaidCreditsDialog from '../../billing/AddPrepaidCreditsDialog.vue';
import AccountReferral from './AccountReferral.vue';
import AccountPartner from './AccountPartner.vue';
import EditIcon from '~icons/lucide/edit';
export default {
name: 'AccountProfile',
components: {
NCard,
NSpace,
NButton,
NAvatar,
NList,
NListItem,
NThing,
NModal,
NForm,
NFormItem,
NFormItemGi,
NInput,
NGrid,
NAlert,
NIcon,
TFADialog,
ResetPasswordDialog,
FileUploader,
AddPrepaidCreditsDialog,
AccountReferral,
AccountPartner,
EditIcon,
},
data() {
return {
show2FADialog: false,
showResetPasswordDialog: false,
disableAccount2FACode: '',
showProfileEditDialog: false,
showEnableAccountDialog: false,
showDisableAccountDialog: false,
showAddPrepaidCreditsDialog: false,
showActiveServersDialog: false,
showMessage: false,
draftInvoice: {},
unpaidInvoices: [] | {},
windowWidth: window.innerWidth,
};
},
computed: {
teamEnabled() {
return this.$team.pg.enabled;
},
user() {
return this.$team?.pg?.user_info;
},
isMobile() {
return this.windowWidth <= 768;
},
avatarSize() {
return this.isMobile ? 80 : 96;
},
modalStyle() {
return {
width: this.isMobile ? '95vw' : '800px',
maxWidth: this.isMobile ? '95vw' : '90vw',
};
},
inputSize() {
return this.isMobile ? 'medium' : 'large';
},
buttonSize() {
return this.isMobile ? 'medium' : 'medium';
},
twoFactorAuthTitle() {
return this.user.is_2fa_enabled
? this.$t('Disable Two-Factor Authentication')
: this.$t('Enable Two-Factor Authentication');
},
twoFactorAuthSubtitle() {
return this.user.is_2fa_enabled
? this.$t('Disable two-factor authentication for your account')
: this.$t('Enable two-factor authentication for your account to add an extra layer of security');
},
twoFactorAuthButtonLabel() {
return this.user.is_2fa_enabled ? this.$t('Disable') : this.$t('Enable');
},
accountStatusTitle() {
return this.teamEnabled ? this.$t('Disable Account') : this.$t('Enable Account');
},
accountStatusSubtitle() {
return this.teamEnabled
? this.$t('Disable your account and stop billing')
: this.$t('Enable your account and resume billing');
},
accountStatusButtonLabel() {
return this.teamEnabled ? this.$t('Disable') : this.$t('Enable');
},
},
resources: {
updateProfile() {
let { first_name, last_name, email, username, mobile_no } = this.user;
return {
url: 'jcloud.api.account.update_profile',
params: {
first_name,
last_name,
email,
username,
mobile_no,
},
onSuccess() {
this.showProfileEditDialog = false;
this.notifySuccess();
},
onError() {
// Error handling
},
};
},
disableAccount: {
url: 'jcloud.api.account.disable_account',
onSuccess() {
this.showDisableAccountDialog = false;
const ChurnFeedbackDialog = defineAsyncComponent(
() => import('../../ChurnFeedbackDialog.vue'),
);
renderDialog(
h(ChurnFeedbackDialog, {
team: this.$team.pg.name,
onUpdated: () => {
toast.success(this.$t('Your feedback has been submitted successfully'));
},
}),
);
toast.success(this.$t('Your account has been disabled successfully'));
this.reloadAccount();
},
},
enableAccount: {
url: 'jcloud.api.account.enable_account',
onSuccess() {
toast.success(this.$t('Your account has been enabled successfully'));
this.reloadAccount();
this.showEnableAccountDialog = false;
},
},
upcomingInvoice: {
url: 'jcloud.api.billing.upcoming_invoice',
auto: true,
onSuccess(data) {
this.draftInvoice = data.upcoming_invoice;
},
},
unPaidInvoices: {
url: 'jcloud.api.billing.get_unpaid_invoices',
auto: true,
onSuccess(data) {
this.unpaidInvoices = data;
},
},
hasActiveServers() {
return {
url: 'jcloud.api.account.has_active_servers',
auto: true,
params: {
team: this.$team.pg.name,
},
onSuccess(data) {
if (data) {
this.showActiveServersDialog = true;
}
},
};
},
},
mounted() {
this.handleResize();
window.addEventListener('resize', this.handleResize);
},
beforeUnmount() {
window.removeEventListener('resize', this.handleResize);
},
methods: {
handleResize() {
this.windowWidth = window.innerWidth;
},
handleSaveProfile() {
// 提交资源onSuccess 中会关闭对话框
this.$resources.updateProfile.submit();
},
reloadAccount() {
this.$team.reload();
},
onProfilePhotoChange() {
this.reloadAccount();
this.notifySuccess();
},
notifySuccess() {
toast.success(this.$t('Your profile has been updated successfully'));
},
deactivateAccount(disableAccount2FACode) {
const currency = this.$team.pg.currency;
const minAmount = currency === 'CNY' ? 0.01 : 0.01;
if (this.draftInvoice && this.draftInvoice.amount_due > minAmount) {
const finalizeInvoicesDialog = defineAsyncComponent(
() => import('../../billing/FinalizeInvoicesDialog.vue'),
);
renderDialog(h(finalizeInvoicesDialog));
} else if (this.unpaidInvoices) {
if (this.unpaidInvoices.length > 1) {
this.showDisableAccountDialog = false;
if (this.$team.pg.payment_mode === 'Prepaid Credits') {
this.showAddPrepaidCreditsDialog = true;
} else {
confirmDialog({
title: this.$t('Multiple Unpaid Invoices'),
message: this.$t('You have multiple unpaid invoices. Please pay them from the invoices page.'),
primaryAction: {
label: this.$t('Go to Invoices'),
variant: 'solid',
onClick: ({ hide }) => {
router.push({ name: 'BillingInvoices' });
hide();
},
},
});
}
} else {
let invoice = this.unpaidInvoices;
if (invoice.amount_due > minAmount) {
this.showDisableAccountDialog = false;
confirmDialog({
title: this.$t('Clear Unpaid Invoice'),
message: this.$t('You have an unpaid invoice of {amount}. Please settle it before deactivating your account.', {
amount: `${invoice.currency === 'CNY' ? '¥' : '$'} ${invoice.amount_due}`
}),
primaryAction: {
label: this.$t('Settle Now'),
variant: 'solid',
onClick: ({ hide }) => {
if (
invoice.stripe_invoice_url &&
this.$team.pg.payment_mode === 'Card'
) {
window.open(
`/api/action/jcloud.api.client.run_pg_method?dt=Invoice&dn=${invoice.name}&method=stripe_payment_url`,
);
} else {
this.showAddPrepaidCreditsDialog = true;
}
hide();
},
},
});
}
}
}
// validate if any active servers
if (this.showActiveServersDialog) {
const activeServersDialog = defineAsyncComponent(
() => import('../../ActiveServersDialog.vue'),
);
renderDialog(h(activeServersDialog));
return;
}
this.$resources.disableAccount.submit({
totp_code: disableAccount2FACode,
});
},
confirmPublisherAccount() {
confirmDialog({
title: this.$t('Become a Marketplace Developer?'),
message: this.$t('After confirmation, you will be able to publish apps to our marketplace.'),
primaryAction: {
label: this.$t('Confirm'),
variant: 'solid'
},
onSuccess: ({ hide }) => {
toast.promise(
this.$team.setValue.submit(
{
is_developer: 1,
},
{
onSuccess: () => {
hide();
this.$router.push({
name: 'Marketplace App List',
});
},
onError(e) {
console.error(e);
},
},
),
{
success: this.$t('You can now publish apps to our marketplace'),
error: this.$t('Failed to mark you as a developer'),
loading: this.$t('Setting you as a developer...'),
},
);
},
});
},
},
};
</script>
<style scoped>
.profile-container {
width: 100%;
}
.profile-card {
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
transition: box-shadow 0.2s ease;
}
.profile-card:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.settings-card {
border-radius: 12px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
transition: box-shadow 0.2s ease;
}
.settings-card:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* 个人资料头部布局 */
.profile-header {
display: flex;
align-items: flex-start;
gap: 24px;
width: 100%;
}
.profile-avatar-wrapper {
position: relative;
flex-shrink: 0;
}
.profile-avatar {
border: 3px solid #f3f4f6;
transition: transform 0.2s ease;
}
.profile-avatar:hover {
transform: scale(1.05);
}
.avatar-edit-btn {
position: absolute;
bottom: -4px;
right: -4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.profile-info {
flex: 1;
min-width: 0;
}
.profile-name {
font-size: 24px;
font-weight: 600;
color: #111827;
margin: 0 0 16px 0;
line-height: 1.2;
word-break: break-word;
}
.profile-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.profile-detail-item {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.profile-detail-label {
font-size: 14px;
color: #6b7280;
flex-shrink: 0;
min-width: 80px;
}
.profile-detail-value {
font-size: 14px;
color: #111827;
word-break: break-word;
flex: 1;
min-width: 0;
}
.profile-actions {
flex-shrink: 0;
}
/* 弹窗样式 */
:deep(.profile-edit-modal .n-card) {
width: 800px;
max-width: 90vw;
margin: 0 auto;
}
:deep(.profile-edit-modal .n-card-body) {
padding: 24px;
}
:deep(.profile-edit-modal .n-form-item) {
margin-bottom: 0;
}
:deep(.profile-edit-modal .n-input) {
width: 100%;
}
:deep(.profile-edit-modal .n-form-item-label) {
font-weight: 500;
margin-bottom: 8px;
}
:deep(.profile-edit-modal .n-card__action) {
padding: 16px 24px;
}
/* 响应式设计 */
@media (max-width: 768px) {
.profile-card,
.settings-card {
border-radius: 8px;
}
/* 移动端个人资料头部布局 */
.profile-header {
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
}
.profile-avatar-wrapper {
display: flex;
justify-content: center;
}
.profile-info {
width: 100%;
text-align: center;
}
.profile-name {
font-size: 20px;
margin-bottom: 16px;
}
.profile-details {
align-items: flex-start;
text-align: left;
gap: 10px;
}
.profile-detail-item {
width: 100%;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 8px 0;
border-bottom: 1px solid #f3f4f6;
}
.profile-detail-item:last-child {
border-bottom: none;
}
.profile-detail-label {
font-size: 12px;
color: #9ca3af;
min-width: auto;
width: 100%;
}
.profile-detail-value {
font-size: 14px;
width: 100%;
word-break: break-all;
}
.profile-actions {
width: 100%;
margin-top: 8px;
}
.profile-actions .n-button {
width: 100%;
}
/* 移动端弹窗优化 */
:deep(.profile-edit-modal .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.profile-edit-modal .n-card-body) {
padding: 20px 16px;
}
:deep(.profile-edit-modal .n-card__header) {
padding: 16px;
font-size: 18px;
}
:deep(.profile-edit-modal .n-card__action) {
padding: 12px 16px;
}
:deep(.profile-edit-modal .n-space) {
width: 100%;
}
:deep(.profile-edit-modal .n-button) {
width: 100%;
font-size: 16px;
height: 44px;
}
:deep(.profile-edit-modal .n-form-item-label) {
font-size: 14px;
margin-bottom: 6px;
}
:deep(.profile-edit-modal .n-input) {
font-size: 16px;
height: 44px;
}
/* 设置列表移动端优化 */
:deep(.settings-card .n-list-item) {
padding: 12px 0;
}
:deep(.settings-card .n-thing) {
flex-direction: column;
gap: 12px;
}
:deep(.settings-card .n-thing-main) {
width: 100%;
}
:deep(.settings-card .n-thing-main__action) {
width: 100%;
margin-top: 8px;
}
:deep(.settings-card .n-button) {
width: 100%;
}
}
/* 超小屏幕优化 */
@media (max-width: 480px) {
:deep(.profile-edit-modal .n-card) {
width: 100vw !important;
max-width: 100vw !important;
margin: 0;
border-radius: 0;
height: 100vh;
display: flex;
flex-direction: column;
}
:deep(.profile-edit-modal .n-card-body) {
flex: 1;
overflow-y: auto;
padding: 16px;
}
:deep(.profile-edit-modal .n-card__action) {
padding: 12px 16px;
border-top: 1px solid var(--n-border-color);
}
}
</style>