feat: add duplicate detection modes and UI enhancements for managing duplicates

This commit is contained in:
shuaiplus
2026-06-15 20:48:57 +08:00
parent a8183166ac
commit 7b3be2c819
11 changed files with 279 additions and 44 deletions
+72 -31
View File
@@ -8,8 +8,10 @@ import { t } from '@/lib/i18n';
import {
CreateTypeIcon,
getCreateTypeOptions,
getDuplicateDetectionOptions,
getVaultSortOptions,
VaultListIcon,
type DuplicateDetectionMode,
type SidebarFilter,
type VaultSortMode,
} from '@/components/vault/vault-page-helpers';
@@ -28,10 +30,12 @@ interface VaultListPanelProps {
searchInput: string;
sortMode: VaultSortMode;
sortMenuOpen: boolean;
duplicateMode: DuplicateDetectionMode;
selectedCount: number;
totalCipherCount: number;
filteredCiphers: Cipher[];
visibleCiphers: Cipher[];
duplicateGroupIndexById: Map<string, number>;
virtualRange: VirtualRange;
selectedCipherId: string;
selectedMap: Record<string, boolean>;
@@ -48,6 +52,7 @@ interface VaultListPanelProps {
onSearchCompositionEnd: (value: string) => void;
onToggleSortMenu: () => void;
onSelectSortMode: (value: VaultSortMode) => void;
onDuplicateModeChange: (value: DuplicateDetectionMode) => void;
onSyncVault: () => void;
onOpenBulkDelete: () => void;
onSelectDuplicates: () => void;
@@ -69,15 +74,18 @@ interface CipherListItemProps {
cipher: Cipher;
selected: boolean;
checked: boolean;
duplicateGroupIndex: number | null;
subtitle: string;
onToggleSelected: (cipherId: string, checked: boolean) => void;
onSelectCipher: (cipherId: string) => void;
}
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360;
return (
<div
className={`list-item ${props.selected ? 'active' : ''}`}
className={`list-item ${props.selected ? 'active' : ''} ${duplicateGroupHue === null ? '' : 'duplicate-group-item'}`}
style={duplicateGroupHue === null ? undefined : { '--duplicate-group-hue': `${duplicateGroupHue}deg` }}
onClick={(event) => {
const target = event.target as HTMLElement;
if (target.closest('.row-check')) return;
@@ -108,6 +116,7 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
export default function VaultListPanel(props: VaultListPanelProps) {
const createTypeOptions = getCreateTypeOptions();
const duplicateDetectionOptions = getDuplicateDetectionOptions();
const vaultSortOptions = getVaultSortOptions();
const createMenu = (
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
@@ -137,29 +146,44 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<section className="list-col">
<div className="list-head">
<div className="search-input-wrap">
<input
className="search-input"
placeholder={t('txt_search_your_secure_vault')}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
<select
className="input duplicate-mode-select duplicate-mode-head-select"
value={props.duplicateMode}
aria-label={t('txt_duplicate_detection_mode')}
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
>
<X size={14} />
</button>
{duplicateDetectionOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
) : (
<>
<input
className="search-input"
placeholder={t('txt_search_your_secure_vault')}
value={props.searchInput}
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
onCompositionStart={props.onSearchCompositionStart}
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
onKeyDown={(e) => {
if (e.key !== 'Escape' || !props.searchInput) return;
e.preventDefault();
props.onClearSearch();
}}
/>
{!!props.searchInput && (
<button
type="button"
className="search-clear-btn"
aria-label={t('txt_clear_search')}
title={t('txt_clear_search_esc')}
onClick={props.onClearSearch}
>
<X size={14} />
</button>
)}
</>
)}
</div>
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
@@ -195,8 +219,20 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button>
</div>
<div className="toolbar actions">
{props.sidebarFilter.kind === 'duplicates' && (
<div className={`toolbar actions ${props.sidebarFilter.kind === 'duplicates' ? 'duplicates-toolbar' : ''}`}>
{props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
<select
className="input duplicate-mode-select duplicate-mode-toolbar-select"
value={props.duplicateMode}
aria-label={t('txt_duplicate_detection_mode')}
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
>
{duplicateDetectionOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{props.sidebarFilter.kind === 'duplicates' && props.duplicateMode === 'exact' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button>
@@ -229,12 +265,16 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button>
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
</button>
{props.isMobileLayout && typeof document !== 'undefined'
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
: createMenu}
{props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
</button>
)}
{props.sidebarFilter.kind !== 'duplicates' && (
props.isMobileLayout && typeof document !== 'undefined'
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
: createMenu
)}
</div>
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
@@ -255,6 +295,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
cipher={cipher}
selected={props.selectedCipherId === cipher.id}
checked={!!props.selectedMap[cipher.id]}
duplicateGroupIndex={props.sidebarFilter.kind === 'duplicates' ? props.duplicateGroupIndexById.get(cipher.id) ?? null : null}
subtitle={props.listSubtitle(cipher)}
onToggleSelected={props.onToggleSelected}
onSelectCipher={props.onSelectCipher}
@@ -10,10 +10,13 @@ import {
import { copyTextToClipboard } from '@/lib/clipboard';
import { t } from '@/lib/i18n';
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
import WebsiteIcon from './WebsiteIcon';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
export type VaultSortMode = 'edited' | 'created' | 'name';
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
export type SidebarFilter =
| { kind: 'all' }
| { kind: 'favorite' }
@@ -126,6 +129,16 @@ export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
export const VAULT_LIST_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10;
export function getDuplicateDetectionOptions(): Array<{ value: DuplicateDetectionMode; label: string }> {
return [
{ value: 'exact', label: t('txt_duplicate_mode_exact') },
{ value: 'login-site', label: t('txt_duplicate_mode_login_site') },
{ value: 'login-credentials', label: t('txt_duplicate_mode_login_credentials') },
{ value: 'password', label: t('txt_duplicate_mode_password') },
];
}
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
return [
{ value: 'edited', label: t('txt_sort_last_edited') },
@@ -242,7 +255,7 @@ export function toBooleanFieldValue(raw: string): boolean {
return v === '1' || v === 'true' || v === 'yes' || v === 'on';
}
export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
export { firstCipherUri, hostFromUri, websiteIconUrl };
export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null, originalUri: '', extra: {} };
@@ -257,6 +270,30 @@ function valueOrFallback(value: string | null | undefined): string {
return String(value || '');
}
function duplicateLoginUsername(cipher: Cipher): string {
return valueOrFallback(cipher.login?.decUsername ?? cipher.login?.username).trim().toLowerCase();
}
function duplicateLoginPassword(cipher: Cipher): string {
return valueOrFallback(cipher.login?.decPassword ?? cipher.login?.password);
}
function duplicateLoginSites(cipher: Cipher): string[] {
const sites = new Set<string>();
for (const uri of cipher.login?.uris || []) {
const raw = valueOrFallback(uri.decUri ?? uri.uri).trim();
if (!raw) continue;
const host = hostFromUri(raw).trim().toLowerCase().replace(/^www\./, '');
const site = normalizeEquivalentDomain(raw) || host;
if (site) sites.add(site);
}
return Array.from(sites).sort();
}
function duplicateSignature(parts: string[]): string {
return JSON.stringify(parts);
}
export function buildCipherDuplicateSignature(cipher: Cipher): string {
const normalized = {
type: Number(cipher.type || 1),
@@ -333,6 +370,23 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
return JSON.stringify(normalized);
}
export function buildCipherDuplicateSignatures(cipher: Cipher, mode: DuplicateDetectionMode): string[] {
if (mode === 'exact') return [buildCipherDuplicateSignature(cipher)];
if (Number(cipher.type || 1) !== 1 || !cipher.login) return [];
const username = duplicateLoginUsername(cipher);
const password = duplicateLoginPassword(cipher);
if (mode === 'password') {
return password ? [duplicateSignature(['password', password])] : [];
}
if (!username || !password) return [];
if (mode === 'login-credentials') {
return [duplicateSignature(['login-credentials', username, password])];
}
return duplicateLoginSites(cipher).map((site) => duplicateSignature(['login-site', site, username, password]));
}
export function createEmptyDraft(type: number): VaultDraft {
return {
type,