fix: 校正一次性密码展示与 CSV 下载
This commit is contained in:
+3
-2
@@ -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[] }> {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user