Purchased.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543
  1. <script setup lang="ts">
  2. import { ref, onMounted, onUnmounted } from "vue";
  3. import { useRouter } from "vue-router";
  4. import {
  5. getSinglePurchaseList,
  6. getVideoDetail,
  7. getTestVideoDetail,
  8. convertTestVideoCoverUrl,
  9. getVideoSourceById
  10. } from "@/services/api";
  11. import DomainReminderDialog from "@/components/DomainReminderDialog.vue";
  12. interface PurchaseRecord {
  13. id: number;
  14. userId: number;
  15. resourceId: string;
  16. status: boolean;
  17. createdAt: string;
  18. }
  19. interface PurchaseItem {
  20. id: number;
  21. resourceId: string;
  22. title: string;
  23. meta: string;
  24. progress: number;
  25. cover: string;
  26. duration: number;
  27. createdAt: string;
  28. videoSource?: 1 | 3; // 视频源:1=视频源1,3=测试版
  29. m3u8?: string; // 视频播放地址
  30. }
  31. const router = useRouter();
  32. const purchasedItems = ref<PurchaseItem[]>([]);
  33. const isLoading = ref(true);
  34. const currentPage = ref(0);
  35. const hasMore = ref(true);
  36. const coverUrls = ref<Record<string, string>>({}); // 存储解密后的封面URLs
  37. const showDomainReminder = ref(false);
  38. // 生成设备标识
  39. const generateMacAddress = (): string => {
  40. const hex = "0123456789ABCDEF";
  41. let mac = "";
  42. for (let i = 0; i < 6; i++) {
  43. if (i > 0) mac += ":";
  44. mac += hex[Math.floor(Math.random() * 16)];
  45. mac += hex[Math.floor(Math.random() * 16)];
  46. }
  47. return mac;
  48. };
  49. const device = generateMacAddress();
  50. // 解密封面图片的函数
  51. const decryptCover = async (url: string): Promise<string> => {
  52. if (!url.includes("cover")) {
  53. return url;
  54. }
  55. try {
  56. const response = await fetch(url, { mode: "cors" });
  57. if (!response.ok) {
  58. throw new Error(`HTTP error! status: ${response.status}`);
  59. }
  60. const reader = response.body!.getReader();
  61. const key = new Uint8Array(8);
  62. const buffer: Uint8Array[] = [];
  63. let len = 0;
  64. let offset = 0;
  65. for (;;) {
  66. const read = await reader.read();
  67. if (read.done) break;
  68. if (!read.value) continue;
  69. if (len < 8) {
  70. let i = 0;
  71. while (i < read.value.length) {
  72. key[len++] = read.value[i++];
  73. if (len > 7) break;
  74. }
  75. if (len < 8) continue;
  76. read.value = read.value.slice(i);
  77. }
  78. // 复制数据以避免修改原始数据
  79. const decryptedValue = new Uint8Array(read.value.length);
  80. for (let j = 0; j < read.value.length; j++) {
  81. decryptedValue[j] = read.value[j] ^ key[(offset + j) % 8];
  82. }
  83. buffer.push(decryptedValue);
  84. offset += read.value.length;
  85. }
  86. // 合并所有解密后的数据
  87. const totalLength = buffer.reduce((sum, chunk) => sum + chunk.length, 0);
  88. const finalBuffer = new Uint8Array(totalLength);
  89. let pos = 0;
  90. for (const chunk of buffer) {
  91. finalBuffer.set(chunk, pos);
  92. pos += chunk.length;
  93. }
  94. // 创建Blob并生成URL
  95. const blob = new Blob([finalBuffer], { type: "image/jpeg" });
  96. return URL.createObjectURL(blob);
  97. } catch (error) {
  98. console.error("解密封面失败:", error);
  99. return url; // 解密失败时返回原始URL
  100. }
  101. };
  102. // 加载购买记录
  103. const loadPurchasedItems = async () => {
  104. try {
  105. isLoading.value = true;
  106. const response = await getSinglePurchaseList(currentPage.value, 20);
  107. if (response.content && Array.isArray(response.content)) {
  108. const purchaseRecords: PurchaseRecord[] = response.content;
  109. // 并发获取每个视频的详情信息
  110. const videoDetails = await Promise.all(
  111. purchaseRecords.map(async (record) => {
  112. // 根据视频ID格式判断视频源
  113. // 纯数字ID是视频源1,其他格式是测试版(视频源3)
  114. const videoSource: 1 | 3 = getVideoSourceById(record.resourceId);
  115. try {
  116. let videoData: any = null;
  117. let coverUrl = "";
  118. // 根据视频源调用不同的API
  119. if (videoSource === 3) {
  120. // 测试版视频源(视频源3)
  121. const videoResponse = await getTestVideoDetail(record.resourceId);
  122. if (videoResponse.code === 1 && videoResponse.data) {
  123. const data = videoResponse.data;
  124. videoData = {
  125. name: data.title || `视频 ${record.resourceId}`,
  126. cover: convertTestVideoCoverUrl(data.image, data.id),
  127. duration: 0,
  128. m3u8: data.m3u8 || "", // 保存视频播放地址
  129. };
  130. coverUrl = videoData.cover;
  131. }
  132. } else {
  133. // 视频源1
  134. const videoResponse = await getVideoDetail(
  135. device,
  136. record.resourceId
  137. );
  138. if (videoResponse.status === 0 && videoResponse.data) {
  139. const data = videoResponse.data;
  140. videoData = {
  141. name: data.name || `视频 ${record.resourceId}`,
  142. cover: data.cover || data.pic || data.thumbnail || "",
  143. duration: data.duration || 0,
  144. m3u8: data.m3u8 || "", // 保存视频播放地址
  145. };
  146. // 解密封面图片(仅视频源1需要解密)
  147. const originalCover = videoData.cover;
  148. if (originalCover && originalCover.includes("cover")) {
  149. try {
  150. coverUrl = await decryptCover(originalCover);
  151. coverUrls.value[record.id] = coverUrl;
  152. } catch (error) {
  153. console.error(`解密封面失败 (${record.resourceId}):`, error);
  154. coverUrl = originalCover;
  155. }
  156. } else {
  157. coverUrl = originalCover;
  158. }
  159. }
  160. }
  161. if (videoData) {
  162. return {
  163. id: record.id,
  164. resourceId: record.resourceId,
  165. title: videoData.name,
  166. meta: "高清 · 永久观看",
  167. progress: Math.round(20 + ((record.id * 13) % 70)),
  168. cover: coverUrl,
  169. duration: videoData.duration || 0,
  170. createdAt: record.createdAt,
  171. videoSource: videoSource,
  172. m3u8: videoData.m3u8 || "", // 保存视频播放地址
  173. } as PurchaseItem;
  174. } else {
  175. // 如果API返回失败,也返回默认信息
  176. return {
  177. id: record.id,
  178. resourceId: record.resourceId,
  179. title: `视频 ${record.resourceId}`,
  180. meta: "高清 · 永久观看",
  181. progress: Math.round(20 + ((record.id * 13) % 70)),
  182. cover: "",
  183. duration: 0,
  184. createdAt: record.createdAt,
  185. videoSource: videoSource,
  186. } as PurchaseItem;
  187. }
  188. } catch (error) {
  189. console.error(`获取视频 ${record.resourceId} 详情失败:`, error);
  190. // 如果获取详情失败,返回默认信息
  191. return {
  192. id: record.id,
  193. resourceId: record.resourceId,
  194. title: `购买条目 ${record.id}`,
  195. meta: "高清 · 永久观看",
  196. progress: Math.round(20 + ((record.id * 13) % 70)),
  197. cover: "",
  198. duration: 0,
  199. createdAt: record.createdAt,
  200. videoSource: videoSource,
  201. } as PurchaseItem;
  202. }
  203. })
  204. );
  205. // 添加视频详情到列表
  206. if (currentPage.value === 0) {
  207. purchasedItems.value = videoDetails;
  208. } else {
  209. purchasedItems.value.push(...videoDetails);
  210. }
  211. // 检查是否还有更多数据
  212. hasMore.value =
  213. response.metadata &&
  214. purchasedItems.value.length < response.metadata.total;
  215. }
  216. } catch (error) {
  217. console.error("加载购买记录失败:", error);
  218. // 如果API失败,显示默认数据
  219. purchasedItems.value = Array.from({ length: 6 }).map((_, i) => ({
  220. id: i + 1,
  221. resourceId: `${i + 1}`, // 默认资源ID
  222. title: `购买条目 ${i + 1}`,
  223. meta: "高清 · 永久观看",
  224. progress: Math.round(20 + ((i * 13) % 70)),
  225. cover: "",
  226. duration: 0,
  227. createdAt: new Date().toISOString(), // 默认当前时间
  228. }));
  229. } finally {
  230. isLoading.value = false;
  231. }
  232. };
  233. // 格式化时长
  234. const formatDuration = (duration: number): string => {
  235. const minutes = Math.floor(duration / 60);
  236. const seconds = duration % 60;
  237. return `${minutes}:${seconds.toString().padStart(2, "0")}`;
  238. };
  239. // 格式化日期
  240. const formatDate = (dateString: string): string => {
  241. try {
  242. const date = new Date(dateString);
  243. const now = new Date();
  244. const diffTime = Math.abs(now.getTime() - date.getTime());
  245. const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
  246. if (diffDays === 0) {
  247. return "今天";
  248. } else if (diffDays === 1) {
  249. return "昨天";
  250. } else if (diffDays < 7) {
  251. return `${diffDays}天前`;
  252. } else if (diffDays < 30) {
  253. const weeks = Math.floor(diffDays / 7);
  254. return `${weeks}周前`;
  255. } else if (diffDays < 365) {
  256. const months = Math.floor(diffDays / 30);
  257. return `${months}个月前`;
  258. } else {
  259. return date.toLocaleDateString("zh-CN", {
  260. year: "numeric",
  261. month: "short",
  262. day: "numeric",
  263. });
  264. }
  265. } catch (error) {
  266. console.error("日期格式化失败:", error);
  267. return "未知";
  268. }
  269. };
  270. // 加载更多
  271. const loadMore = () => {
  272. if (!hasMore.value || isLoading.value) return;
  273. currentPage.value++;
  274. loadPurchasedItems();
  275. };
  276. // 点击播放视频
  277. const playVideo = (item: PurchaseItem) => {
  278. // 跳转到视频播放页面,根据视频源传递source参数
  279. // 确保传递source参数,让播放页面能正确识别视频源并调用对应的接口
  280. const query: Record<string, string> = {};
  281. // 根据视频源传递source参数
  282. if (item.videoSource === 3) {
  283. query.source = '3';
  284. } else if (item.videoSource === 1) {
  285. query.source = '1'; // 显式传递source=1,确保播放页面使用视频源1接口
  286. } else {
  287. // 如果没有videoSource,根据ID格式判断
  288. const idBasedSource = getVideoSourceById(item.resourceId);
  289. query.source = idBasedSource === 3 ? '3' : '1';
  290. }
  291. // 已购买的视频,传递视频信息(包括m3u8),确保播放器能正常显示
  292. // 即使API调用失败,也能使用URL参数中的信息
  293. if (item.m3u8) {
  294. query.m3u8 = item.m3u8;
  295. }
  296. if (item.cover) {
  297. query.cover = item.cover;
  298. }
  299. if (item.title) {
  300. query.name = item.title;
  301. }
  302. router.push({
  303. name: "VideoPlayer",
  304. params: { id: item.resourceId },
  305. query: query,
  306. });
  307. };
  308. onMounted(() => {
  309. loadPurchasedItems();
  310. // 页面加载后显示域名提示弹窗
  311. showDomainReminder.value = true;
  312. });
  313. // 清理URL对象,防止内存泄漏
  314. onUnmounted(() => {
  315. Object.values(coverUrls.value).forEach((url) => {
  316. if (url.startsWith("blob:")) {
  317. URL.revokeObjectURL(url);
  318. }
  319. });
  320. coverUrls.value = {};
  321. });
  322. </script>
  323. <template>
  324. <section class="space-y-4">
  325. <h2 class="sr-only">已购买</h2>
  326. <div
  327. class="rounded-xl bg-white/5 border border-white/10 p-3 text-sm text-white/70"
  328. >
  329. <div v-if="isLoading" class="flex items-center gap-2">
  330. <div
  331. class="w-4 h-4 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"
  332. ></div>
  333. <span>加载中...</span>
  334. </div>
  335. <div v-else>共 {{ purchasedItems.length }} 个已购条目</div>
  336. </div>
  337. <!-- 加载状态 -->
  338. <div
  339. v-if="isLoading && purchasedItems.length === 0"
  340. class="grid grid-cols-2 gap-3"
  341. >
  342. <article
  343. v-for="i in 6"
  344. :key="i"
  345. class="rounded-xl overflow-hidden bg-white/5 border border-white/10 animate-pulse"
  346. >
  347. <div class="aspect-[16/9] bg-slate-700/60" />
  348. <div class="p-3">
  349. <div class="h-4 bg-slate-700/60 rounded mb-1" />
  350. <div class="h-3 bg-slate-700/40 rounded w-16" />
  351. </div>
  352. </article>
  353. </div>
  354. <!-- 购买记录列表 -->
  355. <div v-else class="grid grid-cols-2 gap-3">
  356. <article
  357. v-for="item in purchasedItems"
  358. :key="item.id"
  359. @click="playVideo(item)"
  360. class="group rounded-xl overflow-hidden bg-white/5 border border-white/10 cursor-pointer hover:bg-white/10 transition-all duration-300"
  361. >
  362. <!-- 封面图片 -->
  363. <div class="aspect-[16/9] relative">
  364. <img
  365. v-if="item.cover"
  366. :src="item.cover"
  367. :alt="item.title"
  368. class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
  369. @error="event => {
  370. const target = event.target as HTMLImageElement;
  371. if (target) target.style.display = 'none';
  372. }"
  373. />
  374. <!-- 无封面时显示占位符 -->
  375. <div
  376. v-else
  377. class="w-full h-full bg-gradient-to-br from-slate-700/60 to-slate-800/60 flex flex-col items-center justify-center"
  378. >
  379. <svg
  380. class="w-12 h-12 text-white/40 mb-2"
  381. fill="none"
  382. stroke="currentColor"
  383. viewBox="0 0 24 24"
  384. >
  385. <path
  386. stroke-linecap="round"
  387. stroke-linejoin="round"
  388. stroke-width="1.5"
  389. 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"
  390. />
  391. </svg>
  392. <span class="text-xs text-white/40">暂无预览</span>
  393. </div>
  394. <!-- 视频时长 -->
  395. <div
  396. class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded backdrop-blur-sm"
  397. >
  398. {{ formatDuration(item.duration) }}
  399. </div>
  400. </div>
  401. <!-- 视频信息 -->
  402. <div class="p-3">
  403. <h3
  404. class="text-sm font-medium text-white/90 line-clamp-2 mb-1 group-hover:text-white transition-colors"
  405. >
  406. {{ item.title }}
  407. </h3>
  408. <div class="flex items-center justify-between text-xs text-white/50">
  409. <!-- 购买日期 -->
  410. <span>{{ formatDate(item.createdAt) }}</span>
  411. <!-- 已购买状态 -->
  412. <div class="flex items-center gap-1">
  413. <svg
  414. class="w-3 h-3"
  415. fill="none"
  416. stroke="currentColor"
  417. viewBox="0 0 24 24"
  418. >
  419. <path
  420. stroke-linecap="round"
  421. stroke-linejoin="round"
  422. stroke-width="2"
  423. d="M5 13l4 4L19 7"
  424. />
  425. </svg>
  426. <span>已购买</span>
  427. </div>
  428. </div>
  429. </div>
  430. </article>
  431. </div>
  432. <!-- 加载更多按钮或到底提示 -->
  433. <div class="flex justify-center pt-6">
  434. <div v-if="hasMore">
  435. <button
  436. @click="loadMore"
  437. :disabled="isLoading"
  438. 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"
  439. >
  440. {{ isLoading ? "加载中..." : "加载更多" }}
  441. </button>
  442. </div>
  443. <div
  444. v-else-if="purchasedItems.length > 0"
  445. class="text-center text-white/40 text-sm"
  446. >
  447. <div class="flex items-center justify-center gap-2">
  448. <svg
  449. class="w-4 h-4"
  450. fill="none"
  451. stroke="currentColor"
  452. viewBox="0 0 24 24"
  453. >
  454. <path
  455. stroke-linecap="round"
  456. stroke-linejoin="round"
  457. stroke-width="2"
  458. d="M5 13l4 4L19 7"
  459. />
  460. </svg>
  461. <span>已经到底啦</span>
  462. </div>
  463. </div>
  464. </div>
  465. <!-- 空状态 -->
  466. <div
  467. v-if="!isLoading && purchasedItems.length === 0"
  468. class="text-center py-12"
  469. >
  470. <div class="text-white/40 text-sm">
  471. <svg
  472. class="w-16 h-16 mx-auto mb-4"
  473. fill="none"
  474. stroke="currentColor"
  475. viewBox="0 0 24 24"
  476. >
  477. <path
  478. stroke-linecap="round"
  479. stroke-linejoin="round"
  480. stroke-width="1.5"
  481. d="M15 13l-3 3m0 0l-3-3m3 3V8m0 13a9 9 0 110-18 9 9 0 010 18z"
  482. />
  483. </svg>
  484. <p>暂无购买记录</p>
  485. </div>
  486. </div>
  487. <!-- 域名提示弹窗 -->
  488. <DomainReminderDialog v-model="showDomainReminder" />
  489. </section>
  490. </template>
  491. <style scoped>
  492. .btn-subtle {
  493. @apply px-2.5 py-1 rounded-md bg-white/5 border border-white/10 text-white/80 hover:bg-white/10;
  494. }
  495. </style>