mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-04 15:19:52 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2960e7cfde | ||
|
|
e0ebc35178 | ||
|
|
07c9442ac8 | ||
|
|
ccc820e8d2 | ||
|
|
68bb568400 | ||
|
|
13cd214e6d | ||
|
|
311880bcd3 | ||
|
|
088ebbe0bb | ||
|
|
de3523056a | ||
|
|
cf139a938e | ||
|
|
be2f4d0170 | ||
|
|
79493665c1 | ||
|
|
106062da82 | ||
|
|
50e54e943d | ||
|
|
6b811f2250 | ||
|
|
fa7f2a6c7c | ||
|
|
e362f3cbdd | ||
|
|
f4c4d7495f | ||
|
|
5b850d9464 | ||
|
|
d7f74a3a8a | ||
|
|
91dbf065db | ||
|
|
1759e666ba |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.12.1",
|
||||
"version": "2.12.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
38
src/composables/useCardAccentColor.ts
Normal file
38
src/composables/useCardAccentColor.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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'] })
|
||||
|
||||
@@ -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 (?<ep>...) named group',
|
||||
episodeFormatRuleMinSize: 'Min Size (MB)',
|
||||
episodeFormatRuleMinSizeHint: 'Files smaller than this will be ignored',
|
||||
episodeFormatRuleGuideTitle: 'Tips',
|
||||
episodeFormatRuleGuideContent:
|
||||
'Rules are matched from top to bottom, and the first matched rule will be used.\n' +
|
||||
'Write matching rules based on the actual filename structure:\n' +
|
||||
'Match fixed text directly, and mark the episode number with the named group (?<ep>...);\n' +
|
||||
'For other variable but position-sensitive parts, use named groups like (?<a>...) and (?<b>...).\n' +
|
||||
'Escape special characters such as [](). when they should be matched literally;\n' +
|
||||
'it is recommended to use ^ and $ to constrain the full filename, especially $, to avoid false positives from partial matches.',
|
||||
episodeFormatRuleAdd: 'Add Rule',
|
||||
episodeFormatRuleEdit: 'Edit Rule',
|
||||
episodeFormatRuleDeleteConfirm: 'Are you sure you want to delete this rule?',
|
||||
episodeFormatRuleSaveSuccess: 'Episode format rules saved successfully',
|
||||
episodeFormatRuleSaveFailed: 'Failed to save episode format rules!',
|
||||
episodeFormatRuleEmptyError: 'Rule name and regular expression cannot be empty',
|
||||
},
|
||||
search: {
|
||||
basicSettings: 'Basic Settings',
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '必须包含 (?<ep>...) 命名组',
|
||||
episodeFormatRuleMinSize: '最小大小(MB)',
|
||||
episodeFormatRuleMinSizeHint: '小于此大小的文件将不参与匹配',
|
||||
episodeFormatRuleGuideTitle: '提示',
|
||||
episodeFormatRuleGuideContent:
|
||||
'从上到下依次匹配,第一个匹配的则使用该规则。\n' +
|
||||
'按文件名实际结构编写匹配规则:\n' +
|
||||
'固定文本直接匹配,集数必须使用命名分组 (?<ep>...) 标记;\n' +
|
||||
'其余不固定但需要保留位置的内容,可使用 (?<a>...)、(?<b>...) 等命名分组承接。\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',
|
||||
|
||||
@@ -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: '必須包含 (?<ep>...) 命名組',
|
||||
episodeFormatRuleMinSize: '最小大小(MB)',
|
||||
episodeFormatRuleMinSizeHint: '小於此大小的文件將不參與匹配',
|
||||
episodeFormatRuleGuideTitle: '提示',
|
||||
episodeFormatRuleGuideContent:
|
||||
'從上到下依次匹配,第一個匹配的則使用該規則。\n' +
|
||||
'按文件名實際結構編寫匹配規則:\n' +
|
||||
'固定文本直接匹配,集數必須使用命名分組 (?<ep>...) 標記;\n' +
|
||||
'其餘不固定但需要保留位置的內容,可使用 (?<a>...)、(?<b>...) 等命名分組承接。\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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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取消订阅
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user