mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-04 07:09:54 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cacc2602df | ||
|
|
8c6cfa7fc5 | ||
|
|
0113f28d8c | ||
|
|
d870b788bc | ||
|
|
19a3213be0 | ||
|
|
f5c8a463fa | ||
|
|
ff3b5b4232 | ||
|
|
6da0aae362 | ||
|
|
abbce2644a | ||
|
|
1c5773444e | ||
|
|
1674f15d7c | ||
|
|
c6981e9955 | ||
|
|
96d3426d0c | ||
|
|
c88b2abcce | ||
|
|
42fe928155 | ||
|
|
4cc455b948 | ||
|
|
bce073ebe0 | ||
|
|
c27167097e | ||
|
|
44d23480a3 | ||
|
|
01796b3dc5 | ||
|
|
dcf0924c73 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,3 +35,5 @@ package-lock.json
|
||||
# iconify dist files
|
||||
src/@iconify/*.js
|
||||
public/plugin_icon/**
|
||||
docs-lock/
|
||||
.trae/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.12.4",
|
||||
"version": "2.13.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -152,6 +152,7 @@ watch(
|
||||
otpPassword.value = ''
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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" />
|
||||
<!-- 文件详情 -->
|
||||
|
||||
@@ -10,6 +10,7 @@ interface DynamicHeaderTabButton {
|
||||
class?: string
|
||||
action?: () => void
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
loading?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string // 用于VMenu定位的data属性
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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: {[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} to directly specify TMDBID/Douban ID, where s and e are season and episode numbers (optional)',
|
||||
'Replacement format supports: {[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 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: { tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx } 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: {[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 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?',
|
||||
|
||||
@@ -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:
|
||||
'音频输入接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1',
|
||||
'音频输入接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1,MiniMax 默认 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:
|
||||
'音频输出接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1',
|
||||
'音频输出接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1,MiniMax 默认 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: '添加 CIDR,如:198.18.0.0/15',
|
||||
proxyHost: '代理服务器',
|
||||
proxyHostHint: '设置代理服务器地址,支持:http(s)、socks5、socks5h 等协议',
|
||||
moviePilotAutoUpdate: '自动更新MoviePilot',
|
||||
@@ -1814,7 +1850,7 @@ export default {
|
||||
'被替换词 => 替换词\n' +
|
||||
'前定位词 <> 后定位词 >> 集偏移量(EP)\n' +
|
||||
'被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n' +
|
||||
'其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID识别,其中s、e为季数和集数(可选)',
|
||||
'其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定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其中替换词支持格式:{ tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx } 直接指定TMDBID/豆瓣ID识别,其中s、e为季数和集数(可选)',
|
||||
'屏蔽词\n被替换词 => 替换词\n前定位词 <> 后定位词 >> 集偏移量(EP)\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
|
||||
cancelSubscribe: '取消订阅',
|
||||
save: '保存',
|
||||
cancelSubscribeConfirm: '是否确认取消订阅?',
|
||||
|
||||
@@ -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:
|
||||
'音頻輸入接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1',
|
||||
'音頻輸入接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1,MiniMax 預設 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:
|
||||
'音頻輸出接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1',
|
||||
'音頻輸出接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1,MiniMax 預設 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: '添加 CIDR,如:198.18.0.0/15',
|
||||
proxyHost: '代理服務器',
|
||||
proxyHostHint: '設置代理服務器地址,支持:http(s)、socks5、socks5h 等協議',
|
||||
moviePilotAutoUpdate: '自動更新MoviePilot',
|
||||
@@ -1815,7 +1851,7 @@ export default {
|
||||
'被替換詞 => 替換詞\n' +
|
||||
'前定位詞 <> 後定位詞 >> 集偏移量(EP)\n' +
|
||||
'被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量(EP)\n' +
|
||||
'其中替換詞支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID識別,其中s、e為季數和集數(可選)',
|
||||
'其中替換詞支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定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其中替換詞支援格式:{ tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx } 直接指定TMDBID/豆瓣ID識別,其中s、e為季數和集數(可選)',
|
||||
'屏蔽詞\n被替換詞 => 替換詞\n前定位詞 <> 後定位詞 >> 集偏移量(EP)\n被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量(EP)\n其中替換詞支援格式:{[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID識別,其中g為劇集組編號,s、e為季數和集數(均可選)',
|
||||
cancelSubscribe: '取消訂閱',
|
||||
save: '儲存',
|
||||
cancelSubscribeConfirm: '是否確認取消訂閱?',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || [])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user