Compare commits

...

22 Commits

Author SHA1 Message Date
jxxghp
2960e7cfde fix: keep mobile navbar blur above progress dialog 2026-05-20 06:08:15 +08:00
jxxghp
e0ebc35178 fix: 补充媒体详情订阅成功提示 2026-05-20 05:56:08 +08:00
jxxghp
07c9442ac8 fix: 移除集数定位规则启用开关的文字标签
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:36:05 +08:00
jxxghp
ccc820e8d2 fix: 优化集数定位规则UI,新增按钮改为绿色,启用改为Switch开关
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 13:31:13 +08:00
jxxghp
68bb568400 fix: 移除集数定位规则删除确认 2026-05-19 08:43:38 +08:00
jxxghp
13cd214e6d fix: 优化集数定位规则响应式布局 2026-05-19 08:29:52 +08:00
jxxghp
311880bcd3 fix: align downloader path mapping fields 2026-05-19 08:13:03 +08:00
jxxghp
088ebbe0bb fix: adjust downloader path mapping UI 2026-05-19 08:10:55 +08:00
Album
de3523056a feat: 增加手动整理集数定位规则配置并支持智能生成集数定位模板 (#473) 2026-05-19 07:20:23 +08:00
jxxghp
cf139a938e style: 移除资源搜索结果副标题 2026-05-18 19:11:54 +08:00
jxxghp
be2f4d0170 style: 调整智能推荐重试图标 2026-05-18 14:24:18 +08:00
jxxghp
79493665c1 style: 优化资源搜索结果智能推荐按钮 2026-05-18 14:17:06 +08:00
jxxghp
106062da82 style: 统一资源搜索结果抬头按钮样式 2026-05-18 13:58:00 +08:00
jxxghp
50e54e943d 更新 resource.vue 2026-05-18 13:47:12 +08:00
jxxghp
6b811f2250 style: 优化资源搜索结果抬头 2026-05-18 13:26:37 +08:00
jxxghp
fa7f2a6c7c fix: adapt notification template dialog height 2026-05-18 12:13:07 +08:00
jxxghp
e362f3cbdd fix: adapt custom css dialog height on small screens 2026-05-18 11:55:10 +08:00
jxxghp
f4c4d7495f refactor: optimize storage card accent colors and clean up unused directory UI logic 2026-05-18 11:25:00 +08:00
jxxghp
5b850d9464 chore: bump version to 2.12.2 2026-05-18 11:21:36 +08:00
jxxghp
d7f74a3a8a feat: implement dynamic accent color extraction and styling for UI cards with standardized shadow removal 2026-05-18 11:20:58 +08:00
jxxghp
91dbf065db feat: update Ace editor themes to follow system color scheme and standardize dialog UI layouts 2026-05-18 10:07:22 +08:00
jxxghp
1759e666ba feat: add search resource pages setting 2026-05-18 09:48:43 +08:00
27 changed files with 1328 additions and 383 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.12.1",
"version": "2.12.2",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

@@ -22,6 +22,7 @@ code {
%blurry-bg {
position: relative;
isolation: isolate;
box-shadow: 0 1px 3px rgba(0, 0, 0, 4%), 0 1px 2px rgba(0, 0, 0, 2%);
@media (width >= 1280px) and (hover: hover) {

View File

@@ -14,6 +14,10 @@ import modeIniUrl from 'ace-builds/src-noconflict/mode-ini?url'
import themeGithubUrl from 'ace-builds/src-noconflict/theme-github?url'
import themeGithubDarkUrl from 'ace-builds/src-noconflict/theme-github_dark?url'
import themeGithubLightDefaultUrl from 'ace-builds/src-noconflict/theme-github_light_default?url'
import themeChromeUrl from 'ace-builds/src-noconflict/theme-chrome?url'
import themeMonokaiUrl from 'ace-builds/src-noconflict/theme-monokai?url'
@@ -533,6 +537,8 @@ ace.config.setModuleUrl('ace/mode/yaml', modeYamlUrl)
ace.config.setModuleUrl('ace/mode/css', modeCssUrl)
ace.config.setModuleUrl('ace/mode/ini', modeIniUrl)
ace.config.setModuleUrl('ace/theme/github', themeGithubUrl)
ace.config.setModuleUrl('ace/theme/github_dark', themeGithubDarkUrl)
ace.config.setModuleUrl('ace/theme/github_light_default', themeGithubLightDefaultUrl)
ace.config.setModuleUrl('ace/theme/chrome', themeChromeUrl)
ace.config.setModuleUrl('ace/theme/monokai', themeMonokaiUrl)
ace.config.setModuleUrl('ace/mode/base', workerBaseUrl)

View File

@@ -2,8 +2,10 @@
import type { CustomRule } from '@/api/types'
import filter_svg from '@images/svg/filter.svg'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const CustomRuleInfoDialog = defineAsyncComponent(() => import('@/components/dialog/CustomRuleInfoDialog.vue'))
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
// 输入参数
const props = defineProps({
@@ -45,7 +47,12 @@ function onClose() {
</script>
<template>
<VCard variant="tonal" class="app-card-shell" @click="openRuleInfoDialog">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openRuleInfoDialog"
>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
@@ -58,7 +65,7 @@ function onClose() {
<div class="app-card-summary__subtitle text-body-1">{{ props.rule.id }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_svg" contain class="app-card-summary__image" />
<VImg ref="imageRef" :src="filter_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>

View File

@@ -5,8 +5,20 @@ import { nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { storageRemoteDict } from '@/api/constants'
const DEFAULT_DIRECTORY_ACCENT_RGB = '145, 85, 253'
const STORAGE_ACCENT_COLOR_MAP = {
local: '#FFB400',
alipan: '#00A7F2',
u115: '#17B26A',
rclone: '#6675FF',
alist: '#12B8D7',
smb: '#3B82F6',
}
// 国际化
const { t } = useI18n()
const downloadAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
const libraryAccentRgb = ref(DEFAULT_DIRECTORY_ACCENT_RGB)
// 输入参数
const props = defineProps({
@@ -63,6 +75,47 @@ const transferSourceItems = computed(() => [
{ title: t('directory.manualTransfer'), value: 'manual' },
])
function hasKnownStorageType(storageType?: string): storageType is keyof typeof STORAGE_ACCENT_COLOR_MAP {
return !!storageType && Object.prototype.hasOwnProperty.call(STORAGE_ACCENT_COLOR_MAP, storageType)
}
function hexToRgbString(hexColor: string) {
const normalizedColor = hexColor.replace('#', '')
const colorValue = Number.parseInt(normalizedColor, 16)
if (Number.isNaN(colorValue) || normalizedColor.length !== 6) return DEFAULT_DIRECTORY_ACCENT_RGB
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
}
function getCustomStoragePaletteColor(storageType?: string) {
const customStorageIndex = Math.max(Number(storageType?.match(/\d+$/)?.[0] ?? 1) - 1, 0)
const customStorageColors = ['#F97316', '#8B5CF6', '#06B6D4', '#84CC16', '#EC4899', '#14B8A6']
return customStorageColors[customStorageIndex % customStorageColors.length]
}
function getStorageAccentColor(storageType?: string) {
if (hasKnownStorageType(storageType)) return STORAGE_ACCENT_COLOR_MAP[storageType]
// 自定义存储没有固定品牌图标,使用离散调色板,保证连续 custom1/custom2 也能明显区分。
return getCustomStoragePaletteColor(storageType)
}
// 目录卡片用下载存储和媒体库存储两端的图标主色生成轻渐变,体现整理链路的两个存储端点。
const directoryAccentStyle = computed(() => ({
'--app-card-accent-rgb': downloadAccentRgb.value,
'--app-card-accent-end-rgb': libraryAccentRgb.value,
}))
function updateDirectoryAccentColors() {
const downloadStorage = props.directory.storage
const libraryStorage = props.directory.library_storage || props.directory.storage
downloadAccentRgb.value = hexToRgbString(getStorageAccentColor(downloadStorage))
libraryAccentRgb.value = hexToRgbString(getStorageAccentColor(libraryStorage))
}
// 监控模式下拉字典
const MonitorModeItems = computed(() => [
{ title: t('directory.performanceMode'), value: 'fast' },
@@ -168,6 +221,15 @@ watch(
{ immediate: true },
)
// 存储类型切换后主动重新提取图标色,避免图片缓存导致 load 事件不触发。
watch(
[() => props.directory.storage, () => props.directory.library_storage],
() => {
updateDirectoryAccentColors()
},
{ immediate: true },
)
// 媒体类别和类型变更非空时将按类型分类和按类别分类置为false
watch(
[() => props.directory.media_type, () => props.directory.media_category],
@@ -195,7 +257,13 @@ watch(
</script>
<template>
<VCard variant="tonal" class="app-card-shell" :width="props.width" :height="props.height">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="directoryAccentStyle"
:width="props.width"
:height="props.height"
>
<VDialogCloseBtn @click="onClose" />
<VCardItem>
<VTextField

View File

@@ -7,12 +7,14 @@ import { useI18n } from 'vue-i18n'
import { downloaderDict } from '@/api/constants'
import { useBackground } from '@/composables/useBackground'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const DownloaderInfoDialog = defineAsyncComponent(() => import('@/components/dialog/DownloaderInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
const { useConditionalDataRefresh } = useBackground()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor()
// 定义输入
const props = defineProps({
@@ -122,9 +124,9 @@ onUnmounted(() => {
<VCard
v-bind="hover.props"
variant="tonal"
class="app-card-shell"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openDownloaderInfoDialog"
:class="{ 'transition transform-cpu duration-300 -translate-y-1': hover.isHovering }"
>
<VDialogCloseBtn @click="onClose" />
<span class="app-card-top-action absolute top-3 right-12">
@@ -153,7 +155,7 @@ onUnmounted(() => {
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>

View File

@@ -3,11 +3,13 @@ import type { CustomRule, FilterRuleGroup } from '@/api/types'
import filter_group_svg from '@images/svg/filter-group.svg'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const FilterRuleGroupInfoDialog = defineAsyncComponent(() => import('@/components/dialog/FilterRuleGroupInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#8A8D93')
// 输入参数
const props = defineProps({
@@ -58,7 +60,12 @@ function onClose() {
</script>
<template>
<VCard variant="tonal" class="app-card-shell" @click="openGroupInfoDialog">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openGroupInfoDialog"
>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
@@ -74,7 +81,7 @@ function onClose() {
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="filter_group_svg" contain class="app-card-summary__image" />
<VImg ref="imageRef" :src="filter_group_svg" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>

View File

@@ -5,11 +5,13 @@ import { getLogoUrl } from '@/utils/imageUtils'
import { useI18n } from 'vue-i18n'
import { mediaServerDict } from '@/api/constants'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const MediaServerInfoDialog = defineAsyncComponent(() => import('@/components/dialog/MediaServerInfoDialog.vue'))
// 获取i18n实例
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#56CA00')
// 定义输入
const props = defineProps({
@@ -127,7 +129,12 @@ onMounted(() => {
</script>
<template>
<VCard variant="tonal" class="app-card-shell" @click="openMediaServerInfoDialog">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openMediaServerInfoDialog"
>
<VDialogCloseBtn @click="onClose" />
<VCardText class="app-card-summary app-card-summary--single-action">
<div class="app-card-summary__content">
@@ -146,7 +153,7 @@ onMounted(() => {
</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>

View File

@@ -3,10 +3,12 @@ import type { NotificationConf } from '@/api/types'
import { getLogoUrl } from '@/utils/imageUtils'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const NotificationChannelInfoDialog = defineAsyncComponent(() => import('@/components/dialog/NotificationChannelInfoDialog.vue'))
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor()
// 定义输入
const props = defineProps({
@@ -91,7 +93,12 @@ function onClose() {
</script>
<template>
<VCard variant="tonal" class="app-card-shell" @click="openNotificationInfoDialog">
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openNotificationInfoDialog"
>
<span class="app-card-top-action absolute top-3 right-12">
<IconBtn @click.stop>
<VIcon class="cursor-move" icon="mdi-drag" />
@@ -107,7 +114,7 @@ function onClose() {
<div class="app-card-summary__subtitle text-body-1">{{ notificationTypeNames[notification.type] }}</div>
</div>
<div class="app-card-summary__media" aria-hidden="true">
<VImg :src="getIcon" contain class="app-card-summary__image" />
<VImg ref="imageRef" :src="getIcon" contain class="app-card-summary__image" @load="updateAccentColor" />
</div>
</VCardText>
</VCard>

View File

@@ -13,6 +13,7 @@ import { useToast } from 'vue-toastification'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useI18n } from 'vue-i18n'
import { openSharedDialog } from '@/composables/useSharedDialog'
import { useCardAccentColor } from '@/composables/useCardAccentColor'
const AliyunAuthDialog = defineAsyncComponent(() => import('../dialog/AliyunAuthDialog.vue'))
const U115AuthDialog = defineAsyncComponent(() => import('../dialog/U115AuthDialog.vue'))
@@ -23,6 +24,7 @@ const StorageCustomConfigDialog = defineAsyncComponent(() => import('../dialog/S
// 国际化
const { t } = useI18n()
const { accentRgb, imageRef, updateAccentColor } = useCardAccentColor('#FFB400')
// 定义输入
const props = defineProps({
@@ -142,15 +144,28 @@ function onClose() {
</script>
<template>
<VCard variant="tonal" @click="openStorageDialog">
<VDialogCloseBtn @click="onClose" class="absolute top-1 right-1" />
<VCard
variant="tonal"
class="app-card-shell app-card-colorful"
:style="{ '--app-card-accent-rgb': accentRgb }"
@click="openStorageDialog"
>
<VDialogCloseBtn @click="onClose" />
<VCardText class="flex justify-space-between align-center gap-3">
<div class="align-self-start flex-1">
<h5 class="text-h6 mb-1">{{ storage.name }}</h5>
<div class="mb-3 text-sm" v-if="total">{{ formatBytes(used, 1) }} / {{ formatBytes(total, 1) }}</div>
<div v-else-if="isNullOrEmptyObject(storage.config)">{{ t('storage.notConfigured') }}</div>
</div>
<VImg :src="getIcon" cover class="mt-8" max-width="3rem" min-width="3rem" />
<VImg
ref="imageRef"
:src="getIcon"
cover
class="mt-8"
max-width="3rem"
min-width="3rem"
@load="updateAccentColor"
/>
</VCardText>
<div class="w-full absolute bottom-0">
<VProgressLinear v-if="usage > 0" :model-value="usage" :bg-color="progressColor" :color="progressColor" />

View File

@@ -40,6 +40,14 @@ const visible = computed({
// 正在编辑的 CSS 内容
const editableCSS = ref(props.css)
const editorOptions = {
displayIndentGuides: true,
fontSize: 14,
highlightActiveLine: true,
scrollPastEnd: 0.2,
showPrintMargin: false,
tabSize: 2,
}
watch(
() => props.css,
@@ -55,26 +63,89 @@ function submitCustomCSS() {
</script>
<template>
<VDialog v-if="visible" v-model="visible" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem>
<VDialog v-if="visible" v-model="visible" max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard class="custom-css-dialog">
<VCardItem class="custom-css-header py-3">
<template #prepend>
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
<VIcon icon="mdi-palette" size="22" />
</VAvatar>
</template>
<VCardTitle>
<VIcon icon="mdi-palette" class="me-2" />
{{ t('theme.custom') }}
</VCardTitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VDivider />
<VAceEditor v-model:value="editableCSS" lang="css" :theme="props.editorTheme" class="w-full min-h-[30rem]" />
<VDivider />
<VCardText class="text-center">
<VBtn @click="submitCustomCSS" class="w-1/2">
<template #prepend>
<VIcon icon="mdi-content-save" />
</template>
<div class="custom-css-editor-body">
<VAceEditor
v-model:value="editableCSS"
lang="css"
:theme="props.editorTheme"
:options="editorOptions"
wrap
class="custom-css-editor"
/>
</div>
<VCardActions class="custom-css-actions">
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitCustomCSS">
{{ t('common.save') }}
</VBtn>
</VCardText>
</VCardActions>
</VCard>
</VDialog>
</template>
<style scoped>
.custom-css-dialog {
display: flex;
flex-direction: column;
max-block-size: calc(100dvh - 2rem);
overflow: hidden;
}
.custom-css-header {
flex: 0 0 auto;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.custom-css-editor-body {
flex: 1 1 auto;
min-block-size: 0;
}
.custom-css-editor {
overflow: hidden;
background: rgb(var(--v-theme-surface));
block-size: min(62vh, 34rem);
inline-size: 100%;
}
.custom-css-actions {
flex: 0 0 auto;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 0.875rem;
padding-inline: 1rem;
}
@media (width <= 960px) {
.custom-css-dialog {
block-size: 100dvh;
max-block-size: 100dvh;
}
.custom-css-editor-body {
display: flex;
flex-direction: column;
}
.custom-css-editor {
flex: 1 1 auto;
min-block-size: 0;
block-size: auto;
}
.custom-css-actions {
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
}
}
</style>

View File

@@ -423,7 +423,12 @@ onMounted(() => {
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
</div>
<VCard v-for="(row, index) in pathMappingRows" :key="row.id" variant="outlined" class="my-2">
<VCard
v-for="(row, index) in pathMappingRows"
:key="row.id"
variant="outlined"
class="path-mapping-card my-2"
>
<VCardText class="pa-3">
<VRow align="center" no-gutters>
<VCol cols="12" class="mb-2">
@@ -432,7 +437,7 @@ onMounted(() => {
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
</div>
<VRow no-gutters>
<VCol cols="12" sm="4" class="pe-2">
<VCol cols="12" sm="4" class="path-storage-select-col pe-sm-2">
<VSelect
:model-value="getStorageType(row.storage)"
:items="prefixOptions"
@@ -464,14 +469,19 @@ onMounted(() => {
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
</div>
<VTextField
v-model="row.download"
:placeholder="'/path/to/download'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
/>
<VRow no-gutters>
<VCol cols="12" sm="4" class="d-none d-sm-block" />
<VCol cols="12" sm="8">
<VTextField
v-model="row.download"
:placeholder="'/path/to/download'"
density="compact"
variant="outlined"
hide-details="auto"
:rules="pathValidationRules"
/>
</VCol>
</VRow>
</VCol>
<VCol cols="12" class="d-flex justify-end pt-1">
@@ -505,3 +515,15 @@ onMounted(() => {
</VCard>
</VDialog>
</template>
<style scoped>
.path-mapping-card {
border-color: rgba(var(--v-border-color), 0.08) !important;
}
@media (max-width: 599.98px) {
.path-storage-select-col {
margin-block-end: 8px;
}
}
</style>

View File

@@ -38,6 +38,14 @@ const visible = computed({
})
const editableContent = ref(props.content)
const editorOptions = {
displayIndentGuides: true,
fontSize: 14,
highlightActiveLine: true,
scrollPastEnd: 0.2,
showPrintMargin: false,
tabSize: 2,
}
watch(
() => props.content,
@@ -58,10 +66,12 @@ function submitTemplate() {
<template>
<VDialog v-if="visible" v-model="visible" max-width="50rem" :fullscreen="!display.mdAndUp.value">
<VCard>
<VCardItem class="py-2">
<VCard class="notification-template-editor-dialog">
<VCardItem class="template-editor-header py-3">
<template #prepend>
<VIcon icon="mdi-code-json" class="me-2" />
<VAvatar color="primary" variant="tonal" rounded size="40" class="me-2">
<VIcon icon="mdi-code-json" size="22" />
</VAvatar>
</template>
<VCardTitle>
{{ t('setting.notification.templateConfigTitle') }}
@@ -71,16 +81,18 @@ function submitTemplate() {
</VCardSubtitle>
<VDialogCloseBtn v-model="visible" />
</VCardItem>
<VCardText class="py-0">
<div class="template-editor-body">
<VAceEditor
:key="`${props.templateType}-jinja2-json`"
v-model:value="editableContent"
lang="jinja2_json"
:theme="props.editorTheme"
class="w-full h-full min-h-[30rem] rounded"
:options="editorOptions"
wrap
class="template-ace-editor"
/>
</VCardText>
<VCardActions class="pt-3">
</div>
<VCardActions class="template-editor-actions">
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
{{ t('common.save') }}
</VBtn>
@@ -88,3 +100,58 @@ function submitTemplate() {
</VCard>
</VDialog>
</template>
<style scoped>
.notification-template-editor-dialog {
display: flex;
flex-direction: column;
max-block-size: calc(100dvh - 2rem);
overflow: hidden;
}
.template-editor-header {
flex: 0 0 auto;
border-block-end: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.template-editor-body {
flex: 1 1 auto;
min-block-size: 0;
}
.template-ace-editor {
overflow: hidden;
background: rgb(var(--v-theme-surface));
block-size: min(62vh, 34rem);
inline-size: 100%;
}
.template-editor-actions {
flex: 0 0 auto;
border-block-start: 1px solid rgba(var(--v-theme-on-surface), 0.08);
padding-block: 0.875rem;
padding-inline: 1rem;
}
@media (width <= 960px) {
.notification-template-editor-dialog {
block-size: 100dvh;
max-block-size: 100dvh;
}
.template-editor-body {
display: flex;
flex-direction: column;
}
.template-ace-editor {
flex: 1 1 auto;
min-block-size: 0;
block-size: auto;
}
.template-editor-actions {
padding-block-end: max(0.875rem, calc(env(safe-area-inset-bottom) + 0.75rem));
}
}
</style>

View File

@@ -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,

View File

@@ -0,0 +1,38 @@
import { getDominantColor } from '@/@core/utils/image'
const DEFAULT_ACCENT_RGB = '145, 85, 253'
/** 将图标主色转换为卡片 CSS 变量可直接使用的 RGB 字符串。 */
function hexToRgbString(hexColor: string) {
const normalizedColor = hexColor.replace('#', '')
const colorValue = Number.parseInt(normalizedColor, 16)
if (Number.isNaN(colorValue) || normalizedColor.length !== 6) return DEFAULT_ACCENT_RGB
return `${(colorValue >> 16) & 255}, ${(colorValue >> 8) & 255}, ${colorValue & 255}`
}
/** 从指定图片中提取卡片强调色,返回 CSS 变量可直接使用的 RGB 字符串。 */
export async function getCardAccentRgbFromImage(image: HTMLImageElement | undefined | null, fallback = '#9155FD') {
const dominantColor = await getDominantColor(image, { fallback })
return hexToRgbString(dominantColor)
}
/** 从卡片图标中提取强调色,保证设置页卡片颜色跟随各自图标。 */
export function useCardAccentColor(fallback = '#9155FD') {
const accentRgb = ref(DEFAULT_ACCENT_RGB)
const imageRef = ref<any>()
async function updateAccentColor() {
const imageElement = imageRef.value?.$el?.querySelector('img') as HTMLImageElement | undefined
accentRgb.value = await getCardAccentRgbFromImage(imageElement, fallback)
}
return {
accentRgb,
imageRef,
updateAccentColor,
}
}

View File

@@ -293,8 +293,8 @@ const themes: ThemeSwitcherTheme[] = [
},
]
// 编辑器主题
const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))
// Ace 跟随 Vuetify 当前生效主题,避免 auto 模式或弹窗打开后切主题时颜色不同步。
const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
// 更新主题
async function updateTheme() {
@@ -369,6 +369,11 @@ function showCustomCssDialog() {
)
}
// 共享弹窗打开后也要同步主题变化,否则 Ace 会停留在打开时的配色。
watch(editorTheme, theme => {
customCssDialogController?.updateProps({ editorTheme: theme })
})
/** 打开透明主题设置共享弹窗。 */
function showTransparencySettingsDialog() {
openSharedDialog(TransparencySettingsDialog, {}, {}, { closeOn: ['close', 'update:modelValue'] })

View File

@@ -1755,6 +1755,9 @@ export default {
siteOptions: 'Site Options',
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.',
readSiteMessage: 'Read Site Messages',
readSiteMessageHint: 'Read site messages and send notifications when refreshing data',
siteReset: 'Site Reset',
@@ -1866,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 (?&lt;ep&gt;...) 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 (?&lt;ep&gt;...);\n' +
'For other variable but position-sensitive parts, use named groups like (?&lt;a&gt;...) and (?&lt;b&gt;...).\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',
@@ -2520,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',

View File

@@ -1726,6 +1726,8 @@ export default {
flaresolverrUrlHint: '当仿真方式为 FlareSolverr 时生效例如http://127.0.0.1:8191',
siteDataRefreshInterval: '站点数据刷新间隔',
siteDataRefreshIntervalHint: '刷新站点用户上传下载等数据的时间间隔',
searchResourcePages: '搜索资源获取页数',
searchResourcePagesHint: '站点资源搜索时从当前页开始连续获取的页数,默认 1 页',
readSiteMessage: '阅读站点消息',
readSiteMessageHint: '刷新数据时读取站点消息并发送通知',
siteReset: '站点重置',
@@ -1832,6 +1834,29 @@ export default {
excludeWordsHint: '支持正则表达式,特殊字符需要\\转义,一行代表一个屏蔽词',
excludeWordsSaveSuccess: '文件整理屏蔽词保存成功',
excludeWordsSaveFailed: '文件整理屏蔽词保存失败!',
episodeFormatRule: '手动整理集数定位规则',
episodeFormatRuleDesc: '用于匹配媒体文件名,命中后自动生成对应的集数定位正则。',
episodeFormatRuleName: '规则名称',
episodeFormatRuleNameHint: '输入规则的名称',
episodeFormatRulePattern: '正则表达式',
episodeFormatRulePatternHint: '必须包含 (?&lt;ep&gt;...) 命名组',
episodeFormatRuleMinSize: '最小大小MB',
episodeFormatRuleMinSizeHint: '小于此大小的文件将不参与匹配',
episodeFormatRuleGuideTitle: '提示',
episodeFormatRuleGuideContent:
'从上到下依次匹配,第一个匹配的则使用该规则。\n' +
'按文件名实际结构编写匹配规则:\n' +
'固定文本直接匹配,集数必须使用命名分组 (?&lt;ep&gt;...) 标记;\n' +
'其余不固定但需要保留位置的内容,可使用 (?&lt;a&gt;...)、(?&lt;b&gt;...) 等命名分组承接。\n' +
'如需按字面量匹配 [](). 等特殊字符,请进行转义;\n' +
'建议使用 ^ 和 $ 完整约束整条文件名,其中 $ 尤其重要,可避免只匹配前半段就被误判为命中。',
episodeFormatRuleAdd: '新增规则',
episodeFormatRuleEdit: '编辑规则',
episodeFormatRuleDeleteConfirm: '确定要删除该规则吗?',
episodeFormatRuleEmptyError: '名称和正则表达式不能为空',
episodeFormatRuleSaveSuccess: '集数定位规则保存成功',
episodeFormatRuleSaveFailed: '集数定位规则保存失败!',
},
search: {
basicSettings: '基础设置',
@@ -2475,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',

View File

@@ -1727,6 +1727,8 @@ export default {
flaresolverrUrlHint: '當仿真方式為 FlareSolverr 時生效例如http://127.0.0.1:8191',
siteDataRefreshInterval: '站點數據刷新間隔',
siteDataRefreshIntervalHint: '刷新站點用戶上傳下載等數據的時間間隔',
searchResourcePages: '搜尋資源取得頁數',
searchResourcePagesHint: '站點資源搜尋時從目前頁開始連續取得的頁數,預設 1 頁',
readSiteMessage: '閱讀站點消息',
readSiteMessageHint: '刷新數據時讀取站點消息並發送通知',
siteReset: '站點重置',
@@ -1833,6 +1835,29 @@ export default {
excludeWordsHint: '支持正則表達式,特殊字符需要\\轉義,一行代表一個屏蔽詞',
excludeWordsSaveSuccess: '文件整理屏蔽詞保存成功',
excludeWordsSaveFailed: '文件整理屏蔽詞保存失敗!',
episodeFormatRule: '手動整理集數定位規則',
episodeFormatRuleDesc: '通過正則表達式提取文件名中的集數。',
episodeFormatRuleName: '規則名稱',
episodeFormatRuleNameHint: '輸入規則的名稱',
episodeFormatRulePattern: '正則表達式',
episodeFormatRulePatternHint: '必須包含 (?&lt;ep&gt;...) 命名組',
episodeFormatRuleMinSize: '最小大小MB',
episodeFormatRuleMinSizeHint: '小於此大小的文件將不參與匹配',
episodeFormatRuleGuideTitle: '提示',
episodeFormatRuleGuideContent:
'從上到下依次匹配,第一個匹配的則使用該規則。\n' +
'按文件名實際結構編寫匹配規則:\n' +
'固定文本直接匹配,集數必須使用命名分組 (?&lt;ep&gt;...) 標記;\n' +
'其餘不固定但需要保留位置的內容,可使用 (?&lt;a&gt;...)、(?&lt;b&gt;...) 等命名分組承接。\n' +
'如需按字面量匹配 [](). 等特殊字符,請進行轉義;\n' +
'建議使用 ^ 和 $ 完整約束整條文件名,其中 $ 尤其重要,可避免只匹配前半段就被誤判為命中。',
episodeFormatRuleAdd: '新增規則',
episodeFormatRuleEdit: '編輯規則',
episodeFormatRuleDeleteConfirm: '確定要刪除該規則嗎?',
episodeFormatRuleSaveSuccess: '集數定位規則保存成功',
episodeFormatRuleSaveFailed: '集數定位規則保存失敗!',
episodeFormatRuleEmptyError: '規則名稱和正則表達式不能為空',
},
search: {
basicSettings: '基礎設置',
@@ -2476,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',

View File

@@ -1163,101 +1163,73 @@ onUnmounted(() => {
</div>
</VFadeTransition>
<!-- 结果抬头只承载搜索上下文和快捷动作筛选控制交给下方工具条 -->
<VCard v-if="showResultHeader" class="search-header result-toolbar mb-2" elevation="0">
<div class="result-toolbar__content">
<VAvatar class="result-toolbar__icon" rounded="lg" size="42">
<VIcon icon="mdi-movie-search" size="24" />
</VAvatar>
<div class="search-info-container">
<div class="search-title text-moviepilot">
<span class="d-none d-sm-inline">{{ t('resource.searchResults') }}</span>
<span class="d-inline d-sm-none">{{ t('navItems.searchResult') }}</span>
</div>
<div v-if="hasSearchTags" class="search-tags d-flex flex-wrap mt-1">
<VChip v-if="keyword" class="search-tag" color="primary" size="small" variant="tonal">
{{ t('resource.keyword') }}: {{ keyword }}
</VChip>
<VChip v-if="title" class="search-tag" color="primary" size="small" variant="tonal">
{{ t('resource.title') }}: {{ title }}
</VChip>
<VChip v-if="year" class="search-tag" color="primary" size="small" variant="tonal">
{{ t('resource.year') }}: {{ year }}
</VChip>
<VChip v-if="season" class="search-tag" color="primary" size="small" variant="tonal">
{{ t('resource.season') }}: {{ season }}
</VChip>
</div>
</div>
<!-- 结果抬头保持和站点管理一致的页面标题结构筛选控制交给下方工具条 -->
<div v-if="showResultHeader" class="resource-page-header d-flex justify-space-between align-center mb-4">
<div class="resource-page-header__copy">
<VPageContentTitle
:title="t('resource.searchResults')"
class="resource-page-header__title my-0"
style="margin-block: 0"
/>
</div>
<div class="result-toolbar__actions">
<div class="resource-page-header__actions d-flex align-center gap-1">
<!-- 重新搜索按钮 -->
<VBtn
<IconBtn
variant="text"
size="small"
icon
class="refresh-search-btn"
color="gray"
:loading="isRefreshing"
:disabled="isRefreshing || progressActive"
@click="refreshSearch"
>
<VIcon icon="mdi-refresh" size="20" />
<VIcon icon="mdi-refresh" />
<VTooltip activator="parent" location="top">
{{ t('resource.refreshSearch') }}
</VTooltip>
</VBtn>
</IconBtn>
<!-- AI操作按钮组 -->
<div v-if="aiRecommendEnabled && originalDataList.length > 0" class="ai-toggle-container">
<div class="ai-toggle-buttons">
<VBtn
variant="text"
size="small"
rounded="0"
@click="toggleAiRecommend"
:disabled="isRecommending || !aiStatusChecked"
height="44"
class="ps-4 pe-3 ai-recommend-btn"
:class="{ 'ai-active': showingAiResults }"
>
<template #prepend>
<VIcon icon="lucide:sparkles" size="18" class="ai-icon" :class="{ 'ai-icon-active': showingAiResults }" />
</template>
<span class="ai-text" :class="{ 'ai-text-active': showingAiResults }">
{{ t('resource.aiRecommend') }}
</span>
</VBtn>
<div
v-if="aiRecommendEnabled && originalDataList.length > 0"
class="ai-action-group"
:class="{ 'ai-action-group--active': showingAiResults }"
>
<VBtn
:variant="showingAiResults ? 'tonal' : 'text'"
:color="showingAiResults ? 'primary' : 'gray'"
:disabled="isRecommending || !aiStatusChecked"
size="small"
height="40"
class="ai-action-group__primary"
@click="toggleAiRecommend"
>
<template #prepend>
<VIcon icon="lucide:sparkles" size="18" />
</template>
<span class="ai-action-group__label">{{ t('resource.aiRecommend') }}</span>
<VTooltip activator="parent" location="top">
{{ t('resource.aiRecommend') }}
</VTooltip>
</VBtn>
<VExpandXTransition>
<div v-if="aiRecommended || isRecommending" class="d-flex align-center">
<div class="ai-divider" :style="{ opacity: showingAiResults ? 0 : 1 }"></div>
<VBtn
variant="text"
size="small"
rounded="0"
:disabled="isRecommending || !aiStatusChecked"
@click="reRecommend"
height="44"
min-width="38"
class="px-0"
>
<VIcon
:icon="isRecommending ? 'line-md:loading-twotone-loop' : 'mdi-refresh'"
size="18"
class="ai-refresh-icon"
/>
<VTooltip activator="parent" location="top">
{{ t('resource.reRecommend') }}
</VTooltip>
</VBtn>
</div>
</VExpandXTransition>
</div>
<VExpandXTransition>
<div v-if="aiRecommended || isRecommending" class="ai-action-group__more">
<IconBtn
variant="text"
color="gray"
:disabled="isRecommending || !aiStatusChecked"
@click="reRecommend"
>
<VIcon :icon="isRecommending ? 'line-md:loading-twotone-loop' : 'mdi-auto-fix'" />
<VTooltip activator="parent" location="top">
{{ t('resource.reRecommend') }}
</VTooltip>
</IconBtn>
</div>
</VExpandXTransition>
</div>
</div>
</VCard>
</div>
<!-- 搜索结果 -->
<div v-if="isRefreshed && hasData" class="search-results-container">
@@ -1488,60 +1460,22 @@ onUnmounted(() => {
}
}
/* 结果抬头样式 */
.search-header {
border: 1px solid rgba(var(--v-theme-primary), 0.16);
border-radius: 8px;
background:
linear-gradient(135deg, rgba(var(--v-theme-primary), 0.1), rgba(var(--v-theme-surface), 0) 44%),
rgb(var(--v-theme-surface));
}
.result-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding-block: 12px;
padding-inline: 14px;
}
.result-toolbar__content {
display: flex;
flex: 1 1 auto;
align-items: center;
.resource-page-header {
gap: 12px;
}
.resource-page-header__copy {
flex: 1 1 auto;
min-inline-size: 0;
}
.result-toolbar__icon {
.resource-page-header__title {
max-inline-size: 100%;
}
.resource-page-header__actions {
flex: 0 0 auto;
background: rgba(var(--v-theme-primary), 0.12);
color: rgb(var(--v-theme-primary));
}
.result-toolbar__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.search-info-container {
min-inline-size: 0;
}
.search-title {
overflow: hidden;
font-size: 1.1rem;
font-weight: 600;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-tags {
gap: 6px;
align-self: center;
}
.search-tag {
@@ -1549,81 +1483,33 @@ onUnmounted(() => {
font-size: 0.75rem;
}
/* 重新搜索按钮 */
.refresh-search-btn {
border-radius: 8px !important;
background-color: rgba(var(--v-theme-surface-variant), 0.1);
block-size: 44px !important;
inline-size: 44px !important;
}
/* AI按钮组样式 */
.ai-toggle-container {
position: relative;
}
.ai-toggle-buttons {
.ai-action-group {
display: flex;
overflow: hidden;
align-items: center;
padding: 0;
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 8px;
background-color: rgba(var(--v-theme-surface-variant), 0.1);
block-size: 44px; /* 36px(btn) + 4px*2(padding) to match right side exactly */
}
.ai-recommend-btn {
margin: 0;
block-size: 100% !important;
transition: all 0.3s ease;
.ai-action-group--active {
border-color: rgba(var(--v-theme-primary), 0.24);
background-color: rgba(var(--v-theme-primary), 0.08);
}
/* 仅为激活的按钮添加背景 */
.ai-recommend-btn.ai-active {
z-index: 1;
background-color: rgba(var(--v-theme-primary), 0.15);
.ai-action-group__primary {
border-radius: 8px 0 0 8px !important;
padding-inline: 14px 12px !important;
}
/* 图标基础样式 */
.ai-icon {
color: rgba(var(--v-theme-on-surface), 0.6);
transform: translateZ(0);
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
.ai-action-group__label {
font-size: 0.875rem;
font-weight: 600;
}
/* 激活状态图标:变色 + 辉光 */
.ai-icon-active {
color: rgb(var(--v-theme-primary));
filter: drop-shadow(0 0 4px rgba(var(--v-theme-primary), 0.5));
}
/* 文字基础样式 */
.ai-text {
color: rgba(var(--v-theme-on-surface), 0.6);
font-size: 0.85rem;
font-weight: 600; /* 保持一致的字重防止位移 */
transform: translateZ(0);
transition: color 0.3s ease;
}
/* 激活状态文字 */
.ai-text-active {
color: rgb(var(--v-theme-primary));
}
/* 刷新图标样式 */
.ai-refresh-icon {
color: rgba(var(--v-theme-on-surface), 0.6);
transition: color 0.3s ease;
}
.ai-divider {
z-index: 0;
flex-shrink: 0;
block-size: 20px;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.12); /* 使用边框显示线条 */
inline-size: 0; /* 宽度设为0不占用空间 */
transition: opacity 0.3s ease;
.ai-action-group__more {
display: flex;
align-items: center;
border-inline-start: 1px solid rgba(var(--v-theme-on-surface), 0.12);
}
.search-results-container {
@@ -1657,49 +1543,8 @@ onUnmounted(() => {
}
@media (width <= 600px) {
.search-header {
border-radius: 8px;
}
.result-toolbar {
align-items: flex-start;
gap: 10px;
padding-block: 10px;
padding-inline: 10px;
}
.result-toolbar__content {
gap: 10px;
}
.result-toolbar__icon {
block-size: 36px !important;
inline-size: 36px !important;
}
.result-toolbar__actions {
gap: 6px;
}
.search-title {
font-size: 1rem;
white-space: nowrap;
}
.search-info-container {
.resource-page-header {
gap: 8px;
min-inline-size: 0;
}
.search-tags {
flex-wrap: nowrap;
margin-inline-end: 4px;
overflow-x: auto;
scrollbar-width: none;
}
.search-tags::-webkit-scrollbar {
display: none;
}
.search-loading-state {
@@ -1740,32 +1585,5 @@ onUnmounted(() => {
.search-skeleton-grid {
grid-template-columns: 1fr;
}
.refresh-search-btn {
block-size: 36px !important;
inline-size: 36px !important;
}
.ai-toggle-buttons {
block-size: 36px;
}
.ai-text {
font-size: 0.8rem;
}
.ai-recommend-btn,
.ai-toggle-buttons .v-btn {
block-size: 36px !important;
min-inline-size: unset !important;
}
.ai-recommend-btn {
padding-inline: 12px 8px !important;
}
.ai-toggle-buttons .v-btn:last-child {
min-inline-size: 32px !important;
}
}
</style>

View File

@@ -29,6 +29,12 @@ html.v-overlay-scroll-blocked body {
}
}
// 全局卡片阴影 token卡片统一不使用投影避免透明主题和密集布局下出现脏边。
html {
--app-card-rest-shadow: none;
--app-card-hover-shadow: none;
}
// 进度条样式
#nprogress .bar {
background: rgb(var(--v-theme-primary)) !important;
@@ -48,12 +54,102 @@ html.v-overlay-scroll-blocked body {
}
}
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形
// 统一系统内卡片阴影,显式覆盖 Vuetify elevation 或局部卡片默认投影。
.v-card,
.v-application .v-card.v-card[class] {
box-shadow: none !important;
transition: box-shadow 0.2s ease;
}
@media (hover: hover) {
.v-card:hover,
.v-application .v-card.v-card[class]:hover {
box-shadow: none !important;
}
}
// 应用类信息卡片:固定右侧媒体槽位,避免图片被左侧文字挤压变形。
.app-card-shell {
position: relative;
block-size: 100%;
}
// 设置项强调卡片:复用通知模板入口的强调条、轻渐变与悬浮反馈。
.app-card-colorful {
overflow: hidden;
border: 1px solid rgba(var(--app-card-accent-rgb), var(--app-card-border-opacity)) !important;
border-radius: 8px !important;
background:
linear-gradient(
135deg,
rgba(var(--app-card-accent-rgb), var(--app-card-accent-start-opacity)),
rgba(var(--app-card-accent-end-rgb), var(--app-card-accent-end-opacity)) 46%,
rgba(var(--v-theme-surface), 0) 76%
),
rgba(var(--v-theme-surface), var(--app-card-surface-opacity)) !important;
box-shadow: var(--app-card-rest-shadow) !important;
color: rgb(var(--v-theme-on-surface));
--app-card-accent-rgb: var(--v-theme-primary);
--app-card-accent-end-rgb: var(--app-card-accent-rgb);
--app-card-accent-start-opacity: 0.09;
--app-card-accent-end-opacity: 0.06;
--app-card-border-opacity: 0.2;
--app-card-hover-border-opacity: 0.34;
--app-card-stripe-opacity: 0.78;
--app-card-surface-opacity: 0.92;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.app-card-colorful::before {
position: absolute;
background: rgb(var(--app-card-accent-rgb));
block-size: 100%;
content: "";
inline-size: 0.25rem;
inset-block: 0;
inset-inline-start: 0;
opacity: var(--app-card-stripe-opacity);
pointer-events: none;
}
.app-card-colorful:hover {
border-color: rgba(var(--app-card-accent-rgb), var(--app-card-hover-border-opacity)) !important;
box-shadow: var(--app-card-hover-shadow) !important;
}
.app-card-colorful:focus-visible {
outline: 2px solid rgba(var(--app-card-accent-rgb), 0.7);
outline-offset: 3px;
}
.app-card-color-probe {
position: absolute;
block-size: 3rem;
inline-size: 3rem;
inset-block-start: 0;
inset-inline-start: 0;
opacity: 0;
pointer-events: none;
}
html[data-theme="transparent"] .app-card-colorful,
.v-theme--transparent .app-card-colorful {
backdrop-filter: blur(var(--transparent-blur, 10px));
border: 0 !important;
--app-card-accent-start-opacity: 0.04;
--app-card-accent-end-opacity: 0.03;
--app-card-border-opacity: 0;
--app-card-hover-border-opacity: 0;
--app-card-stripe-opacity: 0.42;
--app-card-surface-opacity: var(--transparent-opacity-light, 0.2);
}
html[data-theme="transparent"],
.v-theme--transparent {
--app-card-rest-shadow: none;
--app-card-hover-shadow: none;
}
// 保证卡片右上角的浮动操作区始终高于可点击的卡片内容层,避免误触发详情打开。
.app-card-top-action {
z-index: 2;
@@ -256,7 +352,7 @@ html.v-overlay-scroll-blocked body {
}
.grid-directory-card {
grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 24rem), 1fr));
}
.grid-filterrule-card {

View File

@@ -37,6 +37,19 @@ html[data-theme="transparent"] {
}
}
// 设置页彩色卡片保留透明主题的玻璃质感,只叠加非常轻的图标主色。
.app-card-colorful {
background:
linear-gradient(
135deg,
rgba(var(--app-card-accent-rgb), var(--app-card-accent-start-opacity, 0.04)),
rgba(var(--app-card-accent-end-rgb, var(--app-card-accent-rgb)), var(--app-card-accent-end-opacity, 0.03)) 46%,
rgba(var(--v-theme-surface), 0) 76%
),
rgba(var(--v-theme-surface), var(--transparent-opacity-light)) !important;
border: 0 !important;
}
// 工具栏
.v-toolbar {
backdrop-filter: blur(var(--transparent-blur));

View File

@@ -325,10 +325,11 @@ async function addSubscribe(season: number | null) {
function showSubscribeAddToast(result: boolean, title: string, season: number | null, message: string, best_version: number) {
if (season !== null) title = `${title} ${formatSeason(season.toString())}`
let subname = t('media.subscribe.normal')
if (best_version > 0) subname = t('media.subscribe.bestVersion')
let subname = t('subscribe.normalSub')
if (best_version > 0) subname = t('subscribe.versionSub')
if (!result) $toast.error(`${title} ${t('media.subscribe.addFailed', { reason: message })}`)
if (result) $toast.success(`${title} ${t('subscribe.addSuccess', { name: subname })}`)
else $toast.error(`${title} ${t('media.subscribe.addFailed', { reason: message })}`)
}
// 调用API取消订阅

View File

@@ -64,7 +64,8 @@ const SystemSettings = ref<any>({
})
// 编辑器主题
const editorTheme = computed(() => (globalTheme.name.value === 'light' ? 'github' : 'monokai'))
// Ace 跟随 Vuetify 当前生效主题auto 模式下也按实际明暗色渲染。
const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
const renameEditorOptions = {
fontSize: 14,

View File

@@ -25,39 +25,55 @@ const NotificationTemplateEditorDialog = defineAsyncComponent(
() => import('@/components/dialog/NotificationTemplateEditorDialog.vue'),
)
// 初始化模板配置字典
const templateConfigs = ref<Record<string, string>>({
organizeSuccess: '{}',
downloadAdded: '{}',
subscribeAdded: '{}',
subscribeComplete: '{}',
})
// 模板类型配置
const templateTypes = ref([
// 通知模板入口的图标和强调色统一维护,避免模板中散落长判断。
const templateTypeDefaults = [
{
type: 'organizeSuccess',
label: t('setting.notification.organizeSuccess'),
icon: 'mdi-folder-check',
accentRgb: 'var(--v-theme-primary)',
},
{
type: 'downloadAdded',
label: t('setting.notification.downloadAdded'),
icon: 'mdi-download-box',
accentRgb: 'var(--v-theme-info)',
},
{
type: 'subscribeAdded',
label: t('setting.notification.subscribeAdded'),
icon: 'mdi-rss-box',
accentRgb: 'var(--v-theme-warning)',
},
{
type: 'subscribeComplete',
label: t('setting.notification.subscribeComplete'),
icon: 'mdi-check-circle',
accentRgb: 'var(--v-theme-success)',
},
])
] as const
// 编辑器主题
const { name: themeName, global: globalTheme } = useTheme()
const savedTheme = ref(localStorage.getItem('theme') ?? 'auto')
const currentThemeName = ref(savedTheme.value)
const editorTheme = computed(() => (currentThemeName.value === 'light' ? 'github' : 'monokai'))
type NotificationTemplateType = (typeof templateTypeDefaults)[number]['type']
// 初始化模板配置字典
const templateConfigs = ref<Record<string, string>>(
templateTypeDefaults.reduce<Record<string, string>>((configs, item) => {
configs[item.type] = '{}'
return configs
}, {}),
)
// 模板类型配置
const templateTypes = computed(() =>
templateTypeDefaults.map(item => ({
...item,
label: t(`setting.notification.${item.type}`),
})),
)
function getTemplateAccentStyle(item: (typeof templateTypes.value)[number]) {
return { '--app-card-accent-rgb': item.accentRgb }
}
// Ace 直接跟随 Vuetify 当前生效主题auto 模式下也能按实际明暗色切换。
const { global: globalTheme } = useTheme()
const editorTheme = computed(() => (globalTheme.current.value.dark ? 'github_dark' : 'github_light_default'))
// 所有消息渠道
const notifications = ref<NotificationConf[]>([])
@@ -66,7 +82,7 @@ const notifications = ref<NotificationConf[]>([])
const $toast = useToast()
const editorDialogOpen = ref(false)
const currentTemplate = ref('')
const currentTemplate = ref<NotificationTemplateType | ''>('')
const editorContent = ref('')
// 消息类型开关
@@ -127,7 +143,7 @@ function closeTemplateEditorDialog() {
}
// 打开通知模板共享弹窗,保持内容通过事件回写到设置页。
function openTemplateEditorDialog(type: string) {
function openTemplateEditorDialog(type: NotificationTemplateType) {
closeTemplateEditorDialog()
editorDialogOpen.value = true
editorDialogController = openSharedDialog(
@@ -158,6 +174,13 @@ function openTemplateEditorDialog(type: string) {
)
}
// 共享弹窗的 props 是打开时写入的,主题切换时主动推送给已打开的编辑器。
watch(editorTheme, theme => {
if (!editorDialogOpen.value) return
editorDialogController?.updateProps({ editorTheme: theme })
})
// 添加通知渠道
function addNotification(notification: string) {
let name = `${t('setting.notification.channel')}${notifications.value.length + 1}`
@@ -229,7 +252,7 @@ async function loadNotificationSetting() {
}
}
async function openEditor(type: string) {
async function openEditor(type: NotificationTemplateType) {
try {
currentTemplate.value = type
const result: { [key: string]: any } = await api.get('system/setting/NotificationTemplates')
@@ -460,34 +483,25 @@ useSilentSettingRefresh(loadPageData, {
<VCardSubtitle>{{ t('setting.notification.templateConfigDesc') }}</VCardSubtitle>
</VCardItem>
<VCardText>
<VRow>
<VCol v-for="item in templateTypes" :key="item.type" cols="12" sm="6" md="3">
<VCard variant="tonal" class="template-card" :class="{ 'on-hover': true }" @click="openEditor(item.type)">
<VCardItem>
<template #prepend>
<VAvatar color="primary" variant="tonal" rounded size="42" class="me-3">
<VIcon
size="24"
:icon="
item.type === 'organizeSuccess'
? 'mdi-folder-check'
: item.type === 'downloadAdded'
? 'mdi-download'
: item.type === 'subscribeAdded'
? 'mdi-rss'
: 'mdi-check-circle'
"
/>
</VAvatar>
</template>
<VCardTitle>{{ item.label }}</VCardTitle>
<template #append>
<VIcon icon="mdi-chevron-right" />
</template>
</VCardItem>
</VCard>
</VCol>
</VRow>
<div class="notification-template-grid">
<button
v-for="item in templateTypes"
:key="item.type"
type="button"
class="notification-template-card app-card-shell app-card-colorful"
:style="getTemplateAccentStyle(item)"
@click="openEditor(item.type)"
>
<span class="template-card-icon">
<VIcon :icon="item.icon" size="24" />
</span>
<span class="template-card-copy">
<span class="template-card-title">{{ item.label }}</span>
<span class="template-card-subtitle">Jinja2 JSON</span>
</span>
<VIcon class="template-card-arrow" icon="mdi-chevron-right" size="22" />
</button>
</div>
</VCardText>
</VCard>
</VCol>
@@ -575,20 +589,80 @@ useSilentSettingRefresh(loadPageData, {
</VRow>
</template>
<style scoped>
/* Monaco编辑器容器样式 */
.monaco-editor-container {
overflow: hidden;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
margin-block-start: 1rem;
/* 模板入口保持设置页的紧凑密度,卡片壳层复用全局 app-card-shell。 */
.notification-template-grid {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
}
.template-card {
.notification-template-card {
position: relative;
display: flex;
align-items: center;
cursor: pointer;
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
gap: 0.875rem;
inline-size: 100%;
min-block-size: 5.25rem;
padding: 1rem;
text-align: start;
}
.template-card.on-hover:hover {
transform: translateY(-4px);
.template-card-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 8px;
background: rgba(var(--app-card-accent-rgb), 0.16);
block-size: 2.75rem;
color: rgb(var(--app-card-accent-rgb));
inline-size: 2.75rem;
}
.template-card-copy {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-inline-size: 0;
}
.template-card-title {
overflow: hidden;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
font-size: 0.98rem;
font-weight: 600;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.template-card-subtitle {
margin-block-start: 0.25rem;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.25;
}
.template-card-arrow {
flex: 0 0 auto;
color: rgba(var(--v-theme-on-surface), 0.42);
transition: color 0.2s ease, transform 0.2s ease;
}
.notification-template-card:hover .template-card-arrow {
color: rgb(var(--app-card-accent-rgb));
transform: translateX(2px);
}
@media (width <= 600px) {
.notification-template-grid {
gap: 0.75rem;
}
.notification-template-card {
min-block-size: 4.75rem;
padding: 0.875rem;
}
}
</style>

View File

@@ -41,6 +41,7 @@ const siteSetting = ref<any>({
Site: {
SITEDATA_REFRESH_INTERVAL: 0,
SITE_MESSAGE: false,
SEARCH_RESOURCE_PAGES: 1,
BROWSER_EMULATION: 'cloakbrowser',
FLARESOLVERR_URL: '',
},
@@ -237,6 +238,19 @@ useSilentSettingRefresh(loadSiteSettings, {
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="siteSetting.Site.SEARCH_RESOURCE_PAGES"
type="number"
min="1"
step="1"
:label="t('setting.site.searchResourcePages')"
:hint="t('setting.site.searchResourcePagesHint')"
persistent-hint
prepend-inner-icon="mdi-file-search"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="siteSetting.Site.BROWSER_EMULATION"

View File

@@ -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,65 @@ 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) {
episodeFormatRules.value.splice(index, 1)
}
// 拖拽结束
function onEpisodeRuleDragEnd() {
saveEpisodeFormatRules()
}
onMounted(() => {
queryCustomIdentifiers()
queryCustomReleaseGroups()
queryCustomization()
queryTransferExcludeWords()
queryEpisodeFormatRules()
})
</script>
@@ -247,4 +351,254 @@ onMounted(() => {
</VCard>
</VCol>
</VRow>
<VRow>
<VCol cols="12">
<VCard>
<VCardItem class="episode-rule-section-header">
<template #append>
<VBtn color="success" class="episode-rule-add-btn" prepend-icon="mdi-plus" @click="addEpisodeRule">
{{ 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="episode-rule-card-content">
<div class="episode-rule-row">
<div class="episode-rule-toolbar">
<IconBtn
icon="mdi-drag"
variant="text"
size="small"
class="episode-rule-drag cursor-move"
/>
<VSwitch
v-model="element.enabled"
color="primary"
density="compact"
hide-details
class="episode-rule-enabled"
/>
</div>
<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-delete"
@click.stop="deleteEpisodeRule(index)"
>
<VIcon icon="mdi-delete" />
<VTooltip activator="parent" location="top">{{ t('common.delete') }}</VTooltip>
</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-section-header {
align-items: flex-start;
}
.episode-rule-add-btn {
flex-shrink: 0;
}
.episode-rule-card {
border-color: rgba(var(--v-border-color), var(--v-border-opacity));
}
.episode-rule-card-content {
padding: 1rem;
}
.episode-rule-row {
display: grid;
grid-template-columns: max-content minmax(8rem, 0.9fr) minmax(18rem, 3fr) minmax(8rem, 0.7fr) max-content;
align-items: center;
gap: 0.75rem;
}
.episode-rule-toolbar {
display: flex;
align-items: center;
gap: 0.25rem;
min-inline-size: 4.75rem;
}
.episode-rule-drag,
.episode-rule-delete {
flex: 0 0 auto;
}
.episode-rule-enabled {
flex: 0 0 auto;
}
.episode-rule-enabled :deep(.v-label) {
display: none;
}
.episode-rule-field {
min-inline-size: 0;
}
.episode-rule-name {
min-inline-size: 0;
}
.episode-rule-pattern {
min-inline-size: 0;
}
.episode-rule-size {
min-inline-size: 0;
}
@media (width <= 959px) {
.episode-rule-section-header {
display: grid;
grid-template-areas:
"content"
"append";
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
}
.episode-rule-section-header :deep(.v-card-item__content) {
grid-area: content;
}
.episode-rule-section-header :deep(.v-card-item__append) {
grid-area: append;
justify-self: stretch;
margin-inline-start: 0;
}
.episode-rule-add-btn {
inline-size: 100%;
}
.episode-rule-row {
grid-template-columns: minmax(0, 1fr) minmax(7rem, 0.42fr) max-content;
grid-template-areas:
"toolbar toolbar delete"
"name size size"
"pattern pattern pattern";
align-items: start;
gap: 0.875rem;
}
.episode-rule-toolbar {
grid-area: toolbar;
min-block-size: 2.5rem;
min-inline-size: 0;
}
.episode-rule-enabled {
margin-inline-start: 0.125rem;
}
.episode-rule-enabled :deep(.v-label) {
display: inline-flex;
opacity: var(--v-medium-emphasis-opacity);
}
.episode-rule-delete {
grid-area: delete;
align-self: center;
}
.episode-rule-name {
grid-area: name;
}
.episode-rule-size {
grid-area: size;
}
.episode-rule-pattern {
grid-area: pattern;
}
}
@media (width <= 599px) {
.episode-rule-card-content {
padding: 0.875rem;
}
.episode-rule-row {
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-areas:
"toolbar delete"
"name name"
"pattern pattern"
"size size";
gap: 0.75rem;
}
.episode-rule-field :deep(.v-field__input) {
min-block-size: 2.75rem;
}
}
</style>