Skip to content

Commit 105dc40

Browse files
committed
feat(media): 并发刮削(默认 5)+ 单条落库
增强 TMDB 搜索:模糊匹配 + 多候选关键词 管理页面新增导入/导出媒体库(全部 / 按扫描路径) 缩略图路径默认值改为 imgs
1 parent 0a0d900 commit 105dc40

3 files changed

Lines changed: 198 additions & 10 deletions

File tree

src/pages/manage/media/MediaManage.tsx

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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")}

src/pages/media/MediaSettings.tsx

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,14 @@ const MediaSettings = () => {
3838
const [imageThumbnailMode, setImageThumbnailMode] = createSignal<
3939
"base64" | "local"
4040
>("base64")
41-
const [imageThumbnailPath, setImageThumbnailPath] =
42-
createSignal("/.thumbnail")
41+
const [imageThumbnailPath, setImageThumbnailPath] = createSignal("/imgs")
4342
const [imageStoreThumbnail, setImageStoreThumbnail] = createSignal(false)
4443

4544
// 图书配置
4645
const [bookThumbnailMode, setBookThumbnailMode] = createSignal<
4746
"base64" | "local"
4847
>("base64")
49-
const [bookThumbnailPath, setBookThumbnailPath] = createSignal("/.thumbnail")
48+
const [bookThumbnailPath, setBookThumbnailPath] = createSignal("/imgs")
5049

5150
const [saving, setSaving] = createSignal(false)
5251
const [saveMsg, setSaveMsg] = createSignal("")
@@ -72,7 +71,7 @@ const MediaSettings = () => {
7271
setImageThumbnailMode(
7372
(sc.thumbnail_mode as "base64" | "local") || "base64",
7473
)
75-
setImageThumbnailPath(sc.thumbnail_path || "/.thumbnail")
74+
setImageThumbnailPath(sc.thumbnail_path || "/imgs")
7675
} catch {}
7776
}
7877
if (bookConfig) {
@@ -81,7 +80,7 @@ const MediaSettings = () => {
8180
setBookThumbnailMode(
8281
(sc.thumbnail_mode as "base64" | "local") || "base64",
8382
)
84-
setBookThumbnailPath(sc.thumbnail_path || "/.thumbnail")
83+
setBookThumbnailPath(sc.thumbnail_path || "/imgs")
8584
} catch {}
8685
}
8786
})
@@ -383,7 +382,7 @@ const MediaSettings = () => {
383382
onInput={(e) =>
384383
setImageThumbnailPath(e.currentTarget.value)
385384
}
386-
placeholder="/.thumbnail"
385+
placeholder="/imgs"
387386
style={{
388387
background: inputBg(),
389388
border: `1px solid ${inputBorder()}`,
@@ -414,7 +413,7 @@ const MediaSettings = () => {
414413
"border-radius": "3px",
415414
}}
416415
>
417-
/.thumbnail
416+
/imgs
418417
</code>
419418
</p>
420419
</div>
@@ -514,7 +513,7 @@ const MediaSettings = () => {
514513
type="text"
515514
value={bookThumbnailPath()}
516515
onInput={(e) => setBookThumbnailPath(e.currentTarget.value)}
517-
placeholder="/.thumbnail"
516+
placeholder="/imgs"
518517
style={{
519518
background: inputBg(),
520519
border: `1px solid ${inputBorder()}`,
@@ -545,7 +544,7 @@ const MediaSettings = () => {
545544
"border-radius": "3px",
546545
}}
547546
>
548-
/.thumbnail
547+
/imgs
549548
</code>
550549
</p>
551550
</div>
@@ -634,7 +633,7 @@ const MediaSettings = () => {
634633
</li>
635634
<li>
636635
本地存储路径支持 VFS 路径(如{" "}
637-
<code style={{ "font-family": "monospace" }}>/.thumbnail</code>
636+
<code style={{ "font-family": "monospace" }}>/imgs</code>
638637
),需确保对应存储有写入权限
639638
</li>
640639
</ul>

src/utils/media_api.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,44 @@ export const adminClearMediaDB = (mediaType: MediaType) =>
141141
r.post("/admin/media/clear", null, {
142142
params: { media_type: mediaType },
143143
}) as Promise<Resp<null>>
144+
145+
/** 导出媒体数据
146+
* - mediaType 为空 + scanPathId 为空 => 全部导出
147+
* - 仅 mediaType => 导出指定类型
148+
* - 仅 scanPathId => 导出单个扫描路径
149+
* 返回 Blob,供前端触发浏览器下载
150+
*/
151+
export const adminExportMediaDB = (
152+
mediaType?: MediaType,
153+
scanPathId?: number,
154+
) => {
155+
const params: Record<string, any> = {}
156+
if (mediaType) params.media_type = mediaType
157+
if (scanPathId) params.scan_path_id = scanPathId
158+
return r.get("/admin/media/export", {
159+
params,
160+
responseType: "blob",
161+
}) as unknown as Promise<Blob>
162+
}
163+
164+
/** 导入媒体数据(multipart/form-data)
165+
* scanPathId 可选:传入则把导入条目的 scan_path_id 全部覆盖为该值
166+
* (用于"导入到指定扫描路径"场景)
167+
*/
168+
export const adminImportMediaDB = (file: File, scanPathId?: number) => {
169+
const form = new FormData()
170+
form.append("file", file)
171+
const params: Record<string, any> = {}
172+
if (scanPathId) params.scan_path_id = scanPathId
173+
return r.post("/admin/media/import", form, {
174+
params,
175+
headers: { "Content-Type": "multipart/form-data" },
176+
}) as Promise<
177+
Resp<{
178+
scan_paths_created: number
179+
scan_paths_updated: number
180+
items_created: number
181+
items_updated: number
182+
}>
183+
>
184+
}

0 commit comments

Comments
 (0)