feat: 视频预览/数据看板/下载配置 三项业务功能

- 新增视频预览:VideoPlayer 组件、VideoController/DyCollectVideoService/Repository、VideoPageRequestDTO 对应后端改动
- 新增数据看板:workplace/statics.vue、RecordTable、相关 store/http/axios 改动
- 新增下载配置:DownloadConfigService、ConfigController、AppSet.vue、AppConfig
- 其他:DyHttpClientService / 同步 Job(Collect, Favorit) / Cookie 表 / CommonService / ServiceExtension 的配套调整
- 仓库卫生:.gitignore 追加 feiniu 过滤;运维脚本归档到 docs/scripts/
This commit is contained in:
yuming
2026-04-25 00:22:33 +08:00
parent ca530312d0
commit b328e82eb2
27 changed files with 3602 additions and 348 deletions
+369 -49
View File
@@ -7,6 +7,19 @@
<a-form-item label="抖音作者" ref="author" name="author">
<a-input v-model:value="quaryData.author"></a-input>
</a-form-item>
<a-form-item label="视频类型">
<a-select
v-model:value="selectedCategories"
mode="multiple"
placeholder="请选择视频类型(可多选)"
style="width: 300px"
:options="categoryOptions"
:loading="categoryLoading"
allow-clear
@change="onCategoryChanged"
>
</a-select>
</a-form-item>
<a-form-item>
<a-radio-group v-model:value="quaryData.viedoType" button-style="solid" @change="onViedoTypeChanged">
<a-radio-button value="*">全部</a-radio-button>
@@ -15,69 +28,165 @@
<a-radio-button value="3">关注的</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item label="删除状态">
<a-select
v-model:value="quaryData.isDeleted"
placeholder="全部"
style="width: 120px"
allow-clear
@change="handleQuery"
>
<a-select-option :value="undefined">全部</a-select-option>
<a-select-option :value="false">未删除</a-select-option>
<a-select-option :value="true">已删除</a-select-option>
</a-select>
</a-form-item>
<a-form-item :wrapper-col="{ offset: 8, span: 16 }">
<a-space>
<a-button type="primary" @click="GetRecords">查询</a-button>
<a-button type="primary" @click="handleQuery">查询</a-button>
<a-button type="danger" @click="StartNow">立即重新同步</a-button>
</a-space>
</a-form-item>
</a-form>
<a-table :columns="columns" :data-source="dataSource" bordered :pagination="pagination" @change="handleTableChange" :loading="loading">
<a-table
:columns="columns"
:data-source="dataSource"
bordered
:pagination="pagination"
@change="handleTableChange"
:loading="loading"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<a-space>
<!-- 如果视频文件不存在,显示"已删除" -->
<span v-if="!record.videoSavePath" style="color: #999; font-size: 13px;">已删除</span>
<!-- 如果视频文件存在,显示预览按钮 -->
<a-button v-else type="link" size="small" @click="handlePreview(record)">
<template #icon><PlayCircleOutlined /></template>
预览
</a-button>
</a-space>
</template>
</template>
</a-table>
<!-- 视频播放器 -->
<VideoPlayer ref="videoPlayerRef" />
</div>
</template>
<script lang="ts" setup>
import { defineComponent, reactive, ref } from 'vue';
import { defineComponent, reactive, ref, watch } from 'vue';
import { useApiStore } from '@/store';
import type { UnwrapRef } from 'vue';
import { onMounted } from 'vue';
import dayjs, { Dayjs } from 'dayjs';
import locale from 'ant-design-vue/es/date-picker/locale/zh_CN';
import { PlayCircleOutlined } from '@ant-design/icons-vue';
import VideoPlayer from '@/components/video/VideoPlayer.vue';
type RangeValue = [Dayjs, Dayjs];
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
const columns = ref([
{
title: '同步时间',
dataIndex: 'syncTimeStr',
// sorter: true,
align: 'center',
width: 180,
},
{
title: '同步类型',
dataIndex: 'viedoTypeStr',
// sorter: true,
align: 'center',
width: 100,
},
{
title: '视频类型',
dataIndex: 'viedoCate',
// sorter: true,
width: 300,
align: 'center',
},
{
title: '作者',
dataIndex: 'author',
// sorter: true,
align: 'center',
width: 150,
},
{
title: '视频名称',
dataIndex: 'videoTitle',
// sorter: true,
align: 'left',
},
const videoPlayerRef = ref<InstanceType<typeof VideoPlayer> | null>(null);
// 当前排序状态
const currentSortField = ref<string | null>(null);
const currentSortOrder = ref<'asc' | 'desc' | null>(null);
// 处理列头点击排序
const handleColumnSort = (fieldKey: string) => {
console.log('列头点击排序:', fieldKey);
// 如果点击的是当前排序列,切换排序方向;否则设置为升序
if (currentSortField.value === fieldKey) {
if (currentSortOrder.value === 'asc') {
currentSortOrder.value = 'desc';
} else if (currentSortOrder.value === 'desc') {
// 第三次点击取消排序
currentSortField.value = null;
currentSortOrder.value = null;
}
} else {
currentSortField.value = fieldKey;
currentSortOrder.value = 'asc';
}
// 更新查询参数
quaryData.sortField = currentSortField.value;
quaryData.sortOrder = currentSortOrder.value;
console.log('设置排序参数:', {
sortField: quaryData.sortField,
sortOrder: quaryData.sortOrder
});
// 更新列配置以反映排序状态
updateColumnSortState();
// 重置到第一页并重新查询
pagination.value.current = 1;
GetRecords();
};
// 更新列配置的排序状态
const updateColumnSortState = () => {
columns.value.forEach((col: any) => {
if (col.sorter && col.key) {
if (currentSortField.value === col.key) {
col.sorter.order = currentSortOrder.value === 'asc' ? 'ascend' : currentSortOrder.value === 'desc' ? 'descend' : null;
} else {
col.sorter.order = null;
}
}
});
// 强制触发响应式更新
columns.value = [...columns.value];
};
// 监听排序状态变化,自动更新列配置
watch([currentSortField, currentSortOrder], () => {
updateColumnSortState();
}, { immediate: true });
// 创建可排序列配置
const createSortableColumn = (title: string, dataIndex: string, key: string, width?: number, align: string = 'left') => {
const col: any = {
title,
dataIndex,
key,
width,
align,
sorter: {
compare: () => 0, // 禁用前端排序
multiple: false,
order: null, // 初始化为 null,后续通过 updateColumnSortState 更新
},
customHeaderCell: () => {
return {
onClick: () => {
handleColumnSort(key);
},
style: { cursor: 'pointer' }
};
},
};
return col;
};
const columns = ref([
createSortableColumn('同步时间', 'syncTimeStr', 'syncTime', 180, 'center'),
createSortableColumn('同步类型', 'viedoTypeStr', 'viedoType', 100, 'center'),
createSortableColumn('视频类型', 'viedoCate', 'viedoCate', 300, 'center'),
createSortableColumn('作者', 'author', 'author', 150, 'center'),
createSortableColumn('视频名称', 'videoTitle', 'videoTitle', undefined, 'left'),
createSortableColumn('用户', 'dyUser', 'dyUser', 200, 'center'),
{
title: '用户',
dataIndex: 'dyUser',
// sorter: true,
title: '操作',
key: 'action',
align: 'center',
width: 200,
width: 120,
fixed: 'right',
},
]);
const loading = ref(false);
@@ -90,7 +199,11 @@ interface QuaryParam {
pageSize: number;
author: string;
tag: string;
tags?: string[];
viedoType: string;
isDeleted?: boolean | null;
sortField?: string | null;
sortOrder?: string | null;
}
const value1 = ref<RangeValue>();
@@ -104,26 +217,127 @@ const quaryData: UnwrapRef<QuaryParam> = reactive({
pageSize: 20,
author: '',
tag: '',
tags: undefined,
viedoType: '*',
isDeleted: undefined,
sortField: null,
sortOrder: null,
});
// 视频类型相关
const selectedCategories = ref<string[]>([]);
const categoryOptions = ref<Array<{ label: string; value: string }>>([]);
const categoryLoading = ref(false);
// 加载视频类型列表
const loadCategories = async () => {
categoryLoading.value = true;
try {
console.log('开始加载视频类型列表...');
const res = await useApiStore().VideoCategories();
console.log('视频类型 API 响应(完整):', JSON.stringify(res, null, 2));
console.log('视频类型 API 响应类型:', typeof res);
console.log('视频类型 API 响应是否为数组:', Array.isArray(res));
// 处理不同的响应格式
let categories: string[] = [];
if (Array.isArray(res)) {
// 如果直接返回数组
categories = res;
} else if (res && typeof res === 'object') {
// 如果是对象,尝试获取 data 字段
if (res.code === 0 && res.data) {
categories = Array.isArray(res.data) ? res.data : [];
} else if (Array.isArray(res.data)) {
categories = res.data;
} else if (res.code === 0 && Array.isArray(res)) {
categories = res as any;
}
}
console.log('提取的视频类型数据:', categories);
if (categories && categories.length > 0) {
categoryOptions.value = categories.map(cat => ({
label: cat || '其他',
value: cat || '其他',
}));
console.log('视频类型选项:', categoryOptions.value);
} else {
console.warn('视频类型数据为空,可能原因:1. 数据库中没有视频数据 2. 所有视频的 Tag1 和 Tag2 都为空');
}
} catch (err) {
console.error('加载视频类型列表失败:', err);
} finally {
categoryLoading.value = false;
}
};
// 视频类型选择变化
const onCategoryChanged = (values: string[]) => {
selectedCategories.value = values;
if (values && values.length > 0) {
quaryData.tags = values;
quaryData.tag = ''; // 清空单标签筛选
} else {
quaryData.tags = undefined;
}
pagination.value.current = 1;
GetRecords();
};
// 查询处理函数:先刷新类型列表,再查询记录
const handleQuery = async () => {
// 先刷新视频类型列表(确保包含最新的类型)
await loadCategories();
// 然后查询记录
GetRecords();
};
const GetRecords = () => {
loading.value = true;
quaryData.pageIndex = pagination.value.current;
quaryData.pageSize = pagination.value.defaultPageSize;
// 构建请求参数,确保排序参数被包含
const requestParams = {
...quaryData,
sortField: quaryData.sortField || null,
sortOrder: quaryData.sortOrder || null,
};
console.log('发送查询请求,参数:', JSON.stringify(requestParams, null, 2));
useApiStore()
.VideoPageList(quaryData)
.VideoPageList(requestParams)
.then((res) => {
loading.value = false;
console.log('查询响应:', res);
if (res.code === 0) {
console.log('接收到的数据:', res.data.data);
console.log('数据条数:', res.data.data?.length);
if (res.data.data && res.data.data.length > 0) {
console.log('第一条数据的同步时间:', res.data.data[0].syncTimeStr);
console.log('最后一条数据的同步时间:', res.data.data[res.data.data.length - 1].syncTimeStr);
}
dataSource.value = res.data.data;
pagination.value.current = res.data.pageIndex;
pagination.value.defaultPageSize = res.data.pageSize;
pagination.value.total = res.data.total;
pagination.value.showTotal = () => `共 ${res.data.total} 条`;
// 数据加载完成后,确保排序状态正确显示
updateColumnSortState();
}
})
.catch((err) => {
loading.value = false;
console.error('查询失败:', err);
});
};
onMounted(() => {
loadCategories(); // 加载视频类型列表
GetRecords();
});
const pagination = ref({
@@ -133,12 +347,88 @@ const pagination = ref({
showTotal: () => `共 ${0} 条`,
});
const handleTableChange = (e) => {
console.log(e);
pagination.value.current = e.current;
pagination.value.defaultPageSize = e.defaultPageSize;
pagination.value.total = e.total;
pagination.value.showTotal = () => `共 ${e.total} 条`;
const handleTableChange = (e: any) => {
console.log('表格变化事件:', e);
console.log('事件对象键:', Object.keys(e));
console.log('sorter 属性:', e.sorter);
// 处理分页变化
if (e.current !== undefined) {
pagination.value.current = e.current;
}
if (e.pageSize !== undefined) {
pagination.value.defaultPageSize = e.pageSize;
}
if (e.total !== undefined) {
pagination.value.total = e.total;
pagination.value.showTotal = () => `共 ${e.total} 条`;
}
// 处理排序变化(Ant Design Vue 3.x 中排序通过 change 事件的 sorter 属性传递)
if (e.sorter) {
console.log('检测到排序变化:', e.sorter);
handleSorterChange(e.sorter);
} else {
// 如果没有排序变化,直接查询
console.log('无排序变化,直接查询');
GetRecords();
}
};
// 处理排序变化
const handleSorterChange = (sorter: any) => {
console.log('排序变化事件:', sorter);
console.log('排序变化类型:', typeof sorter, Array.isArray(sorter) ? '数组' : '对象');
console.log('当前查询参数:', quaryData);
// Ant Design Vue 3.x 的 sorterChange 可能传递单个对象或对象数组
let activeSorter: any = null;
if (Array.isArray(sorter)) {
// 如果是数组,找到第一个有 order 的排序器
activeSorter = sorter.find((s: any) => s && s.order);
} else if (sorter && typeof sorter === 'object') {
// 如果是单个对象
activeSorter = sorter;
}
console.log('活动排序器:', activeSorter);
if (!activeSorter || !activeSorter.field || !activeSorter.order) {
// 没有排序或取消排序
quaryData.sortField = null;
quaryData.sortOrder = null;
console.log('取消排序');
} else {
// 映射前端字段名(key)到后端字段名
const fieldMap: Record<string, string> = {
'syncTime': 'syncTime',
'viedoType': 'viedoType',
'viedoCate': 'viedoCate',
'author': 'author',
'videoTitle': 'videoTitle',
'dyUser': 'dyUser',
};
const field = activeSorter.field;
const order = activeSorter.order;
const backendField = fieldMap[field] || field;
// 有排序:ascend -> asc, descend -> desc
quaryData.sortField = backendField;
quaryData.sortOrder = order === 'ascend' ? 'asc' : 'desc';
console.log('设置排序参数:', {
field: field,
backendField: backendField,
order: order,
sortField: quaryData.sortField,
sortOrder: quaryData.sortOrder
});
}
// 重置到第一页并重新查询
pagination.value.current = 1;
GetRecords();
};
@@ -159,6 +449,36 @@ const onViedoTypeChanged = (e) => {
pagination.value.current = 1;
GetRecords();
};
// 预览视频
const handlePreview = (record: any) => {
console.log('handlePreview called', record);
if (!record.id) {
console.error('视频记录没有 id', record);
return;
}
if (!videoPlayerRef.value) {
console.error('videoPlayerRef.value 为 null,组件可能未正确挂载');
return;
}
console.log('调用 videoPlayerRef.value.open');
try {
videoPlayerRef.value.open(record.id, {
id: record.id,
videoTitle: record.videoTitle || '未知标题',
author: record.author || '未知作者',
viedoCate: record.viedoCate || '未分类',
resolution: record.resolution || '未知',
fileSize: record.fileSize || 0,
videoSavePath: record.videoSavePath,
});
} catch (error) {
console.error('打开视频播放器失败:', error);
}
};
</script>
<style scoped>
</style>