fix(webapp): add CSV export and stabilize dialog dismissal

fix(webapp): 添加 CSV 导出并稳定弹窗关闭行为
This commit is contained in:
DiaMeoww
2026-06-13 13:47:44 +08:00
committed by Shuai
parent b444c0f4b8
commit b0242265f4
3 changed files with 137 additions and 1 deletions
+5 -1
View File
@@ -83,6 +83,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
const [present, setPresent] = useState(props.open); const [present, setPresent] = useState(props.open);
const [closing, setClosing] = useState(false); const [closing, setClosing] = useState(false);
const cardRef = useRef<HTMLFormElement | null>(null); const cardRef = useRef<HTMLFormElement | null>(null);
const maskPointerStartedRef = useRef(false);
const restoreFocusRef = useRef<HTMLElement | null>(null); const restoreFocusRef = useRef<HTMLElement | null>(null);
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []); const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
const titleId = `${dialogId}-title`; const titleId = `${dialogId}-title`;
@@ -176,8 +177,11 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
return createPortal(( return createPortal((
<div <div
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`} className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
onPointerDown={(event) => {
maskPointerStartedRef.current = event.target === event.currentTarget;
}}
onClick={(event) => { onClick={(event) => {
if (event.target !== event.currentTarget || !canDismiss) return; if (event.target !== event.currentTarget || !maskPointerStartedRef.current || !canDismiss) return;
props.onCancel(); props.onCancel();
}} }}
> >
+7
View File
@@ -4,6 +4,7 @@ import type { ExportRequest, ZipAttachmentEntry } from '@/lib/export-formats';
import { import {
attachNodeWardenEncryptedAttachmentPayload, attachNodeWardenEncryptedAttachmentPayload,
buildAccountEncryptedBitwardenJsonString, buildAccountEncryptedBitwardenJsonString,
buildBitwardenCsvString,
buildBitwardenZipBytes, buildBitwardenZipBytes,
buildExportFileName, buildExportFileName,
buildNodeWardenAttachmentRecords, buildNodeWardenAttachmentRecords,
@@ -1190,6 +1191,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
mimeType: 'application/json', mimeType: 'application/json',
bytes: new TextEncoder().encode(await getPlainJson()), bytes: new TextEncoder().encode(await getPlainJson()),
}; };
} else if (format === 'bitwarden_csv') {
result = {
fileName: buildExportFileName(format),
mimeType: 'text/csv;charset=utf-8',
bytes: new TextEncoder().encode(buildBitwardenCsvString(await getPlainJsonDoc())),
};
} else if (format === 'bitwarden_encrypted_json') { } else if (format === 'bitwarden_encrypted_json') {
if (request.encryptedJsonMode === 'password') { if (request.encryptedJsonMode === 'password') {
const plainJson = await getPlainJson(); const plainJson = await getPlainJson();
+125
View File
@@ -9,6 +9,7 @@ configureZipJs({ useWebWorkers: false });
export const EXPORT_FORMATS = [ export const EXPORT_FORMATS = [
{ id: 'bitwarden_json', label: 'Bitwarden (vault as json)' }, { id: 'bitwarden_json', label: 'Bitwarden (vault as json)' },
{ id: 'bitwarden_csv', label: 'Bitwarden (vault as csv)' },
{ id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' }, { id: 'bitwarden_encrypted_json', label: 'Bitwarden (encrypted vault as json)' },
{ id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' }, { id: 'bitwarden_json_zip', label: 'Bitwarden (vault + attachments as zip)' },
{ id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' }, { id: 'bitwarden_encrypted_json_zip', label: 'Bitwarden (encrypted vault + attachments as zip)' },
@@ -70,6 +71,27 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'; return !!value && typeof value === 'object';
} }
function csvText(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function escapeCsvCell(value: unknown): string {
const text = csvText(value);
if (!/[",\r\n]/.test(text)) return text;
return `"${text.replace(/"/g, '""')}"`;
}
function buildCsvString(rows: string[][]): string {
return `${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n')}\r\n`;
}
function isCipherString(value: string): boolean { function isCipherString(value: string): boolean {
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
} }
@@ -383,6 +405,107 @@ export async function buildPlainBitwardenJsonString(args: BuildPlainJsonArgs): P
return JSON.stringify(doc, null, 2); return JSON.stringify(doc, null, 2);
} }
const BITWARDEN_CSV_HEADERS = [
'folder',
'favorite',
'type',
'name',
'notes',
'fields',
'reprompt',
'login_uri',
'login_username',
'login_password',
'login_totp',
] as const;
function bitwardenCsvType(type: number): 'login' | 'note' {
return type === 1 ? 'login' : 'note';
}
function sourceTypeLabel(type: number): string {
if (type === 3) return 'card';
if (type === 4) return 'identity';
if (type === 5) return 'sshKey';
if (type === 2) return 'note';
return `type ${type}`;
}
function appendFieldLine(lines: string[], name: unknown, value: unknown): void {
const key = csvText(name).trim();
const text = csvText(value);
if (!key || !text) return;
lines.push(`${key}: ${text}`);
}
function appendRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
if (!isRecord(value)) return;
for (const [key, fieldValue] of Object.entries(value)) {
appendFieldLine(lines, `${prefix}.${key}`, fieldValue);
}
}
function buildBitwardenCsvFields(item: Record<string, unknown>, type: number): string {
const lines: string[] = [];
const fields = Array.isArray(item.fields) ? item.fields : [];
for (const field of fields) {
if (!isRecord(field)) continue;
appendFieldLine(lines, field.name, field.value);
}
if (type !== 1 && type !== 2) {
appendFieldLine(lines, 'nodewardenType', sourceTypeLabel(type));
appendRecordFieldLines(lines, sourceTypeLabel(type), item[sourceTypeLabel(type)]);
}
return lines.join('\n');
}
function buildFolderNameById(foldersRaw: unknown): Map<string, string> {
const out = new Map<string, string>();
const folders = Array.isArray(foldersRaw) ? foldersRaw : [];
for (const folder of folders) {
if (!isRecord(folder)) continue;
const id = csvText(folder.id).trim();
if (!id) continue;
out.set(id, csvText(folder.name));
}
return out;
}
function buildBitwardenCsvLoginUri(login: Record<string, unknown> | null): string {
const uris = Array.isArray(login?.uris) ? login.uris : [];
return uris
.map((uri) => (isRecord(uri) ? csvText(uri.uri).trim() : ''))
.filter(Boolean)
.join('\n');
}
export function buildBitwardenCsvString(bitwardenJsonDoc: Record<string, unknown>): string {
const folderNameById = buildFolderNameById(bitwardenJsonDoc.folders);
const rows: string[][] = [[...BITWARDEN_CSV_HEADERS]];
const items = Array.isArray(bitwardenJsonDoc.items) ? bitwardenJsonDoc.items : [];
for (const itemRaw of items) {
if (!isRecord(itemRaw)) continue;
const type = normalizeNumber(itemRaw.type, 1);
const isLogin = type === 1;
const login = isRecord(itemRaw.login) ? itemRaw.login : null;
const folderId = csvText(itemRaw.folderId).trim();
rows.push([
folderNameById.get(folderId) || '',
itemRaw.favorite ? '1' : '0',
bitwardenCsvType(type),
csvText(itemRaw.name) || '--',
csvText(itemRaw.notes),
buildBitwardenCsvFields(itemRaw, type),
String(normalizeNumber(itemRaw.reprompt, 0)),
isLogin ? buildBitwardenCsvLoginUri(login) : '',
isLogin ? csvText(login?.username) : '',
isLogin ? csvText(login?.password) : '',
isLogin ? csvText(login?.totp) : '',
]);
}
return `\uFEFF${buildCsvString(rows)}`;
}
export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> { export async function buildAccountEncryptedBitwardenJsonString(args: BuildEncryptedJsonArgs): Promise<string> {
const userEnc = base64ToBytes(args.userEncB64); const userEnc = base64ToBytes(args.userEncB64);
const userMac = base64ToBytes(args.userMacB64); const userMac = base64ToBytes(args.userMacB64);
@@ -566,11 +689,13 @@ function nowStamp(now = new Date()): string {
export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string { export function buildExportFileName(format: ExportFormatId, zipEncrypted = false): string {
const stamp = nowStamp(); const stamp = nowStamp();
if ( if (
format === 'bitwarden_csv' ||
format === 'bitwarden_json' || format === 'bitwarden_json' ||
format === 'bitwarden_encrypted_json' || format === 'bitwarden_encrypted_json' ||
format === 'nodewarden_json' || format === 'nodewarden_json' ||
format === 'nodewarden_encrypted_json' format === 'nodewarden_encrypted_json'
) { ) {
if (format === 'bitwarden_csv') return `bitwarden_export_${stamp}.csv`;
if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`; if (format.startsWith('nodewarden_')) return `nodewarden_export_${stamp}.json`;
return `bitwarden_export_${stamp}.json`; return `bitwarden_export_${stamp}.json`;
} }