基于naive ui重构AccountProfile.vue

This commit is contained in:
jingrow 2025-12-29 23:45:39 +08:00
parent 45359cd57f
commit 980f52ee3b

View File

@ -1,188 +1,295 @@
<template>
<Card :title="$t('Profile')" v-if="user" class="mx-auto max-w-3xl">
<div class="flex items-center border-b pb-3">
<div class="relative">
<Avatar size="2xl" :label="user.first_name" :image="user.user_image" />
<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 }">
<div class="ml-4">
<button
@click="openFileSelector()"
class="absolute inset-0 grid h-10 w-full place-items-center rounded-full bg-black text-xs font-medium text-white opacity-0 transition hover:opacity-50 focus:opacity-50 focus:outline-none"
:class="{ 'opacity-50': uploading }"
<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"
>
<span v-if="uploading">{{ progress }}%</span>
<span v-else>{{ $t('Edit') }}</span>
</button>
{{ 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>
</template>
</FileUploader>
</div>
<div class="ml-4">
<h3 class="text-base font-semibold">
{{ user.first_name }} {{ user.last_name }}
</h3>
<p class="mt-1 text-base text-gray-600">{{ $t('Username') }}: {{ user.username }}</p>
<p class="mt-1 text-base text-gray-600">{{ $t('Phone') }}: {{ user.mobile_no }}</p>
<p class="mt-1 text-base text-gray-600">{{ $t('Email') }}: {{ user.email }}</p>
</div>
<div class="ml-auto">
<Button icon-left="edit" @click="showProfileEditDialog = true">
{{ $t('Edit') }}
</Button>
</div>
</div>
<div>
<ListItem
:title="$t('Marketplace Developer')"
:subtitle="$t('Developers can publish their apps on the marketplace for users to subscribe to, either paid or free.')"
v-if="!$team.pg.is_developer"
>
<template #actions>
<Button @click="confirmPublisherAccount">
<span>{{ $t('Become a Developer') }}</span>
</Button>
</template>
</ListItem>
<ListItem
:title="twoFactorAuthTitle"
:subtitle="twoFactorAuthSubtitle"
>
<template #actions>
<Button @click="show2FADialog = true">
{{ twoFactorAuthButtonLabel }}
</Button>
</template>
</ListItem>
<ListItem
:title="$t('Reset Password')"
:subtitle="$t('Change your account login password')"
>
<template #actions>
<Button @click="showResetPasswordDialog = true">
{{ $t('Reset Password') }}
</Button>
</template>
</ListItem>
<ListItem
:title="accountStatusTitle"
:subtitle="accountStatusSubtitle"
>
<template #actions>
<Button
@click="
() => {
if (teamEnabled) {
showDisableAccountDialog = true;
} else {
showEnableAccountDialog = true;
}
}
"
<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>
</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"
>
<span :class="{ 'text-red-600': teamEnabled }">{{
accountStatusButtonLabel
}}</span>
</Button>
</template>
</ListItem>
</div>
<Dialog
:options="{
title: $t('Update Profile Information'),
actions: [
{
variant: 'solid',
label: $t('Save Changes'),
onClick: () => $resources.updateProfile.submit(),
},
],
}"
v-model="showProfileEditDialog"
>
<template v-slot:body-content>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormControl :label="$t('First Name')" v-model="user.first_name" />
<FormControl :label="$t('Username')" v-model="user.username" />
<FormControl :label="$t('Phone')" v-model="user.mobile_no" />
<FormControl :label="$t('Email')" v-model="user.email" />
</div>
<ErrorMessage class="mt-4" :message="$resources.updateProfile.error" />
{{ $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>
</Dialog>
</n-modal>
<Dialog
:options="{
title: $t('Disable Account'),
actions: [
{
label: $t('Disable Account'),
variant: 'solid',
theme: 'red',
loading: $resources.disableAccount.loading,
onClick: () => deactivateAccount(disableAccount2FACode),
},
],
}"
v-model="showDisableAccountDialog"
<!-- 禁用账户对话框 -->
<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)"
>
<template v-slot:body-content>
<div class="prose text-base">
{{ $t('After confirming this action:') }}
<ul>
<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>
{{ $t('You can log in later to re-enable your account. Do you want to continue?') }}
</div>
<FormControl
<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"
class="mt-4"
:label="$t('Enter your 2FA code to confirm')"
v-model="disableAccount2FACode"
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"
/>
<ErrorMessage class="mt-2" :message="$resources.disableAccount.error" />
</template>
</Dialog>
</div>
</n-modal>
<Dialog
:options="{
title: $t('Enable Account'),
actions: [
{
label: $t('Enable Account'),
variant: 'solid',
loading: $resources.enableAccount.loading,
onClick: () => $resources.enableAccount.submit(),
},
],
}"
v-model="showEnableAccountDialog"
<!-- 启用账户对话框 -->
<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()"
>
<template v-slot:body-content>
<div class="prose text-base">
{{ $t('After confirming this action:') }}
<ul>
<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>
{{ $t('Do you want to continue?') }}
</div>
<ErrorMessage class="mt-2" :message="$resources.enableAccount.error" />
</template>
</Dialog>
<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"
@ -194,27 +301,61 @@
v-if="showResetPasswordDialog"
v-model="showResetPasswordDialog"
/>
</Card>
<TFADialog v-model="show2FADialog" />
<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 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,
EditIcon,
},
data() {
return {
@ -229,6 +370,7 @@ export default {
showMessage: false,
draftInvoice: {},
unpaidInvoices: [] | {},
windowWidth: window.innerWidth,
};
},
computed: {
@ -238,6 +380,24 @@ export default {
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')
@ -342,7 +502,21 @@ url: 'jcloud.api.billing.get_unpaid_invoices',
};
},
},
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();
},
@ -460,4 +634,293 @@ url: 'jcloud.api.billing.get_unpaid_invoices',
},
},
};
</script>
</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>