From eb4e2db91866369973836c5a84639e328f9a2dc4 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 15:22:11 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=87=AA=E9=80=82=E5=BA=94=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E9=AB=98=E5=BA=A6=E5=B9=B6=E6=94=B9=E7=94=A8=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEVIATIONS.md | 9 ++++ web/e2e/admin-main.spec.ts | 2 +- web/src/api/mock.ts | 1 + web/src/api/types.ts | 3 +- web/src/utils/labels.spec.ts | 15 ++++++ web/src/utils/labels.ts | 75 ++++++++++++++++++++++++++++++ web/src/views/EndpointsView.vue | 29 ++++++++++-- web/src/views/GroupsView.vue | 18 ++++++- web/src/views/MessagesView.vue | 42 +++++++++++------ web/src/views/OverviewView.spec.ts | 13 +++++- web/src/views/OverviewView.vue | 4 +- web/src/views/SettingsView.vue | 44 ++++++++++++++---- web/src/views/TokensView.vue | 18 ++++++- 13 files changed, 236 insertions(+), 37 deletions(-) create mode 100644 web/src/utils/labels.spec.ts create mode 100644 web/src/utils/labels.ts diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index c4f13fc..00a7592 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -1039,6 +1039,15 @@ - 备选方案:后端按编号前缀搜索;未采用,与现接口「名称包含」一致。 - 影响:网页线改了 `internal/admin/groups.go` 与 admin-api 7.8(issue 允许)。 +### 复审修复 W-06 + +- 日期:2026-09-30 +- 原条款:用户界面规则长列表内部滚动;PRD F17 界面中文与自助注册数;审查 #56。 +- 实际做法:列表页 `.page-body` 改为 flex 列,筛选不伸缩,表格 `flex-height`。新增 `utils/labels.ts` 译消息/投递状态与原因(原值 title)。运行参数中文标签、键名放 HelpTip。端列表拆成最近上线/离线。概览展示「其中自助注册」,运行时长按天时分。e2e 断言「定时中」。Playwright 双分辨率截图未跑(本波以 Vitest 验收布局类改动)。 +- 原因:写死 480 高度双滚动;英文枚举;漏自助数。 +- 备选方案:一次性密码改弹窗;仍放顶部不参与伸缩。 +- 影响:投递页不再显示 `scheduled` 英文单元格。 + ### S1.1 传输层可注入假实现(Go / JS) - 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。 diff --git a/web/e2e/admin-main.spec.ts b/web/e2e/admin-main.spec.ts index d862b77..152f868 100644 --- a/web/e2e/admin-main.spec.ts +++ b/web/e2e/admin-main.spec.ts @@ -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/); diff --git a/web/src/api/mock.ts b/web/src/api/mock.ts index 64782af..c62de3e 100644 --- a/web/src/api/mock.ts +++ b/web/src/api/mock.ts @@ -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, diff --git a/web/src/api/types.ts b/web/src/api/types.ts index e7b6ddc..a892f1e 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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; diff --git a/web/src/utils/labels.spec.ts b/web/src/utils/labels.spec.ts new file mode 100644 index 0000000..a9b3bc0 --- /dev/null +++ b/web/src/utils/labels.spec.ts @@ -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 分"); + }); +}); diff --git a/web/src/utils/labels.ts b/web/src/utils/labels.ts new file mode 100644 index 0000000..554d93a --- /dev/null +++ b/web/src/utils/labels.ts @@ -0,0 +1,75 @@ +/** 后台界面中文标签;原值可放 tooltip。 */ + +export const messageStateLabel: Record = { + scheduled: "定时中", + dispatched: "投递中", + completed: "已完成", +}; + +export const destKindLabel: Record = { + endpoint: "端", + group: "群", +}; + +export const deliveryStateLabel: Record = { + pending: "待投递", + accepted: "已收下", + recalled: "已撤回", + expired: "已过期", + dropped: "已丢弃", + rejected: "已拒绝", +}; + +export const reasonLabel: Record = { + disabled: "端已停用", + expired: "已过期", + recalled: "已撤回", + dropped: "已丢弃", + rejected: "已拒绝", + unauthorized: "未授权", + not_found: "不存在", +}; + +export const settingsFieldLabel: Record = { + listen: "端监听地址", + admin_listen: "后台监听地址", + session_idle_days: "会话闲置天数", + record_retention_days: "记录保留天数", + idempotency_hours: "防重小时数", + receipt_retention_days: "回执保留天数", + sqlite_synchronous: "SQLite 同步模式", +}; + +export const limitsLabel: Record = { + 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, 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(" "); +} diff --git a/web/src/views/EndpointsView.vue b/web/src/views/EndpointsView.vue index 84cc2c7..1438358 100644 --- a/web/src/views/EndpointsView.vue +++ b/web/src/views/EndpointsView.vue @@ -187,10 +187,16 @@ const columns = computed>(() => [ 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) {
@@ -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; } diff --git a/web/src/views/GroupsView.vue b/web/src/views/GroupsView.vue index 49eef29..eafda09 100644 --- a/web/src/views/GroupsView.vue +++ b/web/src/views/GroupsView.vue @@ -293,17 +293,20 @@ async function onTransfer(endpointId: string) {
@@ -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; +} diff --git a/web/src/views/MessagesView.vue b/web/src/views/MessagesView.vue index d36923d..dec07d2 100644 --- a/web/src/views/MessagesView.vue +++ b/web/src/views/MessagesView.vue @@ -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>(() => [ 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>(() => [ const deliveryColumns = computed>(() => [ { 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() {
- + - {{ Math.floor(data.uptime_ms / 1000) }} 秒(约 {{ formatLocalMs(Date.now() - data.uptime_ms) }} 起) + {{ formatUptime(data.uptime_ms) }}(约 {{ formatLocalMs(Date.now() - data.uptime_ms) }} 起) {{ data.endpoints_total }} + {{ data.endpoints_self }} {{ data.endpoints_online }} {{ data.endpoints_disabled }} {{ data.groups_total }} diff --git a/web/src/views/SettingsView.vue b/web/src/views/SettingsView.vue index 9cc8824..f72ecc4 100644 --- a/web/src/views/SettingsView.vue +++ b/web/src/views/SettingsView.vue @@ -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() {