mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-08 09:16:58 +08:00
feat: support manual media selection for file scraping
This commit is contained in:
@@ -1,5 +1,15 @@
|
|||||||
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist'
|
export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist'
|
||||||
|
|
||||||
|
// 手动刮削选项
|
||||||
|
export interface ManualScrapeOptions {
|
||||||
|
// 媒体数据源
|
||||||
|
media_source: MediaDataSource
|
||||||
|
// 数据源原生ID
|
||||||
|
media_id?: string
|
||||||
|
// 媒体类型
|
||||||
|
type_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
// 订阅
|
// 订阅
|
||||||
export interface Subscribe {
|
export interface Subscribe {
|
||||||
// 订阅ID
|
// 订阅ID
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { numberValidator } from '@/@validators'
|
||||||
|
import type { FileItem, ManualScrapeOptions, MediaDataSource, MediaInfo } from '@/api/types'
|
||||||
|
import { useGlobalSettingsStore } from '@/stores'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import MediaIdSelector from '../misc/MediaIdSelector.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: {
|
||||||
|
type: Array as PropType<FileItem[]>,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'close'): void
|
||||||
|
(event: 'scrape', options: ManualScrapeOptions): void
|
||||||
|
(event: 'update:modelValue', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mediaSourceItems: { title: string; value: MediaDataSource }[] = [
|
||||||
|
{ title: 'TheMovieDb', value: 'themoviedb' },
|
||||||
|
{ title: '豆瓣', value: 'douban' },
|
||||||
|
{ title: 'Bangumi', value: 'bangumi' },
|
||||||
|
{ title: 'AniList', value: 'anilist' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const globalSettingsStore = useGlobalSettingsStore()
|
||||||
|
const mediaType = ref('')
|
||||||
|
const mediaSource = ref<MediaDataSource>(getDefaultMediaSource())
|
||||||
|
const mediaId = ref<string | null>(null)
|
||||||
|
const mediaSelectorDialog = ref(false)
|
||||||
|
|
||||||
|
const dialogVisible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: value => emit('update:modelValue', value),
|
||||||
|
})
|
||||||
|
|
||||||
|
const dialogSubtitle = computed(() => {
|
||||||
|
if (props.items.length > 1) {
|
||||||
|
return t('dialog.reorganize.multipleItemsTitle', { count: props.items.length })
|
||||||
|
}
|
||||||
|
return t('dialog.reorganize.singleItemTitle', { path: props.items[0]?.path ?? '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
const mediaIdLabel = computed(() => {
|
||||||
|
const labels: Record<MediaDataSource, string> = {
|
||||||
|
themoviedb: t('dialog.reorganize.tmdbId'),
|
||||||
|
douban: t('dialog.reorganize.doubanId'),
|
||||||
|
bangumi: t('dialog.reorganize.bangumiId'),
|
||||||
|
anilist: t('dialog.reorganize.anilistId'),
|
||||||
|
}
|
||||||
|
return labels[mediaSource.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
const normalizedMediaId = mediaId.value?.trim()
|
||||||
|
return !normalizedMediaId || /^\d+$/.test(normalizedMediaId)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取后台设置中的默认识别数据源,未知值兼容回退到 TheMovieDb。
|
||||||
|
function getDefaultMediaSource(): MediaDataSource {
|
||||||
|
const configuredSource = globalSettingsStore.globalSettings.RECOGNIZE_SOURCE as MediaDataSource
|
||||||
|
return mediaSourceItems.some(item => item.value === configuredSource) ? configuredSource : 'themoviedb'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将搜索结果媒体类型映射为手动刮削接口接受的类型名。
|
||||||
|
function resolveMediaType(type?: string) {
|
||||||
|
const normalizedType = type?.trim().toLowerCase()
|
||||||
|
if (['电影', 'movie'].includes(normalizedType ?? '')) return '电影'
|
||||||
|
if (['电视剧', 'tv', 'series'].includes(normalizedType ?? '')) return '电视剧'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择搜索结果后同步媒体类型,减少手动填写出错。
|
||||||
|
function handleMediaSelected(item: Pick<MediaInfo, 'type'>) {
|
||||||
|
mediaType.value = resolveMediaType(item.type) ?? mediaType.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭弹窗并通知共享弹窗 Host 回收当前实例。
|
||||||
|
function closeDialog() {
|
||||||
|
emit('close')
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交本次手动刮削的请求级识别条件。
|
||||||
|
function submitScrape() {
|
||||||
|
const normalizedMediaId = mediaId.value?.trim()
|
||||||
|
emit('scrape', {
|
||||||
|
media_source: mediaSource.value,
|
||||||
|
media_id: normalizedMediaId || undefined,
|
||||||
|
type_name: mediaType.value || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换数据源时清空上一来源的原生 ID,避免错用同一编号。
|
||||||
|
watch(mediaSource, () => {
|
||||||
|
mediaId.value = null
|
||||||
|
mediaSelectorDialog.value = false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VDialog v-model="dialogVisible" max-width="45rem" scrollable>
|
||||||
|
<VCard>
|
||||||
|
<VCardItem class="py-2">
|
||||||
|
<template #prepend>
|
||||||
|
<VIcon icon="mdi-auto-fix" class="me-2" />
|
||||||
|
</template>
|
||||||
|
<VCardTitle>{{ t('file.manualScrape') }}</VCardTitle>
|
||||||
|
<VCardSubtitle>{{ dialogSubtitle }}</VCardSubtitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VDialogCloseBtn @click="closeDialog" />
|
||||||
|
<VDivider />
|
||||||
|
<VCardText class="pt-6">
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12" md="4">
|
||||||
|
<VSelect
|
||||||
|
v-model="mediaType"
|
||||||
|
:label="t('dialog.reorganize.mediaType')"
|
||||||
|
:items="[
|
||||||
|
{ title: t('dialog.reorganize.auto'), value: '' },
|
||||||
|
{ title: t('dialog.reorganize.movie'), value: '电影' },
|
||||||
|
{ title: t('dialog.reorganize.tv'), value: '电视剧' },
|
||||||
|
]"
|
||||||
|
:hint="t('dialog.reorganize.mediaTypeHint')"
|
||||||
|
persistent-hint
|
||||||
|
prepend-inner-icon="mdi-movie-open"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="4">
|
||||||
|
<VSelect
|
||||||
|
v-model="mediaSource"
|
||||||
|
:items="mediaSourceItems"
|
||||||
|
:label="t('dialog.reorganize.mediaSource')"
|
||||||
|
:hint="t('dialog.reorganize.mediaSourceHint')"
|
||||||
|
persistent-hint
|
||||||
|
prepend-inner-icon="mdi-database-search"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="4">
|
||||||
|
<VTextField
|
||||||
|
v-model="mediaId"
|
||||||
|
:disabled="mediaType === ''"
|
||||||
|
:label="mediaIdLabel"
|
||||||
|
:placeholder="t('dialog.reorganize.mediaIdPlaceholder')"
|
||||||
|
:rules="[numberValidator]"
|
||||||
|
append-inner-icon="mdi-magnify"
|
||||||
|
:hint="t('dialog.reorganize.mediaIdHint')"
|
||||||
|
persistent-hint
|
||||||
|
prepend-inner-icon="mdi-identifier"
|
||||||
|
@click:append-inner="mediaSelectorDialog = true"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
<VCardActions class="app-dialog-actions">
|
||||||
|
<VSpacer />
|
||||||
|
<VBtn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
prepend-icon="mdi-auto-fix"
|
||||||
|
class="px-5"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="submitScrape"
|
||||||
|
>
|
||||||
|
{{ t('common.confirm') }}
|
||||||
|
</VBtn>
|
||||||
|
</VCardActions>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<VDialog v-model="mediaSelectorDialog" width="40rem" scrollable max-height="85vh">
|
||||||
|
<MediaIdSelector
|
||||||
|
v-model="mediaId"
|
||||||
|
:type="mediaSource"
|
||||||
|
@close="mediaSelectorDialog = false"
|
||||||
|
@select="handleMediaSelected"
|
||||||
|
/>
|
||||||
|
</VDialog>
|
||||||
|
</VDialog>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import ScrapeDialog from '@/components/dialog/ScrapeDialog.vue'
|
||||||
|
import type { FileItem, ManualScrapeOptions } from '@/api/types'
|
||||||
|
import DialogCloseBtn from '@/@core/components/DialogCloseBtn.vue'
|
||||||
|
import { screen } from '@testing-library/vue'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { renderWithProviders } from '@tests/support/render'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// 渲染手动刮削弹窗并收集业务事件。
|
||||||
|
async function renderDialog(recognizeSource = 'themoviedb', items?: FileItem[]) {
|
||||||
|
const events = {
|
||||||
|
close: vi.fn(),
|
||||||
|
scrape: vi.fn<(options: ManualScrapeOptions) => void>(),
|
||||||
|
}
|
||||||
|
const result = await renderWithProviders(ScrapeDialog, {
|
||||||
|
global: {
|
||||||
|
components: {
|
||||||
|
VDialogCloseBtn: DialogCloseBtn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
initialState: {
|
||||||
|
globalSettings: {
|
||||||
|
data: {
|
||||||
|
RECOGNIZE_SOURCE: recognizeSource,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
items: items ?? [{ name: 'Test Movie.mkv', path: '/media/Test Movie.mkv', storage: 'local', type: 'file' }],
|
||||||
|
modelValue: true,
|
||||||
|
onClose: events.close,
|
||||||
|
onScrape: events.scrape,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return { ...result, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ScrapeDialog', () => {
|
||||||
|
it('uses the configured source while keeping media id optional', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { events } = await renderDialog('douban')
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '确认' }))
|
||||||
|
|
||||||
|
expect(events.scrape).toHaveBeenCalledWith({
|
||||||
|
media_source: 'douban',
|
||||||
|
media_id: undefined,
|
||||||
|
type_name: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('submits the selected media type, source, and native id', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const { events } = await renderDialog()
|
||||||
|
|
||||||
|
await user.click(screen.getByLabelText('类型'))
|
||||||
|
await user.click(await screen.findByRole('option', { name: '电影' }))
|
||||||
|
await user.click(screen.getByLabelText('数据源'))
|
||||||
|
await user.click(await screen.findByRole('option', { name: '豆瓣' }))
|
||||||
|
await user.type(screen.getByLabelText('豆瓣编号'), '1295644')
|
||||||
|
await user.click(screen.getByRole('button', { name: '确认' }))
|
||||||
|
|
||||||
|
expect(events.scrape).toHaveBeenCalledWith({
|
||||||
|
media_source: 'douban',
|
||||||
|
media_id: '1295644',
|
||||||
|
type_name: '电影',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the selected item count for batch scraping', async () => {
|
||||||
|
await renderDialog('themoviedb', [
|
||||||
|
{ name: 'Test Show S01E01.mkv', path: '/tv/Test Show S01E01.mkv', storage: 'local', type: 'file' },
|
||||||
|
{ name: 'Test Show S01E02.mkv', path: '/tv/Test Show S01E02.mkv', storage: 'local', type: 'file' },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(screen.getByText('共 2 项')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,7 +4,7 @@ import type { PropType } from 'vue'
|
|||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { formatBytes } from '@core/utils/formatters'
|
import { formatBytes } from '@core/utils/formatters'
|
||||||
import type { Context, EndPoints, FileItem } from '@/api/types'
|
import type { Context, EndPoints, FileItem, ManualScrapeOptions } from '@/api/types'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -18,6 +18,7 @@ const FileRenameDialog = defineAsyncComponent(() => import('../dialog/FileRename
|
|||||||
const MediaInfoDialog = defineAsyncComponent(() => import('../dialog/MediaInfoDialog.vue'))
|
const MediaInfoDialog = defineAsyncComponent(() => import('../dialog/MediaInfoDialog.vue'))
|
||||||
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
|
const ProgressDialog = defineAsyncComponent(() => import('../dialog/ProgressDialog.vue'))
|
||||||
const ReorganizeDialog = defineAsyncComponent(() => import('../dialog/ReorganizeDialog.vue'))
|
const ReorganizeDialog = defineAsyncComponent(() => import('../dialog/ReorganizeDialog.vue'))
|
||||||
|
const ScrapeDialog = defineAsyncComponent(() => import('../dialog/ScrapeDialog.vue'))
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -105,10 +106,12 @@ const currentItem = ref<FileItem>()
|
|||||||
// 选中的项目
|
// 选中的项目
|
||||||
const selected = ref<FileItem[]>([])
|
const selected = ref<FileItem[]>([])
|
||||||
|
|
||||||
|
// 生成文件项稳定键,用于去重和状态同步。
|
||||||
function getFileItemKey(item?: FileItem) {
|
function getFileItemKey(item?: FileItem) {
|
||||||
return [item?.storage ?? inProps.item.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
return [item?.storage ?? inProps.item.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 按存储、类型和路径去重文件项。
|
||||||
function dedupeFileItems(fileItems: FileItem[]) {
|
function dedupeFileItems(fileItems: FileItem[]) {
|
||||||
const uniqueItems = new Map<string, FileItem>()
|
const uniqueItems = new Map<string, FileItem>()
|
||||||
fileItems.forEach(item => {
|
fileItems.forEach(item => {
|
||||||
@@ -118,6 +121,7 @@ function dedupeFileItems(fileItems: FileItem[]) {
|
|||||||
return Array.from(uniqueItems.values())
|
return Array.from(uniqueItems.values())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 列表刷新后将选中项同步为最新文件对象。
|
||||||
function syncSelectedItems(nextItems: FileItem[] = items.value) {
|
function syncSelectedItems(nextItems: FileItem[] = items.value) {
|
||||||
if (!selected.value.length) return
|
if (!selected.value.length) return
|
||||||
|
|
||||||
@@ -129,10 +133,12 @@ function syncSelectedItems(nextItems: FileItem[] = items.value) {
|
|||||||
|
|
||||||
const selectedKeys = computed(() => new Set(selected.value.map(item => getFileItemKey(item))))
|
const selectedKeys = computed(() => new Set(selected.value.map(item => getFileItemKey(item))))
|
||||||
|
|
||||||
|
// 判断文件项当前是否已选中。
|
||||||
function isSelected(item: FileItem) {
|
function isSelected(item: FileItem) {
|
||||||
return selectedKeys.value.has(getFileItemKey(item))
|
return selectedKeys.value.has(getFileItemKey(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更新单个文件项的选中状态。
|
||||||
function setItemSelected(item: FileItem, checked: boolean) {
|
function setItemSelected(item: FileItem, checked: boolean) {
|
||||||
const itemKey = getFileItemKey(item)
|
const itemKey = getFileItemKey(item)
|
||||||
|
|
||||||
@@ -219,6 +225,7 @@ const transferItems = ref<FileItem[]>([])
|
|||||||
// 当前图片地址
|
// 当前图片地址
|
||||||
const currentImgLink = ref('')
|
const currentImgLink = ref('')
|
||||||
|
|
||||||
|
// 释放当前图片预览使用的临时对象地址。
|
||||||
function revokeCurrentImgLink() {
|
function revokeCurrentImgLink() {
|
||||||
if (!currentImgLink.value) return
|
if (!currentImgLink.value) return
|
||||||
|
|
||||||
@@ -610,7 +617,7 @@ watch(
|
|||||||
props: {
|
props: {
|
||||||
prependIcon: 'mdi-auto-fix',
|
prependIcon: 'mdi-auto-fix',
|
||||||
click: (_item: FileItem) => {
|
click: (_item: FileItem) => {
|
||||||
scrape(_item)
|
showScrape(_item)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -672,58 +679,67 @@ async function recognize(path: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用API刮削
|
// 调用 API 按请求级媒体条件刮削单个文件项。
|
||||||
async function scrape(item: FileItem, confirm: boolean = true) {
|
async function scrape(item: FileItem, options: ManualScrapeOptions) {
|
||||||
try {
|
try {
|
||||||
if (confirm) {
|
|
||||||
// 确认
|
|
||||||
const confirmed = await createConfirm({
|
|
||||||
title: t('common.confirm'),
|
|
||||||
content: t('file.confirmScrape', { path: item.path }),
|
|
||||||
})
|
|
||||||
if (!confirmed) return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示进度条
|
|
||||||
progressText.value = t('file.scraping', { path: item.path })
|
progressText.value = t('file.scraping', { path: item.path })
|
||||||
openProgressDialog(progressText.value)
|
progressDialogController?.updateProps({ text: progressText.value })
|
||||||
|
|
||||||
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item)
|
const result: { [key: string]: any } = await api.post(`media/scrape/${inProps.item.storage}`, item, {
|
||||||
|
params: options,
|
||||||
|
})
|
||||||
|
|
||||||
// 关闭进度条
|
|
||||||
closeProgressDialog()
|
|
||||||
if (!result.success) $toast.error(result.message)
|
if (!result.success) $toast.error(result.message)
|
||||||
else $toast.success(t('file.scrapeCompleted', { path: item.path }))
|
else $toast.success(t('file.scrapeCompleted', { path: item.path }))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
closeProgressDialog()
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 批量刮削
|
// 按同一媒体条件依次刮削选中的文件项。
|
||||||
async function batchScrape() {
|
async function scrapeItems(itemsToScrape: FileItem[], options: ManualScrapeOptions) {
|
||||||
if (!selected.value.length) return
|
const normalizedItems = dedupeFileItems(itemsToScrape)
|
||||||
|
if (!normalizedItems.length) return
|
||||||
// 确认
|
|
||||||
const confirmed = await createConfirm({
|
|
||||||
title: t('common.confirm'),
|
|
||||||
content: t('file.confirmBatchScrape', { count: selected.value.length }),
|
|
||||||
})
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
|
progressText.value = t('file.scraping', { path: normalizedItems[0].path })
|
||||||
|
progressValue.value = 0
|
||||||
|
openProgressDialog(progressText.value, progressValue.value)
|
||||||
try {
|
try {
|
||||||
const selectedItems = dedupeFileItems(selected.value)
|
for (const [index, item] of normalizedItems.entries()) {
|
||||||
|
await scrape(item, options)
|
||||||
for (const item of selectedItems) {
|
progressValue.value = Math.round(((index + 1) / normalizedItems.length) * 100)
|
||||||
await scrape(item, false)
|
progressDialogController?.updateProps({ value: progressValue.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
exitSelectMode()
|
|
||||||
} finally {
|
} finally {
|
||||||
|
closeProgressDialog()
|
||||||
|
if (selectMode.value) exitSelectMode()
|
||||||
list_files({ silent: true })
|
list_files({ silent: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 打开单项手动刮削弹窗。
|
||||||
|
function showScrape(item: FileItem) {
|
||||||
|
openScrapeDialog([item])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开批量手动刮削弹窗。
|
||||||
|
function showBatchScrape() {
|
||||||
|
openScrapeDialog(dedupeFileItems(selected.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开手动刮削弹窗,并将确认结果交给文件列表执行。
|
||||||
|
function openScrapeDialog(itemsToScrape: FileItem[]) {
|
||||||
|
if (!itemsToScrape.length) return
|
||||||
|
openSharedDialog(
|
||||||
|
ScrapeDialog,
|
||||||
|
{ items: itemsToScrape },
|
||||||
|
{
|
||||||
|
scrape: (options: ManualScrapeOptions) => scrapeItems(itemsToScrape, options),
|
||||||
|
},
|
||||||
|
{ closeOn: ['close', 'scrape'] },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// 进度SSE消息处理函数
|
// 进度SSE消息处理函数
|
||||||
function handleProgressMessage(event: MessageEvent) {
|
function handleProgressMessage(event: MessageEvent) {
|
||||||
const progress = JSON.parse(event.data)
|
const progress = JSON.parse(event.data)
|
||||||
@@ -802,7 +818,7 @@ onUnmounted(() => {
|
|||||||
</IconBtn>
|
</IconBtn>
|
||||||
<!-- 批量操作按钮 -->
|
<!-- 批量操作按钮 -->
|
||||||
<span v-if="selected.length > 0">
|
<span v-if="selected.length > 0">
|
||||||
<IconBtn @click.stop="batchScrape">
|
<IconBtn @click.stop="showBatchScrape">
|
||||||
<VIcon color="primary" icon="mdi-auto-fix" />
|
<VIcon color="primary" icon="mdi-auto-fix" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
<IconBtn @click.stop="showBatchTransfer">
|
<IconBtn @click.stop="showBatchTransfer">
|
||||||
@@ -892,7 +908,7 @@ onUnmounted(() => {
|
|||||||
<IconBtn @click.stop="recognize(item.path)">
|
<IconBtn @click.stop="recognize(item.path)">
|
||||||
<VIcon icon="mdi-text-recognition" />
|
<VIcon icon="mdi-text-recognition" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
<IconBtn @click.stop="scrape(item)">
|
<IconBtn @click.stop="showScrape(item)">
|
||||||
<VIcon icon="mdi-auto-fix" />
|
<VIcon icon="mdi-auto-fix" />
|
||||||
</IconBtn>
|
</IconBtn>
|
||||||
<IconBtn @click.stop="showRenmae(item)">
|
<IconBtn @click.stop="showRenmae(item)">
|
||||||
|
|||||||
@@ -3332,6 +3332,7 @@ export default {
|
|||||||
recognizing: 'Recognizing {path}...',
|
recognizing: 'Recognizing {path}...',
|
||||||
recognizeFailed: '{path} recognition failed!',
|
recognizeFailed: '{path} recognition failed!',
|
||||||
scrape: 'Scrape',
|
scrape: 'Scrape',
|
||||||
|
manualScrape: 'Manual Scrape',
|
||||||
scraping: 'Scraping {path}...',
|
scraping: 'Scraping {path}...',
|
||||||
scrapeCompleted: '{path} scraping completed!',
|
scrapeCompleted: '{path} scraping completed!',
|
||||||
confirmScrape: 'Are you sure you want to scrape {path}?',
|
confirmScrape: 'Are you sure you want to scrape {path}?',
|
||||||
|
|||||||
@@ -3280,6 +3280,7 @@ export default {
|
|||||||
recognizing: '正在识别 {path}...',
|
recognizing: '正在识别 {path}...',
|
||||||
recognizeFailed: '{path} 识别失败!',
|
recognizeFailed: '{path} 识别失败!',
|
||||||
scrape: '刮削',
|
scrape: '刮削',
|
||||||
|
manualScrape: '手动刮削',
|
||||||
scraping: '正在刮削 {path}...',
|
scraping: '正在刮削 {path}...',
|
||||||
scrapeCompleted: '{path} 削刮完成!',
|
scrapeCompleted: '{path} 削刮完成!',
|
||||||
confirmScrape: '是否确认刮削 {path}?',
|
confirmScrape: '是否确认刮削 {path}?',
|
||||||
|
|||||||
@@ -3279,6 +3279,7 @@ export default {
|
|||||||
recognizing: '正在識別 {path}...',
|
recognizing: '正在識別 {path}...',
|
||||||
recognizeFailed: '{path} 識別失敗!',
|
recognizeFailed: '{path} 識別失敗!',
|
||||||
scrape: '刮削',
|
scrape: '刮削',
|
||||||
|
manualScrape: '手動刮削',
|
||||||
scraping: '正在刮削 {path}...',
|
scraping: '正在刮削 {path}...',
|
||||||
scrapeCompleted: '{path} 削刮完成!',
|
scrapeCompleted: '{path} 削刮完成!',
|
||||||
confirmScrape: '是否確認刮削 {path}?',
|
confirmScrape: '是否確認刮削 {path}?',
|
||||||
|
|||||||
Reference in New Issue
Block a user