jingrowtools/src/views/settings/DeveloperSettings.vue

394 lines
12 KiB
Vue

<template>
<div class="developer-settings">
<n-space vertical :size="24">
<!-- API Access 卡片 -->
<n-card :title="t('API Access')" class="settings-card">
<n-space vertical :size="20">
<div class="flex items-center justify-between flex-wrap gap-4">
<div class="text-base text-gray-700">
{{ t('API key and API secret can be used to access') }}
<a href="/docs/api" class="text-primary underline" target="_blank">
{{ t('Jingrow API') }}
</a>
</div>
<n-button type="primary" @click="showCreateSecretDialog = true" :size="buttonSize">
{{ apiKeyButtonLabel }}
</n-button>
</div>
<div v-if="apiKey">
<ClickToCopyField :textContent="apiKey" />
</div>
<div v-else class="text-base text-gray-600">
{{ 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')" class="settings-card">
<n-space vertical :size="16">
<n-button type="primary" @click="showAddSSHKeyDialog = true" :size="buttonSize">
<template #icon>
<n-icon><Icon icon="tabler:plus" /></n-icon>
</template>
{{ t('Add SSH Key') }}
</n-button>
<n-data-table
v-if="sshKeys.length > 0"
:columns="sshKeyColumns"
:data="sshKeys"
:loading="loadingSSHKeys"
:pagination="false"
/>
<n-empty v-else :description="t('No SSH keys')" />
</n-space>
</n-card>
</n-space>
<!-- API Key 创建/重新生成弹窗 -->
<n-modal
v-model:show="showCreateSecretDialog"
preset="card"
:title="t('API Access')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
>
<template #header>
<span class="text-lg font-semibold">{{ t('API Access') }}</span>
</template>
<n-space vertical :size="20">
<div v-if="apiSecretData">
<n-alert type="warning" class="mb-4">
{{ t('Please copy the API key immediately. You will not be able to view it again!') }}
</n-alert>
<n-form-item :label="t('API Key')">
<ClickToCopyField :textContent="apiSecretData.api_key" />
</n-form-item>
<n-form-item :label="t('API Secret')">
<ClickToCopyField :textContent="apiSecretData.api_secret" />
</n-form-item>
</div>
<div v-else class="text-base text-gray-700">
{{ t('API key and API secret can be used to access') }}
<a href="/docs/api" class="text-primary underline" target="_blank">
{{ t('Jingrow API') }}
</a>.
</div>
</n-space>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button @click="handleCloseCreateSecretDialog" :block="isMobile" :size="buttonSize">
{{ t('Cancel') }}
</n-button>
<n-button
type="primary"
:loading="creatingSecret"
:disabled="!!apiSecretData"
@click="handleCreateSecret"
:block="isMobile"
:size="buttonSize"
>
{{ apiKey ? t('Regenerate API Key') : t('Create New API Key') }}
</n-button>
</n-space>
</template>
</n-modal>
<!-- SSH Key 添加弹窗 -->
<n-modal
v-model:show="showAddSSHKeyDialog"
preset="card"
:title="t('Add New SSH Key')"
:style="modalStyle"
:mask-closable="true"
:close-on-esc="true"
>
<template #header>
<span class="text-lg font-semibold">{{ t('Add New SSH Key') }}</span>
</template>
<n-space vertical :size="20">
<p class="text-base text-gray-700">{{ 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"
:size="inputSize"
class="w-full"
/>
</n-form-item>
<n-alert v-if="sshKeyError" type="error" :title="sshKeyError" />
</n-space>
<template #action>
<n-space :vertical="isMobile" :size="isMobile ? 12 : 16" class="w-full">
<n-button @click="showAddSSHKeyDialog = false" :block="isMobile" :size="buttonSize">
{{ t('Cancel') }}
</n-button>
<n-button
type="primary"
:loading="addingSSHKey"
@click="handleAddSSHKey"
:block="isMobile"
:size="buttonSize"
>
{{ t('Add SSH Key') }}
</n-button>
</n-space>
</template>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'
import {
NCard,
NSpace,
NButton,
NModal,
NFormItem,
NInput,
NAlert,
NIcon,
NDataTable,
NEmpty,
useMessage,
useDialog,
type DataTableColumns
} from 'naive-ui'
import { Icon } from '@iconify/vue'
import { t } from '../../shared/i18n'
import ClickToCopyField from '../../shared/components/ClickToCopyField.vue'
import {
getAccountInfo,
createApiSecret,
getUserSSHKeys,
addSSHKey,
deleteSSHKey,
setSSHKeyAsDefault
} from '../../shared/api/account'
const message = useMessage()
const dialog = useDialog()
const apiKey = ref('')
const apiSecretData = ref<{ api_key: string; api_secret: string } | null>(null)
const sshKeys = ref<any[]>([])
const sshKeyValue = ref('')
const sshKeyError = ref('')
const showCreateSecretDialog = ref(false)
const showAddSSHKeyDialog = ref(false)
const creatingSecret = ref(false)
const addingSSHKey = ref(false)
const loadingSSHKeys = ref(false)
const windowWidth = ref(window.innerWidth)
const isMobile = computed(() => windowWidth.value <= 768)
const modalStyle = computed(() => ({
width: isMobile.value ? '95vw' : '700px',
maxWidth: isMobile.value ? '95vw' : '90vw'
}))
const inputSize = computed(() => isMobile.value ? 'medium' : 'large')
const buttonSize = computed(() => isMobile.value ? 'medium' : 'medium')
const apiKeyButtonLabel = computed(() => {
return apiKey.value ? t('Regenerate API Key') : t('Create New API Key')
})
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'")
})
const sshKeyColumns: DataTableColumns<any> = [
{
title: t('SSH Fingerprint'),
key: 'ssh_fingerprint',
render: (row) => {
return h('span', { class: 'font-mono' }, `SHA256:${row.ssh_fingerprint}`)
}
},
{
title: t('Added Time'),
key: 'creation',
width: 200,
render: (row) => {
return new Date(row.creation).toLocaleDateString()
}
},
{
title: t('Actions'),
key: 'actions',
width: 200,
render: (row) => {
return h('div', { class: 'flex gap-2' }, [
!row.is_default ? h(NButton, {
size: 'small',
onClick: () => handleSetDefault(row.name)
}, { default: () => t('Set as Default') }) : null,
h(NButton, {
size: 'small',
type: 'error',
onClick: () => handleDeleteSSHKey(row.name)
}, { default: () => t('Delete') })
])
}
}
]
const loadAccountInfo = async () => {
try {
const result = await getAccountInfo()
if (result.success && result.data) {
apiKey.value = result.data.user?.api_key || result.data.team?.user_info?.api_key || ''
}
} catch (error: any) {
console.error('加载账户信息失败:', error)
}
}
const loadSSHKeys = async () => {
loadingSSHKeys.value = true
try {
const result = await getUserSSHKeys()
if (result.success && result.data) {
sshKeys.value = result.data || []
}
} catch (error: any) {
console.error('加载 SSH 密钥失败:', error)
message.error(error.message || t('Failed to load SSH keys'))
} finally {
loadingSSHKeys.value = false
}
}
const handleCreateSecret = async () => {
creatingSecret.value = true
try {
const result = await createApiSecret()
if (result.success && result.data) {
apiSecretData.value = {
api_key: result.data.api_key,
api_secret: result.data.api_secret
}
apiKey.value = result.data.api_key
message.success(apiKey.value ? t('API key regenerated successfully') : t('API key created successfully'))
await loadAccountInfo()
} 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 {
creatingSecret.value = false
}
}
const handleCloseCreateSecretDialog = () => {
showCreateSecretDialog.value = false
apiSecretData.value = null
}
const handleAddSSHKey = async () => {
if (!sshKeyValue.value) {
sshKeyError.value = t('SSH key is required')
return
}
// 验证 SSH 密钥格式
const sshKeyPatterns = [
/^ssh-rsa /,
/^ecdsa-sha2-nistp256 /,
/^ecdsa-sha2-nistp384 /,
/^ecdsa-sha2-nistp521 /,
/^ssh-ed25519 /,
/^sk-ecdsa-sha2-nistp256@openssh\.com /,
/^sk-ssh-ed25519@openssh\.com /
]
const isValid = sshKeyPatterns.some(pattern => pattern.test(sshKeyValue.value))
if (!isValid) {
sshKeyError.value = t('Invalid SSH key format')
return
}
sshKeyError.value = ''
addingSSHKey.value = true
try {
const result = await addSSHKey(sshKeyValue.value)
if (result.success) {
message.success(result.message || 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 {
addingSSHKey.value = false
}
}
const handleSetDefault = async (keyName: string) => {
try {
const result = await setSSHKeyAsDefault(keyName)
if (result.success) {
message.success(result.message || 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'))
}
}
const handleDeleteSSHKey = (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'),
positiveButtonProps: { type: 'error' },
onPositiveClick: async () => {
try {
const result = await deleteSSHKey(keyName)
if (result.success) {
message.success(result.message || 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'))
}
}
})
}
onMounted(() => {
loadAccountInfo()
loadSSHKeys()
window.addEventListener('resize', () => {
windowWidth.value = window.innerWidth
})
})
</script>
<style scoped>
.developer-settings {
width: 100%;
}
.settings-card {
margin-bottom: 24px;
}
</style>