Compare commits

..

21 Commits

Author SHA1 Message Date
jxxghp
cacc2602df fix: initialize OTP dialog on open 2026-05-25 19:49:30 +08:00
jxxghp
8c6cfa7fc5 feat: add MiniMax audio provider option 2026-05-25 19:10:21 +08:00
jxxghp
0113f28d8c 更新 package.json 2026-05-25 18:20:49 +08:00
jxxghp
d870b788bc feat: add usage version statistics dialog 2026-05-25 18:16:35 +08:00
jxxghp
19a3213be0 fix: 插件页面再次进入时不显示新版本提示 2026-05-25 14:32:20 +08:00
InfinityPacer
f5c8a463fa feat(settings): expose image proxy private ranges (#479) 2026-05-25 14:17:27 +08:00
jxxghp
ff3b5b4232 fix: hide episode sort for movie subscriptions 2026-05-25 11:40:06 +08:00
jxxghp
6da0aae362 feat: add subscription sort options 2026-05-25 11:30:42 +08:00
InfinityPacer
abbce2644a fix(subscribe-card): i18n paused/pending state labels (#478) 2026-05-25 10:47:23 +08:00
InfinityPacer
1c5773444e feat(subscribe-card): style paused/pending states with frosted shimmer badge (#477) 2026-05-25 10:14:08 +08:00
jxxghp
1674f15d7c fix: use null for empty episode group selection 2026-05-25 05:33:26 +08:00
jxxghp
c6981e9955 feat: 添加剧集组功能,支持自动查询和手动输入剧集组编号 2026-05-24 23:33:17 +08:00
jxxghp
96d3426d0c fix: 优化插件市场刷新按钮状态 2026-05-24 20:28:45 +08:00
jxxghp
c88b2abcce fix: 修复 Rust 加速可用性标志并调整插件本地仓库路径和传输线程设置的布局 2026-05-23 21:10:48 +08:00
jxxghp
42fe928155 fix: 调整插件本地仓库路径输入框的位置 2026-05-23 20:55:19 +08:00
jxxghp
4cc455b948 feat: add Rust acceleration configuration option to system settings 2026-05-23 20:41:51 +08:00
jxxghp
bce073ebe0 fix: adjust file manager selection toolbar 2026-05-23 13:30:46 +08:00
jxxghp
c27167097e 更新 package.json 2026-05-23 09:25:12 +08:00
Album
44d23480a3 feat: 支持多文件整理预览与模板智能生成 (#476) 2026-05-23 09:24:49 +08:00
jxxghp
01796b3dc5 更新 package.json 2026-05-22 21:52:03 +08:00
InfinityPacer
dcf0924c73 feat(subscribe-card): render progress with backend completed_episode (#475) 2026-05-22 19:39:07 +08:00
18 changed files with 1013 additions and 153 deletions

2
.gitignore vendored
View File

@@ -35,3 +35,5 @@ package-lock.json
# iconify dist files
src/@iconify/*.js
public/plugin_icon/**
docs-lock/
.trae/

View File

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

View File

@@ -46,6 +46,8 @@ export interface Subscribe {
start_episode?: number
// 缺失集数
lack_episode?: number
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
completed_episode?: number
// 附加信息
note?: string
// 状态N-新建 R-订阅中 P-待定 S-暂停
@@ -64,6 +66,8 @@ export interface Subscribe {
search_imdbid?: any
// 当前优先级
current_priority: number
// 洗版时已下载剧集的优先级状态
episode_priority?: Record<string, number>
// 保存目录
save_path?: string
// 时间
@@ -1318,13 +1322,18 @@ export interface TransferForm {
// 媒体库类别子目录
library_category_folder?: boolean
// 剧集组编号
episode_group?: string
episode_group?: string | null
// 预览模式
preview?: boolean
}
// 手动整理请求
export interface ManualTransferPayload extends TransferForm {}
export interface ManualTransferPayload extends Omit<TransferForm, 'fileitem'> {
// 文件项
fileitem?: FileItem
// 多选文件批量请求
fileitems?: FileItem[]
}
// 手动整理预览统计
export interface ManualTransferPreviewSummary {

View File

@@ -70,13 +70,64 @@ function isTvSubscribe(media?: Subscribe) {
return media?.type === '电视剧' || media?.type === 'tv' || !!media?.season || !!media?.total_episode
}
// TV 洗版订阅在卡片上展示分集或全集短标签
const bestVersionModeLabel = computed(() => {
if (!isEnabledFlag(props.media?.best_version) || !isTvSubscribe(props.media)) return ''
// 已下载集数total_episode - lack_episode
const downloadedEpisode = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return 0
return Math.min(Math.max(total - (props.media?.lack_episode || 0), 0), total)
})
return isEnabledFlag(props.media?.best_version_full)
? t('subscribe.bestVersionWholeShort')
: t('subscribe.bestVersionEpisodeShort')
// 是否为洗版订阅(影响进度条与 tooltip 的展示分支)
const isBestVersion = computed(() => isEnabledFlag(props.media?.best_version) && isTvSubscribe(props.media))
const rightBottomStateDisplay = computed(() => {
if (subscribeState.value === 'S') {
return { icon: 'mdi-pause-circle', label: t('subscribe.cardStatePaused') }
}
if (subscribeState.value === 'P') {
return { icon: 'mdi-clock', label: t('subscribe.cardStatePending') }
}
return null
})
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
const bestVersionBadge = computed(() => {
if (!isEnabledFlag(props.media?.best_version)) return null
return {
icon: 'mdi-shimmer',
full: isEnabledFlag(props.media?.best_version_full),
}
})
// 已洗版集数:取后端派生字段 completed_episode
const completedEpisode = computed(() => {
const total = props.media?.total_episode || 0
return Math.min(Math.max(props.media?.completed_episode ?? 0, 0), total)
})
// 卡片主文案:已下载集数 / 总集数
const subscribeProgressText = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return ''
return `${downloadedEpisode.value} / ${total}`
})
// 订阅卡片 hover 文案:
// - 普通订阅:「已下载 X · 共 Y 集」
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
const subscribeProgressTooltip = computed(() => {
const total = props.media?.total_episode || 0
if (!total) return ''
if (isBestVersion.value) {
return t('subscribe.bestVersionEpisodeProgressTooltip', {
completed: completedEpisode.value,
downloaded: downloadedEpisode.value,
total,
})
}
return t('subscribe.subscribeProgressTooltip', { downloaded: downloadedEpisode.value, total })
})
// 图片加载完成响应
@@ -84,13 +135,19 @@ function imageLoadHandler() {
imageLoaded.value = true
}
// 计算百分
// 进度条 model 段百分比:洗版订阅表示"已洗版"占比(亮段),普通订阅表示"已下载"占
function getPercentage() {
if (props.media?.total_episode === 0) return 0
const total = props.media?.total_episode || 0
if (!total) return 0
const value = isBestVersion.value ? completedEpisode.value : downloadedEpisode.value
return Math.round((value / total) * 100)
}
return Math.round(
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0)) / (props.media?.total_episode ?? 1)) * 100,
)
// 洗版进度条的 buffer 段百分比:表示"已下载"占比,仅在洗版场景被模板调用
function getBufferPercentage() {
const total = props.media?.total_episode || 0
if (!isBestVersion.value || !total) return 0
return Math.round((downloadedEpisode.value / total) * 100)
}
// 删除订阅
@@ -352,11 +409,11 @@ function handleCardClick() {
<VHover>
<template #default="hover">
<div
class="w-full h-full rounded-lg overflow-hidden"
class="w-full h-full rounded-lg overflow-hidden relative"
:class="{
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
'subscribe-card-pending-tint': subscribeState === 'P',
}"
>
<VCard
@@ -364,7 +421,7 @@ function handleCardClick() {
:key="props.media?.id"
class="flex flex-col h-full"
:class="{
'opacity-70': subscribeState === 'S',
'subscribe-card-paused': subscribeState === 'S',
'cursor-move': props.sortable,
}"
rounded="0"
@@ -372,6 +429,13 @@ function handleCardClick() {
@click="handleCardClick"
:ripple="!props.batchMode && !props.sortable"
>
<div
v-if="bestVersionBadge && imageLoaded"
class="best-version-badge"
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
>
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
</div>
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
<IconBtn @click.stop>
<VIcon icon="mdi-dots-vertical" color="white" />
@@ -400,15 +464,11 @@ function handleCardClick() {
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
</template>
</VImg>
<div
v-if="subscribeState === 'P'"
class="absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none"
/>
</template>
<div>
<VCardText class="flex items-center pt-3 pb-2">
<div
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
v-if="imageLoaded"
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
>
@@ -444,19 +504,13 @@ function handleCardClick() {
icon="mdi-progress-download"
color="white"
/>
<div v-if="props.media?.season" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
{{ props.media?.total_episode }}
<!-- 守卫改用 total_episode电视剧订阅可能不带 season 字段旧数据或自定义来源仍应展示集数进度 -->
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
{{ subscribeProgressText }}
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
{{ subscribeProgressTooltip }}
</VTooltip>
</div>
<VChip
v-if="bestVersionModeLabel"
size="x-small"
color="primary"
variant="flat"
class="me-2 flex-shrink-0"
>
{{ bestVersionModeLabel }}
</VChip>
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="flex-shrink-0 me-1" />
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" class="flex-shrink-0" />
<!-- 用户名过长时限制在卡片宽度内并用省略号展示剩余内容 -->
@@ -465,16 +519,38 @@ function handleCardClick() {
</span>
</div>
</VCardText>
<!-- 右下角元数据暂停 / 待定时替换"x 天前"为状态文案 -->
<VCardText
v-if="lastUpdateText"
v-if="rightBottomStateDisplay"
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
>
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
{{ rightBottomStateDisplay.label }}
</VCardText>
<VCardText
v-else-if="lastUpdateText"
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
>
<VIcon icon="mdi-download" class="me-1" />
{{ lastUpdateText }}
</VCardText>
<div class="w-full absolute bottom-0">
<!--
分集洗版模式底色保持深绿buffer 段显示"已下载未洗版"为浅绿model 段显示"已洗版完成"为亮绿
形成两段语义其余订阅维持原有单段进度条
-->
<VProgressLinear
v-if="getPercentage() > 0"
v-if="isBestVersion && getBufferPercentage() > 0"
:model-value="getPercentage()"
:buffer-value="getBufferPercentage()"
bg-color="success"
bg-opacity="0.25"
color="success"
buffer-color="success"
buffer-opacity="0.55"
/>
<VProgressLinear
v-else-if="getPercentage() > 0"
:model-value="getPercentage()"
bg-color="success"
color="success"
@@ -491,4 +567,54 @@ function handleCardClick() {
.subscribe-card-background {
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
}
/**
* 暂停:降低不透明度表达"已停止活动"
*/
.subscribe-card-paused {
opacity: 0.65;
transition: opacity 0.2s ease;
}
/**
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
*/
.subscribe-card-pending-tint {
position: relative;
}
.subscribe-card-pending-tint::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
border-radius: 8px;
box-shadow: inset 0 0 48px rgba(56, 189, 248, 0.4); // sky-400
z-index: 3;
}
/**
* 洗版标识:卡片左上角 24x24 圆形徽标
* 分集:深色半透底 + 模糊
* 全集:磨砂玻璃半透白底 + 大模糊
*/
.best-version-badge {
position: absolute;
top: 6px;
left: 8px;
width: 24px;
height: 24px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 4;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
}
.best-version-badge-full {
background: rgba(255, 255, 255, 0.22);
backdrop-filter: blur(10px);
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15);
}
</style>

View File

@@ -84,6 +84,24 @@ const releaseDialogTitle = ref('')
// 变更日志对话框内容
const releaseDialogBody = ref('')
// 版本统计对话框
const versionStatisticDialog = ref(false)
// 版本统计加载状态
const versionStatisticLoading = ref(false)
// 版本统计数据
const versionStatistic = ref<any>({})
// 后端版本统计
const backendVersionStatistics = computed(() => versionStatistic.value?.backend_versions ?? [])
// 前端版本统计
const frontendVersionStatistics = computed(() => versionStatistic.value?.frontend_versions ?? [])
// 活跃用户统计
const activeUsers = computed(() => versionStatistic.value?.active_users ?? {})
// 打开日志对话框
function showReleaseDialog(title: string, body: string) {
releaseDialogTitle.value = title
@@ -91,6 +109,28 @@ function showReleaseDialog(title: string, body: string) {
releaseDialog.value = true
}
// 查询版本统计
async function queryVersionStatistic() {
if (!systemEnv.value.USAGE_STATISTIC_SHARE) return
versionStatisticLoading.value = true
try {
const result: { [key: string]: any } = await api.get('system/usage/statistic')
versionStatistic.value = result.data ?? {}
} catch (error) {
console.log(error)
versionStatistic.value = {}
} finally {
versionStatisticLoading.value = false
}
}
// 打开版本统计对话框
async function showVersionStatisticDialog() {
versionStatisticDialog.value = true
await queryVersionStatistic()
}
// 查询系统环境变量
async function querySystemEnv() {
try {
@@ -182,6 +222,18 @@ onMounted(() => {
{{ t('setting.about.latest') }}
</span>
</a>
<VTooltip v-if="systemEnv.USAGE_STATISTIC_SHARE" :text="t('setting.about.versionStatistic')">
<template #activator="{ props }">
<VBtn
v-bind="props"
icon="mdi-chart-bar"
size="x-small"
variant="text"
class="ms-2 flex-shrink-0"
@click="showVersionStatisticDialog"
/>
</template>
</VTooltip>
</span>
</dd>
</div>
@@ -406,6 +458,86 @@ onMounted(() => {
<VCardText class="markdown-body" v-html="releaseDialogBody" />
</VCard>
</VDialog>
<VDialog v-if="versionStatisticDialog" v-model="versionStatisticDialog" width="680" scrollable max-height="85vh">
<VCard>
<VCardItem>
<VDialogCloseBtn @click="versionStatisticDialog = false" />
<VCardTitle>
<VIcon icon="mdi-chart-bar" class="me-2" />
{{ t('setting.about.versionStatisticTitle') }}
</VCardTitle>
</VCardItem>
<VDivider />
<VProgressLinear v-if="versionStatisticLoading" indeterminate color="primary" />
<VCardText>
<div class="version-stat-summary">
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.totalInstallUsers') }}</div>
<div class="version-stat-number">{{ versionStatistic.total_users ?? 0 }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.activeToday') }}</div>
<div class="version-stat-number">{{ activeUsers.today ?? 0 }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</div>
<div class="version-stat-number">{{ activeUsers.last_7_days ?? 0 }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active30Days') }}</div>
<div class="version-stat-number">{{ activeUsers.last_30_days ?? 0 }}</div>
</div>
</div>
<div class="mt-5">
<div class="text-subtitle-2 mb-2">{{ t('setting.about.backendVersionStatistic') }}</div>
<VTable density="compact">
<thead>
<tr>
<th>{{ t('setting.about.version') }}</th>
<th class="text-end">{{ t('setting.about.users') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in backendVersionStatistics" :key="`backend-${item.version}`">
<td>
<code>{{ item.version }}</code>
</td>
<td class="text-end">{{ item.count }}</td>
</tr>
<tr v-if="!backendVersionStatistics.length">
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
</tr>
</tbody>
</VTable>
</div>
<div class="mt-5">
<div class="text-subtitle-2 mb-2">{{ t('setting.about.frontendVersionStatistic') }}</div>
<VTable density="compact">
<thead>
<tr>
<th>{{ t('setting.about.version') }}</th>
<th class="text-end">{{ t('setting.about.users') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in frontendVersionStatistics" :key="`frontend-${item.version}`">
<td>
<code>{{ item.version }}</code>
</td>
<td class="text-end">{{ item.count }}</td>
</tr>
<tr v-if="!frontendVersionStatistics.length">
<td colspan="2" class="text-medium-emphasis">{{ t('setting.about.noVersionStatisticData') }}</td>
</tr>
</tbody>
</VTable>
</div>
<div v-if="versionStatistic.updated_at" class="mt-4 text-caption text-medium-emphasis">
{{ t('setting.about.lastUpdated') }}: {{ versionStatistic.updated_at }}
</div>
</VCardText>
</VCard>
</VDialog>
</VDialog>
</template>
@@ -422,6 +554,18 @@ onMounted(() => {
margin-block: 0.5rem 2.5rem;
}
.version-stat-summary {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
}
.version-stat-number {
font-size: 1.5rem;
font-weight: 700;
line-height: 2rem;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3) {

View File

@@ -152,6 +152,7 @@ watch(
otpPassword.value = ''
}
},
{ immediate: true },
)
</script>

View File

@@ -113,10 +113,12 @@ const episodeFormatRecommendState = reactive<{
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
// 生成文件项稳定键,用于去重和状态同步。
function getFileItemKey(item?: FileItem) {
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
}
// 按存储、类型和路径去重文件项。
function dedupeFileItems(fileItems?: FileItem[]) {
if (!fileItems?.length) return []
@@ -128,6 +130,7 @@ function dedupeFileItems(fileItems?: FileItem[]) {
return Array.from(uniqueItems.values())
}
// 生成预览项稳定键,避免合并多次预览结果时重复展示。
function getPreviewItemKey(item: ManualTransferPreviewItem) {
return [item.source ?? '', item.target ?? '', item.success === false ? 'failed' : 'success'].join('|')
}
@@ -147,6 +150,15 @@ let previewFileBodyResizeObserver: ResizeObserver | undefined
// 所有存储
const storages = ref<StorageConf[]>([])
// 所有剧集组
const episodeGroups = ref<{ [key: string]: any }[]>([])
// 剧集组加载状态
const episodeGroupLoading = ref(false)
// 剧集组查询防抖句柄
let episodeGroupQueryTimer: ReturnType<typeof setTimeout> | undefined
// 查询存储
async function loadStorages() {
try {
@@ -166,6 +178,63 @@ const storageOptions = computed(() => {
}))
})
// 剧集组选项属性
function episodeGroupItemProps(item: { title: string; subtitle?: string }) {
return {
title: item.title,
subtitle: item.subtitle,
}
}
interface EpisodeGroupOption {
title: string
subtitle: string
value: string | null
}
// 剧集组选项,保留 null 作为不指定剧集组。
const episodeGroupOptions = computed<EpisodeGroupOption[]>(() => {
const options: EpisodeGroupOption[] = (
episodeGroups.value as { id: string; name: string; group_count: number; episode_count: number }[]
).map(item => {
return {
title: item.name,
subtitle: `${t('dialog.reorganize.seasonCount', { count: item.group_count })} • ${t(
'dialog.reorganize.episodeCount',
{ count: item.episode_count },
)}`,
value: item.id,
}
})
options.unshift({
title: t('dialog.reorganize.defaultEpisodeGroup'),
subtitle: t('dialog.reorganize.defaultEpisodeGroupHint'),
value: null,
})
return options
})
// 查询指定 TMDB 剧集的所有剧集组。
async function getEpisodeGroups(tmdbid?: number | string) {
const normalizedTmdbId = Number(tmdbid)
if (!Number.isInteger(normalizedTmdbId) || normalizedTmdbId <= 0) {
episodeGroups.value = []
return
}
episodeGroupLoading.value = true
try {
episodeGroups.value = await api.get(`media/groups/${normalizedTmdbId}`)
} catch (error) {
console.error(error)
episodeGroups.value = []
} finally {
episodeGroupLoading.value = false
}
}
// 标题
const dialogTitle = computed(() => {
return t('dialog.reorganize.manualTitle')
@@ -201,6 +270,7 @@ const transferForm = reactive<TransferForm>({
min_filesize: 0,
scrape: false,
from_history: false,
episode_group: null,
})
// 所有媒体库目录
@@ -249,6 +319,31 @@ watch(
},
)
// 监听 TMDB 编号变化,自动加载可用剧集组并清空旧选择。
watch(
() => transferForm.tmdbid,
tmdbid => {
transferForm.episode_group = null
episodeGroups.value = []
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
if (transferForm.type_name !== '电视剧' || mediaSource.value !== 'themoviedb') return
episodeGroupQueryTimer = setTimeout(() => getEpisodeGroups(tmdbid), 400)
},
)
// 切换媒体类型或识别源时,非 TMDB 电视剧不保留剧集组选择。
watch(
[() => transferForm.type_name, () => mediaSource.value],
([typeName, source]) => {
if (typeName === '电视剧' && source === 'themoviedb' && transferForm.tmdbid) {
getEpisodeGroups(transferForm.tmdbid)
return
}
transferForm.episode_group = null
episodeGroups.value = []
},
)
// 过滤后的预览数据
const filteredPreviewItems = computed(() => {
return previewData.value?.items ?? []
@@ -438,15 +533,38 @@ const previewToggleIcon = computed(() => {
return previewVisible.value ? 'mdi-eye-off-outline' : 'mdi-eye-outline'
})
// 获取文件父目录键,用于判断多文件是否来自同一目录。
function getFileParentKey(item?: FileItem) {
if (!item?.path) return ''
const storage = item.storage ?? 'local'
const pathParts = item.path.split('/')
pathParts.pop()
const parentPath = pathParts.join('/') || '/'
return `${storage}|${parentPath}`
}
const episodeFormatRecommendSelectedFileItems = computed(() => {
return shouldUseBatchFileItems(normalizedItems.value) ? normalizedItems.value : []
})
const episodeFormatRecommendHasValidSelectedFiles = computed(() => {
if (episodeFormatRecommendSelectedFileItems.value.length <= 1) return false
const directoryKeys = new Set(
episodeFormatRecommendSelectedFileItems.value.map(item => getFileParentKey(item)),
)
return directoryKeys.size === 1
})
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) &&
(Boolean(episodeFormatRecommendSourceItem.value?.path) ||
episodeFormatRecommendHasValidSelectedFiles.value) &&
!progressDialog.value &&
!episodeFormatRecommendState.loading
)
@@ -454,7 +572,15 @@ const canRecommendEpisodeFormat = computed(() => {
const episodeFormatRecommendTooltip = computed(() => {
if (episodeFormatRecommendState.loading) return t('dialog.reorganize.episodeFormatRecommendLoading')
if (!episodeFormatRecommendSourceItem.value?.path) return t('dialog.reorganize.episodeFormatRecommendSelectFile')
if (
normalizedItems.value.length > 1 &&
!episodeFormatRecommendHasValidSelectedFiles.value
) {
return t('dialog.reorganize.episodeFormatRecommendInvalidSelection')
}
if (!episodeFormatRecommendSourceItem.value?.path && !episodeFormatRecommendHasValidSelectedFiles.value) {
return t('dialog.reorganize.episodeFormatRecommendSelectFile')
}
if (episodeFormatRuleConfigured.value === false) return t('dialog.reorganize.episodeFormatRecommendNeedWords')
return t('dialog.reorganize.episodeFormatRecommendAction')
})
@@ -472,14 +598,38 @@ watch(
{ immediate: true },
)
// 判断文件集合是否可以按批量文件请求提交。
function shouldUseBatchFileItems(items: FileItem[]) {
return items.length > 0 && items.every(item => item.type === 'file')
}
// 生成批量文件在提示和错误信息中的显示名称。
function getBatchItemsLabel(items: FileItem[]) {
if (items.length === 1) return items[0].path || items[0].name
return t('dialog.reorganize.multipleItemsTitle', { count: items.length })
}
// 构造整理请求
function createTransferPayload(options: { item?: FileItem; logid?: number; preview?: boolean }) {
function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; logid?: number; preview?: boolean }) {
const sourceItem =
options.item ??
(options.items?.length
? options.items[0]
: ({} as FileItem))
const payload: ManualTransferPayload = {
...transferForm,
fileitem: options.item ?? ({} as FileItem),
fileitem: sourceItem,
logid: options.logid ?? 0,
episode_group: transferForm.episode_group?.trim() || null,
}
if (options.items?.length) {
payload.fileitems = options.items
if (!options.item) {
// 文件集合请求以 fileitems 为准,避免残留 fileitem 状态把请求误导成目录语义。
delete payload.fileitem
}
}
if (options.preview) payload.preview = true
return payload
}
@@ -489,9 +639,10 @@ async function requestManualTransfer<T = any>(
payload: ManualTransferPayload,
background: boolean = false,
): Promise<ApiResponse<T>> {
return await api.post(`transfer/manual?background=${background}`, payload)
return await api.post<ApiResponse<T>, ApiResponse<T>>(`transfer/manual?background=${background}`, payload)
}
// 加载剧集格式规则配置状态,用于决定是否允许自动推荐。
async function loadEpisodeFormatRuleConfiguration() {
try {
const result: { [key: string]: any } = await api.get('system/setting/EpisodeFormatRuleTable')
@@ -502,10 +653,17 @@ async function loadEpisodeFormatRuleConfiguration() {
}
}
// 根据当前文件或同目录多文件请求推荐剧集格式。
async function handleRecommendEpisodeFormat() {
const sourceItem = episodeFormatRecommendSourceItem.value
if (!sourceItem?.path) {
$toast.warning(t('dialog.reorganize.episodeFormatRecommendSelectFile'))
const selectedFileItems = episodeFormatRecommendSelectedFileItems.value
const hasValidSelectedFiles = episodeFormatRecommendHasValidSelectedFiles.value
if (!sourceItem?.path && !hasValidSelectedFiles) {
$toast.warning(
normalizedItems.value.length > 1
? t('dialog.reorganize.episodeFormatRecommendInvalidSelection')
: t('dialog.reorganize.episodeFormatRecommendSelectFile'),
)
return
}
@@ -518,16 +676,23 @@ async function handleRecommendEpisodeFormat() {
try {
const hasExistingEpisodeFormat = Boolean(transferForm.episode_format?.trim())
const result = await api.post('transfer/episode-format/recommend', {
fileitem: sourceItem,
})
const result = await api.post<ApiResponse<EpisodeFormatRecommendData>, ApiResponse<EpisodeFormatRecommendData>>(
'transfer/episode-format/recommend',
hasValidSelectedFiles
? {
fileitems: selectedFileItems,
}
: {
fileitem: sourceItem,
},
)
if (!result.success) {
$toast.error(result.message || t('dialog.reorganize.episodeFormatRecommendFailed'))
return
}
const data = (result.data ?? {}) as EpisodeFormatRecommendData
const data = result.data ?? {}
if (!data.episode_format) {
$toast.error(t('dialog.reorganize.episodeFormatRecommendFailed'))
return
@@ -551,7 +716,7 @@ async function handleRecommendEpisodeFormat() {
}
}
// 默认预览数据
// 创建空预览数据,作为多次预览结果的合并目标。
function getDefaultPreviewData(): ManualTransferPreviewData {
return {
summary: {
@@ -564,18 +729,21 @@ function getDefaultPreviewData(): ManualTransferPreviewData {
}
}
// 重置预览数据和分页状态。
function resetPreviewState() {
previewData.value = undefined
previewLoaded.value = false
previewPage.value = 1
}
// 判断预览结果中是否存在失败项。
function previewHasFailures(data?: ManualTransferPreviewData) {
if (!data) return false
return (data.summary.failed ?? 0) > 0 || (data.items ?? []).some(item => item.success === false)
}
// 生成预览结果成功和失败数量摘要。
function getPreviewResultSummaryMessage(data?: ManualTransferPreviewData) {
const success = data?.summary.success ?? 0
const failed = data?.summary.failed ?? 0
@@ -586,6 +754,7 @@ function getPreviewResultSummaryMessage(data?: ManualTransferPreviewData) {
].join('')
}
// 构造单条失败预览数据,便于把异常请求合并到预览列表。
function createFailedPreviewData(options: { source?: string; type?: string; title?: string; message?: string }) {
const failedItem: ManualTransferPreviewItem = {
source: options.source,
@@ -645,40 +814,68 @@ async function previewTransfer() {
const tasks: Promise<void>[] = []
if (normalizedItems.value.length) {
tasks.push(
...normalizedItems.value.map(async item => {
try {
const result = await requestManualTransfer<ManualTransferPreviewData>(
createTransferPayload({ item, preview: true }),
if (shouldUseBatchFileItems(normalizedItems.value)) {
try {
const result = await requestManualTransfer<ManualTransferPreviewData>(
createTransferPayload({ items: normalizedItems.value, preview: true }),
)
if (!result.success) {
mergePreviewData(
mergedPreviewData,
createFailedPreviewData({
source: getBatchItemsLabel(normalizedItems.value),
message: result.message || t('dialog.reorganize.previewRequestFailed'),
}),
)
if (!result.success) {
} else {
mergePreviewData(mergedPreviewData, result.data)
}
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
mergePreviewData(
mergedPreviewData,
createFailedPreviewData({
source: getBatchItemsLabel(normalizedItems.value),
message: `${getBatchItemsLabel(normalizedItems.value)}: ${err?.message || t('dialog.reorganize.previewRequestFailed')}`,
}),
)
}
} else {
tasks.push(
...normalizedItems.value.map(async item => {
try {
const result = await requestManualTransfer<ManualTransferPreviewData>(
createTransferPayload({ item, preview: true }),
)
if (!result.success) {
mergePreviewData(
mergedPreviewData,
createFailedPreviewData({
source: item.path || item.name,
type: item.type,
title: item.name,
message: result.message || t('dialog.reorganize.previewRequestFailed'),
}),
)
return
}
mergePreviewData(mergedPreviewData, result.data)
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
mergePreviewData(
mergedPreviewData,
createFailedPreviewData({
source: item.path || item.name,
type: item.type,
title: item.name,
message: result.message || t('dialog.reorganize.previewRequestFailed'),
message: `${item.name || item.path}: ${err?.message || t('dialog.reorganize.previewRequestFailed')}`,
}),
)
return
}
mergePreviewData(mergedPreviewData, result.data)
} catch (err: any) {
console.warn(`预览请求异常: ${err?.message}`)
mergePreviewData(
mergedPreviewData,
createFailedPreviewData({
source: item.path || item.name,
type: item.type,
title: item.name,
message: `${item.name || item.path}: ${err?.message || t('dialog.reorganize.previewRequestFailed')}`,
}),
)
}
}),
)
}),
)
}
}
if (props.logids) {
@@ -732,6 +929,7 @@ async function previewTransfer() {
}
}
// 切换预览面板,首次展开时拉取最新预览结果。
async function togglePreview() {
if (previewLoading.value) return
@@ -794,6 +992,17 @@ async function handleTransfer(item: FileItem, background: boolean = false) {
}
}
// 批量整理文件并按后台模式决定是否提示入队成功。
async function handleTransferBatch(items: FileItem[], background: boolean = false) {
try {
const result: { [key: string]: any } = await requestManualTransfer(createTransferPayload({ items }), background)
if (!result.success) $toast.error(result.message)
else if (background) $toast.success(t('dialog.reorganize.successMessage', { name: getBatchItemsLabel(items) }))
} catch (e) {
console.log(e)
}
}
// 整理日志
async function handleTransferLog(logid: number, background: boolean = false) {
try {
@@ -850,15 +1059,22 @@ async function transfer(background: boolean = false) {
// 文件整理
if (normalizedItems.value.length) {
for (const item of normalizedItems.value) {
if (shouldUseBatchFileItems(normalizedItems.value)) {
if (!background) {
// 如果是文件计算MD5
const key = item.type === 'dir' ? 'filetransfer' : CryptoJS.MD5(item.path).toString()
// 开始监听进度
startLoadingProgress(key)
startLoadingProgress('filetransfer')
}
await handleTransferBatch(normalizedItems.value, background)
} else {
for (const item of normalizedItems.value) {
if (!background) {
// 如果是文件计算MD5
const key = item.type === 'dir' ? 'filetransfer' : CryptoJS.MD5(item.path).toString()
// 开始监听进度
startLoadingProgress(key)
}
await handleTransfer(item, background)
}
await handleTransfer(item, background)
}
}
@@ -891,6 +1107,7 @@ onMounted(() => {
onUnmounted(() => {
stopLoadingProgress()
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
previewFileBodyResizeObserver?.disconnect()
})
</script>
@@ -1000,9 +1217,14 @@ onUnmounted(() => {
</VCol>
</VRow>
<VRow v-show="transferForm.type_name === '电视剧'">
<VCol cols="12" md="6">
<VTextField
<VCol v-if="mediaSource === 'themoviedb'" cols="12" md="6">
<VCombobox
v-model="transferForm.episode_group"
:items="episodeGroupOptions"
:item-props="episodeGroupItemProps"
:loading="episodeGroupLoading"
:disabled="!transferForm.tmdbid"
clearable
:label="t('dialog.reorganize.episodeGroup')"
:placeholder="t('dialog.reorganize.episodeGroupPlaceholder')"
:hint="t('dialog.reorganize.episodeGroupHint')"

View File

@@ -772,19 +772,16 @@ onUnmounted(() => {
rounded
/>
<VSpacer v-if="isFile" />
<IconBtn v-if="!isFile" @click="ignoreCase = !ignoreCase">
<IconBtn v-if="!isFile && !selectMode" @click="ignoreCase = !ignoreCase">
<VIcon :color="ignoreCase ? 'primary' : 'error'" icon="mdi-format-letter-case" />
</IconBtn>
<IconBtn v-if="!isFile" @click="changeSelectMode">
<VIcon color="primary" :icon="selectMode ? 'mdi-selection-remove' : 'mdi-select'" />
</IconBtn>
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
<VIcon color="primary"> mdi-text-recognition </VIcon>
</IconBtn>
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
<VIcon color="primary"> mdi-download </VIcon>
</IconBtn>
<IconBtn v-if="!isFile" @click="list_files">
<IconBtn v-if="!isFile && !selectMode" @click="list_files">
<VIcon color="primary"> mdi-refresh </VIcon>
</IconBtn>
<!-- 批量操作按钮 -->
@@ -799,6 +796,9 @@ onUnmounted(() => {
<VIcon icon="mdi-delete-outline" color="error" />
</IconBtn>
</span>
<IconBtn v-if="!isFile" @click="changeSelectMode">
<VIcon color="primary" :icon="selectMode ? 'mdi-selection-remove' : 'mdi-select'" />
</IconBtn>
</div>
<LoadingBanner v-if="loading" />
<!-- 文件详情 -->

View File

@@ -10,6 +10,7 @@ interface DynamicHeaderTabButton {
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string // 用于VMenu定位的data属性
}

View File

@@ -79,6 +79,7 @@ interface DynamicHeaderTab {
class?: string
action?: () => void
show?: boolean | ComputedRef<boolean>
loading?: boolean | ComputedRef<boolean>
dataAttr?: string
}>
routePath?: string // 用于标识哪个路由注册的
@@ -395,6 +396,7 @@ onMounted(async () => {
:color="typeof button.color === 'string' ? button.color : (button.color as any)?.value || 'gray'"
:size="button.size || 'default'"
:class="button.class || 'settings-icon-button'"
:loading="typeof button.loading === 'boolean' ? button.loading : (button.loading as any)?.value || false"
:data-menu-activator="button.dataAttr"
@click="button.action"
/>

View File

@@ -986,11 +986,22 @@ export default {
bestVersion: 'Version Upgrading',
bestVersionEpisodeShort: 'Episode',
bestVersionWholeShort: 'Full',
bestVersionEpisodeProgressTooltip: 'Upgraded {completed} · Downloaded {downloaded} · Total {total}',
subscribeProgressTooltip: 'Downloaded {downloaded} · Total {total}',
completed: 'Completed',
subscribing: 'Subscribing',
notStarted: 'Not Started',
pending: 'Pending',
paused: 'Paused',
cardStatePaused: 'Paused',
cardStatePending: 'Pending',
sortTitle: 'Sort',
sort: {
custom: 'Custom',
lastUpdate: 'Last Updated',
addTime: 'Added Time',
lackEpisode: 'Missing Episodes',
},
selectedCount: 'Selected {count}/{total} items',
noSelectedItems: 'Please select subscriptions to operate',
batchEnable: 'Batch Enable',
@@ -1367,6 +1378,18 @@ export default {
expand: 'Expand',
collapse: 'Collapse',
clearCache: 'Clear Cache',
versionStatistic: 'Version Statistics',
versionStatisticTitle: 'Installation Version Statistics',
totalInstallUsers: 'Install Users',
activeToday: 'Active Today',
active7Days: 'Active 7 Days',
active30Days: 'Active 30 Days',
backendVersionStatistic: 'Backend Versions',
frontendVersionStatistic: 'Frontend Versions',
version: 'Version',
users: 'Users',
lastUpdated: 'Updated At',
noVersionStatisticData: 'No statistics data',
},
system: {
custom: 'Custom',
@@ -1447,15 +1470,16 @@ export default {
llmProviderCheckAuthStatus: 'Check Authorization Status',
audioInputProvider: 'Audio Input Provider',
audioInputProviderHint:
'Service used to transcribe incoming audio messages. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
'Service used to transcribe incoming audio messages. Supports OpenAI audio, Chat Audio compatible APIs, Xiaomi MiMo, and MiniMax.',
audioProviderOpenAiAudio: 'OpenAI Audio Compatible',
audioProviderChatAudio: 'Chat Audio Compatible',
audioProviderMimo: 'Xiaomi MiMo',
audioProviderMinimax: 'MiniMax',
audioInputApiKey: 'Audio Input API Key',
audioInputApiKeyHint: 'API key used for audio transcription.',
audioInputBaseUrl: 'Audio Input Base URL',
audioInputBaseUrlHint:
'Base URL for audio input. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
'Base URL for audio input. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1, MiniMax defaults to https://api.minimaxi.com/v1.',
audioInputModel: 'Audio Input Model',
audioInputModelHint: 'Model name used to convert audio content into text.',
audioInputLanguage: 'Recognition Language',
@@ -1463,12 +1487,12 @@ export default {
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
audioOutputProvider: 'Audio Output Provider',
audioOutputProviderHint:
'Service used to generate voice replies. Supports OpenAI audio, Chat Audio compatible APIs, and Xiaomi MiMo.',
'Service used to generate voice replies. Supports OpenAI audio, Chat Audio compatible APIs, Xiaomi MiMo, and MiniMax.',
audioOutputApiKey: 'Audio Output API Key',
audioOutputApiKeyHint: 'API key used for speech synthesis.',
audioOutputBaseUrl: 'Audio Output Base URL',
audioOutputBaseUrlHint:
'Base URL for audio output. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1.',
'Base URL for audio output. Use the matching compatible endpoint for Chat Audio services; MiMo defaults to https://api.xiaomimimo.com/v1, MiniMax defaults to https://api.minimaxi.com/v1.',
audioOutputModel: 'Audio Output Model',
audioOutputModelHint: 'Model name used to convert text content into speech.',
audioOutputVoice: 'Voice Preset',
@@ -1550,6 +1574,9 @@ export default {
'Share subscription statistics to popular subscriptions for other MP users to reference',
pluginStatisticShare: 'Report Plugin Installation Data',
pluginStatisticShareHint: 'Report plugin installation data to the server for statistics and display purposes',
usageStatisticShare: 'Report Installation Version Statistics',
usageStatisticShareHint:
'Report anonymous installation ID and current backend/frontend versions to count users by version',
workflowStatisticShare: 'Share Workflow Data',
workflowStatisticShareHint: 'Share workflow statistics to popular workflows for other MP users to reference',
bigMemoryMode: 'Large Memory Mode',
@@ -1643,6 +1670,9 @@ export default {
encodingDetectionPerformanceMode: 'Encoding Detection Performance Mode',
encodingDetectionPerformanceModeHint:
'Prioritize detection efficiency, but may reduce encoding detection accuracy',
rustAccel: 'Rust Acceleration',
rustAccelHint: 'Use the backend Rust extension to accelerate filtering, RSS, indexer parsing, and recognition hot paths',
rustAccelUnavailableHint: 'The backend Rust acceleration extension is not installed or loaded, so this cannot be enabled',
transferThreads: 'File Transfer Threads',
transferThreadsHint: 'Multi-threaded file transfer can improve speed but may increase system resource usage',
tokenizedSearch: 'Tokenized Search',
@@ -1690,6 +1720,11 @@ export default {
securityImageDomainsHint: 'Allowed image domains whitelist for caching, used to control trusted image sources',
noSecurityImageDomains: 'No security domains',
securityImageDomainAdd: 'Add domain, e.g.: image.tmdb.org',
imageProxyAllowedPrivateRanges: 'Image Proxy Allowed Private Ranges',
imageProxyAllowedPrivateRangesHint:
'Only applies after a URL matches a security image domain. Use for TUN mappings or internal CDNs; broad ranges weaken SSRF protection',
noImageProxyAllowedPrivateRanges: 'No allowed ranges',
imageProxyAllowedPrivateRangeAdd: 'Add CIDR, e.g.: 198.18.0.0/15',
proxyHost: 'Proxy Server',
proxyHostHint: 'Set proxy server address, support: http(s), socks5, socks5h, etc.',
moviePilotAutoUpdate: 'Auto Update MoviePilot',
@@ -1845,7 +1880,7 @@ export default {
'Word to replace => Replacement\n' +
'Front word <> Back word >> Episode offset (EP)\n' +
'Word to replace => Replacement && Front word <> Back word >> Episode offset (EP)\n' +
'Replacement format supports: &#123;[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]&#125; to directly specify TMDBID/Douban ID, where s and e are season and episode numbers (optional)',
'Replacement format supports: &#123;[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; to directly specify TMDBID/Douban ID, where g is the episode group ID and s/e are season and episode numbers (all optional)',
identifierSaveSuccess: 'Custom identifiers saved successfully',
identifierSaveFailed: 'Failed to save custom identifiers!',
@@ -2539,9 +2574,13 @@ export default {
doubanId: 'Douban ID',
mediaIdHint: 'Query media ID by name, leave empty for auto recognition',
mediaIdPlaceholder: 'Leave empty for auto recognition',
episodeGroup: 'Episode Group ID',
episodeGroupHint: 'Specify episode group',
episodeGroupPlaceholder: 'Manually query episode group',
episodeGroup: 'Episode Group',
episodeGroupHint: 'After entering a TMDB ID, episode groups are queried automatically; group IDs can still be entered manually',
episodeGroupPlaceholder: 'Enter TMDB ID first',
defaultEpisodeGroup: 'No episode group',
defaultEpisodeGroupHint: 'Use TMDB default season and episode order',
seasonCount: '{count} seasons',
episodeCount: '{count} episodes',
season: 'Season',
seasonHint: 'Which season',
episodeDetail: 'Episode',
@@ -2552,7 +2591,8 @@ export default {
episodeFormatPlaceholder: 'Use {ep} to position episode',
episodeFormatRecommendAction: 'Generate',
episodeFormatRecommendLoading: 'Generating...',
episodeFormatRecommendSelectFile: 'Please select a single file or directory first',
episodeFormatRecommendSelectFile: 'Please select a single file, a single directory, or multiple files first',
episodeFormatRecommendInvalidSelection: 'Current selection cannot be used for template generation',
episodeFormatRecommendNeedWords:
'Manual episode positioning rules are empty, please fill them in on the words page first',
episodeFormatRecommendSuccess: 'Episode format template generated',
@@ -2654,7 +2694,7 @@ export default {
customWords: 'Custom Recognition Words',
customWordsHint: 'Recognition words only used for this subscription',
customWordsPlaceholder:
'Block word\nReplaced word => Replacement word\nPrefix <> Suffix >> Episode offset (EP)\nReplaced word => Replacement word && Prefix <> Suffix >> Episode offset (EP)\nReplacement word supports format: &#123; tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx &#125; to directly specify TMDBID/Douban ID recognition, where s, e are season and episode numbers (optional)',
'Block word\nReplaced word => Replacement word\nPrefix <> Suffix >> Episode offset (EP)\nReplaced word => Replacement word && Prefix <> Suffix >> Episode offset (EP)\nReplacement word supports format: &#123;[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; to directly specify TMDBID/Douban ID recognition, where g is the episode group ID and s/e are season and episode numbers (all optional)',
cancelSubscribe: 'Cancel Subscription',
save: 'Save',
cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?',

View File

@@ -981,11 +981,22 @@ export default {
bestVersion: '洗版中',
bestVersionEpisodeShort: '分集',
bestVersionWholeShort: '全集',
bestVersionEpisodeProgressTooltip: '已洗版 {completed} · 已下载 {downloaded} · 共 {total} 集',
subscribeProgressTooltip: '已下载 {downloaded} · 共 {total} 集',
completed: '订阅完成',
subscribing: '订阅中',
notStarted: '未开始',
pending: '待定',
paused: '暂停',
cardStatePaused: '已暂停',
cardStatePending: '待定中',
sortTitle: '排序',
sort: {
custom: '自定义',
lastUpdate: '最后更新时间',
addTime: '添加时间',
lackEpisode: '缺失集数',
},
selectedCount: '已选择 {count}/{total} 项',
noSelectedItems: '请先选择要操作的订阅',
batchEnable: '批量启用',
@@ -1362,6 +1373,18 @@ export default {
expand: '展开',
collapse: '收起',
clearCache: '清除缓存',
versionStatistic: '版本统计',
versionStatisticTitle: '安装版本统计',
totalInstallUsers: '安装用户',
activeToday: '今日活跃',
active7Days: '7日活跃',
active30Days: '30日活跃',
backendVersionStatistic: '后端版本',
frontendVersionStatistic: '前端版本',
version: '版本',
users: '用户数',
lastUpdated: '更新时间',
noVersionStatisticData: '暂无统计数据',
},
system: {
custom: '自定义',
@@ -1436,26 +1459,29 @@ export default {
llmProviderOpenAuthPage: '打开授权页面',
llmProviderCheckAuthStatus: '检查授权状态',
audioInputProvider: '音频输入提供商',
audioInputProviderHint: '用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioInputProviderHint:
'用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
audioProviderChatAudio: 'Chat Audio 兼容',
audioProviderMimo: '小米 MiMo',
audioProviderMinimax: 'MiniMax',
audioInputApiKey: '音频输入 API密钥',
audioInputApiKeyHint: '音频输入转写使用的 API 密钥',
audioInputBaseUrl: '音频输入基础URL',
audioInputBaseUrlHint:
'音频输入接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1',
'音频输入接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1MiniMax 默认 https://api.minimaxi.com/v1',
audioInputModel: '音频输入模型',
audioInputModelHint: '用于将音频内容转换为文字的模型名称',
audioInputLanguage: '识别语言',
audioInputLanguageHint: '音频转写默认语言,例如 zh、en留空时按后端默认处理',
audioOutputProvider: '音频输出提供商',
audioOutputProviderHint: '用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioOutputProviderHint:
'用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioOutputApiKey: '音频输出 API密钥',
audioOutputApiKeyHint: '文字转语音使用的 API 密钥',
audioOutputBaseUrl: '音频输出基础URL',
audioOutputBaseUrlHint:
'音频输出接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1',
'音频输出接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1MiniMax 默认 https://api.minimaxi.com/v1',
audioOutputModel: '音频输出模型',
audioOutputModelHint: '用于将文字内容转换为语音的模型名称',
audioOutputVoice: '语音音色',
@@ -1534,6 +1560,8 @@ export default {
subscribeStatisticShareHint: '分享订阅统计数据到热门订阅供其他MPer参考',
pluginStatisticShare: '上报插件安装数据',
pluginStatisticShareHint: '上报插件安装数据给服务器,用于统计展示插件安装情况',
usageStatisticShare: '上报安装版本统计',
usageStatisticShareHint: '上报匿名安装ID和当前前后端版本用于统计各版本安装用户数',
workflowStatisticShare: '分享工作流数据',
workflowStatisticShareHint: '分享工作流统计数据到热门工作流供其他MPer参考',
bigMemoryMode: '大内存模式',
@@ -1618,6 +1646,9 @@ export default {
pluginLocalRepoPathsHint: '本地插件仓库目录,多个目录用英文逗号分隔,支持相对路径和绝对路径',
encodingDetectionPerformanceMode: '编码探测性能模式',
encodingDetectionPerformanceModeHint: '优先提升探测效率,但可能降低编码探测的准确性',
rustAccel: 'Rust 加速',
rustAccelHint: '启用后使用后端 Rust 扩展加速过滤、RSS、索引器和媒体识别等热路径',
rustAccelUnavailableHint: '当前后端未安装或未加载 Rust 加速扩展,无法启用',
transferThreads: '文件整理线程数',
transferThreadsHint: '多线程整理文件可以提高速度,但可能增加系统资源占用',
tokenizedSearch: '分词搜索',
@@ -1663,6 +1694,11 @@ export default {
securityImageDomainsHint: '允许缓存的图片域名白名单,用于控制可信任的图片来源',
noSecurityImageDomains: '暂无安全域名',
securityImageDomainAdd: '添加域名image.tmdb.org',
imageProxyAllowedPrivateRanges: '图片代理允许非公网网段',
imageProxyAllowedPrivateRangesHint:
'仅对已命中安全图片域名的地址生效,用于 TUN 映射、内网 CDN 等特殊场景;配置过宽会降低 SSRF 防护强度',
noImageProxyAllowedPrivateRanges: '暂无允许网段',
imageProxyAllowedPrivateRangeAdd: '添加 CIDR198.18.0.0/15',
proxyHost: '代理服务器',
proxyHostHint: '设置代理服务器地址支持http(s)、socks5、socks5h 等协议',
moviePilotAutoUpdate: '自动更新MoviePilot',
@@ -1814,7 +1850,7 @@ export default {
'被替换词 => 替换词\n' +
'前定位词 <> 后定位词 >> 集偏移量EP\n' +
'被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量EP\n' +
'其中替换词支持格式:&#123;[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]&#125; 直接指定TMDBID/豆瓣ID识别其中s、e为季数和集数可选',
'其中替换词支持格式:&#123;[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定TMDBID/豆瓣ID识别其中g为剧集组编号s、e为季数和集数可选)',
identifierSaveSuccess: '自定义识别词保存成功',
identifierSaveFailed: '自定义识别词保存失败!',
@@ -2493,9 +2529,13 @@ export default {
doubanId: '豆瓣编号',
mediaIdHint: '按名称查询媒体编号,留空自动识别',
mediaIdPlaceholder: '留空自动识别',
episodeGroup: '剧集组编号',
episodeGroupHint: '指定剧集组',
episodeGroupPlaceholder: '手动查询剧集组',
episodeGroup: '剧集组',
episodeGroupHint: '输入 TMDB 编号后自动查询剧集组,也可手动填写剧集组编号',
episodeGroupPlaceholder: '先输入 TMDB 编号',
defaultEpisodeGroup: '不指定剧集组',
defaultEpisodeGroupHint: '使用 TMDB 默认季集排序',
seasonCount: '{count} 季',
episodeCount: '{count} 集',
season: '季',
seasonHint: '第几季',
episodeDetail: '集',
@@ -2506,7 +2546,8 @@ export default {
episodeFormatPlaceholder: '使用{ep}定位集数',
episodeFormatRecommendAction: '智能生成',
episodeFormatRecommendLoading: '生成中...',
episodeFormatRecommendSelectFile: '请先选择单个文件或目录',
episodeFormatRecommendSelectFile: '请先选择单个文件、单个目录,或多个文件',
episodeFormatRecommendInvalidSelection: '当前选择不满足智能生成条件',
episodeFormatRecommendNeedWords: '手动整理集数定位规则为空,请先前往词表填写',
episodeFormatRecommendSuccess: '已生成集数定位模板',
episodeFormatRecommendOverwriteSuccess: '已用智能生成结果覆盖当前集数定位',
@@ -2607,7 +2648,7 @@ export default {
customWords: '自定义识别词',
customWordsHint: '只对该订阅使用的识别词',
customWordsPlaceholder:
'屏蔽词\n被替换词 => 替换词\n前定位词 <> 后定位词 >> 集偏移量EP\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量EP\n其中替换词支持格式&#123; tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx &#125; 直接指定TMDBID/豆瓣ID识别其中s、e为季数和集数可选',
'屏蔽词\n被替换词 => 替换词\n前定位词 <> 后定位词 >> 集偏移量EP\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量EP\n其中替换词支持格式&#123;[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定TMDBID/豆瓣ID识别其中g为剧集组编号s、e为季数和集数可选)',
cancelSubscribe: '取消订阅',
save: '保存',
cancelSubscribeConfirm: '是否确认取消订阅?',

View File

@@ -981,11 +981,22 @@ export default {
bestVersion: '洗版中',
bestVersionEpisodeShort: '分集',
bestVersionWholeShort: '全集',
bestVersionEpisodeProgressTooltip: '已洗版 {completed} · 已下載 {downloaded} · 共 {total} 集',
subscribeProgressTooltip: '已下載 {downloaded} · 共 {total} 集',
completed: '訂閱完成',
subscribing: '訂閱中',
notStarted: '未開始',
pending: '待定',
paused: '暫停',
cardStatePaused: '已暫停',
cardStatePending: '待定中',
sortTitle: '排序',
sort: {
custom: '自定義',
lastUpdate: '最後更新時間',
addTime: '添加時間',
lackEpisode: '缺失集數',
},
selectedCount: '已選擇 {count}/{total} 項',
noSelectedItems: '請先選擇要操作的訂閱',
batchEnable: '批量啟用',
@@ -1363,6 +1374,18 @@ export default {
expand: '展開',
collapse: '收起',
clearCache: '清除快取',
versionStatistic: '版本統計',
versionStatisticTitle: '安裝版本統計',
totalInstallUsers: '安裝用戶',
activeToday: '今日活躍',
active7Days: '7日活躍',
active30Days: '30日活躍',
backendVersionStatistic: '後端版本',
frontendVersionStatistic: '前端版本',
version: '版本',
users: '用戶數',
lastUpdated: '更新時間',
noVersionStatisticData: '暫無統計數據',
},
system: {
custom: '自定義',
@@ -1437,26 +1460,29 @@ export default {
llmProviderOpenAuthPage: '開啟授權頁面',
llmProviderCheckAuthStatus: '檢查授權狀態',
audioInputProvider: '音頻輸入提供商',
audioInputProviderHint: '用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioInputProviderHint:
'用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
audioProviderChatAudio: 'Chat Audio 兼容',
audioProviderMimo: '小米 MiMo',
audioProviderMinimax: 'MiniMax',
audioInputApiKey: '音頻輸入 API密鑰',
audioInputApiKeyHint: '音頻輸入轉寫使用的 API 密鑰',
audioInputBaseUrl: '音頻輸入基礎URL',
audioInputBaseUrlHint:
'音頻輸入接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1',
'音頻輸入接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1MiniMax 預設 https://api.minimaxi.com/v1',
audioInputModel: '音頻輸入模型',
audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱',
audioInputLanguage: '識別語言',
audioInputLanguageHint: '音頻轉寫預設語言,例如 zh、en留空時按後端預設處理',
audioOutputProvider: '音頻輸出提供商',
audioOutputProviderHint: '用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
audioOutputProviderHint:
'用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioOutputApiKey: '音頻輸出 API密鑰',
audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰',
audioOutputBaseUrl: '音頻輸出基礎URL',
audioOutputBaseUrlHint:
'音頻輸出接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1',
'音頻輸出接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1MiniMax 預設 https://api.minimaxi.com/v1',
audioOutputModel: '音頻輸出模型',
audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱',
audioOutputVoice: '語音音色',
@@ -1535,6 +1561,8 @@ export default {
subscribeStatisticShareHint: '分享訂閱統計數據到熱門訂閱供其他MPer參考',
pluginStatisticShare: '上報插件安裝數據',
pluginStatisticShareHint: '上報插件安裝數據給服務器,用於統計展示插件安裝情況',
usageStatisticShare: '上報安裝版本統計',
usageStatisticShareHint: '上報匿名安裝ID和當前前後端版本用於統計各版本安裝用戶數',
workflowStatisticShare: '分享工作流數據',
workflowStatisticShareHint: '分享工作流統計數據到熱門工作流供其他MPer參考',
bigMemoryMode: '大內存模式',
@@ -1619,6 +1647,9 @@ export default {
pluginLocalRepoPathsHint: '本地插件倉庫目錄,多個目錄用英文逗號分隔,支持相對路徑和絕對路徑',
encodingDetectionPerformanceMode: '編碼探測性能模式',
encodingDetectionPerformanceModeHint: '優先提升探測效率,但可能降低編碼探測的準確性',
rustAccel: 'Rust 加速',
rustAccelHint: '啟用後使用後端 Rust 擴展加速過濾、RSS、索引器和媒體識別等熱路徑',
rustAccelUnavailableHint: '當前後端未安裝或未加載 Rust 加速擴展,無法啟用',
transferThreads: '文件整理線程數',
transferThreadsHint: '多線程整理文件可以提高速度,但可能增加系統資源佔用',
tokenizedSearch: '分詞搜索',
@@ -1664,6 +1695,11 @@ export default {
securityImageDomainsHint: '允許緩存的圖片域名白名單,用於控制可信任的圖片來源',
noSecurityImageDomains: '暫無安全域名',
securityImageDomainAdd: '添加域名image.tmdb.org',
imageProxyAllowedPrivateRanges: '圖片代理允許非公網網段',
imageProxyAllowedPrivateRangesHint:
'僅對已命中安全圖片域名的地址生效,用於 TUN 映射、內網 CDN 等特殊場景;配置過寬會降低 SSRF 防護強度',
noImageProxyAllowedPrivateRanges: '暫無允許網段',
imageProxyAllowedPrivateRangeAdd: '添加 CIDR198.18.0.0/15',
proxyHost: '代理服務器',
proxyHostHint: '設置代理服務器地址支持http(s)、socks5、socks5h 等協議',
moviePilotAutoUpdate: '自動更新MoviePilot',
@@ -1815,7 +1851,7 @@ export default {
'被替換詞 => 替換詞\n' +
'前定位詞 <> 後定位詞 >> 集偏移量EP\n' +
'被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量EP\n' +
'其中替換詞支持格式:&#123;[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]&#125; 直接指定TMDBID/豆瓣ID識別其中s、e為季數和集數可選',
'其中替換詞支持格式:&#123;[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定TMDBID/豆瓣ID識別其中g為劇集組編號s、e為季數和集數可選)',
identifierSaveSuccess: '自定義識別詞保存成功',
identifierSaveFailed: '自定義識別詞保存失敗!',
@@ -2494,9 +2530,13 @@ export default {
doubanId: '豆瓣編號',
mediaIdHint: '按名稱查詢媒體編號,留空自動識別',
mediaIdPlaceholder: '留空自動識別',
episodeGroup: '劇集組編號',
episodeGroupHint: '指定劇集組',
episodeGroupPlaceholder: '手動查詢劇集組',
episodeGroup: '劇集組',
episodeGroupHint: '輸入 TMDB 編號後自動查詢劇集組,也可手動填寫劇集組編號',
episodeGroupPlaceholder: '先輸入 TMDB 編號',
defaultEpisodeGroup: '不指定劇集組',
defaultEpisodeGroupHint: '使用 TMDB 預設季集排序',
seasonCount: '{count} 季',
episodeCount: '{count} 集',
season: '季',
seasonHint: '第幾季',
episodeDetail: '集',
@@ -2507,7 +2547,8 @@ export default {
episodeFormatPlaceholder: '使用{ep}定位集數',
episodeFormatRecommendAction: '智能生成',
episodeFormatRecommendLoading: '生成中...',
episodeFormatRecommendSelectFile: '請先選擇單個文件或目錄',
episodeFormatRecommendSelectFile: '請先選擇單個文件、單個目錄,或多個文件',
episodeFormatRecommendInvalidSelection: '當前選擇不滿足智能生成條件',
episodeFormatRecommendNeedWords: '手動整理集數定位規則為空,請先前往詞表填寫',
episodeFormatRecommendSuccess: '已生成集數定位模板',
episodeFormatRecommendOverwriteSuccess: '已用智能生成結果覆蓋當前集數定位',
@@ -2608,7 +2649,7 @@ export default {
customWords: '自定義識別詞',
customWordsHint: '只對該訂閱使用的識別詞',
customWordsPlaceholder:
'屏蔽詞\n被替換詞 => 替換詞\n前定位詞 <> 後定位詞 >> 集偏移量EP\n被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量EP\n其中替換詞支援格式&#123; tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx &#125; 直接指定TMDBID/豆瓣ID識別其中s、e為季數和集數可選',
'屏蔽詞\n被替換詞 => 替換詞\n前定位詞 <> 後定位詞 >> 集偏移量EP\n被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量EP\n其中替換詞支援格式&#123;[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]&#125; 直接指定TMDBID/豆瓣ID識別其中g為劇集組編號s、e為季數和集數可選)',
cancelSubscribe: '取消訂閱',
save: '儲存',
cancelSubscribeConfirm: '是否確認取消訂閱?',

View File

@@ -54,6 +54,11 @@ const subscribeFilter = ref('')
// 订阅状态筛选
const subscribeStatusFilter = ref<string | null>(null)
type SubscribeSortBy = 'custom' | 'last_update' | 'date' | 'lack_episode'
// 订阅排序方式
const subscribeSortBy = ref<SubscribeSortBy | ''>('')
// 分享搜索词
const shareKeyword = ref('')
const shareKeywordInput = ref('')
@@ -85,6 +90,30 @@ const filterOptions = computed(() => {
]
})
// 排序选项
const sortOptions = computed<Array<{ value: SubscribeSortBy; label: string }>>(() => {
const options: Array<{ value: SubscribeSortBy; label: string }> = [
{ value: 'custom', label: t('subscribe.sort.custom') },
{ value: 'last_update', label: t('subscribe.sort.lastUpdate') },
{ value: 'date', label: t('subscribe.sort.addTime') },
]
if (subType !== '电影') {
options.push({ value: 'lack_episode', label: t('subscribe.sort.lackEpisode') })
}
return options
})
// 当前选中的排序选项
const currentSortBy = computed<SubscribeSortBy>(() => {
if (subscribeSortBy.value && sortOptions.value.some(option => option.value === subscribeSortBy.value)) {
return subscribeSortBy.value
}
return 'date'
})
// 当前选中的筛选选项
const currentFilter = computed(() => {
return filterOptions.value.find(option => option.value === (subscribeStatusFilter.value || 'all'))
@@ -104,6 +133,18 @@ function selectFilter(value: string) {
filterSubscribeDialog.value = false
}
// 选择订阅排序选项,非自定义排序会退出拖拽排序模式。
function selectSubscribeSort(value: SubscribeSortBy) {
if (!sortOptions.value.some(option => option.value === value)) {
return
}
subscribeSortBy.value = value
if (value !== 'custom') {
subscribeSortMode.value = false
}
}
// VMenu activator选择器
const filterActivator = computed(() => '[data-menu-activator="filter-btn"]')
const searchActivator = computed(() => '[data-menu-activator="share-filter-btn"]')
@@ -132,7 +173,11 @@ function openShareStatisticsDialog() {
openSharedDialog(SubscribeShareStatisticsDialog, {}, {}, { closeOn: ['close'] })
}
// 切换订阅拖拽排序模式,进入时固定使用自定义排序。
function toggleSubscribeSortMode() {
if (!subscribeSortMode.value) {
subscribeSortBy.value = 'custom'
}
subscribeSortMode.value = !subscribeSortMode.value
}
@@ -290,8 +335,10 @@ onMounted(() => {
:keyword="subscribeFilter"
:status-filter="subscribeStatusFilter ?? ''"
:sort-mode="subscribeSortMode"
:sort-by="subscribeSortBy"
:active="activeTab === 'mysub'"
@update:sort-mode="subscribeSortMode = $event"
@update:sort-by="subscribeSortBy = $event"
/>
</div>
</transition>
@@ -358,6 +405,23 @@ onMounted(() => {
</template>
</VListItem>
</VList>
<VDivider />
<!-- 排序 -->
<VList density="compact" class="px-2 py-1">
<VListSubheader>{{ t('subscribe.sortTitle') }}</VListSubheader>
<VListItem
v-for="option in sortOptions"
:key="option.value"
:active="currentSortBy === option.value"
@click="selectSubscribeSort(option.value)"
density="compact"
>
<VListItemTitle>{{ option.label }}</VListItemTitle>
<template #append>
<VIcon v-if="currentSortBy === option.value" icon="mdi-check" color="primary" size="small" />
</template>
</VListItem>
</VList>
</VCard>
</VMenu>
</Teleport>

View File

@@ -92,6 +92,7 @@ registerHeaderTab({
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
loading: computed(() => isMarketRefreshing.value),
action: () => {
refreshMarket()
},
@@ -879,19 +880,16 @@ function marketSettingDone() {
// 手动刷新插件市场
async function refreshMarket() {
const showMarketLoading = !isAppMarketLoaded.value
if (showMarketLoading) {
isMarketRefreshing.value = true
}
if (isMarketRefreshing.value) return
isMarketRefreshing.value = true
try {
await fetchUninstalledPlugins(true, { silent: isAppMarketLoaded.value, source: 'manual' })
await fetchUninstalledPlugins(true, { silent: false, source: 'manual' })
await getPluginStatistics()
} catch (error) {
console.error(error)
} finally {
if (showMarketLoading) {
isMarketRefreshing.value = false
}
isMarketRefreshing.value = false
}
}
@@ -905,6 +903,7 @@ async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
}
await fetchInstalledPlugins(context)
await fetchUninstalledPlugins(false, context)
await getPluginStatistics()
// 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。
await loadPluginFolders()

View File

@@ -82,6 +82,7 @@ const SystemSettings = ref<any>({
GLOBAL_IMAGE_CACHE: false,
SUBSCRIBE_STATISTIC_SHARE: true,
PLUGIN_STATISTIC_SHARE: true,
USAGE_STATISTIC_SHARE: true,
WORKFLOW_STATISTIC_SHARE: true,
BIG_MEMORY_MODE: false,
DB_WAL_ENABLE: false,
@@ -112,6 +113,7 @@ const SystemSettings = ref<any>({
DOH_RESOLVERS: null,
DOH_DOMAINS: null,
SECURITY_IMAGE_DOMAINS: [],
IMAGE_PROXY_ALLOWED_PRIVATE_RANGES: [],
// 日志
DEBUG: false,
LOG_LEVEL: 'INFO',
@@ -121,6 +123,7 @@ const SystemSettings = ref<any>({
// 实验室
PLUGIN_AUTO_RELOAD: false,
PLUGIN_LOCAL_REPO_PATHS: '',
RUST_ACCEL: false,
ENCODING_DETECTION_PERFORMANCE_MODE: true,
TRANSFER_THREADS: 1,
},
@@ -130,6 +133,7 @@ const audioProviderItems = computed(() => [
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
{ title: t('setting.system.audioProviderMinimax'), value: 'minimax' },
])
// 刮削配置
@@ -197,6 +201,7 @@ const advancedDialog = ref(false)
const savingBasic = ref(false)
const testingLlm = ref(false)
const rustAccelAvailable = ref(false)
// 智能助手配置项较多,默认收起以降低基础设置页的视觉占用。
const aiAgentSettingsCollapsed = ref(true)
@@ -438,6 +443,10 @@ const canTestLlm = computed(() => {
)
})
const rustAccelHint = computed(() =>
rustAccelAvailable.value ? t('setting.system.rustAccelHint') : t('setting.system.rustAccelUnavailableHint'),
)
const thinkingLevelItems = computed(() => [
{ title: t('setting.system.llmThinkingLevelOff'), value: 'off' },
{ title: t('setting.system.llmThinkingLevelAuto'), value: 'auto' },
@@ -488,6 +497,8 @@ const dataCleanupFieldRules = [
// 安全域名添加变量
const newSecurityDomain = ref('')
// 图片代理允许非公网网段添加变量
const newImageProxyAllowedPrivateRange = ref('')
// 加载 LLM 模型列表与 provider 目录
async function refreshLlmModels(forceRefresh = true) {
@@ -538,6 +549,19 @@ function addSecurityDomain() {
}
}
// 添加图片代理允许访问的非公网网段
function addImageProxyAllowedPrivateRange() {
if (
newImageProxyAllowedPrivateRange.value &&
!SystemSettings.value.Advanced.IMAGE_PROXY_ALLOWED_PRIVATE_RANGES.includes(
newImageProxyAllowedPrivateRange.value,
)
) {
SystemSettings.value.Advanced.IMAGE_PROXY_ALLOWED_PRIVATE_RANGES.push(newImageProxyAllowedPrivateRange.value)
newImageProxyAllowedPrivateRange.value = ''
}
}
// 调用API查询下载器设置
async function loadDownloaderSetting() {
try {
@@ -618,6 +642,9 @@ async function loadSystemSettings() {
if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]
})
}
const accelEnabled = Boolean(result.data.RUST_ACCEL_ENABLED)
rustAccelAvailable.value = accelEnabled
if (!accelEnabled) SystemSettings.value.Advanced.RUST_ACCEL = false
SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data)
await loadLlmProviders()
}
@@ -699,6 +726,7 @@ async function testLlmConnection() {
// 保存高级设置
async function saveAdvancedSettings() {
if (!rustAccelAvailable.value) SystemSettings.value.Advanced.RUST_ACCEL = false
cleanEmptyFields(SystemSettings.value.Advanced, ['LOG_FILE_FORMAT'])
// 同时保存高级设置和刮削开关设置
@@ -1741,6 +1769,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.USAGE_STATISTIC_SHARE"
:label="t('setting.system.usageStatisticShare')"
:hint="t('setting.system.usageStatisticShareHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.WORKFLOW_STATISTIC_SHARE"
@@ -2093,6 +2129,51 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
</template>
</VTextField>
</div>
<VDivider class="my-4" />
<div class="text-subtitle-2 mb-1">
{{ t('setting.system.imageProxyAllowedPrivateRanges') }}
</div>
<div class="text-caption text-medium-emphasis mb-3">
{{ t('setting.system.imageProxyAllowedPrivateRangesHint') }}
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<VChip
v-for="(range, index) in SystemSettings.Advanced.IMAGE_PROXY_ALLOWED_PRIVATE_RANGES"
:key="index"
closable
@click:close="
SystemSettings.Advanced.IMAGE_PROXY_ALLOWED_PRIVATE_RANGES.splice(index, 1)
"
>
{{ range }}
</VChip>
<VChip
v-if="SystemSettings.Advanced.IMAGE_PROXY_ALLOWED_PRIVATE_RANGES.length === 0"
color="warning"
>
{{ t('setting.system.noImageProxyAllowedPrivateRanges') }}
</VChip>
</div>
<div class="d-flex align-center gap-2">
<VTextField
v-model="newImageProxyAllowedPrivateRange"
:placeholder="t('setting.system.imageProxyAllowedPrivateRangeAdd')"
hide-details
density="compact"
prepend-inner-icon="mdi-ip-network"
>
<template #append>
<VBtn
icon
color="primary"
@click="addImageProxyAllowedPrivateRange"
:disabled="!newImageProxyAllowedPrivateRange"
>
<VIcon icon="mdi-plus" />
</VBtn>
</template>
</VTextField>
</div>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
@@ -2236,14 +2317,6 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<VWindowItem value="dev">
<div>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.PLUGIN_AUTO_RELOAD"
:label="t('setting.system.pluginAutoReload')"
:hint="t('setting.system.pluginAutoReloadHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="SystemSettings.Advanced.PLUGIN_LOCAL_REPO_PATHS"
@@ -2253,14 +2326,6 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
prepend-inner-icon="mdi-folder"
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.ENCODING_DETECTION_PERFORMANCE_MODE"
:label="t('setting.system.encodingDetectionPerformanceMode')"
:hint="t('setting.system.encodingDetectionPerformanceModeHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="SystemSettings.Advanced.TRANSFER_THREADS"
@@ -2273,6 +2338,33 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
/>
</VCol>
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.PLUGIN_AUTO_RELOAD"
:label="t('setting.system.pluginAutoReload')"
:hint="t('setting.system.pluginAutoReloadHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.ENCODING_DETECTION_PERFORMANCE_MODE"
:label="t('setting.system.encodingDetectionPerformanceMode')"
:hint="t('setting.system.encodingDetectionPerformanceModeHint')"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VSwitch
v-model="SystemSettings.Advanced.RUST_ACCEL"
:label="t('setting.system.rustAccel')"
:hint="rustAccelHint"
:disabled="!rustAccelAvailable"
persistent-hint
/>
</VCol>
</VRow>
</div>
</VWindowItem>
</VWindow>

View File

@@ -171,6 +171,7 @@ const audioProviderItems = computed(() => [
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' },
{ title: t('setting.system.audioProviderMinimax'), value: 'minimax' },
])
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])

View File

@@ -40,6 +40,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
sortBy: {
type: String,
default: '',
},
active: {
type: Boolean,
default: true,
@@ -48,8 +52,11 @@ const props = defineProps({
const emit = defineEmits<{
'update:sortMode': [value: boolean]
'update:sortBy': [value: SubscribeSortBy]
}>()
type SubscribeSortBy = 'custom' | 'last_update' | 'date' | 'lack_episode'
// 是否刷新过
let isRefreshed = ref(false)
@@ -71,8 +78,26 @@ const selectedSubscribes = ref<number[]>([])
const normalizedKeyword = computed(() => props.keyword?.trim().toLowerCase() || '')
const selectedSubscribesSet = computed(() => new Set(selectedSubscribes.value))
const hasCustomOrder = computed(() => orderConfig.value.length > 0)
// 归一化订阅排序方式,电影订阅不使用缺失集数排序。
const normalizedSortBy = computed<SubscribeSortBy | ''>(() => {
const sortBy = props.sortBy as SubscribeSortBy | ''
if (props.type === '电影' && sortBy === 'lack_episode') {
return 'date'
}
return sortBy
})
const effectiveSortBy = computed<SubscribeSortBy>(() => {
return normalizedSortBy.value || (hasCustomOrder.value ? 'custom' : 'date')
})
const canSortContext = computed(
() => !normalizedKeyword.value && (!props.statusFilter || props.statusFilter === 'all') && !isBatchMode.value,
() =>
effectiveSortBy.value === 'custom' &&
!normalizedKeyword.value &&
(!props.statusFilter || props.statusFilter === 'all') &&
!isBatchMode.value,
)
const sortMode = computed({
get: () => props.sortMode,
@@ -109,10 +134,11 @@ function getSubscribeStatus(subscribe: Subscribe) {
return 'all'
}
// 电视剧根据集数情况判断
// 电视剧根据集数情况判断completed_episode 由后端按订阅类型派生
// (普通=已入库集数,洗版=起始集前 + [start, total] 范围内 priority==100 命中)
if (subscribe.total_episode && subscribe.total_episode > 0) {
const lackEpisode = subscribe.lack_episode || 0
const completedEpisode = subscribe.total_episode - lackEpisode
const completedEpisode = subscribe.completed_episode ?? 0
if (lackEpisode === 0) {
return 'completed' // 订阅完成
@@ -129,11 +155,62 @@ function getSubscribeStatus(subscribe: Subscribe) {
// API请求键值计算属性
const orderRequestKey = computed(() => (props.type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'))
// 监听数据和筛选变化,同步更新显示列表
// 转换订阅时间字段为可排序时间戳。
function getSubscribeTimeValue(value?: string) {
if (!value) return 0
const directTime = Date.parse(value)
if (!Number.isNaN(directTime)) return directTime
const compatibleTime = Date.parse(value.replace(/-/g, '/'))
return Number.isNaN(compatibleTime) ? 0 : compatibleTime
}
// 按自定义顺序排序订阅,未配置顺序的订阅按添加时间倒序补齐。
function sortByCustomOrder(a: Subscribe, b: Subscribe, orderIndexMap: Map<number, number>) {
const aIndex = orderIndexMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bIndex = orderIndexMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
if (aIndex !== bIndex) {
return aIndex - bIndex
}
return getSubscribeTimeValue(b.date) - getSubscribeTimeValue(a.date)
}
// 按当前排序选项调整订阅列表顺序。
function sortSubscribeList(list: Subscribe[]) {
const orderIndexMap = new Map(orderConfig.value.map((item, index) => [item.id, index]))
list.sort((a, b) => {
if (effectiveSortBy.value === 'custom') {
return sortByCustomOrder(a, b, orderIndexMap)
}
if (effectiveSortBy.value === 'last_update') {
return getSubscribeTimeValue(b.last_update) - getSubscribeTimeValue(a.last_update)
}
if (effectiveSortBy.value === 'lack_episode') {
const lackEpisodeDiff = (b.lack_episode || 0) - (a.lack_episode || 0)
return lackEpisodeDiff || getSubscribeTimeValue(b.date) - getSubscribeTimeValue(a.date)
}
return getSubscribeTimeValue(b.date) - getSubscribeTimeValue(a.date)
})
}
// 同步订阅排序默认值给父组件。
function syncDefaultSortBy() {
if (!props.sortBy) {
emit('update:sortBy', hasCustomOrder.value ? 'custom' : 'date')
}
}
// 监听数据、筛选和排序变化,同步更新显示列表
watch(
[dataList, normalizedKeyword, () => props.statusFilter, orderConfig],
[dataList, normalizedKeyword, () => props.statusFilter, orderConfig, effectiveSortBy],
() => {
const orderIndexMap = new Map(orderConfig.value.map((item, index) => [item.id, index]))
const nextDisplayList = dataList.value.filter(data => {
if (data.type !== props.type) {
return false
@@ -154,12 +231,7 @@ watch(
return true
})
nextDisplayList.sort((a, b) => {
const aIndex = orderIndexMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
const bIndex = orderIndexMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
return aIndex - bIndex
})
sortSubscribeList(nextDisplayList)
displayList.value = nextDisplayList
},
@@ -183,9 +255,11 @@ async function loadSubscribeOrderConfig() {
if (response && response.data && response.data.value) {
orderConfig.value = response.data.value
}
syncDefaultSortBy()
} catch (error) {
console.error('Failed to load subscribe order config:', error)
orderConfig.value = []
syncDefaultSortBy()
}
}
@@ -194,6 +268,7 @@ async function saveSubscribeOrder() {
// 顺序配置
const orderObj = displayList.value.map(item => ({ id: item.id }))
orderConfig.value = orderObj
emit('update:sortBy', 'custom')
// 保存到服务端
try {