基于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> <template>
<Card :title="$t('Profile')" v-if="user" class="mx-auto max-w-3xl"> <div v-if="user" class="profile-container">
<div class="flex items-center border-b pb-3"> <n-space vertical :size="24">
<div class="relative"> <!-- 个人资料卡片 -->
<Avatar size="2xl" :label="user.first_name" :image="user.user_image" /> <n-card :title="$t('Profile')" class="profile-card">
<FileUploader <n-space vertical :size="20">
@success="onProfilePhotoChange" <div class="profile-header">
fileTypes="image/*" <div class="profile-avatar-wrapper">
:upload-args="{ <n-avatar
pagetype: 'User', :size="avatarSize"
docname: user.name, :src="user.user_image"
method: 'jcloud.api.account.update_profile_picture', round
}" class="profile-avatar"
>
<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 }"
> >
<span v-if="uploading">{{ progress }}%</span> {{ user.first_name?.[0]?.toUpperCase() || 'A' }}
<span v-else>{{ $t('Edit') }}</span> </n-avatar>
</button> <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>
</template> <div class="profile-info">
</FileUploader> <h2 class="profile-name">
</div> {{ user.first_name }} {{ user.last_name }}
<div class="ml-4"> </h2>
<h3 class="text-base font-semibold"> <div class="profile-details">
{{ user.first_name }} {{ user.last_name }} <div class="profile-detail-item">
</h3> <span class="profile-detail-label">{{ $t('Username') }}:</span>
<p class="mt-1 text-base text-gray-600">{{ $t('Username') }}: {{ user.username }}</p> <span class="profile-detail-value">{{ user.username }}</span>
<p class="mt-1 text-base text-gray-600">{{ $t('Phone') }}: {{ user.mobile_no }}</p> </div>
<p class="mt-1 text-base text-gray-600">{{ $t('Email') }}: {{ user.email }}</p> <div class="profile-detail-item">
</div> <span class="profile-detail-label">{{ $t('Phone') }}:</span>
<div class="ml-auto"> <span class="profile-detail-value">{{ user.mobile_no || '-' }}</span>
<Button icon-left="edit" @click="showProfileEditDialog = true"> </div>
{{ $t('Edit') }} <div class="profile-detail-item">
</Button> <span class="profile-detail-label">{{ $t('Email') }}:</span>
</div> <span class="profile-detail-value">{{ user.email }}</span>
</div> </div>
<div> </div>
<ListItem </div>
:title="$t('Marketplace Developer')" <div class="profile-actions">
:subtitle="$t('Developers can publish their apps on the marketplace for users to subscribe to, either paid or free.')" <n-button type="primary" @click="showProfileEditDialog = true" :block="isMobile">
v-if="!$team.pg.is_developer" <template #icon>
> <n-icon><EditIcon /></n-icon>
<template #actions> </template>
<Button @click="confirmPublisherAccount"> {{ $t('Edit') }}
<span>{{ $t('Become a Developer') }}</span> </n-button>
</Button> </div>
</template> </div>
</ListItem> </n-space>
<ListItem </n-card>
:title="twoFactorAuthTitle"
:subtitle="twoFactorAuthSubtitle" <!-- 功能设置卡片 -->
> <n-card class="settings-card">
<template #actions> <n-list>
<Button @click="show2FADialog = true"> <n-list-item v-if="!$team.pg.is_developer">
{{ twoFactorAuthButtonLabel }} <n-thing>
</Button> <template #header>
</template> <span class="text-base font-medium">{{ $t('Marketplace Developer') }}</span>
</ListItem> </template>
<ListItem <template #description>
:title="$t('Reset Password')" <span class="text-sm text-gray-600">
:subtitle="$t('Change your account login password')" {{ $t('Developers can publish their apps on the marketplace for users to subscribe to, either paid or free.') }}
> </span>
<template #actions> </template>
<Button @click="showResetPasswordDialog = true"> <template #action>
{{ $t('Reset Password') }} <n-button type="primary" @click="confirmPublisherAccount">
</Button> {{ $t('Become a Developer') }}
</template> </n-button>
</ListItem> </template>
<ListItem </n-thing>
:title="accountStatusTitle" </n-list-item>
:subtitle="accountStatusSubtitle"
> <n-list-item>
<template #actions> <n-thing>
<Button <template #header>
@click=" <span class="text-base font-medium">{{ twoFactorAuthTitle }}</span>
() => { </template>
if (teamEnabled) { <template #description>
showDisableAccountDialog = true; <span class="text-sm text-gray-600">{{ twoFactorAuthSubtitle }}</span>
} else { </template>
showEnableAccountDialog = true; <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 }">{{ {{ $t('Cancel') }}
accountStatusButtonLabel </n-button>
}}</span> <n-button
</Button> type="primary"
</template> @click="handleSaveProfile"
</ListItem> :loading="$resources.updateProfile.loading"
</div> :block="isMobile"
<Dialog :size="buttonSize"
:options="{ >
title: $t('Update Profile Information'), {{ $t('Save Changes') }}
actions: [ </n-button>
{ </n-space>
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" />
</template> </template>
</Dialog> </n-modal>
<Dialog <!-- 禁用账户对话框 -->
:options="{ <n-modal
title: $t('Disable Account'), v-model:show="showDisableAccountDialog"
actions: [ preset="dialog"
{ :title="$t('Disable Account')"
label: $t('Disable Account'), :positive-text="$t('Disable Account')"
variant: 'solid', :positive-button-props="{ type: 'error' }"
theme: 'red', :loading="$resources.disableAccount.loading"
loading: $resources.disableAccount.loading, :mask-closable="true"
onClick: () => deactivateAccount(disableAccount2FACode), :close-on-esc="true"
}, @positive-click="() => deactivateAccount(disableAccount2FACode)"
],
}"
v-model="showDisableAccountDialog"
> >
<template v-slot:body-content> <div class="py-4">
<div class="prose text-base"> <p class="text-base mb-4">{{ $t('After confirming this action:') }}</p>
{{ $t('After confirming this action:') }} <ul class="list-disc list-inside space-y-2 text-sm text-gray-700 mb-4">
<ul> <li>{{ $t('Your account will be disabled') }}</li>
<li>{{ $t('Your account will be disabled') }}</li> <li>{{ $t('Your activated sites will be suspended immediately and deleted after one week.') }}</li>
<li> <li>{{ $t('Your account billing will stop') }}</li>
{{ $t('Your activated sites will be suspended immediately and deleted after one week.') }} </ul>
</li> <p class="text-base mb-4">{{ $t('You can log in later to re-enable your account. Do you want to continue?') }}</p>
<li>{{ $t('Your account billing will stop') }}</li> <n-form-item
</ul>
{{ $t('You can log in later to re-enable your account. Do you want to continue?') }}
</div>
<FormControl
v-if="user.is_2fa_enabled" v-if="user.is_2fa_enabled"
class="mt-4"
:label="$t('Enter your 2FA code to confirm')" :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" /> </div>
</template> </n-modal>
</Dialog>
<Dialog <!-- 启用账户对话框 -->
:options="{ <n-modal
title: $t('Enable Account'), v-model:show="showEnableAccountDialog"
actions: [ preset="dialog"
{ :title="$t('Enable Account')"
label: $t('Enable Account'), :positive-text="$t('Enable Account')"
variant: 'solid', :loading="$resources.enableAccount.loading"
loading: $resources.enableAccount.loading, :mask-closable="true"
onClick: () => $resources.enableAccount.submit(), :close-on-esc="true"
}, @positive-click="() => $resources.enableAccount.submit()"
],
}"
v-model="showEnableAccountDialog"
> >
<template v-slot:body-content> <div class="py-4">
<div class="prose text-base"> <p class="text-base mb-4">{{ $t('After confirming this action:') }}</p>
{{ $t('After confirming this action:') }} <ul class="list-disc list-inside space-y-2 text-sm text-gray-700 mb-4">
<ul> <li>{{ $t('Your account will be enabled') }}</li>
<li>{{ $t('Your account will be enabled') }}</li> <li>{{ $t('Your suspended sites will be reactivated') }}</li>
<li>{{ $t('Your suspended sites will be reactivated') }}</li> <li>{{ $t('Your account billing will resume') }}</li>
<li>{{ $t('Your account billing will resume') }}</li> </ul>
</ul> <p class="text-base">{{ $t('Do you want to continue?') }}</p>
{{ $t('Do you want to continue?') }} <n-alert
</div> v-if="$resources.enableAccount.error"
<ErrorMessage class="mt-2" :message="$resources.enableAccount.error" /> type="error"
</template> class="mt-4"
</Dialog> :title="$resources.enableAccount.error"
/>
</div>
</n-modal>
<AddPrepaidCreditsDialog <AddPrepaidCreditsDialog
:showMessage="showMessage" :showMessage="showMessage"
@ -194,27 +301,61 @@
v-if="showResetPasswordDialog" v-if="showResetPasswordDialog"
v-model="showResetPasswordDialog" v-model="showResetPasswordDialog"
/> />
</Card> <TFADialog v-model="show2FADialog" />
<TFADialog v-model="show2FADialog" /> </div>
</template> </template>
<script> <script>
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import { defineAsyncComponent, h } from 'vue'; 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 FileUploader from '@/components/FileUploader.vue';
import { confirmDialog, renderDialog } from '../../../utils/components'; import { confirmDialog, renderDialog } from '../../../utils/components';
import TFADialog from './TFADialog.vue'; import TFADialog from './TFADialog.vue';
import ResetPasswordDialog from './ResetPasswordDialog.vue'; import ResetPasswordDialog from './ResetPasswordDialog.vue';
import router from '../../../router'; import router from '../../../router';
import AddPrepaidCreditsDialog from '../../billing/AddPrepaidCreditsDialog.vue'; import AddPrepaidCreditsDialog from '../../billing/AddPrepaidCreditsDialog.vue';
import EditIcon from '~icons/lucide/edit';
export default { export default {
name: 'AccountProfile', name: 'AccountProfile',
components: { components: {
NCard,
NSpace,
NButton,
NAvatar,
NList,
NListItem,
NThing,
NModal,
NForm,
NFormItem,
NFormItemGi,
NInput,
NGrid,
NAlert,
NIcon,
TFADialog, TFADialog,
ResetPasswordDialog, ResetPasswordDialog,
FileUploader, FileUploader,
AddPrepaidCreditsDialog, AddPrepaidCreditsDialog,
EditIcon,
}, },
data() { data() {
return { return {
@ -229,6 +370,7 @@ export default {
showMessage: false, showMessage: false,
draftInvoice: {}, draftInvoice: {},
unpaidInvoices: [] | {}, unpaidInvoices: [] | {},
windowWidth: window.innerWidth,
}; };
}, },
computed: { computed: {
@ -238,6 +380,24 @@ export default {
user() { user() {
return this.$team?.pg?.user_info; 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() { twoFactorAuthTitle() {
return this.user.is_2fa_enabled return this.user.is_2fa_enabled
? this.$t('Disable Two-Factor Authentication') ? 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: { methods: {
handleResize() {
this.windowWidth = window.innerWidth;
},
handleSaveProfile() {
// onSuccess
this.$resources.updateProfile.submit();
},
reloadAccount() { reloadAccount() {
this.$team.reload(); this.$team.reload();
}, },
@ -461,3 +635,292 @@ 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>