This commit is contained in:
jxxghp
2023-12-14 07:10:56 +08:00
16 changed files with 850 additions and 35 deletions

23
src/@core/utils/image.ts Normal file
View File

@@ -0,0 +1,23 @@
import ColorThief from 'colorthief'
// 将 RGB 转换为十六进制
function rgbStringToHex(rgbArray: number[]): string {
if (rgbArray.length !== 3 || rgbArray.some(isNaN))
throw new Error('Invalid RGB string format')
const [r, g, b] = rgbArray
const toHex = (c: number): string => {
const hex = c.toString(16)
return hex.length === 1 ? `0${hex}` : hex
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
// 提取主要颜色
export async function getDominantColor(image: HTMLImageElement): Promise<string> {
const colorThief = new ColorThief()
const dominantColor = colorThief.getColor(image)
return rgbStringToHex(dominantColor)
}

View File

@@ -0,0 +1,30 @@
// 请求和获取剪贴板内容
export async function getClipboardContent() {
if (navigator.clipboard && window.isSecureContext) {
return await navigator.clipboard.readText()
}
else {
const input = document.createElement('textarea')
document.body.appendChild(input)
input.select()
document.execCommand('paste')
const content = input.value
document.body.removeChild(input)
return content
}
}
// 将内容复制到剪贴板,兼容非安全域场景
export async function copyToClipboard(content: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(content)
}
else {
const input = document.createElement('textarea')
input.value = content
document.body.appendChild(input)
input.select()
document.execCommand('copy')
document.body.removeChild(input)
}
}

View File

@@ -540,9 +540,6 @@ export interface Plugin {
// 插件图标
plugin_icon?: string
// 主题色
plugin_color?: string
// 插件版本
plugin_version?: string

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -2,6 +2,8 @@
import { useToast } from 'vue-toast-notification'
import api from '@/api'
import type { Plugin } from '@/api/types'
import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
// 输入参数
const props = defineProps({
@@ -13,6 +15,12 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['install'])
// 背景颜色
const backgroundColor = ref('#28A9E1')
// 图片对象
const imageRef = ref<any>()
// 提示框
const $toast = useToast()
@@ -25,6 +33,17 @@ const progressText = ref('正在安装插件...')
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
// 从图片中提取背景色
backgroundColor.value = await getDominantColor(imageElement)
}
// 安装插件
async function installPlugin() {
try {
@@ -61,11 +80,54 @@ async function installPlugin() {
}
// 计算图标路径
const iconPath = computed(() => {
return props.plugin?.plugin_icon?.startsWith('http')
? props.plugin?.plugin_icon
: `/plugin_icon/${props.plugin?.plugin_icon}`
const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value)
return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}`
return `/plugin_icon/${props.plugin?.plugin_icon}`
})
// 访问插件页面
function visitPluginPage() {
// 将raw.githubusercontent.com转换为项目地址
let repoUrl = props.plugin?.repo_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')
}
// 弹出菜单
const dropdownItems = ref([
{
title: '查看详情',
value: 1,
props: {
prependIcon: 'mdi-information-outline',
click: visitPluginPage,
},
},
])
</script>
<template>
@@ -76,11 +138,34 @@ const iconPath = computed(() => {
>
<div
class="relative pa-4 text-center card-cover-blurred"
:style="{ background: `${props.plugin?.plugin_color}` }"
:style="{ background: `${backgroundColor}` }"
>
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" class="text-white" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
v-for="(item, i) in dropdownItems"
:key="i"
variant="plain"
@click="item.props.click"
>
<template #prepend>
<VIcon :icon="item.props.prependIcon" />
</template>
<VListItemTitle v-text="item.title" />
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
<div
v-if="props.plugin?.has_update"
class="me-n3 absolute top-0 right-5"
class="me-n3 absolute top-0 left-1"
>
<VIcon
icon="mdi-new-box"
@@ -91,11 +176,13 @@ const iconPath = computed(() => {
size="8rem"
>
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="isImageLoaded = true"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>

View File

@@ -6,6 +6,8 @@ import type { Plugin } from '@/api/types'
import FormRender from '@/components/render/FormRender.vue'
import PageRender from '@/components/render/PageRender.vue'
import { isNullOrEmptyObject } from '@core/utils'
import noImage from '@images/logos/plugin.png'
import { getDominantColor } from '@/@core/utils/image'
// 输入参数
const props = defineProps({
@@ -17,6 +19,12 @@ const props = defineProps({
// 定义触发的自定义事件
const emit = defineEmits(['remove', 'save'])
// 背景颜色
const backgroundColor = ref('#28A9E1')
// 图片对象
const imageRef = ref<any>()
// 提示框
const $toast = useToast()
@@ -35,15 +43,26 @@ const pluginConfigForm = ref({})
// 插件表单配置项
let pluginFormItems = reactive([])
// 插件详情页面
// 插件数据页面
const pluginInfoDialog = ref(false)
// 插件详情页面配置项
// 插件数据页面配置项
let pluginPageItems = reactive([])
// 图片是否加载完成
const isImageLoaded = ref(false)
// 图片是否加载失败
const imageLoadError = ref(false)
// 图片加载完成
async function imageLoaded() {
isImageLoaded.value = true
const imageElement = imageRef.value?.$el.querySelector('img') as HTMLImageElement
// 从图片中提取背景色
backgroundColor.value = await getDominantColor(imageElement)
}
// 调用API卸载插件
async function uninstallPlugin() {
const isConfirmed = await createConfirm({
@@ -94,7 +113,7 @@ async function loadPluginForm() {
}
}
// 调用API读取详情页面
// 调用API读取数据页面
async function loadPluginPage() {
try {
const result: [] = await api.get(`plugin/page/${props.plugin?.id}`)
@@ -137,9 +156,9 @@ async function savePluginConf() {
}
}
// 显示插件详情
// 显示插件数据
async function showPluginInfo() {
// 加载详情
// 加载数据
await loadPluginPage()
pluginConfigDialog.value = false
pluginInfoDialog.value = true
@@ -157,10 +176,14 @@ async function showPluginConfig() {
}
// 计算图标路径
const iconPath = computed(() => {
return props.plugin?.plugin_icon?.startsWith('http')
? props.plugin?.plugin_icon
: `/plugin_icon/${props.plugin?.plugin_icon}`
const iconPath: Ref<string> = computed(() => {
if (imageLoadError.value)
return noImage
// 如果是网络图片则使用代理后返回
if (props.plugin?.plugin_icon?.startsWith('http'))
return `${import.meta.env.VITE_API_BASE_URL}system/img/${encodeURIComponent(props.plugin?.plugin_icon)}`
return `/plugin_icon/${props.plugin?.plugin_icon}`
})
// 重置插件
@@ -197,10 +220,15 @@ async function resetPlugin() {
}
}
// 访问作者主页
function visitAuthorPage() {
window.open(props.plugin?.author_url, '_blank')
}
// 弹出菜单
const dropdownItems = ref([
{
title: '查看详情',
title: '查看数据',
value: 1,
show: props.plugin?.has_page,
props: {
@@ -209,7 +237,7 @@ const dropdownItems = ref([
},
},
{
title: '置',
title: '置',
value: 2,
show: true,
props: {
@@ -237,6 +265,15 @@ const dropdownItems = ref([
click: uninstallPlugin,
},
},
{
title: '作者主页',
value: 4,
show: true,
props: {
prependIcon: 'mdi-home-circle-outline',
click: visitAuthorPage,
},
},
])
</script>
@@ -255,7 +292,7 @@ const dropdownItems = ref([
>
<div
class="relative pa-4 text-center card-cover-blurred"
:style="{ background: `${props.plugin?.plugin_color}` }"
:style="{ background: `${backgroundColor}` }"
>
<div class="me-n3 absolute top-0 right-3">
<IconBtn>
@@ -286,10 +323,13 @@ const dropdownItems = ref([
size="8rem"
>
<VImg
ref="imageRef"
:src="iconPath"
aspect-ratio="4/3"
cover
:class="{ shadow: isImageLoaded }"
@load="imageLoaded"
@error="imageLoadError = true"
/>
</VAvatar>
</div>
@@ -324,7 +364,7 @@ const dropdownItems = ref([
</VCardText>
<VCardActions>
<VBtn v-if="pluginPageItems.length > 0" @click="showPluginInfo">
查看详情
查看数据
</VBtn>
<VSpacer />
<VBtn
@@ -337,7 +377,7 @@ const dropdownItems = ref([
</VCard>
</VDialog>
<!-- 插件详情页面 -->
<!-- 插件数据页面 -->
<VDialog
v-model="pluginInfoDialog"
scrollable

View File

@@ -25,7 +25,6 @@ const tmdbKeyword = ref<HTMLElement | null>(null)
// 选中条目
function selectMedia(item: TmdbItem) {
console.log(item)
emit('update:modelValue', item.tmdbid)
emit('close')
}

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
// 输入参数
const props = defineProps({
title: String,
})
// 定义事件
const emit = defineEmits(['update:modelValue', 'close'])
// 代码
const codeString = ref('')
// 导入
function handleImport() {
emit('update:modelValue', codeString.value)
emit('close')
}
</script>
<template>
<VCard
:title="props.title"
class="rounded-t"
>
<DialogCloseBtn @click="emit('close')" />
<VCardText class="pt-2">
<VTextarea v-model="codeString" />
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="tonal"
@click="handleImport"
>
导入
</VBtn>
</VCardActions>
</VCard>
</template>

View File

@@ -199,7 +199,7 @@ async function updateSiteInfo() {
md="4"
>
<VTextField
v-model="siteForm.limit_seconds"
v-model="siteForm.limit_count"
label="访问次数"
:rules="[numberValidator]"
/>

View File

@@ -39,6 +39,7 @@ const subscribeForm = ref<Subscribe>({
last_update: '',
username: '',
current_priority: 0,
save_path: ''
})
// 提示框
@@ -321,6 +322,17 @@ watchEffect(() => {
multiple
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="4"
>
<VTextField
v-model="subscribeForm.save_path"
label="保存路径"
/>
</VCol>
</VRow>
<VRow>
<VCol

View File

@@ -40,6 +40,9 @@ const dataList = ref<TransferHistory[]>([])
// 搜索
const search = ref('')
// 搜索提示词列表
const searchHintList = ref<string[]>([])
// 加载状态
const loading = ref(false)
@@ -89,6 +92,7 @@ async function fetchData({
dataList.value = result.data.list
totalItems.value = result.data.total
searchHintList.value = [...new Set(dataList.value.map(item => item.title || ''))].filter(title => title !== '')
}
catch (error) {
console.error(error)
@@ -294,9 +298,10 @@ const dropdownItems = ref([
<VRow>
<VCol> 历史记录 </VCol>
<VCol>
<VTextField
<VCombobox
key="search_navbar"
v-model="search"
:items="searchHintList"
class="text-disabled"
density="compact"
label="搜索"
@@ -306,6 +311,7 @@ const dropdownItems = ref([
hide-details
flat
rounded
clearable
/>
</VCol>
</VRow>

View File

@@ -3,6 +3,8 @@ import { useToast } from 'vue-toast-notification'
import api from '@/api'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import type { Site } from '@/api/types'
import { copyToClipboard } from '@/@core/utils/navigator'
import ImportCodeForm from '@/components/form/ImportCodeForm.vue'
// 规则卡片类型
interface FilterCard {
@@ -30,6 +32,12 @@ const defaultFilterRules = ref({
exclude: '',
})
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入的代码
const importCodeString = ref('')
// 查询已设置优先级规则
async function queryCustomFilters() {
try {
@@ -227,6 +235,49 @@ async function saveDefaultFilter() {
}
}
// 分享规则
function shareRules() {
// 有值才处理
if (filterCards.value.length === 0)
return
// 将卡片规则接装为字符串
const value = filterCards.value
.filter(card => card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
// 复制到剪贴板
try {
copyToClipboard(value)
$toast.success('优先级规则已复制到剪贴板')
}
catch (error) {
$toast.error('优先级规则复制失败!')
}
}
// 监听导入代码变化
watchEffect(() => {
if (!importCodeString.value)
return
// 导入代码需要以空格开头和结束,没有则拼接
if (!importCodeString.value.startsWith(' '))
importCodeString.value = ` ${importCodeString.value}`
if (!importCodeString.value.endsWith(' '))
importCodeString.value = `${importCodeString.value} `
// 将导入的代码转换为规则卡片
const groups = importCodeString.value.split('>')
filterCards.value = groups.map((group: string, index: number) => {
return {
pri: (index + 1).toString(),
rules: group.split('&'),
}
})
})
onMounted(() => {
queryCustomFilters()
querySites()
@@ -264,6 +315,36 @@ onMounted(() => {
</VCol>
<VCol cols="12">
<VCard title="搜索优先级">
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="shareRules"
>
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem
variant="plain"
@click="importCodeDialog = true"
>
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在搜索时默认使用的优先级排序未在优先级中的资源将不在搜索结果中显示 </VCardSubtitle>
<VCardItem>
<div class="grid gap-3 grid-filterrule-card">
@@ -332,6 +413,17 @@ onMounted(() => {
</VCard>
</VCol>
</VRow>
<VDialog
v-model="importCodeDialog"
width="60rem"
scrollable
>
<ImportCodeForm
v-model="importCodeString"
title="导入优先级规则"
@close="importCodeDialog = false"
/>
</VDialog>
</template>
<style lang="scss">

View File

@@ -3,6 +3,8 @@ import { useToast } from 'vue-toast-notification'
import api from '@/api'
import FilterRuleCard from '@/components/cards/FilterRuleCard.vue'
import type { Site } from '@/api/types'
import { copyToClipboard } from '@/@core/utils/navigator'
import ImportCodeForm from '@/components/form/ImportCodeForm.vue'
// 规则卡片类型
interface FilterCard {
@@ -27,12 +29,21 @@ const allSites = ref<Site[]>([])
// 选中订阅站点
const selectedRssSites = ref<number[]>([])
// 当前规则类型
const currentRuleType = ref('SubscribeFilterRules')
// 包含与排除规则
const defaultFilterRules = ref({
include: '',
exclude: '',
})
// 导入代码弹窗
const importCodeDialog = ref(false)
// 导入的代码
const importCodeString = ref('')
// 查询用户选中的订阅站点
async function querySelectedRssSites() {
try {
@@ -244,6 +255,66 @@ async function saveDefaultFilter() {
}
}
// 分享规则
function shareRules(ruleType: string) {
let filterCards: Ref<FilterCard[]>
if (ruleType === 'SubscribeFilterRules')
filterCards = subscribeFilterCards
else
filterCards = bestVersionFilterCards
// 有值才处理
if (filterCards.value.length === 0)
return
// 将卡片规则接装为字符串
const value = filterCards.value
.filter(card => card.rules.length > 0)
.map(card => card.rules.join('&'))
.join('>')
// 复制到剪贴板
try {
copyToClipboard(value)
$toast.success('优先级规则已复制到剪贴板')
}
catch (error) {
$toast.error('优先级规则复制失败!')
}
}
// 导入规则
async function importRules(ruleType: string) {
currentRuleType.value = ruleType
importCodeString.value = ''
importCodeDialog.value = true
}
// 监听导入代码变化
watchEffect(() => {
if (!importCodeString.value)
return
if (!currentRuleType.value)
return
// 导入代码需要以空格开头和结束,没有则拼接
if (!importCodeString.value.startsWith(' '))
importCodeString.value = ` ${importCodeString.value}`
if (!importCodeString.value.endsWith(' '))
importCodeString.value = `${importCodeString.value} `
let filterCards: Ref<FilterCard[]>
if (currentRuleType.value === 'SubscribeFilterRules')
filterCards = subscribeFilterCards
else
filterCards = bestVersionFilterCards
// 将导入的代码转换为规则卡片
const groups = importCodeString.value.split('>')
filterCards.value = groups.map((group: string, index: number) => {
return {
pri: (index + 1).toString(),
rules: group.split('&'),
}
})
})
onMounted(() => {
querySites()
queryCustomFilters('SubscribeFilterRules')
@@ -282,6 +353,36 @@ onMounted(() => {
</VCol>
<VCol cols="12">
<VCard title="订阅优先级">
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="shareRules('SubscribeFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem
variant="plain"
@click="importRules('SubscribeFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在正常订阅时默认使用的优先级未在优先级中的资源将不会自动下载 </VCardSubtitle>
<VCardItem>
<div class="grid gap-3 grid-filterrule-card">
@@ -318,6 +419,36 @@ onMounted(() => {
</VCol>
<VCol cols="12">
<VCard title="洗版优先级">
<template #append>
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu
activator="parent"
close-on-content-click
>
<VList>
<VListItem
variant="plain"
@click="shareRules('BestVersionFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-share" />
</template>
<VListItemTitle>分享</VListItemTitle>
</VListItem>
<VListItem
variant="plain"
@click="importRules('BestVersionFilterRules')"
>
<template #prepend>
<VIcon icon="mdi-import" />
</template>
<VListItemTitle>导入</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</template>
<VCardSubtitle> 设置在订阅洗版时使用的优先级匹配优先级1时洗版完成 </VCardSubtitle>
<VCardItem>
<div class="grid gap-3 grid-filterrule-card">
@@ -386,6 +517,17 @@ onMounted(() => {
</VCard>
</VCol>
</VRow>
<VDialog
v-model="importCodeDialog"
width="60rem"
scrollable
>
<ImportCodeForm
v-model="importCodeString"
title="导入优先级规则"
@close="importCodeDialog = false"
/>
</VDialog>
</template>
<style lang="scss">

View File

@@ -173,7 +173,7 @@ onMounted(() => {
<VAlert
type="info"
variant="tonal"
title="支持的配置格式:"
title="支持的配置格式(注意空格)"
>
<span
v-html="`