Scert/frontend/src/views/Dashboard/Dashboard.js

1362 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { settingsPayload } from "./SettingsPayload.js";
import { deploymentSummary, serverAuthLabel } from "./DeploymentFeedback.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 domains = ref([]);
const servers = ref([]);
const sshKeys = ref([]);
const certificates = ref([]);
const tasks = ref([]);
const auditLogs = ref([]);
const settings = ref(null);
const dnsProviderCatalog = ref([]);
const domainServers = ref({});
const domainPage = ref(1);
const domainPageSize = ref(5);
const domainPageSizeOptions = [5, 10, 15, 30];
const loading = ref(true);
const saving = ref(false);
const error = ref("");
const notice = ref("");
const formError = ref("");
const showForm = ref(false);
const editingId = ref("");
const showServerForm = ref(false);
const serverEditingId = ref("");
const serverSaving = ref(false);
const serverFormError = ref("");
const showSSHKeyForm = ref(false);
const sshKeySaving = ref(false);
const sshKeyFormError = ref("");
const sshKeyPlatform = ref("windows");
const sshKeyPassphraseVisible = ref(false);
const sshKeyCommandUsername = ref("用户名");
const sshKeyCommandHost = ref("服务器地址");
const sshKeyRandomSuffix = ref(createSSHKeyRandomSuffix());
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 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 hasOpenModal = computed(
() =>
showForm.value ||
showServerForm.value ||
showSSHKeyForm.value ||
showSettings.value ||
showCertificateImport.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 sshKeyFileName = computed(
() =>
`${sanitizeSSHKeySegment(sshKeyCommandUsername.value, "用户名")}+scert-${sshKeyRandomSuffix.value}`,
);
const sshKeyPath = computed(() => `/ssl/ssh/${sshKeyFileName.value}`);
const sshKeyPrivatePath = computed(() => `${sshKeyPath.value}.key`);
const sshKeyPublicPath = computed(() => sshKeyPath.value);
const sshKeyRemoteTarget = computed(
() =>
`${sanitizeSSHKeySegment(sshKeyCommandUsername.value, "用户名")}@${sanitizeSSHHost(sshKeyCommandHost.value)}`,
);
const sshKeyCommands = computed(() => ({
windows:
`New-Item -ItemType Directory -Force -Path "/ssl/ssh" | Out-Null; ssh-keygen -t ed25519 -C "scert-deploy" -f "${sshKeyPrivatePath.value}"; Move-Item -Force "${sshKeyPrivatePath.value}.pub" "${sshKeyPublicPath.value}"`,
linux:
`sudo install -d -m 700 -o "$(id -un)" -g "$(id -gn)" /ssl/ssh && ssh-keygen -t ed25519 -C "scert-deploy" -f "${sshKeyPrivatePath.value}" && mv -f "${sshKeyPrivatePath.value}.pub" "${sshKeyPublicPath.value}" && chmod 644 "${sshKeyPublicPath.value}"`,
}));
const sshPublicKeyCommands = computed(() => ({
windows:
`Get-Content "${sshKeyPublicPath.value}" | ssh ${sshKeyRemoteTarget.value} "umask 077; mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys"`,
linux:
`ssh-copy-id -i "${sshKeyPublicPath.value}" ${sshKeyRemoteTarget.value}`,
}));
const certificateTypeLabels = {
single: "单域名",
wildcard: "泛域名",
san: "SAN 多域名",
};
const taskStatusLabels = {
queued: "排队中",
running: "执行中",
success: "已完成",
failed: "失败",
cancelled: "已取消",
};
taskStatusLabels.error = taskStatusLabels.failed;
const taskTypeLabels = {
management_renewal: "管理证书续期",
certificate_issue: "申请证书",
certificate_import: "导入证书",
certificate_deploy: "部署证书",
certificate_validate: "校验证书",
certificate_restore: "恢复证书版本",
renewal_run: "自动续期",
dns_test: "DNS 在线测试",
};
const auditActionLabels = {
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: "删除服务器",
ssh_key_create: "添加 SSH 私钥",
ssh_key_delete: "删除 SSH 私钥",
renewal_run: "执行自动续期",
};
const taskStageLabels = {
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: "已取消",
};
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 isEditing = computed(() => Boolean(editingId.value));
const formTitle = computed(() =>
isEditing.value ? "编辑域名配置" : "添加托管域名",
);
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 form = ref(createEmptyForm());
const serverForm = ref(createEmptyServerForm());
const sshKeyForm = ref(createEmptySSHKeyForm());
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 createEmptyForm() {
return {
name: "",
certificate_type: "single",
sans: "",
auto_renew: true,
acme_environment: "staging",
dns_provider: "",
server_ids: [],
};
}
function createEmptyServerForm() {
return {
name: "",
address: "",
port: 22,
username: "",
auth_method: "password",
private_key_ref: "",
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 createEmptySSHKeyForm() {
return { name: "", private_key: "", passphrase: "" };
}
function createSSHKeyRandomSuffix() {
const bytes = new Uint8Array(6);
crypto.getRandomValues(bytes);
return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join(
"",
);
}
function sanitizeSSHKeySegment(value, fallback) {
const normalized = String(value || "")
.trim()
.replace(/[^\p{L}\p{N}._-]+/gu, "-")
.replace(/^-+|-+$/g, "");
return normalized || fallback;
}
function sanitizeSSHHost(value) {
const normalized = String(value || "")
.trim()
.replace(/[^a-zA-Z0-9.:[\]-]+/g, "");
return normalized || "服务器地址";
}
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 openCreate() {
editingId.value = "";
form.value = createEmptyForm();
formError.value = "";
showForm.value = true;
}
function openEdit(item) {
editingId.value = item.id;
form.value = {
name: item.name,
certificate_type: item.certificate_type,
sans: (item.sans || []).filter((name) => name !== item.name).join("\n"),
auto_renew: item.auto_renew !== false,
acme_environment: item.acme_environment || "staging",
dns_provider: item.dns_provider || "",
server_ids: (domainServers.value[item.id] || []).map(
(server) => server.id,
),
};
formError.value = "";
showForm.value = true;
}
function closeForm() {
if (!saving.value) showForm.value = false;
}
function openServerCreate() {
serverEditingId.value = "";
serverForm.value = createEmptyServerForm();
serverFormError.value = "";
showServerForm.value = true;
}
function openServerEdit(item) {
serverEditingId.value = item.id;
serverForm.value = {
...item,
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 openSSHKeyCreate() {
sshKeyForm.value = createEmptySSHKeyForm();
sshKeyFormError.value = "";
sshKeyPlatform.value = "windows";
sshKeyPassphraseVisible.value = false;
sshKeyCommandUsername.value = serverForm.value.username.trim() || "用户名";
sshKeyCommandHost.value = serverForm.value.address.trim() || "服务器地址";
sshKeyRandomSuffix.value = createSSHKeyRandomSuffix();
sshKeyForm.value.name = sshKeyFileName.value;
showSSHKeyForm.value = true;
}
function closeSSHKeyForm() {
if (sshKeySaving.value) return;
sshKeyForm.value = createEmptySSHKeyForm();
sshKeyFormError.value = "";
sshKeyPassphraseVisible.value = false;
showSSHKeyForm.value = false;
}
async function importSSHKeyFile(event) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
try {
const content = await file.text();
if (!content.trim()) throw new Error("选择的私钥文件为空");
sshKeyForm.value.private_key = content;
sshKeyFormError.value = "";
notice.value = `已读取私钥文件:${file.name}`;
} catch (err) {
sshKeyFormError.value = err.message || "读取私钥文件失败";
}
}
async function copySSHKeyCommand(value, message) {
try {
await navigator.clipboard.writeText(value);
notice.value = message;
} catch {
sshKeyFormError.value = "浏览器未允许访问剪贴板,请手动复制命令";
}
}
async function saveSSHKey() {
sshKeyFormError.value = "";
sshKeySaving.value = true;
try {
const response = await fetchAdmin("/api/v1/ssh-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sshKeyForm.value),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "保存 SSH 私钥失败");
sshKeyForm.value = createEmptySSHKeyForm();
showSSHKeyForm.value = false;
notice.value = "SSH 私钥已加密保存";
await loadData();
} catch (err) {
sshKeyFormError.value = err.message || "保存 SSH 私钥失败";
} finally {
sshKeySaving.value = false;
}
}
async function deleteSSHKey(item) {
if (
!window.confirm(
`确定删除 SSH 私钥“${item.name}”吗?正在被服务器使用的私钥无法删除。`,
)
)
return;
try {
const response = await fetchAdmin(`/api/v1/ssh-keys/${item.id}`, {
method: "DELETE",
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "删除 SSH 私钥失败");
notice.value = "SSH 私钥已删除";
await loadData();
} catch (err) {
error.value = err.message || "删除 SSH 私钥失败";
}
}
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,
auth_method: serverForm.value.auth_method,
private_key_ref: serverForm.value.private_key_ref,
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),
};
if (serverForm.value.auth_method === "password") {
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 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") {
const suffix = crypto.randomUUID ? crypto.randomUUID() : createSSHKeyRandomSuffix();
options.headers = { "Idempotency-Key": `issue-${item.id}-${suffix}` };
}
const response = await fetchAdmin(`/api/v1/domains/${item.id}/${action}`, options);
const result = await response.json().catch(() => ({}));
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 openTaskDetails(item) {
showTaskDetails.value = true;
taskDetails.value = null;
taskAttempts.value = [];
taskDetailsError.value = "";
taskDetailsLoading.value = true;
try {
const [taskResponse, attemptsResponse] = await Promise.all([
fetchAdmin(`/api/v1/tasks/${item.id}`),
fetchAdmin(`/api/v1/tasks/${item.id}/attempts`),
]);
if (!taskResponse.ok) {
throw new Error(await responseError(taskResponse, "读取任务详情失败"));
}
if (!attemptsResponse.ok) {
throw new Error(await responseError(attemptsResponse, "读取任务尝试记录失败"));
}
taskDetails.value = await taskResponse.json();
taskAttempts.value = (await attemptsResponse.json()).items || [];
} catch (err) {
taskDetailsError.value = err.message || "读取任务详情失败";
} finally {
taskDetailsLoading.value = false;
}
}
function closeTaskDetails() {
showTaskDetails.value = false;
taskDetails.value = null;
taskAttempts.value = [];
taskDetailsError.value = "";
}
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 = "证书任务已取消";
await loadData();
} catch (err) {
error.value = err.message || "取消任务失败";
}
}
async function runRenewalNow() {
error.value = "";
notice.value = "";
try {
const response = await fetchAdmin("/api/v1/renewal/run", { method: "POST" });
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "执行续期检查失败");
const report = result.report || {};
notice.value = `续期检查完成:检查 ${report.checked || 0} 项,选中 ${report.selected || 0} 项,成功 ${report.succeeded || 0} 项,失败 ${report.failed || 0} 项`;
await loadData();
} 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() {
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 = "";
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;
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;
}
function selectCertificateImportFile(kind, event) {
importFiles.value[kind] = event.target.files?.[0] || null;
event.target.value = "";
importError.value = "";
}
async function importCertificate() {
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 },
);
const result = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(result.error || "导入证书失败");
}
importResult.value = result;
notice.value = `域名 ${importDomain.value.name} 的证书已导入`;
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 saveDomainServers(domainId, serverIds) {
const response = await fetchAdmin(`/api/v1/domains/${domainId}/servers`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ server_ids: serverIds }),
});
const result = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(result.error || "保存域名服务器关联失败");
domainServers.value = {
...domainServers.value,
[domainId]: result.items || [],
};
}
function parseSANs(value) {
return value
.split(/[\n,,]/)
.map((item) => item.trim())
.filter(Boolean);
}
async function saveDomain() {
formError.value = "";
notice.value = "";
if (!form.value.name.trim()) {
formError.value = "请输入主域名";
return;
}
if (
form.value.certificate_type === "san" &&
parseSANs(form.value.sans).length === 0
) {
formError.value = "SAN 证书至少需要填写一个附加域名";
return;
}
saving.value = true;
try {
const payload = {
name: form.value.name,
certificate_type: form.value.certificate_type,
sans: parseSANs(form.value.sans),
auto_renew: form.value.auto_renew,
acme_environment: form.value.acme_environment,
dns_provider: form.value.dns_provider,
};
const path = isEditing.value
? `/api/v1/domains/${editingId.value}`
: "/api/v1/domains";
const response = await fetchAdmin(path, {
method: isEditing.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 || "保存域名失败");
await saveDomainServers(result.id, form.value.server_ids);
showForm.value = false;
notice.value = isEditing.value
? "域名配置已更新"
: "域名已添加,等待申请证书";
await loadData();
} catch (err) {
formError.value = err.message || "保存域名失败";
} finally {
saving.value = false;
}
}
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,
sshKeyResponse,
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/ssh-keys"),
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,
sshKeyResponse,
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 || [];
sshKeys.value = (await sshKeyResponse.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 {
status,
currentUser,
domains,
servers,
sshKeys,
certificates,
tasks,
auditLogs,
settings,
settingsForm,
dnsProviderCatalog,
selectedDNSProvider,
dnsTesting,
dnsTestError,
dnsVerificationElapsedSeconds,
dnsVerificationTimeoutSeconds,
dnsReadyToSave,
dnsVerificationRequired,
handleDNSProviderChange,
clearDNSCredentials,
testDNS,
domainServers,
domainPage,
domainPageSize,
domainPageSizeOptions,
domainPageCount,
paginatedDomains,
domainPageStart,
domainPageEnd,
previousDomainPage,
nextDomainPage,
loading,
saving,
error,
notice,
formError,
showForm,
editingId,
showServerForm,
serverEditingId,
serverSaving,
serverFormError,
showSSHKeyForm,
sshKeySaving,
sshKeyFormError,
certificateTypeLabels,
domainStatusLabels,
taskStatusLabels,
taskTypeLabels,
auditActionLabels,
taskStageLabels,
attemptStatusLabels,
httpsModeLabels,
dnsProviders,
environmentLabels,
webServerLabels,
serverAuthLabel,
isEditing,
formTitle,
form,
serverForm,
sshKeyForm,
sshKeyPlatform,
sshKeyPassphraseVisible,
sshKeyCommandUsername,
sshKeyCommandHost,
sshKeyFileName,
sshKeyPath,
sshKeyPrivatePath,
sshKeyPublicPath,
showSettings,
settingsSaving,
settingsError,
showCertificateImport,
importDomain,
importSaving,
importError,
importResult,
sshKeyCommands,
sshPublicKeyCommands,
defaultCertificatePath,
defaultCertificateChainPath,
defaultPrivateKeyPath,
openCreate,
openEdit,
closeForm,
openServerCreate,
openServerEdit,
closeServerForm,
openSSHKeyCreate,
closeSSHKeyForm,
importSSHKeyFile,
copySSHKeyCommand,
saveSSHKey,
deleteSSHKey,
openCertificateImport,
closeCertificateImport,
selectCertificateImportFile,
importCertificate,
saveServer,
deleteServer,
saveDomain,
deleteDomain,
issueDomain,
validateDomain,
deployDomain,
cancelTask,
openTaskDetails,
closeTaskDetails,
showTaskDetails,
taskDetails,
taskAttempts,
taskDetailsLoading,
taskDetailsError,
deploymentResult,
taskTypeLabel,
auditActionLabel,
taskStageLabel,
attemptStatusLabel,
runRenewalNow,
openSettings,
closeSettings,
saveSettings,
loadData,
displayLabel,
formatDate,
daysRemaining,
statusClass,
};
}