feat: 首次提交NBA前端项目

This commit is contained in:
2026-05-14 22:16:25 +08:00
parent 5f48a8a55e
commit 3533904ced
26 changed files with 8668 additions and 434 deletions

33
SEO_NOTES.md Normal file
View File

@@ -0,0 +1,33 @@
SEO 改进说明
已完成的改动:
-`index.html` 中添加了更完整的 metarobots、canonical 占位、Open Graph、Twitter Card、JSON-LD
-`src/router/index.js` 中为路由添加了 `meta.title``meta.description`,并添加了 `afterEach` 钩子,用于在单页应用导航后更新 `document.title``meta description``canonical`
-`public/` 下添加了 `robots.txt` 与静态 `sitemap.xml`(请替换其中的 `REPLACE_WITH_YOUR_SITE_URL` 为真实域名)。
1. 已替换占位符
- 我已将所有 `REPLACE_WITH_YOUR_SITE_URL` 替换为你提供的域名 `http://nba.1024x.icu/`(包括 `index.html``public/robots.txt``public/sitemap.xml`)。请确认你已在该域名下部署网站,并把 `og-image.png` 上传至站点根目录(或修改 meta 指向的图片地址)。
2. 自动化 sitemap推荐
2. 自动化 sitemap推荐
- 如果你有大量带参数的播放页面(/play/:gameId建议在构建或后台生成 sitemap 时把具体页面 URL 列入 sitemap例如在 CI 中运行脚本生成 sitemap.xml
3. SSR / 预渲染
- 单页应用SPA依赖客户端渲染部分搜索引擎和社交分享抓取可能受限。建议采用
- 服务端渲染Nuxt / Vite SSR
- 构建时预渲染prerender-spa-plugin / vite-plugin-prerender来生成静态 HTML提升首屏可抓取性。
4. 提交站点地图与验证站点
- 在 Google Search Console、Bing Webmaster 提交 sitemap/sitemap.xml并验证站点所有权。
5. 其它优化点(可选)
- 添加 hreflang多语言站点
- 为重要页面添加结构化的 Article/Event/Video SchemaJSON-LD
- 提高页面加载性能Lighthouse 得分)以提升搜索排名。
如何回滚或微调
- 如果想回退 `index.html` 或路由的修改请使用版本控制git回退这些文件。修改完成后重新构建并部署。
如果你愿意,我可以:
- 替你把 `REPLACE_WITH_YOUR_SITE_URL` 批量替换为实际域名(请提供域名);
- 添加一个构建时脚本生成 sitemap把你希望列出的动态页面列表发给我

View File

@@ -1,11 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang=""> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 推荐加入 robots 指令,控制搜索引擎抓取 -->
<meta name="robots" content="index,follow" />
<!-- 已替换为站点域名 -->
<link rel="canonical" href="http://nba.1024x.icu/" />
<style> <style>
html, body { html,
body {
margin: 0; margin: 0;
padding: 0; padding: 0;
/* height: 100%; */ /* height: 100%; */
@@ -16,11 +21,69 @@
height: 100%; height: 100%;
} }
</style> </style>
<title>NBA在线</title> <title>NBA在线观看NBA免费直播</title>
<meta
name="description"
content="NBA在线观看NBA免费直播jrskan免费在线NBA中国NBA纬来体育CCTV5赛事直播"
/>
<meta
name="keywords"
content="NBA在线观看NBA免费直播jrskan免费在线NBA中国NBA纬来体育CCTV5赛事直播"
/>
<meta name="author" content="Ping" />
<!-- Open Graph / 社交分享 -->
<meta property="og:site_name" content="NBA 在线观看" />
<meta property="og:type" content="website" />
<meta property="og:title" content="NBA在线观看NBA免费直播" />
<meta
property="og:description"
content="直播NBA赛事在线观看高清赛事直播及赛程信息。"
/>
<meta property="og:url" content="http://jrs77.xyz/" />
<meta property="og:image" content="http://jrs77.xyz/og-image.png" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="NBA在线观看NBA免费直播" />
<meta
name="twitter:description"
content="直播NBA赛事在线观看高清赛事直播及赛程信息。"
/>
<meta name="twitter:image" content="http://jrs77.xyz/og-image.png" />
<!-- 结构化数据JSON-LD: 简单的网站信息,便于搜索引擎理解 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "NBA 在线观看",
"url": "http://jrs77.xyz/",
"potentialAction": {
"@type": "SearchAction",
"target": "http://jrs77.xyz/search?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>
<script type="text/javascript" src="https://js.users.51.la/21957239.js"></script> <script
charset="UTF-8"
id="LA_COLLECT"
src="//sdk.51.la/js-sdk-pro.min.js"
></script>
<script>
LA.init({ id: "KRoZweOTY55GYa3d", ck: "KRoZweOTY55GYa3d" });
</script>
<script>
var _hmt = _hmt || [];
(function () {
var hm = document.createElement("script");
hm.src = "https://hm.baidu.com/hm.js?5292bb429c34a0b0a70a86e8b6a9711f";
var s = document.getElementsByTagName("script")[0];
s.parentNode.insertBefore(hm, s);
})();
</script>
</body> </body>
</html> </html>

4289
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,13 @@
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.1", "@element-plus/icons-vue": "^2.3.1",
"artplayer": "^5.3.0",
"axios": "^1.8.4", "axios": "^1.8.4",
"dplayer": "^1.27.1", "dplayer": "^1.27.1",
"element-plus": "^2.9.7", "element-plus": "^2.9.7",
"flv.js": "^1.6.2", "flv.js": "^1.6.2",
"hls.js": "^1.6.2", "hls.js": "^1.6.2",
"md5": "^2.3.0",
"pinia": "^3.0.2", "pinia": "^3.0.2",
"router": "^2.2.0", "router": "^2.2.0",
"video.js": "^8.22.0", "video.js": "^8.22.0",

61
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@element-plus/icons-vue': '@element-plus/icons-vue':
specifier: ^2.3.1 specifier: ^2.3.1
version: 2.3.1(vue@3.5.13) version: 2.3.1(vue@3.5.13)
artplayer:
specifier: ^5.3.0
version: 5.3.0
axios: axios:
specifier: ^1.8.4 specifier: ^1.8.4
version: 1.8.4 version: 1.8.4
@@ -26,6 +29,9 @@ importers:
hls.js: hls.js:
specifier: ^1.6.2 specifier: ^1.6.2
version: 1.6.2 version: 1.6.2
md5:
specifier: ^2.3.0
version: 2.3.0
pinia: pinia:
specifier: ^3.0.2 specifier: ^3.0.2
version: 3.0.2(vue@3.5.13) version: 3.0.2(vue@3.5.13)
@@ -439,67 +445,56 @@ packages:
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==} resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.40.0': '@rollup/rollup-linux-arm-musleabihf@4.40.0':
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==} resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.40.0': '@rollup/rollup-linux-arm64-gnu@4.40.0':
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==} resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.40.0': '@rollup/rollup-linux-arm64-musl@4.40.0':
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==} resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loongarch64-gnu@4.40.0': '@rollup/rollup-linux-loongarch64-gnu@4.40.0':
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==} resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0': '@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==} resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-gnu@4.40.0': '@rollup/rollup-linux-riscv64-gnu@4.40.0':
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==} resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.40.0': '@rollup/rollup-linux-riscv64-musl@4.40.0':
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==} resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.40.0': '@rollup/rollup-linux-s390x-gnu@4.40.0':
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==} resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.40.0': '@rollup/rollup-linux-x64-gnu@4.40.0':
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==} resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.40.0': '@rollup/rollup-linux-x64-musl@4.40.0':
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==} resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-win32-arm64-msvc@4.40.0': '@rollup/rollup-win32-arm64-msvc@4.40.0':
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==} resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
@@ -636,6 +631,9 @@ packages:
aes-decrypter@4.0.2: aes-decrypter@4.0.2:
resolution: {integrity: sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==} resolution: {integrity: sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==}
artplayer@5.3.0:
resolution: {integrity: sha512-yExO39MpEg4P+bxgChxx1eJfiUPE4q1QQRLCmqGhlsj+ANuaoEkR8hF93LdI5ZyrAcIbJkuEndxEiUoKobifDw==}
async-validator@4.2.5: async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
@@ -673,6 +671,9 @@ packages:
caniuse-lite@1.0.30001714: caniuse-lite@1.0.30001714:
resolution: {integrity: sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg==} resolution: {integrity: sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg==}
charenc@0.0.2:
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
colorjs.io@0.5.2: colorjs.io@0.5.2:
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
@@ -691,6 +692,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
crypt@0.0.2:
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
csstype@3.1.3: csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -888,6 +892,9 @@ packages:
immutable@5.1.1: immutable@5.1.1:
resolution: {integrity: sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==} resolution: {integrity: sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==}
is-buffer@1.1.6:
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
is-docker@3.0.0: is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -943,6 +950,10 @@ packages:
jsonfile@6.1.0: jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
kind-of@6.0.3:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
kolorist@1.8.0: kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
@@ -972,6 +983,9 @@ packages:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
md5@2.3.0:
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
memoize-one@6.0.0: memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
@@ -1029,6 +1043,9 @@ packages:
resolution: {integrity: sha512-zy1wx4+P3PfhXSEPJNtZmJXfhkkIaxU1VauWIrDZw1O7uJRDRJtKr9n3Ic4NgbA16KyOxOXO2ng9gYwCdXuSXA==} resolution: {integrity: sha512-zy1wx4+P3PfhXSEPJNtZmJXfhkkIaxU1VauWIrDZw1O7uJRDRJtKr9n3Ic4NgbA16KyOxOXO2ng9gYwCdXuSXA==}
engines: {node: '>=18'} engines: {node: '>=18'}
option-validator@2.0.6:
resolution: {integrity: sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==}
parse-ms@4.0.0: parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -2005,6 +2022,10 @@ snapshots:
global: 4.4.0 global: 4.4.0
pkcs7: 1.0.4 pkcs7: 1.0.4
artplayer@5.3.0:
dependencies:
option-validator: 2.0.6
async-validator@4.2.5: {} async-validator@4.2.5: {}
asynckit@0.4.0: {} asynckit@0.4.0: {}
@@ -2049,6 +2070,8 @@ snapshots:
caniuse-lite@1.0.30001714: {} caniuse-lite@1.0.30001714: {}
charenc@0.0.2: {}
colorjs.io@0.5.2: {} colorjs.io@0.5.2: {}
combined-stream@1.0.8: combined-stream@1.0.8:
@@ -2067,6 +2090,8 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
crypt@0.0.2: {}
csstype@3.1.3: {} csstype@3.1.3: {}
dayjs@1.11.13: {} dayjs@1.11.13: {}
@@ -2286,6 +2311,8 @@ snapshots:
immutable@5.1.1: {} immutable@5.1.1: {}
is-buffer@1.1.6: {}
is-docker@3.0.0: {} is-docker@3.0.0: {}
is-function@1.0.2: {} is-function@1.0.2: {}
@@ -2322,6 +2349,8 @@ snapshots:
optionalDependencies: optionalDependencies:
graceful-fs: 4.2.11 graceful-fs: 4.2.11
kind-of@6.0.3: {}
kolorist@1.8.0: {} kolorist@1.8.0: {}
lodash-es@4.17.21: {} lodash-es@4.17.21: {}
@@ -2350,6 +2379,12 @@ snapshots:
math-intrinsics@1.1.0: {} math-intrinsics@1.1.0: {}
md5@2.3.0:
dependencies:
charenc: 0.0.2
crypt: 0.0.2
is-buffer: 1.1.6
memoize-one@6.0.0: {} memoize-one@6.0.0: {}
mime-db@1.52.0: {} mime-db@1.52.0: {}
@@ -2400,6 +2435,10 @@ snapshots:
is-inside-container: 1.0.0 is-inside-container: 1.0.0
is-wsl: 3.1.0 is-wsl: 3.1.0
option-validator@2.0.6:
dependencies:
kind-of: 6.0.3
parse-ms@4.0.0: {} parse-ms@4.0.0: {}
parseurl@1.3.3: {} parseurl@1.3.3: {}

BIN
public/imgs/spa.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

7
public/robots.txt Normal file
View File

@@ -0,0 +1,7 @@
# Robots.txt - allow all crawlers to index the site
# 使用实际域名
User-agent: *
Allow: /
Sitemap: http://nba.1024x.icu/sitemap.xml
Host: http://nba.1024x.icu

20
public/sitemap.xml Normal file
View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<!-- 已替换为实际域名 -->
<url>
<loc>http://nba.1024x.icu/</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>http://nba.1024x.icu/lives</loc>
<changefreq>daily</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>http://nba.1024x.icu/test</loc>
<changefreq>weekly</changefreq>
<priority>0.5</priority>
</url>
<!-- 如需列出带参数的播放页面,请在生成 sitemap 时列出具体的 play 页面网址 -->
</urlset>

View File

@@ -1,45 +1,69 @@
import request from "./request"; import request from "./request";
import axios from 'axios' import axios from "axios";
import md5 from "md5";
const SECRET_KEY = "20251125";
const nbaapi = axios.create({ const nbaapi = axios.create({
baseURL: 'http://api.new9.me/api', baseURL: "/api",
// baseURL: 'http://110.42.255.182:8080', timeout: 5000,
timeout: 2000, withCredentials: true,
}) });
// const urls = async () => {
// const timestamp = Date.now();
// const sign = md5(`${timestamp}${SECRET_KEY}`);
const urls = async () => { // return await nbaapi({
// url: '/urls',
// method: 'get',
// headers: {
// 'X-Timestamp': timestamp,
// 'X-Sign': sign,
// },
// });
// };
const urls = async (includeM3u8) => {
const timestamp = Date.now();
const sign = md5(`${timestamp}${SECRET_KEY}`);
return await nbaapi({ return await nbaapi({
url: '/urls', url: "/urls",
method: 'get', method: "get",
headers: {
"X-Timestamp": timestamp,
"X-Sign": sign,
},
params: {
includeM3u8 : includeM3u8
},
}) })
.then((response) => { .then((response) => {
return response.data; return response.data;
}) })
.catch((error) => { .catch((error) => {
console.error('获取直播URL失败:', error); console.error("获取直播URL失败:", error);
throw error; throw error;
}); });
}; };
const games = async () => { const games = async () => {
return await nbaapi({ return await nbaapi({
url: '/games', url: "/games",
method: 'get', method: "get",
}) })
.then((response) => { .then((response) => {
// console.log(response.data); // 调试用 // console.log(response.data); // 调试用
return response.data; // 确保返回数据 return response.data; // 确保返回数据
}) })
.catch((error) => { .catch((error) => {
console.error('获取赛事数据失败:', error); console.error("获取赛事数据失败:", error);
throw error; // 或者返回空数组 return [] throw error; // 或者返回空数组 return []
}); });
}; };
const go = async (pwd) => { const go = async (pwd) => {
return await nbaapi({ return await nbaapi({
url: '/go', url: "/go",
method: 'get', method: "get",
params: { params: {
// 这里可以添加请求参数 // 这里可以添加请求参数
pwd: pwd, pwd: pwd,
@@ -50,33 +74,38 @@ const go = async (pwd) => {
return response.data; // 确保返回数据 return response.data; // 确保返回数据
}) })
.catch((error) => { .catch((error) => {
console.error('获取赛事数据失败:', error); console.error("获取赛事数据失败:", error);
throw error; // 或者返回空数组 return [] throw error; // 或者返回空数组 return []
}); });
}; };
const schedule = (params) => { const schedule = (params) => {
return request({ return request({
url: '/game/schedule', url: "/game/schedule",
method: 'get', method: "get",
params: params, params: params,
}); });
}; };
const addUrls = async (gameId, urls) => { const addUrls = async (gameId, urls) => {
const payloadUrls = urls.map((item) => ({
...item,
m3u8_url: item.m3u8_url || item.url,
}));
return await nbaapi({ return await nbaapi({
url: '/addUrls', url: "/addUrls",
method: 'post', method: "post",
data: { data: {
gameId: gameId, gameId: gameId,
urls: urls urls: payloadUrls,
} },
}) })
.then((response) => { .then((response) => {
return response.data; return response.data;
}) })
.catch((error) => { .catch((error) => {
console.error('添加直播URL失败:', error); console.error("添加直播URL失败:", error);
throw error; throw error;
}); });
}; };
@@ -85,16 +114,60 @@ const addUrls = async (gameId, urls) => {
const deleteUrlById = async (id) => { const deleteUrlById = async (id) => {
return await nbaapi({ return await nbaapi({
url: `/delete/${id}`, url: `/delete/${id}`,
method: 'get', method: "get",
}) })
.then((response) => { .then((response) => {
return response.data; return response.data;
}) })
.catch((error) => { .catch((error) => {
console.error('删除直播URL失败:', error); console.error("删除直播URL失败:", error);
throw error; throw error;
}); });
}; };
// 修改直播链接
const updateUrlById = async ({ id, url, gameId, type }) => {
return await nbaapi({
url: "/update",
method: "post",
data: {
id,
url,
gameId,
type,
},
})
.then((response) => {
return response.data;
})
.catch((error) => {
console.error("修改直播URL失败:", error);
throw error;
});
};
export { schedule, games, urls, go,addUrls,deleteUrlById }; // 按需获取单个直播源的播放地址
const fetchLiveUrl = async (gameId, type) => {
const timestamp = Date.now();
const sign = md5(`${timestamp}${SECRET_KEY}`);
return await nbaapi({
url: "/live/url",
method: "get",
headers: {
"X-Timestamp": timestamp,
"X-Sign": sign,
},
params: {
gameId,
type,
},
})
.then((response) => response.data)
.catch((error) => {
console.error("获取直播源URL失败:", error);
throw error;
});
};
export { schedule, games, urls, go, addUrls, deleteUrlById, updateUrlById, fetchLiveUrl };

70
src/api/userApi.js Normal file
View File

@@ -0,0 +1,70 @@
import axios from "axios";
const userApi = axios.create({
// baseURL: "https://api9.jrs77.xyz/user",
timeout: 5000,
// baseURL: "http://154.36.154.211:9001/user",
// baseURL: "http://127.0.0.1:9001/user",
// baseURL: "http://116.62.173.2:9001/user",
baseURL: "/user",
withCredentials: true
});
// 发送验证码type: 1 注册 / 2 修改密码)
export const sendRegisterCode = async (email, type = 1) => {
return userApi({
url: "/send",
method: "get",
params: { email, type },
}).then((res) => res.data);
};
// 注册
export const registerUser = async ({ email, password, code, username = "" }) => {
return userApi({
url: "/register",
method: "post",
data: { email, password, code, username },
}).then((res) => res.data);
};
// 登录(推荐用 form-data 提交给 Spring
export const loginUser = async ({ email, password }) => {
const form = new URLSearchParams();
form.append("email", email);
form.append("password", password);
return userApi({
url: "/login",
method: "post",
data: form,
}).then((res) => res.data);
};
// 获取当前登录用户(需要后端提供 /user/me
export const getCurrentUser = async () => {
return userApi({
url: "/me",
method: "get",
}).then((res) => res.data);
};
// 退出登录(需要后端提供 /user/logout
export const logoutUser = async () => {
return userApi({
url: "/logout",
method: "post",
}).then((res) => res.data);
};
//添加修改密码接口
export const updatePassword = async ({ email, password, code}) => {
return userApi({
url: "/update",
method: "post",
data: { email, password, code},
}).then((res) => res.data);
};
export default userApi;

BIN
src/assets/imgs/qw.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

BIN
src/assets/imgs/zfb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -0,0 +1,365 @@
<template>
<div class="auth-overlay" @click.self="close">
<div class="auth-modal">
<header class="auth-header">
<div class="tabs">
<button
:class="{ active: mode === 'login' }"
@click="switchMode('login')"
>
登录
</button>
<button
:class="{ active: mode === 'register' }"
@click="switchMode('register')"
>
注册
</button>
<button
:class="{ active: mode === 'reset' }"
@click="switchMode('reset')"
>
修改密码
</button>
</div>
<button class="close-btn" @click="close">×</button>
</header>
<form @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label>邮箱</label>
<div class="email-row">
<input
v-model="form.email"
type="email"
required
placeholder="请输入邮箱"
/>
<button
v-if="mode !== 'login'"
type="button"
class="code-btn"
:disabled="countdown > 0"
@click="handleSendCode"
>
{{ countdown > 0 ? countdown + ' 秒后重发' : '发送验证码' }}
</button>
</div>
</div>
<div class="form-group" v-if="mode !== 'login'">
<label>邮箱验证码</label>
<input
v-model="form.code"
type="text"
required
placeholder="请输入邮箱验证码"
/>
</div>
<div class="form-group">
<label>{{ mode === 'reset' ? '新密码' : '密码' }}</label>
<input
v-model="form.password"
type="password"
required
placeholder="请输入密码"
/>
</div>
<div class="form-group" v-if="mode !== 'login'">
<label>确认密码</label>
<input
v-model="form.confirm"
type="password"
required
placeholder="请再次输入密码"
/>
</div>
<div class="actions">
<button type="submit" class="primary-btn">
{{
mode === 'login'
? '登录'
: mode === 'register'
? '注册'
: '修改密码'
}}
</button>
<button type="button" class="ghost-btn" @click="close">
取消
</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { reactive, ref, watch, onUnmounted } from "vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
defaultMode: {
type: String,
default: "login",
},
});
const emit = defineEmits(["update:modelValue", "submit", "send-code"]);
const countdown = ref(0);
let timer = null;
const form = reactive({
email: "",
password: "",
confirm: "",
code: "",
});
const mode = ref(props.defaultMode || "login");
watch(
() => props.defaultMode,
() => {
resetForm();
mode.value = props.defaultMode || "login";
}
);
const resetForm = () => {
form.email = "";
form.password = "";
form.confirm = "";
form.code = "";
countdown.value = 0;
if (timer) {
clearInterval(timer);
timer = null;
}
};
const switchMode = (target) => {
if (target !== mode.value) {
mode.value = target;
resetForm();
}
};
const close = () => {
resetForm();
mode.value = props.defaultMode || "login";
emit("update:modelValue", false);
};
const isEmailValid = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const handleSubmit = () => {
if (!isEmailValid(form.email)) {
alert("邮箱格式不正确");
return;
}
if (mode.value !== "login" && form.password !== form.confirm) {
alert("两次输入的密码不一致");
return;
}
if (mode.value !== "login" && !form.code) {
alert("请输入邮箱验证码");
return;
}
emit("submit", {
mode: mode.value,
email: form.email,
password: form.password,
code: form.code,
});
resetForm();
close();
};
const handleSendCode = () => {
if (countdown.value > 0) {
// 在倒计时中,直接忽略点击
return;
}
if (!form.email) {
alert("请先输入邮箱");
return;
}
if (!isEmailValid(form.email)) {
alert("邮箱格式不正确");
return;
}
// 通知父组件发送验证码
emit("send-code", { email: form.email, mode: mode.value });
// 这里默认认为发送成功,如果你要等后端返回再开始倒计时,
// 可以把下面这段逻辑放到父组件里,再通过 props 传回当前组件。
// alert("验证码发送成功");
countdown.value = 60;
timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
countdown.value = 0;
clearInterval(timer);
timer = null;
}
}, 1000);
};
onUnmounted(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
// const handleSendCode = () => {
// if (!form.email) {
// alert("请先输入邮箱");
// return;
// }
// if (!isEmailValid(form.email)) {
//
("邮箱格式不正确");
// return;
// }
// emit("send-code", { email: form.email });
// };
</script>
<style scoped>
.code-btn[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.auth-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.auth-modal {
width: 360px;
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.18);
padding: 20px;
}
.auth-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.tabs {
display: flex;
gap: 8px;
}
.tabs button {
padding: 8px 14px;
border: none;
border-radius: 6px;
background: #f2f4f7;
cursor: pointer;
font-weight: 600;
}
.tabs button.active {
background: #1d428a;
color: #fff;
}
.close-btn {
border: none;
background: transparent;
font-size: 20px;
cursor: pointer;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.email-row {
display: flex;
gap: 8px;
}
.email-row input {
flex: 1;
}
.code-btn {
white-space: nowrap;
padding: 0 12px;
border: 1px solid #1d428a;
background: #1d428a;
color: #fff;
border-radius: 6px;
cursor: pointer;
}
.form-group label {
font-size: 14px;
color: #444;
}
.form-group input {
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #d9d9d9;
font-size: 14px;
}
.actions {
display: flex;
gap: 10px;
margin-top: 6px;
}
.primary-btn,
.ghost-btn {
flex: 1;
padding: 10px 12px;
border-radius: 6px;
border: none;
cursor: pointer;
font-weight: 600;
}
.primary-btn {
background: #1d428a;
color: #fff;
}
.ghost-btn {
background: #f2f4f7;
color: #1d428a;
}
</style>

View File

@@ -0,0 +1,365 @@
<template>
<div class="auth-overlay" @click.self="close">
<div class="auth-modal">
<header class="auth-header">
<div class="tabs">
<button
:class="{ active: mode === 'login' }"
@click="switchMode('login')"
>
登录
</button>
<button
:class="{ active: mode === 'register' }"
@click="switchMode('register')"
>
注册
</button>
<button
:class="{ active: mode === 'reset' }"
@click="switchMode('reset')"
>
修改密码
</button>
</div>
<button class="close-btn" @click="close">×</button>
</header>
<form @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label>邮箱</label>
<div class="email-row">
<input
v-model="form.email"
type="email"
required
placeholder="请输入邮箱"
/>
<button
v-if="mode !== 'login'"
type="button"
class="code-btn"
:disabled="countdown > 0"
@click="handleSendCode"
>
{{ countdown > 0 ? countdown + ' 秒后重发' : '发送验证码' }}
</button>
</div>
</div>
<div class="form-group" v-if="mode !== 'login'">
<label>邮箱验证码</label>
<input
v-model="form.code"
type="text"
required
placeholder="请输入邮箱验证码"
/>
</div>
<div class="form-group">
<label>{{ mode === 'reset' ? '新密码' : '密码' }}</label>
<input
v-model="form.password"
type="password"
required
placeholder="请输入密码"
/>
</div>
<div class="form-group" v-if="mode !== 'login'">
<label>确认密码</label>
<input
v-model="form.confirm"
type="password"
required
placeholder="请再次输入密码"
/>
</div>
<div class="actions">
<button type="submit" class="primary-btn">
{{
mode === 'login'
? '登录'
: mode === 'register'
? '注册'
: '修改密码'
}}
</button>
<button type="button" class="ghost-btn" @click="close">
取消
</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { reactive, ref, watch, onUnmounted } from "vue";
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
defaultMode: {
type: String,
default: "login",
},
});
const emit = defineEmits(["update:modelValue", "submit", "send-code"]);
const countdown = ref(0);
let timer = null;
const form = reactive({
email: "",
password: "",
confirm: "",
code: "",
});
const mode = ref(props.defaultMode || "login");
watch(
() => props.defaultMode,
() => {
resetForm();
mode.value = props.defaultMode || "login";
}
);
const resetForm = () => {
form.email = "";
form.password = "";
form.confirm = "";
form.code = "";
countdown.value = 0;
if (timer) {
clearInterval(timer);
timer = null;
}
};
const switchMode = (target) => {
if (target !== mode.value) {
mode.value = target;
resetForm();
}
};
const close = () => {
resetForm();
mode.value = props.defaultMode || "login";
emit("update:modelValue", false);
};
const isEmailValid = (email) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
const handleSubmit = () => {
if (!isEmailValid(form.email)) {
alert("邮箱格式不正确");
return;
}
if (mode.value !== "login" && form.password !== form.confirm) {
alert("两次输入的密码不一致");
return;
}
if (mode.value !== "login" && !form.code) {
alert("请输入邮箱验证码");
return;
}
emit("submit", {
mode: mode.value,
email: form.email,
password: form.password,
code: form.code,
});
resetForm();
close();
};
const handleSendCode = () => {
if (countdown.value > 0) {
// 在倒计时中,直接忽略点击
return;
}
if (!form.email) {
alert("请先输入邮箱");
return;
}
if (!isEmailValid(form.email)) {
alert("邮箱格式不正确");
return;
}
// 通知父组件发送验证码
emit("send-code", { email: form.email, mode: mode.value });
// 这里默认认为发送成功,如果你要等后端返回再开始倒计时,
// 可以把下面这段逻辑放到父组件里,再通过 props 传回当前组件。
// alert("验证码发送成功");
countdown.value = 60;
timer = setInterval(() => {
countdown.value--;
if (countdown.value <= 0) {
countdown.value = 0;
clearInterval(timer);
timer = null;
}
}, 1000);
};
onUnmounted(() => {
if (timer) {
clearInterval(timer);
timer = null;
}
});
// const handleSendCode = () => {
// if (!form.email) {
// alert("请先输入邮箱");
// return;
// }
// if (!isEmailValid(form.email)) {
//
("邮箱格式不正确");
// return;
// }
// emit("send-code", { email: form.email });
// };
</script>
<style scoped>
.code-btn[disabled] {
opacity: 0.6;
cursor: not-allowed;
}
.auth-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.auth-modal {
width: 360px;
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.18);
padding: 20px;
}
.auth-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.tabs {
display: flex;
gap: 8px;
}
.tabs button {
padding: 8px 14px;
border: none;
border-radius: 6px;
background: #f2f4f7;
cursor: pointer;
font-weight: 600;
}
.tabs button.active {
background: #1d428a;
color: #fff;
}
.close-btn {
border: none;
background: transparent;
font-size: 20px;
cursor: pointer;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.email-row {
display: flex;
gap: 8px;
}
.email-row input {
flex: 1;
}
.code-btn {
white-space: nowrap;
padding: 0 12px;
border: 1px solid #1d428a;
background: #1d428a;
color: #fff;
border-radius: 6px;
cursor: pointer;
}
.form-group label {
font-size: 14px;
color: #444;
}
.form-group input {
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #d9d9d9;
font-size: 14px;
}
.actions {
display: flex;
gap: 10px;
margin-top: 6px;
}
.primary-btn,
.ghost-btn {
flex: 1;
padding: 10px 12px;
border-radius: 6px;
border: none;
cursor: pointer;
font-weight: 600;
}
.primary-btn {
background: #1d428a;
color: #fff;
}
.ghost-btn {
background: #f2f4f7;
color: #1d428a;
}
</style>

View File

@@ -0,0 +1,438 @@
<template>
<div class="live-stream-container">
<div class="game-header" v-if="gameData">
<div class="team-info away-team">
<img :src="gameData.awayTeam.logo" :alt="gameData.awayTeam.name" />
<div class="team-details">
<p>{{ gameData.awayTeam.name }}</p>
<span>{{ gameData.awayTeam.record }}</span>
</div>
</div>
<div class="vs-circle">VS</div>
<div class="team-info home-team">
<div class="team-details">
<p>{{ gameData.homeTeam.name }}</p>
<span>{{ gameData.homeTeam.record }}</span>
</div>
<img :src="gameData.homeTeam.logo" :alt="gameData.homeTeam.name" />
</div>
</div>
<div class="video-container">
<video
id="native-player"
ref="videoRef"
class="native-video"
controls
playsinline
webkit-playsinline
x5-video-player-type="h5-page"
x5-video-player-fullscreen="true"
>
您的浏览器不支持 HTML5 video 标签
</video>
</div>
<div class="stream-switcher" v-if="allStreams.length > 0">
<div class="stream-buttons">
<button
v-for="stream in allStreams"
:key="stream.type"
@click="switchStream(stream)"
:class="{ active: currentStream?.type === stream.type }"
>
{{ getStreamName(stream.type) }}
</button>
</div>
<div v-if="loading" class="loading-tip">正在获取播放地址...</div>
<div v-if="loadError" class="error-tip">{{ loadError }}</div>
</div>
<div style="display: flex; justify-content: center;">
<h5>出现黑屏无法播放等问题时建议切换浏览器或刷新页面
</h5>
</div>
<button class="back-button" @click="goBack"> 返回赛程</button>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed, nextTick } from "vue";
import { useRouter } from "vue-router";
import { useGameStore } from "@/stores/game";
import { fetchLiveUrl } from "@/api/nba";
import Hls from "hls.js"; // 仅保留 Hls.js 用于 PC 端
const router = useRouter();
const gameStore = useGameStore();
const loading = ref(false);
const loadError = ref("");
const videoRef = ref(null); // Video 标签引用
let hlsInstance = null; // Hls 实例
const gameData = computed(() => gameStore.currentGame);
const allStreams = computed(() => gameStore.allStreams);
const gameId = computed(() => gameStore.gameId);
const currentStream = computed({
get: () => gameStore.currentStream,
set: (val) => (gameStore.currentStream = val),
});
// 初始化播放器核心逻辑
const initPlayer = () => {
if (!currentStream.value?.url) return;
const url = currentStream.value.url;
const video = videoRef.value;
loadError.value = "";
// 1. 清理旧实例
destroyPlayer();
nextTick(() => {
if (!video) return;
console.log("正在初始化播放:", url);
// 2. 移动端/Safari 原生支持 m3u8 (投屏兼容性最佳)
if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = url;
video.load();
video.play().catch(e => {
console.log("自动播放被拦截,切换静音播放", e);
video.muted = true;
video.play();
});
video.onerror = () => {
loadError.value = "播放失败,请尝试切换其他源";
};
}
// 3. PC端 (Chrome/Edge) 使用 Hls.js
else if (Hls.isSupported()) {
hlsInstance = new Hls({
enableWorker: true,
lowLatencyMode: true,
});
hlsInstance.loadSource(url);
hlsInstance.attachMedia(video);
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(e => {
video.muted = true;
video.play();
});
});
// 错误重连机制
hlsInstance.on(Hls.Events.ERROR, function (event, data) {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.log("网络错误(可能是Token过期),尝试重连...");
hlsInstance.destroy();
// 触发重新获取 URL 逻辑
const currentType = currentStream.value.type;
currentStream.value.url = null;
switchStream({ type: currentType });
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hlsInstance.recoverMediaError();
break;
default:
hlsInstance.destroy();
break;
}
}
});
} else {
loadError.value = "您的浏览器不支持播放此视频";
}
});
};
const destroyPlayer = () => {
if (hlsInstance) {
hlsInstance.destroy();
hlsInstance = null;
}
if (videoRef.value) {
const video = videoRef.value;
video.pause();
video.removeAttribute('src');
video.load();
}
};
const ensureStreamUrl = async (stream) => {
if (!stream || !gameId.value) return null;
if (stream.url) return stream;
loading.value = true;
loadError.value = "";
try {
const resp = await fetchLiveUrl(gameId.value, stream.type);
const url =
resp?.url ||
resp?.data?.url ||
resp?.data?.data ||
(typeof resp === "string" ? resp : null);
if (!url) throw new Error("未返回播放地址");
return { ...stream, url };
} catch (err) {
console.error("获取直播地址失败:", err);
loadError.value = "获取直播地址失败,请重试或切换其他源";
return null;
} finally {
loading.value = false;
}
};
const switchStream = async (stream) => {
const streamWithUrl = await ensureStreamUrl(stream);
if (!streamWithUrl) return;
loadError.value = "";
currentStream.value = streamWithUrl;
initPlayer();
};
const getStreamName = (type) => {
const names = {
tx: "企鹅超清",
wl: "纬来体育",
nba: "高清原声",
mg: "咪咕高清",
zb: "高清直播",
};
return names[type] || type;
};
const goBack = () => {
gameStore.clearGameData();
router.go(-1);
};
onMounted(() => {
if (allStreams.value.length > 0 && !currentStream.value) {
currentStream.value = allStreams.value[0];
}
const initial = currentStream.value || allStreams.value[0];
if (initial) {
switchStream(initial);
}
});
onBeforeUnmount(() => {
destroyPlayer();
});
</script>
<style lang="scss" scoped>
.live-stream-container {
max-width: 1200px;
margin: 0 auto;
background: #f5f5f5;
min-height: 100vh;
}
.game-header {
display: flex;
justify-content: center;
align-items: center;
gap: 40px;
margin-bottom: 20px;
margin-top: 20px;
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.team-info {
display: flex;
align-items: center;
gap: 15px;
img {
width: 80px;
height: 80px;
object-fit: contain;
}
.team-details {
text-align: center;
p {
margin: 5px 0;
font-size: 1.1rem;
font-weight: bold;
}
span {
font-size: 0.9rem;
color: #666;
}
}
}
.vs-circle {
width: 50px;
height: 50px;
background: #f29155;
color: white;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
font-size: 1.2rem;
font-weight: bold;
}
/* 核心播放器容器样式 */
.video-container {
width: 100%;
/* 16:9 比例容器 */
aspect-ratio: 16/9;
position: relative;
background: #000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
display: flex;
justify-content: center;
align-items: center;
}
/* 原生 Video 标签样式 */
/* 原生 Video 标签样式 */
.native-video {
width: 100%;
height: 100%;
object-fit: contain;
outline: none;
/* === 新增PC端 隐藏进度条和时间 === */
/* 隐藏进度条 (滑动条) */
&::-webkit-media-controls-timeline {
display: none !important;
}
/* 隐藏当前播放时间 (例如 00:15) */
&::-webkit-media-controls-current-time-display {
display: none !important;
}
/* 隐藏剩余时间 (例如 -02:30) */
&::-webkit-media-controls-time-remaining-display {
display: none !important;
}
/* (可选) 如果你想连“回到直播”的按钮都隐藏,或者调整布局,可以加更多
但通常上面三个就够了,效果就是:播放/暂停 + 音量 + 画中画 + 全屏 */
}
.stream-switcher {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-top: 20px;
}
.stream-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
button {
padding: 8px 16px;
background: #f0f0f0;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #e0e0e0;
}
&.active {
background: #3498db;
color: white;
}
}
}
.loading-tip {
margin-top: 10px;
text-align: center;
color: #666;
}
.error-tip {
margin-top: 8px;
text-align: center;
color: #d9534f;
}
.back-button {
display: block;
width: 200px;
margin: 10px auto 0;
padding: 12px 24px;
background: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
text-align: center;
&:hover {
background: #2980b9;
}
}
@media (max-width: 768px) {
.game-header {
gap: 20px;
.team-info {
display: flex;
align-items: center;
gap: 15px;
img {
width: 60px;
height: 60px;
object-fit: contain;
}
.team-details {
text-align: center;
p {
margin: 5px 0;
font-size: 1.1rem;
font-weight: bold;
}
span {
font-size: 0.9rem;
color: #666;
}
}
}
}
.vs-circle {
margin: 10px 0;
}
.back-button {
width: 100%;
}
}
</style>

View File

@@ -1,11 +1,9 @@
<template> <template>
<div class="live-stream-container"> <div class="live-stream-container">
<!-- 比赛信息 -->
<div class="game-header" v-if="gameData"> <div class="game-header" v-if="gameData">
<div class="team-info away-team"> <div class="team-info away-team">
<img :src="gameData.awayTeam.logo" :alt="gameData.awayTeam.name" /> <img :src="gameData.awayTeam.logo" :alt="gameData.awayTeam.name" />
<div class="team-details"> <div class="team-details">
<!-- <h3>{{ gameData.awayTeam.city }}</h3> -->
<p>{{ gameData.awayTeam.name }}</p> <p>{{ gameData.awayTeam.name }}</p>
<span>{{ gameData.awayTeam.record }}</span> <span>{{ gameData.awayTeam.record }}</span>
</div> </div>
@@ -14,9 +12,7 @@
<div class="vs-circle">VS</div> <div class="vs-circle">VS</div>
<div class="team-info home-team"> <div class="team-info home-team">
<div class="team-details"> <div class="team-details">
<!-- <h3>{{ gameData.homeTeam.city }}</h3> -->
<p>{{ gameData.homeTeam.name }}</p> <p>{{ gameData.homeTeam.name }}</p>
<span>{{ gameData.homeTeam.record }}</span> <span>{{ gameData.homeTeam.record }}</span>
</div> </div>
@@ -24,12 +20,22 @@
</div> </div>
</div> </div>
<!-- 播放器 --> <div class="video-container">
<div id="dplayer-live" class="dplayer-container"></div> <video
id="native-player"
ref="videoRef"
class="native-video"
controls
playsinline
webkit-playsinline
x5-video-player-type="h5-page"
x5-video-player-fullscreen="true"
>
您的浏览器不支持 HTML5 video 标签
</video>
</div>
<!-- 直播源列表始终显示 -->
<div class="stream-switcher" v-if="allStreams.length > 0"> <div class="stream-switcher" v-if="allStreams.length > 0">
<!-- <h3>直播源</h3> -->
<div class="stream-buttons"> <div class="stream-buttons">
<button <button
v-for="stream in allStreams" v-for="stream in allStreams"
@@ -40,164 +46,189 @@
{{ getStreamName(stream.type) }} {{ getStreamName(stream.type) }}
</button> </button>
</div> </div>
<div v-if="loading" class="loading-tip">正在获取播放地址...</div>
<div v-if="loadError" class="error-tip">{{ loadError }}</div>
</div>
<div style="display: flex; justify-content: center;align-items: center;
flex-direction: column;">
<p style="font-size: 15px;">推荐使用谷歌浏览器观看</p>
<p style="font-size: 15px;">出现卡顿推荐开启VPN选择美国香港节点观看</p>
<p style="font-size: 15px;">请不要相信访问视频中广告站点谨防钓鱼诈骗</p>
</div> </div>
<!-- 返回按钮 -->
<button class="back-button" @click="goBack"> 返回赛程</button> <button class="back-button" @click="goBack"> 返回赛程</button>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, computed } from "vue"; import { ref, onMounted, onBeforeUnmount, computed, nextTick } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { useGameStore } from "@/stores/game"; import { useGameStore } from "@/stores/game";
import DPlayer from "dplayer"; import { fetchLiveUrl } from "@/api/nba";
import Hls from "hls.js"; import Hls from "hls.js"; // 仅保留 Hls.js 用于 PC 端
import Flv from "flv.js";
// 注册全局变量
window.flvjs = Flv;
window.Hls = Hls;
const router = useRouter(); const router = useRouter();
const gameStore = useGameStore(); const gameStore = useGameStore();
const dpInstance = ref(null); const loading = ref(false);
const loadError = ref("");
const videoRef = ref(null); // Video 标签引用
let hlsInstance = null; // Hls 实例
// 从store获取数据
const gameData = computed(() => gameStore.currentGame); const gameData = computed(() => gameStore.currentGame);
const allStreams = computed(() => gameStore.allStreams); const allStreams = computed(() => gameStore.allStreams);
const gameId = computed(() => gameStore.gameId);
const currentStream = computed({ const currentStream = computed({
get: () => gameStore.currentStream, get: () => gameStore.currentStream,
set: (val) => (gameStore.currentStream = val), set: (val) => (gameStore.currentStream = val),
}); });
// 初始化播放器 // 初始化播放器核心逻辑
// const initPlayer = () => {
// if (dpInstance.value) {
// dpInstance.value.destroy();
// }
// dpInstance.value = new DPlayer({
// container: document.getElementById('dplayer-live'),
// live: true,
// autoplay: true,
// video: {
// url: currentStream.value?.url || '',
// type: 'auto'
// }
// });
// // 强制设置视频尺寸
// setTimeout(() => {
// const container = document.getElementById('dplayer-live');
// const video = container?.querySelector('video');
// if (video) {
// video.style.cssText = `
// width: 100% !important;
// height: 100% !important;
// object-fit: contain !important;
// position: absolute !important;
// top: 0 !important;
// left: 0 !important;
// `;
// // console.log('视频实际尺寸:', video.videoWidth, 'x', video.videoHeight);
// }
// }, 500);
// };
// 改进的播放器初始化(核心修改点)
// 改进的播放器初始化(核心修改点)
const initPlayer = () => { const initPlayer = () => {
if (!currentStream.value?.url) return; if (!currentStream.value?.url) return;
const url = currentStream.value.url;
const video = videoRef.value;
// 销毁旧实例 loadError.value = "";
if (dpInstance.value) {
dpInstance.value.destroy();
}
dpInstance.value = new DPlayer({ // 1. 清理旧实例
container: document.getElementById("dplayer-live"), destroyPlayer();
live: true,
autoplay: true,
airplay: true,
video: {
url: currentStream.value.url,
type: "custom", // 修改为custom类型
customType: {
custom: function (video, player) {
const url = video.src;
// 自动检测协议类型 nextTick(() => {
if (url.includes(".m3u8") || url.endsWith("/hls")) { if (!video) return;
const hls = new Hls(); console.log("正在初始化播放:", url);
hls.loadSource(url);
hls.attachMedia(video); // 2. 移动端/Safari 原生支持 m3u8 (投屏兼容性最佳)
player.on("destroy", () => hls.destroy()); if (video.canPlayType("application/vnd.apple.mpegurl")) {
} else if (url.includes(".flv") || url.endsWith("/flv")) {
const flv = Flv.createPlayer({ type: "flv", url });
flv.attachMediaElement(video);
flv.load();
player.on("destroy", () => flv.destroy());
} else {
// 其他协议回退到DPlayer默认处理
video.src = url; video.src = url;
} video.load();
},
}, video.play().catch(e => {
}, console.log("自动播放被拦截,切换静音播放", e);
video.muted = true;
video.play();
}); });
// 保持原有尺寸调整逻辑 video.onerror = () => {
setTimeout(() => { loadError.value = "播放失败,请尝试切换其他源";
const video = document };
.getElementById("dplayer-live")
?.querySelector("video");
if (video) {
video.style.cssText = `
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
`;
} }
}, 500); // 3. PC端 (Chrome/Edge) 使用 Hls.js
else if (Hls.isSupported()) {
hlsInstance = new Hls({
enableWorker: true,
lowLatencyMode: true,
});
hlsInstance.loadSource(url);
hlsInstance.attachMedia(video);
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(e => {
video.muted = true;
video.play();
});
});
// 错误重连机制
hlsInstance.on(Hls.Events.ERROR, function (event, data) {
if (data.fatal) {
switch (data.type) {
case Hls.ErrorTypes.NETWORK_ERROR:
console.log("网络错误(可能是Token过期),尝试重连...");
hlsInstance.destroy();
// 触发重新获取 URL 逻辑
const currentType = currentStream.value.type;
currentStream.value.url = null;
switchStream({ type: currentType });
break;
case Hls.ErrorTypes.MEDIA_ERROR:
hlsInstance.recoverMediaError();
break;
default:
hlsInstance.destroy();
break;
}
}
});
} else {
loadError.value = "您的浏览器不支持播放此视频";
}
});
}; };
// 切换直播源 const destroyPlayer = () => {
const switchStream = (stream) => { if (hlsInstance) {
currentStream.value = stream; hlsInstance.destroy();
hlsInstance = null;
}
if (videoRef.value) {
const video = videoRef.value;
video.pause();
video.removeAttribute('src');
video.load();
}
};
const ensureStreamUrl = async (stream) => {
if (!stream || !gameId.value) return null;
if (stream.url) return stream;
loading.value = true;
loadError.value = "";
try {
const resp = await fetchLiveUrl(gameId.value, stream.type);
const url =
resp?.url ||
resp?.data?.url ||
resp?.data?.data ||
(typeof resp === "string" ? resp : null);
if (!url) throw new Error("未返回播放地址");
return { ...stream, url };
} catch (err) {
console.error("获取直播地址失败:", err);
loadError.value = "获取直播地址失败,请重试或切换其他源";
return null;
} finally {
loading.value = false;
}
};
const switchStream = async (stream) => {
const streamWithUrl = await ensureStreamUrl(stream);
if (!streamWithUrl) return;
loadError.value = "";
currentStream.value = streamWithUrl;
initPlayer(); initPlayer();
}; };
// 获取直播源名称
const getStreamName = (type) => { const getStreamName = (type) => {
const names = { const names = {
tx: "企鹅体育", tx: "企鹅超清",
wl: "纬来体育", wl: "纬来体育",
mg: "咪咕体育",
nba: "高清原声", nba: "高清原声",
mg: "咪咕高清",
zb: "高清直播", zb: "高清直播",
}; };
return names[type] || type; return names[type] || type;
}; };
// 返回赛程页
const goBack = () => { const goBack = () => {
gameStore.clearGameData(); gameStore.clearGameData();
router.go(-1); router.go(-1);
}; };
onMounted(() => { onMounted(() => {
// 默认选择第一个直播源
if (allStreams.value.length > 0 && !currentStream.value) { if (allStreams.value.length > 0 && !currentStream.value) {
currentStream.value = allStreams.value[0]; currentStream.value = allStreams.value[0];
} }
initPlayer(); const initial = currentStream.value || allStreams.value[0];
if (initial) {
switchStream(initial);
}
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (dpInstance.value) { destroyPlayer();
dpInstance.value.destroy();
}
}); });
</script> </script>
@@ -205,7 +236,6 @@ onBeforeUnmount(() => {
.live-stream-container { .live-stream-container {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
// padding: 20px;
background: #f5f5f5; background: #f5f5f5;
min-height: 100vh; min-height: 100vh;
} }
@@ -237,12 +267,6 @@ onBeforeUnmount(() => {
.team-details { .team-details {
text-align: center; text-align: center;
h3 {
margin: 0;
font-size: 1.2rem;
color: #333;
}
p { p {
margin: 5px 0; margin: 5px 0;
font-size: 1.1rem; font-size: 1.1rem;
@@ -269,19 +293,46 @@ onBeforeUnmount(() => {
font-weight: bold; font-weight: bold;
} }
.dplayer-container { /* 核心播放器容器样式 */
.video-container {
width: 100%; width: 100%;
/* 16:9 比例容器 */
aspect-ratio: 16/9; aspect-ratio: 16/9;
position: relative; position: relative;
background: #000; background: #000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
display: flex;
justify-content: center;
align-items: center;
}
::v-deep { /* 原生 Video 标签样式 */
.dplayer-video { /* 原生 Video 标签样式 */
width: 100% !important; .native-video {
height: 100% !important; width: 100%;
object-fit: contain !important; height: 100%;
object-fit: contain;
outline: none;
/* === 新增PC端 隐藏进度条和时间 === */
/* 隐藏进度条 (滑动条) */
&::-webkit-media-controls-timeline {
display: none !important;
} }
/* 隐藏当前播放时间 (例如 00:15) */
&::-webkit-media-controls-current-time-display {
display: none !important;
} }
/* 隐藏剩余时间 (例如 -02:30) */
&::-webkit-media-controls-time-remaining-display {
display: none !important;
}
/* (可选) 如果你想连“回到直播”的按钮都隐藏,或者调整布局,可以加更多
但通常上面三个就够了,效果就是:播放/暂停 + 音量 + 画中画 + 全屏 */
} }
.stream-switcher { .stream-switcher {
@@ -317,10 +368,22 @@ onBeforeUnmount(() => {
} }
} }
.loading-tip {
margin-top: 10px;
text-align: center;
color: #666;
}
.error-tip {
margin-top: 8px;
text-align: center;
color: #d9534f;
}
.back-button { .back-button {
display: block; display: block;
width: 200px; width: 200px;
margin: 30px auto 0; margin: 10px auto 0;
padding: 12px 24px; padding: 12px 24px;
background: #3498db; background: #3498db;
color: white; color: white;
@@ -337,7 +400,6 @@ onBeforeUnmount(() => {
@media (max-width: 768px) { @media (max-width: 768px) {
.game-header { .game-header {
// flex-direction: column;
gap: 20px; gap: 20px;
.team-info { .team-info {
display: flex; display: flex;
@@ -353,12 +415,6 @@ onBeforeUnmount(() => {
.team-details { .team-details {
text-align: center; text-align: center;
h3 {
margin: 0;
font-size: 1.2rem;
color: #333;
}
p { p {
margin: 5px 0; margin: 5px 0;
font-size: 1.1rem; font-size: 1.1rem;

View File

@@ -0,0 +1,940 @@
<template>
<div class="nba-schedule-container">
<div v-if="scheduleData?.data?.sponsor" class="sponsor-banner">
<h3>所有内容均来源互联网反馈联系邮箱super@2026123.xyz</h3>
<h4>遇到播放问题可以尝试切换浏览器刷新重试</h4>
</div>
<!-- <div class="ad">
<a href="https://1.aysj.xyz/#/register" target="_blank">
遨游世界加速器畅游GoogleYouTubeChatGptins等海外网站支持多平台使用仅需15/
</a>
</div> -->
<div class="top">
<div class="login" v-if="!isLoggedIn">
<button class="login-btn" @click="openAuth">登录/注册</button>
</div>
<div class="user-info" v-else>
<span class="user-email">{{ userEmail }}</span>
<button class="logout-btn" @click="logout">退出</button>
</div>
</div>
<div class="date-navigation">
<button
@click="changeDate('pre')"
class="nav-button"
:disabled="!scheduleData?.data?.preDate"
>
<span class="arrow"></span>
{{ scheduleData?.data?.preDate || "无更早日期" }}
</button>
<div class="current-date">
{{ currentDisplayDate }}
</div>
<button
@click="changeDate('next')"
class="nav-button"
:disabled="!scheduleData?.data?.nextDate"
>
{{ scheduleData?.data?.nextDate || "无更晚日期" }}
<span class="arrow"></span>
</button>
</div>
<div class="games-list">
<div v-for="group in scheduleData?.data?.groups" :key="group.date">
<div v-if="group.games && group.games.length > 0">
<div v-for="game in group.games" :key="game.gameId" class="game-card">
<div class="game-time" :class="getStatusClass(game.status)">
美国时间{{ formatGameTime(game.dateTimeUtc) }}
</div>
<div class="game-main">
<div
class="team home-team"
:class="{
'tbd-team': !game.teamValid,
winner: isWinner(game, 'home'),
}"
>
<img
:src="game.homeTeamLogoDark"
:alt="game.homeTeamName"
class="team-logo"
/>
<div class="team-info">
<div class="team-name">
<span class="city">{{ game.homeTeamCity }}</span>
<span class="name">{{ game.homeTeamName || "待定" }}</span>
</div>
<div class="team-record">
<span v-if="game.homeTeamWins !== undefined">
{{ game.homeTeamWins }}{{ game.homeTeamLosses }}
</span>
<span v-if="game.hasTotalWins" class="series-wins">
(系列赛{{ game.homeTeamTotalWins }}-{{
game.awayTeamTotalWins
}})
</span>
</div>
</div>
<h4>主场</h4>
<div v-if="game.status !== 1" class="team-score">
{{ game.homeTeamScore }}
</div>
</div>
<div class="game-status">
<div v-if="game.status === 1" class="game-not-started">
北京时间{{ game.startTime }} 开赛
</div>
<div v-else class="game-in-progress">
<div class="status-text">{{ game.statusText }}</div>
<div v-if="game.periodText" class="period-text">
{{ game.periodText }}
</div>
<div v-if="game.gameClock" class="game-clock">
{{ game.gameClock }}
</div>
</div>
</div>
<div
class="team away-team"
:class="{
'tbd-team': !game.teamValid,
winner: isWinner(game, 'away'),
}"
>
<img
:src="game.awayTeamLogoDark"
:alt="game.awayTeamName"
class="team-logo"
/>
<div class="team-info">
<div class="team-name">
<span class="city">{{ game.awayTeamCity }}</span>
<span class="name">{{ game.awayTeamName || "待定" }}</span>
</div>
<div class="team-record">
<span v-if="game.awayTeamWins !== undefined">
{{ game.awayTeamWins }}{{ game.awayTeamLosses }}
</span>
<span v-if="game.hasTotalWins" class="series-wins">
(系列赛{{ game.awayTeamTotalWins }}-{{
game.homeTeamTotalWins
}})
</span>
</div>
</div>
<h4>客场</h4>
<div v-if="game.status !== 1" class="team-score">
{{ game.awayTeamScore }}
</div>
</div>
</div>
<div class="live-buttons">
<!-- 当天的比赛 -->
<template v-if="isTodayGame(game)">
<!-- 当天且有 urls显示直播按钮 -->
<!-- <template v-if="hasLiveStreams(game.gameId) || game.status == 3">
<button
v-for="stream in getLiveStreams(game.gameId)"
:key="stream.type"
@click="goToLive(game, stream)"
class="live-btn"
>
<span class="btn-icon">📺</span>
{{ getStreamName(stream.type) }}
</button>
</template> -->
<template v-if="hasLiveStreams(game.gameId) && game.status !== 3">
<button
v-for="stream in getLiveStreams(game.gameId)"
:key="stream.type"
@click="goToLive(game, stream)"
class="live-btn"
>
<span class="btn-icon">📺</span>
{{ getStreamName(stream.type) }}
</button>
</template>
<div v-else-if="game.status === 3" class="no-live">
比赛已结束
</div>
<!-- 当天但暂时没有 urls -->
<div v-else class="no-live">无直播信息</div>
</template>
<!-- 不是当天已结束 -->
<div v-else-if="game.status === 3" class="no-live">
比赛已结束
</div>
<!-- 不是当天未结束显示订阅 -->
<div
v-else
class="no-live subscribe-btn"
@click="handleSubscribe(game)"
>
订阅
</div>
</div>
<div class="game-footer">
<div class="game-arena">
<span v-if="game.arenaName">{{ game.arenaName }}</span>
<span v-else>场地待定</span>
</div>
<div class="game-season">
{{ game.seasonName }}
</div>
</div>
</div>
</div>
<div v-else class="no-games-message">
{{ currentDisplayDate }} 当天没有NBA比赛
</div>
</div>
</div>
</div>
<AuthModal
v-if="showAuth"
v-model="showAuth"
:default-mode="authMode"
@submit="handleAuthSubmit"
@send-code="handleSendCode"
/>
</template>
<script setup>
import { useRouter } from "vue-router";
import { urls, fetchLiveUrl } from "@/api/nba";
import { sendRegisterCode } from "@/api/userApi";
import { useGameStore } from "@/stores/game";
import AuthModal from "@/components/AuthModal.vue";
import { computed, ref, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
const { isLoggedIn, user } = storeToRefs(userStore);
const userEmail = computed(() => user.value || "已登录");
const showAuth = ref(false);
const authMode = ref("login");
const gameStore = useGameStore();
const router = useRouter();
const urlsData = ref([]);
onMounted(async () => {
try {
const response = await urls();
urlsData.value = Array.isArray(response) ? response : [];
// console.log(urlsData.value);
} catch (err) {
console.error("获取直播URL失败:", err);
urlsData.value = [];
}
// 2. 再检查当前登录用户(如果 session 还在,会自动登录)
if (!userStore.checkedLogin) {
await userStore.fetchCurrentUser(); // 调 /user/me看 session 里有没有登录
}
});
const isTodayGame = (game) => {
if (!game || !game.startDate) return false;
const gameDate = new Date(game.startDate);
if (isNaN(gameDate.getTime())) return false;
const today = new Date();
return (
gameDate.getFullYear() === today.getFullYear() &&
gameDate.getMonth() === today.getMonth() &&
gameDate.getDate() === today.getDate()
);
};
const normalizeGameId = (id) => {
if (id == null) return "";
// 转成字符串,左侧补 0 到 10 位,跟 /api/urls 里的 "0022500255" 对齐
return String(id).padStart(10, "0");
};
const hasLiveStreams = (gameId) => {
if (!urlsData.value || !gameId) return false;
const id = normalizeGameId(gameId);
for (const streamGroup of urlsData.value) {
if (streamGroup[id]) {
return true;
}
}
return false;
};
const getLiveStreams = (gameId) => {
const id = normalizeGameId(gameId);
for (const streamGroup of urlsData.value) {
if (streamGroup[id]) {
return streamGroup[id];
}
}
return [];
};
const getStreamName = (type) => {
const names = {
tx: "企鹅超清",
wl: "纬来体育",
nba: "高清原声",
mg: "咪咕高清",
zb: "高清直播",
};
return names[type] || type;
};
const openAuth = () => {
showAuth.value = true;
authMode.value = "login";
};
const handleAuthSubmit = async (payload) => {
if (payload.mode === "register") {
try {
const msg = await userStore.register({
email: payload.email,
password: payload.password,
code: payload.code,
});
alert(msg || "注册成功");
showAuth.value = false;
} catch (e) {
alert("注册失败,服务出现异常联系管理员");
}
} else if (payload.mode === "reset") {
try {
const msg = await userStore.changePassword({
email: payload.email,
password: payload.password,
code: payload.code,
});
alert(msg || "密码修改成功");
showAuth.value = false;
} catch (e) {
alert(e.message || "修改密码失败,服务出现异常联系管理员");
}
} else {
try {
await userStore.login({
email: payload.email,
password: payload.password,
});
alert("登录成功");
showAuth.value = false;
} catch (e) {
alert(e.message || "登录失败,服务出现异常联系管理员");
}
}
};
const handleSendCode = ({ email, mode }) => {
const type = mode === "reset" ? 2 : 1;
sendRegisterCode(email, type)
.then((res) => {
alert(res)
// alert("验证码已发送,因邮件有延迟 请不要频繁发送");
})
.catch(() => {
alert("服务出现异常,请联系管理员");
});
};
const logout = async () => {
try {
await userStore.logout(); // 调 /user/logout + 清空 user
alert("已退出登录");
} catch (e) {
alert("退出登录异常,但本地已清空登录状态");
}
};
const handleSubscribe = (game) => {
if (!isLoggedIn.value) {
alert("订阅功能需登录使用");
// openAuth(); //(可选)如果你想自动弹出登录弹窗
return;
}
// 登录了
alert("订阅功能等待开放");
return;
// 未来真正开放订阅后,在这里写接口逻辑:
// console.log("订阅比赛:", game.gameId);
};
const goToLive = async (game, stream) => {
if (!game?.gameId || !stream?.type) return;
// 未登录用户提示请先登录
if (!isLoggedIn.value) {
alert("请先登录后再观看直播");
openAuth();
return;
}
const gameData = {
homeTeam: {
name: game.homeTeamName,
logo: game.homeTeamLogoDark,
city: game.homeTeamCity,
record: `${game.homeTeamWins}${game.homeTeamLosses}`,
},
awayTeam: {
name: game.awayTeamName,
logo: game.awayTeamLogoDark,
city: game.awayTeamCity,
record: `${game.awayTeamWins}${game.awayTeamLosses}`,
},
gameInfo: {
arena: game.arenaName,
season: game.seasonName,
},
};
try {
const liveResp = await fetchLiveUrl(game.gameId, stream.type);
const url =
liveResp?.url ||
liveResp?.data?.url ||
liveResp?.data?.data ||
(typeof liveResp === "string" ? liveResp : null);
gameStore.setCurrentGame({
gameData,
gameId: game.gameId,
currentStream: { ...stream, url },
allStreams: getLiveStreams(game.gameId),
});
router.push({
name: "Play",
params: { gameId: game.gameId },
});
} catch (err) {
console.error("获取直播地址失败:", err);
}
};
const props = defineProps({
scheduleData: {
type: Object,
default: null,
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String,
default: null,
},
});
const emit = defineEmits(["dateChange"]);
const currentDisplayDate = computed(() => {
if (!props.scheduleData?.data?.start) return "加载中...";
const dateStr = props.scheduleData.data.start;
const date = new Date(dateStr);
const weekdays = [
"星期日",
"星期一",
"星期二",
"星期三",
"星期四",
"星期五",
"星期六",
];
const weekday = weekdays[date.getDay()];
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}${month}${day}${weekday}`;
});
const formatGameTime = (utcTime) => {
if (!utcTime) return "时间待定";
const date = new Date(utcTime);
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
};
const getStatusClass = (status) => {
switch (status) {
case 1:
return "not-started";
case 2:
return "in-progress";
case 3:
return "finished";
default:
return "not-started";
}
};
const changeDate = (direction) => {
if (!props.scheduleData?.data) return;
const date =
direction === "pre"
? props.scheduleData.data.preDate
: props.scheduleData.data.nextDate;
if (date) {
emit("dateChange", date);
}
};
const isWinner = (game, teamType) => {
if (game.status !== 3) return false;
if (teamType === "away") {
return game.awayTeamScore > game.homeTeamScore;
} else {
return game.homeTeamScore > game.awayTeamScore;
}
};
</script>
<style scoped>
.subscribe-btn {
cursor: pointer;
}
.subscribe-btn:hover {
opacity: 0.8;
}
.nba-schedule-container {
max-width: 1000px;
margin: 0 auto;
padding: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
}
.no-live {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
padding: 8px 16px;
background-color: #808080;
color: #ffffff;
border-radius: 4px;
text-align: center;
font-size: 18px;
font-weight: 600;
}
.sponsor-banner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-bottom: 10px;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.sponsor-banner h3,
.sponsor-banner h4 {
margin: 5px;
}
.date-navigation {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #e9ecef;
}
.current-date {
font-size: 22px;
font-weight: 600;
color: #1b1f27;
}
.nav-button {
background: none;
border: 1px solid #dee2e6;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 16px;
}
.nav-button:hover:not(:disabled) {
background-color: #ffffff;
border-color: #ffffff;
}
.nav-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.arrow {
font-weight: bold;
}
.games-list {
display: flex;
flex-direction: column;
gap: 25px;
}
.game-card {
border: 1px solid #e9ecef;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
transition: transform 0.2s, box-shadow 0.2s;
}
.game-card:hover {
transform: translateY(-3px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
}
.game-time {
padding: 12px 20px;
font-weight: 600;
font-size: 16px;
color: white;
}
.not-started {
background-color: #6c757d;
}
.in-progress {
background-color: #dc3545;
}
.finished {
background-color: #3dbe5b;
}
.game-main {
padding: 20px;
}
.team {
display: flex;
align-items: center;
padding: 15px 0;
position: relative;
}
.team-logo {
width: 60px;
height: 60px;
margin-right: 20px;
object-fit: contain;
}
.team-info {
flex: 1;
display: flex;
flex-direction: column;
}
.team-name {
display: flex;
flex-direction: column;
}
.team-name .city {
font-size: 14px;
color: #6c757d;
}
.team-name .name {
font-weight: 700;
font-size: 20px;
margin: 4px 0;
color: #212529;
}
.team-record {
font-size: 14px;
color: #868e96;
}
.team-score {
font-size: 28px;
font-weight: 700;
min-width: 60px;
text-align: center;
margin-left: 20px;
color: #212529;
}
.tbd-team {
opacity: 0.7;
}
.game-status {
padding: 12px 0;
text-align: center;
margin: 10px 0;
border-top: 1px dashed #e9ecef;
border-bottom: 1px dashed #e9ecef;
}
.game-not-started {
color: #0008ff;
font-size: 19px;
}
.game-in-progress {
display: flex;
flex-direction: column;
gap: 6px;
}
.status-text {
font-weight: 700;
color: #dc3545;
font-size: 18px;
}
.period-text,
.game-clock {
font-size: 14px;
color: #6c757d;
}
.home-team {
border-top: 1px solid #f1f3f5;
}
.game-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background-color: #f8f9fa;
border-top: 1px solid #e9ecef;
}
.game-arena {
font-size: 14px;
color: #6c757d;
}
.game-season {
font-size: 14px;
font-weight: 600;
color: #1d428a;
}
@media (max-width: 768px) {
.game-card {
margin: 0 10px;
}
.team {
padding: 12px 0;
}
.team-logo {
width: 50px;
height: 50px;
margin-right: 15px;
}
.team-name .name {
font-size: 18px;
}
.team-score {
font-size: 24px;
}
}
@media (max-width: 480px) {
.date-navigation {
flex-direction: column;
gap: 12px;
}
.nav-button {
width: 100%;
display: flex;
justify-content: space-between;
}
.current-date {
order: -1;
margin-bottom: 8px;
}
.team-logo {
width: 40px;
height: 40px;
margin-right: 12px;
}
.team-name .name {
font-size: 16px;
}
.team-score {
font-size: 20px;
min-width: 50px;
}
}
.live-buttons {
display: flex;
gap: 15px;
justify-content: center;
align-items: center;
padding: 15px 20px;
border-top: 1px solid #f1f3f5;
border-bottom: 1px solid #f1f3f5;
}
.live-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.live-btn.primary {
background-color: #1d428a;
color: white;
}
.live-btn.primary:hover {
background-color: #15316e;
}
.live-btn.secondary {
background-color: #f8f9fa;
color: #1d428a;
border: 1px solid #dee2e6;
}
.live-btn.secondary:hover {
background-color: #e9ecef;
}
.btn-icon {
font-size: 18px;
}
@media (max-width: 768px) {
.live-buttons {
flex-direction: column;
gap: 10px;
}
.live-btn {
padding: 10px;
}
}
.no-games-message {
text-align: center;
padding: 40px;
font-size: 18px;
color: #666;
background-color: #f8f8f8;
border-radius: 8px;
margin: 20px 0;
}
.team.winner {
background-color: rgba(183, 239, 167, 0.898);
border-left: 3px solid #a4b1ee;
}
.team.winner .team-name {
font-weight: bold;
color: #d4e66a;
}
.team.winner .team-score {
font-weight: bold;
color: #68db81;
}
.top {
display: flex;
padding: 5px;
justify-content: center;
align-items: center;
}
.login {
font-size: 20px;
font-weight: 500;
}
.login-btn {
padding: 6px 14px;
border: 1px solid #1d428a;
background: #1d428a;
color: #fff;
border-radius: 6px;
cursor: pointer;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
}
.user-email {
color: #1d428a;
font-weight: 700;
}
.logout-btn {
padding: 6px 10px;
border: 1px solid #e74c3c;
background: #e74c3c;
color: #fff;
border-radius: 6px;
cursor: pointer;
}
a {
text-decoration: none;
color: #4d58f5;
font-size: 18px;
}
.ad{
margin: 10px 0;
padding: 15px;
background-color: #d4eef1;
/* border: 1px solid #79d0e4; */
border-radius: 6px;
text-align: center;
}
</style>

View File

@@ -1,16 +1,30 @@
<template> <template>
<!-- <div class="zfb" v-if="showZfb">
<img src="../assets/imgs/qw.jpg" alt="千问有礼">
<div class="zfb-text">下载千问app扫码免费点奶茶</div>
<button class="close-btn" @click="closeZfb">关闭</button>
</div> -->
<div class="nba-schedule-container"> <div class="nba-schedule-container">
<!-- 赞助商信息 --> <div class="sponsor-banner">
<div v-if="scheduleData?.data?.sponsor" class="sponsor-banner"> <h3>所有内容均来源互联网反馈邮箱super@2026123.xyz</h3>
<span>所有内容均来源互联网有问题请联系邮箱xdd9@vip.qq.com</span> <h4>遇到播放问题可以尝试切换浏览器刷新重试</h4>
<!-- <img <h4>推荐使用谷歌浏览器</h4>
:src="scheduleData.data.sponsor.logo" </div>
:alt="scheduleData.data.sponsor.name" <div class="ad">
class="sponsor-logo" <a href="https://1.aysj.xyz/#/register" target="_blank">
/> --> 遨游世界VPN畅游YouTubeChatGptinstelegram等海外应用支持苹果安卓电脑使用仅需20/点击试用
</a>
</div>
<div class="top">
<div class="login" v-if="!isLoggedIn">
<button class="login-btn" @click="openAuth">登录/注册</button>
</div>
<div class="user-info" v-else>
<span class="user-email">{{ userEmail }}</span>
<button class="logout-btn" @click="logout">退出</button>
</div>
</div> </div>
<!-- 赛程日期导航 -->
<div class="date-navigation"> <div class="date-navigation">
<button <button
@click="changeDate('pre')" @click="changeDate('pre')"
@@ -33,70 +47,20 @@
</button> </button>
</div> </div>
<!-- 比赛列表 - 每行一场比赛 -->
<div class="games-list"> <div class="games-list">
<div v-for="group in scheduleData?.data?.groups" :key="group.date"> <div v-for="group in scheduleData?.data?.groups" :key="group.date">
<!-- 添加判断当games数组为空时显示提示信息 -->
<div v-if="group.games && group.games.length > 0"> <div v-if="group.games && group.games.length > 0">
<div v-for="game in group.games" :key="game.gameId" class="game-card"> <div v-for="game in group.games" :key="game.gameId" class="game-card">
<!-- 比赛时间 -->
<div class="game-time" :class="getStatusClass(game.status)"> <div class="game-time" :class="getStatusClass(game.status)">
美国时间{{ formatGameTime(game.dateTimeUtc) }} 美国时间{{ formatGameTime(game.dateTimeUtc) }}
</div> </div>
<!-- 比赛主要内容 -->
<div class="game-main"> <div class="game-main">
<!-- 客队信息 -->
<div
class="team away-team"
:class="{
'tbd-team': !game.teamValid,
winner: isWinner(game, 'away'), // 添加判断是否为胜者
}"
>
<img
:src="game.awayTeamLogoDark"
:alt="game.awayTeamName"
class="team-logo"
/>
<div class="team-info">
<div class="team-name">
<span class="city">{{ game.awayTeamCity }}</span>
<span class="name">{{ game.awayTeamName || "待定" }}</span>
</div>
<div class="team-record">
<span v-if="game.awayTeamWins !== undefined">
{{ game.awayTeamWins }}-{{ game.awayTeamLosses }}
</span>
</div>
</div>
<div v-if="game.status !== 1" class="team-score">
{{ game.awayTeamScore }}
</div>
</div>
<!-- 比赛状态 -->
<div class="game-status">
<div v-if="game.status === 1" class="game-not-started">
北京时间{{ game.startTime }} 开始
</div>
<div v-else class="game-in-progress">
<div class="status-text">{{ game.statusText }}</div>
<div v-if="game.periodText" class="period-text">
{{ game.periodText }}
</div>
<div v-if="game.gameClock" class="game-clock">
{{ game.gameClock }}
</div>
</div>
</div>
<!-- 主队信息 -->
<div <div
class="team home-team" class="team home-team"
:class="{ :class="{
'tbd-team': !game.teamValid, 'tbd-team': !game.teamValid,
winner: isWinner(game, 'home'), // 添加判断是否为胜者 winner: isWinner(game, 'home'),
}" }"
> >
<img <img
@@ -111,22 +75,87 @@
</div> </div>
<div class="team-record"> <div class="team-record">
<span v-if="game.homeTeamWins !== undefined"> <span v-if="game.homeTeamWins !== undefined">
{{ game.homeTeamWins }}-{{ game.homeTeamLosses }} {{ game.homeTeamWins }}{{ game.homeTeamLosses }}
</span>
<span v-if="game.hasTotalWins" class="series-wins">
(系列赛{{ game.homeTeamTotalWins }}-{{
game.awayTeamTotalWins
}})
</span> </span>
</div> </div>
</div> </div>
<h4>主场</h4>
<div v-if="game.status !== 1" class="team-score"> <div v-if="game.status !== 1" class="team-score">
{{ game.homeTeamScore }} {{ game.homeTeamScore }}
</div> </div>
</div> </div>
<div class="game-status">
<div v-if="game.status === 1" class="game-not-started">
北京时间{{ game.startTime }} 开赛
</div>
<div v-else class="game-in-progress">
<div class="status-text">{{ game.statusText }}</div>
<div v-if="game.periodText" class="period-text">
{{ game.periodText }}
</div>
<div v-if="game.gameClock" class="game-clock">
{{ game.gameClock }}
</div>
</div>
</div> </div>
<div
class="team away-team"
:class="{
'tbd-team': !game.teamValid,
winner: isWinner(game, 'away'),
}"
>
<img
:src="game.awayTeamLogoDark"
:alt="game.awayTeamName"
class="team-logo"
/>
<div class="team-info">
<div class="team-name">
<span class="city">{{ game.awayTeamCity }}</span>
<span class="name">{{ game.awayTeamName || "待定" }}</span>
</div>
<div class="team-record">
<span v-if="game.awayTeamWins !== undefined">
{{ game.awayTeamWins }}{{ game.awayTeamLosses }}
</span>
<span v-if="game.hasTotalWins" class="series-wins">
(系列赛{{ game.awayTeamTotalWins }}-{{
game.homeTeamTotalWins
}})
</span>
</div>
</div>
<h4>客场</h4>
<div v-if="game.status !== 1" class="team-score">
{{ game.awayTeamScore }}
</div>
</div>
</div>
<div class="live-buttons"> <div class="live-buttons">
<!-- 进行中的比赛 --> <!-- 当天的比赛 -->
<template v-if="game.status === 2">
<!-- 只对当天比赛显示直播按钮 -->
<template v-if="isTodayGame(game)"> <template v-if="isTodayGame(game)">
<template v-if="hasLiveStreams(game.gameId)"> <!-- 当天且有 urls显示直播按钮 -->
<!-- <template v-if="hasLiveStreams(game.gameId) || game.status == 3">
<button
v-for="stream in getLiveStreams(game.gameId)"
:key="stream.type"
@click="goToLive(game, stream)"
class="live-btn"
>
<span class="btn-icon">📺</span>
{{ getStreamName(stream.type) }}
</button>
</template> -->
<template v-if="hasLiveStreams(game.gameId) && game.status !== 3">
<button <button
v-for="stream in getLiveStreams(game.gameId)" v-for="stream in getLiveStreams(game.gameId)"
:key="stream.type" :key="stream.type"
@@ -137,22 +166,27 @@
{{ getStreamName(stream.type) }} {{ getStreamName(stream.type) }}
</button> </button>
</template> </template>
<div v-else class="no-live">无直播信号</div>
</template>
<!-- 非当天进行中比赛理论上不应该存在 -->
<div v-else class="no-live">比赛进行中</div>
</template>
<!-- 已结束的比赛无论是否当天 -->
<div v-else-if="game.status === 3" class="no-live"> <div v-else-if="game.status === 3" class="no-live">
比赛已结束 比赛已结束
</div> </div>
<!-- 当天但暂时没有 urls -->
<div v-else class="no-live">无直播信息</div>
</template>
<!-- 未开始的比赛 --> <!-- 不是当天已结束 -->
<div v-else class="no-live">未开始</div> <div v-else-if="game.status === 3" class="no-live">
比赛已结束
</div>
<!-- 不是当天未结束显示订阅 -->
<div
v-else
class="no-live subscribe-btn"
@click="handleSubscribe(game)"
>
订阅
</div>
</div> </div>
<!-- 比赛场地和赛季信息 -->
<div class="game-footer"> <div class="game-footer">
<div class="game-arena"> <div class="game-arena">
<span v-if="game.arenaName">{{ game.arenaName }}</span> <span v-if="game.arenaName">{{ game.arenaName }}</span>
@@ -164,96 +198,211 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 当没有比赛时显示提示信息 -->
<div v-else class="no-games-message"> <div v-else class="no-games-message">
{{ currentDisplayDate }} 当天没有NBA比赛 {{ currentDisplayDate }} 当天没有NBA比赛
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<AuthModal
v-if="showAuth"
v-model="showAuth"
:default-mode="authMode"
@submit="handleAuthSubmit"
@send-code="handleSendCode"
/>
</template> </template>
<script setup> <script setup>
import { computed, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { urls } from "@/api/nba"; import { urls, fetchLiveUrl } from "@/api/nba";
import { onMounted } from "vue"; import { sendRegisterCode } from "@/api/userApi";
import { useGameStore } from "@/stores/game"; import { useGameStore } from "@/stores/game";
import AuthModal from "@/components/AuthModal.vue";
import { computed, ref, onMounted } from "vue";
import { storeToRefs } from "pinia";
import { useUserStore } from "@/stores/user";
const userStore = useUserStore();
const { isLoggedIn, user } = storeToRefs(userStore);
const userEmail = computed(() => user.value || "已登录");
const showAuth = ref(false);
const authMode = ref("login");
const gameStore = useGameStore(); const gameStore = useGameStore();
const router = useRouter(); const router = useRouter();
const urlsData = ref([]); const urlsData = ref([]);
const showZfb = ref(true);
onMounted(async () => { onMounted(async () => {
try { try {
const response = await urls(); const response = await urls();
urlsData.value = response || []; urlsData.value = Array.isArray(response) ? response : [];
// console.log(urlsData.value);
} catch (err) { } catch (err) {
console.error("获取直播URL失败:", err); console.error("获取直播URL失败:", err);
urlsData.value = []; urlsData.value = [];
} }
// 2. 再检查当前登录用户(如果 session 还在,会自动登录)
if (!userStore.checkedLogin) {
await userStore.fetchCurrentUser(); // 调 /user/me看 session 里有没有登录
}
}); });
// 判断是否为当天比赛(无论状态如何)
const isTodayGame = (game) => { const isTodayGame = (game) => {
// 获取今天的日期(北京时间) if (!game || !game.startDate) return false;
const today = new Date();
const todayStr = `${today.getFullYear()}-${(today.getMonth() + 1)
.toString()
.padStart(2, "0")}-${today.getDate().toString().padStart(2, "0")}`;
// 直接比较 startDate已经是北京时间 const gameDate = new Date(game.startDate);
return game.startDate === todayStr; if (isNaN(gameDate.getTime())) return false;
const today = new Date();
return (
gameDate.getFullYear() === today.getFullYear() &&
gameDate.getMonth() === today.getMonth() &&
gameDate.getDate() === today.getDate()
);
}; };
// 检查比赛是否有直播流 const normalizeGameId = (id) => {
if (id == null) return "";
// 转成字符串,左侧补 0 到 10 位,跟 /api/urls 里的 "0022500255" 对齐
return String(id).padStart(10, "0");
};
const hasLiveStreams = (gameId) => { const hasLiveStreams = (gameId) => {
if (!urlsData.value || !gameId) return false; if (!urlsData.value || !gameId) return false;
const id = normalizeGameId(gameId);
// 遍历所有直播流数据
for (const streamGroup of urlsData.value) { for (const streamGroup of urlsData.value) {
if (streamGroup[gameId]) { if (streamGroup[id]) {
return true; return true;
} }
} }
return false; return false;
}; };
// 获取比赛的直播流
const getLiveStreams = (gameId) => { const getLiveStreams = (gameId) => {
const id = String(gameId); // 转为字符串 const id = normalizeGameId(gameId);
for (const streamGroup of urlsData.value) { for (const streamGroup of urlsData.value) {
if (streamGroup[id]) return streamGroup[id]; if (streamGroup[id]) {
return streamGroup[id];
}
} }
return []; return [];
}; };
// 获取流名称
const getStreamName = (type) => { const getStreamName = (type) => {
const names = { const names = {
tx: "企鹅体育", tx: "企鹅超清",
wl: "纬来体育", wl: "纬来体育",
nba: "高清原声", nba: "高清原声",
mg: "咪咕体育", mg: "咪咕高清",
zb: "高清直播", zb: "高清直播",
}; };
return names[type] || type; return names[type] || type;
}; };
// 跳转到直播页面 const openAuth = () => {
const goToLive = (game, stream) => { showAuth.value = true;
// 准备比赛数据 authMode.value = "login";
};
const handleAuthSubmit = async (payload) => {
if (payload.mode === "register") {
try {
const msg = await userStore.register({
email: payload.email,
password: payload.password,
code: payload.code,
});
alert(msg || "注册成功");
showAuth.value = false;
} catch (e) {
alert("注册失败,服务出现异常联系管理员");
}
} else if (payload.mode === "reset") {
try {
const msg = await userStore.changePassword({
email: payload.email,
password: payload.password,
code: payload.code,
});
alert(msg || "密码修改成功");
showAuth.value = false;
} catch (e) {
alert(e.message || "修改密码失败,服务出现异常联系管理员");
}
} else {
try {
await userStore.login({
email: payload.email,
password: payload.password,
});
alert("登录成功");
showAuth.value = false;
} catch (e) {
alert(e.message || "登录失败,服务出现异常联系管理员");
}
}
};
const handleSendCode = ({ email, mode }) => {
const type = mode === "reset" ? 2 : 1;
sendRegisterCode(email, type)
.then((res) => {
alert(res)
// alert("验证码已发送,因邮件有延迟 请不要频繁发送");
})
.catch(() => {
alert("服务出现异常,请联系管理员");
});
};
const logout = async () => {
try {
await userStore.logout(); // 调 /user/logout + 清空 user
alert("已退出登录");
} catch (e) {
alert("退出登录异常,但本地已清空登录状态");
}
};
const handleSubscribe = (game) => {
if (!isLoggedIn.value) {
alert("订阅功能需登录使用");
// openAuth(); //(可选)如果你想自动弹出登录弹窗
return;
}
// 登录了
alert("订阅功能等待开放");
return;
// 未来真正开放订阅后,在这里写接口逻辑:
// console.log("订阅比赛:", game.gameId);
};
const goToLive = async (game, stream) => {
if (!game?.gameId || !stream?.type) return;
// 未登录用户提示请先登录
if (!isLoggedIn.value) {
alert("请先登录后再观看直播");
openAuth();
return;
}
const gameData = { const gameData = {
homeTeam: { homeTeam: {
name: game.homeTeamName, name: game.homeTeamName,
logo: game.homeTeamLogoDark, logo: game.homeTeamLogoDark,
city: game.homeTeamCity, city: game.homeTeamCity,
record: `${game.homeTeamWins}-${game.homeTeamLosses}`, record: `${game.homeTeamWins}${game.homeTeamLosses}`,
}, },
awayTeam: { awayTeam: {
name: game.awayTeamName, name: game.awayTeamName,
logo: game.awayTeamLogoDark, logo: game.awayTeamLogoDark,
city: game.awayTeamCity, city: game.awayTeamCity,
record: `${game.awayTeamWins}-${game.awayTeamLosses}`, record: `${game.awayTeamWins}${game.awayTeamLosses}`,
}, },
gameInfo: { gameInfo: {
arena: game.arenaName, arena: game.arenaName,
@@ -261,20 +410,26 @@ const goToLive = (game, stream) => {
}, },
}; };
// 存储到Pinia try {
const liveResp = await fetchLiveUrl(game.gameId, stream.type);
const url =
liveResp?.url ||
liveResp?.data?.url ||
liveResp?.data?.data ||
(typeof liveResp === "string" ? liveResp : null);
gameStore.setCurrentGame({ gameStore.setCurrentGame({
gameData, gameData,
currentStream: stream, gameId: game.gameId,
currentStream: { ...stream, url },
allStreams: getLiveStreams(game.gameId), allStreams: getLiveStreams(game.gameId),
}); });
// 导航到播放页
router.push({ router.push({
name: "Play", name: "Play",
params: { params: { gameId: game.gameId },
gameId: game.gameId,
},
}); });
} catch (err) {
console.error("获取直播地址失败:", err);
}
}; };
const props = defineProps({ const props = defineProps({
@@ -294,14 +449,10 @@ const props = defineProps({
const emit = defineEmits(["dateChange"]); const emit = defineEmits(["dateChange"]);
// 当前显示日期(带星期几)
const currentDisplayDate = computed(() => { const currentDisplayDate = computed(() => {
if (!props.scheduleData?.data?.start) return "加载中..."; if (!props.scheduleData?.data?.start) return "加载中...";
const dateStr = props.scheduleData.data.start; const dateStr = props.scheduleData.data.start;
const date = new Date(dateStr); const date = new Date(dateStr);
// 星期几的中文名称
const weekdays = [ const weekdays = [
"星期日", "星期日",
"星期一", "星期一",
@@ -312,8 +463,6 @@ const currentDisplayDate = computed(() => {
"星期六", "星期六",
]; ];
const weekday = weekdays[date.getDay()]; const weekday = weekdays[date.getDay()];
// 格式化日期为 YYYY年MM月DD日
const year = date.getFullYear(); const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0"); const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0"); const day = date.getDate().toString().padStart(2, "0");
@@ -321,7 +470,6 @@ const currentDisplayDate = computed(() => {
return `${year}${month}${day}${weekday}`; return `${year}${month}${day}${weekday}`;
}); });
// 格式化比赛时间
const formatGameTime = (utcTime) => { const formatGameTime = (utcTime) => {
if (!utcTime) return "时间待定"; if (!utcTime) return "时间待定";
const date = new Date(utcTime); const date = new Date(utcTime);
@@ -330,57 +478,60 @@ const formatGameTime = (utcTime) => {
return `${hours}:${minutes}`; return `${hours}:${minutes}`;
}; };
// 获取比赛状态对应的样式类
const getStatusClass = (status) => { const getStatusClass = (status) => {
switch (status) { switch (status) {
case 1: case 1:
return "not-started"; // 未开始 return "not-started";
case 2: case 2:
return "in-progress"; // 进行中 return "in-progress";
case 3: case 3:
return "finished"; // 已结束 return "finished";
default: default:
return "not-started"; return "not-started";
} }
}; };
// 切换日期
const changeDate = (direction) => { const changeDate = (direction) => {
if (!props.scheduleData?.data) return; if (!props.scheduleData?.data) return;
const date = const date =
direction === "pre" direction === "pre"
? props.scheduleData.data.preDate ? props.scheduleData.data.preDate
: props.scheduleData.data.nextDate; : props.scheduleData.data.nextDate;
if (date) { if (date) {
emit("dateChange", date); emit("dateChange", date);
} }
}; };
// 判断某支球队是否是胜者
const isWinner = (game, teamType) => { const isWinner = (game, teamType) => {
// 如果比赛未结束,没有胜者
if (game.status !== 3) return false; if (game.status !== 3) return false;
// 比较比分
if (teamType === "away") { if (teamType === "away") {
return game.awayTeamScore > game.homeTeamScore; return game.awayTeamScore > game.homeTeamScore;
} else { } else {
return game.homeTeamScore > game.awayTeamScore; return game.homeTeamScore > game.awayTeamScore;
} }
}; };
const closeZfb = () => {
showZfb.value = false;
};
</script> </script>
<style scoped> <style scoped>
.subscribe-btn {
cursor: pointer;
}
.subscribe-btn:hover {
opacity: 0.8;
}
.nba-schedule-container { .nba-schedule-container {
max-width: 1000px; max-width: 1000px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 10px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif; "Helvetica Neue", Arial, sans-serif;
} }
/* 添加未开播样式居中显示 */
.no-live { .no-live {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -393,40 +544,36 @@ const isWinner = (game, teamType) => {
text-align: center; text-align: center;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
margin: 0 auto;
} }
/* 赞助商样式 */
.sponsor-banner { .sponsor-banner {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 30px; margin-bottom: 10px;
padding: 12px;
background-color: #f8f9fa; background-color: #f8f9fa;
border-radius: 8px; border-radius: 8px;
border: 1px solid #e9ecef; border: 1px solid #e9ecef;
} }
.sponsor-banner h3,
.sponsor-logo { .sponsor-banner h4 {
height: 36px; margin: 5px;
margin-left: 12px;
} }
/* 日期导航样式 */
.date-navigation { .date-navigation {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 30px; margin-bottom: 10px;
padding-bottom: 20px; padding-bottom: 10px;
border-bottom: 1px solid #e9ecef; border-bottom: 1px solid #e9ecef;
} }
.current-date { .current-date {
font-size: 22px; font-size: 22px;
font-weight: 600; font-weight: 600;
color: #1d428a; /* NBA 蓝色 */ color: #1b1f27;
} }
.nav-button { .nav-button {
@@ -453,14 +600,12 @@ const isWinner = (game, teamType) => {
font-weight: bold; font-weight: bold;
} }
/* 比赛列表 - 每行一场比赛 */
.games-list { .games-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 25px; gap: 25px;
} }
/* 比赛卡片样式 - 大气风格 */
.game-card { .game-card {
border: 1px solid #e9ecef; border: 1px solid #e9ecef;
border-radius: 10px; border-radius: 10px;
@@ -474,7 +619,6 @@ const isWinner = (game, teamType) => {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
} }
/* 比赛时间 */
.game-time { .game-time {
padding: 12px 20px; padding: 12px 20px;
font-weight: 600; font-weight: 600;
@@ -483,23 +627,21 @@ const isWinner = (game, teamType) => {
} }
.not-started { .not-started {
background-color: #6c757d; /* 灰色 - 未开始 */ background-color: #6c757d;
} }
.in-progress { .in-progress {
background-color: #dc3545; /* 红色 - 进行中 */ background-color: #dc3545;
} }
.finished { .finished {
background-color: #28a745; /* 绿色 - 已结束 */ background-color: #3dbe5b;
} }
/* 比赛主要内容 */
.game-main { .game-main {
padding: 20px; padding: 20px;
} }
/* 球队样式 */
.team { .team {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -551,12 +693,10 @@ const isWinner = (game, teamType) => {
color: #212529; color: #212529;
} }
/* 待定球队样式 */
.tbd-team { .tbd-team {
opacity: 0.7; opacity: 0.7;
} }
/* 比赛状态 */
.game-status { .game-status {
padding: 12px 0; padding: 12px 0;
text-align: center; text-align: center;
@@ -566,8 +706,8 @@ const isWinner = (game, teamType) => {
} }
.game-not-started { .game-not-started {
color: #5a7cec; color: #0008ff;
font-size: 18px; font-size: 19px;
} }
.game-in-progress { .game-in-progress {
@@ -588,12 +728,10 @@ const isWinner = (game, teamType) => {
color: #6c757d; color: #6c757d;
} }
/* 主队样式 */
.home-team { .home-team {
border-top: 1px solid #f1f3f5; border-top: 1px solid #f1f3f5;
} }
/* 比赛页脚 */
.game-footer { .game-footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -614,7 +752,6 @@ const isWinner = (game, teamType) => {
color: #1d428a; color: #1d428a;
} }
/* 响应式设计 */
@media (max-width: 768px) { @media (max-width: 768px) {
.game-card { .game-card {
margin: 0 10px; margin: 0 10px;
@@ -672,10 +809,11 @@ const isWinner = (game, teamType) => {
} }
} }
/* 新增直播间按钮样式 */
.live-buttons { .live-buttons {
display: flex; display: flex;
gap: 15px; gap: 15px;
justify-content: center;
align-items: center;
padding: 15px 20px; padding: 15px 20px;
border-top: 1px solid #f1f3f5; border-top: 1px solid #f1f3f5;
border-bottom: 1px solid #f1f3f5; border-bottom: 1px solid #f1f3f5;
@@ -719,7 +857,6 @@ const isWinner = (game, teamType) => {
font-size: 18px; font-size: 18px;
} }
/* 响应式调整 */
@media (max-width: 768px) { @media (max-width: 768px) {
.live-buttons { .live-buttons {
flex-direction: column; flex-direction: column;
@@ -730,6 +867,7 @@ const isWinner = (game, teamType) => {
padding: 10px; padding: 10px;
} }
} }
.no-games-message { .no-games-message {
text-align: center; text-align: center;
padding: 40px; padding: 40px;
@@ -739,20 +877,144 @@ const isWinner = (game, teamType) => {
border-radius: 8px; border-radius: 8px;
margin: 20px 0; margin: 20px 0;
} }
/* 胜者背景色 */
.team.winner { .team.winner {
background-color: rgba(76, 175, 80, 0.1); /* 浅绿色背景 */ background-color: rgba(183, 239, 167, 0.898);
border-left: 3px solid #74fd79; /* 左侧绿色边框 */ border-left: 3px solid #a4b1ee;
} }
/* 如果希望更明显的效果,可以调整样式 */
.team.winner .team-name { .team.winner .team-name {
font-weight: bold; font-weight: bold;
color: #2e7d32; /* 深绿色文字 */ color: #d4e66a;
} }
.team.winner .team-score { .team.winner .team-score {
font-weight: bold; font-weight: bold;
color: #2e7d32; /* 深绿色比分 */ color: #68db81;
}
.top {
display: flex;
padding: 5px;
justify-content: center;
align-items: center;
}
.login {
font-size: 20px;
font-weight: 500;
}
.login-btn {
padding: 6px 14px;
border: 1px solid #1d428a;
background: #1d428a;
color: #fff;
border-radius: 6px;
cursor: pointer;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
}
.user-email {
color: #1d428a;
font-weight: 700;
}
.logout-btn {
padding: 6px 10px;
border: 1px solid #e74c3c;
background: #e74c3c;
color: #fff;
border-radius: 6px;
cursor: pointer;
}
a {
text-decoration: none;
color: #0011ff;
font-size: 15px;
}
.ad{
margin: 10px 0;
padding: 15px;
background-color: #f8f9fa;
/* border: 1px solid #79d0e4; */
border-radius: 6px;
text-align: center;
}
@media (max-width: 768px){
.zfb{
z-index: 9;
position: fixed;
img{
width: auto;
height: 100%;
}
}
}
.zfb{
position: fixed;
width: auto;
height: auto;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
img{
width: auto;
height: 400px;
border-radius: 8px;
}
.zfb-text {
margin: 10px 0 5px 0;
font-size: 16px;
font-weight: bold;
color: #fff;
background: rgba(0, 0, 0, 0.8);
padding: 8px 12px;
border-radius: 6px;
text-align: center;
}
.close-btn {
margin-top: 5px;
background: rgba(220, 53, 69, 0.9);
color: white;
border: 2px solid white;
border-radius: 6px;
width: auto;
height: auto;
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
}
/* PC端右下角稍微上面一点 */
@media (min-width: 769px) {
.zfb {
bottom: 20px;
right: 20px;
top: auto;
left: auto;
}
}
/* 移动端:居中 */
@media (max-width: 768px){
.zfb{
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
right: auto;
bottom: auto;
}
} }
</style> </style>

View File

@@ -4,9 +4,127 @@ import 'element-plus/dist/index.css'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import { createApp } from 'vue' import { createApp } from 'vue'
import App from './App.vue' import App from './App.vue'
import { useUserStore } from '@/stores/user'
const app = createApp(App) const app = createApp(App)
const pinia = createPinia()
app.use(router) app.use(router)
app.use(ElementPlus) app.use(ElementPlus)
app.use(createPinia()) app.use(pinia)
// 应用启动时尝试根据后端 session 恢复登录态(需要后端正确返回 Set-Cookie
const userStore = useUserStore(pinia)
userStore.fetchCurrentUser().catch(() => {
// 静默失败,维持未登录状态
})
app.mount('#app') app.mount('#app')
// ------------------ DevTools 防护(弱保护,易被绕过) ------------------
// 说明:浏览器端无法绝对禁止 DevTools以下脚本只是拦截常见快捷键并检测 DevTools 打开,
// 在检测到时展示一个覆盖提示,增加逆向门槛,但有经验者仍可绕过。
// 拦截常见的打开 DevTools / 查看源码 的快捷键
// function preventDevShortcuts(e) {
// // F12
// if (e.keyCode === 123) {
// e.preventDefault()
// return false
// }
// // Ctrl+Shift+I / Ctrl+Shift+J / Ctrl+U / Ctrl+S
// if (e.ctrlKey && e.shiftKey && (e.key === 'I' || e.key === 'i' || e.key === 'J' || e.key === 'j')) {
// e.preventDefault()
// return false
// }
// if (e.ctrlKey && (e.key === 'U' || e.key === 'u' || e.key === 'S' || e.key === 's')) {
// e.preventDefault()
// return false
// }
// }
// // 禁用右键菜单(可选)
// function preventContextMenu(e) {
// // 如果你不想全局禁止右键,可以把这个函数内的逻辑改为更精确的判断
// // e.preventDefault()
// }
// // 检测 DevTools 是否打开(基于窗口尺寸差异的启发式方法)
// function createDevToolsDetector(onOpen, onClose) {
// let devtoolsOpen = false
// const threshold = 160 // 阈值,视浏览器和平台可调整
// function scan() {
// try {
// const widthDiff = window.outerWidth - window.innerWidth
// const heightDiff = window.outerHeight - window.innerHeight
// const currentlyOpen = widthDiff > threshold || heightDiff > threshold
// if (currentlyOpen && !devtoolsOpen) {
// devtoolsOpen = true
// onOpen && onOpen()
// } else if (!currentlyOpen && devtoolsOpen) {
// devtoolsOpen = false
// onClose && onClose()
// }
// } catch (e) {
// // 忽略
// }
// }
// const id = setInterval(scan, 800)
// // 返回停止检测的函数
// return () => clearInterval(id)
// }
// // 显示覆盖提示(简单实现)
// function showOverlayMessage() {
// debugger
// if (document.getElementById('__devtools_block_overlay')) return
// const o = document.createElement('div')
// o.id = '__devtools_block_overlay'
// Object.assign(o.style, {
// position: 'fixed',
// inset: '0',
// background: 'rgba(0,0,0,0.7)',
// color: '#fff',
// display: 'flex',
// alignItems: 'center',
// justifyContent: 'center',
// zIndex: '999999',
// textAlign: 'center',
// padding: '20px'
// })
// o.innerHTML = `<div style="max-width:560px"><h2 style="margin:0 0 12px;font-size:20px">检测到开发者工具</h2><p style="margin:0 0 18px;color:#ddd">为保护内容,当前页面在检测到开发者工具打开时被屏蔽。若要继续,请刷新页面或关闭开发者工具。</p><button id="__devtools_block_close" style="padding:10px 16px;border-radius:6px;border:none;background:#1890ff;color:#fff;font-weight:600;cursor:pointer">刷新页面</button></div>`
// document.body.appendChild(o)
// const btn = document.getElementById('__devtools_block_close')
// if (btn) btn.addEventListener('click', () => location.reload())
// }
// function removeOverlayMessage() {
// const el = document.getElementById('__devtools_block_overlay')
// if (el) el.remove()
// }
// // 启动防护(放在监听器里以避免 SSR 问题)
// if (typeof window !== 'undefined') {
// window.addEventListener('keydown', preventDevShortcuts, { capture: true })
// window.addEventListener('contextmenu', preventContextMenu, { capture: true })
// const stopDetector = createDevToolsDetector(() => {
// // 打开时触发:显示覆盖提示
// try { showOverlayMessage() } catch (e) {}
// }, () => {
// // 关闭时触发:移除覆盖
// try { removeOverlayMessage() } catch (e) {}
// })
// // 可选:在页面卸载时清理
// window.addEventListener('beforeunload', () => {
// window.removeEventListener('keydown', preventDevShortcuts, { capture: true })
// window.removeEventListener('contextmenu', preventContextMenu, { capture: true })
// stopDetector()
// })
// }
// ------------------ End DevTools 防护 ------------------

View File

@@ -11,23 +11,39 @@ const routes = [
path: '/', path: '/',
name: 'Index', name: 'Index',
component: IndexVue, component: IndexVue,
props: route => ({ query: route.query }) props: route => ({ query: route.query }),
meta: {
title: '首页 - NBA 在线观看与赛程',
description: '首页 - 提供最新的NBA赛程、比赛直播入口与集锦在线观看NBA赛事。'
}
}, },
{ {
path: '/play/:gameId', path: '/play/:gameId',
name: 'Play', name: 'Play',
component: () => import('@/views/Play.vue'), component: () => import('@/views/Play.vue'),
props: true // 启用props接收路由参数 props: true, // 启用props接收路由参数
meta: {
title: '比赛直播 - NBA 在线观看',
description: '在线观看赛事直播,进入播放页观看指定比赛的实时直播与回放。'
}
}, },
{ {
path: '/lives', path: '/lives',
name: 'Admin', name: 'Admin',
component: AdminVue, component: AdminVue,
meta: {
title: '直播列表 - NBA 在线观看',
description: '直播列表 - 列出当前正在进行或即将进行的NBA比赛直播。'
}
}, },
{ {
path: '/test', path: '/test',
name: 'Test', name: 'Test',
component: TestVue, component: TestVue,
meta: {
title: '测试页面',
description: '测试页面 - 开发与测试用途。'
}
}, },
// 添加通配符路由,捕获所有未匹配的路径 // 添加通配符路由,捕获所有未匹配的路径
{ {
@@ -42,7 +58,7 @@ const router = createRouter({
}) })
// 添加全局路由守卫 // 添加全局路由守卫
router.beforeEach((to, from) => { router.beforeEach(async (to, from) => {
const gameStore = useGameStore() const gameStore = useGameStore()
// 离开播放页时清理数据 // 离开播放页时清理数据
@@ -54,6 +70,70 @@ router.beforeEach((to, from) => {
if (to.name === 'Play' && !gameStore.currentGame) { if (to.name === 'Play' && !gameStore.currentGame) {
return '/' // 无数据则重定向到首页 return '/' // 无数据则重定向到首页
} }
// 进入 Admin 页面时检查认证
if (to.name === 'Admin') {
const storedPassword = localStorage.getItem('password')
// 如果已保存正确密码,则允许进入
if (storedPassword && storedPassword === 'inspur123') {
return true
}
// 需要输入密码
const password = prompt('请输入密码:')
// 用户取消或输入为空
if (password === null || password === '') {
return false // 阻止进入,留在当前页面
}
// 验证密码
try {
const { go } = await import('@/api/nba.js')
const response = await go(password)
if (response) {
localStorage.setItem('password', password)
return true // 允许进入
} else {
alert('密码错误')
return false // 密码错误,阻止进入
}
} catch (error) {
console.error('认证失败:', error)
return false // 认证失败,阻止进入
}
}
})
// 在导航完成后设置页面标题与 description方便 SPA 被搜索引擎抓取时提供合适的 meta
router.afterEach((to) => {
try {
const defaultTitle = 'NBA在线观看NBA免费直播'
const title = (to.meta && to.meta.title) ? to.meta.title : defaultTitle
document.title = title
// 更新 meta description
const descContent = (to.meta && to.meta.description) ? to.meta.description : document.querySelector('meta[name="description"]')?.getAttribute('content') || ''
let desc = document.querySelector('meta[name="description"]')
if (!desc) {
desc = document.createElement('meta')
desc.setAttribute('name', 'description')
document.head.appendChild(desc)
}
desc.setAttribute('content', descContent)
// 更新 canonical 为当前页面(相对 origin
const canonical = document.querySelector('link[rel="canonical"]') || document.createElement('link')
canonical.setAttribute('rel', 'canonical')
// 使用 location.origin 作为域名,当部署后会替换为真实域名
canonical.setAttribute('href', location.origin + to.fullPath)
if (!document.querySelector('link[rel="canonical"]')) document.head.appendChild(canonical)
} catch (e) {
// 不阻塞路由,仅记录错误
// console.warn('更新 meta 失败', e)
}
}) })
export default router export default router

View File

@@ -2,37 +2,38 @@ import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
export const useGameStore = defineStore('game', () => { export const useGameStore = defineStore('game', () => {
// 状态
const _currentGame = ref(null) const _currentGame = ref(null)
const _currentStream = ref(null) const _currentStream = ref(null)
const _allStreams = ref([]) const _allStreams = ref([])
const _gameId = ref(null)
// 计算属性(保持响应式)
const currentGame = computed(() => _currentGame.value) const currentGame = computed(() => _currentGame.value)
const allStreams = computed(() => _allStreams.value) const allStreams = computed(() => _allStreams.value)
const gameId = computed(() => _gameId.value)
const currentStream = computed({ const currentStream = computed({
get: () => _currentStream.value, get: () => _currentStream.value,
set: (val) => _currentStream.value = val // 确保可写 set: (val) => _currentStream.value = val
}) })
// 设置当前比赛
const setCurrentGame = (data) => { const setCurrentGame = (data) => {
_currentGame.value = data.gameData _currentGame.value = data.gameData
_currentStream.value = data.currentStream _currentStream.value = data.currentStream
_allStreams.value = data.allStreams _allStreams.value = data.allStreams
_gameId.value = data.gameId
} }
// 清除数据
const clearGameData = () => { const clearGameData = () => {
_currentGame.value = null _currentGame.value = null
_currentStream.value = null _currentStream.value = null
_allStreams.value = [] _allStreams.value = []
_gameId.value = null
} }
return { return {
currentGame, currentGame,
currentStream, currentStream,
allStreams, allStreams,
gameId,
setCurrentGame, setCurrentGame,
clearGameData clearGameData
} }

86
src/stores/user.js Normal file
View File

@@ -0,0 +1,86 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
loginUser,
registerUser,
getCurrentUser,
logoutUser,
updatePassword,
} from '@/api/userApi' // 就是你前面写的 userApi 那个文件
export const useUserStore = defineStore('user', () => {
// ===== state =====
const user = ref(null) // 当前登录用户信息
const checkedLogin = ref(false) // 是否已经检查过登录状态(用于避免每次路由都打 /me
// ===== getters =====
const isLoggedIn = computed(() => !!user.value)
// ===== actions =====
// 1从后端 session 里取当前用户:/user/me
const fetchCurrentUser = async () => {
try {
const res = await getCurrentUser()
// 假设未登录时后端返回字符串 "未登录",登录时返回 NbaUser 对象
if (typeof res === 'string' && res === '未登录') {
user.value = null
} else {
user.value = res
}
} catch (e) {
user.value = null
} finally {
checkedLogin.value = true
}
}
// 2登录调 /user/login再调 /user/me
const login = async ({ email, password }) => {
const msg = await loginUser({ email, password }).catch((e) => {
throw new Error("服务异常,请联系管理员")
})
if (msg !== '登录成功') {
throw new Error("登录失败,请检查邮箱或密码")
}
// 登录成功后后端已经通过 Set-Cookie 写入 JSESSIONID
// 这里再查一次当前用户,保存到 Pinia
await fetchCurrentUser()
}
// 3注册看你需求注册后是不是自动登录
const register = async ({ email, password, code, username = '' }) => {
const msg = await registerUser({ email, password, code, username })
return msg
}
// 3.5)修改密码:带邮箱验证码
const changePassword = async ({ email, password, code }) => {
const msg = await updatePassword({ email, password, code }).catch(() => {
throw new Error('服务异常,请联系管理员')
})
return msg
}
// 4退出登录
const logout = async () => {
try {
await logoutUser()
} catch (e) {
// 即使后端报错,前端也要清掉状态
} finally {
user.value = null
checkedLogin.value = false
}
}
return {
user,
checkedLogin,
isLoggedIn,
fetchCurrentUser,
login,
register,
changePassword,
logout,
}
})

View File

@@ -0,0 +1,625 @@
<template>
<div class="games-container">
<h2>NBA赛事列表</h2>
<div class="game-list">
<div v-for="game in gamesData" :key="game.id" class="game-item">
<div class="game-info">
<span class="game-date">{{ formatDate(game.date) }}</span>
<span class="game-id">ID: {{ game.gameId }}</span>
<div class="teams">
<div class="team">
<img :src="game.homeTeamLogoDark" :alt="game.homeTeamName" class="team-logo" />
<span class="team-name">{{ game.homeTeamName }}</span>
</div>
<span class="vs">VS</span>
<div class="team">
<img :src="game.awayTeamLogoDark" :alt="game.awayTeamName" class="team-logo" />
<span class="team-name">{{ game.awayTeamName }}</span>
</div>
</div>
<span class="start-time">{{ formatTime(game.startTime) }}</span>
<!-- 直播链接区域 -->
<div v-if="getGameUrls(game.gameId).length > 0" class="urls-container">
<div v-for="(url, index) in getGameUrls(game.gameId)" :key="index" class="url-item">
<span class="url-type">{{ formatUrlType(url.type) }}:</span>
<span class="truncated-url" @click="showFullUrl(url.url)">
{{ truncateUrl(url.url) }}
</span>
<button class="check-btn" @click.stop="showFullUrl(url.url)">
<i class="el-icon-view"></i> 查看
</button>
<button class="delete-btn" @click.stop="deleteUrl(url.id)">
<i class="el-icon-delete"></i> 删除
</button>
</div>
</div>
<div v-else class="no-urls">暂无直播链接</div>
</div>
<button class="add-url-btn" @click="openAddUrlDialog(game)">
添加直播
</button>
</div>
</div>
<!-- URL详情弹窗 -->
<el-dialog v-model="urlDialogVisible" title="直播链接详情" width="50%" center>
<div class="url-dialog-content">
<div class="full-url">{{ currentUrl }}</div>
<el-button type="primary" @click="copyUrl(currentUrl)">复制链接</el-button>
</div>
</el-dialog>
<!-- 添加直播URL对话框 -->
<div v-if="showDialog" class="dialog-overlay">
<div class="dialog-content">
<h3> {{ selectedGame.homeTeamName }} VS {{ selectedGame.awayTeamName }} 添加直播URL</h3>
<div class="url-inputs">
<div v-for="(url, index) in newUrls" :key="index" class="url-input-item">
<div class="form-group">
<label>直播类型 {{ index + 1 }}:</label>
<select v-model="url.type" class="url-type-select">
<option value="tx">腾讯体育</option>
<option value="mg">咪咕视频</option>
<option value="wl">纬来体育</option>
<option value="nba">NBA原声</option>
<option value="zb">其他平台</option>
</select>
</div>
<div class="form-group">
<label>直播地址 {{ index + 1 }}:</label>
<input
v-model="url.url"
type="text"
placeholder="请输入完整的直播URL"
class="url-input"
/>
</div>
<button v-if="newUrls.length > 1" class="remove-btn" @click="removeUrl(index)">
<i class="el-icon-remove"></i> 删除
</button>
</div>
</div>
<button class="add-more-btn" @click="addMoreUrl">
<i class="el-icon-circle-plus"></i> 添加更多直播源
</button>
<div class="dialog-footer">
<button class="cancel-btn" @click="closeDialog">取消</button>
<button class="confirm-btn" @click="submitUrls" :disabled="isSubmitting">
<span v-if="!isSubmitting">确认添加</span>
<span v-else>正在提交...</span>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { ElMessage, ElLoading } from "element-plus";
import { games, urls, addUrls as apiAddUrls, go, deleteUrlById } from "@/api/nba";
// 响应式状态
const gamesData = ref([]);
const urlData = ref([]);
const showDialog = ref(false);
const selectedGame = ref(null);
const newUrls = ref([{ type: "tx", url: "" }]);
const isSubmitting = ref(false);
const urlDialogVisible = ref(false);
const currentUrl = ref('');
// 初始化数据
const initData = async () => {
try {
const [urlsRes, gamesRes] = await Promise.all([urls(), games()]);
urlData.value = urlsRes || [];
gamesData.value = gamesRes || [];
} catch (err) {
console.error("获取数据失败:", err);
ElMessage.error("获取比赛数据失败,请刷新重试");
}
};
// URL处理函数
const truncateUrl = (url) => {
try {
const urlObj = new URL(url);
return `${urlObj.hostname}${urlObj.pathname.substring(0, 20)}...`;
} catch {
return url.length > 30 ? `${url.substring(0, 30)}...` : url;
}
};
const showFullUrl = (url) => {
currentUrl.value = url;
urlDialogVisible.value = true;
};
const copyUrl = (url) => {
navigator.clipboard.writeText(url)
.then(() => {
ElMessage.success('链接已复制');
urlDialogVisible.value = false;
})
.catch(() => ElMessage.error('复制失败'));
};
// 删除URL
const deleteUrl = async (id) => {
let loading = null;
try {
const confirm = window.confirm('确定要删除这个直播链接吗?');
if (!confirm) return;
loading = ElLoading.service({
lock: true,
text: "正在删除直播链接...",
background: "rgba(0, 0, 0, 0.7)",
});
await deleteUrlById(id);
urlData.value = urlData.value.map(gameUrl => {
const gameId = Object.keys(gameUrl)[0];
return {
[gameId]: gameUrl[gameId].filter(url => url.id !== id)
};
});
ElMessage.success('直播链接删除成功');
} catch (error) {
console.error('删除直播链接失败:', error);
ElMessage.error(`删除失败: ${error.message || "服务器错误"}`);
} finally {
loading?.close();
}
};
// 添加URL相关函数
const openAddUrlDialog = (game) => {
selectedGame.value = game;
newUrls.value = [{ type: "tx", url: "" }];
showDialog.value = true;
};
const closeDialog = () => {
showDialog.value = false;
};
const addMoreUrl = () => {
newUrls.value.push({ type: "tx", url: "" });
};
const removeUrl = (index) => {
newUrls.value.splice(index, 1);
};
const isValidUrl = (url) => {
try {
new URL(url);
return true;
} catch {
return false;
}
};
const submitUrls = async () => {
const validUrls = newUrls.value
.filter(item => item.url.trim() !== "")
.map(item => ({
type: item.type,
url: item.url.trim(),
}));
if (validUrls.length === 0) {
ElMessage.warning("请至少输入一个有效的直播URL");
return;
}
for (const url of validUrls) {
if (!isValidUrl(url.url)) {
ElMessage.warning(`直播地址格式不正确: ${url.url}`);
return;
}
}
isSubmitting.value = true;
const loading = ElLoading.service({
lock: true,
text: "正在提交直播链接...",
background: "rgba(0, 0, 0, 0.7)",
});
try {
const response = await apiAddUrls(selectedGame.value.gameId, validUrls);
updateLocalUrls(selectedGame.value.gameId, response.data || validUrls);
ElMessage.success("直播链接添加成功!");
closeDialog();
} catch (error) {
console.error("添加直播URL失败:", error);
ElMessage.error(`添加失败: ${error.message || "服务器错误"}`);
} finally {
loading.close();
isSubmitting.value = false;
}
};
const updateLocalUrls = (gameId, urlsToAdd) => {
const newUrlData = [...urlData.value];
const existingIndex = newUrlData.findIndex(item => Object.keys(item)[0] === gameId.toString());
if (existingIndex >= 0) {
const existingItem = { ...newUrlData[existingIndex] };
existingItem[gameId] = [...existingItem[gameId], ...urlsToAdd];
newUrlData[existingIndex] = existingItem;
} else {
newUrlData.push({ [gameId]: urlsToAdd });
}
urlData.value = newUrlData;
};
// 工具函数
const getGameUrls = (gameId) => {
const gameUrls = urlData.value.find(item => item[gameId]);
return gameUrls ? gameUrls[gameId] : [];
};
const formatUrlType = (type) => {
const typeMap = {
tx: "腾讯", wl: "纬来", mg: "咪咕",
nba: "原声", zb: "其他", qq: "腾讯", wx: "微信"
};
return typeMap[type] || type;
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return `${date.getMonth() + 1}${date.getDate()}`;
};
const formatTime = (timeString) => {
const time = new Date(timeString);
return `${time.getHours()}:${time.getMinutes().toString().padStart(2, "0")}`;
};
// 生命周期钩子
onMounted(() => {
initData();
const storedPassword = localStorage.getItem("password");
if (storedPassword && storedPassword === "inspur123") return;
const password = prompt("请输入密码:");
go(password)
.then(response => {
if (response) {
localStorage.setItem("password", password);
} else {
window.location.href = "/";
}
})
.catch(() => window.location.href = "/");
});
</script>
<style lang="scss" scoped>
.games-container {
padding: 20px;
max-width: 1000px;
margin: 0 auto;
h2 {
text-align: center;
margin-bottom: 20px;
color: #333;
}
}
.game-list {
display: flex;
flex-direction: column;
gap: 15px;
}
.game-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
background-color: #f5f5f5;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.game-info {
display: flex;
flex-direction: column;
gap: 10px;
flex-grow: 1;
.game-date, .game-id, .start-time {
font-size: 14px;
color: #666;
}
}
.teams {
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
margin: 10px 0;
.vs {
font-weight: bold;
color: #e63946;
}
}
.team {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
.team-logo {
width: 40px;
height: 40px;
object-fit: contain;
}
.team-name {
font-size: 14px;
font-weight: 500;
}
}
.urls-container {
margin-top: 10px;
padding: 10px;
background-color: #f9f9f9;
border-radius: 4px;
.url-item {
display: flex;
align-items: center;
padding: 8px;
margin-bottom: 8px;
background-color: #fff;
border-radius: 4px;
&:hover {
background-color: #f0f0f0;
}
.url-type {
font-weight: bold;
min-width: 50px;
}
.truncated-url {
flex: 1;
color: #409eff;
cursor: pointer;
text-decoration: underline;
margin: 0 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&:hover {
color: #66b1ff;
}
}
.check-btn {
padding: 4px 8px;
background-color: #987eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
&:hover {
background-color: #7d5fff;
}
}
.delete-btn {
margin-left: 8px;
padding: 4px 8px;
background-color: #ff4444;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
&:hover {
background-color: #cc0000;
}
}
}
}
.no-urls {
margin-top: 10px;
color: #999;
font-style: italic;
}
.add-url-btn {
padding: 8px 15px;
background-color: #4caf50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
align-self: flex-start;
&:hover {
background-color: #45a049;
}
}
/* 对话框样式 */
.dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.dialog-content {
background-color: white;
padding: 25px;
border-radius: 8px;
width: 90%;
max-width: 500px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
h3 {
margin-top: 0;
color: #333;
text-align: center;
margin-bottom: 20px;
}
}
.url-inputs {
max-height: 400px;
overflow-y: auto;
margin-bottom: 15px;
}
.url-input-item {
background-color: #f9f9f9;
padding: 15px;
border-radius: 6px;
margin-bottom: 15px;
position: relative;
}
.form-group {
margin-bottom: 15px;
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
input, select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
}
.remove-btn {
position: absolute;
top: 10px;
right: 10px;
background-color: #ff4444;
color: white;
border: none;
border-radius: 4px;
padding: 2px 8px;
cursor: pointer;
font-size: 12px;
&:hover {
background-color: #cc0000;
}
}
.add-more-btn {
width: 100%;
padding: 8px;
background-color: #f5f5f5;
border: 1px dashed #ccc;
border-radius: 4px;
cursor: pointer;
margin-bottom: 15px;
&:hover {
background-color: #e0e0e0;
}
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
.cancel-btn {
padding: 8px 15px;
background-color: #f5f5f5;
color: #333;
border: none;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: #e0e0e0;
}
}
.confirm-btn {
padding: 8px 15px;
background-color: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: #0b7dda;
}
&:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
}
}
/* URL详情弹窗样式 */
.url-dialog-content {
text-align: center;
.full-url {
padding: 10px;
margin: 15px 0;
background-color: #f5f5f5;
border-radius: 4px;
word-break: break-all;
}
.el-button {
margin: 0 10px;
}
}
</style>

View File

@@ -26,12 +26,15 @@
<div v-if="getGameUrls(game.gameId).length > 0" class="urls-container"> <div v-if="getGameUrls(game.gameId).length > 0" class="urls-container">
<div v-for="(url, index) in getGameUrls(game.gameId)" :key="index" class="url-item"> <div v-for="(url, index) in getGameUrls(game.gameId)" :key="index" class="url-item">
<span class="url-type">{{ formatUrlType(url.type) }}:</span> <span class="url-type">{{ formatUrlType(url.type) }}:</span>
<span class="truncated-url" @click="showFullUrl(url.url)"> <span class="truncated-url" @click="showFullUrl(getStreamUrl(url))">
{{ truncateUrl(url.url) }} {{ truncateUrl(getStreamUrl(url)) }}
</span> </span>
<button class="check-btn" @click.stop="showFullUrl(url.url)"> <button class="check-btn" @click.stop="showFullUrl(getStreamUrl(url))">
<i class="el-icon-view"></i> 查看 <i class="el-icon-view"></i> 查看
</button> </button>
<button class="edit-btn" @click.stop="openEditUrlDialog(game.gameId, url)">
<i class="el-icon-edit"></i> 修改
</button>
<button class="delete-btn" @click.stop="deleteUrl(url.id)"> <button class="delete-btn" @click.stop="deleteUrl(url.id)">
<i class="el-icon-delete"></i> 删除 <i class="el-icon-delete"></i> 删除
</button> </button>
@@ -54,6 +57,26 @@
</div> </div>
</el-dialog> </el-dialog>
<el-dialog v-model="editDialogVisible" title="修改直播链接" width="45%" center>
<div class="edit-dialog-content">
<label>直播链接</label>
<textarea
v-model="editUrlValue"
class="edit-input edit-textarea"
placeholder="请输入完整的 m3u8 地址"
rows="4"
></textarea>
<div class="dialog-footer">
<button class="cancel-btn" type="button" @click="closeEditDialog">取消</button>
<button class="confirm-btn" type="button" @click="submitUrlUpdate" :disabled="editSubmitting">
<span v-if="!editSubmitting">保存</span>
<span v-else>保存中...</span>
</button>
</div>
</div>
</el-dialog>
<!-- 添加直播URL对话框 --> <!-- 添加直播URL对话框 -->
<div v-if="showDialog" class="dialog-overlay"> <div v-if="showDialog" class="dialog-overlay">
<div class="dialog-content"> <div class="dialog-content">
@@ -75,9 +98,9 @@
<div class="form-group"> <div class="form-group">
<label>直播地址 {{ index + 1 }}:</label> <label>直播地址 {{ index + 1 }}:</label>
<input <input
v-model="url.url" v-model="url.m3u8_url"
type="text" type="text"
placeholder="请输入完整的直播URL" placeholder="请输入完整的 m3u8 地址"
class="url-input" class="url-input"
/> />
</div> </div>
@@ -107,22 +130,26 @@
<script setup> <script setup>
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { ElMessage, ElLoading } from "element-plus"; import { ElMessage, ElLoading } from "element-plus";
import { games, urls, addUrls as apiAddUrls, go, deleteUrlById } from "@/api/nba"; import { games, urls, addUrls as apiAddUrls, deleteUrlById, updateUrlById } from "@/api/nba";
// 响应式状态 // 响应式状态
const gamesData = ref([]); const gamesData = ref([]);
const urlData = ref([]); const urlData = ref([]);
const showDialog = ref(false); const showDialog = ref(false);
const selectedGame = ref(null); const selectedGame = ref(null);
const newUrls = ref([{ type: "tx", url: "" }]); const newUrls = ref([{ type: "tx", m3u8_url: "" }]);
const isSubmitting = ref(false); const isSubmitting = ref(false);
const urlDialogVisible = ref(false); const urlDialogVisible = ref(false);
const currentUrl = ref(''); const currentUrl = ref('');
const editDialogVisible = ref(false);
const editUrlItem = ref(null);
const editUrlValue = ref('');
const editSubmitting = ref(false);
// 初始化数据 // 初始化数据
const initData = async () => { const initData = async () => {
try { try {
const [urlsRes, gamesRes] = await Promise.all([urls(), games()]); const [urlsRes, gamesRes] = await Promise.all([urls("1"), games()]);
urlData.value = urlsRes || []; urlData.value = urlsRes || [];
gamesData.value = gamesRes || []; gamesData.value = gamesRes || [];
} catch (err) { } catch (err) {
@@ -132,6 +159,10 @@ const initData = async () => {
}; };
// URL处理函数 // URL处理函数
const getStreamUrl = (item) => {
return item?.m3u8_url || item?.url || "";
};
const truncateUrl = (url) => { const truncateUrl = (url) => {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
@@ -155,6 +186,76 @@ const copyUrl = (url) => {
.catch(() => ElMessage.error('复制失败')); .catch(() => ElMessage.error('复制失败'));
}; };
const openEditUrlDialog = (gameId, urlItem) => {
editUrlItem.value = { ...urlItem, gameId };
editUrlValue.value = urlItem?.m3u8_url || urlItem?.url || "";
editDialogVisible.value = true;
};
const closeEditDialog = () => {
editDialogVisible.value = false;
editUrlItem.value = null;
editUrlValue.value = "";
};
const updateLocalUrlItem = (gameId, id, newUrl) => {
urlData.value = urlData.value.map((gameUrl) => {
const key = Object.keys(gameUrl)[0];
if (key !== gameId.toString()) return gameUrl;
const updatedUrls = gameUrl[key].map((item) => {
if (item.id !== id) return item;
return {
...item,
m3u8_url: newUrl,
url: newUrl,
};
});
return { [key]: updatedUrls };
});
};
const submitUrlUpdate = async () => {
if (!editUrlItem.value) return;
const trimmedUrl = editUrlValue.value.trim();
if (!trimmedUrl) {
ElMessage.warning('请输入有效链接');
return;
}
if (!isValidUrl(trimmedUrl)) {
ElMessage.warning('链接格式不正确');
return;
}
editSubmitting.value = true;
const loading = ElLoading.service({
lock: true,
text: '正在修改直播链接...',
background: 'rgba(0, 0, 0, 0.7)',
});
try {
await updateUrlById({
id: editUrlItem.value.id,
url: trimmedUrl,
gameId: editUrlItem.value.gameId,
type: editUrlItem.value.type,
});
updateLocalUrlItem(editUrlItem.value.gameId, editUrlItem.value.id, trimmedUrl);
ElMessage.success('直播链接修改成功');
closeEditDialog();
} catch (error) {
console.error('修改直播URL失败:', error);
ElMessage.error(`修改失败: ${error.message || '服务器错误'}`);
} finally {
loading.close();
editSubmitting.value = false;
}
};
// 删除URL // 删除URL
const deleteUrl = async (id) => { const deleteUrl = async (id) => {
let loading = null; let loading = null;
@@ -190,7 +291,7 @@ const deleteUrl = async (id) => {
// 添加URL相关函数 // 添加URL相关函数
const openAddUrlDialog = (game) => { const openAddUrlDialog = (game) => {
selectedGame.value = game; selectedGame.value = game;
newUrls.value = [{ type: "tx", url: "" }]; newUrls.value = [{ type: "tx", m3u8_url: "" }];
showDialog.value = true; showDialog.value = true;
}; };
@@ -199,7 +300,7 @@ const closeDialog = () => {
}; };
const addMoreUrl = () => { const addMoreUrl = () => {
newUrls.value.push({ type: "tx", url: "" }); newUrls.value.push({ type: "tx", m3u8_url: "" });
}; };
const removeUrl = (index) => { const removeUrl = (index) => {
@@ -217,10 +318,10 @@ const isValidUrl = (url) => {
const submitUrls = async () => { const submitUrls = async () => {
const validUrls = newUrls.value const validUrls = newUrls.value
.filter(item => item.url.trim() !== "") .filter(item => item.m3u8_url.trim() !== "")
.map(item => ({ .map(item => ({
type: item.type, type: item.type,
url: item.url.trim(), m3u8_url: item.m3u8_url.trim(),
})); }));
if (validUrls.length === 0) { if (validUrls.length === 0) {
@@ -229,8 +330,8 @@ const submitUrls = async () => {
} }
for (const url of validUrls) { for (const url of validUrls) {
if (!isValidUrl(url.url)) { if (!isValidUrl(url.m3u8_url)) {
ElMessage.warning(`直播地址格式不正确: ${url.url}`); ElMessage.warning(`直播地址格式不正确: ${url.m3u8_url}`);
return; return;
} }
} }
@@ -297,21 +398,8 @@ const formatTime = (timeString) => {
// 生命周期钩子 // 生命周期钩子
onMounted(() => { onMounted(() => {
// 认证已在路由守卫中进行,此处只需加载数据
initData(); initData();
const storedPassword = localStorage.getItem("password");
if (storedPassword && storedPassword === "inspur123") return;
const password = prompt("请输入密码:");
go(password)
.then(response => {
if (response) {
localStorage.setItem("password", password);
} else {
window.location.href = "/";
}
})
.catch(() => window.location.href = "/");
}); });
</script> </script>
@@ -439,6 +527,21 @@ onMounted(() => {
} }
} }
.edit-btn {
margin-left: 8px;
padding: 4px 8px;
background-color: #ffc107;
color: #333;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
&:hover {
background-color: #e0a800;
}
}
.delete-btn { .delete-btn {
margin-left: 8px; margin-left: 8px;
padding: 4px 8px; padding: 4px 8px;
@@ -456,6 +559,49 @@ onMounted(() => {
} }
} }
.edit-dialog-content {
display: flex;
flex-direction: column;
gap: 14px;
}
.edit-dialog-content label {
font-weight: 600;
color: #333;
}
.edit-input {
width: 100%;
max-width: 100%;
min-height: 60px;
padding: 10px 12px;
border: 1px solid #dcdcdc;
border-radius: 6px;
outline: none;
font-size: 14px;
resize: vertical;
white-space: pre-wrap;
word-break: break-word;
box-sizing: border-box;
}
.edit-textarea {
min-height: 120px;
max-height: 180px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.dialog-footer .cancel-btn,
.dialog-footer .confirm-btn {
min-width: 100px;
}
.no-urls { .no-urls {
margin-top: 10px; margin-top: 10px;
color: #999; color: #999;

View File

@@ -8,17 +8,53 @@
/> />
<el-backtop :right="50" :bottom="50" /> <el-backtop :right="50" :bottom="50" />
<!-- 公告弹窗 -->
<el-dialog
v-model="showAnnouncement"
title=""
width="90%"
:close-on-click-modal="true"
:show-close="false"
class="announcement-dialog"
>
<div class="announcement-content">
<!-- <p>欢迎访问 NBA 直播</p> -->
<!-- <p>欢迎访问共享赛事直播平台</p> -->
<!-- <p>主域名jrs77.xyz </p> -->
<p>观看人数过多会出现卡顿情况</p>
<p>出现卡顿推荐开启VPN选择美国香港节点观看</p>
<p>暂只支持登录用户观看</p>
<!-- <p>网站维护中暂时无法观赛预计1-2天恢复</p> -->
<p>发布页2026123.xyz(建议收藏)</p>
<!-- <p>祝您观赛愉快</p> -->
</div>
<template #footer>
<div class="announcement-footer">
<el-button type="primary" class="announcement-confirm" @click="showAnnouncement = false">确认</el-button>
</div>
</template>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from "vue"; import { ref, onMounted } from "vue";
import NBASchedule from "@/components/NBASchedule.vue"; import NBASchedule from "@/components/NBASchedule.vue";
import { schedule,games } from "@/api/nba"; import { schedule,games } from "@/api/nba";
const scheduleData = ref(null); const scheduleData = ref(null);
const loading = ref(false); const loading = ref(false);
const error = ref(null); const error = ref(null);
const showAnnouncement = ref(false);
// 检查并显示公告
onMounted(() => {
if (!sessionStorage.getItem('announcement_shown')) {
showAnnouncement.value = true;
sessionStorage.setItem('announcement_shown', 'true');
}
});
// 获取当前时间戳(秒) // 获取当前时间戳(秒)
const currentTimestamp = Math.floor(Date.now() / 1000); const currentTimestamp = Math.floor(Date.now() / 1000);
@@ -71,5 +107,107 @@
fetchScheduleData(); fetchScheduleData();
</script> </script>
<style scoped></style> <style scoped>
/* 将 dialog 居中显示 */
:deep(.el-dialog) {
display: flex;
flex-direction: column;
max-width: 500px;
position: absolute !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
margin: 0 !important;
}
/* 小屏幕时宽度微调 */
@media screen and (max-width: 768px) {
:deep(.el-dialog) {
width: 90% !important;
}
}
.announcement-content {
color: #666;
line-height: 1.6;
text-align: center;
padding: 8px 12px;
}
.announcement-content p {
margin: 8px 0;
}
/* 底部确认按钮容器,居中显示 */
.announcement-footer {
display: flex;
justify-content: center;
padding: 12px 16px 20px;
}
/* 矩形确认按钮样式 */
.announcement-confirm {
min-width: 120px;
border-radius: 6px;
height: 40px;
font-weight: 600;
}
/* 标题居中,同时保留关闭按钮在右 */
:deep(.el-dialog__header) {
position: relative;
padding: 12px 68px 12px 16px; /* 增加右侧内边距,给更大的关闭按钮留位 */
}
:deep(.el-dialog__title) {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
margin: 0;
font-weight: 700;
font-size: 18px;
text-align: center;
}
/* 突出关闭按钮样式:更大尺寸、蓝色主题、悬停放大 */
:deep(.el-dialog__headerbtn) {
position: absolute;
right: 10px;
top: 8px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #1890ff; /* 调整为蓝色 */
box-shadow: 0 4px 12px rgba(24,144,255,0.18);
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.12s ease;
}
:deep(.el-dialog__headerbtn):hover {
transform: scale(1.06);
box-shadow: 0 6px 18px rgba(24,144,255,0.22);
}
:deep(.el-dialog__headerbtn) :deep(.el-icon) {
color: #fff !important;
font-size: 18px !important;
}
/* 小屏幕下微调关闭按钮 */
@media screen and (max-width: 420px) {
:deep(.el-dialog__headerbtn) {
right: 6px;
top: 6px;
width: 36px;
height: 36px;
}
:deep(.el-dialog__title) {
font-size: 16px;
}
}
</style>

View File

@@ -15,4 +15,22 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
}, },
}, },
server: {
proxy: {
'/api': {
// target: 'http://116.62.173.2:9005',
target: 'http://localhost:9005',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, '/api'),
},
'/user': {
// target: 'http://116.62.173.2:9005',
target: 'http://localhost:9005',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/user/, '/user'),
},
},
},
}) })