fix: 校正一次性密码展示与 CSV 下载
This commit is contained in:
@@ -1003,6 +1003,15 @@
|
|||||||
- 备选方案:改密 401 也自动登出;未采用,与契约「旧密码错误 401」冲突。
|
- 备选方案:改密 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)
|
### S1.1 传输层可注入假实现(Go / JS)
|
||||||
|
|
||||||
- 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。
|
- 相关文档:DEVELOPMENT 第 9 节单元测试要求「用假的 MQTT/HTTP,不要起真实服务器」。
|
||||||
|
|||||||
+3
-2
@@ -354,7 +354,8 @@ export const mockApi = {
|
|||||||
if (endpoints.some((e) => e.id === id)) {
|
if (endpoints.some((e) => e.id === id)) {
|
||||||
throw new ApiError("id_taken", "编号已占用", 409);
|
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({
|
endpoints.unshift({
|
||||||
id,
|
id,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
@@ -369,7 +370,7 @@ export const mockApi = {
|
|||||||
created_at_ms: now(),
|
created_at_ms: now(),
|
||||||
login_locked: false,
|
login_locked: false,
|
||||||
});
|
});
|
||||||
return { id, login_password: loginPassword };
|
return provided ? { id } : { id, login_password: loginPassword };
|
||||||
},
|
},
|
||||||
|
|
||||||
async importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> {
|
async importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export interface EndpointCreateRequest {
|
|||||||
|
|
||||||
export interface EndpointCreateResult {
|
export interface EndpointCreateResult {
|
||||||
id: string;
|
id: string;
|
||||||
login_password: string;
|
login_password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EndpointPatchRequest {
|
export interface EndpointPatchRequest {
|
||||||
@@ -98,7 +98,7 @@ export interface ImportError {
|
|||||||
|
|
||||||
export interface ImportItem {
|
export interface ImportItem {
|
||||||
id: string;
|
id: string;
|
||||||
login_password: string;
|
login_password?: string;
|
||||||
name: 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">
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
import { NAlert, NButton, NSpace, NText } from "naive-ui";
|
import { NAlert, NButton, NSpace, NText } from "naive-ui";
|
||||||
|
import { downloadText } from "@/utils/csv";
|
||||||
|
import { message } from "@/utils/notify";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
title: string;
|
defineProps<{
|
||||||
secret: string;
|
title: string;
|
||||||
filename?: string;
|
secret: string;
|
||||||
}>();
|
filename?: string;
|
||||||
|
mime?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
filename: "secret.txt",
|
||||||
|
mime: "text/plain;charset=utf-8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
dismiss: [];
|
dismiss: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
function copy() {
|
const secretEl = ref<HTMLElement | null>(null);
|
||||||
void navigator.clipboard?.writeText(props.secret);
|
|
||||||
|
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() {
|
function download() {
|
||||||
const blob = new Blob([props.secret], { type: "text/plain;charset=utf-8" });
|
downloadText(props.filename, props.secret, props.mime);
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = props.filename ?? "secret.txt";
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<n-alert type="warning" :title="title" style="margin-bottom: 12px">
|
<n-alert type="warning" :title="title" style="margin-bottom: 12px">
|
||||||
<n-space vertical :size="8">
|
<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-text depth="3">关闭后将无法再次查看,请立即复制或下载。</n-text>
|
||||||
<n-space>
|
<n-space>
|
||||||
<n-button size="small" @click="copy">复制</n-button>
|
<n-button size="small" data-testid="secret-copy" @click="copy">复制</n-button>
|
||||||
<n-button size="small" @click="download">下载</n-button>
|
<n-button size="small" data-testid="secret-download" @click="download">下载</n-button>
|
||||||
<n-button size="small" secondary @click="emit('dismiss')">我已保存</n-button>
|
<n-button size="small" secondary data-testid="secret-dismiss" @click="emit('dismiss')">我已保存</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
</n-space>
|
</n-space>
|
||||||
</n-alert>
|
</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("只显示一次");
|
expect(w.text()).not.toContain("只显示一次");
|
||||||
w.unmount();
|
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";
|
} from "@/api/admin";
|
||||||
import { ApiError } from "@/api/http";
|
import { ApiError } from "@/api/http";
|
||||||
import { formatLocalMs } from "@/utils/time";
|
import { formatLocalMs } from "@/utils/time";
|
||||||
|
import { buildPasswordCsv, downloadText } from "@/utils/csv";
|
||||||
import { message } from "@/utils/notify";
|
import { message } from "@/utils/notify";
|
||||||
|
|
||||||
const dialog = useDialog();
|
const dialog = useDialog();
|
||||||
@@ -67,7 +68,7 @@ const onlineOptions = [
|
|||||||
{ label: "离线", value: "false" },
|
{ 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 importResult = ref<ImportItem[] | null>(null);
|
||||||
|
|
||||||
const createOpen = ref(false);
|
const createOpen = ref(false);
|
||||||
@@ -211,11 +212,15 @@ async function submitCreate() {
|
|||||||
default_delay_seconds: createForm.default_delay_seconds,
|
default_delay_seconds: createForm.default_delay_seconds,
|
||||||
});
|
});
|
||||||
createOpen.value = false;
|
createOpen.value = false;
|
||||||
onceSecret.value = {
|
if (res.login_password) {
|
||||||
title: "开通成功,登录密码只显示一次",
|
onceSecret.value = {
|
||||||
secret: `${res.id}\t${res.login_password}`,
|
title: "开通成功,登录密码只显示一次",
|
||||||
filename: `${res.id}-password.txt`,
|
secret: `${res.id}\t${res.login_password}`,
|
||||||
};
|
filename: `${res.id}-password.txt`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
message.success(`已开通 ${res.id}`);
|
||||||
|
}
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
createError.value = e instanceof ApiError ? e.message : e instanceof Error ? e.message : "开通失败";
|
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 text = await raw.text();
|
||||||
const res = await importEndpoints(text);
|
const res = await importEndpoints(text);
|
||||||
importResult.value = res.items;
|
importResult.value = res.items;
|
||||||
onceSecret.value = {
|
const pwdRows = res.items.filter((i): i is ImportItem & { login_password: string } => Boolean(i.login_password));
|
||||||
title: "CSV 导入成功,密码只显示一次",
|
if (pwdRows.length) {
|
||||||
secret: res.items.map((i) => `${i.id},${i.name},${i.login_password}`).join("\n"),
|
onceSecret.value = {
|
||||||
filename: "import-passwords.csv",
|
title: "CSV 导入成功,密码只显示一次",
|
||||||
};
|
secret: buildPasswordCsv(pwdRows),
|
||||||
|
filename: "import-passwords.csv",
|
||||||
|
mime: "text/csv;charset=utf-8",
|
||||||
|
};
|
||||||
|
}
|
||||||
options.onFinish();
|
options.onFinish();
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} 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) {
|
function onPageChange(p: number) {
|
||||||
page.value = p;
|
page.value = p;
|
||||||
void load();
|
void load();
|
||||||
@@ -387,6 +406,7 @@ function onPageSizeChange(s: number) {
|
|||||||
:title="onceSecret.title"
|
:title="onceSecret.title"
|
||||||
:secret="onceSecret.secret"
|
:secret="onceSecret.secret"
|
||||||
:filename="onceSecret.filename"
|
:filename="onceSecret.filename"
|
||||||
|
:mime="onceSecret.mime"
|
||||||
@dismiss="onceSecret = null"
|
@dismiss="onceSecret = null"
|
||||||
/>
|
/>
|
||||||
<n-space style="margin-bottom: 12px" align="center">
|
<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-input v-model:value="createForm.name" data-testid="create-name" placeholder="可选" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="登录密码">
|
<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>
|
||||||
<n-form-item label="对话密码">
|
<n-form-item label="对话密码">
|
||||||
<n-input v-model:value="createForm.talk_password" placeholder="可选" type="password" show-password-on="click" />
|
<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-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-scrollbar style="max-height: 280px">
|
||||||
<n-text depth="3">以下密码仅本次可见,请下载保存。</n-text>
|
<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>
|
</n-scrollbar>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<n-space justify="end">
|
<n-space justify="end">
|
||||||
|
<n-button @click="downloadImportCsv">下载</n-button>
|
||||||
<n-button @click="importResult = null">关闭</n-button>
|
<n-button @click="importResult = null">关闭</n-button>
|
||||||
</n-space>
|
</n-space>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user