| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543 |
- <script setup lang="ts">
- import { ref, onMounted, onUnmounted } from "vue";
- import { useRouter } from "vue-router";
- import {
- getSinglePurchaseList,
- getVideoDetail,
- getTestVideoDetail,
- convertTestVideoCoverUrl,
- getVideoSourceById
- } from "@/services/api";
- import DomainReminderDialog from "@/components/DomainReminderDialog.vue";
- interface PurchaseRecord {
- id: number;
- userId: number;
- resourceId: string;
- status: boolean;
- createdAt: string;
- }
- interface PurchaseItem {
- id: number;
- resourceId: string;
- title: string;
- meta: string;
- progress: number;
- cover: string;
- duration: number;
- createdAt: string;
- videoSource?: 1 | 3; // 视频源:1=视频源1,3=测试版
- m3u8?: string; // 视频播放地址
- }
- const router = useRouter();
- const purchasedItems = ref<PurchaseItem[]>([]);
- const isLoading = ref(true);
- const currentPage = ref(0);
- const hasMore = ref(true);
- const coverUrls = ref<Record<string, string>>({}); // 存储解密后的封面URLs
- const showDomainReminder = ref(false);
- // 生成设备标识
- const generateMacAddress = (): string => {
- const hex = "0123456789ABCDEF";
- let mac = "";
- for (let i = 0; i < 6; i++) {
- if (i > 0) mac += ":";
- mac += hex[Math.floor(Math.random() * 16)];
- mac += hex[Math.floor(Math.random() * 16)];
- }
- return mac;
- };
- const device = generateMacAddress();
- // 解密封面图片的函数
- const decryptCover = async (url: string): Promise<string> => {
- if (!url.includes("cover")) {
- return url;
- }
- try {
- const response = await fetch(url, { mode: "cors" });
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- const reader = response.body!.getReader();
- const key = new Uint8Array(8);
- const buffer: Uint8Array[] = [];
- let len = 0;
- let offset = 0;
- for (;;) {
- const read = await reader.read();
- if (read.done) break;
- if (!read.value) continue;
- if (len < 8) {
- let i = 0;
- while (i < read.value.length) {
- key[len++] = read.value[i++];
- if (len > 7) break;
- }
- if (len < 8) continue;
- read.value = read.value.slice(i);
- }
- // 复制数据以避免修改原始数据
- const decryptedValue = new Uint8Array(read.value.length);
- for (let j = 0; j < read.value.length; j++) {
- decryptedValue[j] = read.value[j] ^ key[(offset + j) % 8];
- }
- buffer.push(decryptedValue);
- offset += read.value.length;
- }
- // 合并所有解密后的数据
- const totalLength = buffer.reduce((sum, chunk) => sum + chunk.length, 0);
- const finalBuffer = new Uint8Array(totalLength);
- let pos = 0;
- for (const chunk of buffer) {
- finalBuffer.set(chunk, pos);
- pos += chunk.length;
- }
- // 创建Blob并生成URL
- const blob = new Blob([finalBuffer], { type: "image/jpeg" });
- return URL.createObjectURL(blob);
- } catch (error) {
- console.error("解密封面失败:", error);
- return url; // 解密失败时返回原始URL
- }
- };
- // 加载购买记录
- const loadPurchasedItems = async () => {
- try {
- isLoading.value = true;
- const response = await getSinglePurchaseList(currentPage.value, 20);
- if (response.content && Array.isArray(response.content)) {
- const purchaseRecords: PurchaseRecord[] = response.content;
- // 并发获取每个视频的详情信息
- const videoDetails = await Promise.all(
- purchaseRecords.map(async (record) => {
- // 根据视频ID格式判断视频源
- // 纯数字ID是视频源1,其他格式是测试版(视频源3)
- const videoSource: 1 | 3 = getVideoSourceById(record.resourceId);
- try {
- let videoData: any = null;
- let coverUrl = "";
- // 根据视频源调用不同的API
- if (videoSource === 3) {
- // 测试版视频源(视频源3)
- const videoResponse = await getTestVideoDetail(record.resourceId);
- if (videoResponse.code === 1 && videoResponse.data) {
- const data = videoResponse.data;
- videoData = {
- name: data.title || `视频 ${record.resourceId}`,
- cover: convertTestVideoCoverUrl(data.image, data.id),
- duration: 0,
- m3u8: data.m3u8 || "", // 保存视频播放地址
- };
- coverUrl = videoData.cover;
- }
- } else {
- // 视频源1
- const videoResponse = await getVideoDetail(
- device,
- record.resourceId
- );
- if (videoResponse.status === 0 && videoResponse.data) {
- const data = videoResponse.data;
- videoData = {
- name: data.name || `视频 ${record.resourceId}`,
- cover: data.cover || data.pic || data.thumbnail || "",
- duration: data.duration || 0,
- m3u8: data.m3u8 || "", // 保存视频播放地址
- };
- // 解密封面图片(仅视频源1需要解密)
- const originalCover = videoData.cover;
- if (originalCover && originalCover.includes("cover")) {
- try {
- coverUrl = await decryptCover(originalCover);
- coverUrls.value[record.id] = coverUrl;
- } catch (error) {
- console.error(`解密封面失败 (${record.resourceId}):`, error);
- coverUrl = originalCover;
- }
- } else {
- coverUrl = originalCover;
- }
- }
- }
- if (videoData) {
- return {
- id: record.id,
- resourceId: record.resourceId,
- title: videoData.name,
- meta: "高清 · 永久观看",
- progress: Math.round(20 + ((record.id * 13) % 70)),
- cover: coverUrl,
- duration: videoData.duration || 0,
- createdAt: record.createdAt,
- videoSource: videoSource,
- m3u8: videoData.m3u8 || "", // 保存视频播放地址
- } as PurchaseItem;
- } else {
- // 如果API返回失败,也返回默认信息
- return {
- id: record.id,
- resourceId: record.resourceId,
- title: `视频 ${record.resourceId}`,
- meta: "高清 · 永久观看",
- progress: Math.round(20 + ((record.id * 13) % 70)),
- cover: "",
- duration: 0,
- createdAt: record.createdAt,
- videoSource: videoSource,
- } as PurchaseItem;
- }
- } catch (error) {
- console.error(`获取视频 ${record.resourceId} 详情失败:`, error);
- // 如果获取详情失败,返回默认信息
- return {
- id: record.id,
- resourceId: record.resourceId,
- title: `购买条目 ${record.id}`,
- meta: "高清 · 永久观看",
- progress: Math.round(20 + ((record.id * 13) % 70)),
- cover: "",
- duration: 0,
- createdAt: record.createdAt,
- videoSource: videoSource,
- } as PurchaseItem;
- }
- })
- );
- // 添加视频详情到列表
- if (currentPage.value === 0) {
- purchasedItems.value = videoDetails;
- } else {
- purchasedItems.value.push(...videoDetails);
- }
- // 检查是否还有更多数据
- hasMore.value =
- response.metadata &&
- purchasedItems.value.length < response.metadata.total;
- }
- } catch (error) {
- console.error("加载购买记录失败:", error);
- // 如果API失败,显示默认数据
- purchasedItems.value = Array.from({ length: 6 }).map((_, i) => ({
- id: i + 1,
- resourceId: `${i + 1}`, // 默认资源ID
- title: `购买条目 ${i + 1}`,
- meta: "高清 · 永久观看",
- progress: Math.round(20 + ((i * 13) % 70)),
- cover: "",
- duration: 0,
- createdAt: new Date().toISOString(), // 默认当前时间
- }));
- } finally {
- isLoading.value = false;
- }
- };
- // 格式化时长
- const formatDuration = (duration: number): string => {
- const minutes = Math.floor(duration / 60);
- const seconds = duration % 60;
- return `${minutes}:${seconds.toString().padStart(2, "0")}`;
- };
- // 格式化日期
- const formatDate = (dateString: string): string => {
- try {
- const date = new Date(dateString);
- const now = new Date();
- const diffTime = Math.abs(now.getTime() - date.getTime());
- const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
- if (diffDays === 0) {
- return "今天";
- } else if (diffDays === 1) {
- return "昨天";
- } else if (diffDays < 7) {
- return `${diffDays}天前`;
- } else if (diffDays < 30) {
- const weeks = Math.floor(diffDays / 7);
- return `${weeks}周前`;
- } else if (diffDays < 365) {
- const months = Math.floor(diffDays / 30);
- return `${months}个月前`;
- } else {
- return date.toLocaleDateString("zh-CN", {
- year: "numeric",
- month: "short",
- day: "numeric",
- });
- }
- } catch (error) {
- console.error("日期格式化失败:", error);
- return "未知";
- }
- };
- // 加载更多
- const loadMore = () => {
- if (!hasMore.value || isLoading.value) return;
- currentPage.value++;
- loadPurchasedItems();
- };
- // 点击播放视频
- const playVideo = (item: PurchaseItem) => {
- // 跳转到视频播放页面,根据视频源传递source参数
- // 确保传递source参数,让播放页面能正确识别视频源并调用对应的接口
- const query: Record<string, string> = {};
-
- // 根据视频源传递source参数
- if (item.videoSource === 3) {
- query.source = '3';
- } else if (item.videoSource === 1) {
- query.source = '1'; // 显式传递source=1,确保播放页面使用视频源1接口
- } else {
- // 如果没有videoSource,根据ID格式判断
- const idBasedSource = getVideoSourceById(item.resourceId);
- query.source = idBasedSource === 3 ? '3' : '1';
- }
-
- // 已购买的视频,传递视频信息(包括m3u8),确保播放器能正常显示
- // 即使API调用失败,也能使用URL参数中的信息
- if (item.m3u8) {
- query.m3u8 = item.m3u8;
- }
- if (item.cover) {
- query.cover = item.cover;
- }
- if (item.title) {
- query.name = item.title;
- }
-
- router.push({
- name: "VideoPlayer",
- params: { id: item.resourceId },
- query: query,
- });
- };
- onMounted(() => {
- loadPurchasedItems();
- // 页面加载后显示域名提示弹窗
- showDomainReminder.value = true;
- });
- // 清理URL对象,防止内存泄漏
- onUnmounted(() => {
- Object.values(coverUrls.value).forEach((url) => {
- if (url.startsWith("blob:")) {
- URL.revokeObjectURL(url);
- }
- });
- coverUrls.value = {};
- });
- </script>
- <template>
- <section class="space-y-4">
- <h2 class="sr-only">已购买</h2>
- <div
- class="rounded-xl bg-white/5 border border-white/10 p-3 text-sm text-white/70"
- >
- <div v-if="isLoading" class="flex items-center gap-2">
- <div
- class="w-4 h-4 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"
- ></div>
- <span>加载中...</span>
- </div>
- <div v-else>共 {{ purchasedItems.length }} 个已购条目</div>
- </div>
- <!-- 加载状态 -->
- <div
- v-if="isLoading && purchasedItems.length === 0"
- class="grid grid-cols-2 gap-3"
- >
- <article
- v-for="i in 6"
- :key="i"
- class="rounded-xl overflow-hidden bg-white/5 border border-white/10 animate-pulse"
- >
- <div class="aspect-[16/9] bg-slate-700/60" />
- <div class="p-3">
- <div class="h-4 bg-slate-700/60 rounded mb-1" />
- <div class="h-3 bg-slate-700/40 rounded w-16" />
- </div>
- </article>
- </div>
- <!-- 购买记录列表 -->
- <div v-else class="grid grid-cols-2 gap-3">
- <article
- v-for="item in purchasedItems"
- :key="item.id"
- @click="playVideo(item)"
- class="group rounded-xl overflow-hidden bg-white/5 border border-white/10 cursor-pointer hover:bg-white/10 transition-all duration-300"
- >
- <!-- 封面图片 -->
- <div class="aspect-[16/9] relative">
- <img
- v-if="item.cover"
- :src="item.cover"
- :alt="item.title"
- class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
- @error="event => {
- const target = event.target as HTMLImageElement;
- if (target) target.style.display = 'none';
- }"
- />
- <!-- 无封面时显示占位符 -->
- <div
- v-else
- class="w-full h-full bg-gradient-to-br from-slate-700/60 to-slate-800/60 flex flex-col items-center justify-center"
- >
- <svg
- class="w-12 h-12 text-white/40 mb-2"
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="1.5"
- d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
- />
- </svg>
- <span class="text-xs text-white/40">暂无预览</span>
- </div>
- <!-- 视频时长 -->
- <div
- class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded backdrop-blur-sm"
- >
- {{ formatDuration(item.duration) }}
- </div>
- </div>
- <!-- 视频信息 -->
- <div class="p-3">
- <h3
- class="text-sm font-medium text-white/90 line-clamp-2 mb-1 group-hover:text-white transition-colors"
- >
- {{ item.title }}
- </h3>
- <div class="flex items-center justify-between text-xs text-white/50">
- <!-- 购买日期 -->
- <span>{{ formatDate(item.createdAt) }}</span>
- <!-- 已购买状态 -->
- <div class="flex items-center gap-1">
- <svg
- class="w-3 h-3"
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M5 13l4 4L19 7"
- />
- </svg>
- <span>已购买</span>
- </div>
- </div>
- </div>
- </article>
- </div>
- <!-- 加载更多按钮或到底提示 -->
- <div class="flex justify-center pt-6">
- <div v-if="hasMore">
- <button
- @click="loadMore"
- :disabled="isLoading"
- class="px-6 py-3 rounded-lg bg-white/10 border border-white/20 text-white/80 hover:bg-white/20 hover:text-white transition-all disabled:opacity-50 disabled:cursor-not-allowed"
- >
- {{ isLoading ? "加载中..." : "加载更多" }}
- </button>
- </div>
- <div
- v-else-if="purchasedItems.length > 0"
- class="text-center text-white/40 text-sm"
- >
- <div class="flex items-center justify-center gap-2">
- <svg
- class="w-4 h-4"
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M5 13l4 4L19 7"
- />
- </svg>
- <span>已经到底啦</span>
- </div>
- </div>
- </div>
- <!-- 空状态 -->
- <div
- v-if="!isLoading && purchasedItems.length === 0"
- class="text-center py-12"
- >
- <div class="text-white/40 text-sm">
- <svg
- class="w-16 h-16 mx-auto mb-4"
- fill="none"
- stroke="currentColor"
- viewBox="0 0 24 24"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="1.5"
- d="M15 13l-3 3m0 0l-3-3m3 3V8m0 13a9 9 0 110-18 9 9 0 010 18z"
- />
- </svg>
- <p>暂无购买记录</p>
- </div>
- </div>
- <!-- 域名提示弹窗 -->
- <DomainReminderDialog v-model="showDomainReminder" />
- </section>
- </template>
- <style scoped>
- .btn-subtle {
- @apply px-2.5 py-1 rounded-md bg-white/5 border border-white/10 text-white/80 hover:bg-white/10;
- }
- </style>
|