From 6bbac7db7d8f82a4d30171f81407fe1ff1696233 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 3 Jul 2026 08:37:44 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=96=B0=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E8=AF=8D=E8=A1=A8=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/composables/useShortcutTools.ts | 1 + src/locales/en-US.ts | 17 + src/locales/zh-CN.ts | 17 + src/locales/zh-TW.ts | 17 + src/views/system/WordsView.vue | 1372 +++++++++++++++++++-------- 5 files changed, 1003 insertions(+), 421 deletions(-) diff --git a/src/composables/useShortcutTools.ts b/src/composables/useShortcutTools.ts index 78f9a092..c4cb64a3 100644 --- a/src/composables/useShortcutTools.ts +++ b/src/composables/useShortcutTools.ts @@ -80,6 +80,7 @@ export function useShortcutTools() { subtitle: t('shortcut.words.subtitle'), icon: 'mdi-file-word-box', dialog: 'words', + bodyClass: 'words-shortcut-dialog-body pa-0', component: WordsView, maxWidth: '60rem', titleText: t('shortcut.words.subtitle'), diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 2b7a553d..64763b2c 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -2090,6 +2090,23 @@ export default { custom: 'Custom Notification', }, words: { + summary: '{sections} word lists · {entries} entries', + sectionListLabel: 'Word list categories', + switchHint: 'Select an item on the left to edit it', + entryCount: '{count} entries', + ruleCount: '{count} rules', + listLabel: 'Word list content (one rule per line)', + saved: 'Saved', + unsaved: 'Unsaved changes', + saveChanges: 'Save changes', + guideTitle: 'Syntax tips', + dragToSort: 'Drag to reorder', + noRules: 'No episode format rules yet', + identifiersShort: 'Identifiers', + releaseGroupsShort: 'Groups', + customizationShort: 'Placeholders', + excludeWordsShort: 'Block words', + episodeFormatRuleShort: 'Episode rules', customIdentifiers: 'Custom Identifiers', identifiersDesc: 'Add rules to preprocess torrent names or file names to correct identification', identifiersPlaceholder: 'Support regular expressions, special characters need \\ escape, one line for each rule', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 363c2e48..fc7cd65d 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -2054,6 +2054,23 @@ export default { custom: '自定义通知', }, words: { + summary: '共 {sections} 类词表 · {entries} 条配置', + sectionListLabel: '词表分类', + switchHint: '点击左侧项目切换编辑', + entryCount: '{count} 条', + ruleCount: '共 {count} 条规则', + listLabel: '词表内容(每行一个规则)', + saved: '已保存', + unsaved: '有未保存修改', + saveChanges: '保存更改', + guideTitle: '语法提示', + dragToSort: '拖动调整顺序', + noRules: '暂无集数定位规则', + identifiersShort: '识别词', + releaseGroupsShort: '制作组', + customizationShort: '占位符', + excludeWordsShort: '屏蔽词', + episodeFormatRuleShort: '集数规则', customIdentifiers: '自定义识别词', identifiersDesc: '添加规则对种子名或者文件名进行预处理以校正识别', identifiersPlaceholder: '支持正则表达式,特殊字符需要\\转义,一行为一组', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 6230270e..05d7adce 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -2053,6 +2053,23 @@ export default { custom: '自定義通知', }, words: { + summary: '共 {sections} 類詞表 · {entries} 條配置', + sectionListLabel: '詞表分類', + switchHint: '點擊左側項目切換編輯', + entryCount: '{count} 條', + ruleCount: '共 {count} 條規則', + listLabel: '詞表內容(每行一個規則)', + saved: '已保存', + unsaved: '有未保存修改', + saveChanges: '保存更改', + guideTitle: '語法提示', + dragToSort: '拖動調整順序', + noRules: '暫無集數定位規則', + identifiersShort: '識別詞', + releaseGroupsShort: '製作組', + customizationShort: '占位符', + excludeWordsShort: '屏蔽詞', + episodeFormatRuleShort: '集數規則', customIdentifiers: '自定義識別詞', identifiersDesc: '添加規則對種子名或者文件名進行預處理以校正識別', identifiersPlaceholder: '支持正則表達式,特殊字符需要\\轉義,一行為一組', diff --git a/src/views/system/WordsView.vue b/src/views/system/WordsView.vue index c6c4c523..c107bda9 100644 --- a/src/views/system/WordsView.vue +++ b/src/views/system/WordsView.vue @@ -5,13 +5,12 @@ import { useI18n } from 'vue-i18n' const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default)) -// 国际化 const { t } = useI18n() - -// 提示框 const $toast = useToast() -// 集数定位规则 +type TextSectionKey = 'identifiers' | 'releaseGroups' | 'customization' | 'excludeWords' +type WordSectionKey = TextSectionKey | 'episodeRules' + interface EpisodeFormatRule { _localId: string name: string @@ -21,12 +20,174 @@ interface EpisodeFormatRule { min_file_size_mb: number } -const episodeFormatRules = ref([]) +interface WordSectionDefinition { + color: string + description: string + icon: string + key: WordSectionKey + shortTitle: string + title: string +} +interface TextSectionSetting { + endpoint: string + failedMessage: string + successMessage: string +} + +const customIdentifiers = ref('') +const customReleaseGroups = ref('') +const customization = ref('') +const transferExcludeWords = ref('') +const episodeFormatRules = ref([]) +const activeSection = ref('identifiers') +const expandedHelp = ref(null) +const saving = ref(false) + +const savedTextValues = reactive>({ + identifiers: '', + releaseGroups: '', + customization: '', + excludeWords: '', +}) +const savedEpisodeRules = ref('[]') + +const textSectionModels: Record = { + identifiers: customIdentifiers, + releaseGroups: customReleaseGroups, + customization, + excludeWords: transferExcludeWords, +} + +const textSectionSettings = computed>(() => ({ + identifiers: { + endpoint: 'system/setting/CustomIdentifiers', + failedMessage: t('setting.words.identifierSaveFailed'), + successMessage: t('setting.words.identifierSaveSuccess'), + }, + releaseGroups: { + endpoint: 'system/setting/CustomReleaseGroups', + failedMessage: t('setting.words.releaseGroupSaveFailed'), + successMessage: t('setting.words.releaseGroupSaveSuccess'), + }, + customization: { + endpoint: 'system/setting/Customization', + failedMessage: t('setting.words.customizationSaveFailed'), + successMessage: t('setting.words.customizationSaveSuccess'), + }, + excludeWords: { + endpoint: 'system/setting/TransferExcludeWords', + failedMessage: t('setting.words.excludeWordsSaveFailed'), + successMessage: t('setting.words.excludeWordsSaveSuccess'), + }, +})) + +const wordSections = computed(() => [ + { + color: 'primary', + description: t('setting.words.identifiersDesc'), + icon: 'mdi-tag-outline', + key: 'identifiers', + shortTitle: t('setting.words.identifiersShort'), + title: t('setting.words.customIdentifiers'), + }, + { + color: 'secondary', + description: t('setting.words.releaseGroupsDesc'), + icon: 'mdi-account-group-outline', + key: 'releaseGroups', + shortTitle: t('setting.words.releaseGroupsShort'), + title: t('setting.words.customReleaseGroups'), + }, + { + color: 'info', + description: t('setting.words.customizationDesc'), + icon: 'mdi-code-braces', + key: 'customization', + shortTitle: t('setting.words.customizationShort'), + title: t('setting.words.customization'), + }, + { + color: 'success', + description: t('setting.words.excludeWordsDesc'), + icon: 'mdi-shield-off-outline', + key: 'excludeWords', + shortTitle: t('setting.words.excludeWordsShort'), + title: t('setting.words.transferExcludeWords'), + }, + { + color: 'primary', + description: t('setting.words.episodeFormatRuleDesc'), + icon: 'mdi-format-list-numbered', + key: 'episodeRules', + shortTitle: t('setting.words.episodeFormatRuleShort'), + title: t('setting.words.episodeFormatRule'), + }, +]) + +const activeSectionDefinition = computed( + () => wordSections.value.find(section => section.key === activeSection.value) ?? wordSections.value[0], +) + +const isTextSection = computed(() => activeSection.value !== 'episodeRules') + +const activeTextValue = computed({ + get: () => (isTextSection.value ? textSectionModels[activeSection.value as TextSectionKey].value : ''), + set: value => { + if (isTextSection.value) textSectionModels[activeSection.value as TextSectionKey].value = value + }, +}) + +const activeTextPlaceholder = computed(() => { + switch (activeSection.value) { + case 'identifiers': + return t('setting.words.identifiersPlaceholder') + case 'releaseGroups': + return t('setting.words.releaseGroupsPlaceholder') + case 'customization': + return t('setting.words.customizationPlaceholder') + case 'excludeWords': + return t('setting.words.excludeWordsPlaceholder') + default: + return '' + } +}) + +const activeTextHint = computed(() => { + switch (activeSection.value) { + case 'identifiers': + return t('setting.words.identifiersHint') + case 'releaseGroups': + return t('setting.words.releaseGroupsHint') + case 'customization': + return t('setting.words.customizationHint') + case 'excludeWords': + return t('setting.words.excludeWordsHint') + default: + return '' + } +}) + +const activeGuideTitle = computed(() => + activeSection.value === 'identifiers' + ? t('setting.words.formatTitle') + : activeSection.value === 'episodeRules' + ? t('setting.words.episodeFormatRuleGuideTitle') + : t('setting.words.guideTitle'), +) + +const activeGuideContent = computed(() => { + if (activeSection.value === 'identifiers') return t('setting.words.formatContent') + if (activeSection.value === 'episodeRules') return t('setting.words.episodeFormatRuleGuideContent') + return activeTextHint.value +}) + +/** 生成仅供前端拖拽列表使用的稳定规则标识。 */ function createEpisodeRuleLocalId() { return `episode-rule-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` } +/** 将后端规则或空白模板转换为带本地标识的可编辑规则。 */ function createEpisodeRule(rule?: Partial>): EpisodeFormatRule { return { _localId: createEpisodeRuleLocalId(), @@ -38,12 +199,14 @@ function createEpisodeRule(rule?: Partial>): } } +/** 规范化后端返回的集数定位规则列表。 */ function normalizeEpisodeFormatRules( rules: Array> & { _localId?: string }> = [], ) { return rules.map(rule => createEpisodeRule(rule)) } +/** 构建后端保存集数定位规则所需的有序载荷。 */ function buildEpisodeFormatRulePayload() { return episodeFormatRules.value.map((rule, index) => ({ name: rule.name, @@ -54,441 +217,708 @@ function buildEpisodeFormatRulePayload() { })) } -// 添加集数定位规则 +/** 将集数定位规则序列化,用于判断当前内容是否有未保存修改。 */ +function serializeEpisodeFormatRules() { + return JSON.stringify(buildEpisodeFormatRulePayload()) +} + +/** 统计多行词表中非空配置的数量。 */ +function countConfiguredLines(value: string) { + return value.split('\n').filter(line => line.trim().length > 0).length +} + +/** 返回指定词表分类当前配置条目数。 */ +function getSectionCount(section: WordSectionKey) { + return section === 'episodeRules' + ? episodeFormatRules.value.length + : countConfiguredLines(textSectionModels[section].value) +} + +/** 判断指定词表分类是否存在未保存修改。 */ +function isSectionDirty(section: WordSectionKey) { + return section === 'episodeRules' + ? serializeEpisodeFormatRules() !== savedEpisodeRules.value + : textSectionModels[section].value !== savedTextValues[section] +} + +const activeSectionDirty = computed(() => isSectionDirty(activeSection.value)) +const activeSectionCount = computed(() => getSectionCount(activeSection.value)) +const totalConfiguredEntries = computed(() => + wordSections.value.reduce((total, section) => total + getSectionCount(section.key), 0), +) + +/** 切换当前正在编辑的词表分类,并收起帮助内容。 */ +function selectSection(section: WordSectionKey) { + activeSection.value = section + expandedHelp.value = null +} + +/** 新增一条空白集数定位规则并滚动到规则编辑分类。 */ function addEpisodeRule() { episodeFormatRules.value.push(createEpisodeRule()) + activeSection.value = 'episodeRules' } -// 自定义识别词 -const customIdentifiers = ref('') +/** 删除指定位置的集数定位规则。 */ +function deleteEpisodeRule(index: number) { + episodeFormatRules.value.splice(index, 1) +} -// 自定义制作组 -const customReleaseGroups = ref('') - -// 自定义占位符 -const customization = ref('') - -// 文件整理屏蔽词 -const transferExcludeWords = ref('') - -// 查询已设置的识别词 -async function queryCustomIdentifiers() { +/** 查询一个多行词表配置,并同步其已保存快照。 */ +async function queryTextSection(section: TextSectionKey) { try { - const result: { [key: string]: any } = await api.get('system/setting/CustomIdentifiers') - if (result && result.data && result.data.value) customIdentifiers.value = result.data.value.join('\n') + const result: { [key: string]: any } = await api.get(textSectionSettings.value[section].endpoint) + const value = Array.isArray(result?.data?.value) ? result.data.value.join('\n') : '' + textSectionModels[section].value = value + savedTextValues[section] = value } catch (error) { console.log(error) } } -// 查询已设置的制作组 -async function queryCustomReleaseGroups() { +/** 保存一个多行词表配置,并在成功后更新已保存快照。 */ +async function saveTextSection(section: TextSectionKey) { + const setting = textSectionSettings.value[section] + try { - const result: { [key: string]: any } = await api.get('system/setting/CustomReleaseGroups') - if (result && result.data && result.data.value) customReleaseGroups.value = result.data.value.join('\n') + const value = textSectionModels[section].value + const result: { [key: string]: any } = await api.post(setting.endpoint, value.split('\n')) + + if (result.success) { + savedTextValues[section] = value + $toast.success(setting.successMessage) + return true + } + + $toast.error(setting.failedMessage) } catch (error) { console.log(error) + $toast.error(setting.failedMessage) } + + return false } -// 查询已设置的自定义占位符 -async function queryCustomization() { - try { - const result: { [key: string]: any } = await api.get('system/setting/Customization') - if (result && result.data && result.data.value) customization.value = result.data?.value.join('\n') - } catch (error) { - console.log(error) - } -} - -// 查询已设置的屏蔽词 -async function queryTransferExcludeWords() { - try { - const result: { [key: string]: any } = await api.get('system/setting/TransferExcludeWords') - if (result && result.data && result.data.value) transferExcludeWords.value = result.data?.value.join('\n') - } catch (error) { - console.log(error) - } -} - -// 保存用户设置的识别词 -async function saveCustomIdentifiers() { - try { - const result: { [key: string]: any } = await api.post( - 'system/setting/CustomIdentifiers', - customIdentifiers.value.split('\n'), - ) - - if (result.success) $toast.success(t('setting.words.identifierSaveSuccess')) - else $toast.error(t('setting.words.identifierSaveFailed')) - } catch (error) { - console.log(error) - } -} - -// 保存自定义制作组 -async function saveCustomReleaseGroups() { - try { - const result: { [key: string]: any } = await api.post( - 'system/setting/CustomReleaseGroups', - customReleaseGroups.value.split('\n'), - ) - - if (result.success) $toast.success(t('setting.words.releaseGroupSaveSuccess')) - else $toast.error(t('setting.words.releaseGroupSaveFailed')) - } catch (error) { - console.log(error) - } -} - -// 保存自定义占位符 -async function saveCustomization() { - try { - const result: { [key: string]: any } = await api.post( - 'system/setting/Customization', - customization.value.split('\n'), - ) - - if (result.success) $toast.success(t('setting.words.customizationSaveSuccess')) - else $toast.error(t('setting.words.customizationSaveFailed')) - } catch (error) { - console.log(error) - } -} - -// 保存文件整理屏蔽词 -async function saveTransferExcludeWords() { - try { - const result: { [key: string]: any } = await api.post( - 'system/setting/TransferExcludeWords', - transferExcludeWords.value.split('\n'), - ) - - if (result.success) $toast.success(t('setting.words.excludeWordsSaveSuccess')) - else $toast.error(t('setting.words.excludeWordsSaveFailed')) - } catch (error) { - console.log(error) - } -} - -// 查询集数定位规则 +/** 查询集数定位规则,并同步其已保存快照。 */ async function queryEpisodeFormatRules() { try { const result: { [key: string]: any } = await api.get('system/setting/public/EpisodeFormatRuleTable') - if (result && result.data && result.data.value) { - episodeFormatRules.value = normalizeEpisodeFormatRules(result.data.value) - } else { - episodeFormatRules.value = [] - } + episodeFormatRules.value = normalizeEpisodeFormatRules(result?.data?.value ?? []) + savedEpisodeRules.value = serializeEpisodeFormatRules() } 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 + return false } } 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) { + episodeFormatRules.value.forEach((rule, index) => { + rule.order = payload[index].order + rule.min_file_size_mb = payload[index].min_file_size_mb + }) + savedEpisodeRules.value = serializeEpisodeFormatRules() $toast.success(t('setting.words.episodeFormatRuleSaveSuccess')) - queryEpisodeFormatRules() - } else { - $toast.error(result.message || t('setting.words.episodeFormatRuleSaveFailed')) + return true } + + $toast.error(result.message || t('setting.words.episodeFormatRuleSaveFailed')) } catch (error) { console.log(error) $toast.error(t('setting.words.episodeFormatRuleSaveFailed')) } + + return false } -// 删除集数定位规则 -function deleteEpisodeRule(index: number) { - episodeFormatRules.value.splice(index, 1) +/** 保存当前正在编辑的词表分类。 */ +async function saveActiveSection() { + if (saving.value) return + + saving.value = true + try { + if (activeSection.value === 'episodeRules') await saveEpisodeFormatRules() + else await saveTextSection(activeSection.value) + } finally { + saving.value = false + } } -// 拖拽结束 -function onEpisodeRuleDragEnd() { - saveEpisodeFormatRules() +/** 将当前分类恢复为最近一次成功加载或保存的内容。 */ +function resetActiveSection() { + if (activeSection.value === 'episodeRules') { + const savedRules = JSON.parse(savedEpisodeRules.value) as Array> + episodeFormatRules.value = normalizeEpisodeFormatRules(savedRules) + return + } + + textSectionModels[activeSection.value].value = savedTextValues[activeSection.value] +} + +/** 拖拽调整规则顺序后沿用原有行为立即保存。 */ +async function onEpisodeRuleDragEnd() { + if (saving.value) return + + saving.value = true + try { + await saveEpisodeFormatRules() + } finally { + saving.value = false + } } onMounted(() => { - queryCustomIdentifiers() - queryCustomReleaseGroups() - queryCustomization() - queryTransferExcludeWords() - queryEpisodeFormatRules() + Promise.all([ + queryTextSection('identifiers'), + queryTextSection('releaseGroups'), + queryTextSection('customization'), + queryTextSection('excludeWords'), + queryEpisodeFormatRules(), + ]) })