Files
MoviePilot-Frontend/src/views/site/SiteCardListView.vue

512 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import api from '@/api'
import type { Site, SiteUserData } from '@/api/types'
import SiteCard from '@/components/cards/SiteCard.vue'
import NoDataFound from '@/components/NoDataFound.vue'
import VirtualGrid from '@/components/virtual/VirtualGrid.vue'
import { useBreakpointCols } from '@/composables/virtual/useBreakpointCols'
import { useDynamicButton } from '@/composables/useDynamicButton'
import { useI18n } from 'vue-i18n'
import { usePWA } from '@/composables/usePWA'
import { useToast } from 'vue-toastification'
// 列数:按视口断点(路由级全宽页)
const cols = useBreakpointCols({ xs: 1, sm: 2, md: 3, lg: 4, xl: 5, xxl: 5 })
// 国际化
const { t } = useI18n()
// 提示框
const $toast = useToast()
// 路由
const route = useRoute()
// APP 模式检测
const { appMode } = usePWA()
// 拖拽排序和站点弹窗都不是站点列表首屏必需,打开对应功能时再加载。
const Draggable = defineAsyncComponent(() => import('vuedraggable').then(module => module.default))
const SiteAddEditDialog = defineAsyncComponent(() => import('@/components/dialog/SiteAddEditDialog.vue'))
const SiteStatisticsDialog = defineAsyncComponent(() => import('@/components/dialog/SiteStatisticsDialog.vue'))
const SiteImportDialog = defineAsyncComponent(() => import('@/components/dialog/SiteImportDialog.vue'))
// 站点列表
const siteList = ref<Site[]>([])
// 站点数据列表
const userDataList = ref<SiteUserData[]>([])
// 站点统计数据列表
const siteStatsList = ref<{ [domain: string]: any }>({})
// 是否刷新过
const isRefreshed = ref(false)
// 是否加载中
const loading = ref(false)
// 新增站点对话框
const siteAddDialog = ref(false)
// 统计信息对话框
const siteStatsDialog = ref(false)
// 导入站点对话框
const siteImportDialog = ref(false)
const sortMode = ref(false)
// 筛选相关
const filterMenu = ref(false)
const filterOption = ref('all') // all, active, inactive, connected, slow, failed, unknown
// 筛选选项
const filterOptions = computed(() => [
{ value: 'all', label: t('common.all'), icon: 'mdi-filter-multiple-outline' },
{ value: 'active', label: t('common.active'), icon: 'mdi-check-circle', color: 'success' },
{ value: 'inactive', label: t('common.inactive'), icon: 'mdi-stop-circle', color: 'error' },
{ value: 'connected', label: t('site.connectionNormal'), icon: 'mdi-wifi', color: 'success' },
{ value: 'slow', label: t('site.connectionSlow'), icon: 'mdi-wifi-strength-2', color: 'warning' },
{ value: 'failed', label: t('site.connectionFailed'), icon: 'mdi-wifi-off', color: 'error' },
{ value: 'unknown', label: t('site.connectionUnknown'), icon: 'mdi-help-circle', color: 'secondary' },
])
// 筛选后的站点列表
const filteredSiteList = computed(() => {
if (filterOption.value === 'all') {
return siteList.value
}
return siteList.value.filter(site => {
if (filterOption.value === 'active') {
return site.is_active
} else if (filterOption.value === 'inactive') {
return !site.is_active
} else if (['connected', 'slow', 'failed', 'unknown'].includes(filterOption.value)) {
const connectionStatus = getConnectionStatus(site.domain)
return connectionStatus === filterOption.value
}
return true
})
})
// 用于拖拽排序的列表
const draggableSiteList = computed({
get() {
return filterOption.value === 'all' ? siteList.value : filteredSiteList.value
},
set(value) {
if (filterOption.value === 'all') {
siteList.value = value
}
},
})
const siteUserDataMap = computed<Record<string, SiteUserData | undefined>>(() => {
const map: Record<string, SiteUserData | undefined> = {}
userDataList.value.forEach(userData => {
if (userData.domain) {
map[userData.domain] = userData
}
})
return map
})
const canDragSort = computed(() => sortMode.value && filterOption.value === 'all')
const shouldVirtualizeList = computed(() => !sortMode.value)
// 当前筛选选项的显示信息
const currentFilter = computed(() => {
return filterOptions.value.find(option => option.value === filterOption.value)
})
// 获取站点列表数据
async function fetchData() {
try {
loading.value = true
siteList.value = await api.get('site/')
isRefreshed.value = true
// 获取站点列表后,获取统计数据
await fetchSiteStats()
} catch (error) {
console.error(error)
} finally {
loading.value = false
}
}
// 获取站点最新数据
async function fetchUserData() {
try {
userDataList.value = await api.get('site/userdata/latest')
} catch (error) {
console.error(error)
}
}
// 获取站点统计数据
async function fetchSiteStats() {
try {
// 使用批量接口一次性获取所有站点统计数据
const response = await api.get('site/statistic')
const stats = response.data || response
// 将数组转换为以domain为键的对象
const statsMap: { [domain: string]: any } = {}
if (Array.isArray(stats)) {
stats.forEach((stat: any) => {
if (stat.domain) {
statsMap[stat.domain] = stat
}
})
}
siteStatsList.value = statsMap
} catch (error) {
console.error('Failed to fetch site statistics:', error)
siteStatsList.value = {}
}
}
// 根据站点统计数据判断连接状态
function getConnectionStatus(domain: string) {
const stats = siteStatsList.value[domain]
if (!stats || Object.keys(stats).length === 0) {
return 'unknown'
}
if (stats.lst_state === 1) {
return 'failed'
} else if (stats.lst_state === 0) {
if (!stats.seconds) return 'unknown'
if (stats.seconds >= 5) return 'slow'
return 'connected'
}
return 'unknown'
}
// 保存站点排序
async function savaSitesPriority() {
// 只在显示全部站点时允许排序
if (filterOption.value !== 'all') {
return
}
// 重新排序
const priorities = draggableSiteList.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) {
fetchData()
}
} catch (error) {
console.error(error)
}
}
// 处理站点统计数据刷新请求
async function handleRefreshStats(domain?: string) {
if (domain) {
// 刷新特定站点的统计数据
try {
const stats = await api.get(`site/statistic/${domain}`)
siteStatsList.value[domain] = stats
} catch (error) {
console.error(`Failed to refresh stats for ${domain}:`, error)
}
} else {
// 刷新所有站点统计数据
await fetchSiteStats()
}
}
// 更新站点事件时
function onSiteSave() {
siteAddDialog.value = false
fetchData()
}
// 选择筛选选项
function selectFilter(value: string) {
filterOption.value = value
filterMenu.value = false
}
function toggleSortMode() {
sortMode.value = !sortMode.value
}
function openSiteAddDialog() {
siteAddDialog.value = true
}
function openSiteImportDialog() {
siteImportDialog.value = true
}
function openSiteStatisticsDialog() {
siteStatsDialog.value = true
}
// 导出站点数据
async function exportSites() {
try {
// 获取所有站点数据
const sites: Site[] = await api.get('site/')
// 创建导出数据,只包含必要的字段
const exportData = sites.map((site: Site) => ({
name: site.name,
domain: site.domain,
url: site.url,
rss: site.rss,
downloader: site.downloader,
cookie: site.cookie,
apikey: site.apikey,
token: site.token,
ua: site.ua,
proxy: site.proxy,
filter: site.filter,
render: site.render,
public: site.public,
note: site.note,
timeout: site.timeout,
limit_interval: site.limit_interval,
limit_count: site.limit_count,
limit_seconds: site.limit_seconds,
is_active: site.is_active,
pri: site.pri,
}))
// 创建Blob对象
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
// 创建下载链接
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `sites_export_${new Date().toISOString().split('T')[0]}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
// 显示成功提示
$toast.success(t('site.messages.exportSuccess'))
} catch (error) {
console.error('Export sites failed:', error)
$toast.error(t('site.messages.exportFailed'))
}
}
// 加载时获取数据
onBeforeMount(() => {
fetchData()
fetchUserData()
})
onActivated(() => {
if (!loading.value) {
fetchData()
fetchUserData()
}
})
watch(
() => filterOption.value,
value => {
if (value !== 'all' && sortMode.value) {
sortMode.value = false
}
},
{ immediate: true },
)
const shouldShowFloatingActions = computed(() => route.path === '/site' && isRefreshed.value)
// App 模式下将站点操作收纳到 Footer 动态菜单中,和插件页保持一致。
const siteDynamicMenuItems = computed(() => [
{
titleKey: 'site.actions.add',
icon: 'mdi-web-plus',
action: openSiteAddDialog,
},
{
titleKey: 'site.actions.import',
icon: 'mdi-import',
action: openSiteImportDialog,
},
{
titleKey: 'site.actions.export',
icon: 'mdi-export',
action: exportSites,
},
{
titleKey: 'site.statistics',
icon: 'mdi-chart-line',
action: openSiteStatisticsDialog,
},
])
// 使用动态按钮钩子
useDynamicButton({
icon: 'mdi-web-plus',
onClick: openSiteAddDialog,
menuItems: siteDynamicMenuItems,
show: computed(() => appMode.value && shouldShowFloatingActions.value),
})
</script>
<template>
<div class="card-list-container">
<!-- 页面标题和筛选/排序按钮 -->
<div class="d-flex justify-space-between align-center mb-4">
<VPageContentTitle :title="t('navItems.siteManager')" class="my-0" style="margin-block: 0" />
<!-- 右侧按钮组保留筛选和排序其他页面动作移到 FAB -->
<div class="d-flex align-center gap-1">
<!-- 筛选按钮 -->
<VMenu v-model="filterMenu" offset-y :close-on-content-click="false" location="bottom end">
<template #activator="{ props }">
<IconBtn v-bind="props" :variant="filterOption === 'all' ? 'text' : 'tonal'" :color="currentFilter?.color">
<VIcon :icon="currentFilter?.icon || 'mdi-filter'" />
</IconBtn>
</template>
<!-- 筛选菜单 -->
<VCard min-width="200">
<VList class="px-2">
<VListSubheader>{{ t('common.filter') }}</VListSubheader>
<VListItem
v-for="option in filterOptions"
:key="option.value"
:active="filterOption === option.value"
@click="selectFilter(option.value)"
>
<template #prepend>
<VIcon :icon="option.icon" :color="option.color" />
</template>
<VListItemTitle>{{ option.label }}</VListItemTitle>
<template #append>
<VIcon v-if="filterOption === option.value" icon="mdi-check" color="primary" />
</template>
</VListItem>
</VList>
</VCard>
</VMenu>
<!-- 排序按钮 -->
<IconBtn variant="text" :color="sortMode ? 'warning' : 'gray'" @click="toggleSortMode">
<VIcon icon="mdi-sort-variant" />
</IconBtn>
</div>
</div>
<VAlert v-if="sortMode" color="warning" variant="tonal" class="mb-4">
<div class="d-flex flex-wrap align-center justify-space-between gap-2">
<span>{{ t('common.sortModeHint') }}</span>
<VBtn variant="tonal" color="error" @click="sortMode = false">
{{ t('common.exit') }}
</VBtn>
</div>
</VAlert>
<LoadingBanner v-if="!isRefreshed" class="mt-12" />
<Draggable
v-if="draggableSiteList.length > 0 && canDragSort"
v-model="draggableSiteList"
@end="savaSitesPriority"
item-key="id"
tag="div"
:component-data="{ 'class': 'grid gap-4 grid-site-card px-2' }"
>
<template #item="{ element }">
<SiteCard
:site="element"
:data="siteUserDataMap[element.domain]"
:stats="siteStatsList[element.domain] || {}"
:sortable="true"
@remove="fetchData"
@update="fetchData"
@refresh-stats="handleRefreshStats"
/>
</template>
</Draggable>
<VirtualGrid
v-else-if="draggableSiteList.length > 0 && shouldVirtualizeList"
:items="draggableSiteList"
:columns="cols"
:row-estimate-size="220"
:gap="16"
:overscan="3"
key-field="id"
use-window-scroll
class="px-2"
>
<template #item="{ item }">
<SiteCard
:site="item"
:data="siteUserDataMap[item.domain]"
:stats="siteStatsList[item.domain] || {}"
:sortable="false"
@remove="fetchData"
@update="fetchData"
@refresh-stats="handleRefreshStats"
/>
</template>
</VirtualGrid>
</div>
<NoDataFound
v-if="draggableSiteList.length === 0 && isRefreshed"
error-code="404"
:error-title="filterOption === 'all' ? t('site.noSites') : t('common.noMatchingData')"
:error-description="filterOption === 'all' ? t('site.sitesWillBeShownHere') : t('common.tryChangingFilters')"
/>
<!-- 新增站点按钮 -->
<Teleport to="body" v-if="route.path === '/site'">
<div v-if="shouldShowFloatingActions && !appMode" class="compact-fab-stack">
<VFab
icon="mdi-chart-line"
color="info"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="openSiteStatisticsDialog"
/>
<VFab
icon="mdi-export"
color="warning"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="exportSites"
/>
<VFab
icon="mdi-import"
color="success"
variant="tonal"
appear
class="compact-fab compact-fab--secondary"
@click="openSiteImportDialog"
/>
<VFab
icon="mdi-web-plus"
color="primary"
appear
class="compact-fab compact-fab--primary"
@click="openSiteAddDialog"
/>
</div>
</Teleport>
<!-- 新增站点弹窗 -->
<SiteAddEditDialog
v-if="siteAddDialog"
v-model="siteAddDialog"
oper="add"
@save="onSiteSave"
@close="siteAddDialog = false"
/>
<!-- 统计信息弹窗 -->
<SiteStatisticsDialog v-if="siteStatsDialog" v-model="siteStatsDialog" :sites="siteList" />
<!-- 导入站点弹窗 -->
<SiteImportDialog v-if="siteImportDialog" v-model="siteImportDialog" @import-success="fetchData" />
</template>