@@ -15,6 +15,8 @@ import {
1515 adminStartMediaScan ,
1616 adminStartMediaScrape ,
1717 adminClearMediaDB ,
18+ adminExportMediaDB ,
19+ adminImportMediaDB ,
1820 adminGetMediaScanProgress ,
1921 adminListMediaScanPaths ,
2022 adminCreateMediaScanPath ,
@@ -497,6 +499,134 @@ export const MediaManagePage = (props: MediaManagePageProps) => {
497499 } )
498500 }
499501
502+ // ============== 导入 / 导出 ==============
503+
504+ // 触发浏览器下载 Blob
505+ const triggerDownload = ( blob : Blob , filename : string ) => {
506+ const url = URL . createObjectURL ( blob )
507+ const a = document . createElement ( "a" )
508+ a . href = url
509+ a . download = filename
510+ document . body . appendChild ( a )
511+ a . click ( )
512+ document . body . removeChild ( a )
513+ URL . revokeObjectURL ( url )
514+ }
515+
516+ // 校验导出返回是否为合法的 JSON Blob(出错时后端返回 JSON 错误体)
517+ // 若不是 JSON 文件则提示用户
518+ const handleBlobExport = async ( blob : Blob , filename : string ) => {
519+ if ( ! blob || ! ( blob instanceof Blob ) ) {
520+ showToast ( "导出失败:未收到文件流" , "error" )
521+ return
522+ }
523+ // 如果误返回了错误 JSON(很短)则尝试解析
524+ if ( blob . size < 1024 ) {
525+ try {
526+ const txt = await blob . text ( )
527+ const obj = JSON . parse ( txt )
528+ if ( obj && typeof obj . code === "number" && obj . code !== 200 ) {
529+ showToast ( "导出失败: " + ( obj . message || "未知错误" ) , "error" )
530+ return
531+ }
532+ } catch {
533+ // 不是 JSON 错误体,按文件下载继续
534+ }
535+ }
536+ triggerDownload ( blob , filename )
537+ showToast ( "导出成功" )
538+ }
539+
540+ // 导出当前媒体类型的全部数据
541+ const handleExportAll = async ( ) => {
542+ try {
543+ const blob = await adminExportMediaDB ( props . mediaType )
544+ const ts = new Date ( ) . toISOString ( ) . replace ( / [: .] / g, "-" ) . slice ( 0 , 19 )
545+ handleBlobExport ( blob , `media_export_${ props . mediaType } _${ ts } .json` )
546+ } catch ( e : any ) {
547+ showToast ( "导出失败: " + ( e ?. message || e ) , "error" )
548+ }
549+ }
550+
551+ // 导出单个扫描路径
552+ const handleExportScanPath = async ( sp : MediaScanPath ) => {
553+ try {
554+ const blob = await adminExportMediaDB ( undefined , sp . id ! )
555+ const safeName = ( sp . name || sp . path || `path_${ sp . id } ` ) . replace (
556+ / [ \\ / : * ? " < > | ] / g,
557+ "_" ,
558+ )
559+ const ts = new Date ( ) . toISOString ( ) . replace ( / [: .] / g, "-" ) . slice ( 0 , 19 )
560+ handleBlobExport (
561+ blob ,
562+ `media_export_${ props . mediaType } _${ safeName } _${ ts } .json` ,
563+ )
564+ } catch ( e : any ) {
565+ showToast ( "导出失败: " + ( e ?. message || e ) , "error" )
566+ }
567+ }
568+
569+ // 通用:用 input[type=file] 选择文件后回调
570+ const pickJsonFile = ( ) : Promise < File | null > => {
571+ return new Promise ( ( resolve ) => {
572+ const input = document . createElement ( "input" )
573+ input . type = "file"
574+ input . accept = "application/json,.json"
575+ input . onchange = ( ) => {
576+ const f = input . files && input . files [ 0 ] ? input . files [ 0 ] : null
577+ resolve ( f )
578+ }
579+ input . click ( )
580+ } )
581+ }
582+
583+ // 导入到当前媒体库(按导出文件中原扫描路径关系还原)
584+ const handleImportAll = async ( ) => {
585+ const file = await pickJsonFile ( )
586+ if ( ! file ) return
587+ showConfirm ( {
588+ title : `导入 ${ props . title } 数据` ,
589+ message : `即将导入文件「${ file . name } 」,已存在的条目会按唯一键被覆盖更新。是否继续?` ,
590+ confirmText : "导入" ,
591+ type : "warning" ,
592+ onConfirm : async ( ) => {
593+ const resp = await adminImportMediaDB ( file )
594+ if ( resp . code === 200 ) {
595+ const d = resp . data
596+ showToast (
597+ `导入完成:路径 +${ d . scan_paths_created } /~${ d . scan_paths_updated } ,条目 +${ d . items_created } /~${ d . items_updated } ` ,
598+ )
599+ await loadScanPaths ( )
600+ refetchItems ( )
601+ } else {
602+ showToast ( "导入失败: " + resp . message , "error" )
603+ }
604+ } ,
605+ } )
606+ }
607+
608+ // 导入到指定扫描路径(覆盖文件中条目的 scan_path_id)
609+ const handleImportScanPath = async ( sp : MediaScanPath ) => {
610+ const file = await pickJsonFile ( )
611+ if ( ! file ) return
612+ showConfirm ( {
613+ title : `导入到「${ sp . name || sp . path } 」` ,
614+ message : `即将把文件「${ file . name } 」中的条目全部归入此扫描路径,是否继续?` ,
615+ confirmText : "导入" ,
616+ type : "warning" ,
617+ onConfirm : async ( ) => {
618+ const resp = await adminImportMediaDB ( file , sp . id ! )
619+ if ( resp . code === 200 ) {
620+ const d = resp . data
621+ showToast ( `导入完成:条目 +${ d . items_created } /~${ d . items_updated } ` )
622+ refetchItems ( )
623+ } else {
624+ showToast ( "导入失败: " + resp . message , "error" )
625+ }
626+ } ,
627+ } )
628+ }
629+
500630 // 保存编辑
501631 const handleSaveItem = async ( ) => {
502632 if ( ! editingItem ( ) ) return
@@ -670,6 +800,12 @@ export const MediaManagePage = (props: MediaManagePageProps) => {
670800 >
671801 { scraping ( ) ? "刮削中..." : "✨ 立即刮削" }
672802 </ button >
803+ < button onClick = { handleExportAll } style = { btnStyle ( "#0ea5e9" ) } >
804+ ⬇️ 导出全部
805+ </ button >
806+ < button onClick = { handleImportAll } style = { btnStyle ( "#8b5cf6" ) } >
807+ ⬆️ 导入数据
808+ </ button >
673809 < button onClick = { handleClearAll } style = { btnStyle ( "#ef4444" ) } >
674810 🗑️ 清空全部
675811 </ button >
@@ -880,6 +1016,18 @@ export const MediaManagePage = (props: MediaManagePageProps) => {
8801016 >
8811017 编辑
8821018 </ button >
1019+ < button
1020+ onClick = { ( ) => handleExportScanPath ( sp ) }
1021+ style = { actionBtnStyle ( "#ecfeff" , "#0ea5e9" ) }
1022+ >
1023+ 导出
1024+ </ button >
1025+ < button
1026+ onClick = { ( ) => handleImportScanPath ( sp ) }
1027+ style = { actionBtnStyle ( "#f5f3ff" , "#8b5cf6" ) }
1028+ >
1029+ 导入
1030+ </ button >
8831031 < button
8841032 onClick = { ( ) => handleClearScanPathDB ( sp ) }
8851033 style = { actionBtnStyle ( "#fffbeb" , "#f59e0b" ) }
0 commit comments