mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 23:56:42 +08:00
feat: 增加手动整理集数定位规则配置并支持智能生成集数定位模板 (#473)
This commit is contained in:
@@ -89,6 +89,30 @@ const previewLoaded = ref(false)
|
|||||||
// 预览数据
|
// 预览数据
|
||||||
const previewData = ref<ManualTransferPreviewData>()
|
const previewData = ref<ManualTransferPreviewData>()
|
||||||
|
|
||||||
|
interface EpisodeFormatRecommendData {
|
||||||
|
rule_name?: string
|
||||||
|
rule_index?: number
|
||||||
|
pattern?: string
|
||||||
|
episode_format?: string
|
||||||
|
sample_file?: string
|
||||||
|
min_file_size_mb?: number
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeFormatRecommendState = reactive<{
|
||||||
|
loading: boolean
|
||||||
|
ruleName?: string
|
||||||
|
sampleFile?: string
|
||||||
|
lastMessage?: string
|
||||||
|
}>({
|
||||||
|
loading: false,
|
||||||
|
ruleName: undefined,
|
||||||
|
sampleFile: undefined,
|
||||||
|
lastMessage: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
|
||||||
|
|
||||||
function getFileItemKey(item?: FileItem) {
|
function getFileItemKey(item?: FileItem) {
|
||||||
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
|
||||||
}
|
}
|
||||||
@@ -414,6 +438,40 @@ const previewToggleIcon = computed(() => {
|
|||||||
return previewVisible.value ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
|
return previewVisible.value ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const episodeFormatRecommendSourceItem = computed<FileItem | undefined>(() => {
|
||||||
|
if (transferForm.fileitem?.path) return transferForm.fileitem
|
||||||
|
if (normalizedItems.value.length !== 1) return undefined
|
||||||
|
return normalizedItems.value[0]
|
||||||
|
})
|
||||||
|
|
||||||
|
const canRecommendEpisodeFormat = computed(() => {
|
||||||
|
return (
|
||||||
|
Boolean(episodeFormatRecommendSourceItem.value?.path) &&
|
||||||
|
!progressDialog.value &&
|
||||||
|
!episodeFormatRecommendState.loading
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const episodeFormatRecommendTooltip = computed(() => {
|
||||||
|
if (episodeFormatRecommendState.loading) return t('dialog.reorganize.episodeFormatRecommendLoading')
|
||||||
|
if (!episodeFormatRecommendSourceItem.value?.path) return t('dialog.reorganize.episodeFormatRecommendSelectFile')
|
||||||
|
if (episodeFormatRuleConfigured.value === false) return t('dialog.reorganize.episodeFormatRecommendNeedWords')
|
||||||
|
return t('dialog.reorganize.episodeFormatRecommendAction')
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => getFileItemKey(episodeFormatRecommendSourceItem.value),
|
||||||
|
sourceKey => {
|
||||||
|
transferForm.fileitem = episodeFormatRecommendSourceItem.value ?? ({} as FileItem)
|
||||||
|
if (!sourceKey) {
|
||||||
|
episodeFormatRecommendState.ruleName = undefined
|
||||||
|
episodeFormatRecommendState.sampleFile = undefined
|
||||||
|
episodeFormatRecommendState.lastMessage = undefined
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
// 构造整理请求
|
// 构造整理请求
|
||||||
function createTransferPayload(options: { item?: FileItem; logid?: number; preview?: boolean }) {
|
function createTransferPayload(options: { item?: FileItem; logid?: number; preview?: boolean }) {
|
||||||
const payload: ManualTransferPayload = {
|
const payload: ManualTransferPayload = {
|
||||||
@@ -434,6 +492,65 @@ async function requestManualTransfer<T = any>(
|
|||||||
return await api.post(`transfer/manual?background=${background}`, payload)
|
return await api.post(`transfer/manual?background=${background}`, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadEpisodeFormatRuleConfiguration() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('system/setting/EpisodeFormatRuleTable')
|
||||||
|
episodeFormatRuleConfigured.value = Boolean(result.data?.value?.length)
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
episodeFormatRuleConfigured.value = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRecommendEpisodeFormat() {
|
||||||
|
const sourceItem = episodeFormatRecommendSourceItem.value
|
||||||
|
if (!sourceItem?.path) {
|
||||||
|
$toast.warning(t('dialog.reorganize.episodeFormatRecommendSelectFile'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodeFormatRuleConfigured.value === false) {
|
||||||
|
$toast.warning(t('dialog.reorganize.episodeFormatRecommendNeedWords'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
episodeFormatRecommendState.loading = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hasExistingEpisodeFormat = Boolean(transferForm.episode_format?.trim())
|
||||||
|
const result = await api.post('transfer/episode-format/recommend', {
|
||||||
|
fileitem: sourceItem,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
$toast.error(result.message || t('dialog.reorganize.episodeFormatRecommendFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (result.data ?? {}) as EpisodeFormatRecommendData
|
||||||
|
if (!data.episode_format) {
|
||||||
|
$toast.error(t('dialog.reorganize.episodeFormatRecommendFailed'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transferForm.episode_format = data.episode_format
|
||||||
|
episodeFormatRecommendState.ruleName = data.rule_name
|
||||||
|
episodeFormatRecommendState.sampleFile = data.sample_file
|
||||||
|
episodeFormatRecommendState.lastMessage = data.message
|
||||||
|
|
||||||
|
$toast.success(
|
||||||
|
hasExistingEpisodeFormat
|
||||||
|
? t('dialog.reorganize.episodeFormatRecommendOverwriteSuccess')
|
||||||
|
: t('dialog.reorganize.episodeFormatRecommendSuccess'),
|
||||||
|
)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(error)
|
||||||
|
$toast.error(error?.message || t('dialog.reorganize.episodeFormatRecommendFailed'))
|
||||||
|
} finally {
|
||||||
|
episodeFormatRecommendState.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 默认预览数据
|
// 默认预览数据
|
||||||
function getDefaultPreviewData(): ManualTransferPreviewData {
|
function getDefaultPreviewData(): ManualTransferPreviewData {
|
||||||
return {
|
return {
|
||||||
@@ -769,6 +886,7 @@ async function transfer(background: boolean = false) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadDirectories()
|
loadDirectories()
|
||||||
loadStorages()
|
loadStorages()
|
||||||
|
loadEpisodeFormatRuleConfiguration()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -778,8 +896,15 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VDialog :scrollable="!previewVisible || !display.mdAndUp.value" :max-width="dialogMaxWidth" :fullscreen="!display.mdAndUp.value">
|
<VDialog
|
||||||
<VCard class="reorganize-dialog-card" :class="{ 'reorganize-dialog-card--split': previewVisible && display.mdAndUp.value }">
|
:scrollable="!previewVisible || !display.mdAndUp.value"
|
||||||
|
:max-width="dialogMaxWidth"
|
||||||
|
:fullscreen="!display.mdAndUp.value"
|
||||||
|
>
|
||||||
|
<VCard
|
||||||
|
class="reorganize-dialog-card"
|
||||||
|
:class="{ 'reorganize-dialog-card--split': previewVisible && display.mdAndUp.value }"
|
||||||
|
>
|
||||||
<VCardItem class="py-2">
|
<VCardItem class="py-2">
|
||||||
<template #prepend> <VIcon icon="mdi-folder-move" class="me-2" /> </template>
|
<template #prepend> <VIcon icon="mdi-folder-move" class="me-2" /> </template>
|
||||||
<VCardTitle>{{ dialogTitle }}</VCardTitle>
|
<VCardTitle>{{ dialogTitle }}</VCardTitle>
|
||||||
@@ -914,7 +1039,29 @@ onUnmounted(() => {
|
|||||||
:hint="t('dialog.reorganize.episodeFormatHint')"
|
:hint="t('dialog.reorganize.episodeFormatHint')"
|
||||||
persistent-hint
|
persistent-hint
|
||||||
prepend-inner-icon="mdi-format-text"
|
prepend-inner-icon="mdi-format-text"
|
||||||
/>
|
>
|
||||||
|
<template #append-inner>
|
||||||
|
<VTooltip location="top">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<IconBtn
|
||||||
|
v-bind="tooltipProps"
|
||||||
|
type="button"
|
||||||
|
color="primary"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
class="ms-1"
|
||||||
|
icon="mdi-auto-fix"
|
||||||
|
:loading="episodeFormatRecommendState.loading"
|
||||||
|
:disabled="!canRecommendEpisodeFormat"
|
||||||
|
@click.stop="handleRecommendEpisodeFormat"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<span>
|
||||||
|
{{ episodeFormatRecommendTooltip }}
|
||||||
|
</span>
|
||||||
|
</VTooltip>
|
||||||
|
</template>
|
||||||
|
</VTextField>
|
||||||
</VCol>
|
</VCol>
|
||||||
<VCol cols="12" md="6">
|
<VCol cols="12" md="6">
|
||||||
<VTextField
|
<VTextField
|
||||||
@@ -1162,9 +1309,9 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.reorganize-dialog-card--split .reorganize-dialog-card__body {
|
.reorganize-dialog-card--split .reorganize-dialog-card__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reorganize-dialog-card--split .reorganize-main-row {
|
.reorganize-dialog-card--split .reorganize-main-row {
|
||||||
@@ -1177,8 +1324,8 @@ onUnmounted(() => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
min-block-size: 0;
|
|
||||||
inline-size: 100%;
|
inline-size: 100%;
|
||||||
|
min-block-size: 0;
|
||||||
transition: grid-template-columns 0.25s ease;
|
transition: grid-template-columns 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1190,8 +1337,8 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-block-size: 0;
|
|
||||||
max-inline-size: none;
|
max-inline-size: none;
|
||||||
|
min-block-size: 0;
|
||||||
min-inline-size: 0;
|
min-inline-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1264,9 +1411,9 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.reorganize-preview-pane__body {
|
.reorganize-preview-pane__body {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1493,8 +1640,8 @@ onUnmounted(() => {
|
|||||||
@media (width <= 959px) {
|
@media (width <= 959px) {
|
||||||
.reorganize-dialog-card,
|
.reorganize-dialog-card,
|
||||||
.reorganize-dialog-card--split {
|
.reorganize-dialog-card--split {
|
||||||
max-block-size: none;
|
|
||||||
block-size: auto;
|
block-size: auto;
|
||||||
|
max-block-size: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reorganize-dialog-card--split .reorganize-dialog-card__body,
|
.reorganize-dialog-card--split .reorganize-dialog-card__body,
|
||||||
|
|||||||
+35
-1
@@ -1756,7 +1756,8 @@ export default {
|
|||||||
siteDataRefreshInterval: 'Site Data Refresh Interval',
|
siteDataRefreshInterval: 'Site Data Refresh Interval',
|
||||||
siteDataRefreshIntervalHint: 'Time interval for refreshing site user upload/download data',
|
siteDataRefreshIntervalHint: 'Time interval for refreshing site user upload/download data',
|
||||||
searchResourcePages: 'Search Resource Pages',
|
searchResourcePages: 'Search Resource Pages',
|
||||||
searchResourcePagesHint: 'Number of consecutive pages to fetch from the current page when searching site resources. Default is 1.',
|
searchResourcePagesHint:
|
||||||
|
'Number of consecutive pages to fetch from the current page when searching site resources. Default is 1.',
|
||||||
readSiteMessage: 'Read Site Messages',
|
readSiteMessage: 'Read Site Messages',
|
||||||
readSiteMessageHint: 'Read site messages and send notifications when refreshing data',
|
readSiteMessageHint: 'Read site messages and send notifications when refreshing data',
|
||||||
siteReset: 'Site Reset',
|
siteReset: 'Site Reset',
|
||||||
@@ -1868,6 +1869,29 @@ export default {
|
|||||||
excludeWordsHint: 'Support regular expressions, special characters need \\ escape, one line for each block word',
|
excludeWordsHint: 'Support regular expressions, special characters need \\ escape, one line for each block word',
|
||||||
excludeWordsSaveSuccess: 'File organization block words saved successfully',
|
excludeWordsSaveSuccess: 'File organization block words saved successfully',
|
||||||
excludeWordsSaveFailed: 'Failed to save file organization block words!',
|
excludeWordsSaveFailed: 'Failed to save file organization block words!',
|
||||||
|
|
||||||
|
episodeFormatRule: 'Manual Reorganize Episode Format Rules',
|
||||||
|
episodeFormatRuleDesc: 'Extract episode number from filename via regex.',
|
||||||
|
episodeFormatRuleName: 'Rule Name',
|
||||||
|
episodeFormatRuleNameHint: 'Enter the rule name',
|
||||||
|
episodeFormatRulePattern: 'Regular Expression',
|
||||||
|
episodeFormatRulePatternHint: 'Must contain (?<ep>...) named group',
|
||||||
|
episodeFormatRuleMinSize: 'Min Size (MB)',
|
||||||
|
episodeFormatRuleMinSizeHint: 'Files smaller than this will be ignored',
|
||||||
|
episodeFormatRuleGuideTitle: 'Tips',
|
||||||
|
episodeFormatRuleGuideContent:
|
||||||
|
'Rules are matched from top to bottom, and the first matched rule will be used.\n' +
|
||||||
|
'Write matching rules based on the actual filename structure:\n' +
|
||||||
|
'Match fixed text directly, and mark the episode number with the named group (?<ep>...);\n' +
|
||||||
|
'For other variable but position-sensitive parts, use named groups like (?<a>...) and (?<b>...).\n' +
|
||||||
|
'Escape special characters such as [](). when they should be matched literally;\n' +
|
||||||
|
'it is recommended to use ^ and $ to constrain the full filename, especially $, to avoid false positives from partial matches.',
|
||||||
|
episodeFormatRuleAdd: 'Add Rule',
|
||||||
|
episodeFormatRuleEdit: 'Edit Rule',
|
||||||
|
episodeFormatRuleDeleteConfirm: 'Are you sure you want to delete this rule?',
|
||||||
|
episodeFormatRuleSaveSuccess: 'Episode format rules saved successfully',
|
||||||
|
episodeFormatRuleSaveFailed: 'Failed to save episode format rules!',
|
||||||
|
episodeFormatRuleEmptyError: 'Rule name and regular expression cannot be empty',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
basicSettings: 'Basic Settings',
|
basicSettings: 'Basic Settings',
|
||||||
@@ -2522,6 +2546,16 @@ export default {
|
|||||||
episodeFormat: 'Episode Positioning',
|
episodeFormat: 'Episode Positioning',
|
||||||
episodeFormatHint: 'Use {ep} to position episode number part in filename to assist recognition',
|
episodeFormatHint: 'Use {ep} to position episode number part in filename to assist recognition',
|
||||||
episodeFormatPlaceholder: 'Use {ep} to position episode',
|
episodeFormatPlaceholder: 'Use {ep} to position episode',
|
||||||
|
episodeFormatRecommendAction: 'Generate',
|
||||||
|
episodeFormatRecommendLoading: 'Generating...',
|
||||||
|
episodeFormatRecommendSelectFile: 'Please select a single file or directory first',
|
||||||
|
episodeFormatRecommendNeedWords:
|
||||||
|
'Manual episode positioning rules are empty, please fill them in on the words page first',
|
||||||
|
episodeFormatRecommendSuccess: 'Episode format template generated',
|
||||||
|
episodeFormatRecommendOverwriteSuccess: 'Current episode format has been overwritten by the generated result',
|
||||||
|
episodeFormatRecommendFailed: 'Failed to generate episode format, please try again later',
|
||||||
|
episodeFormatRecommendRule: 'Matched Rule: {rule}',
|
||||||
|
episodeFormatRecommendSample: 'Sample File: {file}',
|
||||||
episodeOffset: 'Episode Offset',
|
episodeOffset: 'Episode Offset',
|
||||||
episodeOffsetHint: 'Episode offset calculation, e.g. -10 or EP*2',
|
episodeOffsetHint: 'Episode offset calculation, e.g. -10 or EP*2',
|
||||||
episodeOffsetPlaceholder: 'e.g. -10',
|
episodeOffsetPlaceholder: 'e.g. -10',
|
||||||
|
|||||||
@@ -1834,6 +1834,29 @@ export default {
|
|||||||
excludeWordsHint: '支持正则表达式,特殊字符需要\\转义,一行代表一个屏蔽词',
|
excludeWordsHint: '支持正则表达式,特殊字符需要\\转义,一行代表一个屏蔽词',
|
||||||
excludeWordsSaveSuccess: '文件整理屏蔽词保存成功',
|
excludeWordsSaveSuccess: '文件整理屏蔽词保存成功',
|
||||||
excludeWordsSaveFailed: '文件整理屏蔽词保存失败!',
|
excludeWordsSaveFailed: '文件整理屏蔽词保存失败!',
|
||||||
|
|
||||||
|
episodeFormatRule: '手动整理集数定位规则',
|
||||||
|
episodeFormatRuleDesc: '用于匹配媒体文件名,命中后自动生成对应的集数定位正则。',
|
||||||
|
episodeFormatRuleName: '规则名称',
|
||||||
|
episodeFormatRuleNameHint: '输入规则的名称',
|
||||||
|
episodeFormatRulePattern: '正则表达式',
|
||||||
|
episodeFormatRulePatternHint: '必须包含 (?<ep>...) 命名组',
|
||||||
|
episodeFormatRuleMinSize: '最小大小(MB)',
|
||||||
|
episodeFormatRuleMinSizeHint: '小于此大小的文件将不参与匹配',
|
||||||
|
episodeFormatRuleGuideTitle: '提示',
|
||||||
|
episodeFormatRuleGuideContent:
|
||||||
|
'从上到下依次匹配,第一个匹配的则使用该规则。\n' +
|
||||||
|
'按文件名实际结构编写匹配规则:\n' +
|
||||||
|
'固定文本直接匹配,集数必须使用命名分组 (?<ep>...) 标记;\n' +
|
||||||
|
'其余不固定但需要保留位置的内容,可使用 (?<a>...)、(?<b>...) 等命名分组承接。\n' +
|
||||||
|
'如需按字面量匹配 [](). 等特殊字符,请进行转义;\n' +
|
||||||
|
'建议使用 ^ 和 $ 完整约束整条文件名,其中 $ 尤其重要,可避免只匹配前半段就被误判为命中。',
|
||||||
|
episodeFormatRuleAdd: '新增规则',
|
||||||
|
episodeFormatRuleEdit: '编辑规则',
|
||||||
|
episodeFormatRuleDeleteConfirm: '确定要删除该规则吗?',
|
||||||
|
episodeFormatRuleEmptyError: '名称和正则表达式不能为空',
|
||||||
|
episodeFormatRuleSaveSuccess: '集数定位规则保存成功',
|
||||||
|
episodeFormatRuleSaveFailed: '集数定位规则保存失败!',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
basicSettings: '基础设置',
|
basicSettings: '基础设置',
|
||||||
@@ -2477,6 +2500,15 @@ export default {
|
|||||||
episodeFormat: '集数定位',
|
episodeFormat: '集数定位',
|
||||||
episodeFormatHint: '使用{ep}定位文件名中的集数部分以辅助识别',
|
episodeFormatHint: '使用{ep}定位文件名中的集数部分以辅助识别',
|
||||||
episodeFormatPlaceholder: '使用{ep}定位集数',
|
episodeFormatPlaceholder: '使用{ep}定位集数',
|
||||||
|
episodeFormatRecommendAction: '智能生成',
|
||||||
|
episodeFormatRecommendLoading: '生成中...',
|
||||||
|
episodeFormatRecommendSelectFile: '请先选择单个文件或目录',
|
||||||
|
episodeFormatRecommendNeedWords: '手动整理集数定位规则为空,请先前往词表填写',
|
||||||
|
episodeFormatRecommendSuccess: '已生成集数定位模板',
|
||||||
|
episodeFormatRecommendOverwriteSuccess: '已用智能生成结果覆盖当前集数定位',
|
||||||
|
episodeFormatRecommendFailed: '集数定位生成失败,请稍后重试',
|
||||||
|
episodeFormatRecommendRule: '命中规则:{rule}',
|
||||||
|
episodeFormatRecommendSample: '样本文件:{file}',
|
||||||
episodeOffset: '集数偏移',
|
episodeOffset: '集数偏移',
|
||||||
episodeOffsetHint: '集数偏移运算,如-10或EP*2',
|
episodeOffsetHint: '集数偏移运算,如-10或EP*2',
|
||||||
episodeOffsetPlaceholder: '如-10',
|
episodeOffsetPlaceholder: '如-10',
|
||||||
|
|||||||
@@ -1835,6 +1835,29 @@ export default {
|
|||||||
excludeWordsHint: '支持正則表達式,特殊字符需要\\轉義,一行代表一個屏蔽詞',
|
excludeWordsHint: '支持正則表達式,特殊字符需要\\轉義,一行代表一個屏蔽詞',
|
||||||
excludeWordsSaveSuccess: '文件整理屏蔽詞保存成功',
|
excludeWordsSaveSuccess: '文件整理屏蔽詞保存成功',
|
||||||
excludeWordsSaveFailed: '文件整理屏蔽詞保存失敗!',
|
excludeWordsSaveFailed: '文件整理屏蔽詞保存失敗!',
|
||||||
|
|
||||||
|
episodeFormatRule: '手動整理集數定位規則',
|
||||||
|
episodeFormatRuleDesc: '通過正則表達式提取文件名中的集數。',
|
||||||
|
episodeFormatRuleName: '規則名稱',
|
||||||
|
episodeFormatRuleNameHint: '輸入規則的名稱',
|
||||||
|
episodeFormatRulePattern: '正則表達式',
|
||||||
|
episodeFormatRulePatternHint: '必須包含 (?<ep>...) 命名組',
|
||||||
|
episodeFormatRuleMinSize: '最小大小(MB)',
|
||||||
|
episodeFormatRuleMinSizeHint: '小於此大小的文件將不參與匹配',
|
||||||
|
episodeFormatRuleGuideTitle: '提示',
|
||||||
|
episodeFormatRuleGuideContent:
|
||||||
|
'從上到下依次匹配,第一個匹配的則使用該規則。\n' +
|
||||||
|
'按文件名實際結構編寫匹配規則:\n' +
|
||||||
|
'固定文本直接匹配,集數必須使用命名分組 (?<ep>...) 標記;\n' +
|
||||||
|
'其餘不固定但需要保留位置的內容,可使用 (?<a>...)、(?<b>...) 等命名分組承接。\n' +
|
||||||
|
'如需按字面量匹配 [](). 等特殊字符,請進行轉義;\n' +
|
||||||
|
'建議使用 ^ 和 $ 完整約束整條文件名,其中 $ 尤其重要,可避免只匹配前半段就被誤判為命中。',
|
||||||
|
episodeFormatRuleAdd: '新增規則',
|
||||||
|
episodeFormatRuleEdit: '編輯規則',
|
||||||
|
episodeFormatRuleDeleteConfirm: '確定要刪除該規則嗎?',
|
||||||
|
episodeFormatRuleSaveSuccess: '集數定位規則保存成功',
|
||||||
|
episodeFormatRuleSaveFailed: '集數定位規則保存失敗!',
|
||||||
|
episodeFormatRuleEmptyError: '規則名稱和正則表達式不能為空',
|
||||||
},
|
},
|
||||||
search: {
|
search: {
|
||||||
basicSettings: '基礎設置',
|
basicSettings: '基礎設置',
|
||||||
@@ -2478,6 +2501,15 @@ export default {
|
|||||||
episodeFormat: '集數定位',
|
episodeFormat: '集數定位',
|
||||||
episodeFormatHint: '使用{ep}定位文件名中的集數部分以輔助識別',
|
episodeFormatHint: '使用{ep}定位文件名中的集數部分以輔助識別',
|
||||||
episodeFormatPlaceholder: '使用{ep}定位集數',
|
episodeFormatPlaceholder: '使用{ep}定位集數',
|
||||||
|
episodeFormatRecommendAction: '智能生成',
|
||||||
|
episodeFormatRecommendLoading: '生成中...',
|
||||||
|
episodeFormatRecommendSelectFile: '請先選擇單個文件或目錄',
|
||||||
|
episodeFormatRecommendNeedWords: '手動整理集數定位規則為空,請先前往詞表填寫',
|
||||||
|
episodeFormatRecommendSuccess: '已生成集數定位模板',
|
||||||
|
episodeFormatRecommendOverwriteSuccess: '已用智能生成結果覆蓋當前集數定位',
|
||||||
|
episodeFormatRecommendFailed: '集數定位生成失敗,請稍後重試',
|
||||||
|
episodeFormatRecommendRule: '命中規則:{rule}',
|
||||||
|
episodeFormatRecommendSample: '樣本文件:{file}',
|
||||||
episodeOffset: '集數偏移',
|
episodeOffset: '集數偏移',
|
||||||
episodeOffsetHint: '集數偏移運算,如-10或EP*2',
|
episodeOffsetHint: '集數偏移運算,如-10或EP*2',
|
||||||
episodeOffsetPlaceholder: '如-10',
|
episodeOffsetPlaceholder: '如-10',
|
||||||
|
|||||||
@@ -3,12 +3,62 @@ import { useToast } from 'vue-toastification'
|
|||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 提示框
|
// 提示框
|
||||||
const $toast = useToast()
|
const $toast = useToast()
|
||||||
|
|
||||||
|
// 集数定位规则
|
||||||
|
interface EpisodeFormatRule {
|
||||||
|
_localId: string
|
||||||
|
name: string
|
||||||
|
enabled: boolean
|
||||||
|
order: number
|
||||||
|
pattern: string
|
||||||
|
min_file_size_mb: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeFormatRules = ref<EpisodeFormatRule[]>([])
|
||||||
|
|
||||||
|
function createEpisodeRuleLocalId() {
|
||||||
|
return `episode-rule-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEpisodeRule(rule?: Partial<Omit<EpisodeFormatRule, '_localId'>>): EpisodeFormatRule {
|
||||||
|
return {
|
||||||
|
_localId: createEpisodeRuleLocalId(),
|
||||||
|
name: rule?.name ?? '',
|
||||||
|
enabled: rule?.enabled ?? true,
|
||||||
|
order: rule?.order ?? episodeFormatRules.value.length + 1,
|
||||||
|
pattern: rule?.pattern ?? '',
|
||||||
|
min_file_size_mb: rule?.min_file_size_mb ?? 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEpisodeFormatRules(
|
||||||
|
rules: Array<Partial<Omit<EpisodeFormatRule, '_localId'>> & { _localId?: string }> = [],
|
||||||
|
) {
|
||||||
|
return rules.map(rule => createEpisodeRule(rule))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEpisodeFormatRulePayload() {
|
||||||
|
return episodeFormatRules.value.map((rule, index) => ({
|
||||||
|
name: rule.name,
|
||||||
|
enabled: rule.enabled,
|
||||||
|
order: index + 1,
|
||||||
|
pattern: rule.pattern,
|
||||||
|
min_file_size_mb: Number(rule.min_file_size_mb) || 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加集数定位规则
|
||||||
|
function addEpisodeRule() {
|
||||||
|
episodeFormatRules.value.push(createEpisodeRule())
|
||||||
|
}
|
||||||
|
|
||||||
// 自定义识别词
|
// 自定义识别词
|
||||||
const customIdentifiers = ref('')
|
const customIdentifiers = ref('')
|
||||||
|
|
||||||
@@ -121,11 +171,67 @@ async function saveTransferExcludeWords() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询集数定位规则
|
||||||
|
async function queryEpisodeFormatRules() {
|
||||||
|
try {
|
||||||
|
const result: { [key: string]: any } = await api.get('system/setting/EpisodeFormatRuleTable')
|
||||||
|
if (result && result.data && result.data.value) {
|
||||||
|
episodeFormatRules.value = normalizeEpisodeFormatRules(result.data.value)
|
||||||
|
} else {
|
||||||
|
episodeFormatRules.value = []
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存集数定位规则
|
||||||
|
async function saveEpisodeFormatRules() {
|
||||||
|
// 基础校验
|
||||||
|
for (const rule of episodeFormatRules.value) {
|
||||||
|
if (!rule.name || !rule.pattern) {
|
||||||
|
$toast.error(t('setting.words.episodeFormatRuleEmptyError'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = buildEpisodeFormatRulePayload()
|
||||||
|
episodeFormatRules.value.forEach((rule, index) => {
|
||||||
|
rule.order = payload[index].order
|
||||||
|
rule.min_file_size_mb = payload[index].min_file_size_mb
|
||||||
|
})
|
||||||
|
const result: { [key: string]: any } = await api.post('system/setting/EpisodeFormatRuleTable', payload)
|
||||||
|
if (result.success) {
|
||||||
|
$toast.success(t('setting.words.episodeFormatRuleSaveSuccess'))
|
||||||
|
queryEpisodeFormatRules()
|
||||||
|
} else {
|
||||||
|
$toast.error(result.message || t('setting.words.episodeFormatRuleSaveFailed'))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
$toast.error(t('setting.words.episodeFormatRuleSaveFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除集数定位规则
|
||||||
|
function deleteEpisodeRule(index: number) {
|
||||||
|
if (confirm(t('setting.words.episodeFormatRuleDeleteConfirm'))) {
|
||||||
|
episodeFormatRules.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽结束
|
||||||
|
function onEpisodeRuleDragEnd() {
|
||||||
|
saveEpisodeFormatRules()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
queryCustomIdentifiers()
|
queryCustomIdentifiers()
|
||||||
queryCustomReleaseGroups()
|
queryCustomReleaseGroups()
|
||||||
queryCustomization()
|
queryCustomization()
|
||||||
queryTransferExcludeWords()
|
queryTransferExcludeWords()
|
||||||
|
queryEpisodeFormatRules()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -247,4 +353,165 @@ onMounted(() => {
|
|||||||
</VCard>
|
</VCard>
|
||||||
</VCol>
|
</VCol>
|
||||||
</VRow>
|
</VRow>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12">
|
||||||
|
<VCard>
|
||||||
|
<VCardItem>
|
||||||
|
<template #append>
|
||||||
|
<VBtn color="primary" @click="addEpisodeRule" prepend-icon="mdi-plus">
|
||||||
|
{{ t('setting.words.episodeFormatRuleAdd') }}
|
||||||
|
</VBtn>
|
||||||
|
</template>
|
||||||
|
<VCardTitle>{{ t('setting.words.episodeFormatRule') }}</VCardTitle>
|
||||||
|
<VCardSubtitle>{{ t('setting.words.episodeFormatRuleDesc') }}</VCardSubtitle>
|
||||||
|
</VCardItem>
|
||||||
|
<VCardText>
|
||||||
|
<Draggable
|
||||||
|
v-model="episodeFormatRules"
|
||||||
|
handle=".cursor-move"
|
||||||
|
item-key="_localId"
|
||||||
|
tag="div"
|
||||||
|
:component-data="{ class: 'd-flex flex-column gap-3' }"
|
||||||
|
@end="onEpisodeRuleDragEnd"
|
||||||
|
>
|
||||||
|
<template #item="{ element, index }">
|
||||||
|
<VCard variant="outlined" class="episode-rule-card">
|
||||||
|
<VCardText class="py-4">
|
||||||
|
<div class="episode-rule-row d-flex align-center gap-2">
|
||||||
|
<IconBtn
|
||||||
|
icon="mdi-drag"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
class="episode-rule-control episode-rule-drag cursor-move flex-0-0"
|
||||||
|
/>
|
||||||
|
<VCheckbox
|
||||||
|
v-model="element.enabled"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
class="episode-rule-control episode-rule-enabled flex-0-0"
|
||||||
|
/>
|
||||||
|
<div class="episode-rule-field episode-rule-name">
|
||||||
|
<VTextField
|
||||||
|
v-model="element.name"
|
||||||
|
:label="t('setting.words.episodeFormatRuleName')"
|
||||||
|
hide-details="auto"
|
||||||
|
density="comfortable"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="episode-rule-field episode-rule-pattern">
|
||||||
|
<VTextField
|
||||||
|
v-model="element.pattern"
|
||||||
|
:label="t('setting.words.episodeFormatRulePattern')"
|
||||||
|
hide-details="auto"
|
||||||
|
density="comfortable"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="episode-rule-field episode-rule-size">
|
||||||
|
<VTextField
|
||||||
|
v-model.number="element.min_file_size_mb"
|
||||||
|
:label="t('setting.words.episodeFormatRuleMinSize')"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
hide-details="auto"
|
||||||
|
density="comfortable"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IconBtn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
class="episode-rule-control episode-rule-delete flex-0-0"
|
||||||
|
@click="deleteEpisodeRule(index)"
|
||||||
|
>
|
||||||
|
<VIcon icon="mdi-delete" />
|
||||||
|
</IconBtn>
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
</VCardText>
|
||||||
|
<VCardText>
|
||||||
|
<VAlert type="info" variant="tonal" :title="t('setting.words.episodeFormatRuleGuideTitle')">
|
||||||
|
<div style="white-space: pre-line" v-html="t('setting.words.episodeFormatRuleGuideContent')"></div>
|
||||||
|
</VAlert>
|
||||||
|
</VCardText>
|
||||||
|
<VCardText>
|
||||||
|
<VForm @submit.prevent="() => {}">
|
||||||
|
<div class="d-flex flex-wrap gap-4 mt-4">
|
||||||
|
<VBtn type="submit" @click="saveEpisodeFormatRules" prepend-icon="mdi-content-save">
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</VBtn>
|
||||||
|
</div>
|
||||||
|
</VForm>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.episode-rule-card {
|
||||||
|
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-rule-row {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-rule-name {
|
||||||
|
flex: 0.8 1 9rem;
|
||||||
|
min-inline-size: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-rule-pattern {
|
||||||
|
flex: 3.7 1 26rem;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-rule-size {
|
||||||
|
flex: 0 0 8rem;
|
||||||
|
min-inline-size: 8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 959px) {
|
||||||
|
.episode-rule-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-rule-drag {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-rule-enabled {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-rule-delete {
|
||||||
|
order: 3;
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-rule-name {
|
||||||
|
flex: 1 1 calc(50% - 0.25rem);
|
||||||
|
order: 4;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-rule-size {
|
||||||
|
flex: 1 1 calc(50% - 0.25rem);
|
||||||
|
order: 5;
|
||||||
|
min-inline-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-rule-pattern {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
order: 6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user