feat: enhance recognition and rule testing features

- Updated maxWidth for NameTestView and RuleTestView dialogs to improve layout.
- Added new localization strings for recognition and rule testing in English, Simplified Chinese, and Traditional Chinese.
- Refactored NameTestView to include detailed analysis steps and summary items for better user feedback.
- Improved error handling and loading states in both NameTestView and RuleTestView.
- Redesigned form layouts for NameTestView and RuleTestView to enhance user experience and accessibility.
- Added computed properties for better data handling and display in the recognition and rule testing processes.
This commit is contained in:
jxxghp
2026-07-15 09:11:27 +08:00
parent c0f3814560
commit ea892a31c0
7 changed files with 1171 additions and 103 deletions
+22
View File
@@ -1425,6 +1425,28 @@ export interface FilterRuleGroup {
category?: string
}
// 规则测试结果
export interface RuleTestData {
// 原始标题
title?: string
// 原始副标题
subtitle?: string
// 规则组名称
rulegroup_name?: string
// 规则组详情
rulegroup?: FilterRuleGroup | null
// 识别元数据
meta_info?: MetaInfo | null
// 媒体信息
media_info?: MediaInfo | null
// 种子信息
torrent_info?: TorrentInfo | null
// 优先级
priority?: number | null
// 是否命中过滤规则
matched?: boolean
}
// 订阅下载文件详情
export interface SubscribeDownloadFileInfo {
// 种子名称
+2 -1
View File
@@ -49,7 +49,7 @@ export function useShortcutTools() {
icon: 'mdi-text-recognition',
dialog: 'nameTest',
component: NameTestView,
maxWidth: '45rem',
maxWidth: '65rem',
titleText: t('shortcut.recognition.title'),
},
{
@@ -58,6 +58,7 @@ export function useShortcutTools() {
icon: 'mdi-filter-cog',
dialog: 'ruleTest',
component: RuleTestView,
maxWidth: '65rem',
titleText: t('shortcut.rule.subtitle'),
},
{
+71
View File
@@ -1536,6 +1536,39 @@ export default {
recognizeAgain: 'Recognize Again',
title: 'Title',
subtitle: 'Subtitle',
requestFailed: 'Recognition request failed',
inputTitle: 'Test Input',
inputSubtitle: 'Enter a torrent or file name to inspect the recognition breakdown',
unrecognized: 'No media recognized',
waitingResult: 'Waiting for recognition result',
analysisTitle: 'Analysis Flow',
analysisSubtitle: 'Title preprocessing, meta info, and media match',
summary: {
year: 'Year',
episode: 'Episode',
type: 'Type',
source: 'Source',
},
steps: {
original: {
title: 'Original Title',
caption: 'The raw string entering recognition',
},
words: {
title: 'Custom Words',
caption: 'Applied custom recognition words',
value: '{count} matched',
none: 'No custom words applied',
},
meta: {
title: 'Meta Info',
caption: 'Parsed name, episodes, and resource terms',
},
media: {
title: 'Media Match',
caption: 'Final matched media source',
},
},
},
netTest: {
notTested: 'Not Tested',
@@ -1549,8 +1582,46 @@ export default {
title: 'Title',
subtitle: 'Subtitle',
ruleGroup: 'Rule Group',
ruleGroupPlaceholder: 'Please select',
priority: 'Priority: {value}',
noPriorityRule: 'No priority rule matched!',
requestFailed: 'Rule test request failed',
inputTitle: 'Rule Test',
inputSubtitle: 'Select a rule group to inspect filter matching and priority',
waitingResult: 'Waiting for rule test result',
matched: 'Filter rule matched',
priorityLabel: 'Priority',
analysisTitle: 'Filter Flow',
analysisSubtitle: 'Rule group, media recognition, filter match, and priority order',
ruleCount: '{count} rules',
summary: {
priority: 'Priority',
ruleGroup: 'Rule Group',
ruleCount: 'Rules',
media: 'Media',
},
steps: {
group: {
title: 'Rule Group',
caption: 'Current group contains {count} priority rules',
empty: 'No rules in this group',
},
media: {
title: 'Media Recognition',
caption: 'Recognize media context before filtering',
none: 'No media recognized',
},
filter: {
title: 'Filter Match',
matched: 'Resource passes the filter rules',
pending: 'No match result returned',
},
priority: {
title: 'Priority Result',
caption: 'Converted priority for the matched resource',
empty: 'No priority generated',
},
},
},
setting: {
about: {
+71
View File
@@ -1529,6 +1529,39 @@ export default {
recognizeAgain: '重新识别',
title: '标题',
subtitle: '副标题',
requestFailed: '识别请求失败',
inputTitle: '测试输入',
inputSubtitle: '输入种子名或文件名,查看媒体识别拆解结果',
unrecognized: '未识别到媒体',
waitingResult: '等待识别结果',
analysisTitle: '解析链路',
analysisSubtitle: '标题预处理、元信息和媒体匹配结果',
summary: {
year: '年份',
episode: '季集',
type: '类型',
source: '数据源',
},
steps: {
original: {
title: '原始标题',
caption: '进入识别流程的原始字符串',
},
words: {
title: '识别词',
caption: '自定义识别词应用情况',
value: '命中 {count} 条',
none: '未应用识别词',
},
meta: {
title: '元信息',
caption: '解析出的名称、季集和资源信息',
},
media: {
title: '媒体匹配',
caption: '最终匹配到的媒体数据源',
},
},
},
netTest: {
notTested: '未测试',
@@ -1542,8 +1575,46 @@ export default {
title: '标题',
subtitle: '副标题',
ruleGroup: '规则组',
ruleGroupPlaceholder: '请选择',
priority: '优先级:{value}',
noPriorityRule: '未命中任何优先级规则!',
requestFailed: '规则测试请求失败',
inputTitle: '规则测试',
inputSubtitle: '选择规则组后,查看过滤命中和优先级结果',
waitingResult: '等待规则测试结果',
matched: '命中过滤规则',
priorityLabel: '优先级',
analysisTitle: '过滤链路',
analysisSubtitle: '规则组、媒体识别、过滤命中和优先级排序',
ruleCount: '{count} 条',
summary: {
priority: '优先级',
ruleGroup: '规则组',
ruleCount: '规则数',
media: '媒体',
},
steps: {
group: {
title: '规则组',
caption: '当前规则组包含 {count} 条优先级规则',
empty: '规则组暂无规则',
},
media: {
title: '媒体识别',
caption: '过滤前先识别媒体上下文',
none: '未识别到媒体',
},
filter: {
title: '过滤命中',
matched: '资源符合过滤规则',
pending: '未得到命中结果',
},
priority: {
title: '排序结果',
caption: '命中资源换算后的优先级',
empty: '未生成优先级',
},
},
},
setting: {
about: {
+71
View File
@@ -1528,6 +1528,39 @@ export default {
recognizeAgain: '重新識別',
title: '標題',
subtitle: '副標題',
requestFailed: '識別請求失敗',
inputTitle: '測試輸入',
inputSubtitle: '輸入種子名或檔案名,查看媒體識別拆解結果',
unrecognized: '未識別到媒體',
waitingResult: '等待識別結果',
analysisTitle: '解析鏈路',
analysisSubtitle: '標題預處理、元資訊和媒體匹配結果',
summary: {
year: '年份',
episode: '季集',
type: '類型',
source: '資料源',
},
steps: {
original: {
title: '原始標題',
caption: '進入識別流程的原始字串',
},
words: {
title: '識別詞',
caption: '自訂識別詞應用情況',
value: '命中 {count} 條',
none: '未套用識別詞',
},
meta: {
title: '元資訊',
caption: '解析出的名稱、季集和資源資訊',
},
media: {
title: '媒體匹配',
caption: '最終匹配到的媒體資料源',
},
},
},
netTest: {
notTested: '未測試',
@@ -1541,8 +1574,46 @@ export default {
title: '標題',
subtitle: '副標題',
ruleGroup: '規則組',
ruleGroupPlaceholder: '請選擇',
priority: '優先級:{value}',
noPriorityRule: '未命中任何優先級規則!',
requestFailed: '規則測試請求失敗',
inputTitle: '規則測試',
inputSubtitle: '選擇規則組後,查看過濾命中和優先級結果',
waitingResult: '等待規則測試結果',
matched: '命中過濾規則',
priorityLabel: '優先級',
analysisTitle: '過濾鏈路',
analysisSubtitle: '規則組、媒體識別、過濾命中和優先級排序',
ruleCount: '{count} 條',
summary: {
priority: '優先級',
ruleGroup: '規則組',
ruleCount: '規則數',
media: '媒體',
},
steps: {
group: {
title: '規則組',
caption: '目前規則組包含 {count} 條優先級規則',
empty: '規則組暫無規則',
},
media: {
title: '媒體識別',
caption: '過濾前先識別媒體上下文',
none: '未識別到媒體',
},
filter: {
title: '過濾命中',
matched: '資源符合過濾規則',
pending: '未取得命中結果',
},
priority: {
title: '排序結果',
caption: '命中資源換算後的優先級',
empty: '未產生優先級',
},
},
},
setting: {
about: {
+467 -40
View File
@@ -1,11 +1,22 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import type { Context } from '@/api/types'
import MediaInfoCard from '@/components/cards/MediaInfoCard.vue'
import { useI18n } from 'vue-i18n'
interface SummaryItem {
icon: string
label: string
value: string
}
interface AnalysisStep {
icon: string
title: string
value: string
}
// 国际化
const { t } = useI18n()
@@ -27,64 +38,480 @@ const nameTestText = ref(t('nameTest.recognize'))
// 是否显示结果
const showResult = ref(false)
// 调用API识别
// 请求错误提示
const nameTestError = ref('')
const metaInfo = computed(() => nameTestResult.value?.meta_info)
const mediaInfo = computed(() => nameTestResult.value?.media_info)
const isRecognized = computed(() => Boolean(metaInfo.value?.name))
const resultTitle = computed(() => mediaInfo.value?.title || metaInfo.value?.name || t('nameTest.unrecognized'))
const resultSubtitle = computed(() => {
const parts = [mediaInfo.value?.year || metaInfo.value?.year, mediaInfo.value?.type || metaInfo.value?.type]
if (metaInfo.value?.season_episode) parts.push(metaInfo.value.season_episode)
return parts.filter(Boolean).join(' · ') || t('nameTest.waitingResult')
})
const resourceChips = computed(() => {
return [
metaInfo.value?.web_source,
metaInfo.value?.edition,
metaInfo.value?.resource_pix,
metaInfo.value?.video_encode,
metaInfo.value?.audio_encode,
metaInfo.value?.resource_team,
].filter(Boolean) as string[]
})
const summaryItems = computed<SummaryItem[]>(() => [
{
icon: 'mdi-calendar-range',
label: t('nameTest.summary.year'),
value: mediaInfo.value?.year || metaInfo.value?.year || '-',
},
{
icon: 'mdi-format-list-numbered',
label: t('nameTest.summary.episode'),
value: metaInfo.value?.season_episode || metaInfo.value?.episode || '-',
},
{
icon: 'mdi-shape-outline',
label: t('nameTest.summary.type'),
value: mediaInfo.value?.type || metaInfo.value?.type || '-',
},
{
icon: 'mdi-database-search',
label: t('nameTest.summary.source'),
value: mediaInfo.value?.source || mediaInfo.value?.tmdb_id?.toString() || mediaInfo.value?.douban_id || '-',
},
])
const analysisSteps = computed<AnalysisStep[]>(() => [
{
icon: 'mdi-file-document-outline',
title: t('nameTest.steps.original.title'),
value: metaInfo.value?.org_string || nameTestForm.title || '-',
},
{
icon: 'mdi-puzzle-check-outline',
title: t('nameTest.steps.meta.title'),
value: [
metaInfo.value?.name,
metaInfo.value?.season_episode,
metaInfo.value?.resource_term,
metaInfo.value?.release_group,
]
.filter(Boolean)
.join(' · ') || '-',
},
{
icon: 'mdi-movie-search-outline',
title: t('nameTest.steps.media.title'),
value: mediaInfo.value?.tmdb_id
? `TMDB ${mediaInfo.value.tmdb_id}`
: mediaInfo.value?.douban_id
? `Douban ${mediaInfo.value.douban_id}`
: mediaInfo.value?.title || '-',
},
])
/** 将 TMDB 原始图片地址转换为弹窗内更轻量的海报缩略图。 */
function getPosterImage(url = '') {
if (!url) return ''
return url.replace('original', 'w500')
}
/** 调用媒体识别接口并刷新解析工作台。 */
async function nameTest() {
if (!nameTestForm.title) return
try {
nameTestLoading.value = true
nameTestText.value = t('nameTest.recognizing')
nameTestError.value = ''
showResult.value = false
nameTestResult.value = await api.get('media/recognize', {
nameTestResult.value = await api.get<Context, Context>('media/recognize', {
params: {
title: nameTestForm.title,
subtitle: nameTestForm.subtitle,
},
})
nameTestLoading.value = false
nameTestText.value = t('nameTest.recognizeAgain')
showResult.value = true
} catch (error) {
console.error(error)
nameTestError.value = error instanceof Error ? error.message : t('nameTest.requestFailed')
} finally {
nameTestLoading.value = false
}
}
</script>
<template>
<VForm @submit.prevent="() => {}">
<VRow class="pt-2">
<VCol cols="12">
<VTextField
v-model="nameTestForm.title"
:label="t('nameTest.title')"
:rules="[requiredValidator]"
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="nameTestForm.subtitle"
:label="t('nameTest.subtitle')"
rows="2"
auto-grow
prepend-inner-icon="mdi-subtitles"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" class="text-center">
<VBtn :disabled="nameTestLoading" @click="nameTest">
<template #prepend>
<VIcon icon="mdi-text-recognition" />
</template>
{{ nameTestText }}
</VBtn>
</VCol>
</VRow>
</VForm>
<VExpandTransition>
<div v-show="showResult">
<MediaInfoCard :context="nameTestResult" />
<div class="shortcut-workbench">
<section class="shortcut-panel shortcut-input-panel">
<div class="panel-heading">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ t('nameTest.inputTitle') }}
</div>
<div class="text-caption text-medium-emphasis">
{{ t('nameTest.inputSubtitle') }}
</div>
</div>
<VIcon icon="mdi-text-recognition" color="primary" />
</div>
<VForm validate-on="submit lazy" @submit.prevent="nameTest">
<VRow class="shortcut-form">
<VCol cols="12" class="shortcut-form-col">
<VTextField
v-model="nameTestForm.title"
:label="t('nameTest.title')"
:rules="[requiredValidator]"
hide-details
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VTextarea
v-model="nameTestForm.subtitle"
:label="t('nameTest.subtitle')"
rows="2"
auto-grow
prepend-inner-icon="mdi-subtitles"
/>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VBtn block type="submit" :disabled="nameTestLoading" :loading="nameTestLoading">
<template #prepend>
<VIcon icon="mdi-movie-search-outline" />
</template>
{{ nameTestText }}
</VBtn>
</VCol>
</VRow>
</VForm>
<VAlert
v-if="nameTestError"
class="mt-4"
density="comfortable"
icon="mdi-alert-circle-outline"
type="error"
variant="tonal"
>
{{ nameTestError }}
</VAlert>
</section>
<section class="shortcut-panel shortcut-result-panel">
<div v-if="showResult" class="result-stack">
<div class="media-result-card" :class="{ 'media-result-card--failed': !isRecognized }">
<div v-if="mediaInfo?.poster_path" class="poster-frame">
<VImg :src="getPosterImage(mediaInfo.poster_path)" aspect-ratio="2/3" cover>
<template #placeholder>
<VSkeletonLoader class="h-100 w-100" />
</template>
</VImg>
</div>
<div v-else class="poster-frame poster-frame--empty">
<VIcon :icon="isRecognized ? 'mdi-movie-open-check' : 'mdi-movie-open-remove'" size="32" />
</div>
<div class="min-w-0">
<div class="result-heading">
<VIcon :icon="isRecognized ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'" color="primary" />
<span class="result-title-text text-subtitle-1 font-weight-medium text-truncate">{{ resultTitle }}</span>
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ resultSubtitle }}
</div>
<div class="chip-row mt-3">
<VChip
v-for="chip in resourceChips"
:key="chip"
class="result-chip"
color="primary"
size="small"
variant="tonal"
>
{{ chip }}
</VChip>
</div>
</div>
</div>
<div class="summary-grid">
<div v-for="item in summaryItems" :key="item.label" class="summary-tile">
<VIcon :icon="item.icon" size="18" class="summary-icon" />
<div class="min-w-0">
<div class="text-caption text-medium-emphasis">
{{ item.label }}
</div>
<div class="text-body-2 font-weight-medium text-truncate">
{{ item.value }}
</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<VIcon icon="mdi-movie-search-outline" size="36" />
<div class="text-body-2 text-medium-emphasis">
{{ t('nameTest.waitingResult') }}
</div>
</div>
</section>
</div>
<section v-if="showResult" class="shortcut-panel analysis-panel mt-4">
<div class="panel-heading">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ t('nameTest.analysisTitle') }}
</div>
<div class="text-caption text-medium-emphasis">
{{ t('nameTest.analysisSubtitle') }}
</div>
</div>
</div>
</VExpandTransition>
<div class="analysis-flow">
<div v-for="step in analysisSteps" :key="step.title" class="analysis-step">
<VIcon :icon="step.icon" color="primary" size="20" />
<div class="min-w-0">
<div class="text-body-2 font-weight-medium">
{{ step.title }}
</div>
<div class="text-body-2 step-value">
{{ step.value }}
</div>
</div>
</div>
</div>
</section>
<section v-if="showResult && metaInfo?.apply_words?.length" class="shortcut-panel applied-words-panel mt-4">
<div class="panel-heading">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ t('nameTest.steps.words.title') }}
</div>
</div>
</div>
<div class="words-list">
<div v-if="metaInfo.org_string" class="word-row word-row--source">
<span class="text-caption text-medium-emphasis">{{ t('nameTest.steps.original.title') }}</span>
<span class="word-text">{{ metaInfo.org_string }}</span>
</div>
<div v-for="word in metaInfo.apply_words" :key="word" class="word-row">
<span class="word-text">{{ word }}</span>
</div>
</div>
</section>
</template>
<style scoped>
.shortcut-workbench {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 1rem;
padding-block-start: 0.5rem;
}
.shortcut-panel {
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
padding: 1rem;
}
.panel-heading {
display: flex;
gap: 0.75rem;
align-items: flex-start;
justify-content: space-between;
margin-block-end: 1rem;
}
.shortcut-form {
margin: 0;
}
.shortcut-form-col {
padding-inline: 0;
}
.shortcut-form-col:first-child {
padding-block-start: 0;
}
.shortcut-form-col:last-child {
padding-block-end: 0;
}
.result-stack {
display: grid;
gap: 0.9rem;
}
.media-result-card {
display: grid;
grid-template-columns: 5rem minmax(0, 1fr);
gap: 0.85rem;
align-items: center;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-primary), 0.08);
padding: 0.75rem;
}
.media-result-card--failed {
background: rgba(var(--v-theme-error), 0.08);
}
.poster-frame {
overflow: hidden;
border: var(--app-surface-border);
border-radius: var(--app-control-radius);
aspect-ratio: 2 / 3;
background: rgba(var(--v-theme-surface-variant), 0.35);
}
.poster-frame--empty {
display: grid;
place-items: center;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.result-chip {
max-inline-size: 100%;
}
.result-heading {
display: flex;
gap: 0.5rem;
align-items: center;
}
.result-title-text {
min-inline-size: 0;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.summary-tile {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.5rem;
align-items: center;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface-variant), 0.22);
padding: 0.65rem;
}
.summary-icon {
color: rgb(var(--v-theme-primary));
}
.empty-state {
display: grid;
min-block-size: 14rem;
place-items: center;
align-content: center;
gap: 0.75rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
text-align: center;
}
.analysis-flow {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.6rem;
}
.analysis-step {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.55rem;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface-variant), 0.2);
padding: 0.7rem;
}
.step-value {
overflow: hidden;
margin-block-start: 0.35rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.words-list {
display: grid;
gap: 0.5rem;
}
.word-row {
display: grid;
gap: 0.25rem;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface-variant), 0.2);
padding: 0.65rem;
}
.word-row--source {
background: rgba(var(--v-theme-primary), 0.08);
}
.word-text {
overflow-wrap: anywhere;
word-break: break-word;
}
@media (max-width: 760px) {
.shortcut-workbench,
.analysis-flow {
grid-template-columns: minmax(0, 1fr);
}
.summary-grid {
grid-template-columns: minmax(0, 1fr);
}
.result-heading {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.35rem;
align-items: start;
}
.result-title-text {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
}
@media (max-width: 420px) {
.shortcut-panel {
padding: 0.8rem;
}
.media-result-card {
grid-template-columns: 4.25rem minmax(0, 1fr);
}
}
</style>
+467 -62
View File
@@ -1,21 +1,44 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { requiredValidator } from '@/@validators'
import api from '@/api'
import { FilterRuleGroup } from '@/api/types'
import type { ApiResponse, FilterRuleGroup, RuleTestData } from '@/api/types'
import { useI18n } from 'vue-i18n'
interface SummaryItem {
icon: string
label: string
value: string
}
interface AnalysisStep {
icon: string
title: string
value: string
}
interface FormValidationResult {
valid: boolean
}
interface RuleTestFormRef {
validate: () => Promise<FormValidationResult>
}
// 国际化
const { t } = useI18n()
// 规则测试表单引用
const ruleTestFormRef = ref<RuleTestFormRef>()
// 识别结果
const ruleTestResult = ref('')
const ruleTestResponse = ref<ApiResponse<RuleTestData>>()
// 名称识别表单
const ruleTestForm = reactive({
title: null,
subtitle: null,
rulegroup: null,
title: '',
subtitle: '',
rulegroup: '',
})
// 识别按钮状态
@@ -27,47 +50,145 @@ const ruleTestText = ref(t('ruleTest.test'))
// 是否显示结果
const showResult = ref(false)
// 请求错误提示
const ruleTestError = ref('')
// 所有规则组列表
const filterRuleGroups = ref<FilterRuleGroup[]>([])
// 规则组加载状态
const filterRuleGroupLoading = ref(false)
// 规则组选项
const filterRuleGroupItems = computed(() => {
return filterRuleGroups.value.map(item => ({ title: item.name, value: item.name }))
return [
{ title: t('ruleTest.ruleGroupPlaceholder'), value: '' },
...filterRuleGroups.value.map(item => ({ title: item.name, value: item.name })),
]
})
const selectedRuleGroup = computed(() => filterRuleGroups.value.find(item => item.name === ruleTestForm.rulegroup))
const ruleTestData = computed(() => ruleTestResponse.value?.data)
const metaInfo = computed(() => ruleTestData.value?.meta_info)
const mediaInfo = computed(() => ruleTestData.value?.media_info)
const torrentInfo = computed(() => ruleTestData.value?.torrent_info)
const isMatched = computed(() => Boolean(ruleTestResponse.value?.success && ruleTestData.value?.matched))
const resultIcon = computed(() => (isMatched.value ? 'mdi-filter-check-outline' : 'mdi-filter-remove-outline'))
const resultColor = computed(() => (isMatched.value ? 'success' : 'warning'))
const priorityText = computed(() => {
const priority = ruleTestData.value?.priority
return typeof priority === 'number' ? priority.toString() : '-'
})
const resultTitle = computed(() => {
if (isMatched.value) return t('ruleTest.matched')
return ruleTestResponse.value?.message || t('ruleTest.noPriorityRule')
})
const resultSubtitle = computed(() => {
const parts = [
mediaInfo.value?.title || metaInfo.value?.name || ruleTestForm.title,
mediaInfo.value?.year || metaInfo.value?.year,
metaInfo.value?.season_episode,
]
return parts.filter(Boolean).join(' · ') || t('ruleTest.waitingResult')
})
const ruleCount = computed(() => countRules(ruleTestData.value?.rulegroup?.rule_string || selectedRuleGroup.value?.rule_string))
const resourceChips = computed(() => {
return [
mediaInfo.value?.type || metaInfo.value?.type,
mediaInfo.value?.category,
metaInfo.value?.resource_pix,
metaInfo.value?.edition,
metaInfo.value?.resource_team,
].filter(Boolean) as string[]
})
const summaryItems = computed<SummaryItem[]>(() => [
{
icon: 'mdi-sort-numeric-descending',
label: t('ruleTest.summary.priority'),
value: priorityText.value,
},
{
icon: 'mdi-filter-cog-outline',
label: t('ruleTest.summary.ruleGroup'),
value: ruleTestData.value?.rulegroup_name || ruleTestForm.rulegroup || '-',
},
{
icon: 'mdi-format-list-numbered',
label: t('ruleTest.summary.ruleCount'),
value: ruleCount.value ? t('ruleTest.ruleCount', { count: ruleCount.value }) : '-',
},
{
icon: 'mdi-movie-search-outline',
label: t('ruleTest.summary.media'),
value: mediaInfo.value?.title || metaInfo.value?.name || '-',
},
])
const analysisSteps = computed<AnalysisStep[]>(() => [
{
icon: 'mdi-filter-settings-outline',
title: t('ruleTest.steps.group.title'),
value: ruleTestData.value?.rulegroup_name || ruleTestForm.rulegroup || '-',
},
{
icon: 'mdi-movie-search-outline',
title: t('ruleTest.steps.media.title'),
value: mediaInfo.value?.title || metaInfo.value?.name || t('ruleTest.steps.media.none'),
},
{
icon: resultIcon.value,
title: t('ruleTest.steps.filter.title'),
value:
ruleTestResponse.value?.message ||
(isMatched.value ? torrentInfo.value?.title || ruleTestForm.title : t('ruleTest.steps.filter.pending')),
},
{
icon: 'mdi-sort-numeric-descending',
title: t('ruleTest.steps.priority.title'),
value: priorityText.value,
},
])
// 加载规则组
/** 统计规则组串中的优先级规则数量。 */
function countRules(ruleString = '') {
return ruleString.split('>').filter(item => item.trim()).length
}
/** 加载用户过滤规则组并填充规则组选择框。 */
async function queryFilterRuleGroups() {
try {
filterRuleGroupLoading.value = true
const result: { [key: string]: any } = await api.get('system/setting/UserFilterRuleGroups')
filterRuleGroups.value = result.data?.value ?? []
} catch (error) {
console.log(error)
} finally {
filterRuleGroupLoading.value = false
}
}
// 调用API识别
/** 调用规则测试接口并刷新解析工作台。 */
async function ruleTest() {
if (!ruleTestForm.title || !ruleTestForm.rulegroup) return
const validation = await ruleTestFormRef.value?.validate()
if (!validation?.valid) return
try {
ruleTestLoading.value = true
ruleTestText.value = t('ruleTest.testing')
ruleTestError.value = ''
showResult.value = false
const result: { [key: string]: any } = await api.get('system/ruletest', {
ruleTestResponse.value = await api.get<ApiResponse<RuleTestData>, ApiResponse<RuleTestData>>('system/ruletest', {
params: {
title: ruleTestForm.title,
subtitle: ruleTestForm.subtitle,
rulegroup_name: ruleTestForm.rulegroup,
},
})
if (result.success) ruleTestResult.value = t('ruleTest.priority', { value: result.data.priority })
else ruleTestResult.value = t('ruleTest.noPriorityRule')
ruleTestLoading.value = false
ruleTestText.value = t('ruleTest.testAgain')
showResult.value = true
} catch (error) {
console.error(error)
ruleTestError.value = error instanceof Error ? error.message : t('ruleTest.requestFailed')
} finally {
ruleTestLoading.value = false
}
}
@@ -77,52 +198,336 @@ onMounted(() => {
</script>
<template>
<VForm @submit.prevent="() => {}">
<VRow class="pt-2">
<VCol cols="12" md="8">
<VTextField
v-model="ruleTestForm.title"
:label="t('ruleTest.title')"
:rules="[requiredValidator]"
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="12" md="4">
<VSelect
v-model="ruleTestForm.rulegroup"
:label="t('ruleTest.ruleGroup')"
:items="filterRuleGroupItems"
prepend-inner-icon="mdi-filter"
/>
</VCol>
<VCol cols="12">
<VTextarea
v-model="ruleTestForm.subtitle"
:label="t('ruleTest.subtitle')"
rows="2"
auto-grow
prepend-inner-icon="mdi-subtitles"
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" class="text-center">
<VBtn :disabled="ruleTestLoading" @click="ruleTest">
<template #prepend>
<VIcon icon="mdi-filter-check-outline" />
</template>
{{ ruleTestText }}
</VBtn>
</VCol>
</VRow>
</VForm>
<VExpandTransition>
<div v-show="showResult">
<VCol>
<VAlert icon="mdi-alert-circle-outline">
{{ ruleTestResult }}
</VAlert>
</VCol>
<div class="shortcut-workbench">
<section class="shortcut-panel shortcut-input-panel">
<div class="panel-heading">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ t('ruleTest.inputTitle') }}
</div>
<div class="text-caption text-medium-emphasis">
{{ t('ruleTest.inputSubtitle') }}
</div>
</div>
<VIcon icon="mdi-filter-cog" color="primary" />
</div>
<VForm ref="ruleTestFormRef" validate-on="submit lazy" @submit.prevent="ruleTest">
<VRow class="shortcut-form">
<VCol cols="12" class="shortcut-form-col">
<VTextField
v-model="ruleTestForm.title"
:label="t('ruleTest.title')"
:rules="[requiredValidator]"
hide-details
prepend-inner-icon="mdi-movie-open"
/>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VSelect
v-model="ruleTestForm.rulegroup"
:items="filterRuleGroupItems"
:label="t('ruleTest.ruleGroup')"
:loading="filterRuleGroupLoading"
:rules="[requiredValidator]"
hide-details
prepend-inner-icon="mdi-filter"
/>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VTextarea
v-model="ruleTestForm.subtitle"
:label="t('ruleTest.subtitle')"
rows="2"
auto-grow
prepend-inner-icon="mdi-subtitles"
/>
</VCol>
<VCol cols="12" class="shortcut-form-col">
<VBtn block type="submit" :disabled="ruleTestLoading" :loading="ruleTestLoading">
<template #prepend>
<VIcon icon="mdi-filter-check-outline" />
</template>
{{ ruleTestText }}
</VBtn>
</VCol>
</VRow>
</VForm>
<VAlert
v-if="ruleTestError"
class="mt-4"
density="comfortable"
icon="mdi-alert-circle-outline"
type="error"
variant="tonal"
>
{{ ruleTestError }}
</VAlert>
</section>
<section class="shortcut-panel shortcut-result-panel">
<div v-if="showResult" class="result-stack">
<div class="rule-result-card" :class="{ 'rule-result-card--matched': isMatched }">
<div class="priority-badge" :class="{ 'priority-badge--matched': isMatched }">
<span class="text-caption text-medium-emphasis">{{ t('ruleTest.priorityLabel') }}</span>
<span class="priority-value">{{ priorityText }}</span>
</div>
<div class="min-w-0">
<div class="result-heading">
<VIcon :icon="resultIcon" :color="resultColor" />
<span class="result-title-text text-subtitle-1 font-weight-medium text-truncate">{{ resultTitle }}</span>
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ resultSubtitle }}
</div>
<div class="chip-row mt-3">
<VChip
v-for="chip in resourceChips"
:key="chip"
color="primary"
size="small"
variant="tonal"
>
{{ chip }}
</VChip>
</div>
</div>
</div>
<div class="summary-grid">
<div v-for="item in summaryItems" :key="item.label" class="summary-tile">
<VIcon :icon="item.icon" size="18" class="summary-icon" />
<div class="min-w-0">
<div class="text-caption text-medium-emphasis">
{{ item.label }}
</div>
<div class="text-body-2 font-weight-medium text-truncate">
{{ item.value }}
</div>
</div>
</div>
</div>
</div>
<div v-else class="empty-state">
<VIcon icon="mdi-filter-search-outline" size="36" />
<div class="text-body-2 text-medium-emphasis">
{{ t('ruleTest.waitingResult') }}
</div>
</div>
</section>
</div>
<section v-if="showResult" class="shortcut-panel analysis-panel mt-4">
<div class="panel-heading">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ t('ruleTest.analysisTitle') }}
</div>
<div class="text-caption text-medium-emphasis">
{{ t('ruleTest.analysisSubtitle') }}
</div>
</div>
</div>
</VExpandTransition>
<div class="analysis-flow">
<div v-for="step in analysisSteps" :key="step.title" class="analysis-step">
<VIcon :icon="step.icon" color="primary" size="20" />
<div class="min-w-0">
<div class="text-body-2 font-weight-medium">
{{ step.title }}
</div>
<div class="text-body-2 step-value">
{{ step.value }}
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.shortcut-workbench {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
gap: 1rem;
padding-block-start: 0.5rem;
}
.shortcut-panel {
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
backdrop-filter: var(--app-grouped-list-backdrop-filter);
background: var(--app-grouped-list-background);
box-shadow: var(--app-surface-shadow);
padding: 1rem;
}
.panel-heading {
display: flex;
gap: 0.75rem;
align-items: flex-start;
justify-content: space-between;
margin-block-end: 1rem;
}
.shortcut-form {
margin: 0;
}
.shortcut-form-col {
padding-inline: 0;
}
.shortcut-form-col:first-child {
padding-block-start: 0;
}
.shortcut-form-col:last-child {
padding-block-end: 0;
}
.result-stack {
display: grid;
gap: 0.9rem;
}
.rule-result-card {
display: grid;
grid-template-columns: 5rem minmax(0, 1fr);
gap: 0.85rem;
align-items: center;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-warning), 0.08);
padding: 0.75rem;
}
.rule-result-card--matched {
background: rgba(var(--v-theme-success), 0.08);
}
.priority-badge {
display: grid;
min-block-size: 5rem;
place-items: center;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface-variant), 0.32);
}
.priority-badge--matched {
border-color: rgba(var(--v-theme-success), 0.42);
background: rgba(var(--v-theme-success), 0.1);
}
.priority-value {
font-size: 1.65rem;
font-weight: 600;
line-height: 1;
}
.chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.result-heading {
display: flex;
gap: 0.5rem;
align-items: center;
}
.result-title-text {
min-inline-size: 0;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.summary-tile {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.5rem;
align-items: center;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface-variant), 0.22);
padding: 0.65rem;
}
.summary-icon {
color: rgb(var(--v-theme-primary));
}
.empty-state {
display: grid;
min-block-size: 14rem;
place-items: center;
align-content: center;
gap: 0.75rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
text-align: center;
}
.analysis-flow {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.6rem;
}
.analysis-step {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0.55rem;
border: var(--app-surface-border);
border-radius: var(--app-surface-radius);
background: rgba(var(--v-theme-surface-variant), 0.2);
padding: 0.7rem;
}
.step-value {
overflow: hidden;
margin-block-start: 0.35rem;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 760px) {
.shortcut-workbench,
.analysis-flow {
grid-template-columns: minmax(0, 1fr);
}
.summary-grid {
grid-template-columns: minmax(0, 1fr);
}
.result-heading {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.35rem;
align-items: start;
}
.result-title-text {
overflow: visible;
text-overflow: clip;
white-space: normal;
}
}
@media (max-width: 420px) {
.shortcut-panel {
padding: 0.8rem;
}
.rule-result-card {
grid-template-columns: minmax(0, 1fr);
}
}
</style>