浏览代码

feat(certificate): add new components for certificate management and editing, including upload, download, and actions

0xJacky 2 周之前
父节点
当前提交
f29238fe04

+ 1 - 0
app/components.d.ts

@@ -65,6 +65,7 @@ declare module 'vue' {
     ATag: typeof import('ant-design-vue/es')['Tag']
     ATextarea: typeof import('ant-design-vue/es')['Textarea']
     ATooltip: typeof import('ant-design-vue/es')['Tooltip']
+    AUpload: typeof import('ant-design-vue/es')['Upload']
     AUploadDragger: typeof import('ant-design-vue/es')['UploadDragger']
     AutoCertFormAutoCertForm: typeof import('./src/components/AutoCertForm/AutoCertForm.vue')['default']
     AutoCertFormDNSChallenge: typeof import('./src/components/AutoCertForm/DNSChallenge.vue')['default']

+ 1 - 1
app/src/components/Notification/detailRender.tsx

@@ -6,7 +6,7 @@ export function detailRender(args: Pick<CustomRenderArgs, 'record' | 'text'>) {
   try {
     return (
       <div>
-        <div class="mb-2">
+        <div>
           {
             notifications[args.record.title]?.content(args.record.details)
             || args.record.content || args.record.details

+ 82 - 174
app/src/views/certificate/CertificateEditor.vue

@@ -3,13 +3,13 @@ import type { Ref } from 'vue'
 import type { Cert } from '@/api/cert'
 import { message } from 'ant-design-vue'
 import cert from '@/api/cert'
-import AutoCertForm from '@/components/AutoCertForm'
-import CertInfo from '@/components/CertInfo'
-import CodeEditor from '@/components/CodeEditor'
-import FooterToolBar from '@/components/FooterToolbar'
-import NodeSelector from '@/components/NodeSelector'
 import { AutoCertState } from '@/constants'
-import RenewCert from './components/RenewCert.vue'
+
+import AutoCertManagement from './components/AutoCertManagement.vue'
+import CertificateActions from './components/CertificateActions.vue'
+import CertificateBasicInfo from './components/CertificateBasicInfo.vue'
+import CertificateContentEditor from './components/CertificateContentEditor.vue'
+import CertificateDownload from './components/CertificateDownload.vue'
 import { useCertStore } from './store'
 
 const route = useRoute()
@@ -27,6 +27,10 @@ const notShowInAutoCert = computed(() => {
   return data.value.auto_cert !== AutoCertState.Enable
 })
 
+const isManaged = computed(() => {
+  return data.value.auto_cert === AutoCertState.Enable
+})
+
 function init() {
   if (id.value > 0) {
     cert.getItem(id.value).then(r => {
@@ -55,6 +59,10 @@ async function save() {
   }
 }
 
+function handleBack() {
+  router.push('/certificates/list')
+}
+
 const log = computed(() => {
   if (!data.value.log)
     return ''
@@ -72,177 +80,50 @@ const log = computed(() => {
     }
   }).join('\n')
 })
-
-const isManaged = computed(() => {
-  return data.value.auto_cert === AutoCertState.Enable
-})
 </script>
 
 <template>
   <ACard :title="id > 0 ? $gettext('Modify Certificate') : $gettext('Import Certificate')">
-    <div
-      v-if="isManaged"
-      class="mb-4"
-    >
-      <div class="mb-2">
-        <AAlert
-          :message="$gettext('This certificate is managed by Nginx UI')"
-          type="success"
-          show-icon
-        />
-      </div>
-      <div
-        v-if="!data.filename"
-        class="mt-4 mb-4"
-      >
-        <AAlert
-          :message="$gettext('This Auto Cert item is invalid, please remove it.')"
-          type="error"
-          show-icon
-        />
-      </div>
-      <div
-        v-else-if="!data.domains"
-        class="mt-4 mb-4"
-      >
-        <AAlert
-          :message="$gettext('Domains list is empty, try to reopen Auto Cert for %{config}', { config: data.filename })"
-          type="error"
-          show-icon
-        />
-      </div>
-    </div>
-
-    <ARow>
+    <ARow :gutter="[16, 16]">
       <ACol
         :sm="24"
-        :md="12"
+        :lg="12"
       >
-        <AForm
-          v-if="data.certificate_info"
-          layout="vertical"
-        >
-          <AFormItem :label="$gettext('Certificate Status')">
-            <CertInfo
-              :cert="data.certificate_info"
-              class="max-w-96"
-            />
-          </AFormItem>
-        </AForm>
+        <!-- Auto Certificate Management -->
+        <AutoCertManagement
+          v-model:data="data"
+          :is-managed="isManaged"
+          @renewed="init"
+        />
 
-        <template v-if="isManaged">
-          <RenewCert
-            :options="{
-              name: data.name,
-              domains: data.domains,
-              key_type: data.key_type,
-              challenge_method: data.challenge_method,
-              dns_credential_id: data.dns_credential_id,
-              acme_user_id: data.acme_user_id,
-              revoke_old: data.revoke_old,
-            }"
-            @renewed="init"
+        <AForm layout="vertical">
+          <!-- Certificate Basic Information -->
+          <CertificateBasicInfo
+            v-model:data="data"
+            :errors="errors"
+            :is-managed="isManaged"
           />
 
-          <AutoCertForm
-            v-model:options="data"
-            key-type-read-only
-            style="max-width: 600px"
-            hide-note
-          />
-        </template>
+          <!-- Download Certificate Files -->
+          <CertificateDownload :data="data" />
 
-        <AForm
-          layout="vertical"
-          style="max-width: 600px"
-        >
-          <AFormItem
-            :label="$gettext('Name')"
-            :validate-status="errors.name ? 'error' : ''"
-            :help="errors.name === 'required'
-              ? $gettext('This field is required')
-              : ''"
-          >
-            <p v-if="isManaged">
-              {{ data.name }}
-            </p>
-            <AInput
-              v-else
-              v-model:value="data.name"
-            />
-          </AFormItem>
-          <AFormItem
-            :label="$gettext('SSL Certificate Path')"
-            :validate-status="errors.ssl_certificate_path ? 'error' : ''"
-            :help="errors.ssl_certificate_path === 'required' ? $gettext('This field is required')
-              : errors.ssl_certificate_path === 'certificate_path'
-                ? $gettext('The path exists, but the file is not a certificate') : ''"
-          >
-            <p v-if="isManaged">
-              {{ data.ssl_certificate_path }}
-            </p>
-            <AInput
-              v-else
-              v-model:value="data.ssl_certificate_path"
-            />
-          </AFormItem>
-          <AFormItem
-            :label="$gettext('SSL Certificate Key Path')"
-            :validate-status="errors.ssl_certificate_key_path ? 'error' : ''"
-            :help="errors.ssl_certificate_key_path === 'required' ? $gettext('This field is required')
-              : errors.ssl_certificate_key_path === 'privatekey_path'
-                ? $gettext('The path exists, but the file is not a private key') : ''"
-          >
-            <p v-if="isManaged">
-              {{ data.ssl_certificate_key_path }}
-            </p>
-            <AInput
-              v-else
-              v-model:value="data.ssl_certificate_key_path"
-            />
-          </AFormItem>
-          <AFormItem :label="$gettext('Sync to')">
-            <NodeSelector
-              v-model:target="data.sync_node_ids"
-              hidden-local
-            />
-          </AFormItem>
-          <AFormItem
-            :label="$gettext('SSL Certificate Content')"
-            :validate-status="errors.ssl_certificate ? 'error' : ''"
-            :help="errors.ssl_certificate === 'certificate'
-              ? $gettext('The input is not a SSL Certificate') : ''"
-          >
-            <CodeEditor
-              v-model:content="data.ssl_certificate"
-              default-height="300px"
-              :readonly="!notShowInAutoCert"
-              disable-code-completion
-              :placeholder="$gettext('Leave blank will not change anything')"
-            />
-          </AFormItem>
-          <AFormItem
-            :label="$gettext('SSL Certificate Key Content')"
-            :validate-status="errors.ssl_certificate_key ? 'error' : ''"
-            :help="errors.ssl_certificate_key === 'privatekey'
-              ? $gettext('The input is not a SSL Certificate Key') : ''"
-          >
-            <CodeEditor
-              v-model:content="data.ssl_certificate_key"
-              default-height="300px"
-              :readonly="!notShowInAutoCert"
-              disable-code-completion
-              :placeholder="$gettext('Leave blank will not change anything')"
-            />
-          </AFormItem>
+          <!-- Certificate Content Editor -->
+          <CertificateContentEditor
+            v-model:data="data"
+            :errors="errors"
+            :readonly="!notShowInAutoCert"
+            class="max-w-600px"
+          />
         </AForm>
       </ACol>
+
+      <!-- Log Column for Auto Cert -->
       <ACol
         v-if="data.auto_cert === AutoCertState.Enable"
         :sm="24"
-        :md="12"
+        :lg="12"
       >
-        <ACard :title="$gettext('Log')">
+        <ACard size="small" :title="$gettext('Log')">
           <pre
             v-dompurify-html="log"
             class="log-container"
@@ -251,20 +132,11 @@ const isManaged = computed(() => {
       </ACol>
     </ARow>
 
-    <FooterToolBar>
-      <ASpace>
-        <AButton @click="$router.push('/certificates/list')">
-          {{ $gettext('Back') }}
-        </AButton>
-
-        <AButton
-          type="primary"
-          @click="save"
-        >
-          {{ $gettext('Save') }}
-        </AButton>
-      </ASpace>
-    </FooterToolBar>
+    <!-- Certificate Actions -->
+    <CertificateActions
+      @save="save"
+      @back="handleBack"
+    />
   </ACard>
 </template>
 
@@ -277,4 +149,40 @@ const isManaged = computed(() => {
   font-size: 12px;
   line-height: 2;
 }
+
+.code-editor-container {
+  position: relative;
+
+  .drag-overlay {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-color: rgba(24, 144, 255, 0.1);
+    border: 2px dashed #1890ff;
+    border-radius: 6px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 10;
+
+    .drag-content {
+      text-align: center;
+      color: #1890ff;
+
+      .drag-icon {
+        font-size: 48px;
+        margin-bottom: 16px;
+        display: block;
+      }
+
+      p {
+        font-size: 16px;
+        margin: 0;
+        font-weight: 500;
+      }
+    }
+  }
+}
 </style>

+ 33 - 65
app/src/views/certificate/components/ACMEUserSelector.vue

@@ -1,83 +1,49 @@
 <script setup lang="ts">
-import type { SelectProps } from 'ant-design-vue'
-import type { DefaultOptionType } from 'ant-design-vue/es/select'
-import type { Ref } from 'vue'
 import type { AcmeUser } from '@/api/acme_user'
 import type { AutoCertOptions } from '@/api/auto_cert'
 import acme_user from '@/api/acme_user'
 
-const users = ref([]) as Ref<AcmeUser[]>
-
 const data = defineModel<AutoCertOptions>('options', {
-  default: () => {
-    return {}
-  },
+  default: reactive({}),
   required: true,
 })
 
-const id = computed(() => {
-  return data.value?.acme_user_id
-})
-
-const userIdx = ref<number>()
-function init() {
-  users.value?.forEach((v: AcmeUser, k: number) => {
-    if (v.id === id.value)
-      userIdx.value = k
-  })
-}
-
-const current = computed(() => {
-  return users.value?.[userIdx.value || -1]
-})
-
-const mounted = ref(false)
-
-watch(id, init)
-
-watch(current, () => {
-  if (mounted.value)
-    data.value!.acme_user_id = current.value.id
-})
+const users = ref<AcmeUser[]>([])
+const loading = ref(false)
 
+// Load ACME users on component mount
 onMounted(async () => {
-  users.value = []
-  let page = 1
-  while (true) {
-    try {
-      const r = await acme_user.getList({ page })
-
-      users.value.push(...r.data)
-      if (r?.data?.length < (r?.pagination?.per_page ?? 0))
+  loading.value = true
+  try {
+    users.value = []
+    let page = 1
+    while (true) {
+      try {
+        const r = await acme_user.getList({ page })
+        users.value.push(...r.data)
+        if (r?.data?.length < (r?.pagination?.per_page ?? 0))
+          break
+        page++
+      }
+      catch {
         break
-      page++
-    }
-    catch {
-      break
+      }
     }
   }
-
-  init()
-
-  // prevent the acme_user_id from being overwritten
-  mounted.value = true
+  finally {
+    loading.value = false
+  }
 })
 
-const options = computed<SelectProps['options']>(() => {
-  const list: SelectProps['options'] = []
-
-  users.value.forEach((v, k: number) => {
-    list!.push({
-      value: k,
-      label: v.name,
-    })
-  })
-
-  return list
-})
+// Define field names mapping for ASelect
+const fieldNames = {
+  value: 'id',
+  label: 'name',
+}
 
-function filterOption(input: string, option?: DefaultOptionType) {
-  return option?.label.toLowerCase().includes(input.toLowerCase())
+// Filter function for search - using type assertion for compatibility
+function filterOption(input: string, option?: unknown) {
+  return (option as AcmeUser)?.name?.toLowerCase().includes(input.toLowerCase()) ?? false
 }
 </script>
 
@@ -85,10 +51,12 @@ function filterOption(input: string, option?: DefaultOptionType) {
   <AForm layout="vertical">
     <AFormItem :label="$gettext('ACME User')">
       <ASelect
-        v-model:value="userIdx"
+        v-model:value="data.acme_user_id"
         :placeholder="$gettext('System Initial User')"
+        :loading="loading"
         show-search
-        :options
+        :options="users"
+        :field-names="fieldNames"
         :filter-option="filterOption"
       />
     </AFormItem>

+ 104 - 0
app/src/views/certificate/components/AutoCertManagement.vue

@@ -0,0 +1,104 @@
+<script setup lang="ts">
+import type { Cert } from '@/api/cert'
+import AutoCertForm from '@/components/AutoCertForm'
+import CertInfo from '@/components/CertInfo'
+import RenewCert from './RenewCert.vue'
+
+interface Props {
+  data: Cert
+  isManaged: boolean
+}
+
+defineProps<Props>()
+
+const emit = defineEmits<{
+  renewed: []
+}>()
+
+// Use defineModel for two-way binding
+const data = defineModel<Cert>('data', { required: true })
+
+function handleRenewed() {
+  emit('renewed')
+}
+</script>
+
+<template>
+  <div class="auto-cert-management">
+    <!-- Auto Cert Status Alerts -->
+    <div
+      v-if="isManaged"
+      class="mb-4"
+    >
+      <div class="mb-2">
+        <AAlert
+          :message="$gettext('This certificate is managed by Nginx UI')"
+          type="success"
+          show-icon
+        />
+      </div>
+      <div
+        v-if="!data.filename"
+        class="mt-4 mb-4"
+      >
+        <AAlert
+          :message="$gettext('This Auto Cert item is invalid, please remove it.')"
+          type="error"
+          show-icon
+        />
+      </div>
+      <div
+        v-else-if="!data.domains"
+        class="mt-4 mb-4"
+      >
+        <AAlert
+          :message="$gettext('Domains list is empty, try to reopen Auto Cert for %{config}', { config: data.filename })"
+          type="error"
+          show-icon
+        />
+      </div>
+    </div>
+
+    <!-- Certificate Status -->
+    <AForm
+      v-if="data.certificate_info"
+      layout="vertical"
+    >
+      <AFormItem :label="$gettext('Certificate Status')">
+        <CertInfo
+          :cert="data.certificate_info"
+          class="max-w-96"
+        />
+      </AFormItem>
+    </AForm>
+
+    <!-- Auto Cert Management -->
+    <template v-if="isManaged">
+      <RenewCert
+        :options="{
+          name: data.name,
+          domains: data.domains,
+          key_type: data.key_type,
+          challenge_method: data.challenge_method,
+          dns_credential_id: data.dns_credential_id,
+          acme_user_id: data.acme_user_id,
+          revoke_old: data.revoke_old,
+        }"
+        @renewed="handleRenewed"
+      />
+
+      <AutoCertForm
+        v-model:options="data"
+        key-type-read-only
+        style="max-width: 600px"
+        hide-note
+      />
+    </template>
+  </div>
+</template>
+
+<style scoped lang="less">
+.auto-cert-management {
+  margin-bottom: 24px;
+}
+</style>

+ 36 - 0
app/src/views/certificate/components/CertificateActions.vue

@@ -0,0 +1,36 @@
+<script setup lang="ts">
+import FooterToolBar from '@/components/FooterToolbar'
+
+const emit = defineEmits<{
+  save: []
+  back: []
+}>()
+
+function handleSave() {
+  emit('save')
+}
+
+function handleBack() {
+  emit('back')
+}
+</script>
+
+<template>
+  <FooterToolBar>
+    <ASpace>
+      <AButton @click="handleBack">
+        {{ $gettext('Back') }}
+      </AButton>
+
+      <AButton
+        type="primary"
+        @click="handleSave"
+      >
+        {{ $gettext('Save') }}
+      </AButton>
+    </ASpace>
+  </FooterToolBar>
+</template>
+
+<style scoped lang="less">
+</style>

+ 80 - 0
app/src/views/certificate/components/CertificateBasicInfo.vue

@@ -0,0 +1,80 @@
+<script setup lang="ts">
+import type { Cert } from '@/api/cert'
+import NodeSelector from '@/components/NodeSelector'
+
+interface Props {
+  data: Cert
+  errors: Record<string, string>
+  isManaged: boolean
+}
+
+defineProps<Props>()
+
+// Use defineModel for two-way binding
+const data = defineModel<Cert>('data', { required: true })
+</script>
+
+<template>
+  <AForm
+    layout="vertical"
+    style="max-width: 600px"
+  >
+    <AFormItem
+      :label="$gettext('Name')"
+      :validate-status="errors.name ? 'error' : ''"
+      :help="errors.name === 'required'
+        ? $gettext('This field is required')
+        : ''"
+    >
+      <p v-if="isManaged">
+        {{ data.name }}
+      </p>
+      <AInput
+        v-else
+        v-model:value="data.name"
+      />
+    </AFormItem>
+
+    <AFormItem
+      :label="$gettext('SSL Certificate Path')"
+      :validate-status="errors.ssl_certificate_path ? 'error' : ''"
+      :help="errors.ssl_certificate_path === 'required' ? $gettext('This field is required')
+        : errors.ssl_certificate_path === 'certificate_path'
+          ? $gettext('The path exists, but the file is not a certificate') : ''"
+    >
+      <p v-if="isManaged">
+        {{ data.ssl_certificate_path }}
+      </p>
+      <AInput
+        v-else
+        v-model:value="data.ssl_certificate_path"
+      />
+    </AFormItem>
+
+    <AFormItem
+      :label="$gettext('SSL Certificate Key Path')"
+      :validate-status="errors.ssl_certificate_key_path ? 'error' : ''"
+      :help="errors.ssl_certificate_key_path === 'required' ? $gettext('This field is required')
+        : errors.ssl_certificate_key_path === 'privatekey_path'
+          ? $gettext('The path exists, but the file is not a private key') : ''"
+    >
+      <p v-if="isManaged">
+        {{ data.ssl_certificate_key_path }}
+      </p>
+      <AInput
+        v-else
+        v-model:value="data.ssl_certificate_key_path"
+      />
+    </AFormItem>
+
+    <AFormItem :label="$gettext('Sync to')">
+      <NodeSelector
+        v-model:target="data.sync_node_ids"
+        hidden-local
+      />
+    </AFormItem>
+  </AForm>
+</template>
+
+<style scoped lang="less">
+</style>

+ 229 - 0
app/src/views/certificate/components/CertificateContentEditor.vue

@@ -0,0 +1,229 @@
+<script setup lang="ts">
+import type { Cert } from '@/api/cert'
+import { InboxOutlined } from '@ant-design/icons-vue'
+import CodeEditor from '@/components/CodeEditor'
+import CertificateFileUpload from './CertificateFileUpload.vue'
+
+interface Props {
+  data: Cert
+  errors: Record<string, string>
+  readonly: boolean
+}
+
+defineProps<Props>()
+
+// Use defineModel for two-way binding
+const data = defineModel<Cert>('data', { required: true })
+
+// Drag and drop state
+const isDragOverCert = ref(false)
+const isDragOverKey = ref(false)
+
+// Handle certificate file upload
+function handleCertificateUpload(content: string) {
+  data.value.ssl_certificate = content
+}
+
+// Handle private key file upload
+function handlePrivateKeyUpload(content: string) {
+  data.value.ssl_certificate_key = content
+}
+
+// Drag and drop handlers
+function handleDragEnter(e: DragEvent, type: 'certificate' | 'key') {
+  e.preventDefault()
+  if (type === 'certificate') {
+    isDragOverCert.value = true
+  }
+  else {
+    isDragOverKey.value = true
+  }
+}
+
+function handleDragOver(e: DragEvent) {
+  e.preventDefault()
+}
+
+function handleDragLeave(e: DragEvent, type: 'certificate' | 'key') {
+  e.preventDefault()
+  // Only set to false if leaving the component entirely
+  const currentTarget = e.currentTarget as HTMLElement
+  const relatedTarget = e.relatedTarget as Node
+  if (!currentTarget?.contains(relatedTarget)) {
+    if (type === 'certificate') {
+      isDragOverCert.value = false
+    }
+    else {
+      isDragOverKey.value = false
+    }
+  }
+}
+
+function handleDrop(e: DragEvent, type: 'certificate' | 'key') {
+  e.preventDefault()
+  if (type === 'certificate') {
+    isDragOverCert.value = false
+  }
+  else {
+    isDragOverKey.value = false
+  }
+
+  const files = Array.from(e.dataTransfer?.files || [])
+  if (files.length > 0) {
+    const file = files[0]
+    const reader = new FileReader()
+    reader.onload = e => {
+      const content = e.target?.result as string
+      if (type === 'certificate') {
+        handleCertificateUpload(content)
+      }
+      else {
+        handlePrivateKeyUpload(content)
+      }
+    }
+    reader.readAsText(file)
+  }
+}
+</script>
+
+<template>
+  <div class="certificate-content-editor">
+    <!-- SSL Certificate Content -->
+    <AFormItem
+      :label="$gettext('SSL Certificate Content')"
+      :validate-status="errors.ssl_certificate ? 'error' : ''"
+      :help="errors.ssl_certificate === 'certificate'
+        ? $gettext('The input is not a SSL Certificate') : ''"
+    >
+      <!-- Certificate File Upload -->
+      <CertificateFileUpload
+        v-if="!readonly"
+        type="certificate"
+        @upload="handleCertificateUpload"
+      />
+
+      <div
+        v-if="!readonly"
+        class="code-editor-container"
+        @dragenter.prevent="(e) => handleDragEnter(e, 'certificate')"
+        @dragover.prevent="handleDragOver"
+        @dragleave.prevent="(e) => handleDragLeave(e, 'certificate')"
+        @drop.prevent="(e) => handleDrop(e, 'certificate')"
+      >
+        <CodeEditor
+          v-model:content="data.ssl_certificate"
+          default-height="300px"
+          :readonly="readonly"
+          disable-code-completion
+          :placeholder="$gettext('Leave blank will not change anything')"
+        />
+        <div
+          v-if="isDragOverCert"
+          class="drag-overlay"
+        >
+          <div class="drag-content">
+            <InboxOutlined class="drag-icon" />
+            <p>{{ $gettext('Drop certificate file here') }}</p>
+          </div>
+        </div>
+      </div>
+      <CodeEditor
+        v-else
+        v-model:content="data.ssl_certificate"
+        default-height="300px"
+        :readonly="readonly"
+        disable-code-completion
+        :placeholder="$gettext('Leave blank will not change anything')"
+      />
+    </AFormItem>
+
+    <!-- SSL Certificate Key Content -->
+    <AFormItem
+      :label="$gettext('SSL Certificate Key Content')"
+      :validate-status="errors.ssl_certificate_key ? 'error' : ''"
+      :help="errors.ssl_certificate_key === 'privatekey'
+        ? $gettext('The input is not a SSL Certificate Key') : ''"
+    >
+      <!-- Private Key File Upload -->
+      <CertificateFileUpload
+        v-if="!readonly"
+        type="key"
+        @upload="handlePrivateKeyUpload"
+      />
+
+      <div
+        v-if="!readonly"
+        class="code-editor-container"
+        @dragenter.prevent="(e) => handleDragEnter(e, 'key')"
+        @dragover.prevent="handleDragOver"
+        @dragleave.prevent="(e) => handleDragLeave(e, 'key')"
+        @drop.prevent="(e) => handleDrop(e, 'key')"
+      >
+        <CodeEditor
+          v-model:content="data.ssl_certificate_key"
+          default-height="300px"
+          :readonly="readonly"
+          disable-code-completion
+          :placeholder="$gettext('Leave blank will not change anything')"
+        />
+        <div
+          v-if="isDragOverKey"
+          class="drag-overlay"
+        >
+          <div class="drag-content">
+            <InboxOutlined class="drag-icon" />
+            <p>{{ $gettext('Drop private key file here') }}</p>
+          </div>
+        </div>
+      </div>
+      <CodeEditor
+        v-else
+        v-model:content="data.ssl_certificate_key"
+        default-height="300px"
+        :readonly="readonly"
+        disable-code-completion
+        :placeholder="$gettext('Leave blank will not change anything')"
+      />
+    </AFormItem>
+  </div>
+</template>
+
+<style scoped lang="less">
+.certificate-content-editor {
+  .code-editor-container {
+    position: relative;
+
+    .drag-overlay {
+      position: absolute;
+      top: 0;
+      left: 0;
+      right: 0;
+      bottom: 0;
+      background-color: rgba(24, 144, 255, 0.1);
+      border: 2px dashed #1890ff;
+      border-radius: 6px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      z-index: 10;
+
+      .drag-content {
+        text-align: center;
+        color: #1890ff;
+
+        .drag-icon {
+          font-size: 48px;
+          margin-bottom: 16px;
+          display: block;
+        }
+
+        p {
+          font-size: 16px;
+          margin: 0;
+          font-weight: 500;
+        }
+      }
+    }
+  }
+}
+</style>

+ 102 - 0
app/src/views/certificate/components/CertificateDownload.vue

@@ -0,0 +1,102 @@
+<script setup lang="ts">
+import type { Cert } from '@/api/cert'
+import { DownloadOutlined } from '@ant-design/icons-vue'
+import { message } from 'ant-design-vue'
+
+interface Props {
+  data: Cert
+}
+
+const props = defineProps<Props>()
+
+// Download state
+const isDownloading = ref(false)
+
+// Check if certificate files can be downloaded
+const canDownloadCertificates = computed(() => {
+  return !!(props.data.ssl_certificate?.trim() && props.data.ssl_certificate_key?.trim())
+})
+
+// Download individual files
+function downloadFile(content: string, filename: string, mimeType = 'text/plain') {
+  const blob = new Blob([content], { type: mimeType })
+  const url = URL.createObjectURL(blob)
+  const link = document.createElement('a')
+  link.href = url
+  link.download = filename
+  document.body.appendChild(link)
+  link.click()
+  document.body.removeChild(link)
+  URL.revokeObjectURL(url)
+}
+
+// Download certificate files
+async function downloadCertificateFiles() {
+  if (!canDownloadCertificates.value) {
+    message.error($gettext('Certificate content and private key content cannot be empty'))
+    return
+  }
+
+  if (!props.data.name?.trim()) {
+    message.error($gettext('Certificate name cannot be empty'))
+    return
+  }
+
+  try {
+    isDownloading.value = true
+
+    // Validate certificate content format
+    const certContent = props.data.ssl_certificate.trim()
+    const keyContent = props.data.ssl_certificate_key.trim()
+
+    if (!certContent.includes('-----BEGIN CERTIFICATE-----') && !certContent.includes('-----BEGIN ')) {
+      message.error($gettext('Invalid certificate format'))
+      return
+    }
+
+    if (!keyContent.includes('-----BEGIN') || !keyContent.includes('PRIVATE KEY-----')) {
+      message.error($gettext('Invalid private key format'))
+      return
+    }
+
+    // Download certificate file
+    downloadFile(certContent, `${props.data.name}.crt`, 'application/x-x509-ca-cert')
+
+    // Download private key file with a small delay
+    setTimeout(() => {
+      downloadFile(keyContent, `${props.data.name}.key`, 'application/x-pem-file')
+    }, 100)
+
+    message.success($gettext('Certificate files downloaded successfully'))
+  }
+  catch (error) {
+    console.error('Download error:', error)
+    message.error($gettext('Failed to download certificate files'))
+  }
+  finally {
+    isDownloading.value = false
+  }
+}
+</script>
+
+<template>
+  <div v-if="canDownloadCertificates" class="certificate-download">
+    <AButton
+      type="primary"
+      size="small"
+      :loading="isDownloading"
+      @click="downloadCertificateFiles"
+    >
+      <template #icon>
+        <DownloadOutlined />
+      </template>
+      {{ $gettext('Download Certificate Files') }}
+    </AButton>
+  </div>
+</template>
+
+<style scoped lang="less">
+.certificate-download {
+  margin-bottom: 12px;
+}
+</style>

+ 155 - 0
app/src/views/certificate/components/CertificateFileUpload.vue

@@ -0,0 +1,155 @@
+<script setup lang="ts">
+import { UploadOutlined } from '@ant-design/icons-vue'
+import { message } from 'ant-design-vue'
+
+interface Props {
+  type: 'certificate' | 'key'
+  disabled?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false,
+})
+
+const emit = defineEmits<{
+  upload: [content: string]
+}>()
+
+// File upload state
+const fileInput = ref<HTMLInputElement>()
+
+// Supported file extensions
+const certificateExtensions = ['.crt', '.pem', '.cer', '.cert', '.csr']
+const keyExtensions = ['.key', '.pem', '.private']
+
+// Get accepted file extensions based on type
+const acceptedExtensions = computed(() => {
+  return props.type === 'certificate' ? certificateExtensions : keyExtensions
+})
+
+// Get accept attribute for input
+const acceptAttribute = computed(() => {
+  return acceptedExtensions.value.join(',')
+})
+
+// File size limit (5MB)
+const maxFileSize = 5 * 1024 * 1024
+
+// Validate file type and size
+function validateFile(file: File): boolean {
+  const fileName = file.name.toLowerCase()
+  const isValidExtension = acceptedExtensions.value.some(ext => fileName.endsWith(ext))
+
+  if (!isValidExtension) {
+    const typeText = props.type === 'certificate' ? $gettext('certificate') : $gettext('private key')
+    message.error($gettext('Please select a valid %{type} file (%{extensions})', {
+      type: typeText,
+      extensions: acceptedExtensions.value.join(', '),
+    }))
+    return false
+  }
+
+  if (file.size > maxFileSize) {
+    message.error($gettext('File size cannot exceed 5MB'))
+    return false
+  }
+
+  return true
+}
+
+// Read file content
+function readFileContent(file: File): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = e => {
+      const content = e.target?.result as string
+      resolve(content)
+    }
+    reader.onerror = () => {
+      reject(new Error($gettext('Failed to read file')))
+    }
+    reader.readAsText(file)
+  })
+}
+
+// Handle file upload
+async function handleFileUpload(file: File) {
+  if (!validateFile(file)) {
+    return
+  }
+
+  try {
+    const content = await readFileContent(file)
+
+    // Basic content validation
+    if (props.type === 'certificate') {
+      if (!content.includes('-----BEGIN CERTIFICATE-----') && !content.includes('-----BEGIN ')) {
+        message.error($gettext('Invalid certificate format'))
+        return
+      }
+    }
+    else if (props.type === 'key') {
+      if (!content.includes('-----BEGIN') || !content.includes('PRIVATE KEY-----')) {
+        message.error($gettext('Invalid private key format'))
+        return
+      }
+    }
+
+    emit('upload', content)
+    message.success($gettext('File uploaded successfully'))
+  }
+  catch (error) {
+    console.error('File upload error:', error)
+    message.error($gettext('Failed to upload file'))
+  }
+}
+
+// Handle file selection from input
+function handleFileSelect(event: Event) {
+  const input = event.target as HTMLInputElement
+  const file = input.files?.[0]
+  if (file) {
+    handleFileUpload(file)
+  }
+  // Reset input value to allow selecting the same file again
+  input.value = ''
+}
+
+// Get upload text based on type
+const uploadText = computed(() => {
+  const typeText = props.type === 'certificate' ? $gettext('certificate') : $gettext('private key')
+  return $gettext('Upload %{type} File', { type: typeText })
+})
+</script>
+
+<template>
+  <div class="certificate-file-upload">
+    <input
+      ref="fileInput"
+      type="file"
+      :accept="acceptAttribute"
+      style="display: none"
+      @change="handleFileSelect"
+    >
+    <AButton
+      size="small"
+      type="dashed"
+      :disabled="disabled"
+      @click="fileInput?.click()"
+    >
+      <template #icon>
+        <UploadOutlined />
+      </template>
+      {{ uploadText }}
+    </AButton>
+    <span class="ml-2 text-gray-500 text-sm">
+      {{ $gettext('or drag file to editor below') }}
+    </span>
+  </div>
+</template>
+
+<style scoped lang="less">
+.certificate-file-upload {
+  margin-bottom: 12px;
+}
+</style>

+ 1 - 1
app/src/views/notification/Notification.vue

@@ -3,7 +3,7 @@ import { StdCurd } from '@uozi-admin/curd'
 import { message } from 'ant-design-vue'
 import notification from '@/api/notification'
 import { useUserStore } from '@/pinia'
-import notificationColumns from '@/views/notification/notificationColumns'
+import notificationColumns from './notificationColumns'
 
 const { unreadCount } = storeToRefs(useUserStore())