fix: 自适应表格高度并改用中文标签

This commit is contained in:
Nixevol
2026-09-30 16:22:49 +08:00
parent 965060dc0c
commit eb4e2db918
13 changed files with 236 additions and 37 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ test.describe("后台主路径 W4", () => {
await nav(page, "投递记录");
await expect(page).toHaveURL(/\/messages/);
await expect(page.getByText("w4-e2e-scheduled")).toBeVisible();
await expect(page.getByRole("cell", { name: "scheduled", exact: true })).toBeVisible();
await expect(page.getByRole("cell", { name: "定时中", exact: true })).toBeVisible();
await nav(page, "API 令牌");
await expect(page).toHaveURL(/\/tokens/);
+1
View File
@@ -328,6 +328,7 @@ export const mockApi = {
endpoints_total: endpoints.length,
endpoints_online: endpoints.filter((e) => e.online).length,
endpoints_disabled: endpoints.filter((e) => !e.enabled).length,
endpoints_self: endpoints.filter((e) => e.source === "self").length,
groups_total: groups.length,
messages_pending: messages.filter((m) => m.state === "dispatched").length,
messages_scheduled: messages.filter((m) => m.state === "scheduled").length,
+1 -2
View File
@@ -34,8 +34,7 @@ export interface Overview {
endpoints_total: number;
endpoints_online: number;
endpoints_disabled: number;
/** A3 补充:自助注册端数量;契约示例未列,前端可选展示 */
endpoints_self?: number;
endpoints_self: number;
groups_total: number;
messages_pending: number;
messages_scheduled: number;
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { formatUptime, zhLabel, messageStateLabel } from "./labels";
describe("labels", () => {
it("消息状态译成中文", () => {
expect(zhLabel(messageStateLabel, "scheduled")).toBe("定时中");
expect(zhLabel(messageStateLabel, "unknown")).toBe("unknown");
});
it("运行时长按天时分", () => {
expect(formatUptime(90_000)).toBe("1 分");
expect(formatUptime(3_661_000)).toBe("1 小时 1 分");
expect(formatUptime(90_000_000)).toBe("1 天 1 小时 0 分");
});
});
+75
View File
@@ -0,0 +1,75 @@
/** 后台界面中文标签;原值可放 tooltip。 */
export const messageStateLabel: Record<string, string> = {
scheduled: "定时中",
dispatched: "投递中",
completed: "已完成",
};
export const destKindLabel: Record<string, string> = {
endpoint: "端",
group: "群",
};
export const deliveryStateLabel: Record<string, string> = {
pending: "待投递",
accepted: "已收下",
recalled: "已撤回",
expired: "已过期",
dropped: "已丢弃",
rejected: "已拒绝",
};
export const reasonLabel: Record<string, string> = {
disabled: "端已停用",
expired: "已过期",
recalled: "已撤回",
dropped: "已丢弃",
rejected: "已拒绝",
unauthorized: "未授权",
not_found: "不存在",
};
export const settingsFieldLabel: Record<string, string> = {
listen: "端监听地址",
admin_listen: "后台监听地址",
session_idle_days: "会话闲置天数",
record_retention_days: "记录保留天数",
idempotency_hours: "防重小时数",
receipt_retention_days: "回执保留天数",
sqlite_synchronous: "SQLite 同步模式",
};
export const limitsLabel: Record<string, string> = {
max_body_bytes: "正文上限(字节)",
max_meta_bytes: "元数据上限(字节)",
max_frame_bytes: "帧上限(字节)",
max_ttl_seconds: "TTL 上限(秒)",
max_schedule_seconds: "定时上限(秒)",
max_group_members: "群成员上限",
grace_seconds: "宽限秒数",
ack_timeout_seconds: "确认超时(秒)",
delivery_window: "投递窗口",
receipt_window: "回执窗口",
requests_per_second: "每秒请求",
max_pending_per_sender: "发送方未完成上限",
max_pending_per_receiver: "接收方未完成上限",
};
export function zhLabel(map: Record<string, string>, value: string | undefined | null, fallback = "—"): string {
if (value == null || value === "") return fallback;
return map[value] ?? value;
}
/** 运行时长:天、时、分。 */
export function formatUptime(ms: number): string {
const total = Math.max(0, Math.floor(ms / 1000));
const days = Math.floor(total / 86400);
const hours = Math.floor((total % 86400) / 3600);
const mins = Math.floor((total % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days} 天`);
if (hours > 0 || days > 0) parts.push(`${hours} 小时`);
parts.push(`${mins} 分`);
return parts.join(" ");
}
+24 -5
View File
@@ -187,10 +187,16 @@ const columns = computed<DataTableColumns<Endpoint>>(() => [
render: (r) => (r.login_locked ? "是" : "否"),
},
{
title: "最近在线",
title: "最近上线",
key: "online_since_ms",
width: 160,
render: (r) => formatLocalMs(r.online ? r.online_since_ms : r.offline_since_ms),
render: (r) => formatLocalMs(r.online_since_ms),
},
{
title: "最近离线",
key: "offline_since_ms",
width: 160,
render: (r) => formatLocalMs(r.offline_since_ms),
},
{
title: "创建时间",
@@ -496,6 +502,7 @@ function onPageSizeChange(s: number) {
<div class="page-body">
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
<template v-else>
<div class="page-filters">
<SecretOnceAlert
v-if="onceSecret"
:title="onceSecret.title"
@@ -527,15 +534,17 @@ function onPageSizeChange(s: number) {
<HelpTip>admin=后台开通,self=自助注册。</HelpTip>
</span>
</n-space>
</div>
<div class="page-table">
<n-data-table
remote
flex-height
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(r: Endpoint) => r.id"
:checked-row-keys="checkedKeys"
:max-height="480"
:scroll-x="1200"
:scroll-x="1400"
:pagination="{
page,
pageSize,
@@ -547,6 +556,7 @@ function onPageSizeChange(s: number) {
}"
@update:checked-row-keys="(k) => (checkedKeys = k)"
/>
</div>
</template>
</div>
@@ -688,8 +698,17 @@ function onPageSizeChange(s: number) {
}
.page-body {
flex: 1;
overflow: auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 16px;
}
.page-filters {
flex: 0 0 auto;
}
.page-table {
flex: 1;
min-height: 0;
}
</style>
+16 -2
View File
@@ -293,17 +293,20 @@ async function onTransfer(endpointId: string) {
<div class="page-body">
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
<template v-else>
<div class="page-filters">
<n-space style="margin-bottom: 12px">
<n-input v-model:value="query" placeholder="群名称" style="width: 200px" data-testid="group-query" @keyup.enter="page = 1; load()" />
<n-button @click="page = 1; load()">查询</n-button>
</n-space>
</div>
<div class="page-table">
<n-data-table
remote
flex-height
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(r: GroupSummary) => r.id"
:max-height="480"
:pagination="{
page,
pageSize,
@@ -314,6 +317,7 @@ async function onTransfer(endpointId: string) {
},
}"
/>
</div>
</template>
</div>
@@ -408,7 +412,17 @@ async function onTransfer(endpointId: string) {
}
.page-body {
flex: 1;
overflow: auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 16px;
}
.page-filters {
flex: 0 0 auto;
}
.page-table {
flex: 1;
min-height: 0;
}
</style>
+28 -14
View File
@@ -9,7 +9,6 @@ import {
NDescriptionsItem,
NInput,
NModal,
NScrollbar,
NSelect,
NSpace,
} from "naive-ui";
@@ -18,6 +17,7 @@ import HelpTip from "@/components/HelpTip.vue";
import LoadFailed from "@/components/LoadFailed.vue";
import { getMessage, listMessages, type MessageDelivery, type MessageDetail, type MessageSummary } from "@/api/admin";
import { formatLocalMs } from "@/utils/time";
import { destKindLabel, deliveryStateLabel, messageStateLabel, reasonLabel, zhLabel } from "@/utils/labels";
const loading = ref(false);
const loadError = ref("");
@@ -81,15 +81,15 @@ const columns = computed<DataTableColumns<MessageSummary>>(() => [
title: "目标",
key: "dest",
width: 140,
render: (r) => `${r.dest_kind}:${r.dest_id}`,
render: (r) => `${zhLabel(destKindLabel, r.dest_kind)}:${r.dest_id}`,
},
{ title: "状态", key: "state", width: 100 },
{ title: "状态", key: "state", width: 100, render: (r) => h("span", { title: r.state }, zhLabel(messageStateLabel, r.state)) },
{
title: "原因",
key: "reason",
width: 140,
ellipsis: { tooltip: true },
render: (r) => r.reason || "—",
render: (r) => h("span", { title: r.reason || "" }, r.reason ? zhLabel(reasonLabel, r.reason) : "—"),
},
{
title: "投递统计",
@@ -122,11 +122,11 @@ const columns = computed<DataTableColumns<MessageSummary>>(() => [
const deliveryColumns = computed<DataTableColumns<MessageDelivery>>(() => [
{ title: "接收端", key: "endpoint_id" },
{ title: "状态", key: "state" },
{ title: "状态", key: "state", render: (r) => h("span", { title: r.state }, zhLabel(deliveryStateLabel, r.state)) },
{
title: "原因",
key: "reason",
render: (r) => r.reason || "—",
render: (r) => h("span", { title: r.reason || "" }, r.reason ? zhLabel(reasonLabel, r.reason) : "—"),
},
{ title: "次数", key: "attempts", width: 60 },
{
@@ -168,6 +168,7 @@ async function loadMoreDeliveries() {
<div class="page-body">
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
<template v-else>
<div class="page-filters">
<n-space style="margin-bottom: 12px" align="center">
<n-input v-model:value="filters.sender_id" placeholder="发送方" style="width: 120px" />
<n-input v-model:value="filters.endpoint_id" placeholder="接收端" style="width: 120px" />
@@ -192,14 +193,16 @@ async function loadMoreDeliveries() {
<HelpTip>页面与网络响应均不得出现消息正文。</HelpTip>
</span>
</n-space>
</div>
<div class="page-table">
<n-data-table
remote
flex-height
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(r: MessageSummary) => r.seq"
:max-height="480"
:scroll-x="1100"
:scroll-x="1400"
:pagination="{
page,
pageSize,
@@ -210,18 +213,19 @@ async function loadMoreDeliveries() {
},
}"
/>
</div>
</template>
</div>
<n-modal v-model:show="detailOpen" preset="card" title="投递详情" style="width: 720px">
<n-scrollbar v-if="detail" style="max-height: 420px">
<template v-if="detail">
<n-descriptions label-placement="left" :column="2" size="small" bordered style="margin-bottom: 12px">
<n-descriptions-item label="序号">{{ detail.seq }}</n-descriptions-item>
<n-descriptions-item label="消息号">{{ detail.id }}</n-descriptions-item>
<n-descriptions-item label="发送方">{{ detail.sender_id }}</n-descriptions-item>
<n-descriptions-item label="目标">{{ detail.dest_kind }}:{{ detail.dest_id }}</n-descriptions-item>
<n-descriptions-item label="状态">{{ detail.state }}</n-descriptions-item>
<n-descriptions-item label="原因">{{ detail.reason || "—" }}</n-descriptions-item>
<n-descriptions-item label="目标">{{ destKindLabel[detail.dest_kind] || detail.dest_kind }}:{{ detail.dest_id }}</n-descriptions-item>
<n-descriptions-item label="状态"><span :title="detail.state">{{ zhLabel(messageStateLabel, detail.state) }}</span></n-descriptions-item>
<n-descriptions-item label="原因"><span :title="detail.reason">{{ detail.reason ? zhLabel(reasonLabel, detail.reason) : "—" }}</span></n-descriptions-item>
<n-descriptions-item label="类型">{{ detail.content_type }}</n-descriptions-item>
<n-descriptions-item label="创建">{{ formatLocalMs(detail.created_at_ms) }}</n-descriptions-item>
<n-descriptions-item label="发送">{{ formatLocalMs(detail.send_at_ms) }}</n-descriptions-item>
@@ -236,7 +240,7 @@ async function loadMoreDeliveries() {
>
加载更多
</n-button>
</n-scrollbar>
</template>
<template #footer>
<n-space justify="end">
<n-button @click="detailOpen = false">关闭</n-button>
@@ -255,7 +259,17 @@ async function loadMoreDeliveries() {
}
.page-body {
flex: 1;
overflow: auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 16px;
}
.page-filters {
flex: 0 0 auto;
}
.page-table {
flex: 1;
min-height: 0;
}
</style>
+12 -1
View File
@@ -34,7 +34,7 @@ function wrap(Comp: object) {
});
}
describe("OverviewView 加载失败", () => {
describe("OverviewView", () => {
beforeEach(() => {
setActivePinia(createPinia());
mockApi._setSession();
@@ -68,7 +68,18 @@ describe("OverviewView 加载失败", () => {
await flushPromises();
expect(w.text()).toContain("0.1.0");
expect(w.text()).toContain("其中自助注册");
expect(w.text()).not.toContain("加载失败");
w.unmount();
});
it("成功加载后显示自助注册数", async () => {
const w = mount(wrap(OverviewView), {
global: { plugins: [createPinia()] },
attachTo: document.body,
});
await flushPromises();
expect(w.text()).toContain("其中自助注册");
w.unmount();
});
});
+3 -1
View File
@@ -6,6 +6,7 @@ import HelpTip from "@/components/HelpTip.vue";
import LoadFailed from "@/components/LoadFailed.vue";
import { fetchOverview, type Overview } from "@/api/admin";
import { formatLocalMs } from "@/utils/time";
import { formatUptime } from "@/utils/labels";
const loading = ref(true);
const loadError = ref("");
@@ -41,9 +42,10 @@ onMounted(() => {
运行时长
<HelpTip>自进程启动起的毫秒数,按本地时区换算展示起点附近时间差。</HelpTip>
</template>
{{ Math.floor(data.uptime_ms / 1000) }} 秒(约 {{ formatLocalMs(Date.now() - data.uptime_ms) }} 起)
{{ formatUptime(data.uptime_ms) }}(约 {{ formatLocalMs(Date.now() - data.uptime_ms) }} 起)
</n-descriptions-item>
<n-descriptions-item label="端总数">{{ data.endpoints_total }}</n-descriptions-item>
<n-descriptions-item label="其中自助注册">{{ data.endpoints_self }}</n-descriptions-item>
<n-descriptions-item label="在线端">{{ data.endpoints_online }}</n-descriptions-item>
<n-descriptions-item label="已停用">{{ data.endpoints_disabled }}</n-descriptions-item>
<n-descriptions-item label="群数量">{{ data.groups_total }}</n-descriptions-item>
+35 -9
View File
@@ -21,6 +21,7 @@ import LoadFailed from "@/components/LoadFailed.vue";
import { changePassword, getSettings, type RuntimeSettings } from "@/api/admin";
import { ApiError } from "@/api/http";
import { message } from "@/utils/notify";
import { limitsLabel } from "@/utils/labels";
const loading = ref(true);
const loadError = ref("");
@@ -131,17 +132,42 @@ async function submitPassword() {
<LoadFailed v-if="loadError && !settings" :description="loadError" @retry="load" />
<template v-else-if="settings">
<n-descriptions label-placement="left" :column="2" bordered size="small" style="margin-bottom: 16px">
<n-descriptions-item label="listen">{{ settings.listen }}</n-descriptions-item>
<n-descriptions-item label="admin_listen">{{ settings.admin_listen || "(空,与端共用)" }}</n-descriptions-item>
<n-descriptions-item label="会话闲置天数">{{ settings.session_idle_days }}</n-descriptions-item>
<n-descriptions-item label="记录保留天数">{{ settings.record_retention_days }}</n-descriptions-item>
<n-descriptions-item label="防重小时数">{{ settings.idempotency_hours }}</n-descriptions-item>
<n-descriptions-item label="回执保留天数">{{ settings.receipt_retention_days }}</n-descriptions-item>
<n-descriptions-item label="SQLite synchronous">{{ settings.sqlite_synchronous }}</n-descriptions-item>
<n-descriptions-item>
<template #label>端监听地址 <HelpTip>listen</HelpTip></template>
{{ settings.listen }}
</n-descriptions-item>
<n-descriptions-item>
<template #label>后台监听地址 <HelpTip>admin_listen</HelpTip></template>
{{ settings.admin_listen || "(空,与端共用)" }}
</n-descriptions-item>
<n-descriptions-item>
<template #label>会话闲置天数 <HelpTip>session_idle_days</HelpTip></template>
{{ settings.session_idle_days }}
</n-descriptions-item>
<n-descriptions-item>
<template #label>记录保留天数 <HelpTip>record_retention_days</HelpTip></template>
{{ settings.record_retention_days }}
</n-descriptions-item>
<n-descriptions-item>
<template #label>防重小时数 <HelpTip>idempotency_hours</HelpTip></template>
{{ settings.idempotency_hours }}
</n-descriptions-item>
<n-descriptions-item>
<template #label>回执保留天数 <HelpTip>receipt_retention_days</HelpTip></template>
{{ settings.receipt_retention_days }}
</n-descriptions-item>
<n-descriptions-item>
<template #label>SQLite 同步模式 <HelpTip>sqlite_synchronous</HelpTip></template>
{{ settings.sqlite_synchronous }}
</n-descriptions-item>
</n-descriptions>
<n-text strong style="display: block; margin-bottom: 8px">limits</n-text>
<n-text strong style="display: block; margin-bottom: 8px">限额</n-text>
<n-descriptions label-placement="left" :column="2" bordered size="small">
<n-descriptions-item v-for="(v, k) in settings.limits" :key="k" :label="String(k)">
<n-descriptions-item v-for="(v, k) in settings.limits" :key="k">
<template #label>
{{ limitsLabel[String(k)] || k }}
<HelpTip>{{ k }}</HelpTip>
</template>
{{ v }}
</n-descriptions-item>
</n-descriptions>
+16 -2
View File
@@ -143,6 +143,7 @@ function onDelete(r: ApiToken) {
<div class="page-body">
<LoadFailed v-if="loadError" :description="loadError" @retry="load" />
<template v-else>
<div class="page-filters">
<SecretOnceAlert
v-if="onceToken"
title="令牌只显示一次"
@@ -151,13 +152,16 @@ function onDelete(r: ApiToken) {
data-testid="token-once"
@dismiss="onceToken = null"
/>
</div>
<div class="page-table">
<n-data-table
flex-height
:loading="loading"
:columns="columns"
:data="rows"
:row-key="(r: ApiToken) => r.id"
:max-height="480"
/>
</div>
</template>
</div>
@@ -188,7 +192,17 @@ function onDelete(r: ApiToken) {
}
.page-body {
flex: 1;
overflow: auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
padding: 16px;
}
.page-filters {
flex: 0 0 auto;
}
.page-table {
flex: 1;
min-height: 0;
}
</style>