1- import { useEffect , useState } from 'react' ;
1+ import { useEffect , useState , useRef } from 'react' ;
22import { useTranslation } from 'react-i18next' ;
3- import { Copy , Plus , Trash2 , Download , Upload , Check , Database } from 'lucide-react' ;
3+ import { Copy , Plus , Trash2 , Download , Upload , Check , Database , Image as ImageIcon , Edit2 , Save , X } from 'lucide-react' ;
44import { save , open , confirm } from '@tauri-apps/plugin-dialog' ;
55import { writeTextFile , readTextFile } from '@tauri-apps/plugin-fs' ;
66import * as OTPAuth from 'otpauth' ;
7+ import jsQR from 'jsqr' ;
8+
9+ async function decodeQR ( file : File | Blob ) : Promise < string | null > {
10+ return new Promise ( ( resolve ) => {
11+ const url = URL . createObjectURL ( file ) ;
12+ const img = new Image ( ) ;
13+ img . onload = ( ) => {
14+ URL . revokeObjectURL ( url ) ;
15+ const canvas = document . createElement ( 'canvas' ) ;
16+ canvas . width = img . width ;
17+ canvas . height = img . height ;
18+ const ctx = canvas . getContext ( '2d' , { willReadFrequently : true } ) ;
19+ if ( ! ctx ) return resolve ( null ) ;
20+ ctx . drawImage ( img , 0 , 0 ) ;
21+ const imgData = ctx . getImageData ( 0 , 0 , canvas . width , canvas . height ) ;
22+ const code = jsQR ( imgData . data , imgData . width , imgData . height ) ;
23+ resolve ( code ? code . data : null ) ;
24+ } ;
25+ img . onerror = ( ) => resolve ( null ) ;
26+ img . src = url ;
27+ } ) ;
28+ }
729
830export interface MfaRecord {
931 id : string ; // Internal use
@@ -105,10 +127,12 @@ export function MfaVaultManager() {
105127 return [ ] ;
106128 } ) ;
107129
108- const [ inputAccount , setInputAccount ] = useState ( '' ) ;
109130 const [ inputSecret , setInputSecret ] = useState ( '' ) ;
110131 const [ inputRemark , setInputRemark ] = useState ( '' ) ;
111132 const [ copiedId , setCopiedId ] = useState < string | null > ( null ) ;
133+ const [ editingId , setEditingId ] = useState < string | null > ( null ) ;
134+ const [ editValue , setEditValue ] = useState ( '' ) ;
135+ const fileInputRef = useRef < HTMLInputElement > ( null ) ;
112136 const [ timeRemaining , setTimeRemaining ] = useState ( ( ) => {
113137 const now = Math . floor ( Date . now ( ) / 1000 ) ;
114138 return 30 - ( now % 30 ) ;
@@ -128,24 +152,95 @@ export function MfaVaultManager() {
128152 } , [ ] ) ;
129153
130154 const handleAdd = ( ) => {
131- const act = inputAccount . trim ( ) ;
132- const sec = inputSecret . trim ( ) ;
133- if ( ! act || ! sec ) return ;
155+ const raw = inputSecret . trim ( ) ;
156+ if ( ! raw ) return ;
157+
158+ let accountName = t ( 'mfaVault.defaultAccountName' , '未命名账户' ) ;
159+ let extractedSecret = raw ;
160+
161+ try {
162+ const parsed = OTPAuth . URI . parse ( raw ) ;
163+ if ( parsed instanceof OTPAuth . TOTP ) {
164+ const parts = [ parsed . issuer , parsed . label ] . filter ( p => ! ! p ) ;
165+ if ( parts . length > 0 ) {
166+ accountName = parts . join ( ': ' ) ;
167+ }
168+ extractedSecret = parsed . secret . base32 ;
169+ }
170+ } catch {
171+ extractedSecret = raw ;
172+ }
134173
135174 const newRecord : MfaRecord = {
136175 id : createUniqueId ( ) ,
137- accountName : act ,
138- secret : sec ,
176+ accountName,
177+ secret : extractedSecret ,
139178 remark : inputRemark . trim ( ) ,
140179 time : Date . now ( )
141180 } ;
142181
143182 setRecords ( prev => [ newRecord , ...prev ] ) ;
144- setInputAccount ( '' ) ;
145183 setInputSecret ( '' ) ;
146184 setInputRemark ( '' ) ;
147185 } ;
148186
187+ const handleImageUpload = ( e : React . ChangeEvent < HTMLInputElement > ) => {
188+ const file = e . target . files ?. [ 0 ] ;
189+ if ( ! file ) return ;
190+ decodeQR ( file ) . then ( decoded => {
191+ if ( decoded ) {
192+ setInputSecret ( decoded ) ;
193+ } else {
194+ alert ( t ( 'mfaVault.qrDetectFailed' , '未能从图片中识别出二维码,请确保图片清晰' ) ) ;
195+ }
196+ } ) ;
197+ e . target . value = '' ;
198+ } ;
199+
200+ const handleDragOver = ( e : React . DragEvent ) => {
201+ e . preventDefault ( ) ;
202+ } ;
203+
204+ const handleDrop = ( e : React . DragEvent ) => {
205+ e . preventDefault ( ) ;
206+ const file = e . dataTransfer . files ?. [ 0 ] ;
207+ if ( file && file . type . startsWith ( 'image/' ) ) {
208+ decodeQR ( file ) . then ( decoded => {
209+ if ( decoded ) setInputSecret ( decoded ) ;
210+ } ) ;
211+ }
212+ } ;
213+
214+ const handlePaste = async ( e : React . ClipboardEvent ) => {
215+ const items = e . clipboardData ?. items ;
216+ if ( ! items ) return ;
217+ for ( const item of items ) {
218+ if ( item . type . indexOf ( 'image/' ) !== - 1 ) {
219+ const file = item . getAsFile ( ) ;
220+ if ( file ) {
221+ e . preventDefault ( ) ;
222+ const decoded = await decodeQR ( file ) ;
223+ if ( decoded ) {
224+ setInputSecret ( decoded ) ;
225+ } else {
226+ alert ( t ( 'mfaVault.qrDetectFailed' , '未能从图片中识别出二维码,请确保图片清晰' ) ) ;
227+ }
228+ }
229+ }
230+ }
231+ } ;
232+
233+ const startEdit = ( id : string , currentName : string ) => {
234+ setEditingId ( id ) ;
235+ setEditValue ( currentName ) ;
236+ } ;
237+
238+ const saveEdit = ( id : string ) => {
239+ const trimmed = editValue . trim ( ) || t ( 'mfaVault.defaultAccountName' , '未命名账户' ) ;
240+ setRecords ( prev => prev . map ( r => r . id === id ? { ...r , accountName : trimmed } : r ) ) ;
241+ setEditingId ( null ) ;
242+ } ;
243+
149244 const handleDelete = async ( id : string , accountName : string ) => {
150245 try {
151246 const msg = t ( 'mfaVault.confirmDeleteMsg' , '确定要永久删除 [{{accountName}}] 的凭证记录吗?' ) . replace ( '{{accountName}}' , accountName ) ;
@@ -288,21 +383,18 @@ export function MfaVaultManager() {
288383 </ h3 >
289384
290385 < div className = "query-main" style = { { display : 'flex' , flexDirection : 'column' , gap : '12px' } } >
291- < div style = { { display : 'flex' , gap : '12px' } } >
292- < div className = "form-group" style = { { marginBottom : 0 , flex : 1 } } >
293- < input
294- type = "text"
295- placeholder = { t ( 'mfaVault.inputAccountPlaceholder' , '账号 (Account Name) *' ) }
296- value = { inputAccount }
297- onChange = { e => setInputAccount ( e . target . value ) }
298- />
299- </ div >
386+ < div
387+ style = { { display : 'flex' , gap : '12px' , alignItems : 'center' } }
388+ onDragOver = { handleDragOver }
389+ onDrop = { handleDrop }
390+ >
300391 < div className = "form-group" style = { { marginBottom : 0 , flex : 2 } } >
301392 < input
302393 type = "text"
303- placeholder = { t ( 'mfaVault.inputSecretPlaceholder ' , '密钥 / 恢复码 (Secret) * ' ) }
394+ placeholder = { t ( 'mfaVault.inputSinglePlaceholder ' , '在此粘贴 MFA 秘钥 / otpauth:// 地址 (支持直接粘贴包含二维码的截图) ' ) }
304395 value = { inputSecret }
305396 onChange = { e => setInputSecret ( e . target . value ) }
397+ onPaste = { handlePaste }
306398 style = { { fontFamily : 'var(--font-mono)' } }
307399 />
308400 </ div >
@@ -314,10 +406,28 @@ export function MfaVaultManager() {
314406 onChange = { e => setInputRemark ( e . target . value ) }
315407 />
316408 </ div >
409+
410+ < input
411+ type = "file"
412+ accept = "image/*"
413+ style = { { display : 'none' } }
414+ ref = { fileInputRef }
415+ onChange = { handleImageUpload }
416+ />
417+
418+ < button
419+ className = "btn btn-secondary"
420+ title = { t ( 'mfaVault.selectImage' , '选择图片识别二维码' ) }
421+ onClick = { ( ) => fileInputRef . current ?. click ( ) }
422+ style = { { flexShrink : 0 , padding : '4px 10px' } }
423+ >
424+ < ImageIcon size = { 16 } />
425+ </ button >
426+
317427 < button
318428 className = "btn btn-primary"
319429 onClick = { handleAdd }
320- disabled = { ! inputAccount . trim ( ) || ! inputSecret . trim ( ) }
430+ disabled = { ! inputSecret . trim ( ) }
321431 style = { { flexShrink : 0 } }
322432 >
323433 < Plus size = { 14 } /> { t ( 'mfaVault.add' , '添加' ) }
@@ -368,8 +478,33 @@ export function MfaVaultManager() {
368478 const isWarning = timeRemaining <= 5 ;
369479 return (
370480 < tr key = { record . id } >
371- < td title = { record . accountName } style = { { fontWeight : 500 } } >
372- { record . accountName }
481+ < td style = { { fontWeight : 500 } } >
482+ { editingId === record . id ? (
483+ < div style = { { display : 'flex' , alignItems : 'center' , gap : '6px' } } >
484+ < input
485+ type = "text"
486+ value = { editValue }
487+ onChange = { e => setEditValue ( e . target . value ) }
488+ onKeyDown = { e => {
489+ if ( e . key === 'Enter' ) saveEdit ( record . id ) ;
490+ if ( e . key === 'Escape' ) setEditingId ( null ) ;
491+ } }
492+ autoFocus
493+ style = { { padding : '2px 6px' , fontSize : '13px' , width : '100%' , minWidth : '120px' } }
494+ />
495+ < button className = "action-btn is-success" onClick = { ( ) => saveEdit ( record . id ) } > < Save size = { 14 } /> </ button >
496+ < button className = "action-btn" onClick = { ( ) => setEditingId ( null ) } > < X size = { 14 } /> </ button >
497+ </ div >
498+ ) : (
499+ < div
500+ style = { { display : 'inline-flex' , alignItems : 'center' , gap : '6px' , cursor : 'text' } }
501+ onClick = { ( ) => startEdit ( record . id , record . accountName ) }
502+ title = { t ( 'mfaVault.clickToEdit' , '点击修改账号名' ) }
503+ >
504+ < span style = { { maxWidth : '200px' , overflow : 'hidden' , textOverflow : 'ellipsis' , whiteSpace : 'nowrap' , display : 'inline-block' , verticalAlign : 'middle' } } > { record . accountName } </ span >
505+ < Edit2 size = { 12 } style = { { color : 'var(--text-tertiary)' } } />
506+ </ div >
507+ ) }
373508 </ td >
374509 < td >
375510 < div style = { { display : 'flex' , alignItems : 'center' , gap : '8px' } } >
0 commit comments