perf: 新增站点证书监控从DNS解析记录批量导入功能

本次提交新增了从DNS解析记录批量导入站点监控的完整功能:
1. 扩展Registrable类型新增icon字段支持
2. 新增DNS解析记录获取接口和基础实现
3. 为阿里云、腾讯云、Cloudflare等DNS提供商添加解析记录分页获取支持
4. 新增站点监控导入任务管理功能,支持保存、启动、删除导入任务
5. 新增中文/英文多语言支持
6. 优化暗黑模式表格样式
7. 修复ACME账户访问修复逻辑中项目ID可选的问题
8. 优化HiPM DNS提供商的域名获取逻辑
This commit is contained in:
xiaojunnuo
2026-06-10 23:32:39 +08:00
parent 8d9870e9c6
commit f9541fab70
21 changed files with 749 additions and 55 deletions
@@ -18,6 +18,7 @@ export default {
subdomainConfirmTitle: "Subdomain Confirmation",
subdomainConfirmContent: "{domain} appears to be a subdomain. Only delegated subdomains and free second-level subdomains need to be maintained here. Otherwise certificate application may fail. Continue?",
importFromProvider: "Import from Domain Provider",
importFromResolveRecords: "Import from DNS Records",
syncExpirationDate: "Sync Domain Expiration Time",
syncTaskSubmitted: "Sync task submitted",
syncExpirationProgress: "Sync Domain Expiration Progress",
@@ -18,6 +18,7 @@ export default {
subdomainConfirmTitle: "子域名确认",
subdomainConfirmContent: "检测到{domain}为子域名,只有托管子域名和免费二级子域名才需要在此处维护,否则会导致申请证书失败,请确认是否继续?",
importFromProvider: "从域名提供商导入",
importFromResolveRecords: "从解析记录导入",
syncExpirationDate: "同步域名过期时间",
syncTaskSubmitted: "同步任务已提交",
syncExpirationProgress: "同步域名过期时间进度",
@@ -8,4 +8,45 @@
.vben-normal-menu__item.is-active {
background-color: #3b3b3b !important;
}
.cd-table {
th,
td {
border-bottom: 1px solid #303030;
border-left: 1px solid #303030;
}
th {
background: #1f1f1f;
color: rgba(255, 255, 255, 0.85);
border-top: 1px solid #303030;
&:last-child {
border-right: 1px solid #303030;
}
}
td {
&:last-child {
border-right: 1px solid #303030;
}
}
td.position-sticky-right {
background-color: #141414;
}
.position-sticky-right::before {
background: #303030;
}
tr.hover-color:hover td {
background: #262626;
}
.status-active {
background: #1f3a23;
color: #81c784;
}
}
}
@@ -72,6 +72,36 @@ export const siteInfoApi = {
});
},
async ImportTaskSave(body: any) {
return await request({
url: apiPrefix + "/import/save",
method: "post",
data: body,
});
},
async ImportTaskStatus() {
return await request({
url: apiPrefix + "/import/status",
method: "post",
});
},
async ImportTaskDelete(key: string) {
return await request({
url: apiPrefix + "/import/delete",
method: "post",
data: { key },
});
},
async ImportTaskStart(key: string) {
return await request({
url: apiPrefix + "/import/start",
method: "post",
data: { key },
});
},
async DisabledChange(id: number, disabled: boolean) {
return await request({
url: apiPrefix + "/disabledChange",
@@ -9,7 +9,7 @@ import { useSettingStore } from "/@/store/settings";
import { mySuiteApi } from "/@/views/certd/suite/mine/api";
import { mitter } from "/@/utils/util.mitt";
import { useSiteIpMonitor } from "./ip/use";
import { useSiteImport } from "/@/views/certd/monitor/site/use";
import { useSiteImport, useSiteImportTaskManage } from "/@/views/certd/monitor/site/use";
import { ref } from "vue";
import GroupSelector from "../../basic/group/group-selector.vue";
import { createGroupDictRef } from "../../basic/group/api";
@@ -53,6 +53,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
const { openSiteIpMonitorDialog } = useSiteIpMonitor();
const { openSiteImportDialog } = useSiteImport();
const openSiteImportTaskManageDialog = useSiteImportTaskManage();
const certValidDaysRef = ref(10);
@@ -200,6 +201,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
actionbar: {
buttons: {
add: {
icon: "ion:add-circle-outline",
async click() {
if (!settingsStore.isPlus) {
// 非plus
@@ -236,6 +238,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
show: hasActionPermission("write"),
text: t("monitor.bulkImport"),
type: "primary",
icon: "ion:cloud-upload-outline",
async click() {
const defaultGroupId = getDefaultGroupId();
openSiteImportDialog({
@@ -246,10 +249,27 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
});
},
},
importFromProvider: {
show: hasActionPermission("write"),
title: t("certd.domain.importFromResolveRecords"),
text: t("certd.domain.importFromResolveRecords"),
type: "primary",
needPlus: true,
color: "gold",
icon: "mingcute:vip-1-line",
click: async () => {
await openSiteImportTaskManageDialog({
afterSubmit: () => {
crudExpose.doRefresh();
},
});
},
},
checkAll: {
show: true,
text: t("monitor.checkAll"),
type: "primary",
icon: "ion:play-circle-outline",
click() {
checkAll();
},
@@ -0,0 +1,139 @@
<template>
<div class="site-info-import-task-status min-h-[300px]">
<div class="action mb-5">
<fs-button type="primary" icon="mingcute:vip-1-line" @click="addTask">{{ t("certd.domain.addImportTask") }}</fs-button>
<fs-button type="primary" icon="ion:refresh-outline" class="ml-2" @click="loadImportTaskStatus">{{ t("certd.domain.refresh") }}</fs-button>
</div>
<div class="table-container overflow-auto mb-10">
<table class="cd-table border-gray-300 w-full">
<thead>
<tr>
<th class="w-[220px]">{{ t("certd.sourcee") }}</th>
<th class="">{{ t("certd.domain.progress") }}</th>
<th class="w-[220px]">{{ t("certd.domain.operation") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in list" :key="item.key">
<td class="ellipsis">
<span class="flex items-center pointer" @click="editTask(item)">
<span class="flex-1 ellipsis flex items-center">
<fs-icon :icon="item.icon" class="mr-2"></fs-icon>
{{ item.title }}
</span>
<fs-icon icon="ant-design:edit-outlined" class="ml-2" />
</span>
</td>
<td>
<div v-if="item.task">
<div>
<a-tag color="blue">{{ t("certd.domain.total") }}{{ item.task?.total }}</a-tag>
<a-tag color="success" class="ml-2">{{ t("certd.success") }}{{ item.task?.successCount }}</a-tag>
<a-tag type="info" class="ml-2">{{ t("certd.domain.skipped") }}{{ item.task?.skipCount }}</a-tag>
<a-tooltip v-if="item.task?.errors.length > 0">
<template #title>
<div v-for="error in item.task?.errors" :key="error">{{ error }}</div>
</template>
<a-tag color="red" class="ml-2">{{ t("certd.domain.failed") }}{{ item.task?.errors.length }}</a-tag>
</a-tooltip>
</div>
<a-progress :percent="item.task?.progress" size="small" status="active" />
</div>
<div v-else>{{ t("certd.domain.notExecuted") }}</div>
</td>
<td>
<fs-button type="primary" icon="ion:play-outline" :disabled="item.task?.status === 'running'" @click="startTask(item)">{{ t("certd.domain.execute") }}</fs-button>
<fs-button type="primary" class="ml-2" danger icon="ion:trash-outline" @click="deleteTask(item)">{{ t("certd.domain.delete") }}</fs-button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { Modal } from "ant-design-vue";
import { onMounted, onUnmounted, ref } from "vue";
import * as api from "./api";
import { useSiteImportTask } from "./use";
import { useSettingStore } from "/@/store/settings";
import { useI18n } from "/@/locales";
defineOptions({
name: "SiteInfoImportTaskStatus",
});
const list = ref([]);
const { t } = useI18n();
async function loadImportTaskStatus() {
const res = await api.siteInfoApi.ImportTaskStatus();
list.value = res || [];
}
async function startTask(item: any) {
settingStore.checkPlus();
await api.siteInfoApi.ImportTaskStart(item.key);
await loadImportTaskStatus();
}
async function deleteTask(item: any) {
Modal.confirm({
title: t("certd.domain.confirmDelete"),
okText: t("common.confirm"),
okType: "danger",
onOk: async () => {
await api.siteInfoApi.ImportTaskDelete(item.key);
await loadImportTaskStatus();
},
});
}
const openSiteImportTaskDialog = useSiteImportTask();
const settingStore = useSettingStore();
async function addTask() {
settingStore.checkPlus();
await openSiteImportTaskDialog({
afterSubmit: async (res?: any) => {
if (res) {
await api.siteInfoApi.ImportTaskStart(res.key);
}
await loadImportTaskStatus();
},
});
}
async function editTask(item: any) {
settingStore.checkPlus();
await openSiteImportTaskDialog({
afterSubmit: async () => {
await loadImportTaskStatus();
},
form: item,
});
}
const checkIntervalRef = ref();
onMounted(async () => {
await loadImportTaskStatus();
checkIntervalRef.value = setInterval(async () => {
await loadImportTaskStatus();
}, 3000);
});
onUnmounted(() => {
clearInterval(checkIntervalRef.value);
});
</script>
<style lang="less">
.site-info-import-task-status {
.table-container {
height: 50vh;
}
.ant-progress {
margin-bottom: 0px;
}
}
</style>
@@ -1,7 +1,11 @@
import { useFormWrapper } from "@fast-crud/fast-crud";
import { useFormWrapper, compute } from "@fast-crud/fast-crud";
import { siteInfoApi } from "./api";
import { useI18n } from "/src/locales";
import { useI18n } from "/@/locales";
import { useSettingStore } from "/@/store/settings";
import { useFormDialog } from "/@/use/use-dialog";
import GroupSelector from "../../basic/group/group-selector.vue";
import SiteInfoImportTaskStatus from "./import.vue";
export function useSiteImport() {
const { t } = useI18n();
const { openCrudFormDialog } = useFormWrapper();
@@ -13,7 +17,7 @@ export function useSiteImport() {
columns: {
text: {
type: "textarea",
title: t("certd.domainList.title"), // 域名列表
title: t("certd.domainList.title"),
form: {
helper: t("certd.domainList.helper"),
rules: [{ required: true, message: t("certd.domainList.required") }],
@@ -21,9 +25,7 @@ export function useSiteImport() {
placeholder: t("certd.domainList.placeholder"),
rows: 8,
},
col: {
span: 24,
},
col: { span: 24 },
},
},
groupId: {
@@ -36,13 +38,10 @@ export function useSiteImport() {
vModel: "modelValue",
type: "site",
},
col: {
span: 24,
},
col: { span: 24 },
},
},
},
form: {
async doSubmit({ form }) {
return siteInfoApi.Import(form);
@@ -53,7 +52,99 @@ export function useSiteImport() {
});
}
return {
openSiteImportDialog,
return { openSiteImportDialog };
}
export function useSiteImportTask() {
const { openFormDialog } = useFormDialog();
const { t } = useI18n();
const columns = {
dnsProviderType: {
title: t("certd.domain.domainProvider"),
type: "text",
form: {
component: {
name: "dns-provider-selector",
on: {
selectedChange: ({ form, $event }: any) => {
form.dnsProviderAccessType = $event.accessType;
},
},
},
valueChange({ form }: any) {
form.dnsProviderAccessId = null;
},
},
},
dnsProviderAccessType: {
title: t("certd.domain.domainProviderAccessType"),
type: "text",
form: { show: false },
},
dnsProviderAccessId: {
title: t("certd.domain.domainProviderAccess"),
type: "text",
form: {
component: {
name: "access-selector",
vModel: "modelValue",
type: compute(({ form }: any) => form.dnsProviderAccessType || form.dnsProviderType),
},
},
},
groupId: {
title: t("certd.fields.group"),
type: "text",
form: {
component: {
name: GroupSelector,
vModel: "modelValue",
type: "site",
},
},
},
};
return function openSiteImportTaskDialog(req: { afterSubmit?: (res?: any) => void; form?: any }) {
openFormDialog({
title: t("certd.domain.importFromProvider"),
columns,
initialForm: { ...req.form },
onSubmit: async (form: any) => {
const res = await siteInfoApi.ImportTaskSave({
key: form.key,
dnsProviderType: form.dnsProviderType,
dnsProviderAccessId: form.dnsProviderAccessId,
groupId: form.groupId,
});
if (req.afterSubmit) {
req.afterSubmit(res);
}
},
});
};
}
export function useSiteImportTaskManage() {
const { openFormDialog } = useFormDialog();
const { t } = useI18n();
const settingStore = useSettingStore();
return async function openSiteImportTaskManageDialog(req: {
afterSubmit?: (res?: any) => void;
form?: any;
zIndex?: number;
}) {
settingStore.checkPlus();
await openFormDialog({
title: t("certd.domain.importFromProvider"),
body: () => <SiteInfoImportTaskStatus />,
zIndex: req.zIndex,
onSubmit: async (form: any) => {
if (req.afterSubmit) {
req.afterSubmit(form);
}
},
});
};
}