feat: 系统设置页面增加个人资料和开发者标签页
- 使用 naive-ui Tabs 组件实现标签页布局,对齐 jcloud dashboard 前端 - 个人资料标签页:显示和编辑用户信息(用户名、手机、邮箱)、邮箱通知设置 - 开发者标签页:API 访问(创建/重新生成 API Key)、SSH 密钥管理、功能标志配置 - 创建账户相关 API 接口文件(account.ts),使用 jcloud.api.account.get 获取用户信息 - 创建 ClickToCopyField 组件用于一键复制文本内容 - API 接口与 jcloud dashboard 保持一致,实现相同的功能
This commit is contained in:
parent
4b3ebaa7ed
commit
911ae5e53b
259
src/shared/api/account.ts
Normal file
259
src/shared/api/account.ts
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import { get_session_api_headers } from './auth'
|
||||||
|
|
||||||
|
// 创建或重新生成 API Secret
|
||||||
|
export const createApiSecret = async (): Promise<{ success: boolean; data?: { api_key: string; api_secret: string }; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`/api/action/jcloud.api.account.create_api_secret`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
headers: get_session_api_headers(),
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = response.data
|
||||||
|
if (result?.message) {
|
||||||
|
return { success: true, data: result.message }
|
||||||
|
}
|
||||||
|
return { success: true, data: result }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.detail || error.response?.data?.message || error.message || '创建 API Secret 失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新个人资料
|
||||||
|
export interface UpdateProfileParams {
|
||||||
|
first_name?: string
|
||||||
|
last_name?: string
|
||||||
|
username?: string
|
||||||
|
mobile_no?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateProfile = async (params: UpdateProfileParams): Promise<{ success: boolean; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`/api/action/jcloud.api.account.update_profile`,
|
||||||
|
params,
|
||||||
|
{
|
||||||
|
headers: get_session_api_headers(),
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return { success: true, message: response.data?.message || '更新成功' }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.detail || error.response?.data?.message || error.message || '更新个人资料失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取邮箱列表
|
||||||
|
export const getEmails = async (): Promise<{ success: boolean; data?: Array<{ type: string; value: string }>; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`/api/action/jcloud.api.account.get_emails`,
|
||||||
|
{
|
||||||
|
headers: get_session_api_headers(),
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = response.data
|
||||||
|
return { success: true, data: result?.message || result }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.detail || error.response?.data?.message || error.message || '获取邮箱列表失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新邮箱
|
||||||
|
export const updateEmails = async (data: Array<{ type: string; value: string }>): Promise<{ success: boolean; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`/api/action/jcloud.api.account.update_emails`,
|
||||||
|
{ data: JSON.stringify(data) },
|
||||||
|
{
|
||||||
|
headers: get_session_api_headers(),
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return { success: true, message: response.data?.message || '更新成功' }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.detail || error.response?.data?.message || error.message || '更新邮箱失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户 SSH 密钥列表
|
||||||
|
export const getUserSSHKeys = async (): Promise<{ success: boolean; data?: Array<any>; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`/api/action/jcloud.api.account.get_user_ssh_keys`,
|
||||||
|
{
|
||||||
|
headers: get_session_api_headers(),
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = response.data
|
||||||
|
return { success: true, data: result?.message || result || [] }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.detail || error.response?.data?.message || error.message || '获取 SSH 密钥列表失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 SSH 密钥
|
||||||
|
export const addSSHKey = async (sshPublicKey: string): Promise<{ success: boolean; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`/api/action/jcloud.api.client.insert`,
|
||||||
|
{
|
||||||
|
pg: {
|
||||||
|
pagetype: 'User SSH Key',
|
||||||
|
ssh_public_key: sshPublicKey,
|
||||||
|
user: '' // 后端会自动获取当前用户
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: get_session_api_headers(),
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return { success: true, message: response.data?.message || '添加成功' }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.detail || error.response?.data?.message || error.message || '添加 SSH 密钥失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 SSH 密钥为默认
|
||||||
|
export const markKeyAsDefault = async (keyName: string): Promise<{ success: boolean; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`/api/action/jcloud.api.account.mark_key_as_default`,
|
||||||
|
{ key_name: keyName },
|
||||||
|
{
|
||||||
|
headers: get_session_api_headers(),
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return { success: true, message: response.data?.message || '设置成功' }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.detail || error.response?.data?.message || error.message || '设置默认密钥失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 SSH 密钥
|
||||||
|
export const deleteSSHKey = async (keyName: string): Promise<{ success: boolean; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`/api/action/jcloud.api.client.delete`,
|
||||||
|
{
|
||||||
|
pagetype: 'User SSH Key',
|
||||||
|
name: keyName
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: get_session_api_headers(),
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return { success: true, message: response.data?.message || '删除成功' }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.detail || error.response?.data?.message || error.message || '删除 SSH 密钥失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取功能标志
|
||||||
|
export const getFeatureFlags = async (): Promise<{ success: boolean; data?: Record<string, boolean>; message?: string }> => {
|
||||||
|
try {
|
||||||
|
// 这里需要根据实际 API 调整
|
||||||
|
const response = await axios.get(
|
||||||
|
`/api/action/jcloud.api.account.get_feature_flags`,
|
||||||
|
{
|
||||||
|
headers: get_session_api_headers(),
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = response.data
|
||||||
|
return { success: true, data: result?.message || result }
|
||||||
|
} catch (error: any) {
|
||||||
|
// 如果 API 不存在,返回空对象
|
||||||
|
return { success: false, data: {}, message: '功能标志 API 不可用' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新功能标志
|
||||||
|
export const updateFeatureFlags = async (values: Record<string, boolean>): Promise<{ success: boolean; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
`/api/action/jcloud.api.account.update_feature_flags`,
|
||||||
|
{ values },
|
||||||
|
{
|
||||||
|
headers: get_session_api_headers(),
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return { success: true, message: response.data?.message || '更新成功' }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.detail || error.response?.data?.message || error.message || '更新功能标志失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息(包含 API Key)
|
||||||
|
// 使用 jcloud.api.account.get API 获取账户信息
|
||||||
|
export const getUserAccountInfo = async (): Promise<{ success: boolean; data?: any; message?: string }> => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(
|
||||||
|
`/api/action/jcloud.api.account.get`,
|
||||||
|
{
|
||||||
|
headers: get_session_api_headers(),
|
||||||
|
withCredentials: true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = response.data?.message || response.data
|
||||||
|
|
||||||
|
if (result?.user) {
|
||||||
|
return { success: true, data: result.user }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, message: 'API 返回的数据中未找到用户信息' }
|
||||||
|
} catch (error: any) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: error.response?.data?.detail || error.response?.data?.message || error.message || '获取用户信息失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/shared/components/ClickToCopyField.vue
Normal file
78
src/shared/components/ClickToCopyField.vue
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="click-to-copy-field">
|
||||||
|
<n-input
|
||||||
|
:value="textContent"
|
||||||
|
readonly
|
||||||
|
:placeholder="placeholder"
|
||||||
|
class="copy-input"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<n-button
|
||||||
|
quaternary
|
||||||
|
size="small"
|
||||||
|
@click="handleCopy"
|
||||||
|
:loading="copying"
|
||||||
|
class="copy-button"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<n-icon>
|
||||||
|
<Icon :icon="copied ? 'tabler:check' : 'tabler:copy'" />
|
||||||
|
</n-icon>
|
||||||
|
</template>
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { NInput, NButton, NIcon, useMessage } from 'naive-ui'
|
||||||
|
import { Icon } from '@iconify/vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
textContent: string
|
||||||
|
placeholder?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
placeholder: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const message = useMessage()
|
||||||
|
const copying = ref(false)
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (!props.textContent) return
|
||||||
|
|
||||||
|
copying.value = true
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.textContent)
|
||||||
|
copied.value = true
|
||||||
|
message.success('已复制到剪贴板')
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false
|
||||||
|
}, 2000)
|
||||||
|
} catch (error) {
|
||||||
|
message.error('复制失败')
|
||||||
|
} finally {
|
||||||
|
copying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.click-to-copy-field {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-input {
|
||||||
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -4,6 +4,155 @@
|
|||||||
<h1 class="page-title">{{ t('Settings') }}</h1>
|
<h1 class="page-title">{{ t('Settings') }}</h1>
|
||||||
</div>
|
</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('Phone') }}:</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>
|
||||||
|
|
||||||
|
<!-- 邮箱通知 -->
|
||||||
|
<n-card :title="t('Email Notifications')">
|
||||||
|
<template #header-extra>
|
||||||
|
<n-button quaternary @click="showEmailsEditDialog = true">
|
||||||
|
<template #icon>
|
||||||
|
<n-icon><Icon icon="tabler:edit" /></n-icon>
|
||||||
|
</template>
|
||||||
|
{{ t('Edit') }}
|
||||||
|
</n-button>
|
||||||
|
</template>
|
||||||
|
<n-list v-if="emailData && emailData.length > 0">
|
||||||
|
<n-list-item v-for="email in emailData" :key="email.type">
|
||||||
|
<n-thing>
|
||||||
|
<template #header>{{ emailLabelMap[email.type] || email.type }}</template>
|
||||||
|
<template #description>{{ email.value }}</template>
|
||||||
|
</n-thing>
|
||||||
|
</n-list-item>
|
||||||
|
</n-list>
|
||||||
|
<n-empty v-else :description="t('No email notifications configured')" />
|
||||||
|
</n-card>
|
||||||
|
</n-space>
|
||||||
|
</n-tab-pane>
|
||||||
|
|
||||||
|
<!-- 开发者标签页 -->
|
||||||
|
<n-tab-pane name="developer" :tab="t('Developer')">
|
||||||
|
<n-space vertical :size="24">
|
||||||
|
<!-- API 访问 -->
|
||||||
|
<n-card :title="t('API Access')">
|
||||||
|
<n-space vertical :size="20">
|
||||||
|
<div class="api-description">
|
||||||
|
<span>{{ t('API key and API secret can be used to access') }}</span>
|
||||||
|
<a href="/docs/api" target="_blank" class="api-link">{{ t('Jingrow API') }}</a>
|
||||||
|
</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 密钥 -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 功能标志(仅管理员可见) -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 系统设置标签页 -->
|
||||||
|
<n-tab-pane name="system" :tab="t('System Settings')">
|
||||||
<n-grid :cols="2" :x-gap="24" :y-gap="24">
|
<n-grid :cols="2" :x-gap="24" :y-gap="24">
|
||||||
<!-- 左栏:系统设置 -->
|
<!-- 左栏:系统设置 -->
|
||||||
<n-grid-item>
|
<n-grid-item>
|
||||||
@ -186,11 +335,129 @@
|
|||||||
</n-card>
|
</n-card>
|
||||||
</n-grid-item>
|
</n-grid-item>
|
||||||
</n-grid>
|
</n-grid>
|
||||||
|
</n-tab-pane>
|
||||||
|
</n-tabs>
|
||||||
|
|
||||||
|
<!-- 编辑个人资料对话框 -->
|
||||||
|
<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('First Name')">
|
||||||
|
<n-input v-model:value="profileForm.first_name" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item :label="t('Last Name')">
|
||||||
|
<n-input v-model:value="profileForm.last_name" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item :label="t('Username')">
|
||||||
|
<n-input v-model:value="profileForm.username" />
|
||||||
|
</n-form-item>
|
||||||
|
<n-form-item :label="t('Phone')">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 编辑邮箱对话框 -->
|
||||||
|
<n-modal v-model:show="showEmailsEditDialog" preset="card" :title="t('Edit Emails')" :style="{ width: '600px' }">
|
||||||
|
<n-form :model="emailForm" label-placement="left" label-width="150px">
|
||||||
|
<n-form-item
|
||||||
|
v-for="email in emailForm"
|
||||||
|
:key="email.type"
|
||||||
|
:label="emailLabelMap[email.type] || email.type"
|
||||||
|
>
|
||||||
|
<n-input v-model:value="email.value" />
|
||||||
|
</n-form-item>
|
||||||
|
</n-form>
|
||||||
|
<template #footer>
|
||||||
|
<n-space justify="end">
|
||||||
|
<n-button @click="showEmailsEditDialog = false">{{ t('Cancel') }}</n-button>
|
||||||
|
<n-button type="primary" :loading="emailsSaving" @click="saveEmails">
|
||||||
|
{{ t('Save Changes') }}
|
||||||
|
</n-button>
|
||||||
|
</n-space>
|
||||||
|
</template>
|
||||||
|
</n-modal>
|
||||||
|
|
||||||
|
<!-- 创建 API Secret 对话框 -->
|
||||||
|
<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') }}
|
||||||
|
<a href="/docs/api" target="_blank" class="api-link">{{ t('Jingrow API') }}</a>.
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 添加 SSH 密钥对话框 -->
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, onMounted, computed, ref } from 'vue'
|
import { reactive, onMounted, computed, ref, h } from 'vue'
|
||||||
import {
|
import {
|
||||||
NGrid,
|
NGrid,
|
||||||
NGridItem,
|
NGridItem,
|
||||||
@ -207,6 +474,15 @@ import {
|
|||||||
NCollapseItem,
|
NCollapseItem,
|
||||||
NSpace,
|
NSpace,
|
||||||
NIcon,
|
NIcon,
|
||||||
|
NTabs,
|
||||||
|
NTabPane,
|
||||||
|
NAvatar,
|
||||||
|
NList,
|
||||||
|
NListItem,
|
||||||
|
NThing,
|
||||||
|
NEmpty,
|
||||||
|
NDataTable,
|
||||||
|
NModal,
|
||||||
useMessage,
|
useMessage,
|
||||||
useDialog
|
useDialog
|
||||||
} from 'naive-ui'
|
} from 'naive-ui'
|
||||||
@ -215,15 +491,32 @@ import { getCurrentLocale, setLocale, locales, initLocale, t } from '../../share
|
|||||||
import { useAuthStore } from '../../shared/stores/auth'
|
import { useAuthStore } from '../../shared/stores/auth'
|
||||||
import { getEnvironmentConfig, updateEnvironmentConfig, restartEnvironment, type EnvironmentConfig } from '../../shared/api/system'
|
import { getEnvironmentConfig, updateEnvironmentConfig, restartEnvironment, type EnvironmentConfig } from '../../shared/api/system'
|
||||||
import { getCurrentTimezone, getGroupedTimezoneOptions } from '../../shared/utils/timezone'
|
import { getCurrentTimezone, getGroupedTimezoneOptions } from '../../shared/utils/timezone'
|
||||||
|
import {
|
||||||
|
createApiSecret,
|
||||||
|
updateProfile,
|
||||||
|
getEmails,
|
||||||
|
updateEmails,
|
||||||
|
getUserSSHKeys,
|
||||||
|
addSSHKey,
|
||||||
|
markKeyAsDefault,
|
||||||
|
deleteSSHKey,
|
||||||
|
getFeatureFlags,
|
||||||
|
updateFeatureFlags,
|
||||||
|
getUserAccountInfo
|
||||||
|
} from '../../shared/api/account'
|
||||||
|
import ClickToCopyField from '../../shared/components/ClickToCopyField.vue'
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// 当前激活的标签页
|
||||||
|
const activeTab = ref('profile')
|
||||||
|
|
||||||
// 检查是否为系统管理员
|
// 检查是否为系统管理员
|
||||||
const isAdmin = computed(() => {
|
const isAdmin = computed(() => {
|
||||||
const user = authStore.user
|
const user = authStore.user
|
||||||
return user?.username === 'Administrator' || user?.id === 'Administrator'
|
return user?.user === 'Administrator' || user?.user_type === 'System User'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 检查是否为 local 运行模式
|
// 检查是否为 local 运行模式
|
||||||
@ -231,6 +524,97 @@ const isLocalMode = computed(() => {
|
|||||||
return envConfig.run_mode === 'local'
|
return envConfig.run_mode === 'local'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 用户账户信息
|
||||||
|
const userAccountInfo = ref<any>(null)
|
||||||
|
const userAccountInfoLoading = ref(false)
|
||||||
|
|
||||||
|
// 个人资料相关
|
||||||
|
const showProfileEditDialog = ref(false)
|
||||||
|
const profileForm = reactive({
|
||||||
|
first_name: '',
|
||||||
|
last_name: '',
|
||||||
|
username: '',
|
||||||
|
mobile_no: '',
|
||||||
|
email: ''
|
||||||
|
})
|
||||||
|
const profileSaving = ref(false)
|
||||||
|
|
||||||
|
// 邮箱相关
|
||||||
|
const emailData = ref<Array<{ type: string; value: string }>>([])
|
||||||
|
const showEmailsEditDialog = ref(false)
|
||||||
|
const emailForm = ref<Array<{ type: string; value: string }>>([])
|
||||||
|
const emailsSaving = ref(false)
|
||||||
|
const emailLabelMap: Record<string, string> = {
|
||||||
|
invoices: t('Send invoices to'),
|
||||||
|
marketplace_notifications: t('Send marketplace emails to')
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Secret 相关
|
||||||
|
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 密钥相关
|
||||||
|
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 密钥表格列
|
||||||
|
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') })
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
// 功能标志相关
|
||||||
|
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(() => {
|
||||||
|
// 这里需要根据实际数据判断是否有变更
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
// 环境配置
|
// 环境配置
|
||||||
const envConfig = reactive<Partial<EnvironmentConfig>>({})
|
const envConfig = reactive<Partial<EnvironmentConfig>>({})
|
||||||
const envConfigLoading = ref(false)
|
const envConfigLoading = ref(false)
|
||||||
@ -393,6 +777,221 @@ const handleRestartEnvironment = () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载用户账户信息
|
||||||
|
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 || ''
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('加载用户账户信息失败:', error)
|
||||||
|
} finally {
|
||||||
|
userAccountInfoLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载邮箱列表
|
||||||
|
const loadEmails = async () => {
|
||||||
|
try {
|
||||||
|
const result = await getEmails()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
emailData.value = result.data
|
||||||
|
emailForm.value = JSON.parse(JSON.stringify(result.data))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('加载邮箱列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存个人资料
|
||||||
|
const saveProfile = async () => {
|
||||||
|
profileSaving.value = true
|
||||||
|
try {
|
||||||
|
const result = await updateProfile({
|
||||||
|
first_name: profileForm.first_name,
|
||||||
|
last_name: profileForm.last_name,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存邮箱
|
||||||
|
const saveEmails = async () => {
|
||||||
|
emailsSaving.value = true
|
||||||
|
try {
|
||||||
|
const result = await updateEmails(emailForm.value)
|
||||||
|
if (result.success) {
|
||||||
|
message.success(t('Emails updated successfully'))
|
||||||
|
showEmailsEditDialog.value = false
|
||||||
|
await loadEmails()
|
||||||
|
} else {
|
||||||
|
message.error(result.message || t('Failed to update emails'))
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error.message || t('Failed to update emails'))
|
||||||
|
} finally {
|
||||||
|
emailsSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭创建 Secret 对话框
|
||||||
|
const closeCreateSecretDialog = () => {
|
||||||
|
showCreateSecretDialog.value = false
|
||||||
|
createSecretData.value = null
|
||||||
|
loadUserAccountInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载 SSH 密钥列表
|
||||||
|
const loadSSHKeys = async () => {
|
||||||
|
sshKeysLoading.value = true
|
||||||
|
try {
|
||||||
|
const result = await getUserSSHKeys()
|
||||||
|
if (result.success && result.data) {
|
||||||
|
sshKeys.value = result.data
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('加载 SSH 密钥列表失败:', error)
|
||||||
|
} finally {
|
||||||
|
sshKeysLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加 SSH 密钥
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置默认 SSH 密钥
|
||||||
|
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'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 SSH 密钥
|
||||||
|
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 () => {
|
onMounted(async () => {
|
||||||
initLocale()
|
initLocale()
|
||||||
systemSettings.language = getCurrentLocale()
|
systemSettings.language = getCurrentLocale()
|
||||||
@ -406,6 +1005,18 @@ onMounted(async () => {
|
|||||||
console.error('Failed to load timezone options:', error)
|
console.error('Failed to load timezone options:', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载用户账户信息
|
||||||
|
await loadUserAccountInfo()
|
||||||
|
|
||||||
|
// 加载邮箱列表
|
||||||
|
await loadEmails()
|
||||||
|
|
||||||
|
// 加载 SSH 密钥列表
|
||||||
|
await loadSSHKeys()
|
||||||
|
|
||||||
|
// 加载功能标志
|
||||||
|
await loadFeatureFlags()
|
||||||
|
|
||||||
// 如果是系统管理员,加载环境配置(静默加载,不显示消息)
|
// 如果是系统管理员,加载环境配置(静默加载,不显示消息)
|
||||||
if (isAdmin.value) {
|
if (isAdmin.value) {
|
||||||
await loadEnvironmentConfig(false)
|
await loadEnvironmentConfig(false)
|
||||||
@ -430,6 +1041,91 @@ onMounted(async () => {
|
|||||||
margin: 0;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/* 保存按钮 - 使用柔和的品牌色系,与 pagetype 详情页保存按钮一致 */
|
/* 保存按钮 - 使用柔和的品牌色系,与 pagetype 详情页保存按钮一致 */
|
||||||
.save-btn-brand {
|
.save-btn-brand {
|
||||||
background: #e6f8f0 !important;
|
background: #e6f8f0 !important;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user