优化首页托管域名分页布局

将首页信息卡片移动到托管域名列表上方,增加证书状态列表和每页条数分页,并修正正常状态显示。
This commit is contained in:
xuans 2026-09-25 18:33:48 +08:00
parent 040ad2ed49
commit a0b1f745c9
5 changed files with 255 additions and 33 deletions

View File

@ -145,9 +145,40 @@ h2 {
display: grid;
gap: 10px;
}
.overview-domain-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 16px;
margin: 24px 0 12px;
}
.overview-domain-heading h3 {
margin: 0 0 5px;
color: #32405a;
font-size: 17px;
}
.overview-page-size {
display: flex;
align-items: center;
gap: 9px;
color: #7a879d;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
}
.overview-page-size select {
min-width: 86px;
padding: 8px 10px;
border: 1px solid #bdcde0;
border-radius: 9px;
color: #32405a;
background: #fff;
font: inherit;
font-weight: 700;
}
.overview-domain-row {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(130px, 0.8fr) minmax(180px, 1fr);
grid-template-columns: minmax(0, 1.25fr) minmax(115px, 0.7fr) minmax(130px, 0.8fr) minmax(180px, 1fr);
align-items: center;
gap: 18px;
padding: 15px 16px;
@ -168,9 +199,38 @@ h2 {
.overview-domain-field strong {
overflow-wrap: anywhere;
}
.overview-domain-main small {
color: #7a879d;
font-size: 12px;
}
.overview-domain-field strong {
font-size: 14px;
}
.overview-certificate-status {
display: flex;
align-items: center;
min-height: 27px;
}
.overview-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 14px;
color: #7a879d;
font-size: 13px;
}
.overview-pagination-actions {
display: flex;
align-items: center;
gap: 10px;
color: #52617a;
font-weight: 700;
}
.overview-pagination-actions .secondary-button {
padding: 8px 12px;
font-size: 12px;
}
.overview-details-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);

View File

@ -22,6 +22,9 @@ export function useDashboard() {
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("");
@ -79,6 +82,30 @@ export function useDashboard() {
);
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;
@ -180,6 +207,7 @@ export function useDashboard() {
const domainStatusLabels = {
pending: "等待申请",
active: "正常",
valid: "正常",
error: "错误",
};
const httpsModeLabels = { direct: "核心服务 HTTPS", proxy: "反向代理 HTTPS" };
@ -486,13 +514,21 @@ export function useDashboard() {
}
function statusClass(value) {
return value === "active" || value === "success"
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();
@ -1192,6 +1228,15 @@ export function useDashboard() {
clearDNSCredentials,
testDNS,
domainServers,
domainPage,
domainPageSize,
domainPageSizeOptions,
domainPageCount,
paginatedDomains,
domainPageStart,
domainPageEnd,
previousDomainPage,
nextDomainPage,
loading,
saving,
error,

View File

@ -701,6 +701,58 @@ test("dashboardLayout.replacesHeroWithBasicInformationSummary", async () => {
assert.match(stylesheet, /\.overview-domain-row\s*\{[^}]*grid-template-columns:/);
});
test("dashboardLayout.paginatesHostedDomainsWithConfiguredPageSizes", async (t) => {
const { state } = await mountComposable(t, useDashboard, async () => Response.json({ items: [] }));
state.domains.value = Array.from({ length: 16 }, (_, index) => ({
id: `domain-${index + 1}`,
name: `domain-${index + 1}.example.test`,
}));
await nextTick();
assert.deepEqual(state.domainPageSizeOptions, [5, 10, 15, 30]);
assert.equal(state.domainPageCount.value, 4);
assert.equal(state.paginatedDomains.value.length, 5);
assert.equal(state.domainPageStart.value, 1);
assert.equal(state.domainPageEnd.value, 5);
state.nextDomainPage();
assert.equal(state.domainPage.value, 2);
assert.equal(state.paginatedDomains.value[0].id, "domain-6");
state.domainPageSize.value = 10;
await nextTick();
assert.equal(state.domainPageCount.value, 2);
assert.equal(state.paginatedDomains.value.length, 10);
state.domainPage.value = 2;
state.domainPageSize.value = 5;
await nextTick();
assert.equal(state.domainPage.value, 1);
state.domainPage.value = 2;
state.nextDomainPage();
state.nextDomainPage();
assert.equal(state.domainPage.value, 4);
state.nextDomainPage();
assert.equal(state.domainPage.value, 4);
state.previousDomainPage();
assert.equal(state.domainPage.value, 3);
assert.equal(state.statusClass("valid"), "success");
assert.equal(state.displayLabel(state.domainStatusLabels, "valid"), "正常");
});
test("dashboardLayout.movesOverviewDetailsAboveHostedDomainList", async () => {
const { readFile } = await import("node:fs/promises");
const template = await readFile(new URL("./Dashboard.vue", import.meta.url), "utf8");
const detailsIndex = template.indexOf('class="overview-details-grid"');
const listHeadingIndex = template.indexOf('class="overview-domain-heading"');
assert.ok(detailsIndex >= 0);
assert.ok(listHeadingIndex >= 0);
assert.ok(detailsIndex < listHeadingIndex);
assert.match(template, /v-model\.number="domainPageSize"/);
assert.match(template, /上一页/);
assert.match(template, /下一页/);
});
test("dashboardLayout.usesSharedTooltipForUserManagementDescription", async () => {
const { readFile } = await import("node:fs/promises");
const template = await readFile(new URL("../UserManagement/UserManagement.vue", import.meta.url), "utf8");

View File

@ -22,6 +22,15 @@ const {
settings,
settingsForm,
domainServers,
domainPage,
domainPageSize,
domainPageSizeOptions,
domainPageCount,
paginatedDomains,
domainPageStart,
domainPageEnd,
previousDomainPage,
nextDomainPage,
loading,
saving,
error,
@ -210,37 +219,6 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<small>已配置的证书管理对象</small>
</article>
</div>
<div v-if="loading" class="empty-state compact-empty">正在读取域名状态……</div>
<div v-else-if="domains.length === 0" class="empty-state compact-empty">
<strong>还没有托管域名</strong>
<span>添加第一个域名,开始管理证书申请和自动续期。</span>
</div>
<div v-else class="overview-domain-list">
<article v-for="item in domains" :key="item.id" class="overview-domain-row">
<div class="overview-domain-main">
<strong>{{ item.name }}</strong>
<span class="tag" :class="statusClass(item.status)">
{{ displayLabel(domainStatusLabels, item.status) }}
</span>
</div>
<div class="overview-domain-field">
<span>到期时间</span>
<strong>{{ formatDate(item.expires_at) }}</strong>
<small>{{ daysRemaining(item.expires_at) }}</small>
</div>
<div class="overview-domain-field">
<span>部署服务器</span>
<strong>
{{
(domainServers[item.id] || [])
.map((server) => server.name)
.join("、") || "未配置"
}}
</strong>
<small>{{ item.auto_renew ? "自动续期已开启" : "自动续期已关闭" }}</small>
</div>
</article>
</div>
<div class="overview-details-grid">
<article class="overview-detail-card">
<span>服务版本</span>
@ -264,6 +242,82 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi
<strong>{{ domains.length }} 个</strong>
</article>
</div>
<div class="overview-domain-heading">
<div>
<h3>当前托管域名</h3>
<span class="muted">证书状态、到期时间和部署服务器</span>
</div>
<label class="overview-page-size">
<span>每页显示</span>
<select v-model.number="domainPageSize" aria-label="每页显示条数">
<option v-for="size in domainPageSizeOptions" :key="size" :value="size">
{{ size }} 条
</option>
</select>
</label>
</div>
<div v-if="loading" class="empty-state compact-empty">正在读取域名状态……</div>
<div v-else-if="domains.length === 0" class="empty-state compact-empty">
<strong>还没有托管域名</strong>
<span>添加第一个域名,开始管理证书申请和自动续期。</span>
</div>
<div v-else class="overview-domain-list">
<article v-for="item in paginatedDomains" :key="item.id" class="overview-domain-row">
<div class="overview-domain-main">
<strong>{{ item.name }}</strong>
<small>{{ displayLabel(certificateTypeLabels, item.certificate_type) }}</small>
</div>
<div class="overview-domain-field">
<span>证书状态</span>
<strong class="overview-certificate-status">
<span class="tag" :class="statusClass(item.status)">
{{ displayLabel(domainStatusLabels, item.status) }}
</span>
</strong>
</div>
<div class="overview-domain-field">
<span>到期时间</span>
<strong>{{ formatDate(item.expires_at) }}</strong>
<small>{{ daysRemaining(item.expires_at) }}</small>
</div>
<div class="overview-domain-field">
<span>部署服务器</span>
<strong>
{{
(domainServers[item.id] || [])
.map((server) => server.name)
.join("、") || "未配置"
}}
</strong>
<small>{{ item.auto_renew ? "自动续期已开启" : "自动续期已关闭" }}</small>
</div>
</article>
</div>
<div v-if="domains.length > 0" class="overview-pagination">
<span class="overview-pagination-info">
显示第 {{ domainPageStart }}-{{ domainPageEnd }} 条,共 {{ domains.length }} 条
</span>
<div class="overview-pagination-actions">
<button
class="secondary-button"
type="button"
:disabled="domainPage <= 1"
@click="previousDomainPage"
>
上一页
</button>
<span>第 {{ domainPage }} / {{ domainPageCount }} 页</span>
<button
class="secondary-button"
type="button"
:disabled="domainPage >= domainPageCount"
@click="nextDomainPage"
>
下一页
</button>
</div>
</div>
</section>
</div>

View File

@ -212,6 +212,17 @@
grid-template-columns: 1fr;
}
.overview-domain-heading,
.overview-pagination {
align-items: flex-start;
flex-direction: column;
}
.overview-pagination-actions {
width: 100%;
justify-content: space-between;
}
.overview-details-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}