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

View File

@ -309,6 +309,53 @@
v-model="showResetPasswordDialog"
/>
<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>
</template>
@ -333,7 +380,7 @@ import {
NIcon,
} from 'naive-ui';
import FileUploader from '@/components/FileUploader.vue';
import { confirmDialog, renderDialog } from '../../../utils/components';
import { renderDialog } from '../../../utils/components';
import TFADialog from './TFADialog.vue';
import ResetPasswordDialog from './ResetPasswordDialog.vue';
import router from '../../../router';
@ -382,6 +429,10 @@ export default {
draftInvoice: {},
unpaidInvoices: [] | {},
windowWidth: window.innerWidth,
showBecomeDeveloperDialog: false,
showMultipleInvoicesDialog: false,
showClearInvoiceDialog: false,
currentInvoice: null,
};
},
computed: {
@ -552,46 +603,14 @@ url: 'jcloud.api.billing.get_unpaid_invoices',
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();
},
},
});
this.showMultipleInvoicesDialog = true;
}
} 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();
},
},
});
this.currentInvoice = invoice;
this.showClearInvoiceDialog = true;
}
}
}
@ -609,39 +628,47 @@ url: 'jcloud.api.billing.get_unpaid_invoices',
});
},
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...'),
this.showBecomeDeveloperDialog = true;
},
handleBecomeDeveloper() {
toast.promise(
this.$team.setValue.submit(
{
is_developer: 1,
},
{
onSuccess: () => {
this.showBecomeDeveloperDialog = false;
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...'),
},
});
);
},
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>
<Dialog v-model="showDialog" :options="{ title: $t('Reset Password') }">
<template #body-content>
<div class="space-y-4">
<div v-if="!isResetMode" class="space-y-4">
<FormControl
v-model="oldPassword"
type="password"
:fieldtype="'Password'"
:label="$t('Current Password')"
:fieldname="'old_password'"
:reqd="1"
: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" />
<n-modal
v-model:show="showDialog"
preset="card"
:title="$t('Reset Password')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="reset-password-dialog"
>
<template #header>
<span class="text-lg font-semibold">{{ $t('Reset Password') }}</span>
</template>
<template #actions>
<div class="flex gap-2 w-full">
<Button
variant="outline"
class="flex-1"
@click="hide"
>
<n-space vertical :size="20">
<n-form-item v-if="!isResetMode" :label="$t('Current Password')">
<n-input
v-model:value="oldPassword"
type="password"
: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') }}
</Button>
<Button
variant="solid"
class="flex-1"
</n-button>
<n-button
type="primary"
:loading="isLoading"
:disabled="!isFormValid"
@click="onConfirm"
:block="isMobile"
:size="buttonSize"
>
{{ $t('Confirm') }}
</Button>
</div>
</n-button>
</n-space>
</template>
</Dialog>
</n-modal>
</template>
<script>
import { ErrorMessage, FormControl } from 'jingrow-ui';
import {
NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
} from 'naive-ui';
import { toast } from 'vue-sonner';
export default {
name: 'ResetPasswordDialog',
components: {
NModal,
NFormItem,
NInput,
NSpace,
NAlert,
NButton,
},
props: {
isResetMode: {
type: Boolean,
@ -116,9 +146,23 @@ export default {
} else {
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: {
newPassword() {
this.checkPasswordStrength();
@ -300,3 +344,97 @@ export default {
}
};
</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>
<Dialog
v-model="show"
:options="dialogOptions"
<n-modal
v-model:show="show"
preset="card"
:title="dialogTitle"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
class="tfa-dialog"
>
<template #body-content>
<Configure2FA @enabled="closeDialog" @disabled="closeDialog" />
<template #header>
<span class="text-lg font-semibold">{{ dialogTitle }}</span>
</template>
</Dialog>
<Configure2FA @enabled="closeDialog" @disabled="closeDialog" />
</n-modal>
</template>
<script>
import { NModal } from 'naive-ui';
import Configure2FA from '../../auth/Configure2FA.vue';
export default {
@ -20,22 +27,25 @@ export default {
}
},
components: {
NModal,
Configure2FA
},
methods: {
closeDialog() {
this.show = false;
}
},
computed: {
is2FAEnabled() {
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 {
title: this.is2FAEnabled
? this.$t('Disable Two-Factor Authentication')
: this.$t('Enable Two-Factor Authentication')
width: this.isMobile ? '95vw' : '700px',
maxWidth: this.isMobile ? '95vw' : '90vw',
};
},
show: {
@ -46,6 +56,26 @@ export default {
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等。,
Scan the QR code,扫描二维码,
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.,如果您无法访问认证器应用,您的账户将被锁定。请确保备份您的保险库/密钥。,
Setup Key,设置密钥,
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.