Compare commits

..

18 Commits

Author SHA1 Message Date
jxxghp
14aa75dfae fix: format version install statistics 2026-05-27 17:48:58 +08:00
jxxghp
348aa4757b fix: normalize search site selection 2026-05-27 15:21:44 +08:00
jxxghp
6e6819acc1 fix: auto match manual transfer target path 2026-05-27 13:26:01 +08:00
jxxghp
51a58aaae0 fix: show manual transfer recognition details 2026-05-27 11:03:55 +08:00
jxxghp
fbde99389e 更新 package.json 2026-05-27 07:11:01 +08:00
jxxghp
5a4e345529 feat: add LLM proxy toggle 2026-05-27 06:57:09 +08:00
jxxghp
b446afb6d8 fix: improve plugin market editor layout 2026-05-26 17:39:14 +08:00
jxxghp
8580af36d1 fix: compact plugin market settings dialog 2026-05-26 17:16:19 +08:00
jxxghp
95ca092117 feat: optimize plugin market repository settings 2026-05-26 16:30:31 +08:00
jxxghp
ba200cae5c fix: move LLM user agent after max context 2026-05-26 08:30:33 +08:00
jxxghp
87c73e0253 feat: add llm user agent setting 2026-05-26 08:20:02 +08:00
jxxghp
d4d7f635f5 fix: allow rust acceleration re-enable 2026-05-25 23:48:09 +08:00
jxxghp
729db1510e 更新 package.json 2026-05-25 23:11:22 +08:00
jxxghp
8a12ecf918 fix: render OTP QR code reliably 2026-05-25 23:07:45 +08:00
jxxghp
cacc2602df fix: initialize OTP dialog on open 2026-05-25 19:49:30 +08:00
jxxghp
8c6cfa7fc5 feat: add MiniMax audio provider option 2026-05-25 19:10:21 +08:00
jxxghp
0113f28d8c 更新 package.json 2026-05-25 18:20:49 +08:00
jxxghp
d870b788bc feat: add usage version statistics dialog 2026-05-25 18:16:35 +08:00
14 changed files with 1228 additions and 176 deletions

View File

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

View File

@@ -1292,7 +1292,7 @@ export interface TransferForm {
// 目标存储 // 目标存储
target_storage: string target_storage: string
// 目标路径 // 目标路径
target_path: string target_path: string | null
// TMDB ID // TMDB ID
tmdbid?: number tmdbid?: number
// 豆瓣 ID // 豆瓣 ID
@@ -1335,6 +1335,22 @@ export interface ManualTransferPayload extends Omit<TransferForm, 'fileitem'> {
fileitems?: 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 { export interface ManualTransferPreviewSummary {
// 总数 // 总数
@@ -1369,6 +1385,14 @@ export interface ManualTransferPreviewItem {
episode_end?: number | string episode_end?: number | string
// Part // Part
part?: string part?: string
// 原始识别字符串
org_string?: string
// 应用的自定义识别词
apply_words?: string[]
// 制作组/字幕组
resource_team?: string
// 自定义占位符
customization?: string
} }
// 手动整理预览数据 // 手动整理预览数据

View File

@@ -84,6 +84,33 @@ const releaseDialogTitle = ref('')
// 变更日志对话框内容 // 变更日志对话框内容
const releaseDialogBody = 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) { function showReleaseDialog(title: string, body: string) {
releaseDialogTitle.value = title releaseDialogTitle.value = title
@@ -91,6 +118,28 @@ function showReleaseDialog(title: string, body: string) {
releaseDialog.value = true 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() { async function querySystemEnv() {
try { try {
@@ -182,6 +231,18 @@ onMounted(() => {
{{ t('setting.about.latest') }} {{ t('setting.about.latest') }}
</span> </span>
</a> </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> </span>
</dd> </dd>
</div> </div>
@@ -406,6 +467,86 @@ onMounted(() => {
<VCardText class="markdown-body" v-html="releaseDialogBody" /> <VCardText class="markdown-body" v-html="releaseDialogBody" />
</VCard> </VCard>
</VDialog> </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> </VDialog>
</template> </template>
@@ -422,6 +563,18 @@ onMounted(() => {
margin-block: 0.5rem 2.5rem; 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(h1),
.markdown-body :deep(h2), .markdown-body :deep(h2),
.markdown-body :deep(h3) { .markdown-body :deep(h3) {

View File

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

View File

@@ -10,27 +10,121 @@ const display = useDisplay()
const { t } = useI18n() const { t } = useI18n()
const $toast = useToast() 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 repoList = ref<string[]>([])
const repoText = ref('')
const newRepoUrl = ref('') const newRepoUrl = ref('')
const editingIndex = ref<number | null>(null) const editingIndex = ref<number | null>(null)
const editingUrl = ref('') const editingUrl = ref('')
const emit = defineEmits(['save', 'close']) 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() { async function queryMarketRepoSetting() {
try { try {
const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET') const result: { [key: string]: any } = await api.get('system/setting/PLUGIN_MARKET')
if (result && result.data && result.data.value) { 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) { } catch (error) {
console.log(error) console.log(error)
} }
} }
/** 保存插件市场仓库配置。 */
async function saveHandle() { async function saveHandle() {
try { 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) const result: { [key: string]: any } = await api.post('system/setting/PLUGIN_MARKET', repoStringToSave)
if (result.success) { 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() { function addRepo() {
const url = newRepoUrl.value.trim() const url = newRepoUrl.value.trim()
if (!url) return if (!validateRepoUrl(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
}
repoList.value.push(url) repoList.value.push(url)
newRepoUrl.value = '' newRepoUrl.value = ''
syncTextFromList()
} }
/** 从列表中删除一个仓库地址。 */
function removeRepo(index: number) { function removeRepo(index: number) {
repoList.value.splice(index, 1) repoList.value.splice(index, 1)
syncTextFromList()
} }
/** 进入指定仓库地址的行内编辑状态。 */
function startEdit(index: number) { function startEdit(index: number) {
editingIndex.value = index editingIndex.value = index
editingUrl.value = repoList.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() const url = editingUrl.value.trim()
if (!url) return if (!validateRepoUrl(url, index)) return
if (!url.startsWith('http://') && !url.startsWith('https://')) { repoList.value[index] = url
$toast.error(t('dialog.pluginMarketSetting.invalidUrl')) syncTextFromList()
return
}
repoList.value[editingIndex.value] = url
editingIndex.value = null editingIndex.value = null
editingUrl.value = '' editingUrl.value = ''
} }
/** 取消当前行内编辑状态。 */
function cancelEdit() { function cancelEdit() {
editingIndex.value = null editingIndex.value = null
editingUrl.value = '' editingUrl.value = ''
} }
/** 将仓库地址格式化为更易扫描的显示名称。 */
function formatRepoDisplay(url: string) { function formatRepoDisplay(url: string) {
try { try {
const parsedUrl = new URL(url) const parsedUrl = new URL(url)
@@ -108,6 +236,7 @@ function formatRepoDisplay(url: string) {
return url return url
} }
/** 返回拖拽列表项的稳定键。 */
function repoItemKey(repo: string) { function repoItemKey(repo: string) {
return repo return repo
} }
@@ -118,108 +247,192 @@ onMounted(() => {
</script> </script>
<template> <template>
<VDialog width="50rem" scrollable :fullscreen="!display.mdAndUp.value"> <VDialog width="56rem" :fullscreen="!display.mdAndUp.value">
<VCard class="plugin-market-dialog-card"> <VCard class="plugin-market-dialog-card">
<VCardItem> <VCardItem class="plugin-market-card-item">
<VCardTitle> <div class="plugin-market-header">
<VIcon icon="mdi-store-cog" class="me-2" /> <VCardTitle class="plugin-market-title d-flex align-center pa-0">
{{ t('dialog.pluginMarketSetting.title') }} <VIcon icon="mdi-store-cog" class="me-2" />
</VCardTitle> {{ t('dialog.pluginMarketSetting.title') }}
</VCardTitle>
</div>
<VDialogCloseBtn @click="emit('close')" /> <VDialogCloseBtn @click="emit('close')" />
</VCardItem> </VCardItem>
<VDivider />
<VCardText class="plugin-market-dialog-body pt-4"> <VCardText class="plugin-market-dialog-body pt-4">
<div class="plugin-market-input mb-4"> <div class="plugin-market-toolbar">
<VTextField <VBtnToggle
v-model="newRepoUrl" :model-value="editorMode"
density="compact" mandatory
:placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')" color="primary"
prepend-inner-icon="mdi-link-plus" density="comfortable"
clearable variant="tonal"
@keyup.enter="addRepo" class="plugin-market-mode-toggle"
@update:model-value="switchEditorMode"
> >
<template #append> <VBtn value="list" prepend-icon="mdi-format-list-bulleted">
<VBtn icon="mdi-plus" variant="text" color="primary" @click="addRepo" /> {{ t('dialog.pluginMarketSetting.listMode') }}
</template> </VBtn>
</VTextField> <VBtn value="text" prepend-icon="mdi-text-box-edit-outline">
{{ t('dialog.pluginMarketSetting.textMode') }}
</VBtn>
</VBtnToggle>
</div> </div>
<div class="plugin-market-list-wrap"> <div v-if="editorMode === 'list'" class="plugin-market-list-panel">
<VList v-if="repoList.length > 0" class="px-0"> <div class="plugin-market-input">
<draggable <VTextField
v-model="repoList" v-model="newRepoUrl"
:item-key="repoItemKey" density="compact"
handle=".drag-handle" :placeholder="t('dialog.pluginMarketSetting.urlPlaceholder')"
animation="200" prepend-inner-icon="mdi-link-plus"
:disabled="editingIndex !== null" clearable
hide-details
@keyup.enter="addRepo"
> >
<template #item="{ element: repo, index }"> <template #append>
<div> <VBtn
<VListItem class="py-2"> icon="mdi-plus"
<template #prepend> variant="tonal"
<VBtn color="primary"
icon="mdi-drag-vertical" :aria-label="t('dialog.pluginMarketSetting.addRepo')"
size="small" @click="addRepo"
variant="text" />
color="primary" </template>
class="drag-handle me-2" </VTextField>
:disabled="editingIndex !== null" </div>
/>
</template>
<VListItemTitle v-if="editingIndex !== index"> <div class="plugin-market-list-wrap">
<span class="text-truncate" :title="repo">{{ formatRepoDisplay(repo) }}</span> <VList v-if="repoList.length > 0" class="plugin-market-repo-list px-0">
</VListItemTitle> <draggable
v-model="repoList"
<VTextField :item-key="repoItemKey"
v-else handle=".drag-handle"
v-model="editingUrl" animation="200"
density="compact" :disabled="editingIndex !== null"
variant="outlined" @end="syncTextFromList"
hide-details >
@keyup.enter="saveEdit" <template #item="{ element: repo, index }">
@keyup.escape="cancelEdit" <div>
/> <VListItem class="plugin-market-repo-item py-3">
<template #prepend>
<template #append v-if="editingIndex !== index"> <VBtn
<div class="d-flex align-center"> icon="mdi-drag-vertical"
<IconBtn icon="mdi-pencil" size="small" variant="text" @click="startEdit(index)" />
<IconBtn
icon="mdi-delete"
size="small" size="small"
variant="text" variant="text"
color="error" color="primary"
@click="removeRepo(index)" class="drag-handle me-2"
:disabled="editingIndex !== null"
/> />
</div> </template>
</template>
<template #append v-else> <template v-if="editingIndex !== index">
<div class="d-flex align-center"> <VListItemTitle>
<IconBtn icon="mdi-check" size="small" variant="text" color="success" @click="saveEdit" /> <div class="plugin-market-repo-title">
<IconBtn icon="mdi-close" size="small" variant="text" @click="cancelEdit" /> <span class="plugin-market-repo-index">{{ index + 1 }}</span>
</div> <span class="plugin-market-repo-name" :title="repo">{{ formatRepoDisplay(repo) }}</span>
</template> </div>
</VListItem> </VListItemTitle>
<VDivider v-if="index < repoList.length - 1" class="mx-4" /> <VListItemSubtitle class="plugin-market-repo-url mt-1" :title="repo">
</div> {{ repo }}
</template> </VListItemSubtitle>
</draggable> </template>
</VList>
<div v-else class="text-center text-medium-emphasis py-8"> <VTextField
<VIcon icon="mdi-folder-open-outline" size="48" class="mb-2" /> v-else
<div>{{ t('dialog.pluginMarketSetting.noRepos') }}</div> 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> </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> </VCardText>
<VCardActions>
<VCardActions class="plugin-market-actions">
<VSpacer /> <VSpacer />
<VBtn <VBtn
color="primary"
variant="flat"
@click="saveHandle" @click="saveHandle"
prepend-icon="mdi-content-save-check" prepend-icon="mdi-content-save-check"
class="px-5 me-3" class="px-5"
:disabled="repoList.length === 0" :disabled="saveDisabled"
> >
{{ t('dialog.pluginMarketSetting.save') }} {{ t('dialog.pluginMarketSetting.save') }}
</VBtn> </VBtn>
@@ -232,6 +445,24 @@ onMounted(() => {
.plugin-market-dialog-card { .plugin-market-dialog-card {
display: flex; display: flex;
flex-direction: column; 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 { .plugin-market-dialog-body {
@@ -239,6 +470,31 @@ onMounted(() => {
overflow: hidden; overflow: hidden;
flex: 1; flex: 1;
flex-direction: column; 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; min-block-size: 0;
} }
@@ -248,7 +504,173 @@ onMounted(() => {
.plugin-market-list-wrap { .plugin-market-list-wrap {
flex: 1; 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; min-block-size: 0;
overflow-y: auto; 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> </style>

View File

@@ -10,6 +10,7 @@ import {
ManualTransferPayload, ManualTransferPayload,
ManualTransferPreviewData, ManualTransferPreviewData,
ManualTransferPreviewItem, ManualTransferPreviewItem,
ManualTransferTargetPathData,
StorageConf, StorageConf,
TransferDirectoryConf, TransferDirectoryConf,
TransferForm, TransferForm,
@@ -113,6 +114,14 @@ const episodeFormatRecommendState = reactive<{
const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined) const episodeFormatRuleConfigured = ref<boolean | undefined>(undefined)
interface ManualTransferTargetPathRequest {
fileitem?: FileItem
fileitems?: FileItem[]
logid?: number
logids?: number[]
target_storage?: string | null
}
// 生成文件项稳定键,用于去重和状态同步。 // 生成文件项稳定键,用于去重和状态同步。
function getFileItemKey(item?: FileItem) { function getFileItemKey(item?: FileItem) {
return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|') return [item?.storage ?? '', item?.type ?? '', item?.path ?? ''].join('|')
@@ -265,7 +274,7 @@ const transferForm = reactive<TransferForm>({
fileitem: {} as FileItem, fileitem: {} as FileItem,
logid: 0, logid: 0,
target_storage: props.target_storage ?? 'local', target_storage: props.target_storage ?? 'local',
target_path: props.target_path ?? '', target_path: normalizeTargetPath(props.target_path),
transfer_type: '', transfer_type: '',
min_filesize: 0, min_filesize: 0,
scrape: false, scrape: false,
@@ -292,6 +301,79 @@ const targetDirectories = computed(() => {
return [...new Set(libraryDirectories)] 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( watch(
() => transferForm.target_path, () => transferForm.target_path,
@@ -397,6 +479,12 @@ function getUniqueValues(values: (string | undefined)[]) {
return [...new Set(values.map(item => item?.trim()).filter(Boolean) as string[])] 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 // 统一解析接口返回的数字字段,兼容 string/number
function toPreviewNumber(value: unknown) { function toPreviewNumber(value: unknown) {
if (value === undefined || value === null || value === '') return undefined if (value === undefined || value === null || value === '') return undefined
@@ -511,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 previewNeedsWideLayout = computed(() => {
const candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)] const candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)]
@@ -620,6 +724,7 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
...transferForm, ...transferForm,
fileitem: sourceItem, fileitem: sourceItem,
logid: options.logid ?? 0, logid: options.logid ?? 0,
target_path: normalizeTargetPath(transferForm.target_path),
episode_group: transferForm.episode_group?.trim() || null, episode_group: transferForm.episode_group?.trim() || null,
} }
@@ -1099,8 +1204,9 @@ async function transfer(background: boolean = false) {
emit('done') emit('done')
} }
onMounted(() => { onMounted(async () => {
loadDirectories() await loadDirectories()
await autoSelectTargetPath()
loadStorages() loadStorages()
loadEpisodeFormatRuleConfiguration() loadEpisodeFormatRuleConfiguration()
}) })
@@ -1439,6 +1545,36 @@ onUnmounted(() => {
<span class="preview-overview-card__value">{{ previewEpisodeCountText }}</span> <span class="preview-overview-card__value">{{ previewEpisodeCountText }}</span>
</div> </div>
</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>
<div class="reorganize-preview-list"> <div class="reorganize-preview-list">
<div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body"> <div v-if="pagedPreviewRows.length" ref="previewFileBodyRef" class="preview-file-body">
@@ -1698,6 +1834,66 @@ onUnmounted(() => {
white-space: nowrap; 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 { .reorganize-preview-pane__scroll {
display: flex; display: flex;
overflow: hidden auto; overflow: hidden auto;
@@ -1797,11 +1993,13 @@ onUnmounted(() => {
} }
.preview-file-row__path { .preview-file-row__path {
overflow: hidden; overflow: visible;
overflow-wrap: anywhere;
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity)); color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
font-size: 0.8125rem; font-size: 0.8125rem;
text-overflow: ellipsis; line-height: 1.4;
white-space: nowrap; white-space: normal;
word-break: break-all;
} }
.preview-file-row__card--target .preview-file-row__name { .preview-file-row__card--target .preview-file-row__name {

View File

@@ -10,7 +10,7 @@ const props = defineProps({
type: Array as PropType<Site[]>, type: Array as PropType<Site[]>,
required: true, 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 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( watch(
() => props.selected, [() => props.selected, () => props.sites],
value => { ([value]) => {
if (selectedSites.value.length == 0 && value) { selectedSites.value = normalizeSelectedSites(value || [])
selectedSites.value = value
}
}, },
{ immediate: true },
) )
// 全选/全不选按钮文字 // 全选/全不选按钮文字
const checkAllText = computed(() => { const checkAllText = computed(() => {
return selectedSites.value.length < props.sites?.length return selectedSites.value.length < props.sites.length
? t('dialog.searchSite.selectAll') ? t('dialog.searchSite.selectAll')
: t('dialog.searchSite.deselectAll') : t('dialog.searchSite.deselectAll')
}) })
// 全选/全不选 // 全选/全不选
const checkAllSitesorNot = () => { const checkAllSitesorNot = () => {
if (selectedSites.value.length < props.sites?.length) { if (selectedSites.value.length < props.sites.length) {
selectedSites.value = props.sites?.map((item: Site) => item.id) selectedSites.value = props.sites.map((item: Site) => item.id)
} else { } else {
selectedSites.value = [] 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(() => { const filteredSites = computed(() => {
if (!siteFilter.value) return props.sites if (!siteFilter.value) return props.sites
const filter = siteFilter.value.toLowerCase() 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> </script>
<template> <template>
@@ -107,16 +135,7 @@ const filteredSites = computed(() => {
'site-hover': isHovering && !selectedSites.includes(site.id), 'site-hover': isHovering && !selectedSites.includes(site.id),
}, },
]" ]"
@click=" @click="toggleSiteSelection(site.id)"
() => {
const index = selectedSites.indexOf(site.id)
if (index === -1) {
selectedSites.push(site.id)
} else {
selectedSites.splice(index, 1)
}
}
"
> >
<VIcon <VIcon
:icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'" :icon="selectedSites.includes(site.id) ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'"
@@ -161,7 +180,7 @@ const filteredSites = computed(() => {
<VBtn <VBtn
color="primary" color="primary"
:disabled="selectedSites.length === 0" :disabled="selectedSites.length === 0"
@click="emit('search', selectedSites)" @click="confirmSearch"
prepend-icon="mdi-magnify" prepend-icon="mdi-magnify"
class="d-flex align-center justify-center px-5" class="d-flex align-center justify-center px-5"
> >

View File

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

View File

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

View File

@@ -1378,6 +1378,18 @@ export default {
expand: 'Expand', expand: 'Expand',
collapse: 'Collapse', collapse: 'Collapse',
clearCache: 'Clear Cache', 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: { system: {
custom: 'Custom', custom: 'Custom',
@@ -1444,6 +1456,11 @@ export default {
llmApiKeyPlaceholder: 'Please enter API key', llmApiKeyPlaceholder: 'Please enter API key',
llmBaseUrl: 'LLM Base URL', llmBaseUrl: 'LLM Base URL',
llmBaseUrlHint: 'Base URL for LLM API, used for custom API endpoints', 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', llmProviderAuth: 'Provider Authorization',
llmProviderAuthHint: llmProviderAuthHint:
'Providers that support account authorization can complete sign-in here and reuse the saved auth state.', 'Providers that support account authorization can complete sign-in here and reuse the saved auth state.',
@@ -1458,15 +1475,16 @@ export default {
llmProviderCheckAuthStatus: 'Check Authorization Status', llmProviderCheckAuthStatus: 'Check Authorization Status',
audioInputProvider: 'Audio Input Provider', audioInputProvider: 'Audio Input Provider',
audioInputProviderHint: 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', audioProviderOpenAiAudio: 'OpenAI Audio Compatible',
audioProviderChatAudio: 'Chat Audio Compatible', audioProviderChatAudio: 'Chat Audio Compatible',
audioProviderMimo: 'Xiaomi MiMo', audioProviderMimo: 'Xiaomi MiMo',
audioProviderMinimax: 'MiniMax',
audioInputApiKey: 'Audio Input API Key', audioInputApiKey: 'Audio Input API Key',
audioInputApiKeyHint: 'API key used for audio transcription.', audioInputApiKeyHint: 'API key used for audio transcription.',
audioInputBaseUrl: 'Audio Input Base URL', audioInputBaseUrl: 'Audio Input Base URL',
audioInputBaseUrlHint: 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', audioInputModel: 'Audio Input Model',
audioInputModelHint: 'Model name used to convert audio content into text.', audioInputModelHint: 'Model name used to convert audio content into text.',
audioInputLanguage: 'Recognition Language', audioInputLanguage: 'Recognition Language',
@@ -1474,12 +1492,12 @@ export default {
'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.', 'Default language for audio transcription, such as zh or en. Leave blank to use the backend default.',
audioOutputProvider: 'Audio Output Provider', audioOutputProvider: 'Audio Output Provider',
audioOutputProviderHint: 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', audioOutputApiKey: 'Audio Output API Key',
audioOutputApiKeyHint: 'API key used for speech synthesis.', audioOutputApiKeyHint: 'API key used for speech synthesis.',
audioOutputBaseUrl: 'Audio Output Base URL', audioOutputBaseUrl: 'Audio Output Base URL',
audioOutputBaseUrlHint: 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', audioOutputModel: 'Audio Output Model',
audioOutputModelHint: 'Model name used to convert text content into speech.', audioOutputModelHint: 'Model name used to convert text content into speech.',
audioOutputVoice: 'Voice Preset', audioOutputVoice: 'Voice Preset',
@@ -1561,6 +1579,9 @@ export default {
'Share subscription statistics to popular subscriptions for other MP users to reference', 'Share subscription statistics to popular subscriptions for other MP users to reference',
pluginStatisticShare: 'Report Plugin Installation Data', pluginStatisticShare: 'Report Plugin Installation Data',
pluginStatisticShareHint: 'Report plugin installation data to the server for statistics and display purposes', 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', workflowStatisticShare: 'Share Workflow Data',
workflowStatisticShareHint: 'Share workflow statistics to popular workflows for other MP users to reference', workflowStatisticShareHint: 'Share workflow statistics to popular workflows for other MP users to reference',
bigMemoryMode: 'Large Memory Mode', bigMemoryMode: 'Large Memory Mode',
@@ -2469,11 +2490,19 @@ export default {
title: 'Plugin Market Settings', title: 'Plugin Market Settings',
repoUrl: 'Plugin Repository URL', repoUrl: 'Plugin Repository URL',
repoPlaceholder: 'Format: https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/', 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', 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', noRepos: 'No plugin repository URLs',
invalidUrl: 'Please enter a valid URL', invalidUrl: 'Please enter a valid URL',
duplicateUrl: 'This URL already exists', 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', close: 'Close',
save: 'Save', save: 'Save',
saveSuccess: 'Plugin repository saved successfully', saveSuccess: 'Plugin repository saved successfully',
@@ -2614,6 +2643,7 @@ export default {
previewSeasonInfo: 'Season', previewSeasonInfo: 'Season',
previewSeasonLabel: 'Season', previewSeasonLabel: 'Season',
previewEpisodeCount: 'Episodes', previewEpisodeCount: 'Episodes',
customWordsApplied: 'Recognition Word Details',
previewAfterColumn: 'After', previewAfterColumn: 'After',
previewBeforeColumn: 'Before', previewBeforeColumn: 'Before',
previewFileNameColumn: 'Filename', previewFileNameColumn: 'Filename',

View File

@@ -1373,6 +1373,18 @@ export default {
expand: '展开', expand: '展开',
collapse: '收起', collapse: '收起',
clearCache: '清除缓存', clearCache: '清除缓存',
versionStatistic: '版本统计',
versionStatisticTitle: '安装版本统计',
totalInstallUsers: '安装用户',
activeToday: '今日活跃',
active7Days: '7日活跃',
active30Days: '30日活跃',
backendVersionStatistic: '后端版本',
frontendVersionStatistic: '前端版本',
version: '版本',
users: '用户数',
lastUpdated: '更新时间',
noVersionStatisticData: '暂无统计数据',
}, },
system: { system: {
custom: '自定义', custom: '自定义',
@@ -1436,6 +1448,10 @@ export default {
llmApiKeyPlaceholder: '请输入API密钥', llmApiKeyPlaceholder: '请输入API密钥',
llmBaseUrl: 'LLM基础URL', llmBaseUrl: 'LLM基础URL',
llmBaseUrlHint: 'LLM API的基础URL地址用于自定义API端点', llmBaseUrlHint: 'LLM API的基础URL地址用于自定义API端点',
llmUseProxy: '使用系统代理',
llmUseProxyHint: '启用后Agent 连接当前 LLM 提供商时会应用高级设置中的系统代理',
llmUserAgent: 'User-Agent',
llmUserAgentHint: 'OpenAI 兼容接口请求使用的 User-Agent留空则使用 SDK 默认值',
llmProviderAuth: '提供商授权', llmProviderAuth: '提供商授权',
llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。', llmProviderAuthHint: '支持账号登录授权的提供商,可以直接在这里完成登录并复用授权状态。',
llmProviderConnectedAs: '当前已连接:{label}', llmProviderConnectedAs: '当前已连接:{label}',
@@ -1447,26 +1463,29 @@ export default {
llmProviderOpenAuthPage: '打开授权页面', llmProviderOpenAuthPage: '打开授权页面',
llmProviderCheckAuthStatus: '检查授权状态', llmProviderCheckAuthStatus: '检查授权状态',
audioInputProvider: '音频输入提供商', audioInputProvider: '音频输入提供商',
audioInputProviderHint: '用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo', audioInputProviderHint:
'用于识别用户音频消息的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioProviderOpenAiAudio: 'OpenAI Audio 兼容', audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
audioProviderChatAudio: 'Chat Audio 兼容', audioProviderChatAudio: 'Chat Audio 兼容',
audioProviderMimo: '小米 MiMo', audioProviderMimo: '小米 MiMo',
audioProviderMinimax: 'MiniMax',
audioInputApiKey: '音频输入 API密钥', audioInputApiKey: '音频输入 API密钥',
audioInputApiKeyHint: '音频输入转写使用的 API 密钥', audioInputApiKeyHint: '音频输入转写使用的 API 密钥',
audioInputBaseUrl: '音频输入基础URL', audioInputBaseUrl: '音频输入基础URL',
audioInputBaseUrlHint: audioInputBaseUrlHint:
'音频输入接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1', '音频输入接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1MiniMax 默认 https://api.minimaxi.com/v1',
audioInputModel: '音频输入模型', audioInputModel: '音频输入模型',
audioInputModelHint: '用于将音频内容转换为文字的模型名称', audioInputModelHint: '用于将音频内容转换为文字的模型名称',
audioInputLanguage: '识别语言', audioInputLanguage: '识别语言',
audioInputLanguageHint: '音频转写默认语言,例如 zh、en留空时按后端默认处理', audioInputLanguageHint: '音频转写默认语言,例如 zh、en留空时按后端默认处理',
audioOutputProvider: '音频输出提供商', audioOutputProvider: '音频输出提供商',
audioOutputProviderHint: '用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口和 Xiaomi MiMo', audioOutputProviderHint:
'用于生成语音回复的服务,支持 OpenAI 音频接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioOutputApiKey: '音频输出 API密钥', audioOutputApiKey: '音频输出 API密钥',
audioOutputApiKeyHint: '文字转语音使用的 API 密钥', audioOutputApiKeyHint: '文字转语音使用的 API 密钥',
audioOutputBaseUrl: '音频输出基础URL', audioOutputBaseUrl: '音频输出基础URL',
audioOutputBaseUrlHint: audioOutputBaseUrlHint:
'音频输出接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1', '音频输出接口基础URLChat Audio 类服务可填写对应兼容地址MiMo 默认 https://api.xiaomimimo.com/v1MiniMax 默认 https://api.minimaxi.com/v1',
audioOutputModel: '音频输出模型', audioOutputModel: '音频输出模型',
audioOutputModelHint: '用于将文字内容转换为语音的模型名称', audioOutputModelHint: '用于将文字内容转换为语音的模型名称',
audioOutputVoice: '语音音色', audioOutputVoice: '语音音色',
@@ -1545,6 +1564,8 @@ export default {
subscribeStatisticShareHint: '分享订阅统计数据到热门订阅供其他MPer参考', subscribeStatisticShareHint: '分享订阅统计数据到热门订阅供其他MPer参考',
pluginStatisticShare: '上报插件安装数据', pluginStatisticShare: '上报插件安装数据',
pluginStatisticShareHint: '上报插件安装数据给服务器,用于统计展示插件安装情况', pluginStatisticShareHint: '上报插件安装数据给服务器,用于统计展示插件安装情况',
usageStatisticShare: '上报安装版本统计',
usageStatisticShareHint: '上报匿名安装ID和当前前后端版本用于统计各版本安装用户数',
workflowStatisticShare: '分享工作流数据', workflowStatisticShare: '分享工作流数据',
workflowStatisticShareHint: '分享工作流统计数据到热门工作流供其他MPer参考', workflowStatisticShareHint: '分享工作流统计数据到热门工作流供其他MPer参考',
bigMemoryMode: '大内存模式', bigMemoryMode: '大内存模式',
@@ -2423,11 +2444,19 @@ export default {
title: '插件市场设置', title: '插件市场设置',
repoUrl: '插件仓库地址', repoUrl: '插件仓库地址',
repoPlaceholder: '格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/', repoPlaceholder: '格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: '多个地址使用换行分隔仅支持Github仓库', repoHint: '多个地址使用换行或英文逗号分隔',
urlPlaceholder: '输入插件仓库地址', urlPlaceholder: '输入插件仓库地址',
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
listMode: '列表维护',
textMode: '文本维护',
textHint: '直接粘贴仓库地址串,一行一个或使用英文逗号分隔。',
addRepo: '添加仓库',
noRepos: '暂无插件仓库地址', noRepos: '暂无插件仓库地址',
invalidUrl: '请输入有效的URL地址', invalidUrl: '请输入有效的URL地址',
duplicateUrl: '该地址已存在', duplicateUrl: '该地址已存在',
invalidText: '文本中有 {count} 个无效地址,请修正后保存。',
invalidTextIgnored: '已忽略 {count} 个无效地址',
duplicateTextIgnored: '重复地址会在保存时自动去重。',
close: '关闭', close: '关闭',
save: '保存', save: '保存',
saveSuccess: '插件仓库保存成功', saveSuccess: '插件仓库保存成功',
@@ -2567,6 +2596,7 @@ export default {
previewSeasonInfo: '季信息', previewSeasonInfo: '季信息',
previewSeasonLabel: '季', previewSeasonLabel: '季',
previewEpisodeCount: '总集数', previewEpisodeCount: '总集数',
customWordsApplied: '识别词应用详情',
previewAfterColumn: '整理后', previewAfterColumn: '整理后',
previewBeforeColumn: '整理前', previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名', previewFileNameColumn: '文件名',

View File

@@ -1374,6 +1374,18 @@ export default {
expand: '展開', expand: '展開',
collapse: '收起', collapse: '收起',
clearCache: '清除快取', clearCache: '清除快取',
versionStatistic: '版本統計',
versionStatisticTitle: '安裝版本統計',
totalInstallUsers: '安裝用戶',
activeToday: '今日活躍',
active7Days: '7日活躍',
active30Days: '30日活躍',
backendVersionStatistic: '後端版本',
frontendVersionStatistic: '前端版本',
version: '版本',
users: '用戶數',
lastUpdated: '更新時間',
noVersionStatisticData: '暫無統計數據',
}, },
system: { system: {
custom: '自定義', custom: '自定義',
@@ -1437,6 +1449,10 @@ export default {
llmApiKeyPlaceholder: '請輸入API密鑰', llmApiKeyPlaceholder: '請輸入API密鑰',
llmBaseUrl: 'LLM基礎URL', llmBaseUrl: 'LLM基礎URL',
llmBaseUrlHint: 'LLM API的基礎URL地址用於自定義API端點', llmBaseUrlHint: 'LLM API的基礎URL地址用於自定義API端點',
llmUseProxy: '使用系統代理',
llmUseProxyHint: '啟用後Agent 連接目前 LLM 提供商時會套用進階設定中的系統代理',
llmUserAgent: 'User-Agent',
llmUserAgentHint: 'OpenAI 兼容接口請求使用的 User-Agent留空則使用 SDK 預設值',
llmProviderAuth: '提供商授權', llmProviderAuth: '提供商授權',
llmProviderAuthHint: '支援帳號登入授權的提供商,可以直接在這裡完成登入並重用授權狀態。', llmProviderAuthHint: '支援帳號登入授權的提供商,可以直接在這裡完成登入並重用授權狀態。',
llmProviderConnectedAs: '目前已連接:{label}', llmProviderConnectedAs: '目前已連接:{label}',
@@ -1448,26 +1464,29 @@ export default {
llmProviderOpenAuthPage: '開啟授權頁面', llmProviderOpenAuthPage: '開啟授權頁面',
llmProviderCheckAuthStatus: '檢查授權狀態', llmProviderCheckAuthStatus: '檢查授權狀態',
audioInputProvider: '音頻輸入提供商', audioInputProvider: '音頻輸入提供商',
audioInputProviderHint: '用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo', audioInputProviderHint:
'用於識別用戶音頻消息的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioProviderOpenAiAudio: 'OpenAI Audio 兼容', audioProviderOpenAiAudio: 'OpenAI Audio 兼容',
audioProviderChatAudio: 'Chat Audio 兼容', audioProviderChatAudio: 'Chat Audio 兼容',
audioProviderMimo: '小米 MiMo', audioProviderMimo: '小米 MiMo',
audioProviderMinimax: 'MiniMax',
audioInputApiKey: '音頻輸入 API密鑰', audioInputApiKey: '音頻輸入 API密鑰',
audioInputApiKeyHint: '音頻輸入轉寫使用的 API 密鑰', audioInputApiKeyHint: '音頻輸入轉寫使用的 API 密鑰',
audioInputBaseUrl: '音頻輸入基礎URL', audioInputBaseUrl: '音頻輸入基礎URL',
audioInputBaseUrlHint: audioInputBaseUrlHint:
'音頻輸入接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1', '音頻輸入接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1MiniMax 預設 https://api.minimaxi.com/v1',
audioInputModel: '音頻輸入模型', audioInputModel: '音頻輸入模型',
audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱', audioInputModelHint: '用於將音頻內容轉換為文字的模型名稱',
audioInputLanguage: '識別語言', audioInputLanguage: '識別語言',
audioInputLanguageHint: '音頻轉寫預設語言,例如 zh、en留空時按後端預設處理', audioInputLanguageHint: '音頻轉寫預設語言,例如 zh、en留空時按後端預設處理',
audioOutputProvider: '音頻輸出提供商', audioOutputProvider: '音頻輸出提供商',
audioOutputProviderHint: '用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口和 Xiaomi MiMo', audioOutputProviderHint:
'用於生成語音回覆的服務,支援 OpenAI 音頻接口、Chat Audio 兼容接口、Xiaomi MiMo 和 MiniMax',
audioOutputApiKey: '音頻輸出 API密鑰', audioOutputApiKey: '音頻輸出 API密鑰',
audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰', audioOutputApiKeyHint: '文字轉語音使用的 API 密鑰',
audioOutputBaseUrl: '音頻輸出基礎URL', audioOutputBaseUrl: '音頻輸出基礎URL',
audioOutputBaseUrlHint: audioOutputBaseUrlHint:
'音頻輸出接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1', '音頻輸出接口基礎URLChat Audio 類服務可填寫對應兼容地址MiMo 預設 https://api.xiaomimimo.com/v1MiniMax 預設 https://api.minimaxi.com/v1',
audioOutputModel: '音頻輸出模型', audioOutputModel: '音頻輸出模型',
audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱', audioOutputModelHint: '用於將文字內容轉換為語音的模型名稱',
audioOutputVoice: '語音音色', audioOutputVoice: '語音音色',
@@ -1546,6 +1565,8 @@ export default {
subscribeStatisticShareHint: '分享訂閱統計數據到熱門訂閱供其他MPer參考', subscribeStatisticShareHint: '分享訂閱統計數據到熱門訂閱供其他MPer參考',
pluginStatisticShare: '上報插件安裝數據', pluginStatisticShare: '上報插件安裝數據',
pluginStatisticShareHint: '上報插件安裝數據給服務器,用於統計展示插件安裝情況', pluginStatisticShareHint: '上報插件安裝數據給服務器,用於統計展示插件安裝情況',
usageStatisticShare: '上報安裝版本統計',
usageStatisticShareHint: '上報匿名安裝ID和當前前後端版本用於統計各版本安裝用戶數',
workflowStatisticShare: '分享工作流數據', workflowStatisticShare: '分享工作流數據',
workflowStatisticShareHint: '分享工作流統計數據到熱門工作流供其他MPer參考', workflowStatisticShareHint: '分享工作流統計數據到熱門工作流供其他MPer參考',
bigMemoryMode: '大內存模式', bigMemoryMode: '大內存模式',
@@ -2424,11 +2445,19 @@ export default {
title: '插件市場設置', title: '插件市場設置',
repoUrl: '插件倉庫地址', repoUrl: '插件倉庫地址',
repoPlaceholder: '格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/', repoPlaceholder: '格式https://github.com/jxxghp/MoviePilot-Plugins/,https://github.com/xxxx/xxxxxx/',
repoHint: '多個地址使用换行分隔僅支援Github倉庫', repoHint: '多個地址使用換行或英文逗號分隔',
urlPlaceholder: '輸入插件倉庫地址', urlPlaceholder: '輸入插件倉庫地址',
textPlaceholder: 'https://github.com/jxxghp/MoviePilot-Plugins/\nhttps://github.com/xxxx/xxxxxx/',
listMode: '列表維護',
textMode: '文字維護',
textHint: '直接貼上倉庫地址串,一行一個或使用英文逗號分隔。',
addRepo: '新增倉庫',
noRepos: '暫無插件倉庫地址', noRepos: '暫無插件倉庫地址',
invalidUrl: '請輸入有效的URL地址', invalidUrl: '請輸入有效的URL地址',
duplicateUrl: '該地址已存在', duplicateUrl: '該地址已存在',
invalidText: '文字中有 {count} 個無效地址,請修正後儲存。',
invalidTextIgnored: '已忽略 {count} 個無效地址',
duplicateTextIgnored: '重複地址會在儲存時自動去重。',
close: '關閉', close: '關閉',
save: '儲存', save: '儲存',
saveSuccess: '插件倉庫儲存成功', saveSuccess: '插件倉庫儲存成功',
@@ -2568,6 +2597,7 @@ export default {
previewSeasonInfo: '季資訊', previewSeasonInfo: '季資訊',
previewSeasonLabel: '季', previewSeasonLabel: '季',
previewEpisodeCount: '總集數', previewEpisodeCount: '總集數',
customWordsApplied: '識別詞應用詳情',
previewAfterColumn: '整理後', previewAfterColumn: '整理後',
previewBeforeColumn: '整理前', previewBeforeColumn: '整理前',
previewFileNameColumn: '文件名', previewFileNameColumn: '文件名',

View File

@@ -57,7 +57,10 @@ const SystemSettings = ref<any>({
LLM_SUPPORT_AUDIO_OUTPUT: false, LLM_SUPPORT_AUDIO_OUTPUT: false,
LLM_API_KEY: null, LLM_API_KEY: null,
LLM_BASE_URL: 'https://api.deepseek.com', LLM_BASE_URL: 'https://api.deepseek.com',
LLM_USE_PROXY: true,
LLM_BASE_URL_PRESET: null, LLM_BASE_URL_PRESET: null,
LLM_MAX_CONTEXT_TOKENS: 64,
LLM_USER_AGENT: null,
AUDIO_INPUT_PROVIDER: 'openai', AUDIO_INPUT_PROVIDER: 'openai',
AUDIO_INPUT_API_KEY: null, AUDIO_INPUT_API_KEY: null,
AUDIO_INPUT_BASE_URL: null, AUDIO_INPUT_BASE_URL: null,
@@ -73,7 +76,6 @@ const SystemSettings = ref<any>({
AI_RECOMMEND_ENABLED: false, AI_RECOMMEND_ENABLED: false,
AI_RECOMMEND_USER_PREFERENCE: null, AI_RECOMMEND_USER_PREFERENCE: null,
AI_RECOMMEND_MAX_ITEMS: 50, AI_RECOMMEND_MAX_ITEMS: 50,
LLM_MAX_CONTEXT_TOKENS: 64,
}, },
// 高级系统设置 // 高级系统设置
Advanced: { Advanced: {
@@ -82,6 +84,7 @@ const SystemSettings = ref<any>({
GLOBAL_IMAGE_CACHE: false, GLOBAL_IMAGE_CACHE: false,
SUBSCRIBE_STATISTIC_SHARE: true, SUBSCRIBE_STATISTIC_SHARE: true,
PLUGIN_STATISTIC_SHARE: true, PLUGIN_STATISTIC_SHARE: true,
USAGE_STATISTIC_SHARE: true,
WORKFLOW_STATISTIC_SHARE: true, WORKFLOW_STATISTIC_SHARE: true,
BIG_MEMORY_MODE: false, BIG_MEMORY_MODE: false,
DB_WAL_ENABLE: false, DB_WAL_ENABLE: false,
@@ -132,6 +135,7 @@ const audioProviderItems = computed(() => [
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' }, { title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' }, { title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' }, { title: t('setting.system.audioProviderMimo'), value: 'mimo' },
{ title: t('setting.system.audioProviderMinimax'), value: 'minimax' },
]) ])
// 刮削配置 // 刮削配置
@@ -211,7 +215,9 @@ type LlmSettingsSnapshot = {
LLM_THINKING_LEVEL: string LLM_THINKING_LEVEL: string
LLM_API_KEY: string LLM_API_KEY: string
LLM_BASE_URL: string LLM_BASE_URL: string
LLM_USE_PROXY: boolean
LLM_BASE_URL_PRESET: string LLM_BASE_URL_PRESET: string
LLM_USER_AGENT: string
} }
let llmTestRequestId = 0 let llmTestRequestId = 0
@@ -245,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({ const llmModelRef = computed({
get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''), get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
set: value => { set: value => {
@@ -290,6 +310,8 @@ const {
apiKey: llmApiKeyRef, apiKey: llmApiKeyRef,
baseUrl: llmBaseUrlRef, baseUrl: llmBaseUrlRef,
baseUrlPreset: llmBaseUrlPresetRef, baseUrlPreset: llmBaseUrlPresetRef,
useProxy: llmUseProxyRef,
userAgent: llmUserAgentRef,
model: llmModelRef, model: llmModelRef,
maxContextTokens: llmMaxContextRef, maxContextTokens: llmMaxContextRef,
}) })
@@ -350,7 +372,9 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'), LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''), LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''), 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_BASE_URL_PRESET: String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
LLM_USER_AGENT: String(SystemSettings.value.Basic.LLM_USER_AGENT ?? ''),
} }
} }
@@ -366,7 +390,9 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(), thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
api_key: snapshot.LLM_API_KEY.trim(), api_key: snapshot.LLM_API_KEY.trim(),
base_url: snapshot.LLM_BASE_URL.trim(), base_url: snapshot.LLM_BASE_URL.trim(),
use_proxy: snapshot.LLM_USE_PROXY,
base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(), base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(),
user_agent: snapshot.LLM_USER_AGENT.trim(),
} }
} }
@@ -640,9 +666,9 @@ async function loadSystemSettings() {
if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key] if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]
}) })
} }
const accelEnabled = Boolean(result.data.RUST_ACCEL_ENABLED) const accelAvailable = Boolean(result.data.RUST_ACCEL_AVAILABLE ?? result.data.RUST_ACCEL_ENABLED)
rustAccelAvailable.value = accelEnabled rustAccelAvailable.value = accelAvailable
if (!accelEnabled) SystemSettings.value.Advanced.RUST_ACCEL = false if (!accelAvailable) SystemSettings.value.Advanced.RUST_ACCEL = false
SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data) SystemSettings.value.Basic.LLM_THINKING_LEVEL = resolveThinkingLevelValue(result.data)
await loadLlmProviders() await loadLlmProviders()
} }
@@ -1192,6 +1218,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
</template> </template>
</VCombobox> </VCombobox>
</VCol> </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"> <VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE && showApiKeyField" cols="12" md="6">
<VTextField <VTextField
v-model="SystemSettings.Basic.LLM_API_KEY" v-model="SystemSettings.Basic.LLM_API_KEY"
@@ -1306,6 +1340,15 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
prepend-inner-icon="mdi-counter" prepend-inner-icon="mdi-counter"
/> />
</VCol> </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"> <VCol v-if="SystemSettings.Basic.AI_AGENT_ENABLE" cols="12" md="6">
<VSelect <VSelect
v-model="SystemSettings.Basic.LLM_THINKING_LEVEL" v-model="SystemSettings.Basic.LLM_THINKING_LEVEL"
@@ -1767,6 +1810,14 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
persistent-hint persistent-hint
/> />
</VCol> </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"> <VCol cols="12" md="6">
<VSwitch <VSwitch
v-model="SystemSettings.Advanced.WORKFLOW_STATISTIC_SHARE" v-model="SystemSettings.Advanced.WORKFLOW_STATISTIC_SHARE"

View File

@@ -40,6 +40,20 @@ const baseUrlPresetRef = computed({
}, },
}) })
const useProxyRef = computed({
get: () => wizardData.value.agent.useProxy,
set: value => {
wizardData.value.agent.useProxy = Boolean(value)
},
})
const userAgentRef = computed({
get: () => wizardData.value.agent.userAgent,
set: value => {
wizardData.value.agent.userAgent = value || ''
},
})
const modelRef = computed({ const modelRef = computed({
get: () => wizardData.value.agent.model, get: () => wizardData.value.agent.model,
set: value => { set: value => {
@@ -92,6 +106,8 @@ const {
apiKey: apiKeyRef, apiKey: apiKeyRef,
baseUrl: baseUrlRef, baseUrl: baseUrlRef,
baseUrlPreset: baseUrlPresetRef, baseUrlPreset: baseUrlPresetRef,
useProxy: useProxyRef,
userAgent: userAgentRef,
model: modelRef, model: modelRef,
maxContextTokens: maxContextTokensRef, maxContextTokens: maxContextTokensRef,
authConnected: authConnectedRef, authConnected: authConnectedRef,
@@ -171,6 +187,7 @@ const audioProviderItems = computed(() => [
{ title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' }, { title: t('setting.system.audioProviderOpenAiAudio'), value: 'openai' },
{ title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' }, { title: t('setting.system.audioProviderChatAudio'), value: 'openai_chat_audio' },
{ title: t('setting.system.audioProviderMimo'), value: 'mimo' }, { title: t('setting.system.audioProviderMimo'), value: 'mimo' },
{ title: t('setting.system.audioProviderMinimax'), value: 'minimax' },
]) ])
const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || []) const providerAuthMethods = computed(() => selectedProvider.value?.oauth_methods || [])
@@ -332,6 +349,16 @@ onMounted(async () => {
</VCombobox> </VCombobox>
</VCol> </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"> <VCol v-if="showApiKeyField" cols="12" md="6">
<VTextField <VTextField
v-model="wizardData.agent.apiKey" v-model="wizardData.agent.apiKey"
@@ -437,6 +464,16 @@ onMounted(async () => {
/> />
</VCol> </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"> <VCol cols="12" md="6">
<VSelect <VSelect
v-model="wizardData.agent.thinkingLevel" v-model="wizardData.agent.thinkingLevel"