mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-03 23:01:04 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14aa75dfae | ||
|
|
348aa4757b | ||
|
|
6e6819acc1 | ||
|
|
51a58aaae0 | ||
|
|
fbde99389e | ||
|
|
5a4e345529 | ||
|
|
b446afb6d8 | ||
|
|
8580af36d1 | ||
|
|
95ca092117 | ||
|
|
ba200cae5c | ||
|
|
87c73e0253 | ||
|
|
d4d7f635f5 | ||
|
|
729db1510e | ||
|
|
8a12ecf918 | ||
|
|
cacc2602df | ||
|
|
8c6cfa7fc5 | ||
|
|
0113f28d8c | ||
|
|
d870b788bc | ||
|
|
19a3213be0 | ||
|
|
f5c8a463fa | ||
|
|
ff3b5b4232 | ||
|
|
6da0aae362 | ||
|
|
abbce2644a | ||
|
|
1c5773444e | ||
|
|
1674f15d7c | ||
|
|
c6981e9955 | ||
|
|
96d3426d0c | ||
|
|
c88b2abcce | ||
|
|
42fe928155 | ||
|
|
4cc455b948 | ||
|
|
bce073ebe0 | ||
|
|
c27167097e | ||
|
|
44d23480a3 | ||
|
|
01796b3dc5 | ||
|
|
dcf0924c73 | ||
|
|
cc8d5cf931 | ||
|
|
6083887675 | ||
|
|
beb0506b0c | ||
|
|
0f906f791a | ||
|
|
7614696e61 | ||
|
|
4235d3687c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -35,3 +35,5 @@ package-lock.json
|
||||
# iconify dist files
|
||||
src/@iconify/*.js
|
||||
public/plugin_icon/**
|
||||
docs-lock/
|
||||
.trae/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.12.2",
|
||||
"version": "2.13.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface Subscribe {
|
||||
start_episode?: number
|
||||
// 缺失集数
|
||||
lack_episode?: number
|
||||
// 已完成集数(普通订阅 = 已入库集数,洗版订阅 = 起始集前 + [start, total] 范围内 priority==100 命中数)
|
||||
completed_episode?: number
|
||||
// 附加信息
|
||||
note?: string
|
||||
// 状态:N-新建 R-订阅中 P-待定 S-暂停
|
||||
@@ -64,6 +66,8 @@ export interface Subscribe {
|
||||
search_imdbid?: any
|
||||
// 当前优先级
|
||||
current_priority: number
|
||||
// 洗版时已下载剧集的优先级状态
|
||||
episode_priority?: Record<string, number>
|
||||
// 保存目录
|
||||
save_path?: string
|
||||
// 时间
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 手动整理预览数据
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -70,13 +70,64 @@ function isTvSubscribe(media?: Subscribe) {
|
||||
return media?.type === '电视剧' || media?.type === 'tv' || !!media?.season || !!media?.total_episode
|
||||
}
|
||||
|
||||
// TV 洗版订阅在卡片上展示分集或全集短标签
|
||||
const bestVersionModeLabel = computed(() => {
|
||||
if (!isEnabledFlag(props.media?.best_version) || !isTvSubscribe(props.media)) return ''
|
||||
// 已下载集数:total_episode - lack_episode
|
||||
const downloadedEpisode = computed(() => {
|
||||
const total = props.media?.total_episode || 0
|
||||
if (!total) return 0
|
||||
return Math.min(Math.max(total - (props.media?.lack_episode || 0), 0), total)
|
||||
})
|
||||
|
||||
return isEnabledFlag(props.media?.best_version_full)
|
||||
? t('subscribe.bestVersionWholeShort')
|
||||
: t('subscribe.bestVersionEpisodeShort')
|
||||
// 是否为洗版订阅(影响进度条与 tooltip 的展示分支)
|
||||
const isBestVersion = computed(() => isEnabledFlag(props.media?.best_version) && isTvSubscribe(props.media))
|
||||
|
||||
const rightBottomStateDisplay = computed(() => {
|
||||
if (subscribeState.value === 'S') {
|
||||
return { icon: 'mdi-pause-circle', label: t('subscribe.cardStatePaused') }
|
||||
}
|
||||
if (subscribeState.value === 'P') {
|
||||
return { icon: 'mdi-clock', label: t('subscribe.cardStatePending') }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 洗版徽标:共用 mdi-shimmer 图标,分集 / 全集 由 full 标记区分背景
|
||||
const bestVersionBadge = computed(() => {
|
||||
if (!isEnabledFlag(props.media?.best_version)) return null
|
||||
return {
|
||||
icon: 'mdi-shimmer',
|
||||
full: isEnabledFlag(props.media?.best_version_full),
|
||||
}
|
||||
})
|
||||
|
||||
// 已洗版集数:取后端派生字段 completed_episode
|
||||
const completedEpisode = computed(() => {
|
||||
const total = props.media?.total_episode || 0
|
||||
return Math.min(Math.max(props.media?.completed_episode ?? 0, 0), total)
|
||||
})
|
||||
|
||||
// 卡片主文案:已下载集数 / 总集数
|
||||
const subscribeProgressText = computed(() => {
|
||||
const total = props.media?.total_episode || 0
|
||||
if (!total) return ''
|
||||
return `${downloadedEpisode.value} / ${total}`
|
||||
})
|
||||
|
||||
// 订阅卡片 hover 文案:
|
||||
// - 普通订阅:「已下载 X · 共 Y 集」
|
||||
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
|
||||
const subscribeProgressTooltip = computed(() => {
|
||||
const total = props.media?.total_episode || 0
|
||||
if (!total) return ''
|
||||
|
||||
if (isBestVersion.value) {
|
||||
return t('subscribe.bestVersionEpisodeProgressTooltip', {
|
||||
completed: completedEpisode.value,
|
||||
downloaded: downloadedEpisode.value,
|
||||
total,
|
||||
})
|
||||
}
|
||||
|
||||
return t('subscribe.subscribeProgressTooltip', { downloaded: downloadedEpisode.value, total })
|
||||
})
|
||||
|
||||
// 图片加载完成响应
|
||||
@@ -84,13 +135,19 @@ function imageLoadHandler() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
// 计算百分比
|
||||
// 进度条 model 段百分比:洗版订阅表示"已洗版"占比(亮段),普通订阅表示"已下载"占比
|
||||
function getPercentage() {
|
||||
if (props.media?.total_episode === 0) return 0
|
||||
const total = props.media?.total_episode || 0
|
||||
if (!total) return 0
|
||||
const value = isBestVersion.value ? completedEpisode.value : downloadedEpisode.value
|
||||
return Math.round((value / total) * 100)
|
||||
}
|
||||
|
||||
return Math.round(
|
||||
(((props.media?.total_episode ?? 0) - (props.media?.lack_episode ?? 0)) / (props.media?.total_episode ?? 1)) * 100,
|
||||
)
|
||||
// 洗版进度条的 buffer 段百分比:表示"已下载"占比,仅在洗版场景被模板调用
|
||||
function getBufferPercentage() {
|
||||
const total = props.media?.total_episode || 0
|
||||
if (!isBestVersion.value || !total) return 0
|
||||
return Math.round((downloadedEpisode.value / total) * 100)
|
||||
}
|
||||
|
||||
// 删除订阅
|
||||
@@ -352,11 +409,11 @@ function handleCardClick() {
|
||||
<VHover>
|
||||
<template #default="hover">
|
||||
<div
|
||||
class="w-full h-full rounded-lg overflow-hidden"
|
||||
class="w-full h-full rounded-lg overflow-hidden relative"
|
||||
:class="{
|
||||
'transition transform-cpu duration-300 -translate-y-1': hover.isHovering && !props.sortable,
|
||||
'outline-dashed outline-1': props.media?.best_version && imageLoaded,
|
||||
'outline-dotted outline-pink-500 outline-2': props.batchMode && props.selected,
|
||||
'subscribe-card-pending-tint': subscribeState === 'P',
|
||||
}"
|
||||
>
|
||||
<VCard
|
||||
@@ -364,7 +421,7 @@ function handleCardClick() {
|
||||
:key="props.media?.id"
|
||||
class="flex flex-col h-full"
|
||||
:class="{
|
||||
'opacity-70': subscribeState === 'S',
|
||||
'subscribe-card-paused': subscribeState === 'S',
|
||||
'cursor-move': props.sortable,
|
||||
}"
|
||||
rounded="0"
|
||||
@@ -372,6 +429,13 @@ function handleCardClick() {
|
||||
@click="handleCardClick"
|
||||
:ripple="!props.batchMode && !props.sortable"
|
||||
>
|
||||
<div
|
||||
v-if="bestVersionBadge && imageLoaded"
|
||||
class="best-version-badge"
|
||||
:class="{ 'best-version-badge-full': bestVersionBadge.full }"
|
||||
>
|
||||
<VIcon :icon="bestVersionBadge.icon" color="white" size="16" />
|
||||
</div>
|
||||
<div v-if="!props.sortable" class="me-n3 absolute top-1 right-4">
|
||||
<IconBtn @click.stop>
|
||||
<VIcon icon="mdi-dots-vertical" color="white" />
|
||||
@@ -400,15 +464,11 @@ function handleCardClick() {
|
||||
<div class="absolute inset-0 outline-none subscribe-card-background"></div>
|
||||
</template>
|
||||
</VImg>
|
||||
<div
|
||||
v-if="subscribeState === 'P'"
|
||||
class="absolute inset-0 bg-yellow-900 opacity-80 pointer-events-none"
|
||||
/>
|
||||
</template>
|
||||
<div>
|
||||
<VCardText class="flex items-center pt-3 pb-2">
|
||||
<div
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md"
|
||||
class="h-auto w-12 flex-shrink-0 overflow-hidden rounded-md relative"
|
||||
v-if="imageLoaded"
|
||||
:class="{ 'cursor-move': props.sortable && display.mdAndUp.value }"
|
||||
>
|
||||
@@ -444,19 +504,13 @@ function handleCardClick() {
|
||||
icon="mdi-progress-download"
|
||||
color="white"
|
||||
/>
|
||||
<div v-if="props.media?.season" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
|
||||
{{ (props.media?.total_episode || 0) - (props.media?.lack_episode || 0) }} /
|
||||
{{ props.media?.total_episode }}
|
||||
<!-- 守卫改用 total_episode:电视剧订阅可能不带 season 字段(旧数据或自定义来源),仍应展示集数进度 -->
|
||||
<div v-if="props.media?.total_episode" class="flex-shrink-0 text-subtitle-2 me-2 text-white">
|
||||
{{ subscribeProgressText }}
|
||||
<VTooltip v-if="subscribeProgressTooltip" activator="parent" location="top">
|
||||
{{ subscribeProgressTooltip }}
|
||||
</VTooltip>
|
||||
</div>
|
||||
<VChip
|
||||
v-if="bestVersionModeLabel"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
class="me-2 flex-shrink-0"
|
||||
>
|
||||
{{ bestVersionModeLabel }}
|
||||
</VChip>
|
||||
<VIcon v-if="props.media?.username && props.sortable" icon="mdi-account" size="small" color="white" class="flex-shrink-0 me-1" />
|
||||
<IconBtn v-else-if="props.media?.username" icon="mdi-account" size="small" color="white" class="flex-shrink-0" />
|
||||
<!-- 用户名过长时限制在卡片宽度内,并用省略号展示剩余内容 -->
|
||||
@@ -465,16 +519,38 @@ function handleCardClick() {
|
||||
</span>
|
||||
</div>
|
||||
</VCardText>
|
||||
<!-- 右下角元数据:暂停 / 待定时替换"x 天前"为状态文案 -->
|
||||
<VCardText
|
||||
v-if="lastUpdateText"
|
||||
v-if="rightBottomStateDisplay"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon :icon="rightBottomStateDisplay.icon" class="me-1" />
|
||||
{{ rightBottomStateDisplay.label }}
|
||||
</VCardText>
|
||||
<VCardText
|
||||
v-else-if="lastUpdateText"
|
||||
class="absolute right-0 bottom-0 d-flex align-center p-2 text-gray-300 text-xs"
|
||||
>
|
||||
<VIcon icon="mdi-download" class="me-1" />
|
||||
{{ lastUpdateText }}
|
||||
</VCardText>
|
||||
<div class="w-full absolute bottom-0">
|
||||
<!--
|
||||
分集洗版模式:底色保持深绿、buffer 段显示"已下载未洗版"为浅绿、model 段显示"已洗版完成"为亮绿,
|
||||
形成两段语义;其余订阅维持原有单段进度条
|
||||
-->
|
||||
<VProgressLinear
|
||||
v-if="getPercentage() > 0"
|
||||
v-if="isBestVersion && getBufferPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
:buffer-value="getBufferPercentage()"
|
||||
bg-color="success"
|
||||
bg-opacity="0.25"
|
||||
color="success"
|
||||
buffer-color="success"
|
||||
buffer-opacity="0.55"
|
||||
/>
|
||||
<VProgressLinear
|
||||
v-else-if="getPercentage() > 0"
|
||||
:model-value="getPercentage()"
|
||||
bg-color="success"
|
||||
color="success"
|
||||
@@ -491,4 +567,54 @@ function handleCardClick() {
|
||||
.subscribe-card-background {
|
||||
background-image: linear-gradient(180deg, rgba(31, 41, 55, 47%) 0%, rgb(31, 41, 55) 100%);
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停:降低不透明度表达"已停止活动"
|
||||
*/
|
||||
.subscribe-card-paused {
|
||||
opacity: 0.65;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
/**
|
||||
* 待定:用 ::after 浮层在 VCard 之上渲染 sky 漫反射式内发光
|
||||
*/
|
||||
.subscribe-card-pending-tint {
|
||||
position: relative;
|
||||
}
|
||||
.subscribe-card-pending-tint::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 0 48px rgba(56, 189, 248, 0.4); // sky-400
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 洗版标识:卡片左上角 24x24 圆形徽标
|
||||
* 分集:深色半透底 + 模糊
|
||||
* 全集:磨砂玻璃半透白底 + 大模糊
|
||||
*/
|
||||
.best-version-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 4;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.best-version-badge-full {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -84,6 +84,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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -206,6 +206,7 @@ watch(
|
||||
passkeyList.value = []
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -772,19 +772,16 @@ onUnmounted(() => {
|
||||
rounded
|
||||
/>
|
||||
<VSpacer v-if="isFile" />
|
||||
<IconBtn v-if="!isFile" @click="ignoreCase = !ignoreCase">
|
||||
<IconBtn v-if="!isFile && !selectMode" @click="ignoreCase = !ignoreCase">
|
||||
<VIcon :color="ignoreCase ? 'primary' : 'error'" icon="mdi-format-letter-case" />
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||
<VIcon color="primary" :icon="selectMode ? 'mdi-selection-remove' : 'mdi-select'" />
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile" @click="recognize(inProps.item.path || '')">
|
||||
<VIcon color="primary"> mdi-text-recognition </VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="isFile && items.length > 0" @click="download(items[0])">
|
||||
<VIcon color="primary"> mdi-download </VIcon>
|
||||
</IconBtn>
|
||||
<IconBtn v-if="!isFile" @click="list_files">
|
||||
<IconBtn v-if="!isFile && !selectMode" @click="list_files">
|
||||
<VIcon color="primary"> mdi-refresh </VIcon>
|
||||
</IconBtn>
|
||||
<!-- 批量操作按钮 -->
|
||||
@@ -799,6 +796,9 @@ onUnmounted(() => {
|
||||
<VIcon icon="mdi-delete-outline" color="error" />
|
||||
</IconBtn>
|
||||
</span>
|
||||
<IconBtn v-if="!isFile" @click="changeSelectMode">
|
||||
<VIcon color="primary" :icon="selectMode ? 'mdi-selection-remove' : 'mdi-select'" />
|
||||
</IconBtn>
|
||||
</div>
|
||||
<LoadingBanner v-if="loading" />
|
||||
<!-- 文件详情 -->
|
||||
|
||||
@@ -10,6 +10,7 @@ interface DynamicHeaderTabButton {
|
||||
class?: string
|
||||
action?: () => void
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
loading?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string // 用于VMenu定位的data属性
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -79,6 +79,7 @@ interface DynamicHeaderTab {
|
||||
class?: string
|
||||
action?: () => void
|
||||
show?: boolean | ComputedRef<boolean>
|
||||
loading?: boolean | ComputedRef<boolean>
|
||||
dataAttr?: string
|
||||
}>
|
||||
routePath?: string // 用于标识哪个路由注册的
|
||||
@@ -395,6 +396,7 @@ onMounted(async () => {
|
||||
:color="typeof button.color === 'string' ? button.color : (button.color as any)?.value || 'gray'"
|
||||
:size="button.size || 'default'"
|
||||
:class="button.class || 'settings-icon-button'"
|
||||
:loading="typeof button.loading === 'boolean' ? button.loading : (button.loading as any)?.value || false"
|
||||
:data-menu-activator="button.dataAttr"
|
||||
@click="button.action"
|
||||
/>
|
||||
|
||||
@@ -986,11 +986,22 @@ export default {
|
||||
bestVersion: 'Version Upgrading',
|
||||
bestVersionEpisodeShort: 'Episode',
|
||||
bestVersionWholeShort: 'Full',
|
||||
bestVersionEpisodeProgressTooltip: 'Upgraded {completed} · Downloaded {downloaded} · Total {total}',
|
||||
subscribeProgressTooltip: 'Downloaded {downloaded} · Total {total}',
|
||||
completed: 'Completed',
|
||||
subscribing: 'Subscribing',
|
||||
notStarted: 'Not Started',
|
||||
pending: 'Pending',
|
||||
paused: 'Paused',
|
||||
cardStatePaused: 'Paused',
|
||||
cardStatePending: 'Pending',
|
||||
sortTitle: 'Sort',
|
||||
sort: {
|
||||
custom: 'Custom',
|
||||
lastUpdate: 'Last Updated',
|
||||
addTime: 'Added Time',
|
||||
lackEpisode: 'Missing Episodes',
|
||||
},
|
||||
selectedCount: 'Selected {count}/{total} items',
|
||||
noSelectedItems: 'Please select subscriptions to operate',
|
||||
batchEnable: 'Batch Enable',
|
||||
@@ -1367,6 +1378,18 @@ export default {
|
||||
expand: 'Expand',
|
||||
collapse: 'Collapse',
|
||||
clearCache: 'Clear Cache',
|
||||
versionStatistic: 'Version Statistics',
|
||||
versionStatisticTitle: 'Installation Version Statistics',
|
||||
totalInstallUsers: 'Install Users',
|
||||
activeToday: 'Active Today',
|
||||
active7Days: 'Active 7 Days',
|
||||
active30Days: 'Active 30 Days',
|
||||
backendVersionStatistic: 'Backend Versions',
|
||||
frontendVersionStatistic: 'Frontend Versions',
|
||||
version: 'Version',
|
||||
users: 'Users',
|
||||
lastUpdated: 'Updated At',
|
||||
noVersionStatisticData: 'No statistics data',
|
||||
},
|
||||
system: {
|
||||
custom: 'Custom',
|
||||
@@ -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: {[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} to directly specify TMDBID/Douban ID, where s and e are season and episode numbers (optional)',
|
||||
'Replacement format supports: {[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} to directly specify TMDBID/Douban ID, where g is the episode group ID and s/e are season and episode numbers (all optional)',
|
||||
identifierSaveSuccess: 'Custom identifiers saved successfully',
|
||||
identifierSaveFailed: 'Failed to save custom identifiers!',
|
||||
|
||||
@@ -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: { tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx } to directly specify TMDBID/Douban ID recognition, where s, e are season and episode numbers (optional)',
|
||||
'Block word\nReplaced word => Replacement word\nPrefix <> Suffix >> Episode offset (EP)\nReplaced word => Replacement word && Prefix <> Suffix >> Episode offset (EP)\nReplacement word supports format: {[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} to directly specify TMDBID/Douban ID recognition, where g is the episode group ID and s/e are season and episode numbers (all optional)',
|
||||
cancelSubscribe: 'Cancel Subscription',
|
||||
save: 'Save',
|
||||
cancelSubscribeConfirm: 'Are you sure you want to cancel the subscription?',
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
'音频输入接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1',
|
||||
'音频输入接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1,MiniMax 默认 https://api.minimaxi.com/v1',
|
||||
audioInputModel: '音频输入模型',
|
||||
audioInputModelHint: '用于将音频内容转换为文字的模型名称',
|
||||
audioInputLanguage: '识别语言',
|
||||
audioInputLanguageHint: '音频转写默认语言,例如 zh、en,留空时按后端默认处理',
|
||||
audioOutputProvider: '音频输出提供商',
|
||||
audioOutputProviderHint: '用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
||||
audioOutputProviderHint:
|
||||
'用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
|
||||
audioOutputApiKey: '音频输出 API密钥',
|
||||
audioOutputApiKeyHint: '文字转语音使用的 API 密钥',
|
||||
audioOutputBaseUrl: '音频输出基础URL',
|
||||
audioOutputBaseUrlHint:
|
||||
'音频输出接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1',
|
||||
'音频输出接口基础URL,Chat Audio 类服务可填写对应兼容地址,MiMo 默认 https://api.xiaomimimo.com/v1,MiniMax 默认 https://api.minimaxi.com/v1',
|
||||
audioOutputModel: '音频输出模型',
|
||||
audioOutputModelHint: '用于将文字内容转换为语音的模型名称',
|
||||
audioOutputVoice: '语音音色',
|
||||
@@ -1534,6 +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: '添加 CIDR,如:198.18.0.0/15',
|
||||
proxyHost: '代理服务器',
|
||||
proxyHostHint: '设置代理服务器地址,支持:http(s)、socks5、socks5h 等协议',
|
||||
moviePilotAutoUpdate: '自动更新MoviePilot',
|
||||
@@ -1810,7 +1854,7 @@ export default {
|
||||
'被替换词 => 替换词\n' +
|
||||
'前定位词 <> 后定位词 >> 集偏移量(EP)\n' +
|
||||
'被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n' +
|
||||
'其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID识别,其中s、e为季数和集数(可选)',
|
||||
'其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
|
||||
identifierSaveSuccess: '自定义识别词保存成功',
|
||||
identifierSaveFailed: '自定义识别词保存失败!',
|
||||
|
||||
@@ -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其中替换词支持格式:{ tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx } 直接指定TMDBID/豆瓣ID识别,其中s、e为季数和集数(可选)',
|
||||
'屏蔽词\n被替换词 => 替换词\n前定位词 <> 后定位词 >> 集偏移量(EP)\n被替换词 => 替换词 && 前定位词 <> 后定位词 >> 集偏移量(EP)\n其中替换词支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID识别,其中g为剧集组编号,s、e为季数和集数(均可选)',
|
||||
cancelSubscribe: '取消订阅',
|
||||
save: '保存',
|
||||
cancelSubscribeConfirm: '是否确认取消订阅?',
|
||||
@@ -2791,6 +2849,8 @@ export default {
|
||||
projectHome: '项目主页',
|
||||
updateHistory: '更新说明',
|
||||
local: '本地',
|
||||
systemVersion: '系统版本',
|
||||
incompatibleSystemVersion: '当前 MoviePilot 版本不满足插件要求,无法安装',
|
||||
installToLocal: '安装到本地',
|
||||
totalDownloads: '共 {count} 次下载',
|
||||
viewData: '查看数据',
|
||||
|
||||
@@ -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:
|
||||
'音頻輸入接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1',
|
||||
'音頻輸入接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1,MiniMax 預設 https://api.minimaxi.com/v1',
|
||||
audioInputModel: '音頻輸入模型',
|
||||
audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱',
|
||||
audioInputLanguage: '識別語言',
|
||||
audioInputLanguageHint: '音頻轉寫預設語言,例如 zh、en,留空時按後端預設處理',
|
||||
audioOutputProvider: '音頻輸出提供商',
|
||||
audioOutputProviderHint: '用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo',
|
||||
audioOutputProviderHint:
|
||||
'用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
|
||||
audioOutputApiKey: '音頻輸出 API密鑰',
|
||||
audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰',
|
||||
audioOutputBaseUrl: '音頻輸出基礎URL',
|
||||
audioOutputBaseUrlHint:
|
||||
'音頻輸出接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1',
|
||||
'音頻輸出接口基礎URL,Chat Audio 類服務可填寫對應兼容地址,MiMo 預設 https://api.xiaomimimo.com/v1,MiniMax 預設 https://api.minimaxi.com/v1',
|
||||
audioOutputModel: '音頻輸出模型',
|
||||
audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱',
|
||||
audioOutputVoice: '語音音色',
|
||||
@@ -1535,6 +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: '添加 CIDR,如:198.18.0.0/15',
|
||||
proxyHost: '代理服務器',
|
||||
proxyHostHint: '設置代理服務器地址,支持:http(s)、socks5、socks5h 等協議',
|
||||
moviePilotAutoUpdate: '自動更新MoviePilot',
|
||||
@@ -1811,7 +1855,7 @@ export default {
|
||||
'被替換詞 => 替換詞\n' +
|
||||
'前定位詞 <> 後定位詞 >> 集偏移量(EP)\n' +
|
||||
'被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量(EP)\n' +
|
||||
'其中替換詞支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID識別,其中s、e為季數和集數(可選)',
|
||||
'其中替換詞支持格式:{[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID識別,其中g為劇集組編號,s、e為季數和集數(均可選)',
|
||||
identifierSaveSuccess: '自定義識別詞保存成功',
|
||||
identifierSaveFailed: '自定義識別詞保存失敗!',
|
||||
|
||||
@@ -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其中替換詞支援格式:{ tmdbid/doubanid=xxx;type=movie/tv;s=xxx;e=xxx } 直接指定TMDBID/豆瓣ID識別,其中s、e為季數和集數(可選)',
|
||||
'屏蔽詞\n被替換詞 => 替換詞\n前定位詞 <> 後定位詞 >> 集偏移量(EP)\n被替換詞 => 替換詞 && 前定位詞 <> 後定位詞 >> 集偏移量(EP)\n其中替換詞支援格式:{[tmdbid/doubanid=xxx;type=movie/tv;g=xxx;s=xxx;e=xxx]} 直接指定TMDBID/豆瓣ID識別,其中g為劇集組編號,s、e為季數和集數(均可選)',
|
||||
cancelSubscribe: '取消訂閱',
|
||||
save: '儲存',
|
||||
cancelSubscribeConfirm: '是否確認取消訂閱?',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -40,6 +40,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
sortBy: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -48,8 +52,11 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:sortMode': [value: boolean]
|
||||
'update:sortBy': [value: SubscribeSortBy]
|
||||
}>()
|
||||
|
||||
type SubscribeSortBy = 'custom' | 'last_update' | 'date' | 'lack_episode'
|
||||
|
||||
// 是否刷新过
|
||||
let isRefreshed = ref(false)
|
||||
|
||||
@@ -71,8 +78,26 @@ const selectedSubscribes = ref<number[]>([])
|
||||
|
||||
const normalizedKeyword = computed(() => props.keyword?.trim().toLowerCase() || '')
|
||||
const selectedSubscribesSet = computed(() => new Set(selectedSubscribes.value))
|
||||
const hasCustomOrder = computed(() => orderConfig.value.length > 0)
|
||||
|
||||
// 归一化订阅排序方式,电影订阅不使用缺失集数排序。
|
||||
const normalizedSortBy = computed<SubscribeSortBy | ''>(() => {
|
||||
const sortBy = props.sortBy as SubscribeSortBy | ''
|
||||
if (props.type === '电影' && sortBy === 'lack_episode') {
|
||||
return 'date'
|
||||
}
|
||||
|
||||
return sortBy
|
||||
})
|
||||
const effectiveSortBy = computed<SubscribeSortBy>(() => {
|
||||
return normalizedSortBy.value || (hasCustomOrder.value ? 'custom' : 'date')
|
||||
})
|
||||
const canSortContext = computed(
|
||||
() => !normalizedKeyword.value && (!props.statusFilter || props.statusFilter === 'all') && !isBatchMode.value,
|
||||
() =>
|
||||
effectiveSortBy.value === 'custom' &&
|
||||
!normalizedKeyword.value &&
|
||||
(!props.statusFilter || props.statusFilter === 'all') &&
|
||||
!isBatchMode.value,
|
||||
)
|
||||
const sortMode = computed({
|
||||
get: () => props.sortMode,
|
||||
@@ -109,10 +134,11 @@ function getSubscribeStatus(subscribe: Subscribe) {
|
||||
return 'all'
|
||||
}
|
||||
|
||||
// 电视剧根据集数情况判断
|
||||
// 电视剧根据集数情况判断:completed_episode 由后端按订阅类型派生
|
||||
// (普通=已入库集数,洗版=起始集前 + [start, total] 范围内 priority==100 命中)
|
||||
if (subscribe.total_episode && subscribe.total_episode > 0) {
|
||||
const lackEpisode = subscribe.lack_episode || 0
|
||||
const completedEpisode = subscribe.total_episode - lackEpisode
|
||||
const completedEpisode = subscribe.completed_episode ?? 0
|
||||
|
||||
if (lackEpisode === 0) {
|
||||
return 'completed' // 订阅完成
|
||||
@@ -129,11 +155,62 @@ function getSubscribeStatus(subscribe: Subscribe) {
|
||||
// API请求键值(计算属性)
|
||||
const orderRequestKey = computed(() => (props.type === '电影' ? 'SubscribeMovieOrder' : 'SubscribeTvOrder'))
|
||||
|
||||
// 监听数据和筛选变化,同步更新显示列表
|
||||
// 转换订阅时间字段为可排序时间戳。
|
||||
function getSubscribeTimeValue(value?: string) {
|
||||
if (!value) return 0
|
||||
|
||||
const directTime = Date.parse(value)
|
||||
if (!Number.isNaN(directTime)) return directTime
|
||||
|
||||
const compatibleTime = Date.parse(value.replace(/-/g, '/'))
|
||||
return Number.isNaN(compatibleTime) ? 0 : compatibleTime
|
||||
}
|
||||
|
||||
// 按自定义顺序排序订阅,未配置顺序的订阅按添加时间倒序补齐。
|
||||
function sortByCustomOrder(a: Subscribe, b: Subscribe, orderIndexMap: Map<number, number>) {
|
||||
const aIndex = orderIndexMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = orderIndexMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
if (aIndex !== bIndex) {
|
||||
return aIndex - bIndex
|
||||
}
|
||||
|
||||
return getSubscribeTimeValue(b.date) - getSubscribeTimeValue(a.date)
|
||||
}
|
||||
|
||||
// 按当前排序选项调整订阅列表顺序。
|
||||
function sortSubscribeList(list: Subscribe[]) {
|
||||
const orderIndexMap = new Map(orderConfig.value.map((item, index) => [item.id, index]))
|
||||
|
||||
list.sort((a, b) => {
|
||||
if (effectiveSortBy.value === 'custom') {
|
||||
return sortByCustomOrder(a, b, orderIndexMap)
|
||||
}
|
||||
|
||||
if (effectiveSortBy.value === 'last_update') {
|
||||
return getSubscribeTimeValue(b.last_update) - getSubscribeTimeValue(a.last_update)
|
||||
}
|
||||
|
||||
if (effectiveSortBy.value === 'lack_episode') {
|
||||
const lackEpisodeDiff = (b.lack_episode || 0) - (a.lack_episode || 0)
|
||||
return lackEpisodeDiff || getSubscribeTimeValue(b.date) - getSubscribeTimeValue(a.date)
|
||||
}
|
||||
|
||||
return getSubscribeTimeValue(b.date) - getSubscribeTimeValue(a.date)
|
||||
})
|
||||
}
|
||||
|
||||
// 同步订阅排序默认值给父组件。
|
||||
function syncDefaultSortBy() {
|
||||
if (!props.sortBy) {
|
||||
emit('update:sortBy', hasCustomOrder.value ? 'custom' : 'date')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听数据、筛选和排序变化,同步更新显示列表
|
||||
watch(
|
||||
[dataList, normalizedKeyword, () => props.statusFilter, orderConfig],
|
||||
[dataList, normalizedKeyword, () => props.statusFilter, orderConfig, effectiveSortBy],
|
||||
() => {
|
||||
const orderIndexMap = new Map(orderConfig.value.map((item, index) => [item.id, index]))
|
||||
const nextDisplayList = dataList.value.filter(data => {
|
||||
if (data.type !== props.type) {
|
||||
return false
|
||||
@@ -154,12 +231,7 @@ watch(
|
||||
return true
|
||||
})
|
||||
|
||||
nextDisplayList.sort((a, b) => {
|
||||
const aIndex = orderIndexMap.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const bIndex = orderIndexMap.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
|
||||
return aIndex - bIndex
|
||||
})
|
||||
sortSubscribeList(nextDisplayList)
|
||||
|
||||
displayList.value = nextDisplayList
|
||||
},
|
||||
@@ -183,9 +255,11 @@ async function loadSubscribeOrderConfig() {
|
||||
if (response && response.data && response.data.value) {
|
||||
orderConfig.value = response.data.value
|
||||
}
|
||||
syncDefaultSortBy()
|
||||
} catch (error) {
|
||||
console.error('Failed to load subscribe order config:', error)
|
||||
orderConfig.value = []
|
||||
syncDefaultSortBy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +268,7 @@ async function saveSubscribeOrder() {
|
||||
// 顺序配置
|
||||
const orderObj = displayList.value.map(item => ({ id: item.id }))
|
||||
orderConfig.value = orderObj
|
||||
emit('update:sortBy', 'custom')
|
||||
|
||||
// 保存到服务端
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user