1235 lines
41 KiB
JavaScript
1235 lines
41 KiB
JavaScript
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|
import { settingsPayload } from "./SettingsPayload.js";
|
|
import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.js";
|
|
import { useDomainForm } from "./DomainForm.js";
|
|
import { useTaskMonitor } from "./TaskMonitor.js";
|
|
import { useRenewalAction } from "./RenewalAction.js";
|
|
|
|
const defaultDNSVerificationTimeoutSeconds = 60;
|
|
const aliyunDNSVerificationTimeoutSeconds = 6;
|
|
|
|
function dnsVerificationTimeoutFor(providerID) {
|
|
return String(providerID || "").trim().toLowerCase() === "aliyun"
|
|
? aliyunDNSVerificationTimeoutSeconds
|
|
: defaultDNSVerificationTimeoutSeconds;
|
|
}
|
|
|
|
export function useDashboard() {
|
|
const status = ref(null);
|
|
const currentUser = ref(null);
|
|
const canManageSystemSettings = computed(() => currentUser.value?.role === "super_admin");
|
|
const canRunRenewal = computed(() => currentUser.value?.role === "super_admin");
|
|
const domains = ref([]);
|
|
const servers = ref([]);
|
|
const certificates = ref([]);
|
|
const tasks = ref([]);
|
|
const auditLogs = ref([]);
|
|
const settings = ref(null);
|
|
const dnsProviderCatalog = ref([]);
|
|
const domainServers = ref({});
|
|
const serverDomains = computed(() => Object.fromEntries(
|
|
servers.value.map((server) => [
|
|
server.id,
|
|
domains.value.filter((domain) =>
|
|
(domainServers.value[domain.id] || []).some((item) => item.id === server.id),
|
|
),
|
|
]),
|
|
));
|
|
const domainPage = ref(1);
|
|
const domainPageSize = ref(5);
|
|
const domainPageSizeOptions = [5, 10, 15, 30];
|
|
const loading = ref(true);
|
|
const error = ref("");
|
|
const notice = ref("");
|
|
const domainForm = useDomainForm({ fetchAdmin, domains, servers, domainServers, notice, loadData });
|
|
const { saving, formError, showForm, editingId, isEditing, formTitle, form,
|
|
openCreate, openEdit, closeForm, saveDomain } = domainForm;
|
|
const showServerForm = ref(false);
|
|
const serverEditingId = ref("");
|
|
const serverSaving = ref(false);
|
|
const serverFormError = ref("");
|
|
const showSettings = ref(false);
|
|
const settingsSaving = ref(false);
|
|
const settingsError = ref("");
|
|
const dnsTesting = ref(false);
|
|
const dnsTestError = ref("");
|
|
const dnsVerificationElapsedSeconds = ref(0);
|
|
const dnsVerificationTimeoutSeconds = ref(defaultDNSVerificationTimeoutSeconds);
|
|
const verificationClock = ref(Date.now());
|
|
let verificationTimer;
|
|
let dnsVerificationTimer;
|
|
let dnsRequestVersion = 0;
|
|
const settingsForm = ref(createEmptySettingsForm());
|
|
const showCertificateImport = ref(false);
|
|
const importDomain = ref(null);
|
|
const importSaving = ref(false);
|
|
const importError = ref("");
|
|
const importResult = ref(null);
|
|
const showCertificateVersions = ref(false);
|
|
const versionDomain = ref(null);
|
|
const certificateVersions = ref([]);
|
|
const versionsLoading = ref(false);
|
|
const versionsError = ref("");
|
|
const restoreSubmitting = ref("");
|
|
let importIdempotencyKey = "";
|
|
const importFiles = ref({
|
|
certificate: null,
|
|
private_key: null,
|
|
issuer_bundle: null,
|
|
});
|
|
const showTaskDetails = ref(false);
|
|
const taskDetails = ref(null);
|
|
const taskAttempts = ref([]);
|
|
const taskDetailsLoading = ref(false);
|
|
const taskDetailsError = ref("");
|
|
const deploymentResult = ref(null);
|
|
const domainActionRunning = ref(false);
|
|
const taskMonitor = useTaskMonitor({
|
|
fetchAdmin, tasks, deploymentResult, showTaskDetails, taskDetails,
|
|
taskAttempts, taskDetailsLoading, taskDetailsError,
|
|
refreshResources: refreshTaskResources,
|
|
});
|
|
const { runRenewalNow, renewalSubmitting } = useRenewalAction({
|
|
canRunRenewal, error, notice, fetchAdmin, taskMonitor,
|
|
});
|
|
const hasOpenModal = computed(
|
|
() =>
|
|
showForm.value ||
|
|
showServerForm.value ||
|
|
showSettings.value ||
|
|
showCertificateImport.value ||
|
|
showCertificateVersions.value ||
|
|
deploymentResult.value !== null ||
|
|
showTaskDetails.value,
|
|
);
|
|
let previousBodyOverflow = "";
|
|
|
|
const domainPageCount = computed(() =>
|
|
Math.max(1, Math.ceil(domains.value.length / domainPageSize.value)),
|
|
);
|
|
const paginatedDomains = computed(() => {
|
|
const start = (domainPage.value - 1) * domainPageSize.value;
|
|
return domains.value.slice(start, start + domainPageSize.value);
|
|
});
|
|
const domainPageStart = computed(() =>
|
|
domains.value.length === 0 ? 0 : (domainPage.value - 1) * domainPageSize.value + 1,
|
|
);
|
|
const domainPageEnd = computed(() =>
|
|
Math.min(domainPage.value * domainPageSize.value, domains.value.length),
|
|
);
|
|
|
|
watch(domainPageSize, () => {
|
|
domainPage.value = 1;
|
|
});
|
|
|
|
watch(domains, () => {
|
|
if (domainPage.value > domainPageCount.value) {
|
|
domainPage.value = domainPageCount.value;
|
|
}
|
|
});
|
|
|
|
watch(hasOpenModal, (isOpen) => {
|
|
if (isOpen) {
|
|
previousBodyOverflow = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
return;
|
|
}
|
|
document.body.style.overflow = previousBodyOverflow;
|
|
});
|
|
|
|
const certificateTypeLabels = {
|
|
single: "单域名",
|
|
wildcard: "泛域名",
|
|
san: "SAN 多域名",
|
|
};
|
|
const taskStatusLabels = {
|
|
queued: "排队中",
|
|
running: "执行中",
|
|
success: "已完成",
|
|
failed: "失败",
|
|
cancelled: "已取消",
|
|
cancelling: "\u53d6\u6d88\u4e2d",
|
|
retry_wait: "\u7b49\u5f85\u91cd\u8bd5",
|
|
partial_success: "\u90e8\u5206\u6210\u529f",
|
|
};
|
|
taskStatusLabels.error = taskStatusLabels.failed;
|
|
const taskTypeLabels = {
|
|
renewal_check: "全局续期检查",
|
|
management_renewal: "管理证书续期",
|
|
certificate_issue: "申请证书",
|
|
certificate_import: "导入证书",
|
|
certificate_deploy: "部署证书",
|
|
certificate_validate: "校验证书",
|
|
certificate_restore: "恢复证书版本",
|
|
renewal_run: "自动续期",
|
|
dns_test: "DNS 在线测试",
|
|
};
|
|
const auditActionLabels = {
|
|
renewal_check: "全局续期检查",
|
|
management_renewal: "管理证书续期",
|
|
certificate_issue: "申请证书",
|
|
certificate_import: "导入证书",
|
|
certificate_deploy: "部署证书",
|
|
certificate_validate: "校验证书",
|
|
certificate_version_restore: "恢复证书版本",
|
|
task_cancel: "取消任务",
|
|
settings_update: "更新系统设置",
|
|
domain_create: "创建域名",
|
|
domain_update: "更新域名",
|
|
domain_delete: "删除域名",
|
|
server_create: "创建服务器",
|
|
server_update: "更新服务器",
|
|
server_delete: "删除服务器",
|
|
renewal_run: "执行自动续期",
|
|
};
|
|
const taskStageLabels = {
|
|
renewal_check: "检查管理证书和业务域名续期",
|
|
management_renewal: "管理证书续期",
|
|
preflight: "续期前置检查",
|
|
issue: "申请管理证书",
|
|
activate: "激活管理证书",
|
|
record: "保存任务记录",
|
|
retry_wait: "等待续期重试",
|
|
service_restart: "服务重启恢复",
|
|
queued: "排队中",
|
|
certificate_issue: "申请证书",
|
|
certificate_validate: "校验证书",
|
|
certificate_deploy: "部署证书",
|
|
deployment: "远程部署",
|
|
verification: "部署后校验",
|
|
rollback: "回滚处理",
|
|
completed: "已完成",
|
|
};
|
|
const attemptStatusLabels = {
|
|
running: "执行中",
|
|
success: "已完成",
|
|
failed: "失败",
|
|
cancelled: "已取消",
|
|
cancelling: "\u53d6\u6d88\u4e2d",
|
|
retry_wait: "\u7b49\u5f85\u91cd\u8bd5",
|
|
partial_success: "\u90e8\u5206\u6210\u529f",
|
|
};
|
|
const domainStatusLabels = {
|
|
pending: "等待申请",
|
|
active: "正常",
|
|
valid: "正常",
|
|
error: "错误",
|
|
};
|
|
const httpsModeLabels = { direct: "核心服务 HTTPS", proxy: "反向代理 HTTPS" };
|
|
const environmentLabels = { staging: "测试环境", production: "生产环境" };
|
|
const webServerLabels = {
|
|
nginx: "Nginx",
|
|
caddy: "Caddy",
|
|
apache: "Apache",
|
|
custom: "自定义网站服务",
|
|
};
|
|
const dnsProviders = [
|
|
{ value: "cloudflare", label: "Cloudflare" },
|
|
{ value: "aliyun", label: "阿里云 DNS" },
|
|
{ value: "dnspod", label: "DNSPod" },
|
|
{ value: "cname", label: "CNAME 委派" },
|
|
{ value: "huaweicloud", label: "华为云" },
|
|
{ value: "tencentcloud", label: "腾讯云" },
|
|
{ value: "route53", label: "AWS Route 53" },
|
|
{ value: "googlecloud", label: "Google Cloud DNS" },
|
|
{ value: "other", label: "其他服务商" },
|
|
];
|
|
|
|
const selectedDNSProvider = computed(() =>
|
|
dnsProviderCatalog.value.find(
|
|
(provider) => provider.id === settingsForm.value.dns.provider,
|
|
) || null,
|
|
);
|
|
const dnsVerificationRequired = computed(() => {
|
|
const current = settingsForm.value.dns;
|
|
const saved = settings.value?.dns;
|
|
if (!current.provider) return false;
|
|
return (
|
|
current.provider !== saved?.provider ||
|
|
!saved?.credential_configured ||
|
|
Boolean(current.test_domain.trim()) ||
|
|
Object.values(current.credentials || {}).some((value) => String(value || "").trim()) ||
|
|
Boolean(current.verification_token) ||
|
|
current.verification_state === "pending"
|
|
);
|
|
});
|
|
const dnsReadyToSave = computed(() => {
|
|
verificationClock.value;
|
|
if (dnsTesting.value) return false;
|
|
if (!dnsVerificationRequired.value) return true;
|
|
const dns = settingsForm.value.dns;
|
|
return Boolean(dns.credential_ref && dns.verification_token) &&
|
|
new Date(dns.expires_at).getTime() > Date.now();
|
|
});
|
|
|
|
function updateDNSVerificationTimeoutSeconds(value) {
|
|
const timeout = Number(value);
|
|
if (Number.isFinite(timeout) && timeout > 0) {
|
|
dnsVerificationTimeoutSeconds.value = Math.floor(timeout);
|
|
}
|
|
}
|
|
|
|
function stopDNSVerificationTimer(requestVersion) {
|
|
if (requestVersion !== undefined && requestVersion !== dnsRequestVersion) return;
|
|
if (dnsVerificationTimer) {
|
|
window.clearInterval(dnsVerificationTimer);
|
|
dnsVerificationTimer = undefined;
|
|
}
|
|
dnsVerificationElapsedSeconds.value = 0;
|
|
}
|
|
|
|
function startDNSVerificationTimer(requestVersion, providerID) {
|
|
stopDNSVerificationTimer();
|
|
dnsVerificationTimeoutSeconds.value = dnsVerificationTimeoutFor(providerID);
|
|
const startedAt = Date.now();
|
|
const updateElapsedSeconds = () => {
|
|
if (requestVersion !== dnsRequestVersion) return;
|
|
dnsVerificationElapsedSeconds.value = Math.max(
|
|
0,
|
|
Math.floor((Date.now() - startedAt) / 1000),
|
|
);
|
|
};
|
|
updateElapsedSeconds();
|
|
dnsVerificationTimer = window.setInterval(updateElapsedSeconds, 1000);
|
|
}
|
|
|
|
watch(
|
|
() => [settingsForm.value.dns.provider, settingsForm.value.dns.test_domain, settingsForm.value.dns.credentials],
|
|
handleDNSProviderChange,
|
|
{ deep: true, flush: "sync" },
|
|
);
|
|
|
|
const defaultCertificatePath = "/ssl/{域名}/{证书类型}/cert.pem";
|
|
const defaultCertificateChainPath = "/ssl/{域名}/{证书类型}/fullchain.pem";
|
|
const defaultPrivateKeyPath = "/ssl/{域名}/{证书类型}/key.pem";
|
|
|
|
const serverForm = ref(createEmptyServerForm());
|
|
async function fetchAdmin(path, options = {}) {
|
|
const response = await fetch(path, {
|
|
...options,
|
|
credentials: "include",
|
|
});
|
|
if (response.status === 401) {
|
|
window.location.assign("/login");
|
|
throw new Error("登录会话已过期,请重新登录");
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function responseError(response, fallback) {
|
|
const result = await response.json().catch(() => ({}));
|
|
if (result.error) return result.error;
|
|
if (response.status === 403) return "当前账号没有管理权限";
|
|
if (response.status === 409) return "当前服务状态不允许执行此操作";
|
|
if (response.status >= 500) return "核心服务暂时不可用,请稍后重试";
|
|
return fallback;
|
|
}
|
|
|
|
function taskTypeLabel(value) {
|
|
return displayLabel(taskTypeLabels, value);
|
|
}
|
|
|
|
function auditActionLabel(value) {
|
|
if (typeof value === "string" && /\p{Script=Han}/u.test(value)) {
|
|
return value.trim();
|
|
}
|
|
return displayLabel(auditActionLabels, value);
|
|
}
|
|
|
|
function taskStageLabel(value) {
|
|
return displayLabel(taskStageLabels, value);
|
|
}
|
|
|
|
function attemptStatusLabel(value) {
|
|
return displayLabel(attemptStatusLabels, value);
|
|
}
|
|
|
|
function createEmptyServerForm() {
|
|
return {
|
|
name: "",
|
|
address: "",
|
|
port: 22,
|
|
username: "",
|
|
auth_method: "password",
|
|
password: "",
|
|
passwordConfigured: false,
|
|
host_key_fingerprint: "",
|
|
certificate_path: defaultCertificatePath,
|
|
private_key_path: defaultPrivateKeyPath,
|
|
certificate_chain_path: defaultCertificateChainPath,
|
|
web_server: "nginx",
|
|
validation_command: "",
|
|
reload_command: "",
|
|
server_ids: [],
|
|
};
|
|
}
|
|
|
|
function createRandomSuffix() {
|
|
const bytes = new Uint8Array(6);
|
|
crypto.getRandomValues(bytes);
|
|
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(
|
|
"",
|
|
);
|
|
}
|
|
|
|
function createEmptySettingsForm() {
|
|
return {
|
|
server: { host: "", port: 5200, https_mode: "direct", proxy_https_port: 0 },
|
|
web: { enabled: true, hostname: "", public_ip: "" },
|
|
paths: { certificate: "" },
|
|
acme: {
|
|
ca: "letsencrypt",
|
|
environment: "staging",
|
|
account_email: "",
|
|
renewal_check_interval_days: 3,
|
|
renewal_near_expiry_days: 10,
|
|
renewal_threshold_days: 6,
|
|
renewal_urgent_threshold_days: 3,
|
|
renewal_max_retries: 3,
|
|
renewal_retry_interval_mins: 30,
|
|
propagation_timeout_secs: 300,
|
|
propagation_polling_interval_secs: 5,
|
|
},
|
|
dns: {
|
|
provider: "",
|
|
credential_configured: false,
|
|
verification_state: "not_configured",
|
|
test_domain: "",
|
|
credentials: {},
|
|
credential_ref: "",
|
|
verification_token: "",
|
|
expires_at: "",
|
|
test_report: null,
|
|
},
|
|
notification: {
|
|
http_enabled: false,
|
|
smtp_enabled: false,
|
|
provider: "",
|
|
endpoint: "",
|
|
http_path: "/api/email/send",
|
|
http_login_path: "/api/login",
|
|
http_auth_mode: "none",
|
|
event_mode: "detailed",
|
|
include_deployment_details: true,
|
|
include_certificate_serial: false,
|
|
include_management_url: false,
|
|
sender_name: "Scert",
|
|
sender_email: "",
|
|
recipients: [],
|
|
timeout_secs: 10,
|
|
retry_count: 3,
|
|
retry_intervals_secs: [30, 120, 600],
|
|
smtp_host: "",
|
|
smtp_port: 587,
|
|
smtp_username: "",
|
|
smtp_password: "",
|
|
smtp_mode: "starttls",
|
|
smtp_allow_plaintext_auth: false,
|
|
smtp_credential_configured: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalizeSettings(value) {
|
|
const defaults = createEmptySettingsForm();
|
|
const source = value || {};
|
|
return {
|
|
...defaults,
|
|
...source,
|
|
server: { ...defaults.server, ...(source.server || {}) },
|
|
web: { ...defaults.web, ...(source.web || {}) },
|
|
paths: { ...defaults.paths, ...(source.paths || {}) },
|
|
acme: { ...defaults.acme, ...(source.acme || {}) },
|
|
dns: {
|
|
...defaults.dns,
|
|
...(source.dns || {}),
|
|
credentials: {},
|
|
credential_ref: "",
|
|
verification_token: "",
|
|
expires_at: "",
|
|
test_domain: "",
|
|
test_report: null,
|
|
},
|
|
notification: {
|
|
...defaults.notification,
|
|
...(source.notification || {}),
|
|
smtp_password: "",
|
|
recipients: Array.isArray(source.notification?.recipients)
|
|
? source.notification.recipients
|
|
: [],
|
|
},
|
|
};
|
|
}
|
|
|
|
function displayLabel(labels, value) {
|
|
return labels[value] || (value ? "未知" : "—");
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return "尚未签发";
|
|
return new Intl.DateTimeFormat("zh-CN", {
|
|
dateStyle: "medium",
|
|
timeZone: "Asia/Shanghai",
|
|
}).format(new Date(value));
|
|
}
|
|
|
|
function daysRemaining(value) {
|
|
if (!value) return "待申请";
|
|
const days = Math.ceil((new Date(value).getTime() - Date.now()) / 86400000);
|
|
return days >= 0 ? `剩余 ${days} 天` : `已过期 ${Math.abs(days)} 天`;
|
|
}
|
|
|
|
function statusClass(value) {
|
|
return value === "active" || value === "valid" || value === "success"
|
|
? "success"
|
|
: value === "error" || value === "failed"
|
|
? "danger"
|
|
: "warning";
|
|
}
|
|
|
|
function previousDomainPage() {
|
|
domainPage.value = Math.max(1, domainPage.value - 1);
|
|
}
|
|
|
|
function nextDomainPage() {
|
|
domainPage.value = Math.min(domainPageCount.value, domainPage.value + 1);
|
|
}
|
|
|
|
function openServerCreate() {
|
|
serverEditingId.value = "";
|
|
serverForm.value = createEmptyServerForm();
|
|
serverFormError.value = "";
|
|
showServerForm.value = true;
|
|
}
|
|
|
|
function openServerEdit(item) {
|
|
serverEditingId.value = item.id;
|
|
serverForm.value = {
|
|
...item,
|
|
auth_method: "password",
|
|
passwordConfigured: item.password_configured === true,
|
|
password: "",
|
|
validation_command: (item.validation_command || []).join("\n"),
|
|
reload_command: (item.reload_command || []).join("\n"),
|
|
server_ids: [],
|
|
};
|
|
serverFormError.value = "";
|
|
showServerForm.value = true;
|
|
}
|
|
|
|
function closeServerForm() {
|
|
if (!serverSaving.value) {
|
|
showServerForm.value = false;
|
|
}
|
|
}
|
|
|
|
function parseCommand(value) {
|
|
return value
|
|
.split(/\n/)
|
|
.map((item) => item.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
async function saveServer() {
|
|
serverFormError.value = "";
|
|
serverSaving.value = true;
|
|
try {
|
|
const payload = {
|
|
name: serverForm.value.name,
|
|
address: serverForm.value.address,
|
|
port: Number(serverForm.value.port),
|
|
username: serverForm.value.username,
|
|
host_key_fingerprint: serverForm.value.host_key_fingerprint,
|
|
certificate_path: serverForm.value.certificate_path,
|
|
private_key_path: serverForm.value.private_key_path,
|
|
certificate_chain_path: serverForm.value.certificate_chain_path,
|
|
web_server: serverForm.value.web_server,
|
|
validation_command: parseCommand(serverForm.value.validation_command),
|
|
reload_command: parseCommand(serverForm.value.reload_command),
|
|
};
|
|
payload.password = serverForm.value.password;
|
|
const path = serverEditingId.value
|
|
? `/api/v1/servers/${serverEditingId.value}`
|
|
: "/api/v1/servers";
|
|
const response = await fetchAdmin(path, {
|
|
method: serverEditingId.value ? "PUT" : "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const result = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(result.error || "保存服务器失败");
|
|
showServerForm.value = false;
|
|
notice.value = serverEditingId.value
|
|
? "服务器配置已更新"
|
|
: "服务器已添加";
|
|
await loadData();
|
|
} catch (err) {
|
|
serverFormError.value = err.message || "保存服务器失败";
|
|
} finally {
|
|
serverSaving.value = false;
|
|
}
|
|
}
|
|
|
|
async function refreshTaskResources(signal) {
|
|
// 任务结束后只刷新运行数据,不覆盖用户正在编辑的系统设置或表单。
|
|
const responses = await Promise.all([
|
|
fetchAdmin("/api/v1/status", { signal }),
|
|
fetchAdmin("/api/v1/domains", { signal }),
|
|
fetchAdmin("/api/v1/certificates", { signal }),
|
|
fetchAdmin("/api/v1/audit-logs?limit=20", { signal }),
|
|
]);
|
|
if (responses.some((response) => !response.ok)) throw new Error("刷新任务关联数据失败");
|
|
const [statusResult, domainResult, certificateResult, auditResult] = await Promise.all(
|
|
responses.map((response) => response.json()),
|
|
);
|
|
if (signal.aborted) return;
|
|
if (![domainResult, certificateResult, auditResult].every((result) => Array.isArray(result.items))) {
|
|
throw new Error("任务关联数据响应不完整");
|
|
}
|
|
status.value = statusResult;
|
|
domains.value = domainResult.items;
|
|
certificates.value = certificateResult.items;
|
|
auditLogs.value = auditResult.items;
|
|
}
|
|
|
|
async function runDomainAction(item, action, successMessage) {
|
|
if (domainActionRunning.value) return;
|
|
domainActionRunning.value = true;
|
|
if (action === "deploy") {
|
|
deploymentResult.value = { domain: item.name, running: true, report: null, error: "" };
|
|
}
|
|
error.value = "";
|
|
notice.value = "";
|
|
try {
|
|
const options = { method: "POST" };
|
|
if (action === "issue" || action === "deploy" || action === "certificate/validate") {
|
|
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
|
|
const prefix = action === "certificate/validate" ? "certificate-validate" : action;
|
|
options.headers = { "Idempotency-Key": `${prefix}-${item.id}-${suffix}` };
|
|
}
|
|
const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options);
|
|
const result = await response.json().catch(() => ({}));
|
|
if (response.status === 202 && !result.task?.id) {
|
|
throw new Error("服务未返回任务编号,请在运行概览确认是否已接收,勿重复提交");
|
|
}
|
|
if ((response.ok || response.status === 409 && result.replayed) && result.task?.id &&
|
|
(response.status === 202 || result.accepted || result.replayed)) {
|
|
notice.value = `任务已提交:${result.task.id},可在运行概览查看执行结果`;
|
|
if (action === "deploy") {
|
|
deploymentResult.value = {
|
|
domain: item.name, running: false, pending: true,
|
|
taskID: result.task.id, taskStatus: result.task.status,
|
|
taskMessage: result.task.message || "", report: null, error: "", queryError: "",
|
|
};
|
|
}
|
|
if (action === "certificate/validate") await taskMonitor.openTaskDetails(result.task);
|
|
return;
|
|
}
|
|
if (action === "deploy") {
|
|
deploymentResult.value = {
|
|
domain: item.name,
|
|
running: false,
|
|
report: result.deployment || null,
|
|
error: response.ok && result.deployment ? "" : result.error || deploymentSummary(null),
|
|
};
|
|
}
|
|
if (!response.ok) throw new Error(result.error || "域名操作失败");
|
|
notice.value = successMessage;
|
|
if (action === "deploy") notice.value = deploymentSummary(result.deployment);
|
|
} catch (err) {
|
|
error.value = err.message || "域名操作失败";
|
|
} finally {
|
|
const actionError = error.value;
|
|
if (action === "deploy" && deploymentResult.value) {
|
|
deploymentResult.value.running = false;
|
|
if (actionError) deploymentResult.value.error = actionError;
|
|
}
|
|
domainActionRunning.value = false;
|
|
await loadData();
|
|
if (actionError) error.value = actionError;
|
|
}
|
|
}
|
|
function issueDomain(item) {
|
|
return runDomainAction(item, "issue", `已提交 ${item.name} 的证书申请`);
|
|
}
|
|
|
|
function validateDomain(item) {
|
|
return runDomainAction(item, "certificate/validate", `已提交 ${item.name} 的证书校验`);
|
|
}
|
|
|
|
function deployDomain(item) {
|
|
return runDomainAction(item, "deploy", `已提交 ${item.name} 的证书部署`);
|
|
}
|
|
|
|
async function cancelTask(item) {
|
|
if (!window.confirm("确定取消这项正在执行的证书任务吗?")) return;
|
|
error.value = "";
|
|
notice.value = "";
|
|
try {
|
|
const response = await fetchAdmin(`/api/v1/tasks/${item.id}`, { method: "DELETE" });
|
|
const result = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(result.error || "取消任务失败");
|
|
notice.value = response.status === 202 || result.task?.status === "cancelling"
|
|
? "正在取消,等待任务停止和部署收尾;此时不能重试"
|
|
: "证书任务已取消";
|
|
await loadData();
|
|
await taskMonitor.refreshTaskStatus();
|
|
} catch (err) {
|
|
error.value = err.message || "取消任务失败";
|
|
}
|
|
}
|
|
async function retryTask(item) {
|
|
if (!window.confirm("确定重新执行这项失败的证书任务吗?")) return;
|
|
error.value = "";
|
|
notice.value = "";
|
|
try {
|
|
const response = await fetchAdmin(`/api/v1/tasks/${item.id}/retry`, { method: "POST" });
|
|
const result = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(result.error || "重试任务失败");
|
|
notice.value = "重试已提交,请在任务详情查看本次执行结果";
|
|
await loadData();
|
|
await taskMonitor.refreshTaskStatus();
|
|
} catch (err) {
|
|
error.value = err.message || "重试任务失败";
|
|
}
|
|
}
|
|
|
|
function handleDNSProviderChange() {
|
|
dnsRequestVersion += 1;
|
|
stopDNSVerificationTimer();
|
|
dnsTesting.value = false;
|
|
const dns = settingsForm.value.dns;
|
|
const saved = settings.value?.dns;
|
|
dnsTestError.value = "";
|
|
dns.credential_ref = "";
|
|
dns.verification_token = "";
|
|
dns.expires_at = "";
|
|
dns.test_report = null;
|
|
const hasNewInput = Boolean(dns.test_domain.trim()) ||
|
|
Object.values(dns.credentials || {}).some((value) => String(value || "").trim());
|
|
if (!hasNewInput && dns.provider && dns.provider === saved?.provider && saved.credential_configured) {
|
|
dns.verification_state = saved.verification_state || "verified";
|
|
return;
|
|
}
|
|
dns.verification_state = dns.provider ? "pending" : "not_configured";
|
|
}
|
|
|
|
function clearDNSCredentials() {
|
|
settingsForm.value.dns.credentials = {};
|
|
dnsTestError.value = "";
|
|
}
|
|
|
|
async function testDNS() {
|
|
if (!canManageSystemSettings.value) {
|
|
dnsTestError.value = "只有超级管理员可以进行 DNS 在线测试";
|
|
return;
|
|
}
|
|
handleDNSProviderChange();
|
|
const dns = settingsForm.value.dns;
|
|
const provider = selectedDNSProvider.value;
|
|
dnsTestError.value = "";
|
|
dns.test_report = null;
|
|
if (!dns.provider || !provider) {
|
|
dnsTestError.value = "请选择 DNS 验证服务商";
|
|
return;
|
|
}
|
|
if (!dns.test_domain.trim()) {
|
|
dnsTestError.value = "请输入 DNS 在线测试域名";
|
|
return;
|
|
}
|
|
const credentials = Object.fromEntries(
|
|
Object.entries(dns.credentials || {})
|
|
.map(([key, value]) => [key, String(value || "").trim()])
|
|
.filter(([, value]) => value),
|
|
);
|
|
const missingField = (provider.credential_fields || []).find(
|
|
(field) => field.required && !credentials[field.name],
|
|
);
|
|
if (missingField) {
|
|
dnsTestError.value = `请填写${missingField.label}`;
|
|
return;
|
|
}
|
|
const requestVersion = dnsRequestVersion;
|
|
dnsTesting.value = true;
|
|
dns.verification_state = "pending";
|
|
startDNSVerificationTimer(requestVersion, dns.provider);
|
|
try {
|
|
const response = await fetchAdmin("/api/v1/dns/test", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
provider: dns.provider,
|
|
test_domain: dns.test_domain.trim(),
|
|
credentials,
|
|
}),
|
|
});
|
|
const result = await response.json().catch(() => ({}));
|
|
if (requestVersion !== dnsRequestVersion) return;
|
|
updateDNSVerificationTimeoutSeconds(result.verification_timeout_secs);
|
|
dns.test_report = result.test_report || null;
|
|
if (!response.ok) {
|
|
throw new Error(result.error || "DNS API 在线测试失败");
|
|
}
|
|
dns.credential_ref = result.credential_ref || "";
|
|
dns.verification_token = result.verification_token || "";
|
|
dns.expires_at = result.expires_at || "";
|
|
dns.verification_state = "verified";
|
|
dns.credential_configured = true;
|
|
notice.value = "DNS API 在线测试通过,可以保存系统设置";
|
|
} catch (err) {
|
|
if (requestVersion !== dnsRequestVersion) return;
|
|
dns.credential_ref = "";
|
|
dns.verification_token = "";
|
|
dns.verification_state = "pending";
|
|
dnsTestError.value = err.message || "DNS API 在线测试失败";
|
|
} finally {
|
|
if (requestVersion === dnsRequestVersion) {
|
|
stopDNSVerificationTimer(requestVersion);
|
|
dnsTesting.value = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
function openSettings() {
|
|
settingsError.value = "";
|
|
dnsTestError.value = "";
|
|
settingsForm.value = normalizeSettings(settings.value);
|
|
showSettings.value = true;
|
|
}
|
|
|
|
function closeSettings() {
|
|
if (!settingsSaving.value) {
|
|
handleDNSProviderChange();
|
|
showSettings.value = false;
|
|
}
|
|
}
|
|
|
|
async function saveSettings() {
|
|
settingsError.value = "";
|
|
if (!canManageSystemSettings.value) {
|
|
settingsError.value = "只有超级管理员可以保存系统设置";
|
|
return;
|
|
}
|
|
verificationClock.value = Date.now();
|
|
if (!dnsReadyToSave.value) {
|
|
settingsError.value = "DNS 服务商配置尚未通过在线测试,请先完成测试";
|
|
return;
|
|
}
|
|
settingsSaving.value = true;
|
|
try {
|
|
const payload = settingsPayload(settingsForm.value);
|
|
const response = await fetchAdmin("/api/v1/settings", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const result = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(result.error || "保存系统设置失败");
|
|
settings.value = result.settings;
|
|
showSettings.value = false;
|
|
notice.value = result.message || "系统设置已保存";
|
|
await loadData();
|
|
} catch (err) {
|
|
settingsError.value = err.message || "保存系统设置失败";
|
|
} finally {
|
|
settingsSaving.value = false;
|
|
}
|
|
}
|
|
|
|
function openCertificateImport(item) {
|
|
importDomain.value = item;
|
|
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
|
|
importIdempotencyKey = `import-${item.id}-${suffix}`;
|
|
importFiles.value = {
|
|
certificate: null,
|
|
private_key: null,
|
|
issuer_bundle: null,
|
|
};
|
|
importResult.value = null;
|
|
importError.value = "";
|
|
showCertificateImport.value = true;
|
|
}
|
|
|
|
function closeCertificateImport() {
|
|
if (!importSaving.value) showCertificateImport.value = false;
|
|
}
|
|
|
|
async function openCertificateVersions(item) {
|
|
versionDomain.value = item;
|
|
certificateVersions.value = [];
|
|
versionsError.value = "";
|
|
versionsLoading.value = true;
|
|
showCertificateVersions.value = true;
|
|
try {
|
|
const response = await fetchAdmin(
|
|
`/api/v1/domains/${item.id}/certificate/versions`,
|
|
);
|
|
const result = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(result.error || "读取证书历史版本失败");
|
|
certificateVersions.value = Array.isArray(result.items) ? result.items : [];
|
|
} catch (err) {
|
|
versionsError.value = err.message || "读取证书历史版本失败";
|
|
} finally {
|
|
versionsLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function closeCertificateVersions() {
|
|
if (!restoreSubmitting.value) showCertificateVersions.value = false;
|
|
}
|
|
|
|
async function restoreCertificateVersion(version) {
|
|
if (!version?.history_id || version.current || restoreSubmitting.value) return;
|
|
if (!window.confirm("确定恢复这个证书历史版本吗?恢复只更新 Scert 本地托管证书,不代表所有远端服务器已经部署。")) {
|
|
return;
|
|
}
|
|
const domain = versionDomain.value;
|
|
if (!domain) return;
|
|
const suffix = crypto.randomUUID ? crypto.randomUUID() : createRandomSuffix();
|
|
restoreSubmitting.value = version.history_id;
|
|
versionsError.value = "";
|
|
try {
|
|
const response = await fetchAdmin(
|
|
`/api/v1/domains/${domain.id}/certificate/versions/${encodeURIComponent(version.history_id)}/restore`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Idempotency-Key": `certificate-restore-${domain.id}-${suffix}` },
|
|
},
|
|
);
|
|
const result = await response.json().catch(() => ({}));
|
|
const replayed = response.status === 409 && result.replayed;
|
|
if (!(response.ok || replayed) || !result.task?.id) {
|
|
throw new Error(result.error || "提交证书历史版本恢复失败");
|
|
}
|
|
notice.value = `证书历史版本恢复任务已提交:${result.task.id},恢复完成后请另行确认远端部署状态`;
|
|
showCertificateVersions.value = false;
|
|
await loadData();
|
|
await openTaskDetails(result.task);
|
|
} catch (err) {
|
|
versionsError.value = err.message || "提交证书历史版本恢复失败";
|
|
} finally {
|
|
restoreSubmitting.value = "";
|
|
}
|
|
}
|
|
|
|
function selectCertificateImportFile(kind, event) {
|
|
importFiles.value[kind] = event.target.files?.[0] || null;
|
|
importError.value = "";
|
|
}
|
|
|
|
async function importCertificate() {
|
|
if (importSaving.value || importResult.value) return;
|
|
importError.value = "";
|
|
importResult.value = null;
|
|
if (!importFiles.value.certificate || !importFiles.value.private_key) {
|
|
importError.value = "请选择证书文件和证书密钥文件";
|
|
return;
|
|
}
|
|
const maxSize = 20 * 1024 * 1024;
|
|
const files = Object.values(importFiles.value).filter(Boolean);
|
|
if (files.some((file) => file.size > maxSize)) {
|
|
importError.value = "单个证书文件不能超过 20 MB";
|
|
return;
|
|
}
|
|
importSaving.value = true;
|
|
try {
|
|
const body = new FormData();
|
|
body.append("certificate", importFiles.value.certificate);
|
|
body.append("private_key", importFiles.value.private_key);
|
|
if (importFiles.value.issuer_bundle) {
|
|
body.append("issuer_bundle", importFiles.value.issuer_bundle);
|
|
}
|
|
const response = await fetchAdmin(
|
|
`/api/v1/domains/${importDomain.value.id}/certificate/import`,
|
|
{ method: "POST", body, headers: { "Idempotency-Key": importIdempotencyKey } },
|
|
);
|
|
const result = await response.json().catch(() => ({}));
|
|
if (response.status === 202 && !result.task?.id) {
|
|
throw new Error("服务未返回任务编号,请在运行概览确认是否已接收,勿重复提交");
|
|
}
|
|
if ((response.ok || response.status === 409 && result.replayed) && result.task?.id &&
|
|
(response.status === 202 || result.accepted || result.replayed)) {
|
|
importResult.value = result;
|
|
showCertificateImport.value = false;
|
|
notice.value = `导入任务已提交:${result.task.id},可在运行概览查看执行结果`;
|
|
deploymentResult.value = {
|
|
domain: importDomain.value.name, running: false, pending: true,
|
|
taskID: result.task.id, taskStatus: result.task.status,
|
|
taskMessage: result.task.message || "", report: null, error: "", queryError: "",
|
|
};
|
|
await loadData();
|
|
return;
|
|
}
|
|
if (!response.ok && result.imported !== true) {
|
|
throw new Error(result.error || "导入证书失败");
|
|
}
|
|
importResult.value = result;
|
|
notice.value = `域名 ${importDomain.value.name} 的证书已导入`;
|
|
if (!response.ok || result.deployment?.total > 0) {
|
|
showCertificateImport.value = false;
|
|
deploymentResult.value = {
|
|
domain: importDomain.value.name,
|
|
running: false,
|
|
certificateImported: true,
|
|
report: result.deployment || null,
|
|
error: response.ok ? "" : result.error || "证书已导入,但部署未完成。请重试部署,无需重新导入。",
|
|
};
|
|
}
|
|
await loadData();
|
|
} catch (err) {
|
|
importError.value = err.message || "导入证书失败";
|
|
} finally {
|
|
importSaving.value = false;
|
|
}
|
|
}
|
|
|
|
async function deleteServer(item) {
|
|
if (!window.confirm(`确定删除服务器“${item.name}”吗?关联域名也会解除。`))
|
|
return;
|
|
try {
|
|
const response = await fetchAdmin(`/api/v1/servers/${item.id}`, {
|
|
method: "DELETE",
|
|
});
|
|
const result = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(result.error || "删除服务器失败");
|
|
notice.value = "服务器已删除";
|
|
await loadData();
|
|
} catch (err) {
|
|
error.value = err.message || "删除服务器失败";
|
|
}
|
|
}
|
|
|
|
async function deleteDomain(item) {
|
|
if (
|
|
!window.confirm(
|
|
`确定删除域名“${item.name}”吗?删除前请确认没有正在执行的任务。`,
|
|
)
|
|
)
|
|
return;
|
|
error.value = "";
|
|
notice.value = "";
|
|
try {
|
|
const response = await fetchAdmin(`/api/v1/domains/${item.id}`, {
|
|
method: "DELETE",
|
|
});
|
|
if (!response.ok) {
|
|
const result = await response.json().catch(() => ({}));
|
|
throw new Error(result.error || "删除域名失败");
|
|
}
|
|
notice.value = "域名已删除";
|
|
await loadData();
|
|
} catch (err) {
|
|
error.value = err.message || "删除域名失败";
|
|
}
|
|
}
|
|
|
|
async function loadData() {
|
|
loading.value = true;
|
|
error.value = "";
|
|
try {
|
|
const [
|
|
statusResponse,
|
|
authResponse,
|
|
domainResponse,
|
|
serverResponse,
|
|
certificateResponse,
|
|
taskResponse,
|
|
auditResponse,
|
|
settingsResponse,
|
|
dnsProviderResponse,
|
|
] = await Promise.all([
|
|
fetchAdmin("/api/v1/status"),
|
|
fetchAdmin("/api/v1/auth/session"),
|
|
fetchAdmin("/api/v1/domains"),
|
|
fetchAdmin("/api/v1/servers"),
|
|
fetchAdmin("/api/v1/certificates"),
|
|
fetchAdmin("/api/v1/tasks?limit=20"),
|
|
fetchAdmin("/api/v1/audit-logs?limit=20"),
|
|
fetchAdmin("/api/v1/settings"),
|
|
fetchAdmin("/api/v1/dns/providers"),
|
|
]);
|
|
const failedResponse = [
|
|
statusResponse,
|
|
domainResponse,
|
|
serverResponse,
|
|
certificateResponse,
|
|
taskResponse,
|
|
auditResponse,
|
|
settingsResponse,
|
|
dnsProviderResponse,
|
|
].find((response) => !response.ok);
|
|
if (failedResponse) {
|
|
throw new Error(await responseError(failedResponse, "读取管理数据失败"));
|
|
}
|
|
status.value = await statusResponse.json();
|
|
currentUser.value = await authResponse.json();
|
|
domains.value = (await domainResponse.json()).items || [];
|
|
servers.value = (await serverResponse.json()).items || [];
|
|
certificates.value = (await certificateResponse.json()).items || [];
|
|
tasks.value = (await taskResponse.json()).items || [];
|
|
auditLogs.value = (await auditResponse.json()).items || [];
|
|
settings.value = await settingsResponse.json();
|
|
const providerResult = await dnsProviderResponse.json();
|
|
dnsProviderCatalog.value = (providerResult.items || []).map((item) => ({
|
|
...item,
|
|
value: item.id,
|
|
label: item.name,
|
|
}));
|
|
settingsForm.value = normalizeSettings(settings.value);
|
|
const associations = await Promise.all(
|
|
domains.value.map(async (item) => {
|
|
const response = await fetchAdmin(
|
|
`/api/v1/domains/${item.id}/servers`,
|
|
);
|
|
return [
|
|
item.id,
|
|
response.ok ? (await response.json()).items || [] : [],
|
|
];
|
|
}),
|
|
);
|
|
domainServers.value = Object.fromEntries(associations);
|
|
} catch (err) {
|
|
error.value = err.message || "无法连接核心服务,请先启动 scert serve";
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadData();
|
|
verificationTimer = window.setInterval(() => {
|
|
verificationClock.value = Date.now();
|
|
}, 1000);
|
|
});
|
|
onBeforeUnmount(() => {
|
|
dnsRequestVersion += 1;
|
|
stopDNSVerificationTimer();
|
|
if (verificationTimer) window.clearInterval(verificationTimer);
|
|
document.body.style.overflow = previousBodyOverflow;
|
|
});
|
|
|
|
return {
|
|
...domainForm,
|
|
...taskMonitor,
|
|
status,
|
|
currentUser,
|
|
canManageSystemSettings,
|
|
canRunRenewal,
|
|
domains,
|
|
servers,
|
|
certificates,
|
|
tasks,
|
|
auditLogs,
|
|
settings,
|
|
settingsForm,
|
|
dnsProviderCatalog,
|
|
selectedDNSProvider,
|
|
dnsTesting,
|
|
dnsTestError,
|
|
dnsVerificationElapsedSeconds,
|
|
dnsVerificationTimeoutSeconds,
|
|
dnsReadyToSave,
|
|
dnsVerificationRequired,
|
|
handleDNSProviderChange,
|
|
clearDNSCredentials,
|
|
testDNS,
|
|
domainServers,
|
|
serverDomains,
|
|
domainPage,
|
|
domainPageSize,
|
|
domainPageSizeOptions,
|
|
domainPageCount,
|
|
paginatedDomains,
|
|
domainPageStart,
|
|
domainPageEnd,
|
|
previousDomainPage,
|
|
nextDomainPage,
|
|
loading,
|
|
saving,
|
|
error,
|
|
notice,
|
|
formError,
|
|
showForm,
|
|
editingId,
|
|
showServerForm,
|
|
serverEditingId,
|
|
serverSaving,
|
|
serverFormError,
|
|
certificateTypeLabels,
|
|
domainStatusLabels,
|
|
taskStatusLabels,
|
|
taskTypeLabels,
|
|
auditActionLabels,
|
|
taskStageLabels,
|
|
attemptStatusLabels,
|
|
httpsModeLabels,
|
|
dnsProviders,
|
|
environmentLabels,
|
|
webServerLabels,
|
|
serverAuthLabel,
|
|
isEditing,
|
|
formTitle,
|
|
form,
|
|
serverForm,
|
|
showSettings,
|
|
settingsSaving,
|
|
settingsError,
|
|
showCertificateImport,
|
|
importDomain,
|
|
importSaving,
|
|
importError,
|
|
importResult,
|
|
defaultCertificatePath,
|
|
defaultCertificateChainPath,
|
|
defaultPrivateKeyPath,
|
|
openCreate,
|
|
openEdit,
|
|
closeForm,
|
|
openServerCreate,
|
|
openServerEdit,
|
|
closeServerForm,
|
|
openCertificateImport,
|
|
closeCertificateImport,
|
|
selectCertificateImportFile,
|
|
importCertificate,
|
|
showCertificateVersions,
|
|
versionDomain,
|
|
certificateVersions,
|
|
versionsLoading,
|
|
versionsError,
|
|
restoreSubmitting,
|
|
openCertificateVersions,
|
|
closeCertificateVersions,
|
|
restoreCertificateVersion,
|
|
saveServer,
|
|
deleteServer,
|
|
saveDomain,
|
|
deleteDomain,
|
|
issueDomain,
|
|
validateDomain,
|
|
deployDomain,
|
|
cancelTask,
|
|
retryTask,
|
|
showTaskDetails,
|
|
taskDetails,
|
|
taskAttempts,
|
|
taskDetailsLoading,
|
|
taskDetailsError,
|
|
deploymentResult,
|
|
taskTypeLabel,
|
|
auditActionLabel,
|
|
taskStageLabel,
|
|
attemptStatusLabel,
|
|
runRenewalNow,
|
|
renewalSubmitting,
|
|
openSettings,
|
|
closeSettings,
|
|
saveSettings,
|
|
loadData,
|
|
displayLabel,
|
|
formatDate,
|
|
daysRemaining,
|
|
statusClass,
|
|
};
|
|
}
|