fix: 校正一次性密码展示与 CSV 下载

This commit is contained in:
Nixevol
2026-09-30 16:22:49 +08:00
parent ed788fbb74
commit 15802bf706
9 changed files with 270 additions and 35 deletions
+3 -2
View File
@@ -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[] }> {
+2 -2
View File
@@ -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;
}
@@ -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();
});
});
+49 -18
View File
@@ -1,40 +1,71 @@
<script setup lang="ts">
import { ref } from "vue";
import { NAlert, NButton, NSpace, NText } from "naive-ui";
import { downloadText } from "@/utils/csv";
import { message } from "@/utils/notify";
const props = defineProps<{
title: string;
secret: string;
filename?: string;
}>();
const props = withDefaults(
defineProps<{
title: string;
secret: string;
filename?: string;
mime?: string;
}>(),
{
filename: "secret.txt",
mime: "text/plain;charset=utf-8",
},
);
const emit = defineEmits<{
dismiss: [];
}>();
function copy() {
void navigator.clipboard?.writeText(props.secret);
const secretEl = ref<HTMLElement | null>(null);
async function copy() {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(props.secret);
message.success("已复制");
return;
}
} catch {
/* 回退选中文本 */
}
const el = secretEl.value;
if (el) {
const sel = window.getSelection();
const range = document.createRange();
range.selectNodeContents(el);
sel?.removeAllRanges();
sel?.addRange(range);
}
try {
if (document.execCommand("copy")) {
message.success("已复制");
return;
}
} catch {
/* ignore */
}
message.error("复制失败,请手动选择文本");
}
function download() {
const blob = new Blob([props.secret], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = props.filename ?? "secret.txt";
a.click();
URL.revokeObjectURL(url);
downloadText(props.filename, props.secret, props.mime);
}
</script>
<template>
<n-alert type="warning" :title="title" style="margin-bottom: 12px">
<n-space vertical :size="8">
<n-text code>{{ secret }}</n-text>
<span ref="secretEl" data-testid="secret-text"><n-text code>{{ secret }}</n-text></span>
<n-text depth="3">关闭后将无法再次查看,请立即复制或下载。</n-text>
<n-space>
<n-button size="small" @click="copy">复制</n-button>
<n-button size="small" @click="download">下载</n-button>
<n-button size="small" secondary @click="emit('dismiss')">我已保存</n-button>
<n-button size="small" data-testid="secret-copy" @click="copy">复制</n-button>
<n-button size="small" data-testid="secret-download" @click="download">下载</n-button>
<n-button size="small" secondary data-testid="secret-dismiss" @click="emit('dismiss')">我已保存</n-button>
</n-space>
</n-space>
</n-alert>
+36
View File
@@ -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"');
});
});
+40
View File
@@ -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);
}
+24
View File
@@ -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();
});
});
+34 -13
View File
@@ -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<ImportItem[] | null>(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"
/>
<n-space style="margin-bottom: 12px" align="center">
@@ -439,7 +459,7 @@ function onPageSizeChange(s: number) {
<n-input v-model:value="createForm.name" data-testid="create-name" placeholder="可选" />
</n-form-item>
<n-form-item label="登录密码">
<n-input v-model:value="createForm.login_password" placeholder="留空则生成" type="password" show-password-on="click" />
<n-input v-model:value="createForm.login_password" placeholder="留空则生成" type="password" show-password-on="click" data-testid="create-login-password" />
</n-form-item>
<n-form-item label="对话密码">
<n-input v-model:value="createForm.talk_password" placeholder="可选" type="password" show-password-on="click" />
@@ -502,10 +522,11 @@ function onPageSizeChange(s: number) {
<n-modal v-if="importResult" :show="true" preset="card" title="导入结果" style="width: 560px" @update:show="(v) => !v && (importResult = null)">
<n-scrollbar style="max-height: 280px">
<n-text depth="3">以下密码仅本次可见,请下载保存。</n-text>
<pre style="font-size: 12px; white-space: pre-wrap">{{ importResult.map((i) => `${i.id}\t${i.name}\t${i.login_password}`).join("\n") }}</pre>
<pre style="font-size: 12px; white-space: pre-wrap">{{ importResult.map((i) => `${i.id}\t${i.name}\t${i.login_password ?? ""}`).join("\n") }}</pre>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="downloadImportCsv">下载</n-button>
<n-button @click="importResult = null">关闭</n-button>
</n-space>
</template>