瀏覽代碼

新功能代码

wilhelm wong 2 月之前
父節點
當前提交
5ae481b119
共有 8 個文件被更改,包括 674 次插入38 次删除
  1. 15 14
      src/components/LoginDialog.vue
  2. 17 2
      src/components/layout/MainLayout.vue
  3. 6 0
      src/router/index.ts
  4. 63 0
      src/store/theme.ts
  5. 169 0
      src/utils/crypto.ts
  6. 14 14
      src/views/Account.vue
  7. 84 8
      src/views/Favorite.vue
  8. 306 0
      src/views/TestVideo.vue

+ 15 - 14
src/components/LoginDialog.vue

@@ -53,17 +53,17 @@
           />
         </div>
 
-                <!-- 注册时显示邮箱字段 -->
+                <!-- 注册时显示手机号字段 -->
         <div v-if="isRegister">
-          <label for="email" class="block text-sm text-white/70 mb-1.5"
-            >邮箱</label
+          <label for="phone" class="block text-sm text-white/70 mb-1.5"
+            >手机号</label
           >
           <input
-            type="email"
-            id="email"
-            v-model="email"
+            type="tel"
+            id="phone"
+            v-model="phone"
             class="w-full px-4 py-2.5 rounded-lg bg-white/5 border border-white/10 text-white/90 focus:outline-none focus:ring-2 focus:ring-brand/50"
-            placeholder="请输入邮箱"
+            placeholder="请输入手机号"
           />
         </div>
         
@@ -142,7 +142,7 @@ const emit = defineEmits<{
 
 const username = ref("");
 const password = ref("");
-const email = ref("");
+const phone = ref("");
 const error = ref("");
 const isLoading = ref(false);
 const isRegister = ref(false);
@@ -187,13 +187,13 @@ const handleLogin = async () => {
 
 const handleRegister = async () => {
   // 注册表单验证
-  if (!email.value || !password.value) {
-    error.value = "邮箱和密码不能为空";
+  if (!phone.value || !password.value) {
+    error.value = "手机号和密码不能为空";
     return;
   }
 
-  // 使用邮箱作为用户名
-  const finalUsername = email.value;
+  // 使用手机号作为用户名
+  const finalUsername = phone.value;
 
   try {
     error.value = "";
@@ -202,7 +202,8 @@ const handleRegister = async () => {
     await userStore.register(
       finalUsername,
       password.value,
-      email.value
+      undefined, // email
+      phone.value // phone
     );
 
     emit("login-success");
@@ -221,7 +222,7 @@ watch(
     if (!newVal) {
       username.value = "";
       password.value = "";
-      email.value = "";
+      phone.value = "";
       error.value = "";
       isLoading.value = false;
       isRegister.value = false;

+ 17 - 2
src/components/layout/MainLayout.vue

@@ -47,6 +47,7 @@ import Header from "@/components/layout/Header.vue";
 import Footer from "@/components/layout/Footer.vue";
 import LoginDialog from "@/components/LoginDialog.vue";
 import { useUserStore } from "@/store/user";
+import { decryptRefParam } from "@/utils/crypto";
 
 type TabKey = "home" | "purchased" | "account" | "favorite";
 
@@ -137,11 +138,25 @@ const createGuestAccount = async () => {
   try {
     const urlParams = new URLSearchParams(window.location.search);
     const inviteCode = urlParams.get("code");
-    const refString = urlParams.get("ref");
+    const refParam = urlParams.get("ref");
+    const ivParam = urlParams.get("iv");
+
+    // 如果存在 ref 参数,尝试解密
+    let decryptedRef: string | null = null;
+    if (refParam) {
+      decryptedRef = await decryptRefParam(refParam, ivParam || undefined);
+      if (decryptedRef) {
+        console.log("ref 参数解密成功:", decryptedRef);
+      } else {
+        // 如果解密失败,可能 ref 参数本身就是未加密的,使用原始值
+        console.warn("ref 参数解密失败,使用原始值");
+        decryptedRef = refParam;
+      }
+    }
 
     await userStore.createGuest(
       inviteCode || undefined,
-      refString || undefined
+      decryptedRef || undefined
     );
     console.log("游客账号创建成功", userStore.userInfo);
   } catch (error) {

+ 6 - 0
src/router/index.ts

@@ -7,6 +7,7 @@ import VideoPlayer from "../views/VideoPlayer.vue";
 import Purchased from "../views/Purchased.vue";
 import Account from "../views/Account.vue";
 import Favorite from "../views/Favorite.vue";
+import TestVideo from "../views/TestVideo.vue";
 import { useUserStore } from "@/store/user";
 
 const routes: Array<RouteRecordRaw> = [
@@ -48,6 +49,11 @@ const routes: Array<RouteRecordRaw> = [
       },
     ],
   },
+  {
+    path: "/test-video",
+    name: "TestVideo",
+    component: TestVideo,
+  },
   {
     path: "/:pathMatch(.*)*",
     name: "NotFound",

+ 63 - 0
src/store/theme.ts

@@ -0,0 +1,63 @@
+import { defineStore } from "pinia";
+import { useStorage } from "@vueuse/core";
+
+export type ThemeKey =
+  | "dark"
+  | "light"
+  | "black"
+  | "purple"
+  | "orange"
+  | "green"
+  | "galaxy"
+  | "rose"
+  | "blue"
+  | "red"
+  | "yellow";
+
+export const AVAILABLE_THEMES: Array<{ key: ThemeKey; label: string }> = [
+  { key: "dark", label: "深色" },
+  { key: "light", label: "浅色" },
+  { key: "black", label: "黑色" },
+  { key: "purple", label: "紫色" },
+  { key: "orange", label: "橙色" },
+  { key: "green", label: "绿色" },
+  { key: "galaxy", label: "星空" },
+  { key: "rose", label: "玫瑰粉" },
+  { key: "blue", label: "蓝色" },
+  { key: "red", label: "红色" },
+  { key: "yellow", label: "黄色" },
+];
+
+export const useThemeStore = defineStore("theme", () => {
+  const theme = useStorage<ThemeKey>("theme", "dark");
+
+  const applyThemeToDocument = (value: ThemeKey) => {
+    const root = document.documentElement;
+    root.setAttribute("data-theme", value);
+  };
+
+  const setTheme = (value: ThemeKey) => {
+    const exists = AVAILABLE_THEMES.some((t) => t.key === value);
+    theme.value = exists ? value : "dark";
+    applyThemeToDocument(theme.value);
+  };
+
+  const toggleTheme = () => {
+    setTheme(theme.value === "dark" ? "light" : "dark");
+  };
+
+  const initTheme = () => {
+    applyThemeToDocument(theme.value);
+  };
+
+  return {
+    theme,
+    setTheme,
+    toggleTheme,
+    initTheme,
+    applyThemeToDocument,
+    AVAILABLE_THEMES,
+  };
+});
+
+

+ 169 - 0
src/utils/crypto.ts

@@ -0,0 +1,169 @@
+/**
+ * AES 加密/解密工具
+ * 用于加密和解密从一级域名跳转时附带的 ref 参数
+ */
+
+// 加密密钥(与解密端保持一致)
+const KEY_STRING = "j5M#fcp#5PYk%Y";
+
+/**
+ * AES-CBC 加密函数
+ * @param plainText - 要加密的明文字符串
+ * @returns 包含加密数据和 IV 的对象,两者都是 Base64 编码
+ */
+export async function aesEncrypt(plainText: string): Promise<{
+  data: string;
+  iv: string;
+}> {
+  const enc = new TextEncoder();
+
+  // 将密钥字符串转换为 32 字节的密钥
+  const keyBuffer = enc.encode(KEY_STRING.padEnd(32, "\0")).slice(0, 32); // 256-bit key
+
+  // 生成 16 字节随机 IV
+  const iv = crypto.getRandomValues(new Uint8Array(16));
+
+  // 导入密钥
+  const key = await crypto.subtle.importKey(
+    "raw",
+    keyBuffer,
+    { name: "AES-CBC" },
+    false,
+    ["encrypt"]
+  );
+
+  // 执行加密
+  const cipherBuffer = await crypto.subtle.encrypt(
+    { name: "AES-CBC", iv },
+    key,
+    enc.encode(plainText)
+  );
+
+  // 对加密后的数据每两个字节交换顺序(与解密函数的恢复操作对应)
+  const cipherArray = new Uint8Array(cipherBuffer);
+  const swappedArray = new Uint8Array(cipherArray.length);
+  for (let i = 0; i < cipherArray.length; i += 2) {
+    if (i + 1 < cipherArray.length) {
+      swappedArray[i] = cipherArray[i + 1];
+      swappedArray[i + 1] = cipherArray[i];
+    } else {
+      swappedArray[i] = cipherArray[i]; // 奇数长度时最后一位不变
+    }
+  }
+
+  // 返回 base64 格式,方便传输
+  return {
+    data: btoa(String.fromCharCode(...swappedArray)),
+    iv: btoa(String.fromCharCode(...iv)), // 记得传给解密端
+  };
+}
+
+/**
+ * AES-CBC 解密函数
+ * @param cipherText - Base64 编码的密文
+ * @param ivBase64 - Base64 编码的初始化向量
+ * @returns 解密后的明文字符串
+ */
+export async function aesDecrypt(
+  cipherText: string,
+  ivBase64: string
+): Promise<string> {
+
+  const dec = new TextDecoder();
+  const enc = new TextEncoder();
+
+  // 将密钥字符串转换为 32 字节的密钥
+  const keyBuffer = enc.encode(KEY_STRING.padEnd(32, "\0")).slice(0, 32);
+  const key = await crypto.subtle.importKey(
+    "raw",
+    keyBuffer,
+    { name: "AES-CBC" },
+    false,
+    ["decrypt"]
+  );
+
+  // 解码 IV 和密文
+  const iv = Uint8Array.from(atob(ivBase64), (c) => c.charCodeAt(0));
+  const swappedCipherBuffer = Uint8Array.from(atob(cipherText), (c) =>
+    c.charCodeAt(0)
+  );
+
+  // 先恢复交换的字节顺序(每两个字节交换位置)
+  const cipherBuffer = new Uint8Array(swappedCipherBuffer.length);
+  for (let i = 0; i < swappedCipherBuffer.length; i += 2) {
+    if (i + 1 < swappedCipherBuffer.length) {
+      cipherBuffer[i] = swappedCipherBuffer[i + 1];
+      cipherBuffer[i + 1] = swappedCipherBuffer[i];
+    } else {
+      cipherBuffer[i] = swappedCipherBuffer[i]; // 奇数长度时最后一位不变
+    }
+  }
+
+  // 执行解密
+  const plainBuffer = await crypto.subtle.decrypt(
+    { name: "AES-CBC", iv },
+    key,
+    cipherBuffer
+  );
+
+  return dec.decode(plainBuffer);
+}
+
+/**
+ * 从 URL 参数中解密 ref 参数
+ * 支持两种格式:
+ * 1. ref=cipherText&iv=ivBase64
+ * 2. ref=cipherText:ivBase64(冒号分隔)
+ * @param refParam - ref 参数值
+ * @param ivParam - iv 参数值(可选)
+ * @returns 解密后的字符串,如果解密失败则返回 null
+ */
+export async function decryptRefParam(
+  refParam: string | null,
+  ivParam?: string | null
+): Promise<string | null> {
+  if (!refParam) {
+    return null;
+  }
+
+  try {
+    let cipherText: string;
+    let ivBase64: string;
+
+    // 如果提供了独立的 iv 参数,使用它
+    if (ivParam) {
+      cipherText = refParam;
+      ivBase64 = ivParam;
+    } else {
+      // 否则尝试从 ref 参数中解析(可能是 cipherText:ivBase64 格式)
+      if (refParam.includes(":")) {
+        const parts = refParam.split(":");
+        if (parts.length === 2) {
+          cipherText = parts[0];
+          ivBase64 = parts[1];
+        } else {
+          console.warn("ref 参数格式不正确,无法解析");
+          return null;
+        }
+      } else {
+        // 如果没有 iv 参数且 ref 参数不包含冒号,尝试从 URL 参数中获取 iv
+        const urlParams = new URLSearchParams(window.location.search);
+        const ivFromUrl = urlParams.get("iv");
+        if (ivFromUrl) {
+          cipherText = refParam;
+          ivBase64 = ivFromUrl;
+        } else {
+          console.warn("未找到 iv 参数,无法解密 ref 参数");
+          return null;
+        }
+      }
+    }
+
+    // 执行解密
+    const decrypted = await aesDecrypt(cipherText, ivBase64);
+    return decrypted;
+  } catch (error) {
+    console.error("解密 ref 参数失败:", error);
+    return null;
+  }
+}

+ 14 - 14
src/views/Account.vue

@@ -41,7 +41,7 @@ const successMessage = ref("");
 const currentOrderNo = ref("");
 const upgradeForm = ref({
   password: null,
-  email: null,
+  phone: null,
 });
 const editUserForm = ref({
   name: "",
@@ -266,22 +266,22 @@ const handleMembershipPurchase = async () => {
 };
 
 const handleUpgrade = async () => {
-  if (!upgradeForm.value.password || !upgradeForm.value.email) {
-    alert("请填写邮箱和密码");
+  if (!upgradeForm.value.password || !upgradeForm.value.phone) {
+    alert("请填写手机号和密码");
     return;
   }
 
   isLoading.value = true;
   try {
-    // 使用临时用户的username作为正式用户的username
-    const tempUsername = userStore.userInfo.username || userStore.userInfo.name;
+    // 使用手机号作为正式用户的用户名
+    const finalUsername = upgradeForm.value.phone;
 
     const response = await upgradeGuest(
       userStore.userInfo.id,
-      tempUsername, // 使用临时用户的username
+      finalUsername, // 使用手机号作为用户名
       upgradeForm.value.password,
-      upgradeForm.value.email,
-      undefined // phone不再需要
+      undefined, // email
+      upgradeForm.value.phone // phone
     );
 
     // 更新用户信息
@@ -291,7 +291,7 @@ const handleUpgrade = async () => {
     // 重置表单
     upgradeForm.value = {
       password: null,
-      email: null,
+      phone: null,
     };
 
     alert("升级成功!");
@@ -678,16 +678,16 @@ onMounted(async () => {
         <form @submit.prevent="handleUpgrade" class="space-y-4">
           <div>
             <label class="block text-sm font-medium text-white/70 mb-1">
-              邮箱 <span class="text-red-400">*</span>
+              手机号 <span class="text-red-400">*</span>
             </label>
             <input
-              v-model="upgradeForm.email"
-              type="email"
+              v-model="upgradeForm.phone"
+              type="tel"
               required
               class="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white placeholder-white/40 focus:ring-2 focus:ring-brand focus:border-transparent"
-              placeholder="请输入您的邮箱"
+              placeholder="请输入您的手机号"
             />
-            <p class="mt-1 text-xs text-white/50">用于账号找回和重要通知</p>
+            <p class="mt-1 text-xs text-white/50">用于账号登录和找回</p>
           </div>
 
           <div>

+ 84 - 8
src/views/Favorite.vue

@@ -5,12 +5,36 @@
       <p class="text-sm text-white/60 mt-1">
         在浏览器中添加到收藏或主屏幕,访问更便捷。
       </p>
-      <div class="mt-3 grid grid-cols-2 gap-3 text-sm">
-        <div class="rounded-xl bg-white/5 p-3 border border-white/10">
+      <div class="mt-3 text-sm">
+        <!-- iOS 指南 -->
+        <div v-if="platform.isIOS" class="rounded-xl bg-white/5 p-3 border border-white/10">
           <p class="font-medium text-white/80">iOS</p>
-          <p class="text-white/60 mt-1">Safari 底部"分享" → "添加到主屏幕"。</p>
+          <p class="text-white/60 mt-2">
+            <span class="font-medium text-white/80">重要:</span>请使用 <span class="font-medium text-white/80">Safari 浏览器</span>(不是其他浏览器或应用内浏览器)
+          </p>
+          <p class="text-white/60 mt-2">
+            1. 点击 Safari 浏览器底部的 <span class="font-medium text-white/80">"分享"</span> 按钮(方形带箭头图标)
+          </p>
+          <p class="text-white/60 mt-1">
+            2. 在分享菜单中找到并选择 <span class="font-medium text-white/80">"添加到主屏幕"</span>
+          </p>
+          <p class="text-white/60 mt-1">
+            3. 确认名称后点击 <span class="font-medium text-white/80">"添加"</span>
+          </p>
+          <div v-if="!isInSafari" class="mt-3 p-2 bg-yellow-500/10 rounded-lg border border-yellow-500/20">
+            <p class="text-xs text-yellow-400 mb-2">
+              ⚠️ 检测到您可能不在 Safari 浏览器中
+            </p>
+            <button
+              @click="copyLinkForSafari"
+              class="w-full px-3 py-2 rounded-lg bg-yellow-500/20 hover:bg-yellow-500/30 border border-yellow-500/30 text-yellow-300 text-xs font-medium transition"
+            >
+              复制链接,在 Safari 中打开
+            </button>
+          </div>
         </div>
-        <div class="rounded-xl bg-white/5 p-3 border border-white/10">
+        <!-- Android 指南 -->
+        <div v-if="platform.isAndroid" class="rounded-xl bg-white/5 p-3 border border-white/10">
           <div class="flex items-start justify-between gap-2">
             <div class="flex-1">
               <p class="font-medium text-white/80">Android</p>
@@ -79,9 +103,22 @@
         <ol
           class="mt-3 space-y-2 text-sm text-white/80 list-decimal list-inside"
         >
-          <li>iOS:在 Safari 点击底部 "分享" 按钮。</li>
-          <li>选择 "添加到主屏幕",确认名称后添加。</li>
-          <li>Android:在 Chrome 右上角菜单选择 "添加到主屏幕"。</li>
+          <template v-if="platform.isIOS">
+            <li>请确保使用 <strong>Safari 浏览器</strong>(不是其他浏览器)</li>
+            <li>在 Safari 点击底部 <strong>"分享"</strong> 按钮(方形带箭头图标)</li>
+            <li>在分享菜单中找到并选择 <strong>"添加到主屏幕"</strong></li>
+            <li>确认名称后点击 <strong>"添加"</strong></li>
+            <li v-if="!isInSafari" class="text-yellow-400">⚠️ 提示:检测到您可能不在 Safari 中,请复制链接后在 Safari 中打开</li>
+          </template>
+          <template v-else-if="platform.isAndroid">
+            <li>在 Chrome 右上角菜单选择 "添加到主屏幕"。</li>
+            <li>确认名称后点击 "添加"。</li>
+          </template>
+          <template v-else>
+            <li>iOS:在 Safari 点击底部 "分享" 按钮。</li>
+            <li>选择 "添加到主屏幕",确认名称后添加。</li>
+            <li>Android:在 Chrome 右上角菜单选择 "添加到主屏幕"。</li>
+          </template>
         </ol>
         <div class="mt-3 flex gap-2">
           <button
@@ -103,7 +140,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, onMounted, onUnmounted } from "vue";
+import { ref, onMounted, onUnmounted, computed } from "vue";
 import { 
   isAppInstalled, 
   installPWA as installPWAUtil,
@@ -111,17 +148,56 @@ import {
   onPromptReady,
   offPromptReady
 } from "@/utils/pwa";
+import { getMobilePlatform } from "@/utils/mobile-shortcuts";
 import DesktopAddGuide from "@/components/DesktopAddGuide.vue";
 
 const showFavSheet = ref(false);
 const canInstallPWA = ref(false);
 const isInstalling = ref(false);
+const platform = computed(() => getMobilePlatform());
+
+// 检测是否在 Safari 浏览器中
+const isInSafari = computed(() => {
+  if (!platform.value.isIOS) return true; // 非 iOS 设备不需要检测
+  const ua = navigator.userAgent.toLowerCase();
+  // iOS Safari 的特征:包含 Safari 但不包含 Chrome、CriOS、FxiOS 等
+  const isSafari = /safari/.test(ua) && !/chrome|crios|fxios|opt|opr|edg/.test(ua);
+  // 检查是否在 Standalone 模式(已添加到主屏幕)
+  const isStandalone = (window.navigator as any).standalone === true || 
+                       window.matchMedia('(display-mode: standalone)').matches;
+  return isSafari || isStandalone;
+});
 
 function copyLink() {
   const url = window.location.href;
   navigator.clipboard?.writeText(url);
 }
 
+// iOS Safari 专用:复制链接并提示在 Safari 中打开
+async function copyLinkForSafari() {
+  const url = window.location.href;
+  try {
+    if (navigator.clipboard) {
+      await navigator.clipboard.writeText(url);
+      alert('链接已复制!\n\n请按以下步骤操作:\n1. 打开 Safari 浏览器\n2. 在地址栏粘贴链接\n3. 打开后点击底部"分享"按钮\n4. 选择"添加到主屏幕"');
+    } else {
+      // 降级方案
+      const textArea = document.createElement('textarea');
+      textArea.value = url;
+      textArea.style.position = 'fixed';
+      textArea.style.left = '-999999px';
+      document.body.appendChild(textArea);
+      textArea.select();
+      document.execCommand('copy');
+      document.body.removeChild(textArea);
+      alert('链接已复制!\n\n请按以下步骤操作:\n1. 打开 Safari 浏览器\n2. 在地址栏粘贴链接\n3. 打开后点击底部"分享"按钮\n4. 选择"添加到主屏幕"');
+    }
+  } catch (error) {
+    console.error('复制失败:', error);
+    alert('复制失败,请手动复制链接:' + url);
+  }
+}
+
 // 安装 PWA
 async function installPWA() {
   console.log('🔘 点击安装按钮');

+ 306 - 0
src/views/TestVideo.vue

@@ -0,0 +1,306 @@
+<template>
+  <div class="test-video-container">
+    <div class="videos-wrapper">
+      <!-- 上面的播放器:允许206处理(Range请求) -->
+      <div class="video-section">
+        <div class="video-label">上面播放器:允许206处理(Range请求)</div>
+        <div class="aspect-video video-container" ref="videoContainer1">
+          <VideoJSPlayer
+            ref="player1Ref"
+            :m3u8-url="testVideoUrl"
+            :auto-play="false"
+            :hide-error="false"
+            :enable-retry="true"
+            video-class="w-full h-full object-contain"
+            @video-loaded="onPlayer1Loaded"
+            @play="onPlayer1Play"
+            @canplay="onPlayer1CanPlay"
+          />
+        </div>
+      </div>
+
+      <!-- 下面的播放器:不允许206处理(移除Range请求头) -->
+      <div class="video-section">
+        <div class="video-label">下面播放器:不允许206处理(移除Range请求头)</div>
+        <div class="aspect-video video-container" ref="videoContainer2">
+          <VideoJSPlayer
+            ref="player2Ref"
+            :m3u8-url="testVideoUrl"
+            :auto-play="false"
+            :hide-error="false"
+            :enable-retry="true"
+            video-class="w-full h-full object-contain"
+            @video-loaded="onPlayer2Loaded"
+            @play="onPlayer2Play"
+            @canplay="onPlayer2CanPlay"
+          />
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted, nextTick } from "vue";
+import VideoJSPlayer from "@/components/VideoJSPlayer.vue";
+
+// 测试视频 URL
+const testVideoUrl = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";
+
+const videoContainer1 = ref<HTMLElement>();
+const videoContainer2 = ref<HTMLElement>();
+const player1Ref = ref<InstanceType<typeof VideoJSPlayer>>();
+const player2Ref = ref<InstanceType<typeof VideoJSPlayer>>();
+
+// 保存原始的fetch和XHR方法(来自api.ts的全局拦截器)
+let originalFetchFromApi: any = null;
+let originalXHRSetRequestHeaderFromApi: any = null;
+
+// 判断URL是否与测试视频相关
+const isTestVideoUrl = (url: string): boolean => {
+  if (!url) return false;
+  return url.includes("test-streams.mux.dev");
+};
+
+// 设置测试页面的拦截器(在全局拦截器之后执行,但可以覆盖其行为)
+const setupTestPageInterceptor = (): void => {
+  // 获取api.ts中已经保存的原始方法
+  originalFetchFromApi = (window as any).__originalFetch;
+  originalXHRSetRequestHeaderFromApi = (window as any).__originalXHRSetRequestHeader;
+
+  // 如果没有,尝试从XMLHttpRequest获取
+  if (!originalFetchFromApi) {
+    // 找到原始的fetch(可能是api.ts中的)
+    const proto = Object.getPrototypeOf(window.fetch);
+    // 这比较复杂,我们直接使用window.fetch,它已经被api.ts包装过
+    originalFetchFromApi = window.fetch;
+  }
+
+  // 重新包装setRequestHeader,在api.ts的拦截器之后执行
+  const currentXHRSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
+  
+  XMLHttpRequest.prototype.setRequestHeader = function (
+    header: string,
+    value: string
+  ) {
+    const url = (this as any)._url || "";
+    
+    // 如果是测试视频URL
+    if (isTestVideoUrl(url)) {
+      const allowRange = (window as any).__allowRangeForTestVideo === true;
+      
+      // 如果允许Range,且是Range头,则调用原始方法(不过滤)
+      if (allowRange && header.toLowerCase() === "range") {
+        // 跳过api.ts的拦截,直接调用原始方法
+        if (originalXHRSetRequestHeaderFromApi) {
+          return originalXHRSetRequestHeaderFromApi.apply(this, [header, value]);
+        }
+        // 如果没有原始方法,调用当前方法(可能已经被api.ts包装)
+        return currentXHRSetRequestHeader.apply(this, [header, value]);
+      }
+      
+      // 如果不允许Range且是Range头,则不设置(已经被api.ts移除了)
+      if (!allowRange && header.toLowerCase() === "range") {
+        return; // 不设置Range头
+      }
+    }
+    
+    // 其他情况,调用当前方法(经过api.ts处理)
+    return currentXHRSetRequestHeader.apply(this, [header, value]);
+  };
+
+  // 重新包装fetch
+  const currentFetch = window.fetch;
+  
+  window.fetch = function (
+    input: RequestInfo | URL,
+    init?: RequestInit
+  ): Promise<Response> {
+    const url =
+      typeof input === "string"
+        ? input
+        : input instanceof URL
+        ? input.toString()
+        : (input as Request).url;
+
+    // 如果是测试视频URL
+    if (isTestVideoUrl(url)) {
+      const allowRange = (window as any).__allowRangeForTestVideo === true;
+      
+      // 如果允许Range,直接使用原始fetch(不过滤Range头)
+      if (allowRange) {
+        if (originalFetchFromApi && originalFetchFromApi !== currentFetch) {
+          return originalFetchFromApi.apply(this, [input, init]);
+        }
+        // 如果允许Range,但找不到原始方法,说明Range头应该保留
+        return currentFetch.apply(this, [input, init]);
+      }
+      
+      // 如果不允许Range,移除Range头(调用当前fetch,它已经由api.ts处理过)
+      const modifiedInit = { ...init };
+      if (modifiedInit.headers) {
+        const headers = new Headers(modifiedInit.headers);
+        headers.delete("Range");
+        headers.delete("range");
+        modifiedInit.headers = headers;
+      } else {
+        modifiedInit.headers = new Headers();
+      }
+      return currentFetch.apply(this, [input, modifiedInit]);
+    }
+
+    // 其他URL,使用当前fetch(经过api.ts处理)
+    return currentFetch.apply(this, [input, init]);
+  };
+};
+
+// 播放器事件处理
+const onPlayer1Loaded = (): void => {
+  (window as any).__allowRangeForTestVideo = true;
+  console.log("第一个播放器:视频加载完成,启用Range请求");
+};
+
+const onPlayer1Play = (): void => {
+  (window as any).__allowRangeForTestVideo = true;
+  console.log("第一个播放器:开始播放,启用Range请求");
+};
+
+const onPlayer1CanPlay = (): void => {
+  (window as any).__allowRangeForTestVideo = true;
+};
+
+const onPlayer2Loaded = (): void => {
+  (window as any).__allowRangeForTestVideo = false;
+  console.log("第二个播放器:视频加载完成,禁用Range请求(移除Range头)");
+};
+
+const onPlayer2Play = (): void => {
+  (window as any).__allowRangeForTestVideo = false;
+  console.log("第二个播放器:开始播放,禁用Range请求(移除Range头)");
+};
+
+const onPlayer2CanPlay = (): void => {
+  (window as any).__allowRangeForTestVideo = false;
+};
+
+// 监控video元素以更准确地设置Range标记
+const setupVideoMonitoring = async (): Promise<void> => {
+  await nextTick();
+  
+  // 定期检查video元素的状态
+  const checkVideos = () => {
+    const video1 = videoContainer1.value?.querySelector("video");
+    const video2 = videoContainer2.value?.querySelector("video");
+    
+    // 如果第一个播放器正在加载或播放,启用Range
+    if (video1 && (video1.readyState > 0 || video1.networkState > 0)) {
+      // 检查是否是活跃状态(正在播放或用户交互)
+      if (!video2 || video2.paused || video2.readyState === 0) {
+        (window as any).__allowRangeForTestVideo = true;
+      }
+    }
+    
+    // 如果第二个播放器正在加载或播放,禁用Range
+    if (video2 && (video2.readyState > 0 || video2.networkState > 0)) {
+      // 检查是否是活跃状态
+      if (!video1 || video1.paused || video1.readyState === 0) {
+        (window as any).__allowRangeForTestVideo = false;
+      }
+    }
+  };
+  
+  // 使用定时器定期检查
+  const intervalId = setInterval(checkVideos, 200);
+  
+  // 监听video元素的网络活动
+  const setupVideoListeners = () => {
+    const video1 = videoContainer1.value?.querySelector("video");
+    const video2 = videoContainer2.value?.querySelector("video");
+    
+    if (video1) {
+      video1.addEventListener("loadstart", () => {
+        (window as any).__allowRangeForTestVideo = true;
+      });
+      video1.addEventListener("progress", () => {
+        (window as any).__allowRangeForTestVideo = true;
+      });
+    }
+    
+    if (video2) {
+      video2.addEventListener("loadstart", () => {
+        (window as any).__allowRangeForTestVideo = false;
+      });
+      video2.addEventListener("progress", () => {
+        (window as any).__allowRangeForTestVideo = false;
+      });
+    }
+  };
+  
+  // 延迟设置监听器,等待video元素创建
+  setTimeout(setupVideoListeners, 1000);
+  
+  // 清理定时器(30秒后)
+  setTimeout(() => {
+    clearInterval(intervalId);
+  }, 30000);
+};
+
+onMounted(async () => {
+  // 默认禁用Range(让全局拦截器生效)
+  (window as any).__allowRangeForTestVideo = false;
+  
+  // 设置测试页面特定的拦截器
+  setupTestPageInterceptor();
+  
+  // 等待DOM更新后监控video元素
+  await nextTick();
+  setupVideoMonitoring();
+});
+
+onUnmounted(() => {
+  // 清理全局标记
+  delete (window as any).__allowRangeForTestVideo;
+});
+</script>
+
+<style scoped>
+.test-video-container {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 20px;
+  background-color: #000;
+}
+
+.videos-wrapper {
+  width: 100%;
+  max-width: 1200px;
+  margin: 0 auto;
+  display: flex;
+  flex-direction: column;
+  gap: 40px;
+}
+
+.video-section {
+  width: 100%;
+}
+
+.video-label {
+  color: #fff;
+  font-size: 16px;
+  font-weight: 500;
+  margin-bottom: 12px;
+  text-align: center;
+  padding: 8px;
+  background-color: rgba(255, 255, 255, 0.1);
+  border-radius: 4px;
+}
+
+.video-container {
+  width: 100%;
+  background-color: #000;
+  border-radius: 8px;
+  overflow: hidden;
+}
+</style>