Merge branch 'dev' into dev-user

This commit is contained in:
Aqr-K
2024-10-18 11:03:01 +08:00
committed by GitHub
20 changed files with 1111 additions and 429 deletions

View File

@@ -101,4 +101,4 @@
"resolutions": {
"postcss": "8"
}
}
}

View File

@@ -60,19 +60,25 @@ export const prefixWithPlus = (value: number) => (value > 0 ? `+${value}` : valu
export const formatSeason = (value: string) => (value ? `S${value.padStart(2, '0')}` : '')
// 格式化为xx[TGMK]B
export function formatFileSize(bytes: number, decimals = 2) {
if (bytes < 0) throw new Error('字节数不能为负数。')
export function formatFileSize(bytes: number, decimals = 2, prefix = false) {
// 负数标记
let negative = false
let size = bytes
if (bytes < 0) {
negative = true
size = Math.abs(bytes)
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex++
}
return `${size.toFixed(decimals)} ${units[unitIndex]}`
if (negative) return `-${size.toFixed(decimals)} ${units[unitIndex]}`
else
return prefix ? `+${size.toFixed(decimals)} ${units[unitIndex]}` : `${size.toFixed(decimals)} ${units[unitIndex]}`
}
// 将时间秒格式化为时分秒

View File

@@ -435,6 +435,48 @@ export interface SiteStatistic {
note?: string
}
// 站点用户数据
export interface SiteUserData {
// 站点域名
domain?: string
// 用户名
username?: string
// 用户ID
userid?: number
// 用户等级
user_level?: string
// 加入时间
join_at?: string
// 积分
bonus?: number // 默认为 0.0
// 上传量
upload?: number // 默认为 0
// 下载量
download?: number // 默认为 0
// 分享率
ratio?: number // 默认为 0
// 做种数
seeding?: number // 默认为 0
// 下载数
leeching?: number // 默认为 0
// 做种体积
seeding_size?: number // 默认为 0
// 下载体积
leeching_size?: number // 默认为 0
// 做种人数, 种子大小
seeding_info?: any[] // 默认为空数组
// 未读消息
message_unread?: number // 默认为 0
// 未读消息内容
message_unread_contents?: any[] // 默认为空数组
// 错误信息
err_msg?: string | null // 默认为 null
// 更新日期
updated_day?: string
// 更新时间
updated_time?: string
}
// 正在下载
export interface DownloadingInfo {
// HASH

View File

@@ -374,7 +374,7 @@ const getImgUrl: Ref<string> = computed(() => {
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}douban/img?imgurl=${encodeURIComponent(url)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
})

View File

@@ -16,8 +16,8 @@ const props = defineProps({
// 所有媒体服务器
mediaservers: {
type: Array as PropType<MediaServerConf[]>,
required: true
}
required: true,
},
})
// 提示框
@@ -46,7 +46,12 @@ const infoItems = ref([
])
// 同步媒体库选项
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([])
const librariesOptions = ref<{ title: string; value: string | undefined }[]>([
{
title: '全部',
value: 'all',
},
])
// 媒体服务器详情弹窗
const mediaServerInfoDialog = ref(false)
@@ -68,6 +73,9 @@ function openMediaServerInfoDialog() {
mediaServerInfo.value = props.mediaserver
mediaServerName.value = props.mediaserver.name
mediaServerInfoDialog.value = true
if (!props.mediaserver.sync_libraries) {
mediaServerInfo.value.sync_libraries = ['all']
}
}
// 保存详情数据
@@ -78,7 +86,7 @@ function saveMediaServerInfo() {
return
}
// 重名判断
if (props.mediaservers.some(item => item.name === mediaServerName.value && item!== props.mediaserver)) {
if (props.mediaservers.some(item => item.name === mediaServerName.value && item !== props.mediaserver)) {
$toast.error(`${mediaServerName.value}】已存在,请替换为其他名称`)
return
}
@@ -148,7 +156,13 @@ async function loadLibrary(server: string) {
title: item.name,
value: item.id?.toString(),
}))
} else {
librariesOptions.value = []
}
librariesOptions.value.unshift({
title: '全部',
value: 'all',
})
} catch (e) {
console.log(e)
}
@@ -174,7 +188,7 @@ onMounted(() => {
<VImg :src="getIcon" cover class="mt-5 me-3" max-width="3rem" min-width="3rem" />
</VCardText>
</VCard>
<VDialog v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent >
<VDialog v-model="mediaServerInfoDialog" scrollable max-width="40rem" persistent>
<VCard :title="`${props.mediaserver.name} - 配置`" class="rounded-t">
<DialogCloseBtn v-model="mediaServerInfoDialog" />
<VDivider />

View File

@@ -2,30 +2,24 @@
import type { PropType } from 'vue'
import { useToast } from 'vue-toast-notification'
import SiteAddEditDialog from '../dialog/SiteAddEditDialog.vue'
import SiteTorrentTable from '../table/SiteTorrentTable.vue'
import { requiredValidator } from '@/@validators'
import SiteUserDataDialog from '../dialog/SiteUserDataDialog.vue'
import SiteResourceDialog from '../dialog/SiteResourceDialog.vue'
import SiteCookieUpdateDialog from '../dialog/SiteCookieUpdateDialog.vue'
import api from '@/api'
import type { Site, SiteStatistic } from '@/api/types'
import type { Site, SiteStatistic, SiteUserData } from '@/api/types'
import { isNullOrEmptyObject } from '@/@core/utils'
import { useDisplay } from 'vuetify'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 显示器宽度
const display = useDisplay()
import { VCardActions, VExpandTransition, VProgressLinear, VSpacer } from 'vuetify/lib/components/index.mjs'
import { formatFileSize } from '@/@core/utils/formatters'
// 输入参数
const cardProps = defineProps({
site: Object as PropType<Site>,
width: String,
height: String,
data: Object as PropType<SiteUserData>,
})
// 定义触发的自定义事件
const emit = defineEmits(['update', 'remove'])
// 密码输入
const isPasswordVisible = ref(false)
// 图标
const siteIcon = ref<string>('')
@@ -38,9 +32,6 @@ const testButtonText = ref('测试')
// 测试按钮可用性
const testButtonDisable = ref(false)
// 更新按钮可用性
const updateButtonDisable = ref(false)
// 更新站点Cookie UA弹窗
const siteCookieDialog = ref(false)
@@ -50,18 +41,11 @@ const siteEditDialog = ref(false)
// 资源浏览弹窗
const resourceDialog = ref(false)
// 进度条
const progressDialog = ref(false)
// 用户数据弹窗
const siteUserDataDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
// 用户名密码表单
const userPwForm = ref({
username: '',
password: '',
code: '',
})
// 站点操作显示
const siteActionShow = ref(false)
// 站点使用统计
const siteStats = ref<SiteStatistic>({})
@@ -113,34 +97,9 @@ async function handleResourceBrowse() {
resourceDialog.value = true
}
// 调用API更新站点Cookie UA
async function updateSiteCookie() {
try {
if (!userPwForm.value.username || !userPwForm.value.password) return
// 更新按钮状态
siteCookieDialog.value = false
updateButtonDisable.value = true
progressDialog.value = true
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
params: {
username: userPwForm.value.username,
password: userPwForm.value.password,
code: userPwForm.value.code,
},
})
if (result.success) $toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`)
else $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
progressDialog.value = false
updateButtonDisable.value = false
} catch (error) {
console.error(error)
}
// 打开站点用户数据弹窗
async function handleSiteUserData() {
siteUserDataDialog.value = true
}
// 打开站点页面
@@ -162,9 +121,10 @@ const statColor = computed(() => {
}
})
// 监听resourceDialog如果为false则重新查询站点使用统计
watch(resourceDialog, value => {
if (!value) getSiteStats()
// 计算上传量和下载量的百分比
const getPercentage = computed(() => {
if (cardProps.data?.upload === 0) return 100
return ((cardProps.data?.download ?? 0) / ((cardProps.data?.download ?? 0) + (cardProps.data?.upload ?? 0))) * 100
})
// 保存站点
@@ -173,6 +133,18 @@ function saveSite() {
emit('update')
}
// 更新站点Cookie UA后的回调
function onSiteCookieUpdated() {
siteCookieDialog.value = false
getSiteStats()
}
// 资源浏览弹窗关闭后的回调
function onSiteResourceDone() {
resourceDialog.value = false
getSiteStats()
}
// 装载时查询站点图标
onMounted(() => {
getSiteIcon()
@@ -183,8 +155,6 @@ onMounted(() => {
<template>
<div>
<VCard
:height="cardProps.height"
:width="cardProps.width"
:variant="cardProps.site?.is_active ? 'elevated' : 'outlined'"
class="overflow-hidden"
@click="siteEditDialog = true"
@@ -194,7 +164,7 @@ onMounted(() => {
<VImg :src="siteIcon" />
</VAvatar>
</template>
<VCardItem style="padding-block-end: 0;">
<VCardItem style="padding-block-end: 0">
<VCardTitle class="font-bold">
<span @click.stop="openSitePage">{{ cardProps.site?.name }}</span>
</VCardTitle>
@@ -202,7 +172,7 @@ onMounted(() => {
<span @click.stop="openSitePage">{{ cardProps.site?.url }}</span>
</VCardSubtitle>
</VCardItem>
<VCardText class="py-2" style="block-size: 36px;">
<VCardText class="py-1">
<VTooltip v-if="cardProps.site?.limit_interval" text="流控">
<template #activator="{ props }">
<VIcon color="primary" class="me-2" v-bind="props" icon="mdi-speedometer" />
@@ -224,67 +194,61 @@ onMounted(() => {
</template>
</VTooltip>
</VCardText>
<VDivider />
<VCardActions>
<VBtn v-if="!cardProps.site?.public" :disabled="updateButtonDisable" @click.stop="handleSiteUpdate">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
更新
</VBtn>
<VBtn :disabled="testButtonDisable" @click.stop="testSite">
<template #prepend>
<VIcon icon="mdi-link" />
</template>
{{ testButtonText }}
</VBtn>
<VBtn @click.stop="handleResourceBrowse">
<template #prepend>
<VIcon icon="mdi-web" />
</template>
浏览
</VBtn>
<VBtn
:icon="siteActionShow ? 'mdi-chevron-up' : 'mdi-chevron-down'"
@click.stop="siteActionShow = !siteActionShow"
/>
<span class="text-sm">
{{ formatFileSize(cardProps.data?.upload || 0) }} / {{ formatFileSize(cardProps.data?.download || 0) }}
</span>
<VSpacer />
</VCardActions>
<VDivider class="mb-1" v-if="siteActionShow" />
<VExpandTransition>
<div v-show="siteActionShow" class="py-1 pe-12">
<VBtn v-if="!cardProps.site?.public" @click.stop="handleSiteUpdate" variant="text">
<template #prepend>
<VIcon icon="mdi-refresh" />
</template>
更新
</VBtn>
<VBtn :disabled="testButtonDisable" @click.stop="testSite" variant="text">
<template #prepend>
<VIcon icon="mdi-link" />
</template>
{{ testButtonText }}
</VBtn>
<VBtn @click.stop="handleResourceBrowse" variant="text">
<template #prepend>
<VIcon icon="mdi-web" />
</template>
浏览
</VBtn>
<VBtn @click.stop="handleSiteUserData" variant="text">
<template #prepend>
<VIcon icon="mdi-chart-bell-curve" />
</template>
数据
</VBtn>
</div>
</VExpandTransition>
<StatIcon v-if="cardProps.site?.is_active" :color="statColor" />
<span class="absolute top-1 right-8">
<VIcon class="cursor-move">mdi-drag</VIcon>
</span>
<div class="w-full absolute bottom-0" v-if="(cardProps.data?.upload || cardProps.data?.download || 0) > 0">
<VProgressLinear :model-value="getPercentage" bg-color="success" color="warning" bg-opacity="0.5" height="3" />
</div>
</VCard>
<!-- 更新站点Cookie & UA弹窗 -->
<VDialog v-model="siteCookieDialog" max-width="50rem">
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<DialogCloseBtn @click="siteCookieDialog = false" />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="userPwForm.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@keydown.enter="updateSiteCookie"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.code" label="两步验证" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn variant="elevated" @click="updateSiteCookie" prepend-icon="mdi-refresh" class="px-5"> 开始更新 </VBtn>
</VCardActions>
</VCard>
</VDialog>
<SiteCookieUpdateDialog
v-if="siteCookieDialog"
v-model="siteCookieDialog"
:site="cardProps.site"
@close="siteCookieDialog = false"
@done="onSiteCookieUpdated"
/>
<!-- 站点编辑弹窗 -->
<SiteAddEditDialog
v-if="siteEditDialog"
@@ -294,30 +258,19 @@ onMounted(() => {
@remove="emit('remove')"
@close="siteEditDialog = false"
/>
<!-- 站点数据弹窗 -->
<SiteUserDataDialog
v-if="siteUserDataDialog"
v-model="siteUserDataDialog"
:site="cardProps.site"
@close="siteUserDataDialog = false"
/>
<!-- 站点资源弹窗 -->
<VDialog
<SiteResourceDialog
v-if="resourceDialog"
v-model="resourceDialog"
max-width="80rem"
scrollable
z-index="1010"
:fullscreen="!display.mdAndUp.value"
>
<VCard :title="`浏览站点 - ${cardProps.site?.name}`">
<DialogCloseBtn @click="resourceDialog = false" />
<VDivider />
<VCardText class="pt-2">
<SiteTorrentTable :site="cardProps.site?.id" />
</VCardText>
</VCard>
</VDialog>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
:site="cardProps.site"
@close="onSiteResourceDone"
/>
</div>
</template>
<style lang="scss" scoped>
.v-table th {
white-space: nowrap;
}
</style>

View File

@@ -40,6 +40,12 @@ const siteForm = ref<Site>({
// 提示框
const $toast = useToast()
// 维护类型
const siteType = ref('cookie')
// 是否限流
const isLimit = ref(false)
// 状态下拉项
const statusItems = [
{ title: '启用', value: true },
@@ -106,6 +112,15 @@ async function deleteSiteInfo() {
async function updateSiteInfo() {
startNProgress()
try {
if (isLimit.value) {
siteForm.value.limit_interval = siteForm.value.limit_interval || 0
siteForm.value.limit_count = siteForm.value.limit_count || 0
siteForm.value.limit_seconds = siteForm.value.limit_seconds || 0
} else {
siteForm.value.limit_interval = 0
siteForm.value.limit_count = 0
siteForm.value.limit_seconds = 0
}
const result: { [key: string]: any } = await api.put('site/', siteForm.value)
if (result.success) {
$toast.success(`${siteForm.value?.name} 更新成功!`)
@@ -120,9 +135,12 @@ async function updateSiteInfo() {
doneNProgress()
}
onMounted(() => {
onMounted(async () => {
if (props.oper !== 'add') {
fetchSiteInfo()
await fetchSiteInfo()
if (siteForm.value.limit_interval || siteForm.value.limit_count || siteForm.value.limit_seconds)
isLimit.value = true
if (siteForm.value.apikey) siteType.value = 'api'
}
})
</script>
@@ -179,35 +197,69 @@ onMounted(() => {
<VCol cols="12" md="3">
<VTextField v-model="siteForm.timeout" label="超时时间(秒)" hint="站点请求超时时间" persistent-hint />
</VCol>
<VCol cols="12">
<VTextarea v-model="siteForm.cookie" label="站点Cookie" hint="站点请求头中的Cookie信息" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.token"
label="请求头Authorization"
hint="站点请求头中的Authorization信息特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.apikey"
label="令牌API Key"
hint="站点的访问API Key特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="获取Cookie的浏览器对应的User-Agent"
persistent-hint
/>
</VRow>
<VTabs v-model="siteType" show-arrows class="v-tabs-pill mt-3">
<VTab selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-cookie" value="cookie" />
Cookie
</div>
</VTab>
<VTab selected-class="v-tab--selected">
<div>
<VIcon size="20" start icon="mdi-api" value="api" />
API
</div>
</VTab>
</VTabs>
<VWindow v-model="siteType" class="my-3 disable-tab-transition" :touch="false">
<VWindowItem value="cookie">
<VRow>
<VCol cols="12">
<VTextarea
v-model="siteForm.cookie"
label="站点Cookie"
hint="站点请求头中的Cookie信息"
persistent-hint
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="siteForm.ua"
label="站点User-Agent"
hint="获取Cookie的浏览器对应的User-Agent"
persistent-hint
/>
</VCol>
</VRow>
</VWindowItem>
<VWindowItem value="api">
<VRow>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.token"
label="请求头Authorization"
hint="站点请求头中的Authorization信息特殊站点需要"
persistent-hint
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="siteForm.apikey"
label="令牌API Key"
hint="站点的访问API Key特殊站点需要"
persistent-hint
/>
</VCol>
</VRow>
</VWindowItem>
</VWindow>
<VRow>
<VCol cols="12" md="4">
<VSwitch v-model="isLimit" label="限制站点访问频率" />
</VCol>
</VRow>
<VRow>
<VRow v-if="isLimit">
<VCol cols="12" md="4">
<VTextField
v-model="siteForm.limit_interval"
@@ -238,10 +290,15 @@ onMounted(() => {
</VRow>
<VRow>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.proxy" label="代理" hint="使用代理服务器访问该站点" persistent-hint />
<VSwitch v-model="siteForm.proxy" label="使用代理访问" hint="使用代理服务器访问该站点" persistent-hint />
</VCol>
<VCol cols="12" md="6">
<VSwitch v-model="siteForm.render" label="仿真" hint="使用浏览器模拟真实访问该站点" persistent-hint />
<VSwitch
v-model="siteForm.render"
label="浏览器仿真"
hint="使用浏览器模拟真实访问该站点"
persistent-hint
/>
</VCol>
</VRow>
</VForm>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import api from '@/api'
import { Site } from '@/api/types'
import { requiredValidator } from '@/@validators'
import { useToast } from 'vue-toast-notification'
import ProgressDialog from '../dialog/ProgressDialog.vue'
// 输入参数
const cardProps = defineProps({
site: Object as PropType<Site>,
})
// 定义触发的自定义事件
const emit = defineEmits(['close', 'done'])
// 提示框
const $toast = useToast()
// 用户名密码表单
const userPwForm = ref({
username: '',
password: '',
code: '',
})
// 密码输入
const isPasswordVisible = ref(false)
// 更新按钮可用性
const updateButtonDisable = ref(false)
// 进度条
const progressDialog = ref(false)
// 进度文本
const progressText = ref('请稍候 ...')
// 调用API更新站点Cookie UA
async function updateSiteCookie() {
try {
if (!userPwForm.value.username || !userPwForm.value.password) return
// 更新按钮状态
updateButtonDisable.value = true
progressDialog.value = true
progressText.value = `正在更新 ${cardProps.site?.name} Cookie & UA ...`
const result: { [key: string]: any } = await api.get(`site/cookie/${cardProps.site?.id}`, {
params: {
username: userPwForm.value.username,
password: userPwForm.value.password,
code: userPwForm.value.code,
},
})
if (result.success) {
$toast.success(`${cardProps.site?.name} 更新Cookie & UA 成功!`)
emit('done')
} else $toast.error(`${cardProps.site?.name} 更新失败:${result.message}`)
progressDialog.value = false
updateButtonDisable.value = false
} catch (error) {
console.error(error)
}
}
</script>
<template>
<VDialog max-width="50rem">
<!-- Dialog Content -->
<VCard title="更新站点Cookie & UA">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VForm @submit.prevent="() => {}">
<VRow>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.username" label="用户名" :rules="[requiredValidator]" />
</VCol>
<VCol cols="12" md="4">
<VTextField
v-model="userPwForm.password"
label="密码"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
:rules="[requiredValidator]"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@keydown.enter="updateSiteCookie"
/>
</VCol>
<VCol cols="12" md="4">
<VTextField v-model="userPwForm.code" label="两步验证" />
</VCol>
</VRow>
</VForm>
</VCardText>
<VCardActions>
<VSpacer />
<VBtn
variant="elevated"
@click="updateSiteCookie"
:disabled="updateButtonDisable"
prepend-icon="mdi-refresh"
class="px-5"
>
开始更新
</VBtn>
</VCardActions>
</VCard>
<!-- 进度框 -->
<ProgressDialog v-if="progressDialog" v-model="progressDialog" :text="progressText" />
</VDialog>
</template>

View File

@@ -0,0 +1,222 @@
<script setup lang="ts">
import { Site } from '@/api/types'
import { useDisplay } from 'vuetify'
import api from '@/api'
import type { TorrentInfo } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
site: Object as PropType<Site>,
})
// 注册事件
const emit = defineEmits(['close'])
// 数据列表
const resourceDataList = ref<TorrentInfo[]>([])
// 搜索
const resourceSearch = ref('')
// 总条数
const resourceTotalItems = ref(0)
// 每页条数
const resourceItemsPerPage = ref(25)
// 加载状态
const resourceLoading = ref(false)
// 种子元数据
const torrent = ref<TorrentInfo>()
// 资源浏览表头
const resourceHeaders = [
{ title: '标题', key: 'title', sortable: false },
{ title: '时间', key: 'pubdate', sortable: true },
{ title: '大小', key: 'size', sortable: true },
{ title: '做种', key: 'seeders', sortable: true },
{ title: '下载', key: 'peers', sortable: true },
{ title: '', key: 'actions', sortable: false },
]
// 打开种子详情页面
function openTorrentDetail(page_url: string) {
window.open(page_url, '_blank')
}
// 下载种子文件
async function downloadTorrentFile(enclosure: string) {
window.open(enclosure, '_blank')
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
try {
resourceDataList.value = await api.get(`site/resource/${props.site?.id}`)
resourceLoading.value = false
} catch (error) {
console.error(error)
}
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) return 'text-white bg-lime-500'
else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
else return 'text-white bg-gray-500'
}
// 添加下载
async function addDownload(_torrent: any) {
torrent.value = _torrent
addDownloadDialog.value = true
}
// 添加下载对话框
const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
}
// 装载时查询站点图标
onMounted(() => {
getResourceList()
})
</script>
<template>
<VDialog max-width="80rem" scrollable z-index="1010" :fullscreen="!display.mdAndUp.value">
<VCard :title="`浏览 - ${props.site?.name}`">
<DialogCloseBtn @click="emit('close')" />
<VDivider />
<VCardText class="pt-2">
<VDataTable
v-model:items-per-page="resourceItemsPerPage"
:headers="resourceHeaders"
:items="resourceDataList"
:items-length="resourceTotalItems"
:search="resourceSearch"
:loading="resourceLoading"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.title="{ item }">
<a href="javascript:void(0)" @click.stop="addDownload(item)">
<div class="text-high-emphasis pt-1">
{{ item.title }}
</div>
<div class="text-sm my-1">
{{ item.description }}
</div>
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ item.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in item.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ item.volume_factor }}
</VChip>
</a>
</template>
<template #item.pubdate="{ item }">
<div>{{ item.date_elapsed }}</div>
<div class="text-sm">
{{ item.pubdate }}
</div>
</template>
<template #item.size="{ item }">
<div class="text-nowrap whitespace-nowrap">
{{ formatFileSize(item.size) }}
</div>
</template>
<template #item.seeders="{ item }">
<div>{{ item.seeders }}</div>
</template>
<template #item.peers="{ item }">
<div>{{ item.peers }}</div>
</template>
<template #item.actions="{ item }">
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail(item.page_url || '')">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="item.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile(item.enclosure)"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
</VCardText>
</VCard>
<!-- 添加下载对话框 -->
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:torrent="torrent"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</VDialog>
</template>
<style lang="scss" scoped>
.v-table th {
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,436 @@
<script lang="ts" setup>
import type { Site, SiteUserData } from '@/api/types'
import api from '@/api'
import { useDisplay, useTheme } from 'vuetify'
import { VAvatar, VCardText, VIcon } from 'vuetify/lib/components/index.mjs'
import { formatFileSize } from '@/@core/utils/formatters'
import VueApexCharts from 'vue3-apexcharts'
// 显示器宽度
const display = useDisplay()
// 输入参数
const props = defineProps({
site: Object as PropType<Site>,
})
// 注册事件
const emit = defineEmits(['close'])
const vuetifyTheme = useTheme()
const currentTheme = controlledComputed(
() => vuetifyTheme.name.value,
() => vuetifyTheme.current.value.colors,
)
// 站点数据列表
const siteDatas = ref<SiteUserData[]>([])
// 最新一天的数据,按时间倒序排序后取第一条记录
const siteData = computed(() => siteDatas.value[0])
// 站点数据列表中的上传量、下载量数据生成图形使用的数据
const historySeries = computed(() => {
return [
{
name: '上传量',
data: siteDatas.value.map(item => Math.round((item.upload ?? 0) / 1024 / 1024 / 1024)),
},
{
name: '下载量',
data: siteDatas.value.map(item => Math.round((item.download ?? 0) / 1024 / 1024 / 1024)),
},
]
})
// 图形选项
const historyChartOptions = computed(() => {
return {
chart: {
type: 'area',
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
dataLabels: {
enabled: true,
},
zoom: {
autoScaleYaxis: true,
},
},
tooltip: {
enabled: true,
tooltip: {
x: {
format: 'dd MMM yyyy',
},
},
},
grid: {
xaxis: {
lines: { show: false },
},
yaxis: {
title: {
text: 'GB',
},
lines: { show: true },
},
},
stroke: {
width: 3,
lineCap: 'butt',
curve: 'smooth',
},
colors: [currentTheme.value.success, currentTheme.value.warning],
markers: {
size: 0,
style: 'hollow',
},
xaxis: {
type: 'category',
categories: siteDatas.value.map(item => item.updated_day),
labels: {
show: true,
formatter: function (val: string) {
return new Date(val).toLocaleDateString('zh-CN')
},
},
},
yaxis: {
title: {
text: 'GB',
},
labels: {
formatter: function (val: number) {
return val.toLocaleString()
},
},
},
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.5,
opacityTo: 0.7,
stops: [0, 100],
},
},
}
})
// 做种分布列seeding_info的格式为[[x, y], [x, y], ...]x为做种数y为做种体积做种体积需要转换为GB
const seedingSeries = computed(() => {
return [
{
name: '体积',
data: siteData.value?.seeding_info?.map(item => [item[0] ?? 0, Math.round((item[1] ?? 0) / 1024 / 1024 / 1024)]),
},
]
})
// 做种分布图形选项
const seedingChartOptions = computed(() => {
return {
chart: {
type: 'scatter',
parentHeightOffset: 0,
toolbar: { show: false },
animations: { enabled: true },
zoom: {
autoScaleYaxis: true,
},
},
tooltip: {
enabled: true,
x: {
formatter: function (val: number) {
return '数量:' + val.toLocaleString()
},
},
},
grid: {
xaxis: {
lines: { show: true },
},
yaxis: {
lines: { show: true },
},
},
colors: [currentTheme.value.primary],
xaxis: {
type: 'numeric',
labels: {
show: true,
formatter: function (val: number) {
return Math.round(val).toLocaleString()
},
},
title: {
text: '数量',
},
tickAmount: 10,
},
yaxis: {
title: {
text: 'GB',
},
labels: {
formatter: function (val: number) {
return val.toLocaleString() + ' GB'
},
},
},
}
})
// 根据传入属性,计算列表数据中第一条与第二条的差值,如果没有第二条则差值为全部
const diffData: { [key: string]: any } = computed(() => {
if (siteDatas.value.length < 2) {
return siteData.value
}
const first = siteDatas.value[siteDatas.value.length - 1]
const second = siteDatas.value[siteDatas.value.length - 2]
return {
bonus: (first.bonus ?? 0) - (second.bonus ?? 0),
ratio: (first.ratio ?? 0) - (second.ratio ?? 0),
upload: (first.upload ?? 0) - (second.upload ?? 0),
download: (first.download ?? 0) - (second.download ?? 0),
seeding: (first.seeding ?? 0) - (second.seeding ?? 0),
seeding_size: (first.seeding_size ?? 0) - (second.seeding_size ?? 0),
}
})
// 格式化差值
function getDiffString(diff: number | undefined, format: boolean = true) {
if (diff === undefined) {
return '0'
}
if (format) {
return diff >= 0 ? `+${diff.toLocaleString()}` : diff.toLocaleString()
}
return diff >= 0 ? `+${diff}` : diff
}
// 根据差值的正负,返回不同的样式
function getDiffClass(diff: number | undefined) {
if (diff === undefined) {
return ''
}
if (diff == 0) {
return ''
}
return diff > 0 ? 'text-success' : 'text-error'
}
// 查询站点用户数据
async function fetchSiteUserData() {
try {
const result: { [key: string]: any } = await api.get(`site/userdata/${props.site?.id}`)
if (result.success) {
siteDatas.value = result.data.sort((a: { updated_day: any }, b: { updated_day: any }) =>
(a.updated_day || '').localeCompare(b.updated_day || ''),
)
}
} catch (error) {
console.error(error)
}
}
onBeforeMount(async () => {
await fetchSiteUserData()
})
</script>
<template>
<VDialog scrollable eager max-width="80rem" :fullscreen="!display.mdAndUp.value">
<VCard :title="`数据 - ${props.site?.name}`" class="rounded-t">
<DialogCloseBtn @click="emit('close')" />
<VCardText>
<VRow class="match-height">
<!-- 用户信息 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">用户等级</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.user_level || '无' }}
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-account"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 积分 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">积分</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.bonus?.toLocaleString() }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.bonus)">
({{ getDiffString(diffData?.bonus) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-scoreboard"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 分享率 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">分享率</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.ratio }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.ratio)">
({{ getDiffString(diffData?.ratio) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-percent"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总上传量 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总上传量</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.upload || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.upload)">
({{ formatFileSize(diffData?.upload || 0, 2, true) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-upload"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总下载量 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总下载量</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.download || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.download)">
({{ formatFileSize(diffData?.download || 0, 2, true) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-download"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总做种数 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总做种数</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.seeding?.toLocaleString() }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.seeding)">
({{ getDiffString(diffData?.seeding) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-seed"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 总做种体积 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">总做种体积</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ formatFileSize(siteData?.seeding_size || 0) }}
<span class="text-base font-weight-regular" :class="getDiffClass(diffData?.seeding_size)">
({{ formatFileSize(diffData?.seeding_size || 0, 2, true) }})
</span>
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-database"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
<!-- 加入时间 -->
<VCol cols="12" md="3">
<VCard>
<VCardText class="d-flex align-center">
<div class="d-flex justify-space-between" style="inline-size: 100%">
<div class="d-flex flex-column gap-y-1">
<span class="text-base">加入时间</span>
<h5 class="text-h5 d-flex align-center gap-2 text-wrap">
{{ siteData?.join_at?.split(' ')[0] }}
</h5>
</div>
<VAvatar variant="tonal" size="42" rounded>
<VIcon icon="mdi-calendar"></VIcon>
</VAvatar>
</div>
</VCardText>
</VCard>
</VCol>
</VRow>
<VRow>
<VCol>
<VCard title="历史流量">
<VCardText>
<VueApexCharts type="line" :options="historyChartOptions" :series="historySeries" :height="300" />
</VCardText>
</VCard>
</VCol>
</VRow>
<VRow>
<VCol>
<VCard title="做种分布">
<VCardText>
<VueApexCharts type="scatter" :options="seedingChartOptions" :series="seedingSeries" :height="300" />
</VCardText>
</VCard>
</VCol>
</VRow>
</VCardText>
</VCard>
</VDialog>
</template>

View File

@@ -270,7 +270,6 @@ onMounted(() => {
<input ref="refInputEl" type="file" name="file" accept=".jpeg,.png,.jpg,GIF" hidden @input="changeAvatar" />
<VBtn type="reset" color="info" variant="tonal" @click="restoreCurrentAvatar" v-if="props.oper !== 'add'">
<VIcon icon="mdi-refresh" />
<span v-if="display.mdAndUp.value" class="ms-2">重置</span>

View File

@@ -1,202 +0,0 @@
<script lang="ts" setup>
import api from '@/api'
import type { TorrentInfo } from '@/api/types'
import { formatFileSize } from '@core/utils/formatters'
import AddDownloadDialog from '../dialog/AddDownloadDialog.vue'
// 输入参数
const props = defineProps({
site: Number,
width: String,
height: String,
})
// 数据列表
const resourceDataList = ref<TorrentInfo[]>([])
// 搜索
const resourceSearch = ref('')
// 总条数
const resourceTotalItems = ref(0)
// 每页条数
const resourceItemsPerPage = ref(25)
// 加载状态
const resourceLoading = ref(false)
// 种子元数据
const torrent = ref<TorrentInfo>()
// 资源浏览表头
const resourceHeaders = [
{ title: '标题', key: 'title', sortable: false },
{ title: '时间', key: 'pubdate', sortable: true },
{ title: '大小', key: 'size', sortable: true },
{ title: '做种', key: 'seeders', sortable: true },
{ title: '下载', key: 'peers', sortable: true },
{ title: '', key: 'actions', sortable: false },
]
// 打开种子详情页面
function openTorrentDetail(page_url: string) {
window.open(page_url, '_blank')
}
// 下载种子文件
async function downloadTorrentFile(enclosure: string) {
window.open(enclosure, '_blank')
}
// 调用API查询站点资源
async function getResourceList() {
resourceLoading.value = true
try {
resourceDataList.value = await api.get(`site/resource/${props.site}`)
resourceLoading.value = false
} catch (error) {
console.error(error)
}
}
// 促销Chip类
function getVolumeFactorClass(downloadVolume: number, uploadVolume: number) {
if (downloadVolume === 0) return 'text-white bg-lime-500'
else if (downloadVolume < 1) return 'text-white bg-green-500'
else if (uploadVolume !== 1) return 'text-white bg-sky-500'
else return 'text-white bg-gray-500'
}
// 添加下载
async function addDownload(_torrent: any) {
torrent.value = _torrent
addDownloadDialog.value = true
}
// 添加下载对话框
const addDownloadDialog = ref(false)
// 添加下载成功
function addDownloadSuccess(url: string) {
addDownloadDialog.value = false
}
// 添加下载失败
function addDownloadError(error: string) {
addDownloadDialog.value = false
}
// 装载时查询站点图标
onMounted(() => {
getResourceList()
})
</script>
<template>
<VDataTable
v-model:items-per-page="resourceItemsPerPage"
:headers="resourceHeaders"
:items="resourceDataList"
:items-length="resourceTotalItems"
:search="resourceSearch"
:loading="resourceLoading"
density="compact"
item-value="title"
return-object
fixed-header
hover
items-per-page-text="每页条数"
page-text="{0}-{1} {2} "
loading-text="加载中..."
>
<template #item.title="{ item }">
<a href="javascript:void(0)" @click.stop="addDownload(item)">
<div class="text-high-emphasis pt-1">
{{ item.title }}
</div>
<div class="text-sm my-1">
{{ item.description }}
</div>
<VChip v-if="item.hit_and_run" variant="elevated" size="small" class="me-1 mb-1 text-white bg-black">
H&R
</VChip>
<VChip v-if="item.freedate_diff" variant="elevated" color="secondary" size="small" class="me-1 mb-1">
{{ item.freedate_diff }}
</VChip>
<VChip
v-for="(label, index) in item.labels"
:key="index"
variant="elevated"
size="small"
color="primary"
class="me-1 mb-1"
>
{{ label }}
</VChip>
<VChip
v-if="item.downloadvolumefactor !== 1 || item.uploadvolumefactor !== 1"
:class="getVolumeFactorClass(item.downloadvolumefactor, item.uploadvolumefactor)"
variant="elevated"
size="small"
class="me-1 mb-1"
>
{{ item.volume_factor }}
</VChip>
</a>
</template>
<template #item.pubdate="{ item }">
<div>{{ item.date_elapsed }}</div>
<div class="text-sm">
{{ item.pubdate }}
</div>
</template>
<template #item.size="{ item }">
<div class="text-nowrap whitespace-nowrap">
{{ formatFileSize(item.size) }}
</div>
</template>
<template #item.seeders="{ item }">
<div>{{ item.seeders }}</div>
</template>
<template #item.peers="{ item }">
<div>{{ item.peers }}</div>
</template>
<template #item.actions="{ item }">
<div class="me-n3">
<IconBtn>
<VIcon icon="mdi-dots-vertical" />
<VMenu activator="parent" close-on-content-click>
<VList>
<VListItem variant="plain" @click="openTorrentDetail(item.page_url || '')">
<template #prepend>
<VIcon icon="mdi-information" />
</template>
<VListItemTitle>查看详情</VListItemTitle>
</VListItem>
<VListItem
v-if="item.enclosure?.startsWith('http')"
variant="plain"
@click="downloadTorrentFile(item.enclosure)"
>
<template #prepend>
<VIcon icon="mdi-download" />
</template>
<VListItemTitle>下载种子文件</VListItemTitle>
</VListItem>
</VList>
</VMenu>
</IconBtn>
</div>
</template>
<template #no-data> 没有数据 </template>
</VDataTable>
<AddDownloadDialog
v-if="addDownloadDialog"
v-model="addDownloadDialog"
:torrent="torrent"
@done="addDownloadSuccess"
@error="addDownloadError"
@close="addDownloadDialog = false"
/>
</template>

View File

@@ -16,15 +16,18 @@ const appsMenu = ref(false)
// SSE持续接收消息
function startSSEMessager() {
eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message`)
eventSource.addEventListener('message', event => {
if (event.data) {
const noti: SystemNotification = JSON.parse(event.data)
notificationList.value.unshift(noti)
hasNewMessage.value = true
// TODO 在顶部显示消息汽泡
}
})
// 延迟 3 秒启动 SSE避免相关认证信息尚未写入 Cookie 导致 403
setTimeout(() => {
eventSource = new EventSource(`${import.meta.env.VITE_API_BASE_URL}system/message`)
eventSource.addEventListener('message', event => {
if (event.data) {
const noti: SystemNotification = JSON.parse(event.data)
notificationList.value.unshift(noti)
hasNewMessage.value = true
// TODO 在顶部显示消息汽泡
}
})
}, 3000)
}
// 页面加载时,加载当前用户数据

View File

@@ -3,6 +3,7 @@ import api from '@/api'
import { DownloaderConf } from '@/api/types'
import DownloadingListView from '@/views/reorganize/DownloadingListView.vue'
import router from '@/router'
import NoDataFound from '@/components/NoDataFound.vue'
const route = useRoute()
const activeTab = ref(route.query.tab)
@@ -36,7 +37,7 @@ onMounted(() => {
</script>
<template>
<div>
<div v-if="enabledDownloaders.length > 0">
<VTabs v-model="activeTab">
<VTab v-for="item in enabledDownloaders" :value="item.name" @to="jumpTab(item.name)">
<span class="mx-5">{{ item.name }}</span>
@@ -51,4 +52,10 @@ onMounted(() => {
</VWindowItem>
</VWindow>
</div>
<NoDataFound
v-else
error-code="404"
error-title="没有下载器"
error-description="请先在设置中正确配置并启用下载器"
/>
</template>

View File

@@ -334,7 +334,7 @@ const getPosterUrl: Ref<string> = computed(() => {
return `${import.meta.env.VITE_API_BASE_URL}system/cache/image?url=${encodeURIComponent(url)}`
// 如果地址中包含douban则使用中转代理
if (url.includes('doubanio.com'))
return `${import.meta.env.VITE_API_BASE_URL}douban/img?imgurl=${encodeURIComponent(url)}`
return `${import.meta.env.VITE_API_BASE_URL}system/img/0?imgurl=${encodeURIComponent(url)}`
return url
})

View File

@@ -63,7 +63,6 @@ async function saveStorages() {
// 修改后生效
async function updatedStorage() {
await saveStorages()
loadStorages()
}
@@ -98,9 +97,9 @@ async function saveDirectories() {
// 添加媒体库目录
function addDirectory() {
let name = `目录${directories.value.length + 1}`;
let name = `目录${directories.value.length + 1}`
while (directories.value.some(item => item.name === name)) {
name = `目录${parseInt(name.split('目录')[1]) + 1}`;
name = `目录${parseInt(name.split('目录')[1]) + 1}`
}
directories.value.push({
name: name,

View File

@@ -25,9 +25,10 @@ const $toast = useToast()
// 种子优先规则下拉框
const TorrentPriorityItems = [
{ title: '站点排序优先', value: 'site' },
{ title: '站点上传量优先', value: 'upload' },
{ title: '资源做种数优先', value: 'seeder' },
{ title: '资源优先', value: 'torrent' },
{ title: '站点优先', value: 'site' },
{ title: '站点上传量', value: 'upload' },
{ title: '资源做种数', value: 'seeder' },
]
// 调用API查询自动分类配置
@@ -52,13 +53,13 @@ async function saveCustomRules() {
// 添加自定义规则
function addCustomRule() {
let id = `RULE${customRules.value.length + 1}`;
let id = `RULE${customRules.value.length + 1}`
while (customRules.value.some(item => item.id === id)) {
id = `RULE${parseInt(id.split('RULE')[1]) + 1}`;
id = `RULE${parseInt(id.split('RULE')[1]) + 1}`
}
let name = `规则${customRules.value.length + 1}`;
let name = `规则${customRules.value.length + 1}`
while (customRules.value.some(item => item.name === name)) {
name = `规则${parseInt(name.split('规则')[1]) + 1}`;
name = `规则${parseInt(name.split('规则')[1]) + 1}`
}
customRules.value.push({
id: id,
@@ -97,9 +98,9 @@ async function saveFilterRuleGroups() {
// 添加规则组
function addFilterRuleGroup() {
let name = `规则组${filterRuleGroups.value.length + 1}`;
let name = `规则组${filterRuleGroups.value.length + 1}`
while (filterRuleGroups.value.some(item => item.name === name)) {
name = `规则组${parseInt(name.split('规则组')[1]) + 1}`;
name = `规则组${parseInt(name.split('规则组')[1]) + 1}`
}
filterRuleGroups.value.push({
name: name,
@@ -245,7 +246,7 @@ onMounted(() => {
<VCard>
<VCardItem>
<VCardTitle>下载规则</VCardTitle>
<VCardSubtitle>按站点或做种数量优先下载</VCardSubtitle>
<VCardSubtitle>同时命中多个资源时择优下载</VCardSubtitle>
</VCardItem>
<VCardText>
<VForm>
@@ -254,8 +255,10 @@ onMounted(() => {
<VSelect
v-model="selectedTorrentPriority"
:items="TorrentPriorityItems"
multiple
chips
label="当前使用下载优先规则"
hint="同时命中多个站点的多个资源时下载的优先规则"
hint="排在前面的优先级越高,未选择的项不纳入排序"
persistent-hint
/>
</VCol>

View File

@@ -14,6 +14,8 @@ const resetSitesText = ref('重置站点数据')
// 站点重置按钮可用状态
const resetSitesDisabled = ref(false)
const isPasswordVisible = ref(false)
// CookieCloud设置项
const siteSetting = ref({
COOKIECLOUD_HOST: '',
@@ -155,7 +157,9 @@ onMounted(() => {
<VCol cols="12" md="6">
<VTextField
v-model="siteSetting.COOKIECLOUD_PASSWORD"
type="password"
:type="isPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isPasswordVisible ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
label="端对端加密密码"
hint="CookieCloud浏览器插件生成的端对端加密密码"
persistent-hint

View File

@@ -1,7 +1,7 @@
<script lang="ts" setup>
import draggable from 'vuedraggable'
import api from '@/api'
import type { Site } from '@/api/types'
import type { Site, SiteUserData } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import SiteAddEditDialog from '@/components/dialog/SiteAddEditDialog.vue'
@@ -15,8 +15,11 @@ const appMode = computed(() => {
return localStorage.getItem('MP_APPMODE') != '0' && display.mdAndDown.value
})
// 数据列表
const dataList = ref<Site[]>([])
// 站点列表
const siteList = ref<Site[]>([])
// 站点数据列表
const userDataList = ref<SiteUserData[]>([])
// 是否刷新过
const isRefreshed = ref(false)
@@ -31,7 +34,7 @@ const siteAddDialog = ref(false)
async function fetchData() {
try {
loading.value = true
dataList.value = await api.get('site/')
siteList.value = await api.get('site/')
loading.value = false
isRefreshed.value = true
} catch (error) {
@@ -39,10 +42,19 @@ async function fetchData() {
}
}
// 获取站点最新数据
async function fetchUserData() {
try {
userDataList.value = await api.get('site/userdata/latest')
} catch (error) {
console.error(error)
}
}
// 保存站点排序
async function savaSitesPriority() {
// 重新排序
const priorities = dataList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))
const priorities = siteList.value.map((site, index) => ({ id: site.id, pri: index + 1 }))
try {
const result: { [key: string]: any } = await api.post('site/priorities', priorities)
if (result.success) {
@@ -53,12 +65,27 @@ async function savaSitesPriority() {
}
}
// 根据站点ID获取站点数据
function getUserData(domain: string) {
return userDataList.value.find(userData => userData.domain === domain)
}
// 更新站点事件时
function onSiteSave() {
siteAddDialog.value = false
fetchData()
}
// 加载时获取数据
onBeforeMount(fetchData)
onBeforeMount(() => {
fetchData()
fetchUserData()
})
onActivated(() => {
if (!loading.value) {
fetchData()
fetchUserData()
}
})
</script>
@@ -67,8 +94,8 @@ onActivated(() => {
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<div>
<draggable
v-if="dataList.length > 0"
v-model="dataList"
v-if="siteList.length > 0"
v-model="siteList"
@end="savaSitesPriority"
handle=".cursor-move"
item-key="id"
@@ -76,12 +103,12 @@ onActivated(() => {
:component-data="{ 'class': 'grid gap-3 grid-site-card' }"
>
<template #item="{ element }">
<SiteCard :site="element" @remove="fetchData" @update="fetchData" />
<SiteCard :site="element" :data="getUserData(element.domain)" @remove="fetchData" @update="fetchData" />
</template>
</draggable>
</div>
<NoDataFound
v-if="dataList.length === 0 && isRefreshed"
v-if="siteList.length === 0 && isRefreshed"
error-code="404"
error-title="没有站点"
error-description="已添加并支持的站点将会在这里显示"
@@ -102,12 +129,7 @@ onActivated(() => {
v-if="siteAddDialog"
v-model="siteAddDialog"
oper="add"
@save="
() => {
siteAddDialog = false
fetchData()
}
"
@save="onSiteSave"
@close="siteAddDialog = false"
/>
</template>

View File

@@ -229,9 +229,12 @@ onMounted(() => {
})
// 监听 localStorage 中的用户头像变化
watch(() => store.state.auth.avatar, () => {
currentAvatar.value = store.state.auth.avatar
})
watch(
() => store.state.auth.avatar,
() => {
currentAvatar.value = store.state.auth.avatar
},
)
</script>
<template>
@@ -271,7 +274,7 @@ watch(() => store.state.auth.avatar, () => {
</VBtn>
<VBtn
:color="accountInfo.is_otp ? 'error' : 'info'"
:color="accountInfo.is_otp ? 'warning' : 'success'"
variant="tonal"
@click.stop="accountInfo.is_otp ? disableOtp() : getOtpUri()"
>