From 15802bf7066814011aba65f9718587b3efcaac8c Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 15:14:05 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=A0=A1=E6=AD=A3=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=80=A7=E5=AF=86=E7=A0=81=E5=B1=95=E7=A4=BA=E4=B8=8E=20CSV=20?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/DEVIATIONS.md | 9 +++ web/src/api/mock.ts | 5 +- web/src/api/types.ts | 4 +- web/src/components/SecretOnceAlert.spec.ts | 73 ++++++++++++++++++++++ web/src/components/SecretOnceAlert.vue | 67 ++++++++++++++------ web/src/utils/csv.spec.ts | 36 +++++++++++ web/src/utils/csv.ts | 40 ++++++++++++ web/src/views/EndpointsView.spec.ts | 24 +++++++ web/src/views/EndpointsView.vue | 47 ++++++++++---- 9 files changed, 270 insertions(+), 35 deletions(-) create mode 100644 web/src/components/SecretOnceAlert.spec.ts create mode 100644 web/src/utils/csv.spec.ts create mode 100644 web/src/utils/csv.ts diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 8f15887..ea76df3 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -1003,6 +1003,15 @@ - 备选方案:改密 401 也自动登出;未采用,与契约「旧密码错误 401」冲突。 - 影响:改密失败不会踢当前会话;注册页错误提示仍走原逻辑。 +### 复审修复 W-04 + +- 日期:2026-09-30 +- 原条款:PRD F01 密码只显示一次并可下载;审查 #54。 +- 实际做法:新增 `utils/csv.ts` 按 RFC 4180 引号转义、CRLF、BOM、表头;名称列公式前缀 `'`,密码列不加前缀。`SecretOnceAlert` 增加 `mime`、复制成功/失败提示与 clipboard 回退。开通手填密码时不返回 `login_password`,只提示「已开通 {id}」。导入结果弹窗增加下载。 +- 原因:拼接 CSV 会错列;手填密码显示 undefined;明文 HTTP 下 clipboard 不可用。 +- 备选方案:密码列也加公式前缀;未采用,避免改写密码。 +- 影响:导入下载文件可被 Excel 正确打开。 + ### S1.1 传输层可注入假实现(Go / JS) - 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。 diff --git a/web/src/api/mock.ts b/web/src/api/mock.ts index 7ce9b35..e3658a3 100644 --- a/web/src/api/mock.ts +++ b/web/src/api/mock.ts @@ -354,7 +354,8 @@ export const mockApi = { if (endpoints.some((e) => e.id === id)) { throw new ApiError("id_taken", "编号已占用", 409); } - const loginPassword = (body.login_password || "").trim() || randomPassword(); + const provided = (body.login_password || "").trim(); + const loginPassword = provided || randomPassword(); endpoints.unshift({ id, name: body.name, @@ -369,7 +370,7 @@ export const mockApi = { created_at_ms: now(), login_locked: false, }); - return { id, login_password: loginPassword }; + return provided ? { id } : { id, login_password: loginPassword }; }, async importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 3501fa9..8f5efcb 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -72,7 +72,7 @@ export interface EndpointCreateRequest { export interface EndpointCreateResult { id: string; - login_password: string; + login_password?: string; } export interface EndpointPatchRequest { @@ -98,7 +98,7 @@ export interface ImportError { export interface ImportItem { id: string; - login_password: string; + login_password?: string; name: string; } diff --git a/web/src/components/SecretOnceAlert.spec.ts b/web/src/components/SecretOnceAlert.spec.ts new file mode 100644 index 0000000..f08a27f --- /dev/null +++ b/web/src/components/SecretOnceAlert.spec.ts @@ -0,0 +1,73 @@ +import { config, mount, flushPromises } from "@vue/test-utils"; +import { describe, expect, it, vi, afterEach } from "vitest"; +import { NConfigProvider, NMessageProvider, zhCN, dateZhCN } from "naive-ui"; +import { defineComponent, h } from "vue"; +import SecretOnceAlert from "./SecretOnceAlert.vue"; + +vi.mock("@/utils/notify", () => ({ + message: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + }, +})); + +import { message } from "@/utils/notify"; + +config.global.stubs = { teleport: true }; + +function wrap() { + return defineComponent({ + setup() { + return () => + h(NConfigProvider, { locale: zhCN, dateLocale: dateZhCN, size: "small" }, { + default: () => + h(NMessageProvider, null, { + default: () => + h(SecretOnceAlert, { + title: "令牌只显示一次", + secret: "nxm_abc", + filename: "api-token.txt", + }), + }), + }); + }, + }); +} + +describe("SecretOnceAlert 复制", () => { + afterEach(() => { + vi.mocked(message.success).mockClear(); + vi.mocked(message.error).mockClear(); + }); + + it("不支持 clipboard 时回退 execCommand 并提示成功", async () => { + Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true }); + Object.defineProperty(document, "execCommand", { value: vi.fn(() => true), configurable: true }); + + const w = mount(wrap(), { attachTo: document.body }); + await flushPromises(); + await w.find('[data-testid="secret-copy"]').trigger("click"); + await flushPromises(); + + expect(document.execCommand).toHaveBeenCalledWith("copy"); + expect(message.success).toHaveBeenCalledWith("已复制"); + w.unmount(); + }); + + it("writeText 失败时提示错误", async () => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn().mockRejectedValue(new Error("denied")) }, + configurable: true, + }); + Object.defineProperty(document, "execCommand", { value: vi.fn(() => false), configurable: true }); + + const w = mount(wrap(), { attachTo: document.body }); + await flushPromises(); + await w.find('[data-testid="secret-copy"]').trigger("click"); + await flushPromises(); + + expect(message.error).toHaveBeenCalledWith("复制失败,请手动选择文本"); + w.unmount(); + }); +}); diff --git a/web/src/components/SecretOnceAlert.vue b/web/src/components/SecretOnceAlert.vue index 437a726..c932dc4 100644 --- a/web/src/components/SecretOnceAlert.vue +++ b/web/src/components/SecretOnceAlert.vue @@ -1,40 +1,71 @@