mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-17 05:30:59 +08:00
feat: 增加手动整理集数定位规则配置并支持智能生成集数定位模板 (#473)
This commit is contained in:
@@ -89,6 +89,30 @@ const previewLoaded = ref(false)
|
||||
// 预览数据
|
||||
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) {
|
||||
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'
|
||||
})
|
||||
|
||||
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 }) {
|
||||
const payload: ManualTransferPayload = {
|
||||
@@ -434,6 +492,65 @@ async function requestManualTransfer<T = any>(
|
||||
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 {
|
||||
return {
|
||||
@@ -769,6 +886,7 @@ async function transfer(background: boolean = false) {
|
||||
onMounted(() => {
|
||||
loadDirectories()
|
||||
loadStorages()
|
||||
loadEpisodeFormatRuleConfiguration()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -778,8 +896,15 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :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 }">
|
||||
<VDialog
|
||||
: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">
|
||||
<template #prepend> <VIcon icon="mdi-folder-move" class="me-2" /> </template>
|
||||
<VCardTitle>{{ dialogTitle }}</VCardTitle>
|
||||
@@ -914,7 +1039,29 @@ onUnmounted(() => {
|
||||
:hint="t('dialog.reorganize.episodeFormatHint')"
|
||||
persistent-hint
|
||||
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 cols="12" md="6">
|
||||
<VTextField
|
||||
@@ -1162,9 +1309,9 @@ onUnmounted(() => {
|
||||
|
||||
.reorganize-dialog-card--split .reorganize-dialog-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.reorganize-dialog-card--split .reorganize-main-row {
|
||||
@@ -1177,8 +1324,8 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
align-items: stretch;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-block-size: 0;
|
||||
inline-size: 100%;
|
||||
min-block-size: 0;
|
||||
transition: grid-template-columns 0.25s ease;
|
||||
}
|
||||
|
||||
@@ -1190,8 +1337,8 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-direction: column;
|
||||
min-block-size: 0;
|
||||
max-inline-size: none;
|
||||
min-block-size: 0;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
|
||||
@@ -1264,9 +1411,9 @@ onUnmounted(() => {
|
||||
|
||||
.reorganize-preview-pane__body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
@@ -1493,8 +1640,8 @@ onUnmounted(() => {
|
||||
@media (width <= 959px) {
|
||||
.reorganize-dialog-card,
|
||||
.reorganize-dialog-card--split {
|
||||
max-block-size: none;
|
||||
block-size: auto;
|
||||
max-block-size: none;
|
||||
}
|
||||
|
||||
.reorganize-dialog-card--split .reorganize-dialog-card__body,
|
||||
|
||||
@@ -1756,7 +1756,8 @@ export default {
|
||||
siteDataRefreshInterval: 'Site Data Refresh Interval',
|
||||
siteDataRefreshIntervalHint: 'Time interval for refreshing site user upload/download data',
|
||||
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',
|
||||
readSiteMessageHint: 'Read site messages and send notifications when refreshing data',
|
||||
siteReset: 'Site Reset',
|
||||
@@ -1868,6 +1869,29 @@ export default {
|
||||
excludeWordsHint: 'Support regular expressions, special characters need \\ escape, one line for each block word',
|
||||
excludeWordsSaveSuccess: 'File organization block words saved successfully',
|
||||
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: {
|
||||
basicSettings: 'Basic Settings',
|
||||
@@ -2522,6 +2546,16 @@ export default {
|
||||
episodeFormat: 'Episode Positioning',
|
||||
episodeFormatHint: 'Use {ep} to position episode number part in filename to assist recognition',
|
||||
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',
|
||||
episodeOffsetHint: 'Episode offset calculation, e.g. -10 or EP*2',
|
||||
episodeOffsetPlaceholder: 'e.g. -10',
|
||||
|
||||
@@ -1834,6 +1834,29 @@ export default {
|
||||
excludeWordsHint: '支持正则表达式,特殊字符需要\\转义,一行代表一个屏蔽词',
|
||||
excludeWordsSaveSuccess: '文件整理屏蔽词保存成功',
|
||||
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: {
|
||||
basicSettings: '基础设置',
|
||||
@@ -2477,6 +2500,15 @@ export default {
|
||||
episodeFormat: '集数定位',
|
||||
episodeFormatHint: '使用{ep}定位文件名中的集数部分以辅助识别',
|
||||
episodeFormatPlaceholder: '使用{ep}定位集数',
|
||||
episodeFormatRecommendAction: '智能生成',
|
||||
episodeFormatRecommendLoading: '生成中...',
|
||||
episodeFormatRecommendSelectFile: '请先选择单个文件或目录',
|
||||
episodeFormatRecommendNeedWords: '手动整理集数定位规则为空,请先前往词表填写',
|
||||
episodeFormatRecommendSuccess: '已生成集数定位模板',
|
||||
episodeFormatRecommendOverwriteSuccess: '已用智能生成结果覆盖当前集数定位',
|
||||
episodeFormatRecommendFailed: '集数定位生成失败,请稍后重试',
|
||||
episodeFormatRecommendRule: '命中规则:{rule}',
|
||||
episodeFormatRecommendSample: '样本文件:{file}',
|
||||
episodeOffset: '集数偏移',
|
||||
episodeOffsetHint: '集数偏移运算,如-10或EP*2',
|
||||
episodeOffsetPlaceholder: '如-10',
|
||||
|
||||
@@ -1835,6 +1835,29 @@ export default {
|
||||
excludeWordsHint: '支持正則表達式,特殊字符需要\\轉義,一行代表一個屏蔽詞',
|
||||
excludeWordsSaveSuccess: '文件整理屏蔽詞保存成功',
|
||||
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: {
|
||||
basicSettings: '基礎設置',
|
||||
@@ -2478,6 +2501,15 @@ export default {
|
||||
episodeFormat: '集數定位',
|
||||
episodeFormatHint: '使用{ep}定位文件名中的集數部分以輔助識別',
|
||||
episodeFormatPlaceholder: '使用{ep}定位集數',
|
||||
episodeFormatRecommendAction: '智能生成',
|
||||
episodeFormatRecommendLoading: '生成中...',
|
||||
episodeFormatRecommendSelectFile: '請先選擇單個文件或目錄',
|
||||
episodeFormatRecommendNeedWords: '手動整理集數定位規則為空,請先前往詞表填寫',
|
||||
episodeFormatRecommendSuccess: '已生成集數定位模板',
|
||||
episodeFormatRecommendOverwriteSuccess: '已用智能生成結果覆蓋當前集數定位',
|
||||
episodeFormatRecommendFailed: '集數定位生成失敗,請稍後重試',
|
||||
episodeFormatRecommendRule: '命中規則:{rule}',
|
||||
episodeFormatRecommendSample: '樣本文件:{file}',
|
||||
episodeOffset: '集數偏移',
|
||||
episodeOffsetHint: '集數偏移運算,如-10或EP*2',
|
||||
episodeOffsetPlaceholder: '如-10',
|
||||
|
||||
@@ -3,12 +3,62 @@ import { useToast } from 'vue-toastification'
|
||||
import api from '@/api'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 提示框
|
||||
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('')
|
||||
|
||||
@@ -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(() => {
|
||||
queryCustomIdentifiers()
|
||||
queryCustomReleaseGroups()
|
||||
queryCustomization()
|
||||
queryTransferExcludeWords()
|
||||
queryEpisodeFormatRules()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -247,4 +353,165 @@ onMounted(() => {
|
||||
</VCard>
|
||||
</VCol>
|
||||
</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>
|
||||
|
||||
<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