jingrow ee2f59df81 refactor: 优化前端代码,准备生产环境部署
- 删除所有调试日志(console.error/console.log)
- 将所有中文注释改为英文注释
- 将硬编码的中文文本改为使用 t() 函数的英文,支持国际化
- 移除可能暴露项目信息的内容
- 添加新增翻译键的中文翻译(Portrait Sample, Product Sample, Animal Sample, Object Sample, Unable to get team information)

修改文件:
- src/views/HomePage.vue
- src/views/tools/remove_background/remove_background.vue
- src/views/settings/Settings.vue
- src/locales/zh-CN.json
2026-01-04 22:19:19 +08:00

2062 lines
65 KiB
Vue

<template>
<div class="settings-page">
<div class="page-header">
<h1 class="page-title">{{ t('Settings') }}</h1>
</div>
<n-tabs v-model:value="activeTab" type="line" animated>
<!-- 个人资料标签页 -->
<n-tab-pane name="profile" :tab="t('Profile')">
<n-space vertical :size="24">
<!-- 个人资料信息 -->
<n-card :title="t('Profile')">
<div class="profile-header">
<n-avatar
:size="64"
:src="userAccountInfo?.user_image"
round
>
{{ userAccountInfo?.first_name?.[0] || authStore.user?.user?.[0] || 'U' }}
</n-avatar>
<div class="profile-info">
<h3 class="profile-name">
{{ userAccountInfo?.first_name || '' }} {{ userAccountInfo?.last_name || '' }}
<span v-if="!userAccountInfo?.first_name && !userAccountInfo?.last_name">
{{ authStore.user?.user || '' }}
</span>
</h3>
<div class="profile-details">
<div class="profile-detail-item">
<span class="profile-detail-label">{{ t('Username') }}:</span>
<span class="profile-detail-value">{{ userAccountInfo?.username || '-' }}</span>
</div>
<div class="profile-detail-item">
<span class="profile-detail-label">{{ t('Mobile') }}:</span>
<span class="profile-detail-value">{{ userAccountInfo?.mobile_no || '-' }}</span>
</div>
<div class="profile-detail-item">
<span class="profile-detail-label">{{ t('Email') }}:</span>
<span class="profile-detail-value">{{ userAccountInfo?.email || '-' }}</span>
</div>
</div>
</div>
<div class="profile-actions">
<n-button @click="showProfileEditDialog = true">
<template #icon>
<n-icon><Icon icon="tabler:edit" /></n-icon>
</template>
{{ t('Edit') }}
</n-button>
</div>
</div>
</n-card>
<!-- Feature settings card -->
<n-card>
<n-list>
<!-- Marketplace developer -->
<n-list-item v-if="!teamInfo?.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="handleBecomeDeveloper">
{{ t('Become a Developer') }}
</n-button>
</template>
</n-thing>
</n-list-item>
<!-- Two-factor authentication -->
<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>
<!-- Reset password -->
<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>
</n-card>
<!-- Referral program -->
<n-card v-if="referralLink">
<n-list>
<n-list-item>
<n-thing>
<template #header>
<span class="text-base font-medium">{{ t('Referral Program') }}</span>
</template>
<template #description>
<span class="text-sm text-gray-600">{{ t('Your exclusive referral link') }}</span>
</template>
<template #action>
<div class="flex flex-col gap-3 items-end">
<ClickToCopyField :textContent="referralLink" />
</div>
</template>
</n-thing>
</n-list-item>
</n-list>
<div class="mt-3 pt-3 border-t border-gray-200">
<p class="text-sm text-gray-700">
{{ t('Invite others to join Jingrow,') }}
<strong>{{ t('when they register and spend at least ¥100, you will get ¥20') }}</strong>
</p>
</div>
</n-card>
<!-- Jingrow Partner -->
<n-card v-if="!teamInfo?.jerp_partner">
<n-list>
<n-list-item>
<n-thing>
<template #header>
<span class="text-base font-medium">{{ t('Jingrow Partner') }}</span>
</template>
<template #description>
<span class="text-sm text-gray-600">{{ t('Jingrow partner associated with your account') }}</span>
</template>
<template #action>
<n-button
v-if="!teamInfo?.partner_email"
@click="showAddPartnerCodeDialog = true"
>
<template #icon>
<n-icon><Icon icon="tabler:edit" /></n-icon>
</template>
{{ t('Add Partner Code') }}
</n-button>
<n-button
v-else
type="error"
@click="showRemovePartnerDialog = true"
>
<template #icon>
<n-icon><Icon icon="tabler:trash" /></n-icon>
</template>
{{ t('Unlink Partner') }}
</n-button>
</template>
</n-thing>
</n-list-item>
</n-list>
<div class="mt-3 pt-3 border-t border-gray-200">
<p
v-if="!teamInfo?.partner_email"
class="text-sm text-gray-700"
>
{{ t('Have a Jingrow partner referral code? Click') }}
<strong>{{ t('Add Partner Code') }}</strong>
{{ t('to associate with your partner team.') }}
</p>
<div v-else class="flex items-center gap-3">
<div class="flex-1">
<p class="text-sm font-medium text-gray-900">{{ partnerBillingName }}</p>
<p class="text-sm text-gray-600">{{ teamInfo?.partner_email }}</p>
</div>
</div>
</div>
</n-card>
</n-space>
</n-tab-pane>
<!-- Developer tab -->
<n-tab-pane name="developer" :tab="t('Developer')">
<n-space vertical :size="24">
<!-- API Access -->
<n-card :title="t('API Access')">
<n-space vertical :size="20">
<div class="api-description">
{{ t('API key and API secret pairs can be used to access the Jingrow API.') }}
</div>
<div class="api-actions">
<n-button type="primary" @click="showCreateSecretDialog = true" :loading="createSecretLoading">
{{ apiKeyButtonLabel }}
</n-button>
</div>
<div v-if="userAccountInfo?.api_key">
<ClickToCopyField :textContent="userAccountInfo.api_key" />
</div>
<div v-else class="text-hint">
{{ t("You don't have an API key yet. Click the button above to create one.") }}
</div>
</n-space>
</n-card>
<!-- SSH Keys -->
<n-card :title="t('SSH Keys')">
<n-space vertical :size="16">
<div class="ssh-actions">
<n-button type="primary" @click="showAddSSHKeyDialog = true">
<template #icon>
<n-icon><Icon icon="tabler:plus" /></n-icon>
</template>
{{ t('Add SSH Key') }}
</n-button>
</div>
<n-data-table
v-if="sshKeys && sshKeys.length > 0"
:columns="sshKeyColumns"
:data="sshKeys"
:loading="sshKeysLoading"
:pagination="false"
/>
<n-empty v-else :description="t('No SSH keys configured')" />
</n-space>
</n-card>
<!-- Feature flags (admin only) -->
<n-card v-if="isAdmin" :title="t('Advanced Features')">
<n-form :model="featureFlags" label-placement="left" label-width="200px">
<n-form-item
v-for="field in featureFlagFields"
:key="field.fieldname"
:label="field.label"
>
<n-switch v-model:value="featureFlags[field.fieldname]" />
</n-form-item>
</n-form>
<template #footer>
<n-space justify="start">
<n-button
type="primary"
class="save-btn-brand"
:loading="featureFlagsSaving"
:disabled="!featureFlagsDirty"
@click="saveFeatureFlags"
>
<template #icon>
<n-icon><Icon icon="tabler:check" /></n-icon>
</template>
{{ t('Save Changes') }}
</n-button>
</n-space>
</template>
</n-card>
</n-space>
</n-tab-pane>
<!-- System settings tab -->
<n-tab-pane name="system" :tab="t('System Settings')">
<n-grid :cols="2" :x-gap="24" :y-gap="24">
<!-- Left column: System settings -->
<n-grid-item>
<n-card :title="t('System Settings')">
<n-form :model="systemSettings" label-placement="left" label-width="120px">
<n-form-item :label="t('App Name')">
<n-input v-model:value="systemSettings.appName" :placeholder="t('Enter app name')" />
</n-form-item>
<n-form-item :label="t('Interface Language')">
<n-select
v-model:value="systemSettings.language"
:options="languageOptions"
style="width: 200px"
@update:value="changeLanguage"
/>
</n-form-item>
<n-form-item :label="t('Items Per Page')">
<n-select
v-model:value="systemSettings.itemsPerPage"
:options="pageSizeOptions"
style="width: 120px"
/>
</n-form-item>
<n-form-item :label="t('Timezone')">
<n-alert v-if="timezoneError" type="error" style="margin-bottom: 8px">
{{ timezoneError }}
</n-alert>
<n-select
v-model:value="systemSettings.timezone"
:options="timezoneOptions"
style="width: 250px"
filterable
:placeholder="t('Select timezone')"
:disabled="timezoneOptions.length === 0"
/>
</n-form-item>
</n-form>
<template #footer>
<n-space justify="start">
<n-button type="primary" class="save-btn-brand" @click="saveSystemSettings">
<template #icon>
<n-icon><Icon icon="tabler:check" /></n-icon>
</template>
{{ t('Save') }}
</n-button>
</n-space>
</template>
</n-card>
</n-grid-item>
<!-- Right column: Environment configuration (system admin only) -->
<n-grid-item v-if="isAdmin">
<n-card :title="t('Environment Configuration')">
<n-alert type="warning" style="margin-bottom: 16px">
{{ t('Only system administrators can view and edit environment configuration') }}
</n-alert>
<n-form
:model="envConfig"
label-placement="left"
label-width="180px"
:loading="envConfigLoading"
>
<n-collapse>
<n-collapse-item name="jingrow" :title="t('Jingrow API Configuration')">
<n-form-item :label="t('Jingrow Server URL')">
<n-input v-model:value="envConfig.jingrow_server_url" placeholder="https://example.jingrow.com" />
</n-form-item>
<n-form-item :label="t('Jingrow API Key')">
<n-input v-model:value="envConfig.jingrow_api_key" type="password" show-password-on="click" />
</n-form-item>
<n-form-item :label="t('Jingrow API Secret')">
<n-input v-model:value="envConfig.jingrow_api_secret" type="password" show-password-on="click" />
</n-form-item>
</n-collapse-item>
<n-collapse-item name="cloud" :title="t('Jingrow Cloud Configuration')">
<n-form-item :label="t('Cloud URL')">
<n-input v-model:value="envConfig.jingrow_cloud_url" />
</n-form-item>
<n-form-item :label="t('Cloud API URL')">
<n-input v-model:value="envConfig.jingrow_cloud_api_url" />
</n-form-item>
<n-form-item :label="t('Cloud API Key')">
<n-input v-model:value="envConfig.jingrow_cloud_api_key" type="password" show-password-on="click" />
</n-form-item>
<n-form-item :label="t('Cloud API Secret')">
<n-input v-model:value="envConfig.jingrow_cloud_api_secret" type="password" show-password-on="click" />
</n-form-item>
</n-collapse-item>
<n-collapse-item v-if="isLocalMode" name="database" :title="t('Database Configuration')">
<n-form-item :label="t('DB Host')">
<n-input v-model:value="envConfig.jingrow_db_host" />
</n-form-item>
<n-form-item :label="t('DB Port')">
<n-input v-model:value="envConfig.jingrow_db_port" />
</n-form-item>
<n-form-item :label="t('DB Name')">
<n-input v-model:value="envConfig.jingrow_db_name" />
</n-form-item>
<n-form-item :label="t('DB User')">
<n-input v-model:value="envConfig.jingrow_db_user" />
</n-form-item>
<n-form-item :label="t('DB Password')">
<n-input v-model:value="envConfig.jingrow_db_password" type="password" show-password-on="click" />
</n-form-item>
<n-form-item :label="t('DB Type')">
<n-select v-model:value="envConfig.jingrow_db_type" :options="dbTypeOptions" style="width: 200px" />
</n-form-item>
</n-collapse-item>
<n-collapse-item name="backend" :title="t('Backend Configuration')">
<n-form-item :label="t('Backend Host')">
<n-input v-model:value="envConfig.backend_host" />
</n-form-item>
<n-form-item :label="t('Backend Port')">
<n-input-number v-model:value="envConfig.backend_port" :min="1" :max="65535" />
</n-form-item>
<n-form-item :label="t('Backend Reload')">
<n-switch v-model:value="envConfig.backend_reload" />
</n-form-item>
</n-collapse-item>
<n-collapse-item name="dramatiq" :title="t('Dramatiq')">
<n-form-item :label="t('Worker Processes')">
<n-input-number v-model:value="envConfig.worker_processes" :min="1" :max="32" />
</n-form-item>
<n-form-item :label="t('Worker Threads')">
<n-input-number v-model:value="envConfig.worker_threads" :min="1" :max="32" />
</n-form-item>
<n-form-item :label="t('Watch')">
<n-switch v-model:value="envConfig.watch" />
</n-form-item>
</n-collapse-item>
<n-collapse-item name="qdrant" :title="t('Qdrant Configuration')">
<n-form-item :label="t('Qdrant Host')">
<n-input v-model:value="envConfig.qdrant_host" />
</n-form-item>
<n-form-item :label="t('Qdrant Port')">
<n-input-number v-model:value="envConfig.qdrant_port" :min="1" :max="65535" />
</n-form-item>
</n-collapse-item>
<n-collapse-item name="runtime" :title="t('Other')">
<n-form-item :label="t('Run Mode')">
<n-select v-model:value="envConfig.run_mode" :options="runModeOptions" style="width: 200px" />
</n-form-item>
<n-form-item :label="t('Environment')">
<n-select v-model:value="envConfig.environment" :options="environmentOptions" style="width: 200px" />
</n-form-item>
<n-form-item :label="t('Log Level')">
<n-select v-model:value="envConfig.log_level" :options="logLevelOptions" style="width: 200px" />
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-form>
<template #footer>
<n-space justify="start">
<n-button type="default" @click="() => loadEnvironmentConfig()" :loading="envConfigLoading">
<template #icon>
<n-icon><Icon icon="tabler:refresh" /></n-icon>
</template>
{{ t('Refresh') }}
</n-button>
<n-button type="warning" :loading="envConfigRestarting" @click="handleRestartEnvironment">
<template #icon>
<n-icon><Icon icon="ix:restart" /></n-icon>
</template>
{{ t('Restart Environment') }}
</n-button>
<n-button type="primary" class="save-btn-brand" :loading="envConfigSaving" @click="saveEnvironmentConfig">
<template #icon>
<n-icon><Icon icon="tabler:check" /></n-icon>
</template>
{{ t('Save') }}
</n-button>
</n-space>
</template>
</n-card>
</n-grid-item>
</n-grid>
</n-tab-pane>
</n-tabs>
<!-- Edit profile dialog -->
<n-modal v-model:show="showProfileEditDialog" preset="card" :title="t('Update Profile Information')" :style="{ width: '600px' }">
<n-form :model="profileForm" label-placement="top">
<n-space vertical :size="20">
<n-form-item :label="t('Username')">
<n-input v-model:value="profileForm.username" />
</n-form-item>
<n-form-item :label="t('Mobile')">
<n-input v-model:value="profileForm.mobile_no" :placeholder="t('Please enter your phone number')" />
</n-form-item>
<n-form-item :label="t('Email')">
<n-input v-model:value="profileForm.email" />
</n-form-item>
</n-space>
</n-form>
<template #footer>
<n-space justify="end">
<n-button @click="showProfileEditDialog = false">{{ t('Cancel') }}</n-button>
<n-button type="primary" :loading="profileSaving" @click="saveProfile">
{{ t('Save Changes') }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- Create API Secret dialog -->
<n-modal v-model:show="showCreateSecretDialog" preset="card" :title="t('API Access')" :style="{ width: '700px' }">
<n-space vertical :size="20">
<div v-if="!createSecretData">
<p class="text-base">
{{ t('API key and API secret pairs can be used to access the Jingrow API.') }}
</p>
</div>
<div v-if="createSecretData">
<n-alert type="warning" style="margin-bottom: 16px">
{{ t('Please copy the API secret now. You won\'t be able to see it again!') }}
</n-alert>
<n-form-item :label="t('API Key')">
<ClickToCopyField :textContent="createSecretData.api_key" />
</n-form-item>
<n-form-item :label="t('API Secret')">
<ClickToCopyField :textContent="createSecretData.api_secret" />
</n-form-item>
</div>
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="closeCreateSecretDialog">{{ t('Close') }}</n-button>
<n-button
v-if="!createSecretData"
type="primary"
:loading="createSecretLoading"
@click="handleCreateSecret"
>
{{ apiKeyButtonLabel }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- Add SSH key dialog -->
<n-modal v-model:show="showAddSSHKeyDialog" preset="card" :title="t('Add New SSH Key')" :style="{ width: '700px' }">
<n-space vertical :size="20">
<p class="text-base">{{ t('Add a new SSH key to your account') }}</p>
<n-form-item :label="t('SSH Key')" :required="true">
<n-input
v-model:value="sshKeyValue"
type="textarea"
:placeholder="sshKeyPlaceholder"
:rows="6"
/>
</n-form-item>
<n-alert v-if="sshKeyError" type="error" :title="sshKeyError" />
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="showAddSSHKeyDialog = false">{{ t('Cancel') }}</n-button>
<n-button
type="primary"
:loading="addSSHKeyLoading"
@click="handleAddSSHKey"
>
{{ t('Add SSH Key') }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- Two-factor authentication dialog -->
<n-modal v-model:show="show2FADialog" preset="card" :title="twoFactorAuthTitle" :style="{ width: '700px' }">
<n-spin :show="loadingQRCode">
<n-space vertical :size="24">
<!-- Disable 2FA mode -->
<div v-if="is2FAEnabled">
<n-alert
type="error"
:title="t('If you disable two-factor authentication, your account will become insecure')"
class="mb-4"
/>
<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>
<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>
<!-- 启用 2FA 模式 -->
<div v-else>
<!-- QR code - centered display -->
<div class="tfa-qr-container" v-if="qrCodeUrl">
<img
:src="`https://api.qrserver.com/v1/create-qr-code/?size=240x240&data=${encodeURIComponent(qrCodeUrl)}`"
alt="QR Code"
class="tfa-qr-code"
/>
</div>
<!-- Step instructions -->
<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>
<!-- Setup Key -->
<n-card v-if="showSetupKey && setupKey">
<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>
<!-- Verification code input -->
<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>
<!-- Error message -->
<n-alert
v-if="twoFAError"
type="error"
:title="twoFAError"
/>
<!-- Action buttons -->
<n-button
v-if="!is2FAEnabled"
type="primary"
size="large"
block
:disabled="!totpCode"
:loading="loading2FA"
@click="handleEnable2FA"
>
{{ t('Enable Two-Factor Authentication') }}
</n-button>
<n-button
v-else
type="error"
size="large"
block
:disabled="!totpCode"
:loading="loading2FA"
@click="handleDisable2FA"
>
{{ t('Disable Two-Factor Authentication') }}
</n-button>
</n-space>
</n-spin>
<template #footer>
<n-space justify="end">
<n-button @click="close2FADialog">{{ t('Cancel') }}</n-button>
</n-space>
</template>
</n-modal>
<!-- Reset password dialog -->
<n-modal v-model:show="showResetPasswordDialog" preset="card" :title="t('Reset Password')" :style="{ width: '700px' }">
<n-space vertical :size="20">
<n-form-item :label="t('Current Password')">
<n-input
v-model:value="oldPassword"
type="password"
:placeholder="t('Please enter current password')"
show-password-on="click"
@input="checkPasswordMismatch"
/>
</n-form-item>
<n-form-item :label="t('New Password')">
<n-input
v-model:value="newPassword"
type="password"
:placeholder="t('Please enter new password')"
show-password-on="click"
@input="checkPasswordStrength"
/>
<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')"
show-password-on="click"
@input="checkPasswordMismatch"
/>
<n-alert
v-if="passwordMismatchMessage"
type="error"
class="mt-2"
:title="passwordMismatchMessage"
/>
</n-form-item>
<n-alert
v-if="passwordError"
type="error"
:title="passwordError"
/>
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="closeResetPasswordDialog">{{ t('Cancel') }}</n-button>
<n-button
type="primary"
:loading="resetPasswordLoading"
:disabled="!isResetPasswordFormValid"
@click="handleResetPassword"
>
{{ t('Confirm') }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- Add partner code dialog -->
<n-modal v-model:show="showAddPartnerCodeDialog" preset="card" :title="t('Link Partner Account')" :style="{ width: '700px' }">
<n-space vertical :size="20">
<p class="text-base">{{ t('Enter the partner code provided by your partner') }}</p>
<n-form-item :label="t('Partner Code')">
<n-input
v-model:value="partnerCode"
:placeholder="t('For example: rGjw3hJ81b')"
@input="handlePartnerCodeChange"
/>
</n-form-item>
<n-alert
v-if="partnerExists"
type="success"
:title="`${t('Referral code')} ${partnerCode} ${t('belongs to')} ${partnerName}`"
/>
<n-alert
v-if="partnerCodeError"
type="error"
:title="partnerCodeError"
/>
</n-space>
<template #footer>
<n-space justify="end">
<n-button @click="closeAddPartnerCodeDialog">{{ t('Cancel') }}</n-button>
<n-button
type="primary"
:loading="addPartnerCodeLoading"
:disabled="!partnerExists"
@click="handleAddPartnerCode"
>
{{ t('Submit') }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- Remove partner dialog -->
<n-modal
v-model:show="showRemovePartnerDialog"
preset="dialog"
:title="t('Remove Partner')"
:positive-text="t('Remove')"
:positive-button-props="{ type: 'error' }"
:mask-closable="true"
:close-on-esc="true"
@positive-click="handleRemovePartner"
>
<p class="text-base">
{{ t('This will remove the partner associated with your account. Are you sure you want to remove this partner?') }}
</p>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { reactive, onMounted, computed, ref, h, watch } from 'vue'
import {
NGrid,
NGridItem,
NCard,
NForm,
NFormItem,
NInput,
NButton,
NInputNumber,
NSelect,
NSwitch,
NAlert,
NCollapse,
NCollapseItem,
NSpace,
NIcon,
NTabs,
NTabPane,
NAvatar,
NList,
NListItem,
NThing,
NEmpty,
NDataTable,
NModal,
NSpin,
useMessage,
useDialog
} from 'naive-ui'
import { Icon } from '@iconify/vue'
import { getCurrentLocale, setLocale, locales, initLocale, t } from '../../shared/i18n'
import { useAuthStore } from '../../shared/stores/auth'
import { getEnvironmentConfig, updateEnvironmentConfig, restartEnvironment, type EnvironmentConfig } from '../../shared/api/system'
import { getCurrentTimezone, getGroupedTimezoneOptions } from '../../shared/utils/timezone'
import {
createApiSecret,
updateProfile,
getUserSSHKeys,
addSSHKey,
markKeyAsDefault,
deleteSSHKey,
getFeatureFlags,
updateFeatureFlags,
getUserAccountInfo,
becomeDeveloper,
updatePassword,
testPasswordStrength,
validatePartnerCode,
addPartnerCode,
removePartner,
getPartnerName,
get2FAQRCodeUrl,
enable2FA,
disable2FA
} from '../../shared/api/account'
import ClickToCopyField from '../../shared/components/ClickToCopyField.vue'
const message = useMessage()
const dialog = useDialog()
const authStore = useAuthStore()
// Currently active tab
const activeTab = ref('profile')
// Check if system administrator
const isAdmin = computed(() => {
const user = authStore.user
return user?.user === 'Administrator' || user?.user_type === 'System User'
})
// Check if local run mode
const isLocalMode = computed(() => {
return envConfig.run_mode === 'local'
})
// User account information
const userAccountInfo = ref<any>(null)
const userAccountInfoLoading = ref(false)
// Profile related
const showProfileEditDialog = ref(false)
const profileForm = reactive({
first_name: '',
last_name: '',
username: '',
mobile_no: '',
email: ''
})
const profileSaving = ref(false)
// Team information
const teamInfo = ref<any>(null)
// Referral program
const referralLink = computed(() => {
if (teamInfo.value?.referrer_id) {
return `${location.origin}/dashboard/signup?referrer=${teamInfo.value.referrer_id}`
}
return ''
})
// Partner
const showAddPartnerCodeDialog = ref(false)
const showRemovePartnerDialog = ref(false)
const partnerCode = ref('')
const partnerBillingName = ref('')
const partnerExists = ref(false)
const partnerName = ref('')
const partnerCodeError = ref('')
const addPartnerCodeLoading = ref(false)
// Reset password
const oldPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const passwordStrengthMessage = ref('')
const passwordStrengthClass = ref('')
const passwordMismatchMessage = ref('')
const passwordError = ref('')
const resetPasswordLoading = ref(false)
const passwordStrengthTimeout = ref<any>(null)
// Two-factor authentication
const show2FADialog = ref(false)
const qrCodeUrl = ref('')
const totpCode = ref('')
const showSetupKey = ref(false)
const setupKey = computed(() => {
if (!qrCodeUrl.value) return ''
const match = qrCodeUrl.value.match(/secret=([^&]+)/)
return match ? match[1] : ''
})
const twoFactorAuthTitle = computed(() => {
return userAccountInfo.value?.is_2fa_enabled
? t('Disable Two-Factor Authentication')
: t('Enable Two-Factor Authentication')
})
const twoFactorAuthSubtitle = computed(() => {
return userAccountInfo.value?.is_2fa_enabled
? t('Disable two-factor authentication for your account')
: t('Enable two-factor authentication for your account to add an extra layer of security')
})
const twoFactorAuthButtonLabel = computed(() => {
return userAccountInfo.value?.is_2fa_enabled ? t('Disable') : t('Enable')
})
const is2FAEnabled = computed(() => userAccountInfo.value?.is_2fa_enabled || false)
const loading2FA = ref(false)
const loadingQRCode = ref(false)
const twoFAError = ref('')
// Reset password
const showResetPasswordDialog = ref(false)
// API Secret related
const showCreateSecretDialog = ref(false)
const createSecretData = ref<{ api_key: string; api_secret: string } | null>(null)
const createSecretLoading = ref(false)
const apiKeyButtonLabel = computed(() => {
return userAccountInfo.value?.api_key ? t('Regenerate API Key') : t('Create New API Key')
})
// SSH key related
const sshKeys = ref<any[]>([])
const sshKeysLoading = ref(false)
const showAddSSHKeyDialog = ref(false)
const sshKeyValue = ref('')
const sshKeyError = ref('')
const addSSHKeyLoading = ref(false)
const sshKeyPlaceholder = computed(() => {
return t("Starts with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'")
})
// SSH key table columns
const sshKeyColumns = [
{
title: t('SSH Fingerprint'),
key: 'ssh_fingerprint',
render: (row: any) => {
return h('span', { class: 'font-mono' }, `SHA256:${row.ssh_fingerprint}`)
}
},
{
title: t('Added Time'),
key: 'creation',
render: (row: any) => {
return new Date(row.creation).toLocaleDateString()
}
},
{
title: t('Actions'),
key: 'actions',
render: (row: any) => {
return h(NSpace, { size: 'small' }, [
!row.is_default ? h(NButton, {
size: 'small',
onClick: () => handleSetDefaultSSHKey(row.name)
}, { default: () => t('Set as Default') }) : null,
h(NButton, {
size: 'small',
type: 'error',
onClick: () => handleDeleteSSHKey(row.name)
}, { default: () => t('Delete') })
])
}
}
]
// Feature flags related
const featureFlags = reactive<Record<string, boolean>>({})
const featureFlagsSaving = ref(false)
const featureFlagFields = [
{ label: t('Enable private benches'), fieldname: 'benches_enabled' },
{ label: t('Enable security portal'), fieldname: 'security_portal_enabled' }
]
const featureFlagsDirty = computed(() => {
// Need to check if there are changes based on actual data
return false
})
// Environment configuration
const envConfig = reactive<Partial<EnvironmentConfig>>({})
const envConfigLoading = ref(false)
const envConfigSaving = ref(false)
const envConfigRestarting = ref(false)
const systemSettings = reactive({
appName: localStorage.getItem('appName') || 'Jingrow',
language: getCurrentLocale(),
itemsPerPage: parseInt(localStorage.getItem('itemsPerPage') || '10'),
timezone: getCurrentTimezone()
})
// Language options
const languageOptions = locales.map(locale => ({
label: `${locale.flag} ${locale.name}`,
value: locale.code
}))
// Items per page options
const pageSizeOptions = [
{ label: '10', value: 10 },
{ label: '20', value: 20 },
{ label: '50', value: 50 },
{ label: '100', value: 100 }
]
// Timezone options - use standard IANA timezone list, grouped by UTC offset (industry best practice)
const timezoneOptions = ref<Array<{ type: string; label: string; key: string; children: Array<{ label: string; value: string }> }>>([])
const timezoneError = ref<string | null>(null)
// Database type options
const dbTypeOptions = [
{ label: 'MariaDB', value: 'mariadb' },
{ label: 'MySQL', value: 'mysql' },
{ label: 'PostgreSQL', value: 'postgresql' }
]
// Run mode options
const runModeOptions = [
{ label: 'API', value: 'api' },
{ label: 'Local', value: 'local' }
]
// Environment options
const environmentOptions = [
{ label: 'Development', value: 'development' },
{ label: 'Production', value: 'production' }
]
// Log level options
const logLevelOptions = [
{ label: 'DEBUG', value: 'DEBUG' },
{ label: 'INFO', value: 'INFO' },
{ label: 'WARNING', value: 'WARNING' },
{ label: 'ERROR', value: 'ERROR' },
{ label: 'CRITICAL', value: 'CRITICAL' }
]
const changeLanguage = (locale: string) => {
setLocale(locale)
message.success(t('Language updated'))
}
const saveSystemSettings = () => {
// Save app name
localStorage.setItem('appName', systemSettings.appName)
// Save items per page setting
localStorage.setItem('itemsPerPage', systemSettings.itemsPerPage.toString())
// Save timezone setting
localStorage.setItem('timezone', systemSettings.timezone)
message.success(t('System settings saved'))
// Auto refresh page after saving to apply new settings
setTimeout(() => {
window.location.reload()
}, 1000)
}
// Load environment configuration
const loadEnvironmentConfig = async (showMessage = true) => {
if (!isAdmin.value) {
return
}
envConfigLoading.value = true
try {
const result = await getEnvironmentConfig()
if (result.success && result.data) {
Object.assign(envConfig, result.data)
if (showMessage) {
message.success(t('Environment configuration loaded'))
}
} else {
if (showMessage) {
message.error(result.message || t('Failed to load environment configuration'))
}
}
} catch (error: any) {
if (showMessage) {
message.error(error.message || t('Failed to load environment configuration'))
}
} finally {
envConfigLoading.value = false
}
}
// Save environment configuration
const saveEnvironmentConfig = async () => {
if (!isAdmin.value) {
message.error(t('Only system administrators can edit environment configuration'))
return
}
envConfigSaving.value = true
try {
const result = await updateEnvironmentConfig(envConfig)
if (result.success) {
message.success(result.message || t('Environment configuration saved'))
// Reload configuration to get latest values (silent load, no message)
await loadEnvironmentConfig(false)
} else {
message.error(result.message || t('Failed to save environment configuration'))
}
} catch (error: any) {
message.error(error.message || t('Failed to save environment configuration'))
} finally {
envConfigSaving.value = false
}
}
// Restart environment
const handleRestartEnvironment = () => {
if (!isAdmin.value) {
message.error(t('Only system administrators can restart environment'))
return
}
// 确认对话框
dialog.warning({
title: t('Restart Environment'),
content: t('Are you sure you want to restart the environment? This operation may cause service interruption.'),
positiveText: t('Restart'),
negativeText: t('Cancel'),
onPositiveClick: async () => {
envConfigRestarting.value = true
try {
const result = await restartEnvironment()
if (result.success) {
message.success(result.message || t('Environment restart request submitted. The system will restart shortly.'))
} else {
message.error(result.message || t('Failed to restart environment'))
}
} catch (error: any) {
message.error(error.message || t('Failed to restart environment'))
} finally {
envConfigRestarting.value = false
}
}
})
}
// Load user account information
const loadUserAccountInfo = async () => {
userAccountInfoLoading.value = true
try {
const result = await getUserAccountInfo()
if (result.success && result.data) {
userAccountInfo.value = result.data
profileForm.first_name = result.data.first_name || ''
profileForm.last_name = result.data.last_name || ''
profileForm.username = result.data.username || result.data.name || ''
profileForm.mobile_no = result.data.mobile_no || ''
profileForm.email = result.data.email || ''
}
// Also get Team information
if (result.team) {
teamInfo.value = result.team
// Load partner name
if (result.team.partner_email) {
await loadPartnerName()
}
}
} catch (error: any) {
// Failed to load user account information
} finally {
userAccountInfoLoading.value = false
}
}
// Save profile
const saveProfile = async () => {
profileSaving.value = true
try {
const result = await updateProfile({
username: profileForm.username,
mobile_no: profileForm.mobile_no,
email: profileForm.email
})
if (result.success) {
message.success(t('Profile updated successfully'))
showProfileEditDialog.value = false
await loadUserAccountInfo()
} else {
message.error(result.message || t('Failed to update profile'))
}
} catch (error: any) {
message.error(error.message || t('Failed to update profile'))
} finally {
profileSaving.value = false
}
}
// Become developer
const handleBecomeDeveloper = () => {
dialog.warning({
title: t('Become a Marketplace Developer?'),
content: t('After confirmation, you will be able to publish apps to our marketplace.'),
positiveText: t('Confirm'),
negativeText: t('Cancel'),
onPositiveClick: async () => {
if (!teamInfo.value?.name) {
message.error(t('Unable to get team information'))
return
}
const result = await becomeDeveloper(teamInfo.value.name)
if (result.success) {
message.success(t('You can now publish apps to our marketplace'))
// set_value API returns updated Team object, directly update teamInfo's is_developer field
if (result.data) {
// Ensure is_developer field is set correctly
teamInfo.value = {
...teamInfo.value,
...result.data,
is_developer: result.data.is_developer !== undefined ? result.data.is_developer : 1
}
} else {
// If API doesn't return data, directly set is_developer
teamInfo.value = { ...teamInfo.value, is_developer: 1 }
}
// Delay refresh to avoid cache issues (API has 60 second cache)
setTimeout(async () => {
await loadUserAccountInfo()
}, 1000)
} else {
message.error(result.message || t('Failed to mark you as a developer'))
}
}
})
}
// Remove partner
const handleRemovePartner = async () => {
const result = await removePartner()
if (result.success) {
message.success(t('Partner removed successfully'))
showRemovePartnerDialog.value = false
await loadUserAccountInfo()
} else {
message.error(result.message || t('Failed to remove partner'))
}
}
// Check password strength
const checkPasswordStrength = () => {
if (passwordStrengthTimeout.value) {
clearTimeout(passwordStrengthTimeout.value)
}
passwordStrengthTimeout.value = setTimeout(async () => {
if (!newPassword.value) {
passwordStrengthMessage.value = ''
return
}
try {
const result = await testPasswordStrength({
old_password: oldPassword.value,
new_password: newPassword.value
})
if (result.success && result.data) {
const feedback = result.data.feedback
if (feedback?.password_policy_validation_passed) {
passwordStrengthMessage.value = t('Password strength is good 👍')
passwordStrengthClass.value = 'text-green-600'
} else {
const msg: string[] = []
if (feedback?.suggestions && Array.isArray(feedback.suggestions) && feedback.suggestions.length) {
msg.push(...(feedback.suggestions as string[]))
} else if (feedback?.warning) {
msg.push(String(feedback.warning))
}
msg.push(t('Tip: Password should contain symbols, numbers and uppercase letters'))
passwordStrengthMessage.value = msg.join(' ')
passwordStrengthClass.value = 'text-red-600'
}
}
} catch (error) {
passwordStrengthMessage.value = ''
}
}, 200)
}
// Check password mismatch
const checkPasswordMismatch = () => {
if (oldPassword.value && newPassword.value && oldPassword.value === newPassword.value) {
passwordMismatchMessage.value = t('New password cannot be the same as current password')
passwordStrengthMessage.value = ''
} else if (confirmPassword.value && newPassword.value !== confirmPassword.value) {
passwordMismatchMessage.value = t('Passwords do not match')
passwordStrengthMessage.value = ''
} else {
passwordMismatchMessage.value = ''
}
}
// Reset password form validation
const isResetPasswordFormValid = computed(() => {
const hasNewPassword = newPassword.value && newPassword.value.length > 0
const hasConfirmPassword = confirmPassword.value && confirmPassword.value.length > 0
const passwordsMatch = newPassword.value === confirmPassword.value
const passwordsDifferent = oldPassword.value !== newPassword.value
return oldPassword.value && hasNewPassword && hasConfirmPassword && passwordsMatch && passwordsDifferent
})
// Handle reset password
const handleResetPassword = async () => {
passwordError.value = ''
if (!oldPassword.value) {
passwordError.value = t('Please enter current password')
return
}
if (!newPassword.value) {
passwordError.value = t('Please enter new password')
return
}
if (newPassword.value !== confirmPassword.value) {
passwordError.value = t('Passwords do not match')
return
}
if (oldPassword.value === newPassword.value) {
passwordError.value = t('New password cannot be the same as current password')
return
}
resetPasswordLoading.value = true
try {
const result = await updatePassword({
old_password: oldPassword.value,
new_password: newPassword.value,
confirm_password: confirmPassword.value,
logout_all_sessions: 1
})
if (result.success) {
message.success(t('Password updated successfully'))
closeResetPasswordDialog()
if (result.redirectUrl) {
setTimeout(() => {
window.location.href = result.redirectUrl!
}, 1000)
}
} else {
if (result.message?.includes('incorrect') || result.message?.includes('401')) {
passwordError.value = t('Current password is incorrect')
} else {
passwordError.value = result.message || t('Failed to update password')
}
}
} catch (error: any) {
passwordError.value = error.message || t('Failed to update password')
} finally {
resetPasswordLoading.value = false
}
}
// 关闭重置密码对话框
const closeResetPasswordDialog = () => {
showResetPasswordDialog.value = false
oldPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
passwordError.value = ''
passwordStrengthMessage.value = ''
passwordStrengthClass.value = ''
passwordMismatchMessage.value = ''
if (passwordStrengthTimeout.value) {
clearTimeout(passwordStrengthTimeout.value)
passwordStrengthTimeout.value = null
}
}
// Partner code change handler (debounce)
let partnerCodeDebounceTimer: any = null
const handlePartnerCodeChange = () => {
partnerExists.value = false
partnerCodeError.value = ''
partnerName.value = ''
if (partnerCodeDebounceTimer) {
clearTimeout(partnerCodeDebounceTimer)
}
if (!partnerCode.value) {
return
}
partnerCodeDebounceTimer = setTimeout(async () => {
try {
const result = await validatePartnerCode(partnerCode.value)
if (result.success && result.isValid) {
partnerExists.value = true
partnerName.value = result.partnerName || ''
} else {
partnerCodeError.value = `${partnerCode.value} ${t('is an invalid referral code')}`
}
} catch (error: any) {
partnerCodeError.value = t('Failed to validate partner code')
}
}, 500)
}
// Add partner code
const handleAddPartnerCode = async () => {
if (!partnerExists.value) {
return
}
addPartnerCodeLoading.value = true
try {
const result = await addPartnerCode(partnerCode.value)
if (result.success) {
if (result.isAlreadySent) {
message.error(t('Approval Request has already been sent to Partner'))
} else {
message.success(t('Approval Request has been sent to Partner'))
}
closeAddPartnerCodeDialog()
await loadUserAccountInfo()
} else {
message.error(result.message || t('Failed to add Partner Code'))
}
} catch (error: any) {
message.error(error.message || t('Failed to add Partner Code'))
} finally {
addPartnerCodeLoading.value = false
}
}
// Close add partner code dialog
const closeAddPartnerCodeDialog = () => {
showAddPartnerCodeDialog.value = false
partnerCode.value = ''
partnerExists.value = false
partnerName.value = ''
partnerCodeError.value = ''
if (partnerCodeDebounceTimer) {
clearTimeout(partnerCodeDebounceTimer)
partnerCodeDebounceTimer = null
}
}
// Load partner name
const loadPartnerName = async () => {
if (teamInfo.value?.partner_email) {
const result = await getPartnerName(teamInfo.value.partner_email)
if (result.success && result.data) {
partnerBillingName.value = result.data
}
}
}
// Load 2FA QR code
const load2FAQRCode = async () => {
if (is2FAEnabled.value) {
return // If already enabled, no need to load QR code
}
loadingQRCode.value = true
try {
const result = await get2FAQRCodeUrl()
if (result.success && result.data) {
qrCodeUrl.value = result.data
} else {
twoFAError.value = result.message || t('Failed to load QR code')
}
} catch (error: any) {
twoFAError.value = error.message || t('Failed to load QR code')
} finally {
loadingQRCode.value = false
}
}
// Enable 2FA
const handleEnable2FA = async () => {
if (!totpCode.value) {
twoFAError.value = t('Please enter the code from the authenticator app')
return
}
loading2FA.value = true
twoFAError.value = ''
try {
const result = await enable2FA(totpCode.value)
if (result.success) {
message.success(t('Two-factor authentication enabled successfully'))
totpCode.value = ''
close2FADialog()
// Reload user information
setTimeout(async () => {
await loadUserAccountInfo()
}, 500)
} else {
if (result.message?.includes('Invalid TOTP') || result.message?.includes('Invalid')) {
twoFAError.value = t('Invalid TOTP code, please try again')
} else {
twoFAError.value = result.message || t('Failed to enable two-factor authentication')
}
}
} catch (error: any) {
if (error.message?.includes('Invalid TOTP') || error.message?.includes('Invalid')) {
twoFAError.value = t('Invalid TOTP code, please try again')
} else {
twoFAError.value = error.message || t('Failed to enable two-factor authentication')
}
} finally {
loading2FA.value = false
}
}
// Disable 2FA
const handleDisable2FA = async () => {
if (!totpCode.value) {
twoFAError.value = t('Please enter the code from the authenticator app')
return
}
loading2FA.value = true
twoFAError.value = ''
try {
const result = await disable2FA(totpCode.value)
if (result.success) {
message.success(t('Two-factor authentication disabled successfully'))
totpCode.value = ''
close2FADialog()
// 重新加载用户信息
setTimeout(async () => {
await loadUserAccountInfo()
}, 500)
} else {
if (result.message?.includes('Invalid TOTP') || result.message?.includes('Invalid')) {
twoFAError.value = t('Invalid TOTP code, please try again')
} else {
twoFAError.value = result.message || t('Failed to disable two-factor authentication')
}
}
} catch (error: any) {
if (error.message?.includes('Invalid TOTP') || error.message?.includes('Invalid')) {
twoFAError.value = t('Invalid TOTP code, please try again')
} else {
twoFAError.value = error.message || t('Failed to disable two-factor authentication')
}
} finally {
loading2FA.value = false
}
}
// Close 2FA dialog
const close2FADialog = () => {
show2FADialog.value = false
totpCode.value = ''
twoFAError.value = ''
qrCodeUrl.value = ''
showSetupKey.value = false
}
// Watch 2FA dialog open, load QR code
watch(show2FADialog, (newVal) => {
if (newVal && !is2FAEnabled.value) {
load2FAQRCode()
}
})
// Create API Secret
const handleCreateSecret = async () => {
createSecretLoading.value = true
try {
const result = await createApiSecret()
if (result.success && result.data) {
createSecretData.value = result.data
message.success(t('API key created successfully'))
await loadUserAccountInfo()
} else {
message.error(result.message || t('Failed to create API key'))
}
} catch (error: any) {
message.error(error.message || t('Failed to create API key'))
} finally {
createSecretLoading.value = false
}
}
// Close create Secret dialog
const closeCreateSecretDialog = () => {
showCreateSecretDialog.value = false
createSecretData.value = null
loadUserAccountInfo()
}
// Load SSH key list
const loadSSHKeys = async () => {
sshKeysLoading.value = true
try {
const result = await getUserSSHKeys()
if (result.success && result.data) {
sshKeys.value = result.data
}
} catch (error: any) {
// Failed to load SSH key list
} finally {
sshKeysLoading.value = false
}
}
// Add SSH key
const handleAddSSHKey = async () => {
if (!sshKeyValue.value.trim()) {
sshKeyError.value = t('SSH key is required')
return
}
sshKeyError.value = ''
addSSHKeyLoading.value = true
try {
const result = await addSSHKey(sshKeyValue.value.trim())
if (result.success) {
message.success(t('SSH key added successfully'))
showAddSSHKeyDialog.value = false
sshKeyValue.value = ''
await loadSSHKeys()
} else {
sshKeyError.value = result.message || t('Failed to add SSH key')
}
} catch (error: any) {
sshKeyError.value = error.message || t('Failed to add SSH key')
} finally {
addSSHKeyLoading.value = false
}
}
// Set default SSH key
const handleSetDefaultSSHKey = async (keyName: string) => {
try {
const result = await markKeyAsDefault(keyName)
if (result.success) {
message.success(t('SSH key updated successfully'))
await loadSSHKeys()
} else {
message.error(result.message || t('Failed to set SSH key as default'))
}
} catch (error: any) {
message.error(error.message || t('Failed to set SSH key as default'))
}
}
// Delete SSH key
const handleDeleteSSHKey = async (keyName: string) => {
dialog.warning({
title: t('Delete SSH Key'),
content: t('Are you sure you want to delete this SSH key?'),
positiveText: t('Delete'),
negativeText: t('Cancel'),
onPositiveClick: async () => {
try {
const result = await deleteSSHKey(keyName)
if (result.success) {
message.success(t('SSH key deleted successfully'))
await loadSSHKeys()
} else {
message.error(result.message || t('Failed to delete SSH key'))
}
} catch (error: any) {
message.error(error.message || t('Failed to delete SSH key'))
}
}
})
}
// 加载功能标志
const loadFeatureFlags = async () => {
if (!isAdmin.value) return
try {
const result = await getFeatureFlags()
if (result.success && result.data) {
Object.assign(featureFlags, result.data)
}
} catch (error: any) {
console.error('加载功能标志失败:', error)
}
}
// 保存功能标志
const saveFeatureFlags = async () => {
featureFlagsSaving.value = true
try {
const result = await updateFeatureFlags(featureFlags)
if (result.success) {
message.success(t('Feature flags updated successfully'))
await loadFeatureFlags()
} else {
message.error(result.message || t('Failed to update feature flags'))
}
} catch (error: any) {
message.error(error.message || t('Failed to update feature flags'))
} finally {
featureFlagsSaving.value = false
}
}
onMounted(async () => {
initLocale()
systemSettings.language = getCurrentLocale()
// Initialize timezone options (use grouped display, industry best practice)
try {
timezoneOptions.value = getGroupedTimezoneOptions()
} catch (error) {
timezoneError.value = error instanceof Error ? error.message : String(error)
message.error(t('Failed to load timezone options') + ': ' + timezoneError.value)
}
// Load user account information (including Team information)
await loadUserAccountInfo()
// Load SSH key list
await loadSSHKeys()
// Load feature flags
await loadFeatureFlags()
// If system administrator, load environment configuration (silent load, no message)
if (isAdmin.value) {
await loadEnvironmentConfig(false)
}
})
</script>
<style scoped>
.settings-page {
width: 100%;
padding: 0 16px;
}
.page-header {
margin-bottom: 24px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
/* 个人资料样式 */
.profile-header {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 0;
}
.profile-info {
flex: 1;
}
.profile-name {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px 0;
color: #1f2937;
}
.profile-email {
font-size: 14px;
color: #6b7280;
margin: 0;
}
.profile-details {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 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 {
margin-left: auto;
}
/* API 相关样式 */
.api-description {
font-size: 14px;
color: #374151;
}
.api-link {
color: #2563eb;
text-decoration: underline;
margin-left: 4px;
}
.api-link:hover {
color: #1d4ed8;
}
.text-hint {
font-size: 14px;
color: #6b7280;
}
/* SSH 密钥样式 */
.ssh-actions {
display: flex;
justify-content: flex-start;
}
/* 推荐有礼和合作伙伴卡片样式 */
.referral-card,
.partner-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;
}
.referral-card:hover,
.partner-card:hover {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* 双因素认证对话框样式 */
.tfa-qr-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 24px 0;
background: #fafafa;
border-radius: 8px;
margin-bottom: 24px;
}
.tfa-qr-code {
width: 240px;
height: 240px;
border: 4px solid #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
background: #fff;
}
.tfa-steps-card {
border-radius: 8px;
}
.tfa-steps-title {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 0 0 12px 0;
}
.tfa-steps-list {
margin: 0 0 16px 0;
padding-left: 24px;
color: #4b5563;
font-size: 14px;
line-height: 1.6;
}
.tfa-steps-list li {
margin-bottom: 8px;
}
.tfa-steps-list li:last-child {
margin-bottom: 0;
}
.tfa-warning {
margin-top: 16px;
}
.tfa-setup-key-card {
border-radius: 8px;
background: #f9fafb;
}
.tfa-setup-key-title {
font-size: 14px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px 0;
}
.tfa-setup-key-value {
font-size: 13px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
color: #374151;
word-break: break-all;
margin: 0;
padding: 12px;
background: #fff;
border-radius: 4px;
border: 1px solid #e5e7eb;
}
/* 保存按钮 - 使用柔和的品牌色系,与 pagetype 详情页保存按钮一致 */
.save-btn-brand {
background: #e6f8f0 !important;
border: 1px solid #1fc76f !important;
color: #0d684b !important;
}
.save-btn-brand :deep(.n-button__border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand:hover {
background: #dcfce7 !important;
border-color: #1fc76f !important;
border: 1px solid #1fc76f !important;
color: #166534 !important;
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15) !important;
}
.save-btn-brand:hover :deep(.n-button__border),
.save-btn-brand:hover :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand:focus {
background: #dcfce7 !important;
border-color: #1fc76f !important;
border: 1px solid #1fc76f !important;
color: #166534 !important;
box-shadow: 0 0 0 2px rgba(31, 199, 111, 0.2) !important;
}
.save-btn-brand:focus :deep(.n-button__border),
.save-btn-brand:focus :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
.save-btn-brand:active {
background: #1fc76f !important;
border-color: #1fc76f !important;
border: 1px solid #1fc76f !important;
color: white !important;
box-shadow: 0 1px 4px rgba(31, 199, 111, 0.2) !important;
}
.save-btn-brand:active :deep(.n-button__border),
.save-btn-brand:active :deep(.n-button__state-border) {
border-color: transparent !important;
}
.save-btn-brand:disabled {
background: #f1f5f9 !important;
border: 1px solid #e2e8f0 !important;
border-color: #e2e8f0 !important;
color: #94a3b8 !important;
opacity: 0.6 !important;
cursor: not-allowed !important;
}
.save-btn-brand:disabled :deep(.n-button__border),
.save-btn-brand:disabled :deep(.n-button__state-border) {
border: none !important;
border-color: transparent !important;
}
/* 响应式设计 */
@media (max-width: 1200px) {
.settings-page :deep(.n-grid) {
grid-template-columns: 1fr !important;
}
}
@media (max-width: 768px) {
.settings-page {
padding: 0 12px;
}
.page-header {
margin-bottom: 16px;
}
.page-title {
font-size: 24px;
}
.page-description {
font-size: 14px;
}
}
@media (max-width: 480px) {
.settings-page {
padding: 0 8px;
}
.page-title {
font-size: 20px;
}
.page-description {
font-size: 13px;
}
}
</style>