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 @@ - {{ secret }} + {{ secret }} 关闭后将无法再次查看,请立即复制或下载。 - 复制 - 下载 - 我已保存 + 复制 + 下载 + 我已保存 diff --git a/web/src/utils/csv.spec.ts b/web/src/utils/csv.spec.ts new file mode 100644 index 0000000..97d5ce5 --- /dev/null +++ b/web/src/utils/csv.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { buildPasswordCsv, csvQuote, guardFormula } from "./csv"; + +describe("csvQuote", () => { + it("给所有字段加引号并双写内部引号", () => { + expect(csvQuote("a")).toBe('"a"'); + expect(csvQuote('say "hi"')).toBe('"say ""hi"""'); + expect(csvQuote("a,b")).toBe('"a,b"'); + expect(csvQuote("a\nb")).toBe('"a\nb"'); + }); +}); + +describe("guardFormula", () => { + it("名称以公式字符开头时前缀单引号", () => { + expect(guardFormula("=1+1")).toBe("'=1+1"); + expect(guardFormula("+cmd")).toBe("'+cmd"); + expect(guardFormula("-1")).toBe("'-1"); + expect(guardFormula("@SUM")).toBe("'@SUM"); + expect(guardFormula("门口")).toBe("门口"); + }); +}); + +describe("buildPasswordCsv", () => { + it("带 BOM、表头、CRLF,密码原样加引号", () => { + const out = buildPasswordCsv([ + { id: "e1", name: "门,口", login_password: 'p"w' }, + { id: "e2", name: "=cmd", login_password: "=1+1" }, + ]); + expect(out.startsWith("\uFEFF")).toBe(true); + const body = out.slice(1); + expect(body.startsWith('"id","name","login_password"\r\n')).toBe(true); + const rows = body.split("\r\n"); + expect(rows[1]).toBe('"e1","门,口","p""w"'); + expect(rows[2]).toBe('"e2","\'=cmd","=1+1"'); + }); +}); diff --git a/web/src/utils/csv.ts b/web/src/utils/csv.ts new file mode 100644 index 0000000..b30b996 --- /dev/null +++ b/web/src/utils/csv.ts @@ -0,0 +1,40 @@ +/** RFC 4180 CSV,用于一次性密码下载。 */ + +const FORMULA_START = /^[=+\-@]/; + +export function csvQuote(value: string): string { + return `"${String(value ?? "").replace(/"/g, '""')}"`; +} + +/** 名称列以防公式注入:以 = + - @ 开头时前缀单引号。密码列不要调用。 */ +export function guardFormula(value: string): string { + const v = String(value ?? ""); + return FORMULA_START.test(v) ? `'${v}` : v; +} + +export interface PasswordCsvRow { + id: string; + name: string; + login_password: string; +} + +/** BOM + 表头 + CRLF;所有字段加引号。 */ +export function buildPasswordCsv(rows: PasswordCsvRow[]): string { + const lines = [ + [csvQuote("id"), csvQuote("name"), csvQuote("login_password")].join(","), + ...rows.map((r) => + [csvQuote(r.id), csvQuote(guardFormula(r.name)), csvQuote(r.login_password)].join(","), + ), + ]; + return `\uFEFF${lines.join("\r\n")}`; +} + +export function downloadText(filename: string, text: string, mime: string) { + const blob = new Blob([text], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/web/src/views/EndpointsView.spec.ts b/web/src/views/EndpointsView.spec.ts index ac7616a..6cd6530 100644 --- a/web/src/views/EndpointsView.spec.ts +++ b/web/src/views/EndpointsView.spec.ts @@ -69,4 +69,28 @@ describe("EndpointsView 开通端", () => { expect(w.text()).not.toContain("只显示一次"); w.unmount(); }); + + it("手填密码开通后不显示一次性面板和 undefined", async () => { + const pinia = createPinia(); + const w = mount(wrap(EndpointsView), { + global: { plugins: [pinia] }, + attachTo: document.body, + }); + await flushPromises(); + + const openBtn = w.findAll("button").find((b) => b.text().trim() === "开通"); + await openBtn!.trigger("click"); + await nextTick(); + await flushPromises(); + + await w.find('[data-testid="create-name"]').find("input").setValue("手填端"); + await w.find('[data-testid="create-login-password"]').find("input").setValue("handpassword12"); + await w.find('[data-testid="create-submit"]').trigger("click"); + await flushPromises(); + + expect(w.text()).not.toContain("undefined"); + expect(w.text()).not.toContain("只显示一次"); + expect(w.text()).toContain("手填端"); + w.unmount(); + }); }); diff --git a/web/src/views/EndpointsView.vue b/web/src/views/EndpointsView.vue index ca93cc0..1454532 100644 --- a/web/src/views/EndpointsView.vue +++ b/web/src/views/EndpointsView.vue @@ -38,6 +38,7 @@ import { } from "@/api/admin"; import { ApiError } from "@/api/http"; import { formatLocalMs } from "@/utils/time"; +import { buildPasswordCsv, downloadText } from "@/utils/csv"; import { message } from "@/utils/notify"; const dialog = useDialog(); @@ -67,7 +68,7 @@ const onlineOptions = [ { label: "离线", value: "false" }, ]; -const onceSecret = ref<{ title: string; secret: string; filename: string } | null>(null); +const onceSecret = ref<{ title: string; secret: string; filename: string; mime?: string } | null>(null); const importResult = ref(null); const createOpen = ref(false); @@ -211,11 +212,15 @@ async function submitCreate() { default_delay_seconds: createForm.default_delay_seconds, }); createOpen.value = false; - onceSecret.value = { - title: "开通成功,登录密码只显示一次", - secret: `${res.id}\t${res.login_password}`, - filename: `${res.id}-password.txt`, - }; + if (res.login_password) { + onceSecret.value = { + title: "开通成功,登录密码只显示一次", + secret: `${res.id}\t${res.login_password}`, + filename: `${res.id}-password.txt`, + }; + } else { + message.success(`已开通 ${res.id}`); + } await load(); } catch (e) { createError.value = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "开通失败"; @@ -337,11 +342,15 @@ async function onImport(options: UploadCustomRequestOptions) { const text = await raw.text(); const res = await importEndpoints(text); importResult.value = res.items; - onceSecret.value = { - title: "CSV 导入成功,密码只显示一次", - secret: res.items.map((i) => `${i.id},${i.name},${i.login_password}`).join("\n"), - filename: "import-passwords.csv", - }; + const pwdRows = res.items.filter((i): i is ImportItem & { login_password: string } => Boolean(i.login_password)); + if (pwdRows.length) { + onceSecret.value = { + title: "CSV 导入成功,密码只显示一次", + secret: buildPasswordCsv(pwdRows), + filename: "import-passwords.csv", + mime: "text/csv;charset=utf-8", + }; + } options.onFinish(); await load(); } catch (e) { @@ -355,6 +364,16 @@ async function onImport(options: UploadCustomRequestOptions) { } } +function downloadImportCsv() { + if (!importResult.value?.length) { + return; + } + const pwdRows = importResult.value.filter((i): i is ImportItem & { login_password: string } => + Boolean(i.login_password), + ); + downloadText("import-passwords.csv", buildPasswordCsv(pwdRows), "text/csv;charset=utf-8"); +} + function onPageChange(p: number) { page.value = p; void load(); @@ -387,6 +406,7 @@ function onPageSizeChange(s: number) { :title="onceSecret.title" :secret="onceSecret.secret" :filename="onceSecret.filename" + :mime="onceSecret.mime" @dismiss="onceSecret = null" /> @@ -439,7 +459,7 @@ function onPageSizeChange(s: number) { - + @@ -502,10 +522,11 @@ function onPageSizeChange(s: number) { !v && (importResult = null)"> 以下密码仅本次可见,请下载保存。 - {{ importResult.map((i) => `${i.id}\t${i.name}\t${i.login_password}`).join("\n") }} + {{ importResult.map((i) => `${i.id}\t${i.name}\t${i.login_password ?? ""}`).join("\n") }} + 下载 关闭
{{ importResult.map((i) => `${i.id}\t${i.name}\t${i.login_password}`).join("\n") }}
{{ importResult.map((i) => `${i.id}\t${i.name}\t${i.login_password ?? ""}`).join("\n") }}