Compare commits

...

41 Commits

Author SHA1 Message Date
jxxghp
14aa75dfae fix: format version install statistics 2026-05-27 17:48:58 +08:00
jxxghp
348aa4757b fix: normalize search site selection 2026-05-27 15:21:44 +08:00
jxxghp
6e6819acc1 fix: auto match manual transfer target path 2026-05-27 13:26:01 +08:00
jxxghp
51a58aaae0 fix: show manual transfer recognition details 2026-05-27 11:03:55 +08:00
jxxghp
fbde99389e 更新 package.json 2026-05-27 07:11:01 +08:00
jxxghp
5a4e345529 feat: add LLM proxy toggle 2026-05-27 06:57:09 +08:00
jxxghp
b446afb6d8 fix: improve plugin market editor layout 2026-05-26 17:39:14 +08:00
jxxghp
8580af36d1 fix: compact plugin market settings dialog 2026-05-26 17:16:19 +08:00
jxxghp
95ca092117 feat: optimize plugin market repository settings 2026-05-26 16:30:31 +08:00
jxxghp
ba200cae5c fix: move LLM user agent after max context 2026-05-26 08:30:33 +08:00
jxxghp
87c73e0253 feat: add llm user agent setting 2026-05-26 08:20:02 +08:00
jxxghp
d4d7f635f5 fix: allow rust acceleration re-enable 2026-05-25 23:48:09 +08:00
jxxghp
729db1510e 更新 package.json 2026-05-25 23:11:22 +08:00
jxxghp
8a12ecf918 fix: render OTP QR code reliably 2026-05-25 23:07:45 +08:00
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
jxxghp
cc8d5cf931 style: lighten setting card accents 2026-05-21 22:05:46 +08:00
jxxghp
6083887675 fix: load registered passkeys on dialog open 2026-05-21 07:06:51 +08:00
jxxghp
beb0506b0c feat: show plugin system version compatibility 2026-05-20 19:56:21 +08:00
InfinityPacer
0f906f791a fix(filter-rule): keep custom rule chip titles in sync with props (#474) 2026-05-20 17:29:46 +08:00
jxxghp
7614696e61 fix: 修复智能推荐按钮初始不可用 2026-05-20 12:36:41 +08:00
jxxghp
4235d3687c feat: add tmdb api key setting 2026-05-20 11:28:29 +08:00
30 changed files with 2158 additions and 353 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.2",
"version": "2.13.2",
"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
// 时间
@@ -646,6 +650,12 @@ export interface Plugin {
has_page?: boolean
// 是否有新版本
has_update?: boolean
// 主系统版本是否兼容
system_version_compatible?: boolean
// 主系统版本兼容提示
system_version_message?: string
// 主系统版本限定范围
system_version?: string
// 是否本地插件
is_local?: boolean
// 插件仓库地址
@@ -1282,7 +1292,7 @@ export interface TransferForm {
// 目标存储
target_storage: string
// 目标路径
target_path: string
target_path: string | null
// TMDB ID
tmdbid?: number
// 豆瓣 ID
@@ -1312,13 +1322,34 @@ 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 ManualTransferTargetPathData {
// 目标存储
target_storage?: string | null
// 目标路径
target_path?: string | null
// 整理方式
transfer_type?: string | null
// 刮削
scrape?: boolean
// 媒体库类型子目录
library_type_folder?: boolean
// 媒体库类别子目录
library_category_folder?: boolean
}
// 手动整理预览统计
export interface ManualTransferPreviewSummary {
@@ -1354,6 +1385,14 @@ export interface ManualTransferPreviewItem {
episode_end?: number | string
// Part
part?: string
// 原始识别字符串
org_string?: string
// 应用的自定义识别词
apply_words?: string[]
// 制作组/字幕组
resource_team?: string
// 自定义占位符
customization?: string
}
// 手动整理预览数据

View File

@@ -28,19 +28,18 @@ function filtersChanged(value: string[]) {
}
// 过滤规则下拉框
const selectFilterOptions = ref<{ [key: string]: string }[]>([])
onMounted(() => {
selectFilterOptions.value = cloneDeep(innerFilterRules)
if (props.custom_rules) {
console.log(props.custom_rules)
props.custom_rules.map(rule => {
selectFilterOptions.value.push({
title: rule.name,
value: rule.id,
})
// 同时包含内置规则与用户自定义规则;使用 computed 而非 onMounted 一次性赋值,
// 是为了在父组件异步加载完 custom_rules 或后续新增/删除规则时,
// 选项与已选 chip 的显示名title能跟随刷新避免回退到原始 ID如 "zhong")。
const selectFilterOptions = computed<{ [key: string]: string }[]>(() => {
const options = cloneDeep(innerFilterRules)
props.custom_rules?.forEach(rule => {
options.push({
title: rule.name,
value: rule.id,
})
}
})
return options
})
</script>

View File

@@ -226,6 +226,11 @@ async function resetPlugin() {
// 更新插件
async function updatePlugin() {
if (props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
try {
// 显示等待提示框
showPluginProgress(t('plugin.updating', { name: props.plugin?.plugin_name }))

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,33 @@ 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 formatVersionStatisticNumber(value: unknown) {
const numberValue = Number(value ?? 0)
if (!Number.isFinite(numberValue)) return '0'
return numberValue.toLocaleString()
}
// 打开日志对话框
function showReleaseDialog(title: string, body: string) {
releaseDialogTitle.value = title
@@ -91,6 +118,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 +231,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 +467,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">{{ formatVersionStatisticNumber(versionStatistic.total_users) }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.activeToday') }}</div>
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.today) }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active7Days') }}</div>
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_7_days) }}</div>
</div>
<div>
<div class="text-caption text-medium-emphasis">{{ t('setting.about.active30Days') }}</div>
<div class="version-stat-number">{{ formatVersionStatisticNumber(activeUsers.last_30_days) }}</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">{{ formatVersionStatisticNumber(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">{{ formatVersionStatisticNumber(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 +563,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

@@ -41,42 +41,69 @@ const otpPassword = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
// OTP 初始化加载状态
const otpLoading = ref(false)
// OTP 初始化失败信息
const otpGenerateError = ref('')
// 二维码图片 base64
const qrCodeImage = ref('')
// 二维码信息
const qrCode = ref('')
// 为当前用户获取Otp Uri
// 清空当前 OTP 设置流程的临时数据。
function resetOtpSetupState() {
qrCodeImage.value = ''
qrCode.value = ''
otpUri.value = ''
secret.value = ''
otpGenerateError.value = ''
}
// 标记 OTP 初始化失败,并向用户显示明确错误。
function setOtpGenerateError(message?: string) {
const errorMessage = message || t('common.error')
otpGenerateError.value = t('profile.otpGenerateFailed', { message: errorMessage })
$toast.error(otpGenerateError.value)
}
// 为当前用户获取 OTP URI 并生成二维码图片。
async function getOtpUri() {
resetOtpSetupState()
// 如果已经启用OTP只打开对话框不生成新的二维码
if (props.isOtp) {
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
qrCodeImage.value = ''
return
}
// 未启用OTP生成新的二维码
otpLoading.value = true
try {
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
uri: string
secret: string
}>
if (result.success) {
otpUri.value = result.data.uri
secret.value = result.data.secret
qrCode.value = result.data.uri
const uri = result.data?.uri?.trim()
const otpSecret = result.data?.secret?.trim()
if (result.success && uri) {
otpUri.value = uri
secret.value = otpSecret || ''
qrCode.value = uri
// 生成二维码图片
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, {
qrCodeImage.value = await QRCode.toDataURL(uri, {
width: 200,
margin: 1,
})
} else {
$toast.error(t('profile.otpGenerateFailed', { message: result.message }))
setOtpGenerateError(result.message || 'empty otp uri')
}
} catch (error) {
console.error(error)
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) }))
setOtpGenerateError(error instanceof Error ? error.message : String(error))
} finally {
otpLoading.value = false
}
}
@@ -145,13 +172,12 @@ watch(
otpPassword.value = ''
} else {
// 弹窗关闭时,清空数据
qrCodeImage.value = ''
qrCode.value = ''
otpUri.value = ''
secret.value = ''
resetOtpSetupState()
otpLoading.value = false
otpPassword.value = ''
}
},
{ immediate: true },
)
</script>
@@ -193,16 +219,29 @@ watch(
<!-- 设置新的OTP -->
<template v-else>
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto">
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200">
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
</div>
</template>
</VImg>
<div
class="my-6 rounded text-center p-3 border d-flex align-center justify-center"
style="width: 226px; height: 226px; margin: 0 auto"
>
<img
v-if="qrCodeImage"
class="mx-auto d-block otp-qrcode-image"
:src="qrCodeImage"
:alt="t('profile.setupAuthenticator')"
width="200"
height="200"
/>
<VProgressCircular v-else-if="otpLoading" indeterminate color="primary" />
<div v-else class="w-100">
<VAlert type="error" variant="tonal" density="compact" class="mb-3">
{{ otpGenerateError || t('profile.otpGenerateFailed', { message: t('common.error') }) }}
</VAlert>
<VBtn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="getOtpUri">
{{ t('common.retry') }}
</VBtn>
</div>
</div>
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<VAlert v-if="secret" :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<template #prepend />
</VAlert>
<VForm @submit.prevent="judgeOtpPassword">
@@ -220,7 +259,7 @@ watch(
<VBtn variant="outlined" color="secondary" @click="show = false">
{{ t('common.cancel') }}
</VBtn>
<VBtn type="submit">
<VBtn type="submit" :disabled="!otpUri || otpLoading">
<template #prepend>
<VIcon icon="mdi-check" />
</template>
@@ -233,3 +272,10 @@ watch(
</VCard>
</VDialog>
</template>
<style scoped>
.otp-qrcode-image {
inline-size: 200px;
block-size: 200px;
}
</style>

View File

@@ -206,6 +206,7 @@ watch(
passkeyList.value = []
}
},
{ immediate: true },
)
</script>

View File

@@ -98,6 +98,11 @@ function visitPluginPage() {
/** 安装插件并通知父级刷新市场列表。 */
async function installPlugin() {
if (props.plugin?.system_version_compatible === false) {
$toast.error(props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
try {
showInstallProgress(
t('plugin.installing', {
@@ -176,9 +181,28 @@ onUnmounted(() => {
</span>
</VListItemTitle>
</VListItem>
<VListItem v-if="props.plugin?.system_version" class="ps-0">
<VListItemTitle class="text-center text-md-left">
<span class="font-weight-medium">{{ t('plugin.systemVersion') }}</span>
<span class="text-body-1">{{ props.plugin?.system_version }}</span>
</VListItemTitle>
</VListItem>
</VList>
<VAlert
v-if="props.plugin?.system_version_compatible === false"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<div class="text-center text-md-left">
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download">
<VBtn
color="primary"
@click="installPlugin"
prepend-icon="mdi-download"
:disabled="props.plugin?.system_version_compatible === false"
>
{{ t('plugin.installToLocal') }}
</VBtn>
<div class="text-xs mt-2" v-if="props.count">

View File

@@ -10,27 +10,121 @@ const display = useDisplay()
const { t } = useI18n()
const $toast = useToast()
type EditorMode = 'list' | 'text'
interface RepoParseResult {
repos: string[]
invalidRepos: string[]
duplicateRepos: string[]
}
const editorMode = ref<EditorMode>('list')
const repoList = ref<string[]>([])
const repoText = ref('')
const newRepoUrl = ref('')
const editingIndex = ref<number | null>(null)
const editingUrl = ref('')
const emit = defineEmits(['save', 'close'])
const parsedTextRepos = computed(() => parseRepoInput(repoText.value))
const activeRepoCount = computed(() => (editorMode.value === 'text' ? parsedTextRepos.value.repos.length : repoList.value.length))
const saveDisabled = computed(
() => activeRepoCount.value === 0 || (editorMode.value === 'text' && parsedTextRepos.value.invalidRepos.length > 0),
)
/** 判断仓库地址是否为可保存的 HTTP URL。 */
function isValidRepoUrl(url: string) {
return /^https?:\/\//i.test(url)
}
/** 将粘贴的仓库地址文本解析为有效、无效和重复地址列表。 */
function parseRepoInput(value: string): RepoParseResult {
const repos: string[] = []
const invalidRepos: string[] = []
const duplicateRepos: string[] = []
const seenRepos = new Set<string>()
value
.split(/[\n,]+/)
.map(repo => repo.trim())
.filter(Boolean)
.forEach(repo => {
if (!isValidRepoUrl(repo)) {
invalidRepos.push(repo)
return
}
if (seenRepos.has(repo)) {
duplicateRepos.push(repo)
return
}
seenRepos.add(repo)
repos.push(repo)
})
return {
repos,
invalidRepos,
duplicateRepos: [...new Set(duplicateRepos)],
}
}
/** 将列表模式中的仓库地址同步到文本模式。 */
function syncTextFromList() {
repoText.value = repoList.value.join('\n')
}
/** 将文本模式中的仓库地址同步到列表模式,并忽略无法加入列表的无效地址。 */
function syncListFromText() {
const result = parseRepoInput(repoText.value)
repoList.value = result.repos
syncTextFromList()
if (result.invalidRepos.length > 0) {
$toast.warning(t('dialog.pluginMarketSetting.invalidTextIgnored', { count: result.invalidRepos.length }))
}
}
/** 切换仓库维护模式,并在切换时同步当前模式的编辑内容。 */
function switchEditorMode(mode: EditorMode | undefined) {
if (!mode || mode === editorMode.value) return
if (editorMode.value === 'text') {
syncListFromText()
}
if (mode === 'text') {
syncTextFromList()
}
editorMode.value = mode
}
/** 加载插件市场仓库配置。 */
async function queryMarketRepoSetting() {
try {
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
if (result && result.data && result.data.value) {
repoList.value = result.data.value.split(',').filter((repo: string) => repo.trim() !== '')
repoList.value = parseRepoInput(result.data.value).repos
syncTextFromList()
}
} catch (error) {
console.log(error)
}
}
/** 保存插件市场仓库配置。 */
async function saveHandle() {
try {
const repoStringToSave = repoList.value.join(',')
const reposToSave = normalizeCurrentRepos()
if (!reposToSave) return
const repoStringToSave = reposToSave.join(',')
const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
if (result.success) {
@@ -42,54 +136,88 @@ async function saveHandle() {
}
}
/** 获取当前维护模式下可保存的仓库地址。 */
function normalizeCurrentRepos() {
if (editorMode.value === 'text') {
const result = parseRepoInput(repoText.value)
if (result.invalidRepos.length > 0) {
$toast.error(t('dialog.pluginMarketSetting.invalidText', { count: result.invalidRepos.length }))
return null
}
repoList.value = result.repos
syncTextFromList()
return result.repos
}
return repoList.value
}
/** 校验单个仓库地址是否可以加入或更新到列表。 */
function validateRepoUrl(url: string, editingRepoIndex: number | null = null) {
if (!url) return false
if (!isValidRepoUrl(url)) {
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
return false
}
const duplicated = repoList.value.some((repo, index) => repo === url && index !== editingRepoIndex)
if (duplicated) {
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
return false
}
return true
}
/** 添加一个仓库地址到列表。 */
function addRepo() {
const url = newRepoUrl.value.trim()
if (!url) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
return
}
if (repoList.value.includes(url)) {
$toast.error(t('dialog.pluginMarketSetting.duplicateUrl'))
return
}
if (!validateRepoUrl(url)) return
repoList.value.push(url)
newRepoUrl.value = ''
syncTextFromList()
}
/** 从列表中删除一个仓库地址。 */
function removeRepo(index: number) {
repoList.value.splice(index, 1)
syncTextFromList()
}
/** 进入指定仓库地址的行内编辑状态。 */
function startEdit(index: number) {
editingIndex.value = index
editingUrl.value = repoList.value[index]
}
function saveEdit() {
if (editingIndex.value === null) return
/** 保存当前行内编辑的仓库地址。 */
function saveEdit(index = editingIndex.value) {
if (index === null) return
const url = editingUrl.value.trim()
if (!url) return
if (!validateRepoUrl(url, index)) return
if (!url.startsWith('http://') && !url.startsWith('https://')) {
$toast.error(t('dialog.pluginMarketSetting.invalidUrl'))
return
}
repoList.value[editingIndex.value] = url
repoList.value[index] = url
syncTextFromList()
editingIndex.value = null
editingUrl.value = ''
}
/** 取消当前行内编辑状态。 */
function cancelEdit() {
editingIndex.value = null
editingUrl.value = ''
}
/** 将仓库地址格式化为更易扫描的显示名称。 */
function formatRepoDisplay(url: string) {
try {
const parsedUrl = new URL(url)
@@ -108,6 +236,7 @@ function formatRepoDisplay(url: string) {
return url
}
/** 返回拖拽列表项的稳定键。 */
function repoItemKey(repo: string) {
return repo
}
@@ -118,108 +247,192 @@ onMounted(() => {
</script>
<template>
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
<VDialog width="56rem" :fullscreen="!display.mdAndUp.value">
<VCard class="plugin-market-dialog-card">
<VCardItem>
<VCardTitle>
<VIcon icon="mdi-store-cog" class="me-2" />
{{ t('dialog.pluginMarketSetting.title') }}
</VCardTitle>
<VCardItem class="plugin-market-card-item">
<div class="plugin-market-header">
<VCardTitle class="plugin-market-title d-flex align-center pa-0">
<VIcon icon="mdi-store-cog" class="me-2" />
{{ t('dialog.pluginMarketSetting.title') }}
</VCardTitle>
</div>
<VDialogCloseBtn @click="emit('close')" />
</VCardItem>
<VDivider />
<VCardText class="plugin-market-dialog-body pt-4">
<div class="plugin-market-input mb-4">
<VTextField
v-model="newRepoUrl"
density="compact"
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
prepend-inner-icon="mdi-link-plus"
clearable
@keyup.enter="addRepo"
<div class="plugin-market-toolbar">
<VBtnToggle
:model-value="editorMode"
mandatory
color="primary"
density="comfortable"
variant="tonal"
class="plugin-market-mode-toggle"
@update:model-value="switchEditorMode"
>
<template #append>
<VBtn icon="mdi-plus" variant="text" color="primary" @click="addRepo" />
</template>
</VTextField>
<VBtn value="list" prepend-icon="mdi-format-list-bulleted">
{{ t('dialog.pluginMarketSetting.listMode') }}
</VBtn>
<VBtn value="text" prepend-icon="mdi-text-box-edit-outline">
{{ t('dialog.pluginMarketSetting.textMode') }}
</VBtn>
</VBtnToggle>
</div>
<div class="plugin-market-list-wrap">
<VList v-if="repoList.length > 0" class="px-0">
<draggable
v-model="repoList"
:item-key="repoItemKey"
handle=".drag-handle"
animation="200"
:disabled="editingIndex !== null"
<div v-if="editorMode === 'list'" class="plugin-market-list-panel">
<div class="plugin-market-input">
<VTextField
v-model="newRepoUrl"
density="compact"
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
prepend-inner-icon="mdi-link-plus"
clearable
hide-details
@keyup.enter="addRepo"
>
<template #item="{ element: repo, index }">
<div>
<VListItem class="py-2">
<template #prepend>
<VBtn
icon="mdi-drag-vertical"
size="small"
variant="text"
color="primary"
class="drag-handle me-2"
:disabled="editingIndex !== null"
/>
</template>
<template #append>
<VBtn
icon="mdi-plus"
variant="tonal"
color="primary"
:aria-label="t('dialog.pluginMarketSetting.addRepo')"
@click="addRepo"
/>
</template>
</VTextField>
</div>
<VListItemTitle v-if="editingIndex !== index">
<span class="text-truncate" :title="repo">{{ formatRepoDisplay(repo) }}</span>
</VListItemTitle>
<VTextField
v-else
v-model="editingUrl"
density="compact"
variant="outlined"
hide-details
@keyup.enter="saveEdit"
@keyup.escape="cancelEdit"
/>
<template #append v-if="editingIndex !== index">
<div class="d-flex align-center">
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
<IconBtn
icon="mdi-delete"
<div class="plugin-market-list-wrap">
<VList v-if="repoList.length > 0" class="plugin-market-repo-list px-0">
<draggable
v-model="repoList"
:item-key="repoItemKey"
handle=".drag-handle"
animation="200"
:disabled="editingIndex !== null"
@end="syncTextFromList"
>
<template #item="{ element: repo, index }">
<div>
<VListItem class="plugin-market-repo-item py-3">
<template #prepend>
<VBtn
icon="mdi-drag-vertical"
size="small"
variant="text"
color="error"
@click="removeRepo(index)"
color="primary"
class="drag-handle me-2"
:disabled="editingIndex !== null"
/>
</div>
</template>
</template>
<template #append v-else>
<div class="d-flex align-center">
<IconBtn icon="mdi-check" size="small" variant="text" color="success" @click="saveEdit" />
<IconBtn icon="mdi-close" size="small" variant="text" @click="cancelEdit" />
</div>
</template>
</VListItem>
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
</div>
</template>
</draggable>
</VList>
<template v-if="editingIndex !== index">
<VListItemTitle>
<div class="plugin-market-repo-title">
<span class="plugin-market-repo-index">{{ index + 1 }}</span>
<span class="plugin-market-repo-name" :title="repo">{{ formatRepoDisplay(repo) }}</span>
</div>
</VListItemTitle>
<VListItemSubtitle class="plugin-market-repo-url mt-1" :title="repo">
{{ repo }}
</VListItemSubtitle>
</template>
<div v-else class="text-center text-medium-emphasis py-8">
<VIcon icon="mdi-folder-open-outline" size="48" class="mb-2" />
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
<VTextField
v-else
v-model="editingUrl"
density="compact"
variant="outlined"
hide-details
autofocus
@keyup.enter="saveEdit(index)"
@keyup.escape="cancelEdit"
/>
<template #append v-if="editingIndex !== index">
<div class="d-flex align-center">
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
<IconBtn
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="removeRepo(index)"
/>
</div>
</template>
<template #append v-else>
<div class="d-flex align-center">
<VBtn
icon="mdi-check"
size="small"
variant="text"
color="success"
@click.stop="saveEdit(index)"
/>
</div>
</template>
</VListItem>
<VDivider v-if="index < repoList.length - 1" class="mx-4" />
</div>
</template>
</draggable>
</VList>
<div v-else class="plugin-market-empty text-center text-medium-emphasis">
<VIcon icon="mdi-source-repository-multiple" size="48" class="mb-2" />
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div>
</div>
</div>
</div>
<div v-else class="plugin-market-text-panel">
<div class="plugin-market-textarea-field">
<VIcon icon="mdi-text-box-edit-outline" class="plugin-market-textarea-icon" />
<textarea
v-model="repoText"
class="plugin-market-textarea"
:placeholder="t('dialog.pluginMarketSetting.textPlaceholder')"
/>
</div>
<div class="plugin-market-text-hint">
{{ t('dialog.pluginMarketSetting.textHint') }}
</div>
<VAlert
v-if="parsedTextRepos.invalidRepos.length > 0"
type="error"
variant="tonal"
density="compact"
class="plugin-market-invalid-alert"
>
<div>{{ t('dialog.pluginMarketSetting.invalidText', { count: parsedTextRepos.invalidRepos.length }) }}</div>
<div class="text-truncate">
{{ parsedTextRepos.invalidRepos.slice(0, 3).join(', ') }}
</div>
</VAlert>
<VAlert
v-else-if="parsedTextRepos.duplicateRepos.length > 0"
type="warning"
variant="tonal"
density="compact"
>
{{ t('dialog.pluginMarketSetting.duplicateTextIgnored') }}
</VAlert>
</div>
</VCardText>
<VCardActions>
<VCardActions class="plugin-market-actions">
<VSpacer />
<VBtn
color="primary"
variant="flat"
@click="saveHandle"
prepend-icon="mdi-content-save-check"
class="px-5 me-3"
:disabled="repoList.length === 0"
class="px-5"
:disabled="saveDisabled"
>
{{ t('dialog.pluginMarketSetting.save') }}
</VBtn>
@@ -232,6 +445,24 @@ onMounted(() => {
.plugin-market-dialog-card {
display: flex;
flex-direction: column;
block-size: min(82vh, 50rem);
}
.plugin-market-card-item {
flex: 0 0 auto;
padding-block: 0.875rem;
}
.plugin-market-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-inline-end: 2rem;
}
.plugin-market-title {
min-inline-size: 0;
}
.plugin-market-dialog-body {
@@ -239,6 +470,31 @@ onMounted(() => {
overflow: hidden;
flex: 1;
flex-direction: column;
gap: 0.875rem;
min-block-size: 0;
padding-block: 0.875rem !important;
}
.plugin-market-toolbar {
display: flex;
flex-shrink: 0;
}
.plugin-market-mode-toggle {
inline-size: 100%;
:deep(.v-btn) {
flex: 1;
min-inline-size: 0;
}
}
.plugin-market-list-panel,
.plugin-market-text-panel {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.5rem;
min-block-size: 0;
}
@@ -248,7 +504,173 @@ onMounted(() => {
.plugin-market-list-wrap {
flex: 1;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.72);
min-block-size: 0;
overflow-y: auto;
}
.plugin-market-repo-list {
background: transparent;
}
.plugin-market-repo-item {
min-block-size: 4.5rem;
}
.plugin-market-repo-title {
display: flex;
align-items: center;
gap: 0.5rem;
min-inline-size: 0;
}
.plugin-market-repo-name,
.plugin-market-repo-url {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-break: anywhere;
overflow-wrap: anywhere;
white-space: normal;
word-break: break-word;
}
.plugin-market-repo-url {
line-height: 1.4;
}
.plugin-market-repo-index {
flex: 0 0 auto;
color: rgba(var(--v-theme-on-surface), 0.48);
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
inline-size: 1.75rem;
}
.plugin-market-empty {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
min-block-size: 14rem;
}
.plugin-market-textarea-field {
position: relative;
display: flex;
flex: 1;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 8px;
background: rgba(var(--v-theme-surface), 0.72);
min-block-size: 0;
overflow: hidden;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
&:focus-within {
border-color: rgb(var(--v-theme-primary));
box-shadow: 0 0 0 1px rgb(var(--v-theme-primary));
}
}
.plugin-market-textarea-icon {
position: absolute;
z-index: 1;
color: rgba(var(--v-theme-on-surface), 0.62);
inset-block-start: 1.25rem;
inset-inline-start: 1rem;
pointer-events: none;
}
.plugin-market-textarea {
flex: 1;
border: 0;
background: transparent;
block-size: 100%;
color: rgb(var(--v-theme-on-surface));
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 1rem;
line-height: 1.6;
min-block-size: 0;
outline: none;
overflow-y: auto;
padding: 1rem 1rem 1rem 3.25rem;
resize: none;
white-space: pre-wrap;
word-break: break-word;
}
.plugin-market-text-hint {
flex: 0 0 auto;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 0.8125rem;
line-height: 1.4;
padding-inline: 1rem;
}
.plugin-market-invalid-alert {
:deep(.v-alert__content) {
min-inline-size: 0;
}
}
.plugin-market-actions {
flex: 0 0 auto;
gap: 0.5rem;
padding: 0.75rem 1.5rem 1rem;
}
@media (max-width: 600px) {
.plugin-market-dialog-card {
block-size: 100dvh;
}
.plugin-market-card-item {
padding: 0.75rem 1rem 0.625rem;
}
.plugin-market-header {
align-items: center;
gap: 0.5rem;
padding-inline-end: 2.25rem;
}
.plugin-market-header :deep(.v-card-title) {
font-size: 1.125rem;
line-height: 1.35;
}
.plugin-market-dialog-body {
gap: 0.625rem;
padding: 0.75rem 1rem !important;
}
.plugin-market-mode-toggle {
inline-size: 100%;
:deep(.v-btn) {
flex: 1;
min-inline-size: 0;
}
}
.plugin-market-list-panel,
.plugin-market-text-panel {
gap: 0.625rem;
}
.plugin-market-list-wrap {
min-block-size: 0;
}
.plugin-market-empty {
min-block-size: 10rem;
}
.plugin-market-actions {
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
}
}
</style>

View File

@@ -49,7 +49,15 @@ function handleUpdate() {
<template v-if="props.showUpdateAction">
<VDivider />
<VCardItem>
<VBtn @click="handleUpdate" block>
<VAlert
v-if="props.plugin?.system_version_compatible === false"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
:text="props.plugin?.system_version_message || t('plugin.incompatibleSystemVersion')"
/>
<VBtn @click="handleUpdate" block :disabled="props.plugin?.system_version_compatible === false">
<template #prepend>
<VIcon icon="mdi-arrow-up-circle-outline" />
</template>

View File

@@ -10,6 +10,7 @@ import {
ManualTransferPayload,
ManualTransferPreviewData,
ManualTransferPreviewItem,
ManualTransferTargetPathData,
StorageConf,
TransferDirectoryConf,
TransferForm,
@@ -113,10 +114,20 @@ const episodeFormatRecommendState = reactive<{
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
interface ManualTransferTargetPathRequest {
fileitem?: FileItem
fileitems?: FileItem[]
logid?: number
logids?: number[]
target_storage?: string | null
}
// 生成文件项稳定键,用于去重和状态同步。
function getFileItemKey(item?: FileItem) {
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
}
// 按存储、类型和路径去重文件项。
function dedupeFileItems(fileItems?: FileItem[]) {
if (!fileItems?.length) return []
@@ -128,6 +139,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 +159,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 +187,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')
@@ -196,11 +274,12 @@ const transferForm = reactive<TransferForm>({
fileitem: {} as FileItem,
logid: 0,
target_storage: props.target_storage ?? 'local',
target_path: props.target_path ?? '',
target_path: normalizeTargetPath(props.target_path),
transfer_type: '',
min_filesize: 0,
scrape: false,
from_history: false,
episode_group: null,
})
// 所有媒体库目录
@@ -222,6 +301,79 @@ const targetDirectories = computed(() => {
return [...new Set(libraryDirectories)]
})
// 构造目的路径自动匹配请求,只传用户真实上下文,避免用默认存储误导后端匹配。
function createTargetPathMatchRequest(): ManualTransferTargetPathRequest | undefined {
const payload: ManualTransferTargetPathRequest = {}
if (props.target_storage) {
payload.target_storage = props.target_storage
}
if (normalizedItems.value.length === 1) {
payload.fileitem = normalizedItems.value[0]
return payload
}
if (normalizedItems.value.length > 1) {
payload.fileitems = normalizedItems.value
return payload
}
if (props.logids?.length) {
if (props.logids.length > 1) {
payload.logids = props.logids
return payload
}
payload.logid = props.logids[0]
return payload
}
}
// 应用后端匹配到的目的路径配置,未匹配时保持 null 等待用户手工选择。
function applyMatchedTargetPath(data?: ManualTransferTargetPathData) {
const matchedTargetPath = normalizeTargetPath(data?.target_path)
if (!matchedTargetPath) {
transferForm.target_path = null
return
}
transferForm.target_storage = data?.target_storage || transferForm.target_storage || 'local'
transferForm.transfer_type = data?.transfer_type || transferForm.transfer_type
transferForm.scrape = data?.scrape ?? false
transferForm.library_type_folder = data?.library_type_folder ?? false
transferForm.library_category_folder = data?.library_category_folder ?? false
transferForm.target_path = matchedTargetPath
}
// 请求后端按源目录匹配最合适的手动整理目的路径。
async function autoSelectTargetPath() {
if (normalizeTargetPath(props.target_path) || transferForm.target_path) return
const payload = createTargetPathMatchRequest()
if (!payload) {
transferForm.target_path = null
return
}
try {
const result = await api.post<ApiResponse<ManualTransferTargetPathData>, ApiResponse<ManualTransferTargetPathData>>(
'transfer/manual/target-path',
payload,
)
if (!result.success) {
transferForm.target_path = null
return
}
applyMatchedTargetPath(result.data)
} catch (error) {
console.log(error)
transferForm.target_path = null
}
}
// 监听目的路径变化,配置默认值
watch(
() => transferForm.target_path,
@@ -249,6 +401,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 ?? []
@@ -302,6 +479,12 @@ function getUniqueValues(values: (string | undefined)[]) {
return [...new Set(values.map(item => item?.trim()).filter(Boolean) as string[])]
}
// 归一化可选目的路径,保证未指定时向接口传递 null 而不是空字符串。
function normalizeTargetPath(path?: string | null) {
const normalizedPath = path?.trim()
return normalizedPath || null
}
// 统一解析接口返回的数字字段,兼容 string/number
function toPreviewNumber(value: unknown) {
if (value === undefined || value === null || value === '') return undefined
@@ -416,6 +599,22 @@ const previewFileRows = computed(() => {
})
})
// 标准化预览项中的识别词命中详情
function getPreviewApplyWords(item: ManualTransferPreviewItem) {
return (item.apply_words ?? []).filter(Boolean)
}
// 手动整理识别词应用详情
const previewCustomWordDetails = computed(() => {
return filteredPreviewItems.value
.map(item => ({
sourceName: getFileName(item.source),
orgString: item.org_string,
applyWords: getPreviewApplyWords(item),
}))
.filter(item => item.applyWords.length > 0)
})
// 是否需要拓宽窗口
const previewNeedsWideLayout = computed(() => {
const candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)]
@@ -438,15 +637,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 +676,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 +702,39 @@ 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,
target_path: normalizeTargetPath(transferForm.target_path),
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 +744,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 +758,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 +781,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 +821,7 @@ async function handleRecommendEpisodeFormat() {
}
}
// 默认预览数据
// 创建空预览数据,作为多次预览结果的合并目标。
function getDefaultPreviewData(): ManualTransferPreviewData {
return {
summary: {
@@ -564,18 +834,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 +859,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 +919,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 +1034,7 @@ async function previewTransfer() {
}
}
// 切换预览面板,首次展开时拉取最新预览结果。
async function togglePreview() {
if (previewLoading.value) return
@@ -794,6 +1097,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 +1164,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)
}
}
@@ -883,14 +1204,16 @@ async function transfer(background: boolean = false) {
emit('done')
}
onMounted(() => {
loadDirectories()
onMounted(async () => {
await loadDirectories()
await autoSelectTargetPath()
loadStorages()
loadEpisodeFormatRuleConfiguration()
})
onUnmounted(() => {
stopLoadingProgress()
if (episodeGroupQueryTimer) clearTimeout(episodeGroupQueryTimer)
previewFileBodyResizeObserver?.disconnect()
})
</script>
@@ -1000,9 +1323,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')"
@@ -1217,6 +1545,36 @@ onUnmounted(() => {
<span class="preview-overview-card__value">{{ previewEpisodeCountText }}</span>
</div>
</div>
<div v-if="previewCustomWordDetails.length" class="preview-custom-words">
<div class="preview-custom-words__title">
<VIcon icon="mdi-tag-text-outline" size="16" />
<span>{{ t('dialog.reorganize.customWordsApplied') }}</span>
</div>
<div class="preview-custom-words__items">
<div
v-for="(detail, index) in previewCustomWordDetails"
:key="`${detail.sourceName}-${index}`"
class="preview-custom-words__item"
>
<div class="preview-custom-words__source">{{ detail.sourceName }}</div>
<div v-if="detail.orgString" class="preview-custom-words__original">
{{ detail.orgString }}
</div>
<div class="preview-custom-words__chips">
<VChip
v-for="(word, wordIndex) in detail.applyWords"
:key="`${word}-${wordIndex}`"
variant="outlined"
color="info"
size="small"
class="preview-custom-words__chip"
>
{{ word }}
</VChip>
</div>
</div>
</div>
</div>
</div>
<div class="reorganize-preview-list">
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
@@ -1476,6 +1834,66 @@ onUnmounted(() => {
white-space: nowrap;
}
.preview-custom-words {
display: flex;
flex-direction: column;
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 0.75rem;
gap: 0.75rem;
padding-block: 0.875rem;
padding-inline: 1rem;
}
.preview-custom-words__title {
display: inline-flex;
align-items: center;
color: rgb(var(--v-theme-info));
font-size: 0.875rem;
font-weight: 600;
gap: 0.375rem;
}
.preview-custom-words__items {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-inline-size: 0;
}
.preview-custom-words__item {
display: flex;
flex-direction: column;
gap: 0.375rem;
min-inline-size: 0;
}
.preview-custom-words__source {
overflow-wrap: anywhere;
color: rgb(var(--v-theme-on-surface));
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.4;
}
.preview-custom-words__original {
overflow-wrap: anywhere;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.75rem;
line-height: 1.4;
}
.preview-custom-words__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
min-inline-size: 0;
}
.preview-custom-words__chip {
max-inline-size: 100%;
white-space: normal;
}
.reorganize-preview-pane__scroll {
display: flex;
overflow: hidden auto;
@@ -1575,11 +1993,13 @@ onUnmounted(() => {
}
.preview-file-row__path {
overflow: hidden;
overflow: visible;
overflow-wrap: anywhere;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
white-space: normal;
word-break: break-all;
}
.preview-file-row__card--target .preview-file-row__name {

View File

@@ -10,7 +10,7 @@ const props = defineProps({
type: Array as PropType<Site[]>,
required: true,
},
selected: Array as PropType<Number[]>,
selected: Array as PropType<number[]>,
})
// 定义事件
@@ -20,38 +20,66 @@ const emit = defineEmits(['close', 'search', 'reload'])
const siteFilter = ref('')
// 已选择站点
const selectedSites = ref<any[]>(props.selected || [])
const selectedSites = ref<number[]>([])
// 根据当前可用站点清理选中项,避免停用或已删除站点参与计数。
function normalizeSelectedSites(selectedSiteIds: number[] = []) {
const availableSiteIds = new Set(props.sites.map((site: Site) => site.id))
const normalizedSiteIds: number[] = []
selectedSiteIds.forEach(siteId => {
if (availableSiteIds.has(siteId) && !normalizedSiteIds.includes(siteId)) {
normalizedSiteIds.push(siteId)
}
})
return normalizedSiteIds
}
watch(
() => props.selected,
value => {
if (selectedSites.value.length == 0 && value) {
selectedSites.value = value
}
[() => props.selected, () => props.sites],
([value]) => {
selectedSites.value = normalizeSelectedSites(value || [])
},
{ immediate: true },
)
// 全选/全不选按钮文字
const checkAllText = computed(() => {
return selectedSites.value.length < props.sites?.length
return selectedSites.value.length < props.sites.length
? t('dialog.searchSite.selectAll')
: t('dialog.searchSite.deselectAll')
})
// 全选/全不选
const checkAllSitesorNot = () => {
if (selectedSites.value.length < props.sites?.length) {
selectedSites.value = props.sites?.map((item: Site) => item.id)
if (selectedSites.value.length < props.sites.length) {
selectedSites.value = props.sites.map((item: Site) => item.id)
} else {
selectedSites.value = []
}
}
// 切换单个站点的选择状态。
function toggleSiteSelection(siteId: number) {
const index = selectedSites.value.indexOf(siteId)
if (index === -1) {
selectedSites.value.push(siteId)
} else {
selectedSites.value.splice(index, 1)
}
}
// 确认搜索时只提交当前可用站点。
function confirmSearch() {
emit('search', normalizeSelectedSites(selectedSites.value))
}
// 根据筛选条件过滤站点
const filteredSites = computed(() => {
if (!siteFilter.value) return props.sites
const filter = siteFilter.value.toLowerCase()
return props.sites?.filter((site: Site) => site.name.toLowerCase().includes(filter))
return props.sites.filter((site: Site) => site.name.toLowerCase().includes(filter))
})
</script>
<template>
@@ -107,16 +135,7 @@ const filteredSites = computed(() => {
'site-hover': isHovering && !selectedSites.includes(site.id),
},
]"
@click="
() => {
const index = selectedSites.indexOf(site.id)
if (index === -1) {
selectedSites.push(site.id)
} else {
selectedSites.splice(index, 1)
}
}
"
@click="toggleSiteSelection(site.id)"
>
<VIcon
:icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'"
@@ -161,7 +180,7 @@ const filteredSites = computed(() => {
<VBtn
color="primary"
:disabled="selectedSites.length === 0"
@click="emit('search', selectedSites)"
@click="confirmSearch"
prepend-icon="mdi-magnify"
class="d-flex align-center justify-center px-5"
>

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

@@ -83,6 +83,8 @@ interface UseLlmProviderDirectoryOptions {
apiKey: Ref<string>
baseUrl: Ref<string>
baseUrlPreset?: Ref<string>
useProxy?: Ref<boolean>
userAgent?: Ref<string>
model: Ref<string>
maxContextTokens?: Ref<number>
authConnected?: Ref<boolean>
@@ -253,6 +255,8 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
api_key: normalizeValue(options.apiKey.value) || undefined,
base_url: normalizeValue(options.baseUrl.value) || undefined,
base_url_preset: normalizeValue(options.baseUrlPreset?.value) || undefined,
use_proxy: options.useProxy?.value,
user_agent: normalizeValue(options.userAgent?.value) || undefined,
force_refresh: forceRefresh,
},
})

View File

@@ -61,8 +61,10 @@ export interface WizardData {
supportAudioOutput: boolean
apiKey: string
baseUrl: string
useProxy: boolean
baseUrlPreset: string
maxContextTokens: number
userAgent: string
audioInputProvider: string
audioInputApiKey: string
audioInputBaseUrl: string
@@ -247,8 +249,10 @@ const wizardData = ref<WizardData>({
supportAudioOutput: false,
apiKey: '',
baseUrl: 'https://api.deepseek.com',
useProxy: true,
baseUrlPreset: '',
maxContextTokens: 64,
userAgent: '',
audioInputProvider: 'openai',
audioInputApiKey: '',
audioInputBaseUrl: '',
@@ -1444,8 +1448,10 @@ export function useSetupWizard() {
LLM_SUPPORT_AUDIO_OUTPUT: wizardData.value.agent.supportAudioOutput,
LLM_API_KEY: wizardData.value.agent.apiKey,
LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
LLM_USE_PROXY: wizardData.value.agent.useProxy,
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
LLM_USER_AGENT: wizardData.value.agent.userAgent || null,
AUDIO_INPUT_PROVIDER: wizardData.value.agent.audioInputProvider || 'openai',
AUDIO_INPUT_API_KEY: wizardData.value.agent.audioInputApiKey || null,
AUDIO_INPUT_BASE_URL: wizardData.value.agent.audioInputBaseUrl || null,
@@ -1557,8 +1563,10 @@ export function useSetupWizard() {
wizardData.value.agent.supportAudioOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_OUTPUT)
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
wizardData.value.agent.useProxy = result.data.LLM_USE_PROXY ?? true
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
wizardData.value.agent.userAgent = result.data.LLM_USER_AGENT || ''
wizardData.value.agent.audioInputProvider = result.data.AUDIO_INPUT_PROVIDER || 'openai'
wizardData.value.agent.audioInputApiKey = result.data.AUDIO_INPUT_API_KEY || ''
wizardData.value.agent.audioInputBaseUrl = result.data.AUDIO_INPUT_BASE_URL || ''

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',
@@ -1433,6 +1456,11 @@ export default {
llmApiKeyPlaceholder: 'Please enter API key',
llmBaseUrl: 'LLM Base URL',
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints',
llmUseProxy: 'Use System Proxy',
llmUseProxyHint:
'When enabled, Agent connections to the current LLM provider use the system proxy from advanced settings.',
llmUserAgent: 'User-Agent',
llmUserAgentHint: 'User-Agent sent to OpenAI-compatible APIs. Leave empty to use the SDK default.',
llmProviderAuth: 'Provider Authorization',
llmProviderAuthHint:
'Providers that support account authorization can complete sign-in here and reuse the saved auth state.',
@@ -1447,15 +1475,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 +1492,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 +1579,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',
@@ -1559,15 +1591,19 @@ export default {
'Can improve read/write concurrency performance, but may increase the risk of data loss in exceptional cases, requires restart to take effect',
tmdbApiDomain: 'TMDB API Service Address',
tmdbApiDomainPlaceholder: 'api.themoviedb.org',
tmdbApiDomainHint: 'Customize themoviedb API domain or proxy address',
tmdbApiDomainHint: 'Customize TheMovieDb API domain or proxy address',
tmdbApiDomainRequired: 'Please enter TMDB API domain',
tmdbApiKey: 'TMDB API Key',
tmdbApiKeyPlaceholder: 'Please enter TMDB API Key',
tmdbApiKeyHint: 'Set TheMovieDb API Key',
tmdbApiKeyRequired: 'Please enter TMDB API Key',
tmdbImageDomain: 'TMDB Image Service Address',
tmdbImageDomainPlaceholder: 'image.tmdb.org',
tmdbImageDomainHint: 'Customize themoviedb image service domain or proxy address',
tmdbImageDomainHint: 'Customize TheMovieDb image service domain or proxy address',
tmdbImageDomainRequired: 'Please enter image service domain',
tmdbLocale: 'TMDB Metadata Language',
tmdbLocalePlaceholder: 'en',
tmdbLocaleHint: 'Customize themoviedb metadata language',
tmdbLocaleHint: 'Customize TheMovieDb metadata language',
metaCacheExpire: 'Media Metadata Cache Expiration Time',
metaCacheExpireHint: 'Recognition metadata local cache time, use built-in default value when set to 0',
metaCacheExpireRequired: 'Please enter metadata cache time',
@@ -1576,7 +1612,7 @@ export default {
scrapFollowTmdbHint:
'When turned off, organization history will be used (if available) to avoid TMDB data changes during subscription',
scrapOriginalImage: 'Scrap TheMovieDb Original Language Image',
scrapOriginalImageHint: 'Scrap original language image from themoviedb, otherwise scrap metadata language image',
scrapOriginalImageHint: 'Scrap original language image from TheMovieDb, otherwise scrap metadata language image',
fanartEnable: 'Fanart Image Data Source',
fanartEnableHint: 'Use image data from fanart.tv',
fanartLang: 'Fanart Language',
@@ -1639,6 +1675,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',
@@ -1686,6 +1725,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',
@@ -1841,7 +1885,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!',
@@ -2446,11 +2490,19 @@ export default {
title: 'Plugin Market Settings',
repoUrl: 'Plugin Repository URL',
repoPlaceholder: 'Format: https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: 'Multiple URLs separated by lines, only Github repositories are supported',
repoHint: 'Separate multiple URLs with new lines or commas',
urlPlaceholder: 'Enter plugin repository URL',
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
listMode: 'List',
textMode: 'Text',
textHint: 'Paste repository URLs one per line or separated by commas.',
addRepo: 'Add repository',
noRepos: 'No plugin repository URLs',
invalidUrl: 'Please enter a valid URL',
duplicateUrl: 'This URL already exists',
invalidText: 'There are {count} invalid URLs in the text. Fix them before saving.',
invalidTextIgnored: '{count} invalid URLs ignored',
duplicateTextIgnored: 'Duplicate URLs will be removed automatically when saving.',
close: 'Close',
save: 'Save',
saveSuccess: 'Plugin repository saved successfully',
@@ -2535,9 +2587,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',
@@ -2548,7 +2604,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',
@@ -2586,6 +2643,7 @@ export default {
previewSeasonInfo: 'Season',
previewSeasonLabel: 'Season',
previewEpisodeCount: 'Episodes',
customWordsApplied: 'Recognition Word Details',
previewAfterColumn: 'After',
previewBeforeColumn: 'Before',
previewFileNameColumn: 'Filename',
@@ -2650,7 +2708,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?',
@@ -2838,6 +2896,8 @@ export default {
projectHome: 'Project Home',
updateHistory: 'Update History',
local: 'Local',
systemVersion: 'System Version',
incompatibleSystemVersion: 'The current MoviePilot version does not meet this plugin requirement.',
installToLocal: 'Install to Local',
totalDownloads: 'Total {count} downloads',
viewData: 'View Data',

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: '自定义',
@@ -1425,6 +1448,10 @@ export default {
llmApiKeyPlaceholder: '请输入API密钥',
llmBaseUrl: 'LLM基础URL',
llmBaseUrlHint: 'LLM API的基础URL地址用于自定义API端点',
llmUseProxy: '使用系统代理',
llmUseProxyHint: '启用后Agent 连接当前 LLM 提供商时会应用高级设置中的系统代理',
llmUserAgent: 'User-Agent',
llmUserAgentHint: 'OpenAI 兼容接口请求使用的 User-Agent留空则使用 SDK 默认值',
llmProviderAuth: '提供商授权',
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
llmProviderConnectedAs: '当前已连接:{label}',
@@ -1436,26 +1463,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 +1564,8 @@ export default {
subscribeStatisticShareHint: '分享订阅统计数据到热门订阅供其他MPer参考',
pluginStatisticShare: '上报插件安装数据',
pluginStatisticShareHint: '上报插件安装数据给服务器,用于统计展示插件安装情况',
usageStatisticShare: '上报安装版本统计',
usageStatisticShareHint: '上报匿名安装ID和当前前后端版本用于统计各版本安装用户数',
workflowStatisticShare: '分享工作流数据',
workflowStatisticShareHint: '分享工作流统计数据到热门工作流供其他MPer参考',
bigMemoryMode: '大内存模式',
@@ -1542,15 +1574,19 @@ export default {
dbWalEnableHint: '可提升读写并发性能,但可能在异常情况下增加数据丢失风险,更改后需重启生效',
tmdbApiDomain: 'TMDB API服务地址',
tmdbApiDomainPlaceholder: 'api.themoviedb.org',
tmdbApiDomainHint: '自定义themoviedb API域名或代理地址',
tmdbApiDomainHint: '自定义 TheMovieDb API域名或代理地址',
tmdbApiDomainRequired: '请输入TMDB API域名',
tmdbApiKey: 'TMDB API Key',
tmdbApiKeyPlaceholder: '请输入 TMDB API Key',
tmdbApiKeyHint: '设置 TheMovieDb API Key',
tmdbApiKeyRequired: '请输入TMDB API Key',
tmdbImageDomain: 'TMDB 图片服务地址',
tmdbImageDomainPlaceholder: 'image.tmdb.org',
tmdbImageDomainHint: '自定义themoviedb图片服务域名或代理地址',
tmdbImageDomainHint: '自定义 TheMovieDb 图片服务域名或代理地址',
tmdbImageDomainRequired: '请输入图片服务域名',
tmdbLocale: 'TMDB 元数据语言',
tmdbLocalePlaceholder: 'zh',
tmdbLocaleHint: '自定义themoviedb元数据语言',
tmdbLocaleHint: '自定义 TheMovieDb 元数据语言',
metaCacheExpire: '媒体元数据缓存过期时间',
metaCacheExpireHint: '识别元数据本地缓存时间,为 0 时使用内置默认值',
metaCacheExpireRequired: '请输入元数据缓存时间',
@@ -1614,6 +1650,9 @@ export default {
pluginLocalRepoPathsHint: '本地插件仓库目录,多个目录用英文逗号分隔,支持相对路径和绝对路径',
encodingDetectionPerformanceMode: '编码探测性能模式',
encodingDetectionPerformanceModeHint: '优先提升探测效率,但可能降低编码探测的准确性',
rustAccel: 'Rust 加速',
rustAccelHint: '启用后使用后端 Rust 扩展加速过滤、RSS、索引器和媒体识别等热路径',
rustAccelUnavailableHint: '当前后端未安装或未加载 Rust 加速扩展,无法启用',
transferThreads: '文件整理线程数',
transferThreadsHint: '多线程整理文件可以提高速度,但可能增加系统资源占用',
tokenizedSearch: '分词搜索',
@@ -1659,6 +1698,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',
@@ -1810,7 +1854,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: '自定义识别词保存失败!',
@@ -2400,11 +2444,19 @@ export default {
title: '插件市场设置',
repoUrl: '插件仓库地址',
repoPlaceholder: '格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: '多个地址使用换行分隔仅支持Github仓库',
repoHint: '多个地址使用换行或英文逗号分隔',
urlPlaceholder: '输入插件仓库地址',
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
listMode: '列表维护',
textMode: '文本维护',
textHint: '直接粘贴仓库地址串,一行一个或使用英文逗号分隔。',
addRepo: '添加仓库',
noRepos: '暂无插件仓库地址',
invalidUrl: '请输入有效的URL地址',
duplicateUrl: '该地址已存在',
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
invalidTextIgnored: '已忽略 {count} 个无效地址',
duplicateTextIgnored: '重复地址会在保存时自动去重。',
close: '关闭',
save: '保存',
saveSuccess: '插件仓库保存成功',
@@ -2489,9 +2541,13 @@ export default {
doubanId: '豆瓣编号',
mediaIdHint: '按名称查询媒体编号,留空自动识别',
mediaIdPlaceholder: '留空自动识别',
episodeGroup: '剧集组编号',
episodeGroupHint: '指定剧集组',
episodeGroupPlaceholder: '手动查询剧集组',
episodeGroup: '剧集组',
episodeGroupHint: '输入 TMDB 编号后自动查询剧集组,也可手动填写剧集组编号',
episodeGroupPlaceholder: '先输入 TMDB 编号',
defaultEpisodeGroup: '不指定剧集组',
defaultEpisodeGroupHint: '使用 TMDB 默认季集排序',
seasonCount: '{count} 季',
episodeCount: '{count} 集',
season: '季',
seasonHint: '第几季',
episodeDetail: '集',
@@ -2502,7 +2558,8 @@ export default {
episodeFormatPlaceholder: '使用{ep}定位集数',
episodeFormatRecommendAction: '智能生成',
episodeFormatRecommendLoading: '生成中...',
episodeFormatRecommendSelectFile: '请先选择单个文件或目录',
episodeFormatRecommendSelectFile: '请先选择单个文件、单个目录,或多个文件',
episodeFormatRecommendInvalidSelection: '当前选择不满足智能生成条件',
episodeFormatRecommendNeedWords: '手动整理集数定位规则为空,请先前往词表填写',
episodeFormatRecommendSuccess: '已生成集数定位模板',
episodeFormatRecommendOverwriteSuccess: '已用智能生成结果覆盖当前集数定位',
@@ -2539,6 +2596,7 @@ export default {
previewSeasonInfo: '季信息',
previewSeasonLabel: '季',
previewEpisodeCount: '总集数',
customWordsApplied: '识别词应用详情',
previewAfterColumn: '整理后',
previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名',
@@ -2603,7 +2661,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: '是否确认取消订阅?',
@@ -2791,6 +2849,8 @@ export default {
projectHome: '项目主页',
updateHistory: '更新说明',
local: '本地',
systemVersion: '系统版本',
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
installToLocal: '安装到本地',
totalDownloads: '共 {count} 次下载',
viewData: '查看数据',

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: '自定義',
@@ -1426,6 +1449,10 @@ export default {
llmApiKeyPlaceholder: '請輸入API密鑰',
llmBaseUrl: 'LLM基礎URL',
llmBaseUrlHint: 'LLM API的基礎URL地址用於自定義API端點',
llmUseProxy: '使用系統代理',
llmUseProxyHint: '啟用後Agent 連接目前 LLM 提供商時會套用進階設定中的系統代理',
llmUserAgent: 'User-Agent',
llmUserAgentHint: 'OpenAI 兼容接口請求使用的 User-Agent留空則使用 SDK 預設值',
llmProviderAuth: '提供商授權',
llmProviderAuthHint: '支援帳號登入授權的提供商,可以直接在這裡完成登入並重用授權狀態。',
llmProviderConnectedAs: '目前已連接:{label}',
@@ -1437,26 +1464,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 +1565,8 @@ export default {
subscribeStatisticShareHint: '分享訂閱統計數據到熱門訂閱供其他MPer參考',
pluginStatisticShare: '上報插件安裝數據',
pluginStatisticShareHint: '上報插件安裝數據給服務器,用於統計展示插件安裝情況',
usageStatisticShare: '上報安裝版本統計',
usageStatisticShareHint: '上報匿名安裝ID和當前前後端版本用於統計各版本安裝用戶數',
workflowStatisticShare: '分享工作流數據',
workflowStatisticShareHint: '分享工作流統計數據到熱門工作流供其他MPer參考',
bigMemoryMode: '大內存模式',
@@ -1543,15 +1575,19 @@ export default {
dbWalEnableHint: '可提升讀寫併發性能,但可能在異常情況下增加數據丟失風險,更改後需重啟生效',
tmdbApiDomain: 'TMDB API服務地址',
tmdbApiDomainPlaceholder: 'api.themoviedb.org',
tmdbApiDomainHint: '自定義themoviedb API域名或代理地址',
tmdbApiDomainHint: '自定義 TheMovieDb API域名或代理地址',
tmdbApiDomainRequired: '請輸入TMDB API域名',
tmdbApiKey: 'TMDB API Key',
tmdbApiKeyPlaceholder: '請輸入 TMDB API Key',
tmdbApiKeyHint: '設定 TheMovieDb API Key',
tmdbApiKeyRequired: '請輸入TMDB API Key',
tmdbImageDomain: 'TMDB 圖片服務地址',
tmdbImageDomainPlaceholder: 'image.tmdb.org',
tmdbImageDomainHint: '自定義themoviedb圖片服務域名或代理地址',
tmdbImageDomainHint: '自定義 TheMovieDb 圖片服務域名或代理地址',
tmdbImageDomainRequired: '請輸入圖片服務域名',
tmdbLocale: 'TMDB 元數據語言',
tmdbLocalePlaceholder: 'zh',
tmdbLocaleHint: '自定義themoviedb元數據語言',
tmdbLocaleHint: '自定義 TheMovieDb 元數據語言',
metaCacheExpire: '媒體元數據緩存過期時間',
metaCacheExpireHint: '識別元數據本地緩存時間,為 0 時使用內置默認值',
metaCacheExpireRequired: '請輸入元數據緩存時間',
@@ -1615,6 +1651,9 @@ export default {
pluginLocalRepoPathsHint: '本地插件倉庫目錄,多個目錄用英文逗號分隔,支持相對路徑和絕對路徑',
encodingDetectionPerformanceMode: '編碼探測性能模式',
encodingDetectionPerformanceModeHint: '優先提升探測效率,但可能降低編碼探測的準確性',
rustAccel: 'Rust 加速',
rustAccelHint: '啟用後使用後端 Rust 擴展加速過濾、RSS、索引器和媒體識別等熱路徑',
rustAccelUnavailableHint: '當前後端未安裝或未加載 Rust 加速擴展,無法啟用',
transferThreads: '文件整理線程數',
transferThreadsHint: '多線程整理文件可以提高速度,但可能增加系統資源佔用',
tokenizedSearch: '分詞搜索',
@@ -1660,6 +1699,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',
@@ -1811,7 +1855,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: '自定義識別詞保存失敗!',
@@ -2401,11 +2445,19 @@ export default {
title: '插件市場設置',
repoUrl: '插件倉庫地址',
repoPlaceholder: '格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: '多個地址使用换行分隔僅支援Github倉庫',
repoHint: '多個地址使用換行或英文逗號分隔',
urlPlaceholder: '輸入插件倉庫地址',
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
listMode: '列表維護',
textMode: '文字維護',
textHint: '直接貼上倉庫地址串,一行一個或使用英文逗號分隔。',
addRepo: '新增倉庫',
noRepos: '暫無插件倉庫地址',
invalidUrl: '請輸入有效的URL地址',
duplicateUrl: '該地址已存在',
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
invalidTextIgnored: '已忽略 {count} 個無效地址',
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
close: '關閉',
save: '儲存',
saveSuccess: '插件倉庫儲存成功',
@@ -2490,9 +2542,13 @@ export default {
doubanId: '豆瓣編號',
mediaIdHint: '按名稱查詢媒體編號,留空自動識別',
mediaIdPlaceholder: '留空自動識別',
episodeGroup: '劇集組編號',
episodeGroupHint: '指定劇集組',
episodeGroupPlaceholder: '手動查詢劇集組',
episodeGroup: '劇集組',
episodeGroupHint: '輸入 TMDB 編號後自動查詢劇集組,也可手動填寫劇集組編號',
episodeGroupPlaceholder: '先輸入 TMDB 編號',
defaultEpisodeGroup: '不指定劇集組',
defaultEpisodeGroupHint: '使用 TMDB 預設季集排序',
seasonCount: '{count} 季',
episodeCount: '{count} 集',
season: '季',
seasonHint: '第幾季',
episodeDetail: '集',
@@ -2503,7 +2559,8 @@ export default {
episodeFormatPlaceholder: '使用{ep}定位集數',
episodeFormatRecommendAction: '智能生成',
episodeFormatRecommendLoading: '生成中...',
episodeFormatRecommendSelectFile: '請先選擇單個文件或目錄',
episodeFormatRecommendSelectFile: '請先選擇單個文件、單個目錄,或多個文件',
episodeFormatRecommendInvalidSelection: '當前選擇不滿足智能生成條件',
episodeFormatRecommendNeedWords: '手動整理集數定位規則為空,請先前往詞表填寫',
episodeFormatRecommendSuccess: '已生成集數定位模板',
episodeFormatRecommendOverwriteSuccess: '已用智能生成結果覆蓋當前集數定位',
@@ -2540,6 +2597,7 @@ export default {
previewSeasonInfo: '季資訊',
previewSeasonLabel: '季',
previewEpisodeCount: '總集數',
customWordsApplied: '識別詞應用詳情',
previewAfterColumn: '整理後',
previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名',
@@ -2604,7 +2662,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

@@ -294,6 +294,7 @@ const streamPreviewLimit = 24
const streamUiFlushDelay = 1000
const streamPreviewBufferLimit = streamPreviewLimit * 4
const searchStreamIdleTimeout = 90_000
const searchStreamDoneCloseDelay = 1500
const streamTotalCount = ref(0)
const streamPreviewDataList = ref<Array<Context>>([])
@@ -528,6 +529,11 @@ function resetSearchResults() {
applyFilter()
}
// 判断当前页面是否已经完成过一次带关键词的空结果搜索,避免 keep-alive 返回时自动重搜。
function hasLoadedEmptySearchResult() {
return isRefreshed.value && !progressActive.value && rawDataList.value.length === 0 && hasSearchKeyword(activeSearchParams.value)
}
// 更新搜索进度
function updateSearchProgress(eventData: { [key: string]: any }, flushNow: boolean = false) {
if (eventData.text) {
@@ -658,6 +664,7 @@ function searchByStream(params: SearchParams, requestToken?: string) {
closeSearchEventSource()
let settled = false
let receivedDone = false
const source = new EventSource(buildSearchStreamUrl(params, requestToken))
searchEventSource = source
@@ -692,7 +699,12 @@ function searchByStream(params: SearchParams, requestToken?: string) {
}
if (eventData.type === 'done') {
settleSearchStream(resolve)
// 收到 done 后给后端留出收尾时间,避免过早关闭连接中断搜索结果缓存写入
receivedDone = true
clearSearchStreamIdleTimer()
searchStreamIdleTimer = setTimeout(() => {
settleSearchStream(resolve)
}, searchStreamDoneCloseDelay)
}
} catch (error) {
settleSearchStream(() => reject(error))
@@ -702,6 +714,11 @@ function searchByStream(params: SearchParams, requestToken?: string) {
source.onerror = () => {
if (source !== searchEventSource || settled) return
if (receivedDone) {
settleSearchStream(resolve)
return
}
settleSearchStream(() => reject(new Error(t('resource.noResourceFound'))))
}
})
@@ -1009,8 +1026,8 @@ async function checkAiRecommendStatus() {
const { success, data } = result
const status = data?.status
// 只要有数据且状态不是disabled就标记已检查允许重试
if (data && status !== 'disabled') {
// 状态检查只是初始化已有推荐结果,非禁用状态下即使后端暂无历史状态也不应锁住按钮
if (status !== 'disabled') {
aiStatusChecked.value = true
}
@@ -1030,6 +1047,8 @@ async function checkAiRecommendStatus() {
}
} catch (error) {
console.error('检查AI状态失败:', error)
// 检查失败不影响用户手动发起智能推荐,避免按钮永久不可用
aiStatusChecked.value = true
}
}
@@ -1081,6 +1100,7 @@ onMounted(async () => {
useKeepAliveRefresh(async () => {
if (progressActive.value || isRefreshing.value || isRecommending.value || showingAiResults.value) return
if (hasLoadedEmptySearchResult()) return
const refreshParams = await resolveRefreshSearchParams()
if (!refreshParams) return

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

@@ -74,7 +74,7 @@ html {
block-size: 100%;
}
// 设置项强调卡片:复用通知模板入口的强调条、轻渐变与悬浮反馈。
// 设置项强调卡片:复用通知模板入口的强调条、轻渐变与悬浮反馈。
.app-card-colorful {
overflow: hidden;
border: 1px solid rgba(var(--app-card-accent-rgb), var(--app-card-border-opacity)) !important;
@@ -91,11 +91,11 @@ html {
color: rgb(var(--v-theme-on-surface));
--app-card-accent-rgb: var(--v-theme-primary);
--app-card-accent-end-rgb: var(--app-card-accent-rgb);
--app-card-accent-start-opacity: 0.09;
--app-card-accent-end-opacity: 0.06;
--app-card-border-opacity: 0.2;
--app-card-hover-border-opacity: 0.34;
--app-card-stripe-opacity: 0.78;
--app-card-accent-start-opacity: 0.025;
--app-card-accent-end-opacity: 0.012;
--app-card-border-opacity: 0.08;
--app-card-hover-border-opacity: 0.16;
--app-card-stripe-opacity: 0.22;
--app-card-surface-opacity: 0.92;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
@@ -105,7 +105,7 @@ html {
background: rgb(var(--app-card-accent-rgb));
block-size: 100%;
content: "";
inline-size: 0.25rem;
inline-size: 0.125rem;
inset-block: 0;
inset-inline-start: 0;
opacity: var(--app-card-stripe-opacity);
@@ -136,11 +136,11 @@ html[data-theme="transparent"] .app-card-colorful,
.v-theme--transparent .app-card-colorful {
backdrop-filter: blur(var(--transparent-blur, 10px));
border: 0 !important;
--app-card-accent-start-opacity: 0.04;
--app-card-accent-end-opacity: 0.03;
--app-card-accent-start-opacity: 0.018;
--app-card-accent-end-opacity: 0.01;
--app-card-border-opacity: 0;
--app-card-hover-border-opacity: 0;
--app-card-stripe-opacity: 0.42;
--app-card-stripe-opacity: 0.16;
--app-card-surface-opacity: var(--transparent-opacity-light, 0.2);
}

View File

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

View File

@@ -92,6 +92,7 @@ registerHeaderTab({
variant: 'text',
color: 'gray',
class: 'settings-icon-button',
loading: computed(() => isMarketRefreshing.value),
action: () => {
refreshMarket()
},
@@ -665,6 +666,11 @@ function closePluginProgressDialog() {
// 安装插件
async function installPlugin(item: Plugin) {
if (item?.system_version_compatible === false) {
$toast.error(item.system_version_message || t('plugin.incompatibleSystemVersion'))
return
}
try {
// 显示等待提示框
progressText.value = t('plugin.installing', { name: item?.plugin_name, version: item?.plugin_version })
@@ -775,6 +781,9 @@ async function fetchUninstalledPlugins(force: boolean = false, context: KeepAliv
data.has_update = true
data.repo_url = uninstalled.repo_url
data.history = uninstalled.history
data.system_version = uninstalled.system_version
data.system_version_compatible = uninstalled.system_version_compatible
data.system_version_message = uninstalled.system_version_message
}
}
}
@@ -871,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
}
}
@@ -897,6 +903,7 @@ async function refreshActiveTabData(context: KeepAliveRefreshContext = {}) {
}
await fetchInstalledPlugins(context)
await fetchUninstalledPlugins(false, context)
await getPluginStatistics()
// 文件夹配置可能在其它入口被插件操作改变,重新进入时同步一次。
await loadPluginFolders()

View File

@@ -57,7 +57,10 @@ const SystemSettings = ref<any>({
LLM_SUPPORT_AUDIO_OUTPUT: false,
LLM_API_KEY: null,
LLM_BASE_URL: 'https://api.deepseek.com',
LLM_USE_PROXY: true,
LLM_BASE_URL_PRESET: null,
LLM_MAX_CONTEXT_TOKENS: 64,
LLM_USER_AGENT: null,
AUDIO_INPUT_PROVIDER: 'openai',
AUDIO_INPUT_API_KEY: null,
AUDIO_INPUT_BASE_URL: null,
@@ -73,7 +76,6 @@ const SystemSettings = ref<any>({
AI_RECOMMEND_ENABLED: false,
AI_RECOMMEND_USER_PREFERENCE: null,
AI_RECOMMEND_MAX_ITEMS: 50,
LLM_MAX_CONTEXT_TOKENS: 64,
},
// 高级系统设置
Advanced: {
@@ -82,6 +84,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,
@@ -96,6 +99,7 @@ const SystemSettings = ref<any>({
RECOGNIZE_PLUGIN_FIRST: false,
MEDIA_RECOGNIZE_SHARE: true,
TMDB_API_DOMAIN: null,
TMDB_API_KEY: null,
TMDB_IMAGE_DOMAIN: null,
TMDB_LOCALE: null,
META_CACHE_EXPIRE: 0,
@@ -111,6 +115,7 @@ const SystemSettings = ref<any>({
DOH_RESOLVERS: null,
DOH_DOMAINS: null,
SECURITY_IMAGE_DOMAINS: [],
IMAGE_PROXY_ALLOWED_PRIVATE_RANGES: [],
// 日志
DEBUG: false,
LOG_LEVEL: 'INFO',
@@ -120,6 +125,7 @@ const SystemSettings = ref<any>({
// 实验室
PLUGIN_AUTO_RELOAD: false,
PLUGIN_LOCAL_REPO_PATHS: '',
RUST_ACCEL: false,
ENCODING_DETECTION_PERFORMANCE_MODE: true,
TRANSFER_THREADS: 1,
},
@@ -129,6 +135,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' },
])
// 刮削配置
@@ -196,6 +203,7 @@ const advancedDialog = ref(false)
const savingBasic = ref(false)
const testingLlm = ref(false)
const rustAccelAvailable = ref(false)
// 智能助手配置项较多,默认收起以降低基础设置页的视觉占用。
const aiAgentSettingsCollapsed = ref(true)
@@ -207,7 +215,9 @@ type LlmSettingsSnapshot = {
LLM_THINKING_LEVEL: string
LLM_API_KEY: string
LLM_BASE_URL: string
LLM_USE_PROXY: boolean
LLM_BASE_URL_PRESET: string
LLM_USER_AGENT: string
}
let llmTestRequestId = 0
@@ -241,6 +251,20 @@ const llmBaseUrlPresetRef = computed({
},
})
const llmUseProxyRef = computed({
get: () => Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
set: value => {
SystemSettings.value.Basic.LLM_USE_PROXY = Boolean(value)
},
})
const llmUserAgentRef = computed({
get: () => String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
set: value => {
SystemSettings.value.Basic.LLM_USER_AGENT = value || ''
},
})
const llmModelRef = computed({
get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
set: value => {
@@ -286,6 +310,8 @@ const {
apiKey: llmApiKeyRef,
baseUrl: llmBaseUrlRef,
baseUrlPreset: llmBaseUrlPresetRef,
useProxy: llmUseProxyRef,
userAgent: llmUserAgentRef,
model: llmModelRef,
maxContextTokens: llmMaxContextRef,
})
@@ -346,7 +372,9 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
LLM_USE_PROXY: Boolean(SystemSettings.value.Basic.LLM_USE_PROXY),
LLM_BASE_URL_PRESET: String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
LLM_USER_AGENT: String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
}
}
@@ -362,7 +390,9 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
api_key: snapshot.LLM_API_KEY.trim(),
base_url: snapshot.LLM_BASE_URL.trim(),
use_proxy: snapshot.LLM_USE_PROXY,
base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(),
user_agent: snapshot.LLM_USER_AGENT.trim(),
}
}
@@ -437,6 +467,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' },
@@ -487,6 +521,8 @@ const dataCleanupFieldRules = [
// 安全域名添加变量
const newSecurityDomain = ref('')
// 图片代理允许非公网网段添加变量
const newImageProxyAllowedPrivateRange = ref('')
// 加载 LLM 模型列表与 provider 目录
async function refreshLlmModels(forceRefresh = true) {
@@ -537,6 +573,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 {
@@ -617,6 +666,9 @@ async function loadSystemSettings() {
if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]
})
}
const accelAvailable = Boolean(result.data.RUST_ACCEL_AVAILABLE ?? result.data.RUST_ACCEL_ENABLED)
rustAccelAvailable.value = accelAvailable
if (!accelAvailable) SystemSettings.value.Advanced.RUST_ACCEL = false
SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data)
await loadLlmProviders()
}
@@ -698,6 +750,7 @@ async function testLlmConnection() {
// 保存高级设置
async function saveAdvancedSettings() {
if (!rustAccelAvailable.value) SystemSettings.value.Advanced.RUST_ACCEL = false
cleanEmptyFields(SystemSettings.value.Advanced, ['LOG_FILE_FORMAT'])
// 同时保存高级设置和刮削开关设置
@@ -1165,6 +1218,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
</template>
</VCombobox>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12">
<VSwitch
v-model="SystemSettings.Basic.LLM_USE_PROXY"
:label="t('setting.system.llmUseProxy')"
:hint="t('setting.system.llmUseProxyHint')"
persistent-hint
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6">
<VTextField
v-model="SystemSettings.Basic.LLM_API_KEY"
@@ -1279,6 +1340,15 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
prepend-inner-icon="mdi-counter"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showBaseUrlField" cols="12" md="6">
<VTextField
v-model="SystemSettings.Basic.LLM_USER_AGENT"
:label="t('setting.system.llmUserAgent')"
:hint="t('setting.system.llmUserAgentHint')"
persistent-hint
prepend-inner-icon="mdi-card-account-details-outline"
/>
</VCol>
<VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSelect
v-model="SystemSettings.Basic.LLM_THINKING_LEVEL"
@@ -1740,6 +1810,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"
@@ -1798,6 +1876,17 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
prepend-inner-icon="mdi-api"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="SystemSettings.Advanced.TMDB_API_KEY"
:label="t('setting.system.tmdbApiKey')"
:hint="t('setting.system.tmdbApiKeyHint')"
persistent-hint
:placeholder="t('setting.system.tmdbApiKeyPlaceholder')"
:rules="[(v: string) => !!v || t('setting.system.tmdbApiKeyRequired')]"
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VCombobox
v-model="SystemSettings.Advanced.TMDB_IMAGE_DOMAIN"
@@ -2081,6 +2170,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>
@@ -2224,14 +2358,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"
@@ -2241,14 +2367,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"
@@ -2261,6 +2379,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

@@ -40,6 +40,20 @@ const baseUrlPresetRef = computed({
},
})
const useProxyRef = computed({
get: () => wizardData.value.agent.useProxy,
set: value => {
wizardData.value.agent.useProxy = Boolean(value)
},
})
const userAgentRef = computed({
get: () => wizardData.value.agent.userAgent,
set: value => {
wizardData.value.agent.userAgent = value || ''
},
})
const modelRef = computed({
get: () => wizardData.value.agent.model,
set: value => {
@@ -92,6 +106,8 @@ const {
apiKey: apiKeyRef,
baseUrl: baseUrlRef,
baseUrlPreset: baseUrlPresetRef,
useProxy: useProxyRef,
userAgent: userAgentRef,
model: modelRef,
maxContextTokens: maxContextTokensRef,
authConnected: authConnectedRef,
@@ -171,6 +187,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 || [])
@@ -332,6 +349,16 @@ onMounted(async () => {
</VCombobox>
</VCol>
<VCol v-if="showBaseUrlField" cols="12">
<VSwitch
v-model="wizardData.agent.useProxy"
:label="t('setting.system.llmUseProxy')"
:hint="t('setting.system.llmUseProxyHint')"
persistent-hint
color="primary"
/>
</VCol>
<VCol v-if="showApiKeyField" cols="12" md="6">
<VTextField
v-model="wizardData.agent.apiKey"
@@ -437,6 +464,16 @@ onMounted(async () => {
/>
</VCol>
<VCol v-if="showBaseUrlField" cols="12" md="6">
<VTextField
v-model="wizardData.agent.userAgent"
:label="t('setting.system.llmUserAgent')"
:hint="t('setting.system.llmUserAgentHint')"
persistent-hint
prepend-inner-icon="mdi-card-account-details-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VSelect
v-model="wizardData.agent.thinkingLevel"

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 {