优化运行概览布局并建立用户分组改造前检查点

This commit is contained in:
xuans 2026-09-25 21:16:28 +08:00
parent 35d2c91999
commit c91aab659b
6 changed files with 566 additions and 71 deletions

View File

@ -3,6 +3,7 @@ import test from "node:test";
import { nextTick, ref } from "vue";
import { useDashboard } from "./Dashboard.js";
import { useDashboardNavigation } from "./DashboardNavigation.js";
import { summarizeRuntime, runtimeTime, useRuntimeOverview } from "./RuntimeOverview.js";
import { aliyunProvider, deferred, mountComposable, verifiedResponse } from "../../test-support/mount.js";
test("板块导航默认只选中基本信息,超级管理员可切换全部板块", () => {
@ -88,7 +89,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
const navigation = useDashboardNavigation(state.currentUser);
const headings = {
overview: "基本信息",
activity: "最近活动",
activity: "运行概览",
domains: "托管域名",
servers: "部署服务器",
keys: "SSH 私钥",
@ -100,7 +101,7 @@ test("完整页面逐个渲染六个板块,每次只有一个内容标题", as
render,
setup: () => ({ ...state, ...navigation, dashboard: state }),
});
for (const name of ["ChangePassword", "DashboardNavigation", "SettingsModal", "DomainModal", "ServerModal", "SSHKeyModal"]) {
for (const name of ["ChangePassword", "DashboardNavigation", "SettingsModal", "DomainModal", "ServerModal", "SSHKeyModal", "RuntimeOverview"]) {
app.component(name, { render: () => null });
}
app.component("UserManagement", { render: () => Vue.h("h2", headings.users) });
@ -389,7 +390,7 @@ test("auditStatusRendering", async () => {
const { compileTemplate } = await import("@vue/compiler-sfc");
const { renderToString } = await import("@vue/server-renderer");
const { createSSRApp } = await import("vue");
const source = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
const source = await readFile(new URL("./RuntimeOverview.vue", import.meta.url), "utf8");
const start = source.indexOf('<article v-for="item in auditLogs"');
assert.notEqual(start, -1);
const end = source.indexOf("</article>", start) + "</article>".length;
@ -681,11 +682,104 @@ test("domainAction.refreshesSuccessfulResultAndRetainsNotice", async (t) => {
test("dashboardLayout.keepsActivityListsBoundedAndKeyboardAccessible", async () => {
const { readFile } = await import("node:fs/promises");
const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
const stylesheet = await readFile(new URL("./Dashboard.css", import.meta.url), "utf8");
assert.equal((template.match(/class="record-list" tabindex="0"/g) || []).length, 3);
assert.match(stylesheet, /\.record-list\s*\{[^}]*max-height:\s*280px;[^}]*overflow-y:\s*auto;/);
assert.match(stylesheet, /\.record-list:focus-visible/);
const template = await readFile(new URL("./RuntimeOverview.vue", import.meta.url), "utf8");
const stylesheet = await readFile(new URL("./RuntimeOverview.css", import.meta.url), "utf8");
assert.match(template, /class="runtime-table-scroll" tabindex="0"/);
assert.match(template, /class="runtime-audit-list" tabindex="0"/);
assert.match(stylesheet, /\.runtime-audit-list\s*\{[^}]*max-height:[^;]+;[^}]*overflow:\s*auto;/);
assert.match(stylesheet, /\.runtime-table-scroll:focus-visible/);
assert.match(stylesheet, /\.runtime-audit-list:focus-visible/);
});
test("runtimeOverview.summarizesCertificateBoundariesAndConfiguration", () => {
const now = Date.parse("2026-09-25T12:00:00Z");
const day = 86400000;
const domains = ["valid", "near", "expired", "missing", "invalid", "future"].map((id) => ({ id, name: `${id}.test`, auto_renew: id === "valid" }));
const certificate = (id, end, start = now - day) => ({ domain: { id }, certificate: { saved: { not_after: end, not_before: new Date(start).toISOString() } } });
const result = summarizeRuntime({
domains,
certificates: [
certificate("valid", new Date(now + 11 * day).toISOString()),
certificate("near", new Date(now + 10 * day).toISOString()),
certificate("expired", new Date(now).toISOString()),
certificate("invalid", "invalid"),
certificate("future", new Date(now + 30 * day).toISOString(), now + day),
],
settings: { acme: { renewal_near_expiry_days: 10 } },
servers: [{ id: "server" }],
domainServers: { valid: [{ id: "server" }], near: [] },
}, now);
assert.equal(result.total, 6);
assert.equal(result.valid, 2);
assert.equal(result.expiring, 1);
assert.equal(result.expired, 1);
assert.equal(result.missing, 3);
assert.equal(result.alerts.length, 5);
assert.equal(result.alerts[0].id, "expired");
assert.equal(result.autoRenew, 1);
assert.equal(result.configuredServers, 1);
assert.equal(result.boundDomains, 1);
});
test("runtimeOverview.handlesEmptyDataThresholdsAndTaskOrdering", () => {
const empty = summarizeRuntime({});
for (const key of ["total", "valid", "expiring", "expired", "missing", "autoRenew", "configuredServers", "boundDomains", "activeTasks", "failedTasks"]) assert.equal(empty[key], 0);
for (const value of [0, -1, "invalid"]) assert.equal(summarizeRuntime({ settings: { acme: { renewal_near_expiry_days: value } } }).threshold, 10);
assert.equal(summarizeRuntime({ settings: { acme: { renewal_near_expiry_days: 30 } } }).threshold, 30);
const tasks = ["queued", "running", "failed", "error", "success", "cancelled"].map((status, index) => ({ id: index, status, created_at: `2026-09-25T12:0${index}:00Z` }));
const original = structuredClone(tasks);
const result = summarizeRuntime({ tasks });
assert.equal(result.activeTasks, 2);
assert.equal(result.failedTasks, 2);
assert.deepEqual(result.tasks.map((item) => item.id), [5, 4, 3, 2, 1, 0]);
assert.deepEqual(tasks, original);
assert.equal(runtimeTime(null), runtimeTime("invalid"));
assert.doesNotMatch(runtimeTime("2026-09-25T12:00:00Z"), /Invalid/);
});
test("runtimeOverview.defaultsToSummaryAndLimitsRecentTasks", async (t) => {
const data = {
domains: ref([{ id: "domain", name: "example.test" }]), certificates: ref([]),
servers: ref([]), domainServers: ref({}), settings: ref({}),
tasks: ref(Array.from({ length: 12 }, (_, id) => ({ id, status: "success" }))),
};
const { state } = await mountComposable(t, () => useRuntimeOverview(data), async () => Response.json({}));
assert.equal(state.detailView.value, "");
assert.equal(state.visibleTasks.value.length, 5);
state.detailView.value = "tasks";
assert.equal(state.visibleTasks.value.length, 12);
state.detailView.value = "";
assert.equal(state.visibleTasks.value.length, 5);
assert.equal(state.taskDomain({ domain_id: "domain" }), "example.test");
assert.notEqual(state.taskDomain({ task_type: "management_renewal" }), state.taskDomain({}));
});
test("runtimeOverview.compilesAndKeepsAuditOutOfDefaultView", async () => {
const { readFile } = await import("node:fs/promises");
const { parse, compileScript, compileTemplate } = await import("@vue/compiler-sfc");
const { renderToString } = await import("@vue/server-renderer");
const Vue = await import("vue");
const source = await readFile(new URL("./RuntimeOverview.vue", import.meta.url), "utf8");
const { descriptor, errors } = parse(source);
assert.deepEqual(errors, []);
assert.ok(compileScript(descriptor, { id: "runtime-overview" }));
const compiled = compileTemplate({ source: descriptor.template.content, filename: "RuntimeOverview.vue", id: "runtime-overview", compilerOptions: { mode: "function" } });
assert.deepEqual(compiled.errors, []);
const render = new Function("Vue", compiled.code)(Vue);
for (const detailView of ["", "tasks", "audit"]) {
const app = Vue.createSSRApp({ render, setup: () => ({
loading: false, error: "", detailView, summary: summarizeRuntime({}),
visibleTasks: [], auditLogs: [{ id: "audit", action: "login", status: "success", message: "audit-only-marker" }],
auditActionLabel: (value) => value, statusClass: () => "success",
}) });
app.component("HelpTip", { render: () => null });
const warnings = [];
app.config.warnHandler = (message) => warnings.push(message);
const html = await renderToString(app);
assert.equal(html.includes('class="runtime-metrics"'), detailView === "");
assert.equal(html.includes("audit-only-marker"), detailView === "audit");
assert.deepEqual(warnings, []);
}
});
test("dashboardLayout.replacesHeroWithBasicInformationSummary", async () => {

View File

@ -4,6 +4,7 @@ import UserManagement from "../UserManagement/UserManagement.vue";
import SettingsModal from "./SettingsModal.vue";
import DomainModal from "./DomainModal.vue";
import ServerModal from "./ServerModal.vue";
import RuntimeOverview from "./RuntimeOverview.vue";
import SSHKeyModal from "./SSHKeyModal.vue";
import DashboardNavigation from "./DashboardNavigation.vue";
import { useDashboardNavigation } from "./DashboardNavigation.js";
@ -327,70 +328,11 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<section v-if="activeSection === 'activity'" class="panel operations-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">运行概览</p>
<h2>最近活动</h2>
</div>
<span class="muted">最近 20 条记录</span>
</div>
<div class="operation-columns">
<div>
<h3>证书状态</h3>
<div v-if="certificates.length === 0" class="empty-state compact-empty">暂无证书记录</div>
<div v-else class="record-list" tabindex="0">
<article v-for="item in certificates" :key="item.domain.id" class="record-row">
<div>
<strong>{{ item.domain.name }}</strong>
<span v-if="item.certificate" class="muted">到期:{{ formatDate(item.certificate.saved.not_after) }}</span>
<span v-else class="muted">{{ item.certificate_error || "读取失败" }}</span>
</div>
<span v-if="item.certificate" class="tag success">已保存</span>
<span v-else class="tag warning">未保存</span>
</article>
</div>
</div>
<div>
<h3>任务记录</h3>
<div v-if="tasks.length === 0" class="empty-state compact-empty">暂无任务记录</div>
<div v-else class="record-list" tabindex="0">
<article v-for="item in tasks" :key="item.id" class="record-row">
<div>
<strong>{{ taskTypeLabel(item.task_type) }}</strong>
<span class="muted">{{ item.message || "无附加信息" }}</span>
</div>
<div class="record-row-actions">
<span class="tag" :class="statusClass(item.status)">{{ displayLabel(taskStatusLabels, item.status) }}</span>
<button class="text-button" type="button" @click="openTaskDetails(item)">
查看详情
</button>
<button
v-if="item.task_type !== 'management_renewal' && (item.status === 'queued' || item.status === 'running')"
class="text-button"
type="button"
@click="cancelTask(item)"
>
取消任务
</button>
</div>
</article>
</div>
</div>
<div>
<h3>审计日志</h3>
<div v-if="auditLogs.length === 0" class="empty-state compact-empty">暂无审计日志</div>
<div v-else class="record-list" tabindex="0">
<article v-for="item in auditLogs" :key="item.id" class="record-row">
<div>
<strong>{{ auditActionLabel(item.action) }}</strong>
<span class="muted">{{ item.message || item.resource_type }}</span>
</div>
<span v-if="item.status === 'success' || item.status === 'failed'" class="tag" :class="statusClass(item.status)">{{ item.status === "success" ? "成功" : "失败" }}</span>
<span v-else class="tag" :class="statusClass(item.status)">
{{ displayLabel(taskStatusLabels, item.status) }}
</span>
</article>
</div>
<h2>运行概览</h2>
</div>
<span class="muted">证书有效期 · 自动续期 · 任务执行</span>
</div>
<RuntimeOverview :state="dashboard" @navigate="selectSection" />
</section>
<section v-if="activeSection === 'domains'" class="panel">

View File

@ -2,7 +2,7 @@ import { computed, ref } from "vue";
export const dashboardSections = [
{ id: "overview", label: "基本信息", group: "总览", hint: "查看用户、域名状态、证书到期时间和部署服务器" },
{ id: "activity", label: "运行概览", group: "总览", hint: "查看证书、任务与审计日志" },
{ id: "activity", label: "运行概览", group: "总览", hint: "查看证书有效期、续期覆盖和任务执行情况" },
{ id: "domains", label: "域名配置", group: "证书与部署", hint: "管理域名、申请证书和执行部署" },
{ id: "servers", label: "部署服务器", group: "证书与部署", hint: "配置部署目标及 SSH 认证方式" },
{ id: "keys", label: "SSH 私钥", group: "证书与部署", hint: "管理加密保存的 SSH 私钥" },

View File

@ -0,0 +1,250 @@
.runtime-overview {
color: #25344d;
font-size: 13px;
}
.runtime-toolbar,
.runtime-actions,
.runtime-section-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.runtime-toolbar {
margin-bottom: 10px;
color: #687792;
}
.runtime-overview button {
padding: 5px 8px;
border: 0;
border-radius: 6px;
color: #315faa;
background: #eff4fb;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.runtime-overview button:focus-visible,
.runtime-table-scroll:focus-visible,
.runtime-audit-list:focus-visible {
outline: 2px solid #315faa;
outline-offset: 2px;
}
.runtime-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.runtime-metric {
padding: 10px 16px;
border: 1px solid #e1e9f4;
border-radius: 12px;
background: #f7faff;
}
.runtime-metric > span {
display: flex;
align-items: center;
min-height: 22px;
color: #62748f;
}
.runtime-metric > strong {
display: block;
margin: 5px 0;
font-size: 28px;
line-height: 1.1;
}
.runtime-metric small {
color: #7b8ba3;
font-size: 13px;
font-weight: 400;
}
.runtime-metric p {
margin: 6px 0 0;
color: #687792;
font-size: 12px;
}
.runtime-metric.attention {
border-color: #f2dcbb;
background: #fffbf4;
}
.runtime-focus-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
gap: 16px;
margin: 12px 0;
}
.runtime-focus {
min-width: 0;
padding: 12px 16px;
border: 1px solid #e6ecf5;
border-radius: 10px;
}
.runtime-section-heading {
margin-bottom: 8px;
}
.runtime-section-heading h3 {
display: flex;
align-items: center;
margin: 0;
font-size: 14px;
}
.runtime-section-heading > span,
.runtime-empty {
color: #7b8ba3;
font-size: 12px;
}
.runtime-clear {
color: #248158;
}
.runtime-alerts {
margin: 0;
padding: 0;
list-style: none;
}
.runtime-alerts li {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 4px 12px;
padding: 5px 0;
overflow-wrap: anywhere;
}
.runtime-deployment {
display: flex;
gap: 28px;
margin: 10px 0 4px;
}
.runtime-deployment dt {
color: #687792;
font-size: 12px;
}
.runtime-deployment dd {
margin: 4px 0 0;
font-weight: 700;
}
.runtime-table-scroll,
.runtime-audit-list {
max-height: max(210px, calc(100dvh - 360px));
overflow: auto;
border: 1px solid #e6ecf5;
border-radius: 10px;
}
.runtime-table-scroll table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.runtime-table-scroll th,
.runtime-table-scroll td {
padding: 6px 12px;
border-bottom: 1px solid #eef2f7;
vertical-align: middle;
}
.runtime-table-scroll th {
position: sticky;
top: 0;
color: #687792;
background: #f7f9fc;
font-size: 12px;
font-weight: 500;
}
.runtime-table-scroll td:first-child {
max-width: 360px;
overflow-wrap: anywhere;
}
.runtime-table-scroll td:not(:first-child) {
white-space: nowrap;
}
.runtime-table-scroll small,
.runtime-audit-row small {
display: block;
margin-top: 4px;
color: #7b8ba3;
font-size: 12px;
}
.runtime-tag {
display: inline-block;
padding: 4px 9px;
border-radius: 20px;
background: #edf1f7;
font-size: 12px;
}
.runtime-tag.success {
color: #248158;
background: #e7f6ee;
}
.danger,
.runtime-stale {
color: #c54343;
}
.runtime-tag.danger {
background: #fff0f0;
}
.warning {
color: #a46c13;
}
.runtime-tag.warning {
background: #fff5e3;
}
.runtime-audit-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid #eef2f7;
overflow-wrap: anywhere;
}
@media (max-width: 1000px) {
.runtime-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.runtime-focus-grid {
grid-template-columns: minmax(0, 1fr);
}
.runtime-toolbar {
align-items: flex-start;
flex-direction: column;
}
}

View File

@ -0,0 +1,87 @@
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const dayMilliseconds = 86400000;
export function summarizeRuntime(data, now = Date.now()) {
const domains = data.domains || [];
const certificates = new Map((data.certificates || []).map((item) => [item.domain?.id, item]));
const configuredThreshold = Number(data.settings?.acme?.renewal_near_expiry_days);
const threshold = Number.isFinite(configuredThreshold) && configuredThreshold > 0 ? configuredThreshold : 10;
const alerts = [];
let valid = 0;
let expiring = 0;
let expired = 0;
let missing = 0;
for (const domain of domains) {
const entry = certificates.get(domain.id);
const saved = entry?.certificate?.saved;
const expiresAt = Date.parse(saved?.not_after);
const startsAt = Date.parse(saved?.not_before);
let message;
let severity = "warning";
if (!saved || !Number.isFinite(expiresAt)) {
missing += 1;
message = entry?.certificate_error || "暂无可读取证书";
} else if (expiresAt <= now) {
expired += 1;
message = "证书已到期";
severity = "danger";
} else if (Number.isFinite(startsAt) && startsAt > now) {
missing += 1;
message = "证书尚未生效";
} else {
valid += 1;
const remaining = Math.ceil((expiresAt - now) / dayMilliseconds);
if (expiresAt - now <= threshold * dayMilliseconds) {
expiring += 1;
message = `证书将在 ${remaining} 天内到期`;
}
}
if (message) alerts.push({ id: domain.id, name: domain.name, message, severity });
}
alerts.sort((left, right) => Number(right.severity === "danger") - Number(left.severity === "danger"));
const tasks = [...(data.tasks || [])].sort((left, right) =>
(Date.parse(right.created_at) || 0) - (Date.parse(left.created_at) || 0));
return {
total: domains.length,
valid,
expiring,
expired,
missing,
threshold,
alerts,
autoRenew: domains.filter((item) => item.auto_renew === true).length,
configuredServers: (data.servers || []).length,
boundDomains: domains.filter((item) => (data.domainServers?.[item.id] || []).length > 0).length,
activeTasks: tasks.filter((item) => item.status === "queued" || item.status === "running").length,
failedTasks: tasks.filter((item) => item.status === "failed" || item.status === "error").length,
tasks,
};
}
export function runtimeTime(value) {
const date = new Date(value);
return value && Number.isFinite(date.getTime()) ? date.toLocaleString("zh-CN", { hour12: false }) : "时间未记录";
}
export function useRuntimeOverview(state) {
const detailView = ref("");
const clock = ref(Date.now());
let timer;
const summary = computed(() => summarizeRuntime({
domains: state.domains.value,
certificates: state.certificates.value,
tasks: state.tasks.value,
servers: state.servers.value,
domainServers: state.domainServers.value,
settings: state.settings.value,
}, clock.value));
const visibleTasks = computed(() => detailView.value === "tasks" ? summary.value.tasks : summary.value.tasks.slice(0, 5));
function taskDomain(task) {
if (task.task_type === "management_renewal") return "管理页面证书";
return state.domains.value.find((item) => item.id === task.domain_id)?.name || "系统任务或已移除域名";
}
onMounted(() => { timer = window.setInterval(() => { clock.value = Date.now(); }, 60000); });
onBeforeUnmount(() => { if (timer) window.clearInterval(timer); });
return { detailView, summary, visibleTasks, taskDomain };
}

View File

@ -0,0 +1,122 @@
<script setup>
import HelpTip from "../components/HelpTip/HelpTip.vue";
import { runtimeTime, useRuntimeOverview } from "./RuntimeOverview.js";
const props = defineProps({ state: { type: Object, required: true } });
const emit = defineEmits(["navigate"]);
const {
loading, error, auditLogs, taskTypeLabel, taskStatusLabels,
statusClass, displayLabel, auditActionLabel, openTaskDetails, cancelTask,
} = props.state;
const { detailView, summary, visibleTasks, taskDomain } = useRuntimeOverview(props.state);
</script>
<template>
<div class="runtime-overview">
<p v-if="loading" class="runtime-empty" role="status">正在读取运行状态……</p>
<template v-else>
<p v-if="error" class="runtime-stale" role="alert">本次数据读取失败,以下内容可能不是最新状态。</p>
<div class="runtime-toolbar">
<span>{{ detailView ? (detailView === "audit" ? "审计日志 · 最近 20 条" : "任务记录 · 最近 20 条") : "当前托管资源" }}</span>
<div class="runtime-actions">
<button v-if="detailView" type="button" @click="detailView = ''">返回运行概览</button>
<button v-if="detailView !== 'tasks'" type="button" @click="detailView = 'tasks'">任务记录</button>
<button v-if="detailView !== 'audit'" type="button" @click="detailView = 'audit'">审计日志</button>
</div>
</div>
<template v-if="!detailView">
<div class="runtime-metrics">
<article class="runtime-metric">
<span>有效期内证书 <HelpTip>根据已保存证书的有效期统计,不代表远程站点已完成部署校验。</HelpTip></span>
<strong>{{ summary.valid }}<small> / {{ summary.total }}</small></strong>
<p>当前托管域名</p>
</article>
<article class="runtime-metric" :class="{ attention: summary.alerts.length }">
<span>证书待关注</span>
<strong>{{ summary.alerts.length }}<small> 个</small></strong>
<p>临期 {{ summary.expiring }} · 到期 {{ summary.expired }} · 不可用 {{ summary.missing }}</p>
</article>
<article class="runtime-metric">
<span>自动续期开启 <HelpTip>表示配置已开启,实际续期结果请查看任务记录。</HelpTip></span>
<strong>{{ summary.autoRenew }}<small> / {{ summary.total }}</small></strong>
<p>托管域名续期覆盖</p>
</article>
<article class="runtime-metric">
<span>进行中任务 <HelpTip>仅统计最近 20 条任务中的排队和执行中任务,不是全部历史任务。</HelpTip></span>
<strong>{{ summary.activeTasks }}<small> 项</small></strong>
<p>最近 {{ summary.tasks.length }} 条中失败 {{ summary.failedTasks }} 项</p>
</article>
</div>
<div class="runtime-focus-grid">
<section class="runtime-focus">
<div class="runtime-section-heading">
<h3>需要关注</h3>
<button type="button" @click="emit('navigate', 'domains')">管理域名</button>
</div>
<p v-if="summary.total === 0" class="runtime-empty">尚未添加托管域名</p>
<p v-else-if="summary.alerts.length === 0" class="runtime-clear">当前无临期或不可用证书</p>
<ul v-else class="runtime-alerts">
<li v-for="item in summary.alerts.slice(0, 3)" :key="item.id">
<strong>{{ item.name }}</strong>
<span :class="item.severity">{{ item.message }}</span>
</li>
</ul>
<small v-if="summary.alerts.length > 3">另有 {{ summary.alerts.length - 3 }} 项,请在域名配置中查看。</small>
</section>
<section class="runtime-focus">
<div class="runtime-section-heading">
<h3>部署配置 <HelpTip>这里只展示部署配置覆盖,不推断服务器在线状态或证书推送结果。</HelpTip></h3>
<button type="button" @click="emit('navigate', 'servers')">管理服务器</button>
</div>
<dl class="runtime-deployment">
<div><dt>已配置服务器</dt><dd>{{ summary.configuredServers }} 台</dd></div>
<div><dt>已绑定部署域名</dt><dd>{{ summary.boundDomains }} / {{ summary.total }}</dd></div>
</dl>
</section>
</div>
</template>
<section v-if="detailView !== 'audit'" class="runtime-tasks">
<div class="runtime-section-heading">
<h3>{{ detailView === "tasks" ? "任务记录" : "最近任务" }}</h3>
<span>显示 {{ visibleTasks.length }} 条{{ detailView ? "" : " · 最多 5 条" }}</span>
</div>
<p v-if="visibleTasks.length === 0" class="runtime-empty">暂无任务记录</p>
<div v-else class="runtime-table-scroll" tabindex="0" aria-label="证书任务列表">
<table>
<thead><tr><th scope="col">任务 / 对象</th><th scope="col">状态</th><th scope="col">创建时间</th><th scope="col">操作</th></tr></thead>
<tbody>
<tr v-for="item in visibleTasks" :key="item.id">
<td><strong>{{ taskTypeLabel(item.task_type) }}</strong><small>{{ taskDomain(item) }}</small></td>
<td><span class="runtime-tag" :class="statusClass(item.status)">{{ displayLabel(taskStatusLabels, item.status) }}</span></td>
<td>{{ runtimeTime(item.created_at) }}</td>
<td>
<button type="button" @click="openTaskDetails(item)">查看详情</button>
<button
v-if="item.task_type !== 'management_renewal' && (item.status === 'queued' || item.status === 'running')"
type="button"
@click="cancelTask(item)"
>取消任务</button>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section v-else class="runtime-audit" aria-label="审计日志">
<p v-if="auditLogs.length === 0" class="runtime-empty">暂无审计日志</p>
<div v-else class="runtime-audit-list" tabindex="0" aria-label="最近审计记录">
<article v-for="item in auditLogs" :key="item.id" class="runtime-audit-row">
<div>
<strong>{{ auditActionLabel(item.action) }}</strong>
<small>{{ item.message || item.resource_type }}</small>
</div>
<span v-if="item.status === 'success' || item.status === 'failed'" class="runtime-tag" :class="statusClass(item.status)">{{ item.status === "success" ? "成功" : "失败" }}</span>
<span v-else class="runtime-tag" :class="statusClass(item.status)">{{ displayLabel(taskStatusLabels, item.status) }}</span>
</article>
</div>
</section>
</template>
</div>
</template>
<style scoped src="./RuntimeOverview.css"></style>