- 删除所有调试日志(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
2062 lines
65 KiB
Vue
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>
|