mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-07-12 16:01:35 +08:00
refactor: migrate all dialogs to a centralized shared dialog management system using useSharedDialog composable
This commit is contained in:
95
src/components/dialog/CacheReidentifyDialog.vue
Normal file
95
src/components/dialog/CacheReidentifyDialog.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
itemTitle?: string
|
||||
loading?: boolean
|
||||
modelValue?: boolean
|
||||
recognizeSource?: string
|
||||
}>(),
|
||||
{
|
||||
itemTitle: '',
|
||||
loading: false,
|
||||
modelValue: true,
|
||||
recognizeSource: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'confirm', payload: { doubanId?: string; tmdbId?: number }): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const tmdbId = ref<number | undefined>()
|
||||
const doubanId = ref<string | undefined>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 提交重新识别参数给缓存页执行接口调用。
|
||||
function submitReidentify() {
|
||||
emit('confirm', {
|
||||
doubanId: doubanId.value,
|
||||
tmdbId: tmdbId.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="35rem">
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon>mdi-text-recognition</VIcon>
|
||||
</template>
|
||||
<VCardTitle>{{ t('setting.cache.reidentifyDialog.title') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.itemTitle }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-if="props.recognizeSource === 'themoviedb'"
|
||||
v-model="tmdbId"
|
||||
:label="t('setting.cache.reidentifyDialog.tmdbId')"
|
||||
:hint="t('setting.cache.reidentifyDialog.tmdbIdHint')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-id-card"
|
||||
persistent-hint
|
||||
/>
|
||||
<VTextField
|
||||
v-else
|
||||
v-model="doubanId"
|
||||
:label="t('setting.cache.reidentifyDialog.doubanId')"
|
||||
:hint="t('setting.cache.reidentifyDialog.doubanIdHint')"
|
||||
clearable
|
||||
prepend-inner-icon="mdi-id-card"
|
||||
persistent-hint
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VAlert type="info" variant="tonal" class="mt-4">
|
||||
{{ t('setting.cache.reidentifyDialog.autoHint') }}
|
||||
</VAlert>
|
||||
</VCardText>
|
||||
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" :loading="props.loading" prepend-icon="mdi-check" @click="submitReidentify">
|
||||
{{ t('setting.cache.reidentifyDialog.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
237
src/components/dialog/ContentToggleSettingsDialog.vue
Normal file
237
src/components/dialog/ContentToggleSettingsDialog.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
|
||||
type UnknownRecord = Record<string, any>
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
enabled: Record<string, boolean>
|
||||
elevated?: boolean
|
||||
hint: string
|
||||
items: UnknownRecord[]
|
||||
labelGetter?: (item: UnknownRecord) => string
|
||||
modelValue?: boolean
|
||||
selectAllText?: string
|
||||
selectNoneText?: string
|
||||
showBulkActions?: boolean
|
||||
switchLabel?: string
|
||||
title: string
|
||||
valueGetter?: (item: UnknownRecord) => string
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
elevated: false,
|
||||
labelGetter: undefined,
|
||||
modelValue: true,
|
||||
selectAllText: '',
|
||||
selectNoneText: '',
|
||||
showBulkActions: false,
|
||||
switchLabel: '',
|
||||
valueGetter: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'save', payload: { elevated: boolean; enabled: Record<string, boolean> }): void
|
||||
(event: 'update:elevated', value: boolean): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const localEnabled = ref<Record<string, boolean>>({})
|
||||
const localElevated = ref(props.elevated)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const elevatedValue = computed({
|
||||
get: () => localElevated.value,
|
||||
set: value => {
|
||||
localElevated.value = value
|
||||
emit('update:elevated', value)
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.enabled, props.elevated, props.items],
|
||||
() => {
|
||||
resetLocalSettings()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
// 重置弹窗内部设置副本,避免直接修改父级 props。
|
||||
function resetLocalSettings() {
|
||||
localEnabled.value = { ...props.enabled }
|
||||
localElevated.value = props.elevated
|
||||
}
|
||||
|
||||
// 获取设置项的稳定键值。
|
||||
function getItemValue(item: UnknownRecord) {
|
||||
return props.valueGetter?.(item) ?? String(item.id ?? item.title ?? item.name ?? '')
|
||||
}
|
||||
|
||||
// 获取设置项展示名称。
|
||||
function getItemLabel(item: UnknownRecord) {
|
||||
return props.labelGetter?.(item) ?? String(item.attrs?.title ?? item.name ?? item.title ?? '')
|
||||
}
|
||||
|
||||
// 切换单个设置项的启用状态。
|
||||
function toggleItem(item: UnknownRecord) {
|
||||
const key = getItemValue(item)
|
||||
localEnabled.value[key] = !localEnabled.value[key]
|
||||
}
|
||||
|
||||
// 批量设置所有项目启用状态。
|
||||
function setAllItems(value: boolean) {
|
||||
props.items.forEach(item => {
|
||||
localEnabled.value[getItemValue(item)] = value
|
||||
})
|
||||
}
|
||||
|
||||
// 提交通用内容开关设置。
|
||||
function submitSettings() {
|
||||
emit('save', {
|
||||
elevated: localElevated.value,
|
||||
enabled: { ...localEnabled.value },
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="35rem" class="settings-dialog" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard class="settings-card">
|
||||
<VCardItem class="settings-card-header">
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-tune" size="small" class="me-2" />
|
||||
{{ props.title }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="settings-hint">{{ props.hint }}</p>
|
||||
<div class="settings-grid">
|
||||
<div
|
||||
v-for="item in props.items"
|
||||
:key="getItemValue(item)"
|
||||
class="setting-item"
|
||||
:class="{ 'enabled': localEnabled[getItemValue(item)] }"
|
||||
:style="{ '--item-color': props.colors[getItemValue(item)] }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<div class="setting-item-inner">
|
||||
<div class="setting-check">
|
||||
<VIcon
|
||||
:icon="localEnabled[getItemValue(item)] ? 'mdi-check-circle' : 'mdi-circle-outline'"
|
||||
:color="localEnabled[getItemValue(item)] ? 'primary' : undefined"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<span class="setting-label">{{ getItemLabel(item) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="props.switchLabel" class="mt-3">
|
||||
<VSwitch v-model="elevatedValue" :label="props.switchLabel" />
|
||||
</p>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(true)">
|
||||
{{ props.selectAllText }}
|
||||
</VBtn>
|
||||
<VBtn v-if="props.showBulkActions" variant="text" @click="setAllItems(false)">
|
||||
{{ props.selectNoneText }}
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" class="px-5" @click="submitSettings">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-card-header {
|
||||
padding-block: 16px;
|
||||
padding-inline: 20px;
|
||||
}
|
||||
|
||||
.settings-hint {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.9rem;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-on-surface), 0.8);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.3);
|
||||
cursor: pointer;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background: linear-gradient(90deg, var(--item-color, rgb(var(--v-theme-primary))) 0%, transparent 100%);
|
||||
content: '';
|
||||
inline-size: 3px;
|
||||
inset-block: 0;
|
||||
inset-inline-start: 0;
|
||||
opacity: 0.3;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.setting-item.enabled {
|
||||
border-color: rgba(var(--v-theme-primary), 0.4);
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
}
|
||||
|
||||
.setting-item.enabled::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.setting-check {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
80
src/components/dialog/CustomCssDialog.vue
Normal file
80
src/components/dialog/CustomCssDialog.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
css?: string
|
||||
editorTheme?: string
|
||||
modelValue?: boolean
|
||||
}>(),
|
||||
{
|
||||
css: '',
|
||||
editorTheme: 'monokai',
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'save', css: string): void
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 正在编辑的 CSS 内容
|
||||
const editableCSS = ref(props.css)
|
||||
|
||||
watch(
|
||||
() => props.css,
|
||||
value => {
|
||||
editableCSS.value = value
|
||||
},
|
||||
)
|
||||
|
||||
/** 提交当前 CSS 内容给调用方保存。 */
|
||||
function submitCustomCSS() {
|
||||
emit('save', editableCSS.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-palette" class="me-2" />
|
||||
{{ t('theme.custom') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VAceEditor v-model:value="editableCSS" lang="css" :theme="props.editorTheme" class="w-full min-h-[30rem]" />
|
||||
<VDivider />
|
||||
<VCardText class="text-center">
|
||||
<VBtn @click="submitCustomCSS" class="w-1/2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
209
src/components/dialog/CustomRuleInfoDialog.vue
Normal file
209
src/components/dialog/CustomRuleInfoDialog.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<script lang="ts" setup>
|
||||
import { innerFilterRules } from '@/api/constants'
|
||||
import type { CustomRule } from '@/api/types'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 单条规则
|
||||
rule: {
|
||||
type: Object as PropType<CustomRule>,
|
||||
required: true,
|
||||
},
|
||||
// 所有规则
|
||||
rules: {
|
||||
type: Array as PropType<CustomRule[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const ruleInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 规则详情
|
||||
const ruleInfo = ref<CustomRule>({
|
||||
id: '',
|
||||
name: '',
|
||||
include: '',
|
||||
exclude: '',
|
||||
size_range: '',
|
||||
seeders: '',
|
||||
publish_time: '',
|
||||
})
|
||||
|
||||
/** 初始化规则编辑表单数据。 */
|
||||
function initializeRuleInfo() {
|
||||
ruleInfo.value = cloneDeep(props.rule)
|
||||
}
|
||||
|
||||
/** 保存规则编辑结果并通知父级刷新。 */
|
||||
function saveRuleInfo() {
|
||||
if (!ruleInfo.value.id || !ruleInfo.value.name) {
|
||||
if (!ruleInfo.value.id && !ruleInfo.value.name) {
|
||||
$toast.error(t('customRule.error.emptyIdName'))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (innerFilterRules.find(option => option.value === ruleInfo.value.id)) {
|
||||
$toast.error(t('customRule.error.idOccupied'))
|
||||
return
|
||||
}
|
||||
if (innerFilterRules.find(option => option.title === ruleInfo.value.name)) {
|
||||
$toast.error(t('customRule.error.nameOccupied'))
|
||||
return
|
||||
}
|
||||
if (ruleInfo.value.id !== props.rule.id && props.rules.find(rule => rule.id === ruleInfo.value.id)) {
|
||||
$toast.error(t('customRule.error.idExists', { id: ruleInfo.value.id }))
|
||||
return
|
||||
}
|
||||
if (ruleInfo.value.name !== props.rule.name && props.rules.find(rule => rule.name === ruleInfo.value.name)) {
|
||||
$toast.error(t('customRule.error.nameExists', { name: ruleInfo.value.name }))
|
||||
return
|
||||
}
|
||||
ruleInfoDialog.value = false
|
||||
emit('change', ruleInfo.value, props.rule.id)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 规范化规则 ID 输入,只保留英文和数字。 */
|
||||
function validateRuleId() {
|
||||
ruleInfo.value.id = ruleInfo.value.id.replace(/[^a-zA-Z0-9]/g, '')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeRuleInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-if="ruleInfoDialog"
|
||||
v-model="ruleInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-filter-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('customRule.title', { id: props.rule.id }) }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="ruleInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.id"
|
||||
:label="t('customRule.field.ruleId')"
|
||||
:placeholder="t('customRule.placeholder.ruleId')"
|
||||
:hint="t('customRule.hint.ruleId')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
@input="validateRuleId"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.name"
|
||||
:label="t('customRule.field.ruleName')"
|
||||
:placeholder="t('customRule.placeholder.ruleName')"
|
||||
:hint="t('customRule.hint.ruleName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.include"
|
||||
:label="t('customRule.field.include')"
|
||||
:placeholder="t('customRule.placeholder.include')"
|
||||
:hint="t('customRule.hint.include')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-plus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="ruleInfo.exclude"
|
||||
:label="t('customRule.field.exclude')"
|
||||
:placeholder="t('customRule.placeholder.exclude')"
|
||||
:hint="t('customRule.hint.exclude')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-minus-circle"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.size_range"
|
||||
:label="t('customRule.field.sizeRange')"
|
||||
:placeholder="t('customRule.placeholder.sizeRange')"
|
||||
:hint="t('customRule.hint.sizeRange')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-harddisk"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.seeders"
|
||||
:label="t('customRule.field.seeders')"
|
||||
:placeholder="t('customRule.placeholder.seeders')"
|
||||
:hint="t('customRule.hint.seeders')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account-group"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6">
|
||||
<VTextField
|
||||
v-model="ruleInfo.publish_time"
|
||||
:label="t('customRule.field.publishTime')"
|
||||
:placeholder="t('customRule.placeholder.publishTime')"
|
||||
:hint="t('customRule.hint.publishTime')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-calendar-clock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveRuleInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('customRule.action.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
161
src/components/dialog/DiscoverTabOrderDialog.vue
Normal file
161
src/components/dialog/DiscoverTabOrderDialog.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import draggable from 'vuedraggable'
|
||||
import type { DiscoverSource } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
colors?: Record<string, string>
|
||||
modelValue?: boolean
|
||||
tabs: DiscoverSource[]
|
||||
}>(),
|
||||
{
|
||||
colors: () => ({}),
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'save', tabs: DiscoverSource[]): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const localTabs = ref<DiscoverSource[]>([])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.tabs,
|
||||
() => {
|
||||
resetLocalTabs()
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
// 重置弹窗内部排序副本。
|
||||
function resetLocalTabs() {
|
||||
localTabs.value = props.tabs.map(item => ({ ...item }))
|
||||
}
|
||||
|
||||
// 保存当前拖拽后的发现标签顺序。
|
||||
function submitOrder() {
|
||||
emit('save', localTabs.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="35rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-order-alphabetical-ascending" size="small" class="me-2" />
|
||||
{{ t('discover.setTabOrder') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<p class="settings-hint">{{ t('discover.dragToReorder') }}</p>
|
||||
<draggable
|
||||
v-model="localTabs"
|
||||
handle=".cursor-move"
|
||||
item-key="mediaid_prefix"
|
||||
tag="div"
|
||||
:component-data="{ 'class': 'settings-grid' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<VCard
|
||||
variant="text"
|
||||
class="setting-item enabled"
|
||||
:style="{ '--item-color': props.colors[element.mediaid_prefix] }"
|
||||
>
|
||||
<div class="setting-item-inner">
|
||||
<span class="setting-label">{{ element.name }}</span>
|
||||
<VIcon icon="mdi-drag" class="drag-icon cursor-move" />
|
||||
</div>
|
||||
</VCard>
|
||||
</template>
|
||||
</draggable>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn @click="submitOrder">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-save" />
|
||||
</template>
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-hint {
|
||||
color: rgba(var(--v-theme-on-surface), 0.7);
|
||||
font-size: 0.9rem;
|
||||
margin-block-end: 16px;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-primary), 0.3);
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
cursor: grab;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.setting-item::before {
|
||||
position: absolute;
|
||||
background-color: var(--item-color, #4caf50);
|
||||
block-size: 100%;
|
||||
content: '';
|
||||
inline-size: 4px;
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
.setting-item-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
flex: 1;
|
||||
color: rgba(var(--v-theme-primary), 0.9);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.drag-icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@media (width <= 600px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
507
src/components/dialog/DownloaderInfoDialog.vue
Normal file
507
src/components/dialog/DownloaderInfoDialog.vue
Normal file
@@ -0,0 +1,507 @@
|
||||
<script setup lang="ts">
|
||||
import type { DownloaderConf } from '@/api/types'
|
||||
import { storageAttributes } from '@/api/constants'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
downloader: {
|
||||
type: Object as PropType<DownloaderConf>,
|
||||
required: true,
|
||||
},
|
||||
downloaders: {
|
||||
type: Array as PropType<DownloaderConf[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 表单
|
||||
const downloaderForm = ref()
|
||||
|
||||
// 下载器详情弹窗
|
||||
const downloaderInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 下载器详情
|
||||
const downloaderInfo = ref<DownloaderConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
default: false,
|
||||
enabled: false,
|
||||
config: {},
|
||||
path_mapping: [],
|
||||
})
|
||||
|
||||
// 路径映射行定义
|
||||
interface PathMappingRow {
|
||||
id: string
|
||||
storage: string
|
||||
download: string
|
||||
}
|
||||
|
||||
// 路径映射行数据
|
||||
const pathMappingRows = ref<PathMappingRow[]>([])
|
||||
|
||||
// 路径前缀选项
|
||||
const prefixOptions = computed(() => {
|
||||
return storageAttributes.map(item => ({
|
||||
title: t(`storage.${item.type}`),
|
||||
value: item.type,
|
||||
}))
|
||||
})
|
||||
|
||||
/** 获取路径所属的存储类型。 */
|
||||
function getStorageType(path: string) {
|
||||
if (!path) return 'local'
|
||||
const storage = storageAttributes.find(s => s.type !== 'local' && path.startsWith(`${s.type}:`))
|
||||
return storage?.type || 'local'
|
||||
}
|
||||
|
||||
/** 将存储类型转换为路径前缀。 */
|
||||
function storage2Prefix(storage: string) {
|
||||
return storage === 'local' ? '' : storage + ':'
|
||||
}
|
||||
|
||||
/** 拆分存储路径的前缀和真实路径。 */
|
||||
function parseStoragePath(path: string): [prefix: string, suffix: string] {
|
||||
if (!path) return ['', '']
|
||||
const storage = getStorageType(path)
|
||||
const prefix = storage2Prefix(storage)
|
||||
return [prefix, path.slice(prefix.length)]
|
||||
}
|
||||
|
||||
/** 更新单行路径映射的存储前缀。 */
|
||||
function updateStoragePrefix(row: PathMappingRow, storage: string) {
|
||||
const [, currentSuffix] = parseStoragePath(row.storage)
|
||||
const prefix = storage2Prefix(storage)
|
||||
row.storage = prefix + currentSuffix
|
||||
}
|
||||
|
||||
/** 更新单行路径映射的存储路径主体。 */
|
||||
function updateStorageSuffix(row: PathMappingRow, suffix: string) {
|
||||
const [currentPrefix] = parseStoragePath(row.storage)
|
||||
row.storage = currentPrefix + suffix
|
||||
}
|
||||
|
||||
const pathValidationRules = [
|
||||
(v: string) => !!v || t('downloader.pathMappingRequired'),
|
||||
(v: string) => v.startsWith('/') || t('downloader.pathMappingError'),
|
||||
]
|
||||
|
||||
/** 生成路径映射行使用的临时唯一 ID。 */
|
||||
function generateId() {
|
||||
return Math.random().toString(36).substring(2, 9)
|
||||
}
|
||||
|
||||
/** 初始化下载器编辑表单数据。 */
|
||||
function initializeDownloaderInfo() {
|
||||
downloaderInfo.value = cloneDeep(props.downloader)
|
||||
pathMappingRows.value = (downloaderInfo.value.path_mapping || []).map(item => ({
|
||||
id: generateId(),
|
||||
storage: item[0],
|
||||
download: item[1],
|
||||
}))
|
||||
}
|
||||
|
||||
/** 保存下载器编辑结果并通知父级刷新。 */
|
||||
async function saveDownloaderInfo() {
|
||||
const { valid } = (await downloaderForm.value?.validate()) || { valid: true }
|
||||
if (!valid) return
|
||||
|
||||
downloaderInfo.value.path_mapping = pathMappingRows.value.map(row => [row.storage, row.download])
|
||||
|
||||
if (!downloaderInfo.value.name) {
|
||||
$toast.error(t('downloader.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (props.downloaders.some(item => item.name === downloaderInfo.value.name && item !== props.downloader)) {
|
||||
$toast.error(t('downloader.nameDuplicate'))
|
||||
return
|
||||
}
|
||||
if (downloaderInfo.value.default) {
|
||||
props.downloaders.forEach(item => {
|
||||
if (item.default && item !== props.downloader) {
|
||||
item.default = false
|
||||
$toast.info(t('downloader.defaultChanged'))
|
||||
}
|
||||
})
|
||||
}
|
||||
downloaderInfoDialog.value = false
|
||||
emit('change', downloaderInfo.value, props.downloader.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 新增一行路径映射。 */
|
||||
function addPathMapping() {
|
||||
pathMappingRows.value.push({
|
||||
id: generateId(),
|
||||
storage: '',
|
||||
download: '',
|
||||
})
|
||||
}
|
||||
|
||||
/** 移除指定位置的路径映射。 */
|
||||
function removePathMapping(index: number) {
|
||||
pathMappingRows.value.splice(index, 1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeDownloaderInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-if="downloaderInfoDialog"
|
||||
v-model="downloaderInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-download" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('common.config') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.downloader.name }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="downloaderInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm ref="downloaderForm">
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="downloaderInfo.enabled" :label="t('downloader.enabled')" />
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.default"
|
||||
:label="t('downloader.default')"
|
||||
:disabled="!downloaderInfo.enabled"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="downloaderInfo.type == 'qbittorrent'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:placeholder="t('downloader.nameRequired')"
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
:label="t('downloader.host')"
|
||||
placeholder="http(s)://ip:port"
|
||||
:hint="t('downloader.host')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.apikey"
|
||||
type="password"
|
||||
:label="t('downloader.apiKey')"
|
||||
:hint="t('downloader.qbittorrentApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key-variant"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
:disabled="!!downloaderInfo.config.apikey"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.category"
|
||||
:label="t('downloader.category')"
|
||||
:hint="t('downloader.category')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.sequentail"
|
||||
:label="t('downloader.sequentail')"
|
||||
:hint="t('downloader.sequentail')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.force_resume"
|
||||
:label="t('downloader.force_resume')"
|
||||
:hint="t('downloader.force_resume')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="downloaderInfo.config.first_last_piece"
|
||||
:label="t('downloader.first_last_piece')"
|
||||
:hint="t('downloader.first_last_piece')"
|
||||
persistent-hint
|
||||
active
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'transmission'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:placeholder="t('downloader.nameRequired')"
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
:label="t('downloader.host')"
|
||||
placeholder="http(s)://ip:port"
|
||||
:hint="t('downloader.host')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="downloaderInfo.type == 'rtorrent'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:placeholder="t('downloader.nameRequired')"
|
||||
:hint="t('downloader.name')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.host"
|
||||
:label="t('downloader.host')"
|
||||
placeholder="http(s)://ip:port/RPC2"
|
||||
:hint="t('downloader.rtorrentHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.username"
|
||||
:label="t('downloader.username')"
|
||||
:hint="t('downloader.username')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.config.password"
|
||||
type="password"
|
||||
:label="t('downloader.password')"
|
||||
:hint="t('downloader.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.type"
|
||||
:label="t('downloader.type')"
|
||||
:hint="t('downloader.customTypeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="downloaderInfo.name"
|
||||
:label="t('downloader.name')"
|
||||
:hint="t('downloader.nameRequired')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VDivider class="my-2">
|
||||
<span class="text-body-1 font-weight-medium">{{ t('downloader.pathMapping') }}</span>
|
||||
</VDivider>
|
||||
|
||||
<div v-if="pathMappingRows.length === 0" class="text-center py-2">
|
||||
<VIcon icon="mdi-folder-network" size="48" class="text-disabled mb-1" />
|
||||
<div class="text-body-2 text-disabled">{{ t('common.noData') }}</div>
|
||||
</div>
|
||||
|
||||
<VCard v-for="(row, index) in pathMappingRows" :key="row.id" variant="outlined" class="my-2">
|
||||
<VCardText class="pa-3">
|
||||
<VRow align="center" no-gutters>
|
||||
<VCol cols="12" class="mb-2">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-folder-outline" size="18" class="me-1 text-primary" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.storagePath') }}</span>
|
||||
</div>
|
||||
<VRow no-gutters>
|
||||
<VCol cols="12" sm="4" class="pe-2">
|
||||
<VSelect
|
||||
:model-value="getStorageType(row.storage)"
|
||||
:items="prefixOptions"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@update:model-value="v => updateStoragePrefix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" sm="8">
|
||||
<VTextField
|
||||
:model-value="parseStoragePath(row.storage)[1]"
|
||||
:placeholder="'/path/to/storage'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
@update:model-value="v => updateStorageSuffix(row, v)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="mb-1">
|
||||
<div class="d-flex align-center justify-center my-1">
|
||||
<VIcon icon="mdi-arrow-down" size="18" class="text-medium-emphasis" />
|
||||
</div>
|
||||
<div class="d-flex align-center mb-1">
|
||||
<VIcon icon="mdi-download-outline" size="18" class="me-1 text-success" />
|
||||
<span class="text-caption text-medium-emphasis">{{ t('downloader.downloadPath') }}</span>
|
||||
</div>
|
||||
<VTextField
|
||||
v-model="row.download"
|
||||
:placeholder="'/path/to/download'"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details="auto"
|
||||
:rules="pathValidationRules"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" class="d-flex justify-end pt-1">
|
||||
<IconBtn variant="text" color="error" size="small" @click="removePathMapping(index)">
|
||||
<VIcon icon="mdi-delete-outline" />
|
||||
</IconBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VBtn
|
||||
variant="tonal"
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus-circle-outline"
|
||||
@click="addPathMapping"
|
||||
class="mt-1"
|
||||
size="small"
|
||||
>
|
||||
{{ t('common.add') }} {{ t('downloader.pathMapping') }}
|
||||
</VBtn>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveDownloaderInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
63
src/components/dialog/FileNewFolderDialog.vue
Normal file
63
src/components/dialog/FileNewFolderDialog.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'create'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:name', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const folderName = computed({
|
||||
get: () => props.name,
|
||||
set: value => emit('update:name', value),
|
||||
})
|
||||
|
||||
// 关闭新建目录弹窗并通知共享弹窗 Host 回收实例。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="35rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-folder-plus-outline" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('file.newFolder') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField v-model="folderName" :label="t('common.name')" prepend-inner-icon="mdi-format-text" />
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<div class="flex-grow-1" />
|
||||
<VBtn :disabled="!folderName" prepend-icon="mdi-folder-plus" class="px-5 me-3" @click="emit('create')">
|
||||
{{ t('common.create') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
94
src/components/dialog/FileRenameDialog.vue
Normal file
94
src/components/dialog/FileRenameDialog.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script lang="ts" setup>
|
||||
import type { FileItem } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
item: Object as PropType<FileItem>,
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
recursive: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'auto-name'): void
|
||||
(event: 'close'): void
|
||||
(event: 'rename'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:name', value: string): void
|
||||
(event: 'update:recursive', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const renameName = computed({
|
||||
get: () => props.name,
|
||||
set: value => emit('update:name', value),
|
||||
})
|
||||
|
||||
const includeSubfolders = computed({
|
||||
get: () => props.recursive,
|
||||
set: value => emit('update:recursive', value),
|
||||
})
|
||||
|
||||
// 关闭弹窗并通知共享弹窗 Host 回收当前实例。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="35rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-pencil" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('file.rename') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="renameName"
|
||||
:label="t('file.newName')"
|
||||
:loading="loading"
|
||||
prepend-inner-icon="mdi-format-text"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol v-if="item && item.type == 'dir'" cols="12">
|
||||
<VSwitch v-model="includeSubfolders" :label="t('file.includeSubfolders')" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VBtn color="success" prepend-icon="mdi-magic" class="px-5 me-3" @click="emit('auto-name')">
|
||||
{{ t('file.autoRecognizeName') }}
|
||||
</VBtn>
|
||||
<VBtn :disabled="!renameName" prepend-icon="mdi-check" class="px-5 me-3" @click="emit('rename')">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
314
src/components/dialog/FilterRuleGroupInfoDialog.vue
Normal file
314
src/components/dialog/FilterRuleGroupInfoDialog.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<script lang="ts" setup>
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import { CustomRule, FilterRuleGroup } from '@/api/types'
|
||||
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
|
||||
// 规则组详情弹窗内才需要拖拽和导入代码,避免规则组卡片列表首屏带入重交互依赖。
|
||||
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
|
||||
const ImportCodeDialog = defineAsyncComponent(() => import('@/components/dialog/ImportCodeDialog.vue'))
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
// 单个规则组
|
||||
group: {
|
||||
type: Object as PropType<FilterRuleGroup>,
|
||||
required: true,
|
||||
},
|
||||
// 所有规则组
|
||||
groups: {
|
||||
type: Array as PropType<FilterRuleGroup[]>,
|
||||
required: true,
|
||||
},
|
||||
// 媒体类型字典
|
||||
categories: {
|
||||
type: Object as PropType<{ [key: string]: any }>,
|
||||
required: true,
|
||||
},
|
||||
// 自定义规则列表
|
||||
custom_rules: Array as PropType<CustomRule[]>,
|
||||
})
|
||||
|
||||
// 规则卡片类型
|
||||
interface FilterCard {
|
||||
// 优先级
|
||||
pri: string
|
||||
// 已选规则
|
||||
rules: string[]
|
||||
}
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'change', 'done'])
|
||||
|
||||
// 规则详情弹窗
|
||||
const groupInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 规则详情
|
||||
const groupInfo = ref<FilterRuleGroup>({
|
||||
name: props.group?.name ?? '',
|
||||
rule_string: props.group?.rule_string ?? '',
|
||||
media_type: props.group?.media_type ?? '',
|
||||
category: props.group?.category ?? '',
|
||||
})
|
||||
|
||||
// 媒体类型字典
|
||||
const mediaTypeItems = [
|
||||
{ title: t('common.all'), value: '' },
|
||||
{ title: t('mediaType.movie'), value: '电影' },
|
||||
{ title: t('mediaType.tv'), value: '电视剧' },
|
||||
]
|
||||
|
||||
// 根据选中的媒体类型,获取对应的媒体类别
|
||||
const getCategories = computed(() => {
|
||||
const default_value = [{ title: t('common.all'), value: '' }]
|
||||
if (!props.categories || !groupInfo.value.media_type || !props.categories[groupInfo.value.media_type]) {
|
||||
return default_value
|
||||
}
|
||||
return default_value.concat(props.categories[groupInfo.value.media_type] || [])
|
||||
})
|
||||
|
||||
// 规则组规则卡片列表
|
||||
const filterRuleCards = ref<FilterCard[]>([])
|
||||
|
||||
|
||||
/** 更新指定优先级规则卡片的选中规则。 */
|
||||
function updateFilterCardValue(pri: string, rules: string[]) {
|
||||
const card = filterRuleCards.value.find(card => card.pri === pri)
|
||||
if (card && Array.isArray(rules)) card.rules = rules
|
||||
}
|
||||
|
||||
/** 移除指定优先级规则卡片并重排优先级。 */
|
||||
function filterCardClose(pri: string) {
|
||||
filterRuleCards.value = filterRuleCards.value
|
||||
.filter(card => card.pri !== pri)
|
||||
.map((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
return card
|
||||
})
|
||||
}
|
||||
|
||||
/** 将当前规则组规则串复制到剪贴板。 */
|
||||
async function shareRules() {
|
||||
if (filterRuleCards.value.length === 0) return
|
||||
|
||||
const value = filterRuleCards.value
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
|
||||
try {
|
||||
let success
|
||||
success = copyToClipboard(value)
|
||||
if (await success) $toast.success(t('filterRule.shareSuccess'))
|
||||
else $toast.error(t('filterRule.shareFailed'))
|
||||
} catch (error) {
|
||||
$toast.error(t('filterRule.shareFailed'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 打开共享导入弹窗并导入规则串。 */
|
||||
async function importRules(ruleType: string) {
|
||||
openSharedDialog(
|
||||
ImportCodeDialog,
|
||||
{
|
||||
title: t('filterRule.import'),
|
||||
dataType: ruleType,
|
||||
},
|
||||
{
|
||||
save: saveCodeString,
|
||||
},
|
||||
{ closeOn: ['close', 'save'] },
|
||||
)
|
||||
}
|
||||
|
||||
/** 保存导入的规则代码并覆盖当前规则卡片。 */
|
||||
function saveCodeString(type: string, code: any) {
|
||||
try {
|
||||
code = code.value
|
||||
if (type === 'priority') {
|
||||
// 解析值
|
||||
if (!code) return
|
||||
// 首尾增加空格
|
||||
if (!code.startsWith(' ')) code = ` ${code}`
|
||||
if (!code.endsWith(' ')) code = `${code} `
|
||||
const groups = code.split('>')
|
||||
filterRuleCards.value = groups.map((group: string, index: number) => ({
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&').filter(rule => rule),
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
$toast.error(t('filterRule.importFailed'))
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** 新增一个空的规则优先级卡片。 */
|
||||
function addFilterCard() {
|
||||
const pri = (filterRuleCards.value.length + 1).toString()
|
||||
const newCard: FilterCard = { pri, rules: [] }
|
||||
filterRuleCards.value.push(newCard)
|
||||
}
|
||||
|
||||
/** 根据列表的拖动顺序更新优先级。 */
|
||||
function dragOrderEnd() {
|
||||
filterRuleCards.value.forEach((card, index) => {
|
||||
card.pri = (index + 1).toString()
|
||||
})
|
||||
}
|
||||
|
||||
/** 初始化规则组编辑数据。 */
|
||||
function opengroupInfoDialog() {
|
||||
groupInfo.value = cloneDeep(props.group)
|
||||
if (props.group.rule_string) {
|
||||
filterRuleCards.value = props.group.rule_string.split('>').map((group: string, index: number) => ({
|
||||
pri: (index + 1).toString(),
|
||||
rules: group.split('&').filter(rule => rule),
|
||||
}))
|
||||
}
|
||||
groupInfoDialog.value = true
|
||||
}
|
||||
|
||||
/** 保存规则组编辑结果并通知父级刷新。 */
|
||||
function saveGroupInfo() {
|
||||
if (!groupInfo.value.name.trim()) {
|
||||
$toast.error(t('filterRule.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (props.groups.some(item => item.name === groupInfo.value.name && item !== props.group)) {
|
||||
$toast.error(t('filterRule.nameDuplicate'))
|
||||
return
|
||||
}
|
||||
|
||||
groupInfoDialog.value = false
|
||||
groupInfo.value.rule_string = filterRuleCards.value
|
||||
.filter(card => Array.isArray(card.rules) && card.rules.length > 0)
|
||||
.map(card => card.rules.join('&'))
|
||||
.join('>')
|
||||
emit('change', groupInfo.value, props.group.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 关闭规则组编辑弹窗。 */
|
||||
function onClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
opengroupInfoDialog()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-if="groupInfoDialog"
|
||||
v-model="groupInfoDialog"
|
||||
scrollable
|
||||
max-width="80rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard :title="`${props.group.name} - ${t('filterRule.title')}`">
|
||||
<VDialogCloseBtn v-model="groupInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardItem class="pt-1">
|
||||
<VRow class="mt-1">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="groupInfo.name"
|
||||
:label="t('filterRule.groupName')"
|
||||
:placeholder="t('filterRule.nameRequired')"
|
||||
:hint="t('filterRule.groupName')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VAutocomplete
|
||||
v-model="groupInfo.media_type"
|
||||
:label="t('filterRule.mediaType')"
|
||||
:items="mediaTypeItems"
|
||||
:hint="t('filterRule.mediaType')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-movie-open"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="6" md="3">
|
||||
<VAutocomplete
|
||||
v-model="groupInfo.category"
|
||||
:items="getCategories"
|
||||
:label="t('filterRule.category')"
|
||||
:hint="t('filterRule.category')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-folder-open"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<Draggable
|
||||
v-model="filterRuleCards"
|
||||
handle=".cursor-move"
|
||||
item-key="pri"
|
||||
tag="div"
|
||||
@end="dragOrderEnd"
|
||||
:component-data="{ 'class': 'grid gap-3 grid-filterrule-card' }"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<FilterRuleCard
|
||||
:pri="element.pri"
|
||||
:maxpri="filterRuleCards.length.toString()"
|
||||
:rules="element.rules"
|
||||
:custom_rules="props.custom_rules"
|
||||
@changed="updateFilterCardValue"
|
||||
@close="filterCardClose(element.pri)"
|
||||
/>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div class="text-center" v-if="filterRuleCards.length == 0">{{ t('filterRule.add') }}</div>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn color="primary" @click="addFilterCard">
|
||||
<VIcon icon="mdi-plus" />
|
||||
</VBtn>
|
||||
<VBtn color="success" @click="importRules('priority')">
|
||||
<VIcon icon="mdi-import" />
|
||||
</VBtn>
|
||||
<VBtn color="info" @click="shareRules">
|
||||
<VIcon icon="mdi-share" />
|
||||
</VBtn>
|
||||
<VSpacer />
|
||||
<VBtn @click="saveGroupInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
82
src/components/dialog/LlmProviderAuthDialog.vue
Normal file
82
src/components/dialog/LlmProviderAuthDialog.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import type { LlmProviderAuthSession } from '@/composables/useLlmProviderDirectory'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
authSession?: LlmProviderAuthSession | null
|
||||
modelValue?: boolean
|
||||
polling?: boolean
|
||||
popupBlocked?: boolean
|
||||
}>(),
|
||||
{
|
||||
authSession: null,
|
||||
modelValue: true,
|
||||
polling: false,
|
||||
popupBlocked: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'openAuthPage'): void
|
||||
(event: 'poll'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 关闭授权弹窗并通知调用方停止轮询。
|
||||
function closeDialog() {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="560">
|
||||
<VCard>
|
||||
<VCardTitle>{{ t('setting.system.llmProviderAuthDialogTitle') }}</VCardTitle>
|
||||
<VCardText class="d-flex flex-column ga-4">
|
||||
<VAlert v-if="props.authSession?.instructions" type="info" variant="tonal">
|
||||
{{ props.authSession.instructions }}
|
||||
</VAlert>
|
||||
|
||||
<VAlert v-if="props.popupBlocked" type="warning" variant="tonal">
|
||||
{{ t('setting.system.llmProviderPopupBlocked') }}
|
||||
</VAlert>
|
||||
|
||||
<div v-if="props.authSession?.user_code">
|
||||
<div class="text-caption text-medium-emphasis mb-1">{{ t('setting.system.llmProviderDeviceCode') }}</div>
|
||||
<div class="text-h5 font-weight-bold">{{ props.authSession.user_code }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="props.authSession?.message" class="text-body-2">
|
||||
{{ props.authSession.message }}
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<VBtn color="primary" prepend-icon="mdi-open-in-new" @click="emit('openAuthPage')">
|
||||
{{ t('setting.system.llmProviderOpenAuthPage') }}
|
||||
</VBtn>
|
||||
<VBtn variant="tonal" prepend-icon="mdi-refresh" :loading="props.polling" @click="emit('poll')">
|
||||
{{ t('setting.system.llmProviderCheckAuthStatus') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" @click="closeDialog">
|
||||
{{ t('common.close') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
102
src/components/dialog/LoginMfaDialog.vue
Normal file
102
src/components/dialog/LoginMfaDialog.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
errorMessage?: string
|
||||
modelValue?: boolean
|
||||
otpPassword?: string
|
||||
passkeyLoading?: boolean
|
||||
}>(),
|
||||
{
|
||||
errorMessage: '',
|
||||
modelValue: true,
|
||||
otpPassword: '',
|
||||
passkeyLoading: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'otp'): void
|
||||
(event: 'passkey'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:otpPassword', value: string): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const otpValue = computed({
|
||||
get: () => props.otpPassword,
|
||||
set: value => emit('update:otpPassword', value),
|
||||
})
|
||||
|
||||
// 提交 OTP 登录请求。
|
||||
function submitOtp() {
|
||||
emit('otp')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="400" persistent>
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 text-center mt-4 pb-2">{{ t('login.secondaryVerification') }}</VCardTitle>
|
||||
<VCardText class="pt-0">
|
||||
<p class="text-center mb-4">{{ t('login.mfa.selectVerificationMethod') }}</p>
|
||||
|
||||
<VCard variant="tonal" class="mb-3">
|
||||
<VCardText>
|
||||
<VForm @submit.prevent="submitOtp">
|
||||
<VTextField
|
||||
v-model="otpValue"
|
||||
:label="t('login.otpCode')"
|
||||
:placeholder="t('login.otpPlaceholder')"
|
||||
type="text"
|
||||
name="otp"
|
||||
id="otp"
|
||||
autocomplete="one-time-code"
|
||||
inputmode="numeric"
|
||||
prepend-inner-icon="mdi-shield-key"
|
||||
class="mb-2"
|
||||
/>
|
||||
<VBtn block type="submit" color="primary" :disabled="!otpValue">
|
||||
{{ t('login.loginWithOtp') }}
|
||||
</VBtn>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VCard variant="tonal">
|
||||
<VCardText>
|
||||
<p class="text-body-2 mb-2">{{ t('login.orUsePasskey') }}</p>
|
||||
<VBtn
|
||||
block
|
||||
variant="tonal"
|
||||
color="success"
|
||||
class="passkey-btn"
|
||||
prepend-icon="material-symbols:passkey"
|
||||
:loading="props.passkeyLoading"
|
||||
@click="emit('passkey')"
|
||||
>
|
||||
{{ t('login.verifyWithPasskey') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
|
||||
<VAlert v-if="props.errorMessage" type="error" variant="tonal" class="mt-3">
|
||||
{{ props.errorMessage }}
|
||||
</VAlert>
|
||||
|
||||
<VBtn block variant="text" class="mt-4" @click="visible = false">{{ t('common.cancel') }}</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
601
src/components/dialog/MediaServerInfoDialog.vue
Normal file
601
src/components/dialog/MediaServerInfoDialog.vue
Normal file
@@ -0,0 +1,601 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import type { MediaServerConf, MediaServerLibrary } from '@/api/types'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 获取i18n实例
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
mediaserver: {
|
||||
type: Object as PropType<MediaServerConf>,
|
||||
required: true,
|
||||
},
|
||||
mediaservers: {
|
||||
type: Array as PropType<MediaServerConf[]>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'done', 'change'])
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 媒体服务器详情弹窗
|
||||
const mediaServerInfoDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 媒体服务器详情
|
||||
const mediaServerInfo = ref<MediaServerConf>({
|
||||
name: '',
|
||||
type: '',
|
||||
enabled: false,
|
||||
config: {},
|
||||
})
|
||||
|
||||
// 同步媒体库选项
|
||||
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
|
||||
{
|
||||
title: t('common.all'),
|
||||
value: 'all',
|
||||
},
|
||||
])
|
||||
|
||||
const ugreenScanModeOptions = computed(() => [
|
||||
{ title: t('mediaserver.scanModeOptions.newAndModified'), value: 'new_and_modified' },
|
||||
{ title: t('mediaserver.scanModeOptions.supplementMissing'), value: 'supplement_missing' },
|
||||
{ title: t('mediaserver.scanModeOptions.fullOverride'), value: 'full_override' },
|
||||
])
|
||||
|
||||
/** 初始化媒体服务器编辑表单数据。 */
|
||||
function initializeMediaServerInfo() {
|
||||
loadLibrary(props.mediaserver.name)
|
||||
mediaServerInfo.value = cloneDeep(props.mediaserver)
|
||||
if (mediaServerInfo.value.type === 'ugreen') {
|
||||
mediaServerInfo.value.config = mediaServerInfo.value.config || {}
|
||||
if (!mediaServerInfo.value.config.scan_mode) {
|
||||
mediaServerInfo.value.config.scan_mode = 'supplement_missing'
|
||||
}
|
||||
if (mediaServerInfo.value.config.verify_ssl === undefined) {
|
||||
mediaServerInfo.value.config.verify_ssl = true
|
||||
}
|
||||
}
|
||||
if (!props.mediaserver.sync_libraries) {
|
||||
mediaServerInfo.value.sync_libraries = ['all']
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存媒体服务器编辑结果并通知父级刷新。 */
|
||||
function saveMediaServerInfo() {
|
||||
if (!mediaServerInfo.value.name) {
|
||||
$toast.error(t('common.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (props.mediaservers.some(item => item.name === mediaServerInfo.value.name && item !== props.mediaserver)) {
|
||||
$toast.error(t('common.nameExists', { name: mediaServerInfo.value.name }))
|
||||
return
|
||||
}
|
||||
mediaServerInfoDialog.value = false
|
||||
emit('change', mediaServerInfo.value, props.mediaserver.name)
|
||||
emit('done')
|
||||
}
|
||||
|
||||
/** 调用 API 查询指定媒体服务器的媒体库列表。 */
|
||||
async function loadLibrary(server: string) {
|
||||
try {
|
||||
const result: MediaServerLibrary[] = await api.get('mediaserver/library', { params: { server } })
|
||||
if (result && result.length > 0) {
|
||||
librariesOptions.value = result.map(item => ({
|
||||
title: item.name,
|
||||
value: item.id?.toString(),
|
||||
}))
|
||||
} else {
|
||||
librariesOptions.value = []
|
||||
}
|
||||
librariesOptions.value.unshift({
|
||||
title: t('common.all'),
|
||||
value: 'all',
|
||||
})
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeMediaServerInfo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-if="mediaServerInfoDialog"
|
||||
v-model="mediaServerInfoDialog"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('common.config') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ props.mediaserver.name }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="mediaServerInfoDialog" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch v-model="mediaServerInfo.enabled" :label="t('mediaserver.enableMediaServer')" />
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-if="mediaServerInfo.type == 'emby'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.apikey"
|
||||
:label="t('mediaserver.apiKey')"
|
||||
:hint="t('mediaserver.embyApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'zspace'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
:hint="t('mediaserver.usernameHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'jellyfin'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.apikey"
|
||||
:label="t('mediaserver.apiKey')"
|
||||
:hint="t('mediaserver.jellyfinApiKeyHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'trimemedia'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'ugreen'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.username"
|
||||
:label="t('mediaserver.username')"
|
||||
active
|
||||
prepend-inner-icon="mdi-account"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
type="password"
|
||||
v-model="mediaServerInfo.config.password"
|
||||
:label="t('mediaserver.password')"
|
||||
active
|
||||
prepend-inner-icon="mdi-lock"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSelect
|
||||
v-model="mediaServerInfo.config.scan_mode"
|
||||
:label="t('mediaserver.scanMode')"
|
||||
:items="ugreenScanModeOptions"
|
||||
:hint="t('mediaserver.scanModeHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-radar"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VSwitch
|
||||
v-model="mediaServerInfo.config.verify_ssl"
|
||||
:label="t('mediaserver.verifySsl')"
|
||||
:hint="t('mediaserver.verifySslHint')"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
inset
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else-if="mediaServerInfo.type == 'plex'">
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.name"
|
||||
:label="t('common.name')"
|
||||
:placeholder="t('mediaserver.nameRequired')"
|
||||
:hint="t('mediaserver.serverAlias')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.host"
|
||||
:label="t('mediaserver.host')"
|
||||
:placeholder="t('mediaserver.hostPlaceholder')"
|
||||
:hint="t('mediaserver.hostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-server"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.play_host"
|
||||
:label="t('mediaserver.playHost')"
|
||||
:placeholder="t('mediaserver.playHostPlaceholder')"
|
||||
:hint="t('mediaserver.playHostHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-play-network"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.config.token"
|
||||
:label="t('mediaserver.plexToken')"
|
||||
:hint="t('mediaserver.plexTokenHint')"
|
||||
persistent-hint
|
||||
active
|
||||
prepend-inner-icon="mdi-key"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12">
|
||||
<VAutocomplete
|
||||
v-model="mediaServerInfo.sync_libraries"
|
||||
:label="t('mediaserver.syncLibraries')"
|
||||
:items="librariesOptions"
|
||||
chips
|
||||
multiple
|
||||
clearable
|
||||
:hint="t('mediaserver.syncLibrariesHint')"
|
||||
persistent-hint
|
||||
active
|
||||
append-inner-icon="mdi-refresh"
|
||||
prepend-inner-icon="mdi-library"
|
||||
@click:append-inner="loadLibrary(mediaServerInfo.name)"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
<VRow v-else>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="mediaServerInfo.type"
|
||||
:label="t('mediaserver.type')"
|
||||
:hint="t('mediaserver.customTypeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-cog"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
:label="t('common.name')"
|
||||
:hint="t('mediaserver.nameRequired')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="saveMediaServerInfo" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
1131
src/components/dialog/NotificationChannelInfoDialog.vue
Normal file
1131
src/components/dialog/NotificationChannelInfoDialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
90
src/components/dialog/NotificationTemplateEditorDialog.vue
Normal file
90
src/components/dialog/NotificationTemplateEditorDialog.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
content?: string
|
||||
editorTheme?: string
|
||||
modelValue?: boolean
|
||||
subtitle?: string
|
||||
templateType?: string
|
||||
}>(),
|
||||
{
|
||||
content: '{}',
|
||||
editorTheme: 'monokai',
|
||||
modelValue: true,
|
||||
subtitle: '',
|
||||
templateType: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'save', value: string): void
|
||||
(event: 'update:content', value: string): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const editableContent = ref(props.content)
|
||||
|
||||
watch(
|
||||
() => props.content,
|
||||
value => {
|
||||
editableContent.value = value
|
||||
},
|
||||
)
|
||||
|
||||
watch(editableContent, value => {
|
||||
emit('update:content', value)
|
||||
})
|
||||
|
||||
// 提交通知模板内容,由调用方负责保存到后端。
|
||||
function submitTemplate() {
|
||||
emit('save', editableContent.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-code-json" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>
|
||||
{{ t('setting.notification.templateConfigTitle') }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle>
|
||||
{{ props.subtitle }}
|
||||
</VCardSubtitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VCardText class="py-0">
|
||||
<VAceEditor
|
||||
:key="`${props.templateType}-jinja2-json`"
|
||||
v-model:value="editableContent"
|
||||
lang="jinja2_json"
|
||||
:theme="props.editorTheme"
|
||||
class="w-full h-full min-h-[30rem] rounded"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="submitTemplate">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
175
src/components/dialog/OfflineStatusDialog.vue
Normal file
175
src/components/dialog/OfflineStatusDialog.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { useGlobalOfflineStatus } from '@/composables/useOfflineStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
type?: 'offline' | 'online'
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
type: 'offline',
|
||||
},
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const { isOnline, canPerformNetworkAction, getOfflineMessage } = useGlobalOfflineStatus()
|
||||
|
||||
// 重试连接
|
||||
const retrying = ref(false)
|
||||
|
||||
/** 尝试请求静态资源来触发网络状态重新检测。 */
|
||||
async function handleRetry() {
|
||||
if (retrying.value) return
|
||||
|
||||
retrying.value = true
|
||||
|
||||
try {
|
||||
await fetch('/favicon.ico?' + new Date().getTime(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-cache',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
retrying.value = false
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 状态文本
|
||||
const statusText = computed(() => {
|
||||
if (props.type === 'online') {
|
||||
return t('app.onlineMessage')
|
||||
}
|
||||
return getOfflineMessage()
|
||||
})
|
||||
|
||||
// 图标
|
||||
const statusIcon = computed(() => {
|
||||
return props.type === 'online' ? 'mdi-wifi' : 'mdi-wifi-off'
|
||||
})
|
||||
|
||||
// 颜色主题
|
||||
const colorTheme = computed(() => {
|
||||
return props.type === 'online' ? 'success' : 'error'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog :model-value="props.modelValue" persistent max-width="420" scrollable>
|
||||
<VCard class="offline-dialog">
|
||||
<div class="status-icon-wrapper">
|
||||
<div class="status-icon-bg">
|
||||
<VIcon :icon="statusIcon" size="48" :color="colorTheme" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VCardText class="text-center">
|
||||
<h2 class="offline-title mb-4">
|
||||
{{ props.type === 'online' ? t('app.online') : t('app.offline') }}
|
||||
</h2>
|
||||
|
||||
<p class="offline-message mb-6">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
|
||||
<div class="action-section mb-6">
|
||||
<VBtn
|
||||
v-if="props.type === 'offline'"
|
||||
:loading="retrying"
|
||||
:color="colorTheme"
|
||||
size="default"
|
||||
variant="flat"
|
||||
@click="handleRetry"
|
||||
>
|
||||
<VIcon icon="mdi-refresh" class="me-2" />
|
||||
{{ retrying ? t('common.checking') : t('common.retry') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
<div class="status-indicators">
|
||||
<VChip
|
||||
:color="isOnline ? 'success' : 'error'"
|
||||
:prepend-icon="isOnline ? 'mdi-wifi' : 'mdi-wifi-off'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-2"
|
||||
>
|
||||
{{ isOnline ? t('common.networkOnline') : t('common.networkOffline') }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
:color="canPerformNetworkAction ? 'success' : 'warning'"
|
||||
:prepend-icon="canPerformNetworkAction ? 'mdi-check-circle' : 'mdi-alert-circle'"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
>
|
||||
{{ canPerformNetworkAction ? t('common.serviceAvailable') : t('common.serviceUnavailable') }}
|
||||
</VChip>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.offline-dialog {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.status-icon-wrapper {
|
||||
padding-block: 24px 0;
|
||||
padding-inline: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-icon-bg {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
animation: icon-pulse 3s ease-in-out infinite;
|
||||
background: rgba(var(--v-theme-surface-variant), 0.5);
|
||||
block-size: 80px;
|
||||
inline-size: 80px;
|
||||
margin-block: 0;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.status-icon-bg::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 50%;
|
||||
animation: icon-glow 2s ease-in-out infinite alternate;
|
||||
background: linear-gradient(45deg, rgb(var(--v-theme-primary)), rgb(var(--v-theme-secondary)));
|
||||
content: '';
|
||||
inset: -3px;
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
@keyframes icon-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-glow {
|
||||
0% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
172
src/components/dialog/PluginCloneDialog.vue
Normal file
172
src/components/dialog/PluginCloneDialog.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
required: true,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'clone'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 插件分身表单
|
||||
const cloneForm = ref({
|
||||
suffix: '',
|
||||
name: '',
|
||||
description: '',
|
||||
version: '',
|
||||
icon: '',
|
||||
})
|
||||
|
||||
/** 初始化插件分身表单。 */
|
||||
function initializeCloneForm() {
|
||||
cloneForm.value = {
|
||||
suffix: '',
|
||||
name: t('plugin.cloneDefaultName', { name: props.plugin?.plugin_name }),
|
||||
description: t('plugin.cloneDefaultDescription', { description: props.plugin?.plugin_desc }),
|
||||
version: props.plugin?.plugin_version || '1.0',
|
||||
icon: props.plugin?.plugin_icon || '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交插件分身表单。 */
|
||||
function submitClone() {
|
||||
emit('clone', { ...cloneForm.value })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeCloneForm()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="600" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-content-copy" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('plugin.cloneTitle') }}</VCardTitle>
|
||||
<VCardSubtitle>{{ t('plugin.cloneSubtitle', { name: props.plugin?.plugin_name }) }}</VCardSubtitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VForm>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.suffix"
|
||||
:label="t('plugin.suffix') + ' *'"
|
||||
:placeholder="t('plugin.suffixPlaceholder')"
|
||||
:hint="t('plugin.suffixHint')"
|
||||
persistent-hint
|
||||
:rules="[
|
||||
v => !!v || t('plugin.suffixRequired'),
|
||||
v => /^[a-zA-Z0-9]+$/.test(v) || t('plugin.suffixFormatError'),
|
||||
v => v.length <= 20 || t('plugin.suffixLengthError'),
|
||||
]"
|
||||
required
|
||||
prepend-inner-icon="mdi-tag"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.name"
|
||||
:label="t('plugin.cloneName')"
|
||||
:placeholder="t('plugin.cloneNamePlaceholder')"
|
||||
:hint="t('plugin.cloneNameHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-rename-box"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="cloneForm.description"
|
||||
:label="t('plugin.cloneDescriptionLabel')"
|
||||
:placeholder="t('plugin.cloneDescriptionPlaceholder')"
|
||||
:hint="t('plugin.cloneDescriptionHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-text"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.version"
|
||||
:label="t('plugin.cloneVersion')"
|
||||
:placeholder="t('plugin.cloneVersionPlaceholder')"
|
||||
:hint="t('plugin.cloneVersionHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-numeric"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="cloneForm.icon"
|
||||
:label="t('plugin.cloneIcon')"
|
||||
:placeholder="t('plugin.cloneIconPlaceholder')"
|
||||
:hint="t('plugin.cloneIconHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-image"
|
||||
/>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VAlert type="warning" variant="tonal" density="compact" class="mt-2" icon="mdi-alert-circle-outline">
|
||||
<div class="text-body-2">
|
||||
<strong>{{ t('common.notice') }}</strong
|
||||
>:{{ t('plugin.cloneNotice') }}
|
||||
</div>
|
||||
</VAlert>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
color="primary"
|
||||
@click="submitClone"
|
||||
prepend-icon="mdi-content-copy"
|
||||
class="px-5"
|
||||
:disabled="!cloneForm.suffix.trim()"
|
||||
:loading="props.loading"
|
||||
>
|
||||
{{ t('plugin.createClone') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
65
src/components/dialog/PluginFolderCreateDialog.vue
Normal file
65
src/components/dialog/PluginFolderCreateDialog.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'create'): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'update:name', value: string): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const folderName = computed({
|
||||
get: () => props.name,
|
||||
set: value => emit('update:name', value),
|
||||
})
|
||||
|
||||
// 关闭插件文件夹新建弹窗。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-model="dialogVisible" max-width="400">
|
||||
<VCard>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VCardItem>
|
||||
<VCardTitle>{{ t('plugin.newFolder') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField
|
||||
v-model="folderName"
|
||||
:label="t('plugin.folderName')"
|
||||
variant="outlined"
|
||||
@keyup.enter="emit('create')"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-folder-plus" class="px-5" @click="emit('create')">
|
||||
{{ t('plugin.create') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
66
src/components/dialog/PluginFolderRenameDialog.vue
Normal file
66
src/components/dialog/PluginFolderRenameDialog.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
folderName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'rename'])
|
||||
|
||||
// 新名称
|
||||
const newFolderName = ref(props.folderName)
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 提交文件夹重命名。 */
|
||||
function confirmRename() {
|
||||
emit('rename', newFolderName.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="400">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-pencil" class="me-2" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('folder.renameFolder') }}</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VTextField
|
||||
v-model="newFolderName"
|
||||
:label="t('folder.folderName')"
|
||||
variant="outlined"
|
||||
autofocus
|
||||
@keyup.enter="confirmRename"
|
||||
/>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="confirmRename">确认</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
210
src/components/dialog/PluginFolderSettingsDialog.vue
Normal file
210
src/components/dialog/PluginFolderSettingsDialog.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
interface FolderConfig {
|
||||
plugins?: string[]
|
||||
order?: number
|
||||
background?: string
|
||||
icon?: string
|
||||
color?: string
|
||||
gradient?: string
|
||||
showIcon?: boolean
|
||||
}
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 响应式显示
|
||||
const display = useDisplay()
|
||||
|
||||
// 默认颜色
|
||||
const defaultColor = '#2196F3'
|
||||
// 默认图标
|
||||
const defaultIcon = 'mdi-folder'
|
||||
|
||||
// 预设图标选项
|
||||
const iconOptions = [
|
||||
'mdi-folder',
|
||||
'mdi-folder-star',
|
||||
'mdi-folder-heart',
|
||||
'mdi-folder-cog',
|
||||
'mdi-folder-music',
|
||||
'mdi-folder-image',
|
||||
'mdi-folder-video',
|
||||
'mdi-folder-download',
|
||||
'mdi-folder-network',
|
||||
'mdi-folder-special',
|
||||
]
|
||||
|
||||
// 预设颜色选项
|
||||
const colorOptions = [
|
||||
'#2196F3',
|
||||
'#4CAF50',
|
||||
'#FF9800',
|
||||
'#9C27B0',
|
||||
'#F44336',
|
||||
'#607D8B',
|
||||
'#795548',
|
||||
'#E91E63',
|
||||
]
|
||||
|
||||
// 预设渐变选项
|
||||
const gradientOptions = [
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(33, 150, 243, 0.7) 0%, rgba(33, 150, 243, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(76, 175, 80, 0.7) 0%, rgba(76, 175, 80, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(255, 152, 0, 0.7) 0%, rgba(255, 152, 0, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(156, 39, 176, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(244, 67, 54, 0.7) 0%, rgba(244, 67, 54, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(96, 125, 139, 0.7) 0%, rgba(96, 125, 139, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(233, 30, 99, 0.7) 0%, rgba(233, 30, 99, 0.8) 100%)',
|
||||
'linear-gradient(rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.4) 100%), linear-gradient(135deg, rgba(63, 81, 181, 0.7) 0%, rgba(156, 39, 176, 0.8) 100%)',
|
||||
]
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
folderConfig: {
|
||||
type: Object as PropType<FolderConfig>,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'save'])
|
||||
|
||||
// 文件夹设置
|
||||
const folderSettings = ref<FolderConfig>({
|
||||
background: '',
|
||||
icon: defaultIcon,
|
||||
color: defaultColor,
|
||||
gradient: gradientOptions[0],
|
||||
showIcon: true,
|
||||
})
|
||||
|
||||
// 设置对话框
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 初始化文件夹外观设置。 */
|
||||
function initializeSettings() {
|
||||
folderSettings.value = {
|
||||
background: props.folderConfig?.background || '',
|
||||
icon: props.folderConfig?.icon || defaultIcon,
|
||||
color: props.folderConfig?.color || defaultColor,
|
||||
gradient: props.folderConfig?.gradient || gradientOptions[0],
|
||||
showIcon: props.folderConfig?.showIcon !== undefined ? props.folderConfig.showIcon : true,
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存文件夹外观设置。 */
|
||||
function saveSettings() {
|
||||
emit('save', {
|
||||
...props.folderConfig,
|
||||
...folderSettings.value,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initializeSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="600" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-palette" class="mr-2" />
|
||||
{{ t('folder.folderAppearanceSettings') }}
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12">
|
||||
<VSwitch v-model="folderSettings.showIcon" :label="t('folder.showFolderIcon')" color="primary" hide-details />
|
||||
</VCol>
|
||||
|
||||
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.icon') }}</VCardSubtitle>
|
||||
<div class="icon-grid">
|
||||
<VBtn
|
||||
v-for="icon in iconOptions"
|
||||
icon
|
||||
:key="icon"
|
||||
:variant="folderSettings.icon === icon ? 'tonal' : 'text'"
|
||||
:color="folderSettings.icon === icon ? 'primary' : 'default'"
|
||||
size="large"
|
||||
class="ma-1"
|
||||
@click="folderSettings.icon = icon"
|
||||
>
|
||||
<VIcon :icon="icon" size="24" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<VCol v-if="folderSettings.showIcon" cols="12" md="6">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.iconColor') }}</VCardSubtitle>
|
||||
<div class="color-grid">
|
||||
<VBtn
|
||||
v-for="color in colorOptions"
|
||||
:key="color"
|
||||
:variant="folderSettings.color === color ? 'tonal' : 'text'"
|
||||
:color="color"
|
||||
size="large"
|
||||
class="ma-1 color-btn"
|
||||
:style="{ backgroundColor: color }"
|
||||
@click="folderSettings.color = color"
|
||||
>
|
||||
<VIcon v-if="folderSettings.color === color" icon="mdi-check" color="white" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VCardSubtitle class="pa-0 mb-2">{{ t('folder.backgroundGradient') }}</VCardSubtitle>
|
||||
<div class="gradient-grid">
|
||||
<VBtn
|
||||
v-for="(gradient, index) in gradientOptions"
|
||||
:key="index"
|
||||
:variant="folderSettings.gradient === gradient ? 'tonal' : 'text'"
|
||||
class="ma-1 gradient-btn"
|
||||
:style="{ background: gradient }"
|
||||
size="large"
|
||||
@click="folderSettings.gradient = gradient"
|
||||
>
|
||||
<VIcon v-if="folderSettings.gradient === gradient" icon="mdi-check" color="white" />
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCol>
|
||||
|
||||
<VCol cols="12">
|
||||
<VTextField
|
||||
v-model="folderSettings.background"
|
||||
:label="t('folder.customBackgroundImageURL')"
|
||||
placeholder="https://example.com/image.jpg"
|
||||
variant="outlined"
|
||||
:hint="t('folder.customBackgroundImageHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-image"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-content-save" class="px-5" @click="saveSettings">保存</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
69
src/components/dialog/PluginLogDialog.vue
Normal file
69
src/components/dialog/PluginLogDialog.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 打开当前插件日志的新窗口。 */
|
||||
function openLoggerWindow() {
|
||||
const url = `${
|
||||
import.meta.env.VITE_API_BASE_URL
|
||||
}system/logging?length=-1&logfile=plugins/${props.plugin?.id?.toLowerCase()}.log`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="72rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
<VIcon icon="mdi-file-document" class="me-2" />
|
||||
{{ t('plugin.logTitle') }}
|
||||
<a class="mx-2 d-inline-flex align-center cursor-pointer" @click="openLoggerWindow">
|
||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView :logfile="`plugins/${props.plugin?.id?.toLowerCase()}.log`" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
196
src/components/dialog/PluginMarketDetailDialog.vue
Normal file
196
src/components/dialog/PluginMarketDetailDialog.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script lang="ts" setup>
|
||||
import api from '@/api'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { formatDownloadCount } from '@/@core/utils/formatters'
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { openSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const ProgressDialog = defineAsyncComponent(() => import('@/components/dialog/ProgressDialog.vue'))
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 提示框
|
||||
const $toast = useToast()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
required: true,
|
||||
},
|
||||
count: Number,
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'install'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 图片对象
|
||||
const imageRef = ref<any>()
|
||||
|
||||
// 图片是否加载失败
|
||||
const imageLoadError = ref(false)
|
||||
|
||||
let progressDialogController: ReturnType<typeof openSharedDialog> | null = null
|
||||
|
||||
/** 打开插件安装进度弹窗。 */
|
||||
function showInstallProgress(text: string) {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = openSharedDialog(ProgressDialog, { text }, {}, { closeOn: false })
|
||||
}
|
||||
|
||||
/** 关闭插件安装进度弹窗。 */
|
||||
function closeInstallProgress() {
|
||||
progressDialogController?.close()
|
||||
progressDialogController = null
|
||||
}
|
||||
|
||||
/** 计算插件图标路径。 */
|
||||
function pluginIconPath() {
|
||||
if (imageLoadError.value) return getLogoUrl('plugin')
|
||||
if (props.plugin?.plugin_icon?.startsWith('http'))
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(
|
||||
props.plugin?.plugin_icon,
|
||||
)}&cache=true`
|
||||
|
||||
return `./plugin_icon/${props.plugin?.plugin_icon}`
|
||||
}
|
||||
|
||||
/** 访问插件项目或作者页面。 */
|
||||
function visitPluginPage() {
|
||||
let repoUrl = props.plugin?.repo_url
|
||||
if (props.plugin?.is_local || repoUrl?.startsWith('local://')) {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
if (repoUrl) {
|
||||
if (repoUrl.includes('raw.githubusercontent.com')) {
|
||||
if (!repoUrl.endsWith('/')) repoUrl += '/'
|
||||
|
||||
if (repoUrl.split('/').length < 6) repoUrl = `${repoUrl}main/`
|
||||
|
||||
try {
|
||||
const [user, repo] = repoUrl.split('/').slice(-4, -2)
|
||||
repoUrl = `https://github.com/${user}/${repo}`
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
repoUrl = props.plugin?.author_url
|
||||
}
|
||||
window.open(repoUrl, '_blank')
|
||||
}
|
||||
|
||||
/** 安装插件并通知父级刷新市场列表。 */
|
||||
async function installPlugin() {
|
||||
try {
|
||||
showInstallProgress(
|
||||
t('plugin.installing', {
|
||||
name: props.plugin?.plugin_name,
|
||||
version: props?.plugin?.plugin_version,
|
||||
}),
|
||||
)
|
||||
|
||||
const result: { [key: string]: any } = await api.get(`plugin/install/${props.plugin?.id}`, {
|
||||
params: {
|
||||
repo_url: props.plugin?.repo_url,
|
||||
force: props.plugin?.has_update,
|
||||
},
|
||||
})
|
||||
|
||||
closeInstallProgress()
|
||||
|
||||
if (result.success) {
|
||||
$toast.success(t('plugin.installSuccess', { name: props.plugin?.plugin_name }))
|
||||
visible.value = false
|
||||
emit('install')
|
||||
} else {
|
||||
$toast.error(t('plugin.installFailed', { name: props.plugin?.plugin_name, message: result.message }))
|
||||
}
|
||||
} catch (error) {
|
||||
closeInstallProgress()
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
closeInstallProgress()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem">
|
||||
<VCard>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardText>
|
||||
<VCol>
|
||||
<div class="d-flex justify-space-between flex-wrap flex-md-nowrap flex-column flex-md-row">
|
||||
<div class="mx-auto mt-5">
|
||||
<VAvatar size="64">
|
||||
<VImg
|
||||
ref="imageRef"
|
||||
:src="pluginIconPath()"
|
||||
aspect-ratio="4/3"
|
||||
cover
|
||||
@error="imageLoadError = true"
|
||||
/>
|
||||
</VAvatar>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<VCardItem>
|
||||
<VCardTitle class="text-center text-md-left">
|
||||
{{ props.plugin?.plugin_name }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle
|
||||
class="text-center text-md-left break-words whitespace-break-spaces line-clamp-4 overflow-hidden text-ellipsis ..."
|
||||
>
|
||||
{{ props.plugin?.plugin_desc }}
|
||||
</VCardSubtitle>
|
||||
<VList lines="one">
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('common.version') }}:</span>
|
||||
<span class="text-body-1"> v{{ props.plugin?.plugin_version }}</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
<VListItem class="ps-0">
|
||||
<VListItemTitle class="text-center text-md-left">
|
||||
<span class="font-weight-medium">{{ t('common.author') }}:</span>
|
||||
<span class="text-body-1 cursor-pointer" @click="visitPluginPage">
|
||||
{{ props.plugin?.plugin_author }}
|
||||
</span>
|
||||
</VListItemTitle>
|
||||
</VListItem>
|
||||
</VList>
|
||||
<div class="text-center text-md-left">
|
||||
<VBtn color="primary" @click="installPlugin" prepend-icon="mdi-download">
|
||||
{{ t('plugin.installToLocal') }}
|
||||
</VBtn>
|
||||
<div class="text-xs mt-2" v-if="props.count">
|
||||
<VIcon icon="mdi-fire" />
|
||||
{{ t('plugin.totalDownloads', { count: formatDownloadCount(props.count) }) }}
|
||||
</div>
|
||||
</div>
|
||||
</VCardItem>
|
||||
</div>
|
||||
</div>
|
||||
</VCol>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
133
src/components/dialog/PluginSearchDialog.vue
Normal file
133
src/components/dialog/PluginSearchDialog.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
import { getLogoUrl } from '@/utils/imageUtils'
|
||||
import type { Plugin } from '@/api/types'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const props = defineProps({
|
||||
keyword: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
plugins: {
|
||||
type: Array as PropType<Plugin[]>,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'open-plugin', plugin: Plugin): void
|
||||
(event: 'update:keyword', value: string): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
const pluginIconLoaded = ref<Record<string, boolean>>({})
|
||||
|
||||
const dialogVisible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => emit('update:modelValue', value),
|
||||
})
|
||||
|
||||
const searchKeyword = computed({
|
||||
get: () => props.keyword,
|
||||
set: value => emit('update:keyword', value),
|
||||
})
|
||||
|
||||
// 返回插件图标地址,并在远程图标失败后回退到默认图标。
|
||||
function pluginIcon(item: Plugin) {
|
||||
if (pluginIconLoaded.value[item.id || '0'] === false) return getLogoUrl('plugin')
|
||||
if (item?.plugin_icon?.startsWith('http')) {
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/img/1?imgurl=${encodeURIComponent(item?.plugin_icon)}&cache=true`
|
||||
}
|
||||
|
||||
return `./plugin_icon/${item?.plugin_icon}`
|
||||
}
|
||||
|
||||
// 标记指定插件图标加载失败。
|
||||
function pluginIconError(item: Plugin) {
|
||||
pluginIconLoaded.value[item.id || '0'] = false
|
||||
}
|
||||
|
||||
// 获取插件标签列表。
|
||||
function pluginLabels(label: string | undefined) {
|
||||
if (!label) return []
|
||||
return label.split(',')
|
||||
}
|
||||
|
||||
// 关闭搜索弹窗并通知共享弹窗 Host 回收实例。
|
||||
function closeDialog() {
|
||||
emit('close')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-model="dialogVisible"
|
||||
scrollable
|
||||
max-width="40rem"
|
||||
:max-height="!display.mdAndUp.value ? '' : '85vh'"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard class="mx-auto" width="100%">
|
||||
<VToolbar flat class="p-0">
|
||||
<VTextField
|
||||
v-model="searchKeyword"
|
||||
:label="t('plugin.searchPlugins')"
|
||||
single-line
|
||||
:placeholder="t('plugin.searchPlaceholder')"
|
||||
variant="solo"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
flat
|
||||
class="mx-1"
|
||||
/>
|
||||
</VToolbar>
|
||||
<VDialogCloseBtn @click="closeDialog" />
|
||||
<VList v-if="plugins.length > 0" lines="two">
|
||||
<VVirtualScroll :items="plugins">
|
||||
<template #default="{ item }">
|
||||
<VListItem @click="emit('open-plugin', item)">
|
||||
<template #prepend>
|
||||
<VAvatar>
|
||||
<VImg :src="pluginIcon(item)" @error="pluginIconError(item)">
|
||||
<template #placeholder>
|
||||
<div class="w-full h-full">
|
||||
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" />
|
||||
</div>
|
||||
</template>
|
||||
</VImg>
|
||||
</VAvatar>
|
||||
</template>
|
||||
<VListItemTitle>
|
||||
{{ item.plugin_name }}<span class="text-sm ms-2 mt-1 text-gray-500">v{{ item?.plugin_version }}</span>
|
||||
<VIcon v-if="item.installed" color="success" icon="mdi-check-circle" class="ms-2" size="small" />
|
||||
</VListItemTitle>
|
||||
<VListItemSubtitle>
|
||||
<VChip
|
||||
v-for="label in pluginLabels(item.plugin_label)"
|
||||
:key="label"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
class="me-1 my-1"
|
||||
color="info"
|
||||
label
|
||||
>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
{{ item.plugin_desc }}
|
||||
</VListItemSubtitle>
|
||||
</VListItem>
|
||||
</template>
|
||||
</VVirtualScroll>
|
||||
</VList>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
62
src/components/dialog/PluginVersionHistoryDialog.vue
Normal file
62
src/components/dialog/PluginVersionHistoryDialog.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { Plugin } from '@/api/types'
|
||||
import VersionHistory from '@/components/misc/VersionHistory.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 多语言
|
||||
const { t } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
plugin: {
|
||||
type: Object as PropType<Plugin>,
|
||||
required: true,
|
||||
},
|
||||
showUpdateAction: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'update'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 触发插件更新操作。 */
|
||||
function handleUpdate() {
|
||||
emit('update')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" width="600" max-height="85vh" scrollable>
|
||||
<VCard :title="t('plugin.updateHistoryTitle', { name: props.plugin?.plugin_name })">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VDivider />
|
||||
<VersionHistory :history="props.plugin?.history" />
|
||||
<template v-if="props.showUpdateAction">
|
||||
<VDivider />
|
||||
<VCardItem>
|
||||
<VBtn @click="handleUpdate" block>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-arrow-up-circle-outline" />
|
||||
</template>
|
||||
{{ t('plugin.updateToLatest') }}
|
||||
</VBtn>
|
||||
</VCardItem>
|
||||
</template>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
@@ -7,6 +7,9 @@ const props = defineProps({
|
||||
value: Number,
|
||||
text: String,
|
||||
})
|
||||
|
||||
// 有明确进度值时显示确定进度,否则显示不确定进度条。
|
||||
const hasProgressValue = computed(() => typeof props.value === 'number' && Number.isFinite(props.value))
|
||||
</script>
|
||||
<template>
|
||||
<!-- Progress Dialog -->
|
||||
@@ -14,7 +17,12 @@ const props = defineProps({
|
||||
<VCard elevation="3" color="primary">
|
||||
<VCardText class="text-center">
|
||||
{{ props.text || t('dialog.progress.processing') }}
|
||||
<VProgressLinear color="white" class="mb-0 mt-1" :model-value="props.value" indeterminate />
|
||||
<VProgressLinear
|
||||
color="white"
|
||||
class="mb-0 mt-1"
|
||||
:model-value="hasProgressValue ? props.value : undefined"
|
||||
:indeterminate="!hasProgressValue"
|
||||
/>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
|
||||
64
src/components/dialog/SharedDialogHost.vue
Normal file
64
src/components/dialog/SharedDialogHost.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" setup>
|
||||
import type { SharedDialogEntry } from '@/composables/useSharedDialog'
|
||||
import { closeSharedDialog, useSharedDialog } from '@/composables/useSharedDialog'
|
||||
|
||||
const { dialogs } = useSharedDialog()
|
||||
type ReadonlySharedDialogEntry = Readonly<SharedDialogEntry> & {
|
||||
readonly closeOn: readonly string[]
|
||||
readonly events: Readonly<SharedDialogEntry['events']>
|
||||
readonly props: Readonly<SharedDialogEntry['props']>
|
||||
}
|
||||
|
||||
// 关闭弹窗并同步组件自身的 v-model 状态。
|
||||
function closeEntry(entry: ReadonlySharedDialogEntry) {
|
||||
closeSharedDialog(entry.id)
|
||||
}
|
||||
|
||||
// 处理弹窗内部 v-model 变化,用户点击遮罩或返回键关闭时也能释放实例。
|
||||
function handleModelUpdate(entry: ReadonlySharedDialogEntry, value: boolean) {
|
||||
if (!value) closeSharedDialog(entry.id)
|
||||
}
|
||||
|
||||
// 转发业务事件给调用方,并按配置自动关闭当前弹窗。
|
||||
function handleDialogEvent(entry: ReadonlySharedDialogEntry, eventName: string, args: any[]) {
|
||||
entry.events[eventName]?.(...args)
|
||||
|
||||
if (entry.closeOn.includes(eventName) && (eventName !== 'update:modelValue' || args[0] === false)) {
|
||||
closeEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成动态组件事件监听器,让不同业务弹窗复用同一个 Host。
|
||||
function createDialogListeners(entry: ReadonlySharedDialogEntry) {
|
||||
const listeners: Record<string, (...args: any[]) => void> = {}
|
||||
|
||||
listeners['update:modelValue'] = value => {
|
||||
handleModelUpdate(entry, Boolean(value))
|
||||
entry.events['update:modelValue']?.(value)
|
||||
}
|
||||
|
||||
Object.keys(entry.events).forEach(eventName => {
|
||||
if (eventName === 'update:modelValue') return
|
||||
|
||||
listeners[eventName] = (...args: any[]) => handleDialogEvent(entry, eventName, args)
|
||||
})
|
||||
|
||||
entry.closeOn.forEach(eventName => {
|
||||
if (!listeners[eventName]) {
|
||||
listeners[eventName] = (...args: any[]) => handleDialogEvent(entry, eventName, args)
|
||||
}
|
||||
})
|
||||
|
||||
return listeners
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Component
|
||||
:is="entry.component"
|
||||
v-for="entry in dialogs"
|
||||
:key="entry.id"
|
||||
v-bind="{ ...entry.props, modelValue: entry.visible }"
|
||||
v-on="createDialogListeners(entry)"
|
||||
/>
|
||||
</template>
|
||||
61
src/components/dialog/ShortcutLogDialog.vue
Normal file
61
src/components/dialog/ShortcutLogDialog.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const LoggingView = defineAsyncComponent(() => import('@/views/system/LoggingView.vue'))
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 拼接全部日志 URL。 */
|
||||
function allLoggingUrl() {
|
||||
return `${import.meta.env.VITE_API_BASE_URL}system/logging?length=-1`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" scrollable max-width="80rem" :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardItem>
|
||||
<VCardTitle class="d-inline-flex">
|
||||
<VIcon icon="mdi-file-document" class="me-2" />
|
||||
{{ t('shortcut.log.subtitle') }}
|
||||
<a class="mx-2 d-inline-flex align-center" :href="allLoggingUrl()" target="_blank">
|
||||
<VChip color="grey-darken-1" size="small" class="ml-2">
|
||||
<VIcon icon="mdi-open-in-new" size="small" start />
|
||||
{{ t('common.openInNewWindow') }}
|
||||
</VChip>
|
||||
</a>
|
||||
</VCardTitle>
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText class="pa-0">
|
||||
<LoggingView logfile="moviepilot.log" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
139
src/components/dialog/ShortcutMessageDialog.vue
Normal file
139
src/components/dialog/ShortcutMessageDialog.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import api from '@/api'
|
||||
import { clearAppBadge } from '@/utils/badge'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
const MessageView = defineAsyncComponent(() => import('@/views/system/MessageView.vue'))
|
||||
|
||||
type MessageViewExpose = {
|
||||
pauseSSE?: () => void
|
||||
resumeSSE?: () => void
|
||||
refreshLatestMessages?: () => Promise<void> | void
|
||||
forceScrollToEnd?: () => void
|
||||
}
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 输入消息
|
||||
const user_message = ref('')
|
||||
|
||||
// 发送按钮是否可用
|
||||
const sendButtonDisabled = ref(false)
|
||||
|
||||
// 消息视图引用
|
||||
const messageViewRef = ref<MessageViewExpose | null>(null)
|
||||
|
||||
/** 发送 Web 消息。 */
|
||||
async function sendMessage() {
|
||||
const messageText = user_message.value.trim()
|
||||
if (!messageText) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
sendButtonDisabled.value = true
|
||||
await api.post(`message/web?text=${encodeURIComponent(messageText)}`)
|
||||
user_message.value = ''
|
||||
messageViewRef.value?.forceScrollToEnd?.()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
sendButtonDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, async newValue => {
|
||||
if (newValue) {
|
||||
await nextTick()
|
||||
messageViewRef.value?.resumeSSE?.()
|
||||
messageViewRef.value?.forceScrollToEnd?.()
|
||||
|
||||
window.setTimeout(() => {
|
||||
void clearAppBadge()
|
||||
}, 500)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
messageViewRef.value?.pauseSSE?.()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
messageViewRef.value?.resumeSSE?.()
|
||||
messageViewRef.value?.forceScrollToEnd?.()
|
||||
|
||||
window.setTimeout(() => {
|
||||
void clearAppBadge()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
messageViewRef.value?.pauseSSE?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-message" class="me-2" />
|
||||
{{ t('shortcut.message.subtitle') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<MessageView ref="messageViewRef" />
|
||||
</VCardText>
|
||||
<VDivider />
|
||||
<VCardActions class="pa-4">
|
||||
<div class="d-flex w-100 gap-2">
|
||||
<VTextField
|
||||
v-model="user_message"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
density="compact"
|
||||
:placeholder="t('common.inputMessage')"
|
||||
@keyup.enter="sendMessage"
|
||||
/>
|
||||
<VBtn
|
||||
variant="elevated"
|
||||
:disabled="sendButtonDisabled"
|
||||
@click="sendMessage"
|
||||
:loading="sendButtonDisabled"
|
||||
color="primary"
|
||||
prepend-icon="mdi-send"
|
||||
>{{ t('common.send') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
82
src/components/dialog/ShortcutToolDialog.vue
Normal file
82
src/components/dialog/ShortcutToolDialog.vue
Normal file
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
bodyClass?: string
|
||||
cardClass?: string
|
||||
icon?: string
|
||||
maxWidth?: string
|
||||
modelValue?: boolean
|
||||
subtitle?: string
|
||||
title: string
|
||||
view: Component
|
||||
viewProps?: Record<string, unknown>
|
||||
}>(),
|
||||
{
|
||||
bodyClass: '',
|
||||
cardClass: '',
|
||||
icon: 'mdi-cog',
|
||||
maxWidth: '35rem',
|
||||
modelValue: true,
|
||||
viewProps: () => ({}),
|
||||
},
|
||||
)
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" :max-width="props.maxWidth" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard :class="props.cardClass">
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon :icon="props.icon" class="me-2" />
|
||||
{{ props.title }}
|
||||
</VCardTitle>
|
||||
<VCardSubtitle v-if="props.subtitle">{{ props.subtitle }}</VCardSubtitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText :class="props.bodyClass">
|
||||
<Component :is="props.view" v-bind="props.viewProps" />
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.system-health-dialog-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.system-health-dialog-body {
|
||||
/* 弹窗正文本身不滚动,滚动只交给健康检查结果列表。 */
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
block-size: min(42rem, calc(100dvh - 8rem - env(safe-area-inset-top) - env(safe-area-inset-bottom)));
|
||||
min-block-size: 0;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
:global(.v-dialog--fullscreen) .system-health-dialog-body {
|
||||
block-size: auto;
|
||||
}
|
||||
</style>
|
||||
99
src/components/dialog/StorageCustomConfigDialog.vue
Normal file
99
src/components/dialog/StorageCustomConfigDialog.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import type { StorageConf } from '@/api/types'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useDisplay } from 'vuetify'
|
||||
|
||||
// 显示器宽度
|
||||
const display = useDisplay()
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 定义输入
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
storage: {
|
||||
type: Object as PropType<StorageConf>,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
// 定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'done'])
|
||||
|
||||
// 自定义存储名称
|
||||
const customName = ref(props.storage.name)
|
||||
|
||||
// 自定义存储类型
|
||||
const storageType = ref(props.storage.type)
|
||||
|
||||
// 自定义存储配置对话框
|
||||
const customConfigDialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 保存自定义存储基础信息并通知父级刷新。 */
|
||||
function handleDone() {
|
||||
const nextStorage = {
|
||||
...props.storage,
|
||||
name: customName.value,
|
||||
type: storageType.value,
|
||||
}
|
||||
customConfigDialog.value = false
|
||||
emit('done', nextStorage)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog
|
||||
v-if="customConfigDialog"
|
||||
v-model="customConfigDialog"
|
||||
scrollable
|
||||
max-width="30rem"
|
||||
:fullscreen="!display.mdAndUp.value"
|
||||
>
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-cog" />
|
||||
</template>
|
||||
<VCardTitle>{{ t('storage.custom') }}</VCardTitle>
|
||||
<VDialogCloseBtn v-model="customConfigDialog" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VRow>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="storageType"
|
||||
:label="t('storage.type')"
|
||||
:hint="t('storage.customTypeHint')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-database"
|
||||
/>
|
||||
</VCol>
|
||||
<VCol cols="12" md="6">
|
||||
<VTextField
|
||||
v-model="customName"
|
||||
:label="t('storage.name')"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-label"
|
||||
/>
|
||||
</VCol>
|
||||
</VRow>
|
||||
</VCardText>
|
||||
<VCardActions class="pt-3">
|
||||
<VBtn @click="handleDone" prepend-icon="mdi-content-save" class="px-5">
|
||||
{{ t('common.save') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
144
src/components/dialog/TorrentAllFiltersDialog.vue
Normal file
144
src/components/dialog/TorrentAllFiltersDialog.vue
Normal file
@@ -0,0 +1,144 @@
|
||||
<script setup lang="ts">
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const display = useDisplay()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
filterForm: Record<string, string[]>
|
||||
filterOptions: Record<string, string[]>
|
||||
filterTitles: Record<string, string>
|
||||
modelValue?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'clearAllFilters'): void
|
||||
(event: 'clearFilter', key: string): void
|
||||
(event: 'close'): void
|
||||
(event: 'selectAll', key: string): void
|
||||
(event: 'update:filterForm', key: string, values: string[]): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const selectedCount = computed(() => {
|
||||
return Object.values(props.filterForm).reduce((count, values) => count + values.length, 0)
|
||||
})
|
||||
|
||||
// 给定过滤类型返回不同图标。
|
||||
function getFilterIcon(key: string) {
|
||||
const icons: Record<string, string> = {
|
||||
site: 'mdi-server-network',
|
||||
season: 'mdi-television-classic',
|
||||
freeState: 'mdi-gift-outline',
|
||||
resolution: 'mdi-monitor-screenshot',
|
||||
videoCode: 'mdi-video-vintage',
|
||||
edition: 'mdi-quality-high',
|
||||
releaseGroup: 'mdi-account-group-outline',
|
||||
}
|
||||
return icons[key] || 'mdi-filter-variant'
|
||||
}
|
||||
|
||||
// 将筛选值变化回传给过滤条。
|
||||
function updateFilter(key: string, values: string[]) {
|
||||
emit('update:filterForm', key, values)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="50rem" location="center" scrollable :fullscreen="!display.mdAndUp.value">
|
||||
<VCard>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<VIcon icon="mdi-filter-variant" class="me-2"></VIcon>
|
||||
<span>{{ t('torrent.allFilters') }}</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="selectedCount > 0"
|
||||
class="me-10"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="emit('clearAllFilters')"
|
||||
>
|
||||
{{ t('torrent.clearAll') }}
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="all-filters-grid">
|
||||
<VCard
|
||||
v-for="(title, key) in props.filterTitles"
|
||||
:key="key"
|
||||
v-show="props.filterOptions[key].length > 0"
|
||||
variant="tonal"
|
||||
class="filter-section"
|
||||
>
|
||||
<VCardItem class="py-2">
|
||||
<template #prepend>
|
||||
<VIcon :icon="getFilterIcon(String(key))" class="me-2"></VIcon>
|
||||
</template>
|
||||
<VCardTitle>{{ title }}</VCardTitle>
|
||||
<template #append>
|
||||
<VBtn variant="text" size="small" color="primary" @click="emit('selectAll', String(key))">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-if="props.filterForm[key].length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="emit('clearFilter', String(key))"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
</template>
|
||||
</VCardItem>
|
||||
<VCardText>
|
||||
<VChipGroup
|
||||
:model-value="props.filterForm[key]"
|
||||
column
|
||||
multiple
|
||||
class="filter-options"
|
||||
@update:model-value="(val: string[]) => updateFilter(String(key), val)"
|
||||
>
|
||||
<VChip
|
||||
v-for="option in props.filterOptions[key]"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</div>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.all-filters-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
}
|
||||
</style>
|
||||
145
src/components/dialog/TorrentMoreSourcesDialog.vue
Normal file
145
src/components/dialog/TorrentMoreSourcesDialog.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import type { Context } from '@/api/types'
|
||||
import { formatFileSize } from '@/@core/utils/formatters'
|
||||
|
||||
// 输入参数
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
items: {
|
||||
type: Array as PropType<Context[]>,
|
||||
default: () => [],
|
||||
},
|
||||
siteIcons: {
|
||||
type: Object as PropType<Record<number, string>>,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits(['update:modelValue', 'close', 'download', 'detail'])
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
/** 获取优惠标签类。 */
|
||||
function getPromotionChipClass(downloadVolumeFactor: number | undefined, uploadVolumeFactor: number | undefined) {
|
||||
if (!downloadVolumeFactor) return 'chip-free'
|
||||
if (downloadVolumeFactor === 0) return 'chip-free'
|
||||
else if (downloadVolumeFactor < 1) return 'chip-discount'
|
||||
else if (uploadVolumeFactor !== undefined && uploadVolumeFactor > 1) return 'chip-bonus'
|
||||
else return ''
|
||||
}
|
||||
|
||||
/** 选择更多来源进行下载。 */
|
||||
function handleDownload(item: Context) {
|
||||
emit('download', item)
|
||||
}
|
||||
|
||||
/** 打开种子详情页。 */
|
||||
function handleDetail(item: Context) {
|
||||
emit('detail', item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="25rem" location="center">
|
||||
<VCard>
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<span>其他来源</span>
|
||||
<VSpacer />
|
||||
<VBtn variant="text" size="small" icon="mdi-close" @click.stop="visible = false"></VBtn>
|
||||
</VCardTitle>
|
||||
|
||||
<VDivider />
|
||||
|
||||
<VCardText class="more-sources-content pa-0">
|
||||
<VList lines="one" density="compact">
|
||||
<VListItem
|
||||
v-for="(item, index) in props.items"
|
||||
:key="index"
|
||||
@click.stop="handleDownload(item)"
|
||||
class="hover:bg-primary-lighten-5"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<div class="d-flex align-center gap-1">
|
||||
<VImg
|
||||
v-if="props.siteIcons[item.torrent_info?.site || 0]"
|
||||
:src="props.siteIcons[item.torrent_info?.site || 0]"
|
||||
:alt="item.torrent_info?.site_name"
|
||||
width="16"
|
||||
height="16"
|
||||
class="rounded"
|
||||
/>
|
||||
<VAvatar v-else size="16" class="text-caption bg-surface-variant">
|
||||
{{ item.torrent_info?.site_name?.substring(0, 1) }}
|
||||
</VAvatar>
|
||||
<span class="text-body-2 font-weight-bold">{{ item.torrent_info.site_name }}</span>
|
||||
|
||||
<VChip
|
||||
v-if="item.meta_info?.season_episode"
|
||||
class="chip-season rounded-sm ml-1"
|
||||
size="x-small"
|
||||
variant="elevated"
|
||||
>
|
||||
{{ item.meta_info.season_episode }}
|
||||
</VChip>
|
||||
|
||||
<VChip
|
||||
v-if="item.torrent_info?.downloadvolumefactor !== 1 || item.torrent_info?.uploadvolumefactor !== 1"
|
||||
:class="
|
||||
getPromotionChipClass(
|
||||
item.torrent_info?.downloadvolumefactor,
|
||||
item.torrent_info?.uploadvolumefactor,
|
||||
)
|
||||
"
|
||||
size="x-small"
|
||||
variant="elevated"
|
||||
class="rounded-sm ml-1"
|
||||
>
|
||||
{{ item.torrent_info?.volume_factor }}
|
||||
</VChip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:append>
|
||||
<div class="d-flex align-center gap-2">
|
||||
<span class="text-caption font-weight-bold text-primary">
|
||||
{{ formatFileSize(item.torrent_info?.size) }}
|
||||
</span>
|
||||
<span class="d-flex align-center text-caption font-weight-bold">
|
||||
<VIcon size="small" color="success" icon="mdi-arrow-up" class="mr-1"></VIcon>
|
||||
{{ item.torrent_info?.seeders }}
|
||||
</span>
|
||||
<span>
|
||||
<VIcon
|
||||
@click.stop="handleDetail(item)"
|
||||
size="small"
|
||||
color="secondary"
|
||||
icon="mdi-arrow-top-right"
|
||||
class="mr-1"
|
||||
></VIcon>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</VListItem>
|
||||
</VList>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.more-sources-content {
|
||||
max-block-size: 60vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
108
src/components/dialog/TorrentSingleFilterDialog.vue
Normal file
108
src/components/dialog/TorrentSingleFilterDialog.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
filterForm: Record<string, string[]>
|
||||
filterKey: string
|
||||
filterOptions: Record<string, string[]>
|
||||
filterTitle: string
|
||||
modelValue?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'clearFilter', key: string): void
|
||||
(event: 'close'): void
|
||||
(event: 'selectAll', key: string): void
|
||||
(event: 'update:filterForm', key: string, values: string[]): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const filterValues = computed(() => props.filterForm[props.filterKey] ?? [])
|
||||
const options = computed(() => props.filterOptions[props.filterKey] ?? [])
|
||||
|
||||
// 给定过滤类型返回不同图标。
|
||||
function getFilterIcon(key: string) {
|
||||
const icons: Record<string, string> = {
|
||||
site: 'mdi-server-network',
|
||||
season: 'mdi-television-classic',
|
||||
freeState: 'mdi-gift-outline',
|
||||
resolution: 'mdi-monitor-screenshot',
|
||||
videoCode: 'mdi-video-vintage',
|
||||
edition: 'mdi-quality-high',
|
||||
releaseGroup: 'mdi-account-group-outline',
|
||||
}
|
||||
return icons[key] || 'mdi-filter-variant'
|
||||
}
|
||||
|
||||
// 将当前筛选值变化回传给过滤条。
|
||||
function updateFilter(values: string[]) {
|
||||
emit('update:filterForm', props.filterKey, values)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="25rem" max-height="85vh" location="center" scrollable>
|
||||
<VCard>
|
||||
<VCardTitle class="py-3 d-flex align-center">
|
||||
<VIcon :icon="getFilterIcon(props.filterKey)" class="me-2"></VIcon>
|
||||
<span>{{ props.filterTitle }}</span>
|
||||
<VSpacer />
|
||||
<VBtn
|
||||
v-if="filterValues.length > 0"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="error"
|
||||
@click="emit('clearFilter', props.filterKey)"
|
||||
>
|
||||
{{ t('torrent.clear') }}
|
||||
</VBtn>
|
||||
<VBtn variant="text" size="small" color="primary" @click="emit('selectAll', props.filterKey)">
|
||||
{{ t('torrent.selectAll') }}
|
||||
</VBtn>
|
||||
</VCardTitle>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<VChipGroup
|
||||
:model-value="filterValues"
|
||||
column
|
||||
multiple
|
||||
class="filter-options"
|
||||
@update:model-value="updateFilter"
|
||||
>
|
||||
<VChip
|
||||
v-for="option in options"
|
||||
:key="option"
|
||||
:value="option"
|
||||
filter
|
||||
variant="elevated"
|
||||
class="ma-1 filter-chip"
|
||||
size="small"
|
||||
>
|
||||
{{ option }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</VCardText>
|
||||
<VCardActions>
|
||||
<VSpacer />
|
||||
<VBtn color="primary" prepend-icon="mdi-check" class="px-5" @click="visible = false">
|
||||
{{ t('torrent.confirm') }}
|
||||
</VBtn>
|
||||
</VCardActions>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
56
src/components/dialog/TransferHistoryDeleteDialog.vue
Normal file
56
src/components/dialog/TransferHistoryDeleteDialog.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
title: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'delete', deleteSrc: boolean, deleteDest: boolean): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 选择删除范围并通知历史列表执行实际删除。
|
||||
function selectDeleteMode(deleteSrc: boolean, deleteDest: boolean) {
|
||||
emit('delete', deleteSrc, deleteDest)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VBottomSheet v-if="visible" v-model="visible" inset>
|
||||
<VCard class="text-center">
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
<VCardTitle class="pe-10">
|
||||
{{ props.title }}
|
||||
</VCardTitle>
|
||||
<div class="d-flex flex-column flex-lg-row justify-center my-3">
|
||||
<VBtn color="primary" class="mb-2 mx-2" @click="selectDeleteMode(false, false)">
|
||||
{{ $t('transferHistory.deleteRecordOnly') }}
|
||||
</VBtn>
|
||||
<VBtn color="warning" class="mb-2 mx-2" @click="selectDeleteMode(true, false)">
|
||||
{{ $t('transferHistory.deleteSourceOnly') }}
|
||||
</VBtn>
|
||||
<VBtn color="info" class="mb-2 mx-2" @click="selectDeleteMode(false, true)">
|
||||
{{ $t('transferHistory.deleteDestOnly') }}
|
||||
</VBtn>
|
||||
<VBtn color="error" class="mb-2 mx-2" @click="selectDeleteMode(true, true)">
|
||||
{{ $t('transferHistory.deleteAll') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VCard>
|
||||
</VBottomSheet>
|
||||
</template>
|
||||
166
src/components/dialog/TransparencySettingsDialog.vue
Normal file
166
src/components/dialog/TransparencySettingsDialog.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { useTransparencySettings } from '@/composables/useTransparencySettings'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 国际化
|
||||
const { t } = useI18n()
|
||||
|
||||
// 输入参数
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
},
|
||||
)
|
||||
|
||||
// 定义触发的自定义事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
// 弹窗显示状态
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
adjustTransparency,
|
||||
backgroundBlur,
|
||||
backgroundPosterOpacity,
|
||||
currentPresetLevel,
|
||||
onBackgroundBlurChange,
|
||||
onBackgroundPosterOpacityChange,
|
||||
onBlurChange,
|
||||
onOpacityChange,
|
||||
resetTransparencySettings,
|
||||
transparencyBlur,
|
||||
transparencyOpacity,
|
||||
} = useTransparencySettings()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem">
|
||||
<VCard>
|
||||
<VCardItem>
|
||||
<VCardTitle>
|
||||
<VIcon icon="mdi-opacity" class="me-2" />
|
||||
{{ t('theme.transparencyAdjust') }}
|
||||
</VCardTitle>
|
||||
<VDialogCloseBtn v-model="visible" />
|
||||
</VCardItem>
|
||||
<VDivider />
|
||||
<VCardText>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<span class="text-body-2">{{ t('theme.transparencyOpacity') }}</span>
|
||||
<span class="text-caption">{{ Math.round(transparencyOpacity * 100) }}%</span>
|
||||
</div>
|
||||
<VSlider
|
||||
v-model="transparencyOpacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
color="primary"
|
||||
@update:model-value="onOpacityChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<span class="text-body-2">{{ t('theme.transparencyBlur') }}</span>
|
||||
<span class="text-caption">{{ transparencyBlur }}px</span>
|
||||
</div>
|
||||
<VSlider
|
||||
v-model="transparencyBlur"
|
||||
:min="0"
|
||||
:max="30"
|
||||
:step="1"
|
||||
color="primary"
|
||||
@update:model-value="onBlurChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<span class="text-body-2">{{ t('theme.backgroundPosterOpacity') }}</span>
|
||||
<span class="text-caption">{{ Math.round(backgroundPosterOpacity * 100) }}%</span>
|
||||
</div>
|
||||
<VSlider
|
||||
v-model="backgroundPosterOpacity"
|
||||
:min="0"
|
||||
:max="1"
|
||||
:step="0.01"
|
||||
color="primary"
|
||||
@update:model-value="onBackgroundPosterOpacityChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="d-flex align-center justify-space-between mb-2">
|
||||
<span class="text-body-2">{{ t('theme.backgroundBlur') }}</span>
|
||||
<span class="text-caption">{{ backgroundBlur }}px</span>
|
||||
</div>
|
||||
<VSlider
|
||||
v-model="backgroundBlur"
|
||||
:min="0"
|
||||
:max="30"
|
||||
:step="1"
|
||||
color="primary"
|
||||
@update:model-value="onBackgroundBlurChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-body-2 d-block mb-2">{{ t('common.preset') }}</span>
|
||||
<VBtnGroup density="compact" variant="outlined" class="w-full">
|
||||
<VBtn
|
||||
size="small"
|
||||
:color="currentPresetLevel === 'low' ? 'primary' : undefined"
|
||||
@click="adjustTransparency('low')"
|
||||
class="flex-1"
|
||||
>
|
||||
{{ t('theme.transparencyLow') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
:color="currentPresetLevel === 'medium' ? 'primary' : undefined"
|
||||
@click="adjustTransparency('medium')"
|
||||
class="flex-1"
|
||||
>
|
||||
{{ t('theme.transparencyMedium') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
size="small"
|
||||
:color="currentPresetLevel === 'high' ? 'primary' : undefined"
|
||||
@click="adjustTransparency('high')"
|
||||
class="flex-1"
|
||||
>
|
||||
{{ t('theme.transparencyHigh') }}
|
||||
</VBtn>
|
||||
</VBtnGroup>
|
||||
</div>
|
||||
</div>
|
||||
</VCardText>
|
||||
<VDivider />
|
||||
<VCardText class="text-center">
|
||||
<VBtn @click="resetTransparencySettings" variant="outlined" class="me-2">
|
||||
<template #prepend>
|
||||
<VIcon icon="mdi-refresh" />
|
||||
</template>
|
||||
{{ t('theme.transparencyReset') }}
|
||||
</VBtn>
|
||||
<VBtn @click="visible = false" color="primary">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
71
src/components/dialog/VerifyPasswordDialog.vue
Normal file
71
src/components/dialog/VerifyPasswordDialog.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: boolean
|
||||
text?: string
|
||||
title?: string
|
||||
}>(),
|
||||
{
|
||||
modelValue: true,
|
||||
text: '',
|
||||
title: '',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
(event: 'confirm', password: string): void
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const password = ref('')
|
||||
const passwordVisible = ref(false)
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: value => {
|
||||
emit('update:modelValue', value)
|
||||
if (!value) emit('close')
|
||||
},
|
||||
})
|
||||
|
||||
// 提交当前输入的密码给调用方继续业务验证。
|
||||
function submitPassword() {
|
||||
emit('confirm', password.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VDialog v-if="visible" v-model="visible" max-width="30rem">
|
||||
<VCard>
|
||||
<VCardTitle class="text-h5 text-center mt-4">{{ props.title }}</VCardTitle>
|
||||
<VCardText>
|
||||
<p class="mb-4">{{ props.text }}</p>
|
||||
<VForm @submit.prevent="submitPassword">
|
||||
<VTextField
|
||||
v-model="password"
|
||||
:type="passwordVisible ? 'text' : 'password'"
|
||||
:label="t('user.password')"
|
||||
:append-inner-icon="passwordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-lock"
|
||||
autocomplete="current-password"
|
||||
@click:append-inner="passwordVisible = !passwordVisible"
|
||||
/>
|
||||
<div class="d-flex justify-end gap-4 mt-4">
|
||||
<VBtn variant="outlined" color="secondary" @click="visible = false">
|
||||
{{ t('common.cancel') }}
|
||||
</VBtn>
|
||||
<VBtn type="submit" color="primary">
|
||||
{{ t('common.confirm') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
</VForm>
|
||||
</VCardText>
|
||||
</VCard>
|
||||
</VDialog>
|
||||
</template>
|
||||
Reference in New Issue
Block a user