美化dashboard个人资料页面

This commit is contained in:
jingrow 2025-12-30 00:22:23 +08:00
parent 023cbf428c
commit 34577e321d
5 changed files with 450 additions and 228 deletions

View File

@ -1,114 +1,140 @@
<template> <template>
<div> <div>
<div <n-spin :show="$resources.qrUrl.loading">
class="mt-6 flex items-center justify-center" <n-space vertical :size="24">
v-if="$resources.qrUrl.loading" <!-- 禁用 2FA 模式 -->
> <div v-if="is2FAEnabled && $route.name !== 'Enable2FA'">
<LoadingText /> <n-alert
</div> type="error"
<div :title="$t('If you disable two-factor authentication, your account will become insecure')"
v-else-if="is2FAEnabled && $route.name !== 'Enable2FA'" class="mb-4"
class="space-y-4" />
>
<AlertBanner
:title="$t('If you disable two-factor authentication, your account will become insecure')"
type="error"
/>
<!-- 用户需要遵循的步骤 --> <n-card class="mb-4">
<div class="rounded border border-gray-200 bg-gray-50 p-4"> <h3 class="text-lg font-semibold mb-3">{{ $t('Steps to Disable Two-Factor Authentication') }}</h3>
<h3 class="text-lg font-semibold">{{ $t('Steps to Disable Two-Factor Authentication') }}</h3> <ol class="ml-4 list-decimal space-y-2 text-sm text-gray-700">
<ol class="mt-2 list-disc pl-2 text-sm"> <li>{{ $t('Open the authenticator app') }}</li>
<li>{{ $t('Open the authenticator app') }}</li> <li>{{ $t('Enter the code from the app below') }}</li>
<li>{{ $t('Enter the code from the app below') }}</li> </ol>
</ol> </n-card>
</div>
<FormControl <n-form-item :label="$t('Verify the code in the app to disable two-factor authentication')" class="mt-6">
:label="$t('Verify the code in the app to disable two-factor authentication')" <n-input
v-model="totpCode" v-model:value="totpCode"
/> :placeholder="$t('Enter the code from the authenticator app')"
</div> size="large"
class="w-full"
/>
</n-form-item>
</div>
<div v-else class="space-y-4"> <!-- 启用 2FA 模式 -->
<div class="w-full"> <div v-else>
<VueQrcode <!-- QR -->
v-if="qrUrl" <div class="flex justify-center">
class="mx-auto" <VueQrcode
:value="qrUrl" v-if="qrUrl"
type="image/png" class="mx-auto"
:color="{ dark: '#000000ff', light: '#ffffffff' }" :value="qrUrl"
/> type="image/png"
</div> :color="{ dark: '#000000ff', light: '#ffffffff' }"
:size="240"
/>
</div>
<!-- 用户需要遵循的步骤 --> <!-- 步骤说明 -->
<div class="rounded border border-gray-200 bg-gray-50 p-4"> <n-card>
<h3 class="text-lg font-semibold">{{ $t('Steps to Enable Two-Factor Authentication') }}</h3> <h3 class="text-lg font-semibold mb-3">{{ $t('Steps to Enable Two-Factor Authentication') }}</h3>
<ol class="ml-1 mt-2 list-disc pl-2 text-sm text-gray-700"> <ol class="ml-4 list-decimal space-y-2 text-sm text-gray-700 mb-4">
<li>{{ $t('Download an authenticator app on your phone, such as Alibaba Cloud APP, etc.') }}</li> <li>{{ $t('Download an authenticator app on your phone, such as Alibaba Cloud APP, etc.') }}</li>
<li>{{ $t('Scan the QR code') }}</li> <li>{{ $t('Scan the QR code') }}</li>
<li>{{ $t('Enter the code from the authenticator app below') }}</li> <li>{{ $t('Enter the code from the authenticator app below') }}</li>
</ol> </ol>
<p class="mt-4 text-sm text-gray-700"> <n-alert type="warning" class="mt-4">
<strong>{{ $t('Note') }}:</strong> {{ $t('If you cannot access the authenticator app, your account will be locked. Please ensure you back up your vault/key.') }} <template #header>
</p> <strong>{{ $t('Note') }}:</strong>
</div> </template>
{{ $t('If you cannot access the authenticator app, your account will be locked. Please ensure you back up your vault/key.') }}
</n-alert>
</n-card>
<div <!-- Setup Key -->
v-if="showSetupKey" <n-card v-if="showSetupKey">
class="rounded border border-gray-200 bg-gray-50 p-4" <h3 class="text-lg font-semibold mb-2">{{ $t('Setup Key') }}</h3>
> <p class="text-sm font-mono text-gray-700 break-all">
<h3 class="text-lg font-semibold">{{ $t('Setup Key') }}</h3> {{ setupKey }}
<p class="mt-2 text-sm"> </p>
{{ setupKey }} </n-card>
</p>
</div>
<FormControl <!-- 验证码输入 -->
:label="$t('Verify the code in the app to enable two-factor authentication')" <n-form-item :label="$t('Verify the code in the app to enable two-factor authentication')" class="mt-6">
v-model="totpCode" <n-input
/> v-model:value="totpCode"
</div> :placeholder="$t('Enter the code from the authenticator app')"
size="large"
class="w-full"
/>
</n-form-item>
</div>
<div class="!mt-8 flex justify-center"> <!-- 操作按钮 -->
<Button <n-button
v-if="!is2FAEnabled" v-if="!is2FAEnabled"
class="w-full" type="primary"
variant="solid" size="large"
:label="$t('Enable Two-Factor Authentication')" block
:disabled="!totpCode" :disabled="!totpCode"
:loading="$resources.enable2FA.loading" :loading="$resources.enable2FA.loading"
@click="enable2FA" @click="enable2FA"
/> >
<Button {{ $t('Enable Two-Factor Authentication') }}
v-else </n-button>
class="w-full" <n-button
variant="solid" v-else
:label="$t('Disable Two-Factor Authentication')" type="error"
:disabled="!totpCode" size="large"
:loading="$resources.disable2FA.loading" block
@click="disable2FA" :disabled="!totpCode"
/> :loading="$resources.disable2FA.loading"
</div> @click="disable2FA"
>
{{ $t('Disable Two-Factor Authentication') }}
</n-button>
</n-space>
</n-spin>
</div> </div>
</template> </template>
<script> <script>
import {
NSpace,
NAlert,
NCard,
NFormItem,
NInput,
NButton,
NSpin,
} from 'naive-ui';
import VueQrcode from 'vue-qrcode'; import VueQrcode from 'vue-qrcode';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import AlertBanner from '../AlertBanner.vue';
export default { export default {
emits: ['enabled', 'disabled'], emits: ['enabled', 'disabled'],
data() { data() {
return { return {
qrUrl: '', // not storing as computed property to avoid re-fetching on dialog close qrUrl: '', // not storing as computed property to avoid re-fetching on dialog close
totpCode: '', totpCode: '',
showSetupKey: false showSetupKey: false
}; };
}, },
components: { components: {
AlertBanner, NSpace,
NAlert,
NCard,
NFormItem,
NInput,
NButton,
NSpin,
VueQrcode VueQrcode
}, },
methods: { methods: {

View File

@ -309,6 +309,53 @@
v-model="showResetPasswordDialog" v-model="showResetPasswordDialog"
/> />
<TFADialog v-model="show2FADialog" /> <TFADialog v-model="show2FADialog" />
<!-- 成为开发者确认对话框 -->
<n-modal
v-model:show="showBecomeDeveloperDialog"
preset="dialog"
:title="$t('Become a Marketplace Developer?')"
:positive-text="$t('Confirm')"
:mask-closable="true"
:close-on-esc="true"
@positive-click="handleBecomeDeveloper"
>
<p class="text-base">
{{ $t('After confirmation, you will be able to publish apps to our marketplace.') }}
</p>
</n-modal>
<!-- 多个未付发票对话框 -->
<n-modal
v-model:show="showMultipleInvoicesDialog"
preset="dialog"
:title="$t('Multiple Unpaid Invoices')"
:positive-text="$t('Go to Invoices')"
:mask-closable="true"
:close-on-esc="true"
@positive-click="() => { router.push({ name: 'BillingInvoices' }); showMultipleInvoicesDialog = false; }"
>
<p class="text-base">
{{ $t('You have multiple unpaid invoices. Please pay them from the invoices page.') }}
</p>
</n-modal>
<!-- 清除未付发票对话框 -->
<n-modal
v-model:show="showClearInvoiceDialog"
preset="dialog"
:title="$t('Clear Unpaid Invoice')"
:positive-text="$t('Settle Now')"
:mask-closable="true"
:close-on-esc="true"
@positive-click="handleSettleInvoice"
>
<p class="text-base">
{{ $t('You have an unpaid invoice of {amount}. Please settle it before deactivating your account.', {
amount: currentInvoice ? `${currentInvoice.currency === 'CNY' ? '¥' : '$'} ${currentInvoice.amount_due}` : ''
}) }}
</p>
</n-modal>
</div> </div>
</template> </template>
@ -333,7 +380,7 @@ import {
NIcon, NIcon,
} from 'naive-ui'; } from 'naive-ui';
import FileUploader from '@/components/FileUploader.vue'; import FileUploader from '@/components/FileUploader.vue';
import { confirmDialog, renderDialog } from '../../../utils/components'; import { 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';
@ -382,6 +429,10 @@ export default {
draftInvoice: {}, draftInvoice: {},
unpaidInvoices: [] | {}, unpaidInvoices: [] | {},
windowWidth: window.innerWidth, windowWidth: window.innerWidth,
showBecomeDeveloperDialog: false,
showMultipleInvoicesDialog: false,
showClearInvoiceDialog: false,
currentInvoice: null,
}; };
}, },
computed: { computed: {
@ -552,46 +603,14 @@ url: 'jcloud.api.billing.get_unpaid_invoices',
if (this.$team.pg.payment_mode === 'Prepaid Credits') { if (this.$team.pg.payment_mode === 'Prepaid Credits') {
this.showAddPrepaidCreditsDialog = true; this.showAddPrepaidCreditsDialog = true;
} else { } else {
confirmDialog({ this.showMultipleInvoicesDialog = true;
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 { } else {
let invoice = this.unpaidInvoices; let invoice = this.unpaidInvoices;
if (invoice.amount_due > minAmount) { if (invoice.amount_due > minAmount) {
this.showDisableAccountDialog = false; this.showDisableAccountDialog = false;
confirmDialog({ this.currentInvoice = invoice;
title: this.$t('Clear Unpaid Invoice'), this.showClearInvoiceDialog = true;
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();
},
},
});
} }
} }
} }
@ -609,39 +628,47 @@ url: 'jcloud.api.billing.get_unpaid_invoices',
}); });
}, },
confirmPublisherAccount() { confirmPublisherAccount() {
confirmDialog({ this.showBecomeDeveloperDialog = true;
title: this.$t('Become a Marketplace Developer?'), },
message: this.$t('After confirmation, you will be able to publish apps to our marketplace.'), handleBecomeDeveloper() {
primaryAction: { toast.promise(
label: this.$t('Confirm'), this.$team.setValue.submit(
variant: 'solid' {
}, is_developer: 1,
onSuccess: ({ hide }) => { },
toast.promise( {
this.$team.setValue.submit( onSuccess: () => {
{ this.showBecomeDeveloperDialog = false;
is_developer: 1, this.$router.push({
}, name: 'Marketplace App List',
{ });
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...'),
}, },
); 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...'),
}, },
}); );
},
handleSettleInvoice() {
if (this.currentInvoice) {
if (
this.currentInvoice.stripe_invoice_url &&
this.$team.pg.payment_mode === 'Card'
) {
window.open(
`/api/action/jcloud.api.client.run_pg_method?dt=Invoice&dn=${this.currentInvoice.name}&method=stripe_payment_url`,
);
} else {
this.showAddPrepaidCreditsDialog = true;
}
this.showClearInvoiceDialog = false;
}
}, },
}, },
}; };

View File

@ -1,76 +1,106 @@
<template> <template>
<Dialog v-model="showDialog" :options="{ title: $t('Reset Password') }"> <n-modal
<template #body-content> v-model:show="showDialog"
<div class="space-y-4"> preset="card"
<div v-if="!isResetMode" class="space-y-4"> :title="$t('Reset Password')"
<FormControl :style="modalStyle"
v-model="oldPassword" :mask-closable="true"
type="password" :close-on-esc="true"
:fieldtype="'Password'" class="reset-password-dialog"
:label="$t('Current Password')" >
:fieldname="'old_password'" <template #header>
:reqd="1" <span class="text-lg font-semibold">{{ $t('Reset Password') }}</span>
:placeholder="$t('Please enter current password')"
/>
</div>
<div class="space-y-4">
<FormControl
v-model="newPassword"
type="password"
:fieldtype="'Password'"
:label="$t('New Password')"
:fieldname="'new_password'"
:reqd="1"
:placeholder="$t('Please enter new password')"
/>
<div v-if="passwordStrengthMessage" class="text-sm" :class="passwordStrengthClass">
{{ passwordStrengthMessage }}
</div>
<FormControl
v-model="confirmPassword"
type="password"
:fieldtype="'Password'"
:label="$t('Confirm Password')"
:fieldname="'confirm_password'"
:reqd="1"
:placeholder="$t('Please re-enter new password')"
/>
<div v-if="passwordMismatchMessage" class="text-sm text-red-600">
{{ passwordMismatchMessage }}
</div>
</div>
</div>
<ErrorMessage class="mt-2" :message="error" />
</template> </template>
<template #actions> <n-space vertical :size="20">
<div class="flex gap-2 w-full"> <n-form-item v-if="!isResetMode" :label="$t('Current Password')">
<Button <n-input
variant="outline" v-model:value="oldPassword"
class="flex-1" type="password"
@click="hide" :placeholder="$t('Please enter current password')"
> :size="inputSize"
show-password-on="click"
class="w-full"
/>
</n-form-item>
<n-form-item :label="$t('New Password')">
<n-input
v-model:value="newPassword"
type="password"
:placeholder="$t('Please enter new password')"
:size="inputSize"
show-password-on="click"
class="w-full"
/>
<n-alert
v-if="passwordStrengthMessage"
:type="passwordStrengthClass === 'text-green-600' ? 'success' : 'error'"
class="mt-2"
:title="passwordStrengthMessage"
/>
</n-form-item>
<n-form-item :label="$t('Confirm Password')">
<n-input
v-model:value="confirmPassword"
type="password"
:placeholder="$t('Please re-enter new password')"
:size="inputSize"
show-password-on="click"
class="w-full"
/>
<n-alert
v-if="passwordMismatchMessage"
type="error"
class="mt-2"
:title="passwordMismatchMessage"
/>
</n-form-item>
<n-alert
v-if="error"
type="error"
:title="error"
/>
</n-space>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button @click="hide" :block="isMobile" :size="buttonSize">
{{ $t('Cancel') }} {{ $t('Cancel') }}
</Button> </n-button>
<Button <n-button
variant="solid" type="primary"
class="flex-1"
:loading="isLoading" :loading="isLoading"
:disabled="!isFormValid" :disabled="!isFormValid"
@click="onConfirm" @click="onConfirm"
:block="isMobile"
:size="buttonSize"
> >
{{ $t('Confirm') }} {{ $t('Confirm') }}
</Button> </n-button>
</div> </n-space>
</template> </template>
</Dialog> </n-modal>
</template> </template>
<script> <script>
import { ErrorMessage, FormControl } from 'jingrow-ui'; import {
NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
} from 'naive-ui';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
export default { export default {
name: 'ResetPasswordDialog', name: 'ResetPasswordDialog',
components: {
NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
},
props: { props: {
isResetMode: { isResetMode: {
type: Boolean, type: Boolean,
@ -116,9 +146,23 @@ export default {
} else { } else {
return this.oldPassword && hasNewPassword && hasConfirmPassword && passwordsMatch && passwordsDifferent; return this.oldPassword && hasNewPassword && hasConfirmPassword && passwordsMatch && passwordsDifferent;
} }
} },
isMobile() {
return window.innerWidth <= 768;
},
modalStyle() {
return {
width: this.isMobile ? '95vw' : '700px',
maxWidth: this.isMobile ? '95vw' : '90vw',
};
},
inputSize() {
return this.isMobile ? 'medium' : 'large';
},
buttonSize() {
return this.isMobile ? 'medium' : 'medium';
},
}, },
components: { FormControl, ErrorMessage },
watch: { watch: {
newPassword() { newPassword() {
this.checkPasswordStrength(); this.checkPasswordStrength();
@ -300,3 +344,97 @@ export default {
} }
}; };
</script> </script>
<style scoped>
:deep(.reset-password-dialog .n-card) {
width: 700px;
max-width: 90vw;
}
:deep(.reset-password-dialog .n-card-body) {
padding: 24px;
}
:deep(.reset-password-dialog .n-form-item) {
margin-bottom: 0;
}
:deep(.reset-password-dialog .n-input) {
width: 100%;
}
:deep(.reset-password-dialog .n-form-item-label) {
font-weight: 500;
margin-bottom: 8px;
}
:deep(.reset-password-dialog .n-card__action) {
padding: 16px 24px;
}
@media (max-width: 768px) {
:deep(.reset-password-dialog .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.reset-password-dialog .n-card-body) {
padding: 20px 16px;
}
:deep(.reset-password-dialog .n-card__header) {
padding: 16px;
font-size: 18px;
}
:deep(.reset-password-dialog .n-card__action) {
padding: 12px 16px;
}
:deep(.reset-password-dialog .n-space) {
width: 100%;
}
:deep(.reset-password-dialog .n-button) {
width: 100%;
font-size: 16px;
height: 44px;
}
:deep(.reset-password-dialog .n-form-item-label) {
font-size: 14px;
margin-bottom: 6px;
}
:deep(.reset-password-dialog .n-input) {
font-size: 16px;
height: 44px;
}
}
/* 超小屏幕优化 */
@media (max-width: 480px) {
:deep(.reset-password-dialog .n-card) {
width: 100vw !important;
max-width: 100vw !important;
margin: 0;
border-radius: 0;
height: 100vh;
display: flex;
flex-direction: column;
}
:deep(.reset-password-dialog .n-card-body) {
flex: 1;
overflow-y: auto;
padding: 16px;
}
:deep(.reset-password-dialog .n-card__action) {
padding: 12px 16px;
border-top: 1px solid var(--n-border-color);
}
}
</style>

View File

@ -1,15 +1,22 @@
<template> <template>
<Dialog <n-modal
v-model="show" v-model:show="show"
:options="dialogOptions" preset="card"
:title="dialogTitle"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="tfa-dialog"
> >
<template #body-content> <template #header>
<Configure2FA @enabled="closeDialog" @disabled="closeDialog" /> <span class="text-lg font-semibold">{{ dialogTitle }}</span>
</template> </template>
</Dialog> <Configure2FA @enabled="closeDialog" @disabled="closeDialog" />
</n-modal>
</template> </template>
<script> <script>
import { NModal } from 'naive-ui';
import Configure2FA from '../../auth/Configure2FA.vue'; import Configure2FA from '../../auth/Configure2FA.vue';
export default { export default {
@ -20,22 +27,25 @@ export default {
} }
}, },
components: { components: {
NModal,
Configure2FA Configure2FA
}, },
methods: {
closeDialog() {
this.show = false;
}
},
computed: { computed: {
is2FAEnabled() { is2FAEnabled() {
return this.$team.pg?.user_info?.is_2fa_enabled; return this.$team.pg?.user_info?.is_2fa_enabled;
}, },
dialogOptions() { dialogTitle() {
return this.is2FAEnabled
? this.$t('Disable Two-Factor Authentication')
: this.$t('Enable Two-Factor Authentication');
},
isMobile() {
return window.innerWidth <= 768;
},
modalStyle() {
return { return {
title: this.is2FAEnabled width: this.isMobile ? '95vw' : '700px',
? this.$t('Disable Two-Factor Authentication') maxWidth: this.isMobile ? '95vw' : '90vw',
: this.$t('Enable Two-Factor Authentication')
}; };
}, },
show: { show: {
@ -46,6 +56,26 @@ export default {
this.$emit('update:modelValue', value); this.$emit('update:modelValue', value);
} }
} }
},
methods: {
closeDialog() {
this.show = false;
}
} }
}; };
</script> </script>
<style scoped>
@media (max-width: 768px) {
:deep(.tfa-dialog .n-card) {
width: 95vw !important;
max-width: 95vw !important;
margin: 20px auto;
border-radius: 12px;
}
:deep(.tfa-dialog .n-card-body) {
padding: 20px 16px;
}
}
</style>

View File

@ -1036,6 +1036,7 @@ Steps to Enable Two-Factor Authentication,启用双重认证的步骤,
Download an authenticator app on your phone, such as Alibaba Cloud APP, etc.,在手机上下载认证器应用例如阿里云APP等。, Download an authenticator app on your phone, such as Alibaba Cloud APP, etc.,在手机上下载认证器应用例如阿里云APP等。,
Scan the QR code,扫描二维码, Scan the QR code,扫描二维码,
Enter the code from the authenticator app below,在下方输入认证器应用中的代码, Enter the code from the authenticator app below,在下方输入认证器应用中的代码,
Enter the code from the authenticator app,输入认证器应用中的代码,
If you cannot access the authenticator app, your account will be locked. Please ensure you back up your vault/key.,如果您无法访问认证器应用,您的账户将被锁定。请确保备份您的保险库/密钥。, If you cannot access the authenticator app, your account will be locked. Please ensure you back up your vault/key.,如果您无法访问认证器应用,您的账户将被锁定。请确保备份您的保险库/密钥。,
Setup Key,设置密钥, Setup Key,设置密钥,
Verify the code in the app to enable two-factor authentication,验证应用中的代码以启用双重认证, Verify the code in the app to enable two-factor authentication,验证应用中的代码以启用双重认证,

Can't render this file because it has a wrong number of fields in line 400.