update: ui更新

This commit is contained in:
ctwj
2025-08-09 17:26:52 +08:00
parent f870779146
commit 7032235923
7 changed files with 1344 additions and 17 deletions

View File

@@ -17,7 +17,9 @@ type TagRepository interface {
GetResourceCount(tagID uint) (int64, error)
FindByResourceID(resourceID uint) ([]entity.Tag, error)
FindWithPagination(page, pageSize int) ([]entity.Tag, int64, error)
FindWithPaginationOrderByResourceCount(page, pageSize int) ([]entity.Tag, int64, error)
Search(query string, page, pageSize int) ([]entity.Tag, int64, error)
SearchOrderByResourceCount(query string, page, pageSize int) ([]entity.Tag, int64, error)
UpdateWithNulls(tag *entity.Tag) error
GetByID(id uint) (*entity.Tag, error)
RestoreDeletedTag(id uint) error
@@ -172,3 +174,71 @@ func (r *TagRepositoryImpl) GetByID(id uint) (*entity.Tag, error) {
func (r *TagRepositoryImpl) RestoreDeletedTag(id uint) error {
return r.db.Unscoped().Model(&entity.Tag{}).Where("id = ?", id).Update("deleted_at", nil).Error
}
// FindWithPaginationOrderByResourceCount 按资源数量排序的分页查询
func (r *TagRepositoryImpl) FindWithPaginationOrderByResourceCount(page, pageSize int) ([]entity.Tag, int64, error) {
var tags []entity.Tag
var total int64
// 获取总数
err := r.db.Model(&entity.Tag{}).Count(&total).Error
if err != nil {
return nil, 0, err
}
// 使用子查询统计每个标签的资源数量并排序
offset := (page - 1) * pageSize
err = r.db.Preload("Category").
Select("tags.*, COALESCE(resource_counts.count, 0) as resource_count").
Joins(`LEFT JOIN (
SELECT rt.tag_id, COUNT(rt.resource_id) as count
FROM resource_tags rt
INNER JOIN resources r ON rt.resource_id = r.id AND r.deleted_at IS NULL
GROUP BY rt.tag_id
) as resource_counts ON tags.id = resource_counts.tag_id`).
Order("COALESCE(resource_counts.count, 0) DESC, tags.created_at DESC").
Offset(offset).Limit(pageSize).
Find(&tags).Error
if err != nil {
return nil, 0, err
}
return tags, total, nil
}
// SearchOrderByResourceCount 按资源数量排序的搜索
func (r *TagRepositoryImpl) SearchOrderByResourceCount(query string, page, pageSize int) ([]entity.Tag, int64, error) {
var tags []entity.Tag
var total int64
// 构建搜索条件
searchQuery := "%" + query + "%"
// 获取总数
err := r.db.Model(&entity.Tag{}).Where("name ILIKE ? OR description ILIKE ?", searchQuery, searchQuery).Count(&total).Error
if err != nil {
return nil, 0, err
}
// 使用子查询统计每个标签的资源数量并排序
offset := (page - 1) * pageSize
err = r.db.Preload("Category").
Select("tags.*, COALESCE(resource_counts.count, 0) as resource_count").
Joins(`LEFT JOIN (
SELECT rt.tag_id, COUNT(rt.resource_id) as count
FROM resource_tags rt
INNER JOIN resources r ON rt.resource_id = r.id AND r.deleted_at IS NULL
GROUP BY rt.tag_id
) as resource_counts ON tags.id = resource_counts.tag_id`).
Where("tags.name ILIKE ? OR tags.description ILIKE ?", searchQuery, searchQuery).
Order("COALESCE(resource_counts.count, 0) DESC, tags.created_at DESC").
Offset(offset).Limit(pageSize).
Find(&tags).Error
if err != nil {
return nil, 0, err
}
return tags, total, nil
}

View File

@@ -24,11 +24,11 @@ func GetTags(c *gin.Context) {
var err error
if search != "" {
// 搜索标签
tags, total, err = repoManager.TagRepository.Search(search, page, pageSize)
// 搜索标签(按资源数量排序)
tags, total, err = repoManager.TagRepository.SearchOrderByResourceCount(search, page, pageSize)
} else {
// 分页查询
tags, total, err = repoManager.TagRepository.FindWithPagination(page, pageSize)
// 分页查询(按资源数量排序)
tags, total, err = repoManager.TagRepository.FindWithPaginationOrderByResourceCount(page, pageSize)
}
if err != nil {

1
web/components.d.ts vendored
View File

@@ -11,6 +11,7 @@ declare module 'vue' {
NA: typeof import('naive-ui')['NA']
NAlert: typeof import('naive-ui')['NAlert']
NAvatar: typeof import('naive-ui')['NAvatar']
NBadge: typeof import('naive-ui')['NBadge']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCard: typeof import('naive-ui')['NCard']

View File

@@ -0,0 +1,410 @@
<template>
<div class="space-y-6">
<!-- 说明信息 -->
<n-alert type="info" show-icon>
<template #icon>
<i class="fas fa-info-circle"></i>
</template>
批量转存功能支持批量输入资源URL进行转存操作每行一个链接系统将自动处理转存任务
</n-alert>
<!-- 输入区域 -->
<n-card title="批量转存配置">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 左侧URL输入 -->
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
资源链接 <span class="text-red-500">*</span>
</label>
<n-input
v-model:value="resourceText"
type="textarea"
placeholder="请输入资源链接,每行一个..."
:rows="12"
show-count
:maxlength="10000"
/>
<p class="text-xs text-gray-500 mt-1">
支持的格式夸克网盘百度网盘等分享链接
</p>
</div>
<!-- 统计信息 -->
<div class="grid grid-cols-3 gap-4">
<div class="text-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div class="text-2xl font-bold text-blue-600">{{ totalLines }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">总行数</div>
</div>
<div class="text-center p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="text-2xl font-bold text-green-600">{{ validUrls }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">有效链接</div>
</div>
<div class="text-center p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<div class="text-2xl font-bold text-red-600">{{ invalidUrls }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">无效链接</div>
</div>
</div>
</div>
<!-- 右侧配置选项 -->
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
默认分类
</label>
<n-select
v-model:value="selectedCategory"
placeholder="选择分类"
:options="categoryOptions"
clearable
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
标签
</label>
<n-select
v-model:value="selectedTags"
placeholder="选择标签"
:options="tagOptions"
multiple
clearable
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
转存平台
</label>
<n-select
v-model:value="selectedPlatform"
placeholder="选择转存平台"
:options="platformOptions"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
处理选项
</label>
<div class="space-y-2">
<n-checkbox v-model:checked="autoValidate">
自动验证链接有效性
</n-checkbox>
<n-checkbox v-model:checked="skipExisting">
跳过已存在的资源
</n-checkbox>
<n-checkbox v-model:checked="autoTransfer">
添加后立即开始转存
</n-checkbox>
</div>
</div>
<!-- 操作按钮 -->
<div class="space-y-3 pt-4">
<n-button
type="primary"
block
size="large"
:loading="processing"
:disabled="!resourceText.trim() || processing"
@click="handleBatchTransfer"
>
<template #icon>
<i class="fas fa-upload"></i>
</template>
开始批量转存 ({{ validUrls }} )
</n-button>
<n-button
block
@click="clearInput"
:disabled="processing"
>
<template #icon>
<i class="fas fa-trash"></i>
</template>
清空输入
</n-button>
</div>
</div>
</div>
</n-card>
<!-- 处理结果 -->
<n-card v-if="results.length > 0" title="转存结果">
<div class="space-y-4">
<!-- 结果统计 -->
<div class="grid grid-cols-4 gap-4">
<div class="text-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div class="text-xl font-bold text-blue-600">{{ results.length }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">总处理数</div>
</div>
<div class="text-center p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="text-xl font-bold text-green-600">{{ successCount }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">成功</div>
</div>
<div class="text-center p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<div class="text-xl font-bold text-red-600">{{ failedCount }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">失败</div>
</div>
<div class="text-center p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<div class="text-xl font-bold text-yellow-600">{{ processingCount }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">处理中</div>
</div>
</div>
<!-- 结果列表 -->
<n-data-table
:columns="resultColumns"
:data="results"
:pagination="false"
max-height="300"
size="small"
/>
</div>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, h } from 'vue'
import { useCategoryApi, useTagApi, usePanApi } from '~/composables/useApi'
// 数据状态
const resourceText = ref('')
const processing = ref(false)
const results = ref([])
// 配置选项
const selectedCategory = ref(null)
const selectedTags = ref([])
const selectedPlatform = ref(null)
const autoValidate = ref(true)
const skipExisting = ref(true)
const autoTransfer = ref(false)
// 选项数据
const categoryOptions = ref([])
const tagOptions = ref([])
const platformOptions = ref([])
// API实例
const categoryApi = useCategoryApi()
const tagApi = useTagApi()
const panApi = usePanApi()
// 计算属性
const totalLines = computed(() => {
return resourceText.value ? resourceText.value.split('\n').filter(line => line.trim()).length : 0
})
const validUrls = computed(() => {
if (!resourceText.value) return 0
const lines = resourceText.value.split('\n').filter(line => line.trim())
return lines.filter(line => isValidUrl(line.trim())).length
})
const invalidUrls = computed(() => {
return totalLines.value - validUrls.value
})
const successCount = computed(() => {
return results.value.filter((r: any) => r.status === 'success').length
})
const failedCount = computed(() => {
return results.value.filter((r: any) => r.status === 'failed').length
})
const processingCount = computed(() => {
return results.value.filter((r: any) => r.status === 'processing').length
})
// 结果表格列
const resultColumns = [
{
title: '链接',
key: 'url',
width: 300,
ellipsis: {
tooltip: true
}
},
{
title: '状态',
key: 'status',
width: 100,
render: (row: any) => {
const statusMap = {
success: { color: 'success', text: '成功', icon: 'fas fa-check' },
failed: { color: 'error', text: '失败', icon: 'fas fa-times' },
processing: { color: 'info', text: '处理中', icon: 'fas fa-spinner fa-spin' }
}
const status = statusMap[row.status] || statusMap.failed
return h('n-tag', { type: status.color }, {
icon: () => h('i', { class: status.icon }),
default: () => status.text
})
}
},
{
title: '消息',
key: 'message',
ellipsis: {
tooltip: true
}
},
{
title: '转存链接',
key: 'saveUrl',
width: 200,
ellipsis: {
tooltip: true
},
render: (row: any) => {
if (row.saveUrl) {
return h('a', {
href: row.saveUrl,
target: '_blank',
class: 'text-blue-500 hover:text-blue-700'
}, '查看')
}
return '-'
}
}
]
// URL验证
const isValidUrl = (url: string) => {
try {
new URL(url)
// 简单检查是否包含常见网盘域名
const diskDomains = ['quark.cn', 'pan.baidu.com', 'aliyundrive.com']
return diskDomains.some(domain => url.includes(domain))
} catch {
return false
}
}
// 获取分类选项
const fetchCategories = async () => {
try {
const result = await categoryApi.getCategories() as any
if (result && result.items) {
categoryOptions.value = result.items.map((item: any) => ({
label: item.name,
value: item.id
}))
}
} catch (error) {
console.error('获取分类失败:', error)
}
}
// 获取标签选项
const fetchTags = async () => {
try {
const result = await tagApi.getTags() as any
if (result && result.items) {
tagOptions.value = result.items.map((item: any) => ({
label: item.name,
value: item.id
}))
}
} catch (error) {
console.error('获取标签失败:', error)
}
}
// 获取平台选项
const fetchPlatforms = async () => {
try {
const result = await panApi.getPans() as any
if (result && Array.isArray(result)) {
platformOptions.value = result.map((item: any) => ({
label: item.remark || item.name,
value: item.id
}))
}
} catch (error) {
console.error('获取平台失败:', error)
}
}
// 处理批量转存
const handleBatchTransfer = async () => {
if (!resourceText.value.trim()) {
$message.warning('请输入资源链接')
return
}
processing.value = true
results.value = []
try {
const lines = resourceText.value.split('\n').filter(line => line.trim())
const validLines = lines.filter(line => isValidUrl(line.trim()))
if (validLines.length === 0) {
$message.warning('没有找到有效的资源链接')
return
}
// 初始化结果
results.value = validLines.map(url => ({
url: url.trim(),
status: 'processing',
message: '准备处理...',
saveUrl: null
}))
// 这里应该调用实际的批量转存API
// 由于只是UI展示这里模拟处理过程
for (let i = 0; i < results.value.length; i++) {
const result = results.value[i]
// 模拟处理延迟
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟随机成功/失败
const isSuccess = Math.random() > 0.3
if (isSuccess) {
result.status = 'success'
result.message = '转存成功'
result.saveUrl = `https://pan.quark.cn/s/mock${Date.now()}`
} else {
result.status = 'failed'
result.message = '转存失败:网络错误'
}
// 触发响应式更新
results.value = [...results.value]
}
$message.success(`批量转存完成,成功 ${successCount.value} 个,失败 ${failedCount.value}`)
} catch (error) {
console.error('批量转存失败:', error)
$message.error('批量转存失败')
} finally {
processing.value = false
}
}
// 清空输入
const clearInput = () => {
resourceText.value = ''
results.value = []
}
// 初始化
onMounted(() => {
fetchCategories()
fetchTags()
fetchPlatforms()
})
</script>

View File

@@ -0,0 +1,285 @@
<template>
<div class="space-y-4">
<!-- 搜索和筛选 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<n-input
v-model:value="searchQuery"
placeholder="搜索已转存资源..."
@keyup.enter="handleSearch"
clearable
>
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<n-select
v-model:value="selectedCategory"
placeholder="选择分类"
:options="categoryOptions"
clearable
/>
<n-select
v-model:value="selectedTag"
placeholder="选择标签"
:options="tagOptions"
clearable
/>
<n-button type="primary" @click="handleSearch">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
<!-- 数据表格 -->
<n-data-table
:columns="columns"
:data="resources"
:loading="loading"
:pagination="pagination"
:remote="true"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
:row-key="(row: any) => row.id"
max-height="500"
/>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, h } from 'vue'
import { useResourceApi, useCategoryApi, useTagApi } from '~/composables/useApi'
// 数据状态
const loading = ref(false)
const resources = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
// 搜索条件
const searchQuery = ref('')
const selectedCategory = ref(null)
const selectedTag = ref(null)
// 选项数据
const categoryOptions = ref([])
const tagOptions = ref([])
// API实例
const resourceApi = useResourceApi()
const categoryApi = useCategoryApi()
const tagApi = useTagApi()
// 分页配置
const pagination = reactive({
page: 1,
pageSize: 20,
itemCount: 0,
pageSizes: [10, 20, 50, 100],
showSizePicker: true,
showQuickJumper: true,
prefix: ({ itemCount }: any) => `${itemCount}`
})
// 表格列配置
const columns = [
{
title: 'ID',
key: 'id',
width: 60,
fixed: 'left'
},
{
title: '标题',
key: 'title',
width: 200,
ellipsis: {
tooltip: true
}
},
{
title: '分类',
key: 'category_name',
width: 80
},
{
title: '平台',
key: 'platform_name',
width: 80,
render: (row: any) => {
const platform = platformOptions.value.find((p: any) => p.value === row.pan_id)
return platform?.label || '未知'
}
},
{
title: '转存链接',
key: 'save_url',
width: 200,
ellipsis: {
tooltip: true
},
render: (row: any) => {
return h('a', {
href: row.save_url,
target: '_blank',
class: 'text-green-500 hover:text-green-700'
}, row.save_url.length > 30 ? row.save_url.substring(0, 30) + '...' : row.save_url)
}
},
{
title: '转存时间',
key: 'updated_at',
width: 130,
render: (row: any) => {
return new Date(row.updated_at).toLocaleDateString()
}
},
{
title: '操作',
key: 'actions',
width: 120,
fixed: 'right',
render: (row: any) => {
return [
h('n-button', {
size: 'small',
type: 'primary',
onClick: () => viewResource(row)
}, '查看'),
h('n-button', {
size: 'small',
type: 'info',
style: { marginLeft: '8px' },
onClick: () => copyLink(row.save_url)
}, '复制')
]
}
}
]
// 平台选项
const platformOptions = ref([])
// 获取已转存资源
const fetchTransferredResources = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
has_save_url: true // 筛选有转存链接的资源
}
if (searchQuery.value) {
params.search = searchQuery.value
}
if (selectedCategory.value) {
params.category_id = selectedCategory.value
}
const result = await resourceApi.getResources(params) as any
console.log('已转存资源结果:', result)
if (result && result.resources) {
resources.value = result.resources
total.value = result.total || 0
pagination.itemCount = result.total || 0
} else if (Array.isArray(result)) {
resources.value = result
total.value = result.length
pagination.itemCount = result.length
}
} catch (error) {
console.error('获取已转存资源失败:', error)
resources.value = []
total.value = 0
} finally {
loading.value = false
}
}
// 获取分类选项
const fetchCategories = async () => {
try {
const result = await categoryApi.getCategories() as any
console.log('分类结果:', result)
if (result && result.items) {
categoryOptions.value = result.items.map((item: any) => ({
label: item.name,
value: item.id
}))
}
} catch (error) {
console.error('获取分类失败:', error)
}
}
// 获取标签选项
const fetchTags = async () => {
try {
const result = await tagApi.getTags() as any
console.log('标签结果:', result)
if (result && result.items) {
tagOptions.value = result.items.map((item: any) => ({
label: item.name,
value: item.id
}))
}
} catch (error) {
console.error('获取标签失败:', error)
}
}
// 搜索处理
const handleSearch = () => {
currentPage.value = 1
pagination.page = 1
fetchTransferredResources()
}
// 分页处理
const handlePageChange = (page: number) => {
currentPage.value = page
pagination.page = page
fetchTransferredResources()
}
const handlePageSizeChange = (size: number) => {
pageSize.value = size
pagination.pageSize = size
currentPage.value = 1
pagination.page = 1
fetchTransferredResources()
}
// 查看资源
const viewResource = (resource: any) => {
// 这里可以打开资源详情模态框
console.log('查看资源:', resource)
}
// 复制链接
const copyLink = async (url: string) => {
try {
await navigator.clipboard.writeText(url)
$message.success('链接已复制到剪贴板')
} catch (error) {
console.error('复制失败:', error)
$message.error('复制失败')
}
}
// 初始化
onMounted(() => {
fetchCategories()
fetchTags()
fetchTransferredResources()
})
</script>

View File

@@ -0,0 +1,541 @@
<template>
<div class="space-y-4">
<!-- 搜索和筛选 -->
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<n-input
v-model:value="searchQuery"
placeholder="搜索未转存资源..."
@keyup.enter="handleSearch"
clearable
>
<template #prefix>
<i class="fas fa-search"></i>
</template>
</n-input>
<n-select
v-model:value="selectedCategory"
placeholder="选择分类"
:options="categoryOptions"
clearable
/>
<n-select
v-model:value="selectedTag"
placeholder="选择标签"
:options="tagOptions"
clearable
/>
<n-select
v-model:value="selectedStatus"
placeholder="资源状态"
:options="statusOptions"
clearable
/>
<n-button type="primary" @click="handleSearch">
<template #icon>
<i class="fas fa-search"></i>
</template>
搜索
</n-button>
</div>
<!-- 批量操作 -->
<n-card>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="flex items-center space-x-2">
<n-checkbox
:checked="isAllSelected"
@update:checked="toggleSelectAll"
:indeterminate="isIndeterminate"
/>
<span class="text-sm text-gray-600 dark:text-gray-400">全选</span>
</div>
<span class="text-sm text-gray-500">
{{ total }} 个资源已选择 {{ selectedResources.length }}
</span>
</div>
<div class="flex space-x-2">
<n-button
type="primary"
:disabled="selectedResources.length === 0"
:loading="batchTransferring"
@click="handleBatchTransfer"
>
<template #icon>
<i class="fas fa-exchange-alt"></i>
</template>
批量转存 ({{ selectedResources.length }})
</n-button>
<n-button @click="refreshData">
<template #icon>
<i class="fas fa-refresh"></i>
</template>
刷新
</n-button>
</div>
</div>
</n-card>
<!-- 资源列表 -->
<n-card>
<div v-if="loading" class="flex items-center justify-center py-8">
<n-spin size="large" />
</div>
<div v-else-if="resources.length === 0" class="text-center py-8">
<i class="fas fa-inbox text-4xl text-gray-400 mb-4"></i>
<p class="text-gray-500">暂无未转存的夸克资源</p>
</div>
<div v-else>
<!-- 虚拟列表 -->
<n-virtual-list
:items="resources"
:item-size="120"
style="max-height: 500px"
container-style="height: 500px;"
>
<template #default="{ item }">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800">
<div class="flex items-start space-x-4">
<!-- 选择框 -->
<div class="pt-2">
<n-checkbox
:checked="selectedResources.includes(item.id)"
@update:checked="(checked) => toggleResourceSelection(item.id, checked)"
/>
</div>
<!-- 资源信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between">
<div class="flex-1 min-w-0">
<!-- 标题和状态 -->
<div class="flex items-center space-x-2 mb-2">
<h3 class="text-lg font-medium text-gray-900 dark:text-white line-clamp-1">
{{ item.title || '未命名资源' }}
</h3>
<n-tag :type="getStatusType(item)" size="small">
{{ getStatusText(item) }}
</n-tag>
</div>
<!-- 描述 -->
<p class="text-gray-600 dark:text-gray-400 text-sm line-clamp-2 mb-2">
{{ item.description || '暂无描述' }}
</p>
<!-- 元信息 -->
<div class="flex items-center space-x-4 text-sm text-gray-500">
<span class="flex items-center">
<i class="fas fa-folder mr-1"></i>
{{ item.category_name || '未分类' }}
</span>
<span class="flex items-center">
<i class="fas fa-cloud mr-1"></i>
夸克网盘
</span>
<span class="flex items-center">
<i class="fas fa-eye mr-1"></i>
{{ item.view_count || 0 }} 次浏览
</span>
<span class="flex items-center">
<i class="fas fa-calendar mr-1"></i>
{{ formatDate(item.created_at) }}
</span>
</div>
<!-- 原始链接 -->
<div class="mt-2">
<div class="flex items-center space-x-2">
<span class="text-xs text-gray-400">原始链接:</span>
<a
:href="item.url"
target="_blank"
class="text-xs text-blue-500 hover:text-blue-700 truncate max-w-xs"
>
{{ item.url }}
</a>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex flex-col space-y-2 ml-4">
<n-button
size="small"
type="primary"
:loading="item.transferring"
@click="handleSingleTransfer(item)"
>
<template #icon>
<i class="fas fa-exchange-alt"></i>
</template>
{{ item.transferring ? '转存中' : '立即转存' }}
</n-button>
<n-button
size="small"
@click="viewResource(item)"
>
<template #icon>
<i class="fas fa-eye"></i>
</template>
查看详情
</n-button>
</div>
</div>
</div>
</div>
</div>
</template>
</n-virtual-list>
<!-- 分页 -->
<div class="mt-4 flex justify-center">
<n-pagination
v-model:page="currentPage"
v-model:page-size="pageSize"
:item-count="total"
:page-sizes="[20, 50, 100, 200]"
show-size-picker
show-quick-jumper
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
</div>
</div>
</n-card>
<!-- 转存结果模态框 -->
<n-modal v-model:show="showTransferResult" preset="card" title="转存结果" style="width: 600px">
<div v-if="transferResults.length > 0" class="space-y-4">
<div class="grid grid-cols-3 gap-4">
<div class="text-center p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div class="text-xl font-bold text-green-600">{{ transferSuccessCount }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">成功</div>
</div>
<div class="text-center p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<div class="text-xl font-bold text-red-600">{{ transferFailedCount }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">失败</div>
</div>
<div class="text-center p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div class="text-xl font-bold text-blue-600">{{ transferResults.length }}</div>
<div class="text-sm text-gray-600 dark:text-gray-400">总计</div>
</div>
</div>
<div class="max-h-300 overflow-y-auto">
<div v-for="result in transferResults" :key="result.id" class="p-3 border rounded mb-2">
<div class="flex items-center justify-between">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{{ result.title }}</div>
<div class="text-xs text-gray-500 truncate">{{ result.url }}</div>
</div>
<n-tag :type="result.success ? 'success' : 'error'" size="small">
{{ result.success ? '成功' : '失败' }}
</n-tag>
</div>
<div v-if="result.message" class="text-xs text-gray-600 mt-1">
{{ result.message }}
</div>
</div>
</div>
</div>
</n-modal>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useResourceApi, useCategoryApi, useTagApi } from '~/composables/useApi'
// 数据状态
const loading = ref(false)
const resources = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(20)
// 搜索条件
const searchQuery = ref('')
const selectedCategory = ref(null)
const selectedTag = ref(null)
const selectedStatus = ref(null)
// 选择状态
const selectedResources = ref([])
// 批量操作状态
const batchTransferring = ref(false)
const showTransferResult = ref(false)
const transferResults = ref([])
// 选项数据
const categoryOptions = ref([])
const tagOptions = ref([])
const statusOptions = [
{ label: '有效', value: 'valid' },
{ label: '无效', value: 'invalid' },
{ label: '待验证', value: 'pending' }
]
// API实例
const resourceApi = useResourceApi()
const categoryApi = useCategoryApi()
const tagApi = useTagApi()
// 计算属性
const isAllSelected = computed(() => {
return resources.value.length > 0 && selectedResources.value.length === resources.value.length
})
const isIndeterminate = computed(() => {
return selectedResources.value.length > 0 && selectedResources.value.length < resources.value.length
})
const transferSuccessCount = computed(() => {
return transferResults.value.filter(r => r.success).length
})
const transferFailedCount = computed(() => {
return transferResults.value.filter(r => !r.success).length
})
// 获取未转存资源夸克网盘且无save_url
const fetchUntransferredResources = async () => {
loading.value = true
try {
const params = {
page: currentPage.value,
page_size: pageSize.value,
no_save_url: true, // 筛选没有转存链接的资源
pan_name: 'quark' // 仅夸克网盘资源
}
if (searchQuery.value) {
params.search = searchQuery.value
}
if (selectedCategory.value) {
params.category_id = selectedCategory.value
}
const result = await resourceApi.getResources(params) as any
console.log('未转存资源结果:', result)
if (result && result.resources) {
resources.value = result.resources
total.value = result.total || 0
} else if (Array.isArray(result)) {
resources.value = result
total.value = result.length
}
// 清空选择
selectedResources.value = []
} catch (error) {
console.error('获取未转存资源失败:', error)
resources.value = []
total.value = 0
} finally {
loading.value = false
}
}
// 获取分类选项
const fetchCategories = async () => {
try {
const result = await categoryApi.getCategories() as any
if (result && result.items) {
categoryOptions.value = result.items.map((item: any) => ({
label: item.name,
value: item.id
}))
}
} catch (error) {
console.error('获取分类失败:', error)
}
}
// 获取标签选项
const fetchTags = async () => {
try {
const result = await tagApi.getTags() as any
if (result && result.items) {
tagOptions.value = result.items.map((item: any) => ({
label: item.name,
value: item.id
}))
}
} catch (error) {
console.error('获取标签失败:', error)
}
}
// 搜索处理
const handleSearch = () => {
currentPage.value = 1
fetchUntransferredResources()
}
// 刷新数据
const refreshData = () => {
fetchUntransferredResources()
}
// 分页处理
const handlePageChange = (page: number) => {
currentPage.value = page
fetchUntransferredResources()
}
const handlePageSizeChange = (size: number) => {
pageSize.value = size
currentPage.value = 1
fetchUntransferredResources()
}
// 选择处理
const toggleSelectAll = (checked: boolean) => {
if (checked) {
selectedResources.value = resources.value.map(r => r.id)
} else {
selectedResources.value = []
}
}
const toggleResourceSelection = (id: number, checked: boolean) => {
if (checked) {
if (!selectedResources.value.includes(id)) {
selectedResources.value.push(id)
}
} else {
const index = selectedResources.value.indexOf(id)
if (index > -1) {
selectedResources.value.splice(index, 1)
}
}
}
// 单个转存
const handleSingleTransfer = async (resource: any) => {
resource.transferring = true
try {
// 这里应该调用实际的转存API
// 由于只是UI展示模拟转存过程
await new Promise(resolve => setTimeout(resolve, 2000))
// 模拟随机成功/失败
const isSuccess = Math.random() > 0.3
if (isSuccess) {
$message.success(`${resource.title} 转存成功`)
// 刷新列表
refreshData()
} else {
$message.error(`${resource.title} 转存失败`)
}
} catch (error) {
console.error('转存失败:', error)
$message.error('转存失败')
} finally {
resource.transferring = false
}
}
// 批量转存
const handleBatchTransfer = async () => {
if (selectedResources.value.length === 0) {
$message.warning('请选择要转存的资源')
return
}
batchTransferring.value = true
transferResults.value = []
try {
const selectedItems = resources.value.filter(r => selectedResources.value.includes(r.id))
// 这里应该调用实际的批量转存API
// 由于只是UI展示模拟批量转存过程
for (const item of selectedItems) {
await new Promise(resolve => setTimeout(resolve, 1000))
const isSuccess = Math.random() > 0.3
transferResults.value.push({
id: item.id,
title: item.title,
url: item.url,
success: isSuccess,
message: isSuccess ? '转存成功' : '转存失败:网络错误'
})
}
showTransferResult.value = true
// 刷新列表
refreshData()
} catch (error) {
console.error('批量转存失败:', error)
$message.error('批量转存失败')
} finally {
batchTransferring.value = false
}
}
// 查看资源详情
const viewResource = (resource: any) => {
console.log('查看资源详情:', resource)
// 这里可以打开资源详情模态框
}
// 获取状态类型
const getStatusType = (resource: any) => {
if (resource.is_valid === false) return 'error'
if (resource.is_valid === true) return 'success'
return 'warning'
}
// 获取状态文本
const getStatusText = (resource: any) => {
if (resource.is_valid === false) return '无效'
if (resource.is_valid === true) return '有效'
return '待验证'
}
// 格式化日期
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString()
}
// 初始化
onMounted(() => {
fetchCategories()
fetchTags()
fetchUntransferredResources()
})
</script>
<style scoped>
.line-clamp-1 {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -1,26 +1,46 @@
<template>
<div class="p-6">
<div class="mb-6">
<div class="max-w-7xl mx-auto space-y-6">
<!-- 页面标题 -->
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">数据转存管理</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">管理数据转存任务和配置</p>
<p class="text-gray-600 dark:text-gray-400">管理资源转存任务和状态</p>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="text-center py-12">
<div class="text-gray-400 dark:text-gray-500 mb-4">
<i class="fas fa-exchange-alt text-4xl"></i>
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">功能开发中</h3>
<p class="text-gray-500 dark:text-gray-400">数据转存管理功能正在开发中敬请期待...</p>
</div>
</div>
<!-- 主要内容 -->
<n-card>
<n-tabs v-model:value="activeTab" type="line" animated>
<!-- 手动批量转存 -->
<n-tab-pane name="manual" tab="手动批量转存">
<AdminManualBatchTransfer />
</n-tab-pane>
<!-- 已转存列表 -->
<n-tab-pane name="transferred" tab="已转存列表">
<AdminTransferredList ref="transferredListRef" />
</n-tab-pane>
<!-- 未转存列表 -->
<n-tab-pane name="untransferred" tab="未转存列表">
<AdminUntransferredList ref="untransferredListRef" />
</n-tab-pane>
</n-tabs>
</n-card>
</div>
</template>
<script setup lang="ts">
// 数据转存管理页面
import { ref } from 'vue'
// 页面配置
definePageMeta({
layout: 'admin',
middleware: ['auth']
})
// 活动标签
const activeTab = ref('manual')
// 组件引用
const transferredListRef = ref(null)
const untransferredListRef = ref(null)
</script>