mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-06-22 16:13:47 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14aa75dfae | ||
|
|
348aa4757b | ||
|
|
6e6819acc1 | ||
|
|
51a58aaae0 | ||
|
|
fbde99389e | ||
|
|
5a4e345529 | ||
|
|
b446afb6d8 | ||
|
|
8580af36d1 | ||
|
|
95ca092117 | ||
|
|
ba200cae5c | ||
|
|
87c73e0253 | ||
|
|
d4d7f635f5 | ||
|
|
729db1510e | ||
|
|
8a12ecf918 | ||
|
|
cacc2602df | ||
|
|
8c6cfa7fc5 | ||
|
|
0113f28d8c | ||
|
|
d870b788bc |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "moviepilot",
|
||||
"version": "2.13.0",
|
||||
"version": "2.13.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"bin": "dist/service.js",
|
||||
|
||||
@@ -1292,7 +1292,7 @@ export interface TransferForm {
|
||||
// 目标存储
|
||||
target_storage: string
|
||||
// 目标路径
|
||||
target_path: string
|
||||
target_path: string | null
|
||||
// TMDB ID
|
||||
tmdbid?: number
|
||||
// 豆瓣 ID
|
||||
@@ -1335,6 +1335,22 @@ export interface ManualTransferPayload extends Omit<TransferForm, '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 {
|
||||
// 总数
|
||||
@@ -1369,6 +1385,14 @@ export interface ManualTransferPreviewItem {
|
||||
episode_end?: number | string
|
||||
// Part
|
||||
part?: string
|
||||
// 原始识别字符串
|
||||
org_string?: string
|
||||
// 应用的自定义识别词
|
||||
apply_words?: string[]
|
||||
// 制作组/字幕组
|
||||
resource_team?: string
|
||||
// 自定义占位符
|
||||
customization?: string
|
||||
}
|
||||
|
||||
// 手动整理预览数据
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ManualTransferPayload,
|
||||
ManualTransferPreviewData,
|
||||
ManualTransferPreviewItem,
|
||||
ManualTransferTargetPathData,
|
||||
StorageConf,
|
||||
TransferDirectoryConf,
|
||||
TransferForm,
|
||||
@@ -113,6 +114,14 @@ 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('|')
|
||||
@@ -265,7 +274,7 @@ 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,
|
||||
@@ -292,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,
|
||||
@@ -397,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
|
||||
@@ -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 candidates = [...previewFileRows.value.map(item => `${item.sourceName}${item.targetName}`)]
|
||||
@@ -620,6 +724,7 @@ function createTransferPayload(options: { item?: FileItem; items?: FileItem[]; l
|
||||
...transferForm,
|
||||
fileitem: sourceItem,
|
||||
logid: options.logid ?? 0,
|
||||
target_path: normalizeTargetPath(transferForm.target_path),
|
||||
episode_group: transferForm.episode_group?.trim() || null,
|
||||
}
|
||||
|
||||
@@ -1099,8 +1204,9 @@ async function transfer(background: boolean = false) {
|
||||
emit('done')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDirectories()
|
||||
onMounted(async () => {
|
||||
await loadDirectories()
|
||||
await autoSelectTargetPath()
|
||||
loadStorages()
|
||||
loadEpisodeFormatRuleConfiguration()
|
||||
})
|
||||
@@ -1439,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">
|
||||
@@ -1698,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;
|
||||
@@ -1797,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"
|
||||
>
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -1378,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',
|
||||
@@ -1444,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.',
|
||||
@@ -1458,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',
|
||||
@@ -1474,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',
|
||||
@@ -1561,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',
|
||||
@@ -2469,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',
|
||||
@@ -2614,6 +2643,7 @@ export default {
|
||||
previewSeasonInfo: 'Season',
|
||||
previewSeasonLabel: 'Season',
|
||||
previewEpisodeCount: 'Episodes',
|
||||
customWordsApplied: 'Recognition Word Details',
|
||||
previewAfterColumn: 'After',
|
||||
previewBeforeColumn: 'Before',
|
||||
previewFileNameColumn: 'Filename',
|
||||
|
||||
@@ -1373,6 +1373,18 @@ export default {
|
||||
expand: '展开',
|
||||
collapse: '收起',
|
||||
clearCache: '清除缓存',
|
||||
versionStatistic: '版本统计',
|
||||
versionStatisticTitle: '安装版本统计',
|
||||
totalInstallUsers: '安装用户',
|
||||
activeToday: '今日活跃',
|
||||
active7Days: '7日活跃',
|
||||
active30Days: '30日活跃',
|
||||
backendVersionStatistic: '后端版本',
|
||||
frontendVersionStatistic: '前端版本',
|
||||
version: '版本',
|
||||
users: '用户数',
|
||||
lastUpdated: '更新时间',
|
||||
noVersionStatisticData: '暂无统计数据',
|
||||
},
|
||||
system: {
|
||||
custom: '自定义',
|
||||
@@ -1436,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}',
|
||||
@@ -1447,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: '语音音色',
|
||||
@@ -1545,6 +1564,8 @@ export default {
|
||||
subscribeStatisticShareHint: '分享订阅统计数据到热门订阅,供其他MPer参考',
|
||||
pluginStatisticShare: '上报插件安装数据',
|
||||
pluginStatisticShareHint: '上报插件安装数据给服务器,用于统计展示插件安装情况',
|
||||
usageStatisticShare: '上报安装版本统计',
|
||||
usageStatisticShareHint: '上报匿名安装ID和当前前后端版本,用于统计各版本安装用户数',
|
||||
workflowStatisticShare: '分享工作流数据',
|
||||
workflowStatisticShareHint: '分享工作流统计数据到热门工作流,供其他MPer参考',
|
||||
bigMemoryMode: '大内存模式',
|
||||
@@ -2423,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: '插件仓库保存成功',
|
||||
@@ -2567,6 +2596,7 @@ export default {
|
||||
previewSeasonInfo: '季信息',
|
||||
previewSeasonLabel: '季',
|
||||
previewEpisodeCount: '总集数',
|
||||
customWordsApplied: '识别词应用详情',
|
||||
previewAfterColumn: '整理后',
|
||||
previewBeforeColumn: '整理前',
|
||||
previewFileNameColumn: '文件名',
|
||||
|
||||
@@ -1374,6 +1374,18 @@ export default {
|
||||
expand: '展開',
|
||||
collapse: '收起',
|
||||
clearCache: '清除快取',
|
||||
versionStatistic: '版本統計',
|
||||
versionStatisticTitle: '安裝版本統計',
|
||||
totalInstallUsers: '安裝用戶',
|
||||
activeToday: '今日活躍',
|
||||
active7Days: '7日活躍',
|
||||
active30Days: '30日活躍',
|
||||
backendVersionStatistic: '後端版本',
|
||||
frontendVersionStatistic: '前端版本',
|
||||
version: '版本',
|
||||
users: '用戶數',
|
||||
lastUpdated: '更新時間',
|
||||
noVersionStatisticData: '暫無統計數據',
|
||||
},
|
||||
system: {
|
||||
custom: '自定義',
|
||||
@@ -1437,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}',
|
||||
@@ -1448,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: '語音音色',
|
||||
@@ -1546,6 +1565,8 @@ export default {
|
||||
subscribeStatisticShareHint: '分享訂閱統計數據到熱門訂閱,供其他MPer參考',
|
||||
pluginStatisticShare: '上報插件安裝數據',
|
||||
pluginStatisticShareHint: '上報插件安裝數據給服務器,用於統計展示插件安裝情況',
|
||||
usageStatisticShare: '上報安裝版本統計',
|
||||
usageStatisticShareHint: '上報匿名安裝ID和當前前後端版本,用於統計各版本安裝用戶數',
|
||||
workflowStatisticShare: '分享工作流數據',
|
||||
workflowStatisticShareHint: '分享工作流統計數據到熱門工作流,供其他MPer參考',
|
||||
bigMemoryMode: '大內存模式',
|
||||
@@ -2424,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: '插件倉庫儲存成功',
|
||||
@@ -2568,6 +2597,7 @@ export default {
|
||||
previewSeasonInfo: '季資訊',
|
||||
previewSeasonLabel: '季',
|
||||
previewEpisodeCount: '總集數',
|
||||
customWordsApplied: '識別詞應用詳情',
|
||||
previewAfterColumn: '整理後',
|
||||
previewBeforeColumn: '整理前',
|
||||
previewFileNameColumn: '文件名',
|
||||
|
||||
@@ -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,
|
||||
@@ -132,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' },
|
||||
])
|
||||
|
||||
// 刮削配置
|
||||
@@ -211,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
|
||||
@@ -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({
|
||||
get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
|
||||
set: value => {
|
||||
@@ -290,6 +310,8 @@ const {
|
||||
apiKey: llmApiKeyRef,
|
||||
baseUrl: llmBaseUrlRef,
|
||||
baseUrlPreset: llmBaseUrlPresetRef,
|
||||
useProxy: llmUseProxyRef,
|
||||
userAgent: llmUserAgentRef,
|
||||
model: llmModelRef,
|
||||
maxContextTokens: llmMaxContextRef,
|
||||
})
|
||||
@@ -350,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 ?? ''),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -640,9 +666,9 @@ async function loadSystemSettings() {
|
||||
if (result.data.hasOwnProperty(key)) (SystemSettings.value[sectionKey] as any)[key] = result.data[key]
|
||||
})
|
||||
}
|
||||
const accelEnabled = Boolean(result.data.RUST_ACCEL_ENABLED)
|
||||
rustAccelAvailable.value = accelEnabled
|
||||
if (!accelEnabled) SystemSettings.value.Advanced.RUST_ACCEL = false
|
||||
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()
|
||||
}
|
||||
@@ -1192,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"
|
||||
@@ -1306,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"
|
||||
@@ -1767,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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user