feat: 首次提交NBA前端项目
This commit is contained in:
33
SEO_NOTES.md
Normal file
33
SEO_NOTES.md
Normal file
@@ -0,0 +1,33 @@
|
||||
SEO 改进说明
|
||||
|
||||
已完成的改动:
|
||||
- 在 `index.html` 中添加了更完整的 meta(robots、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 Schema(JSON-LD)。
|
||||
- 提高页面加载性能(Lighthouse 得分)以提升搜索排名。
|
||||
|
||||
如何回滚或微调
|
||||
- 如果想回退 `index.html` 或路由的修改,请使用版本控制(git)回退这些文件。修改完成后,重新构建并部署。
|
||||
|
||||
如果你愿意,我可以:
|
||||
- 替你把 `REPLACE_WITH_YOUR_SITE_URL` 批量替换为实际域名(请提供域名);
|
||||
- 添加一个构建时脚本生成 sitemap(把你希望列出的动态页面列表发给我)。
|
||||
77
index.html
77
index.html
@@ -1,11 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<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>
|
||||
html, body {
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* height: 100%; */
|
||||
@@ -16,11 +21,69 @@
|
||||
height: 100%;
|
||||
}
|
||||
</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>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<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>
|
||||
</html>
|
||||
|
||||
4289
package-lock.json
generated
Normal file
4289
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,11 +10,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"artplayer": "^5.3.0",
|
||||
"axios": "^1.8.4",
|
||||
"dplayer": "^1.27.1",
|
||||
"element-plus": "^2.9.7",
|
||||
"flv.js": "^1.6.2",
|
||||
"hls.js": "^1.6.2",
|
||||
"md5": "^2.3.0",
|
||||
"pinia": "^3.0.2",
|
||||
"router": "^2.2.0",
|
||||
"video.js": "^8.22.0",
|
||||
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@@ -11,6 +11,9 @@ importers:
|
||||
'@element-plus/icons-vue':
|
||||
specifier: ^2.3.1
|
||||
version: 2.3.1(vue@3.5.13)
|
||||
artplayer:
|
||||
specifier: ^5.3.0
|
||||
version: 5.3.0
|
||||
axios:
|
||||
specifier: ^1.8.4
|
||||
version: 1.8.4
|
||||
@@ -26,6 +29,9 @@ importers:
|
||||
hls.js:
|
||||
specifier: ^1.6.2
|
||||
version: 1.6.2
|
||||
md5:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0
|
||||
pinia:
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2(vue@3.5.13)
|
||||
@@ -439,67 +445,56 @@ packages:
|
||||
resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.40.0':
|
||||
resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loongarch64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-powerpc64le-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.40.0':
|
||||
resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.40.0':
|
||||
resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.40.0':
|
||||
resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==}
|
||||
@@ -636,6 +631,9 @@ packages:
|
||||
aes-decrypter@4.0.2:
|
||||
resolution: {integrity: sha512-lc+/9s6iJvuaRe5qDlMTpCFjnwpkeOXp8qP3oiZ5jsj1MRg+SBVUmmICrhxHvc8OELSmc+fEyyxAuppY6hrWzw==}
|
||||
|
||||
artplayer@5.3.0:
|
||||
resolution: {integrity: sha512-yExO39MpEg4P+bxgChxx1eJfiUPE4q1QQRLCmqGhlsj+ANuaoEkR8hF93LdI5ZyrAcIbJkuEndxEiUoKobifDw==}
|
||||
|
||||
async-validator@4.2.5:
|
||||
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
|
||||
|
||||
@@ -673,6 +671,9 @@ packages:
|
||||
caniuse-lite@1.0.30001714:
|
||||
resolution: {integrity: sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg==}
|
||||
|
||||
charenc@0.0.2:
|
||||
resolution: {integrity: sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==}
|
||||
|
||||
colorjs.io@0.5.2:
|
||||
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
|
||||
|
||||
@@ -691,6 +692,9 @@ packages:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
crypt@0.0.2:
|
||||
resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==}
|
||||
|
||||
csstype@3.1.3:
|
||||
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
|
||||
|
||||
@@ -888,6 +892,9 @@ packages:
|
||||
immutable@5.1.1:
|
||||
resolution: {integrity: sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==}
|
||||
|
||||
is-buffer@1.1.6:
|
||||
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
||||
|
||||
is-docker@3.0.0:
|
||||
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -943,6 +950,10 @@ packages:
|
||||
jsonfile@6.1.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
|
||||
|
||||
@@ -972,6 +983,9 @@ packages:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
md5@2.3.0:
|
||||
resolution: {integrity: sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==}
|
||||
|
||||
memoize-one@6.0.0:
|
||||
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
|
||||
|
||||
@@ -1029,6 +1043,9 @@ packages:
|
||||
resolution: {integrity: sha512-zy1wx4+P3PfhXSEPJNtZmJXfhkkIaxU1VauWIrDZw1O7uJRDRJtKr9n3Ic4NgbA16KyOxOXO2ng9gYwCdXuSXA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
option-validator@2.0.6:
|
||||
resolution: {integrity: sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==}
|
||||
|
||||
parse-ms@4.0.0:
|
||||
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -2005,6 +2022,10 @@ snapshots:
|
||||
global: 4.4.0
|
||||
pkcs7: 1.0.4
|
||||
|
||||
artplayer@5.3.0:
|
||||
dependencies:
|
||||
option-validator: 2.0.6
|
||||
|
||||
async-validator@4.2.5: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
@@ -2049,6 +2070,8 @@ snapshots:
|
||||
|
||||
caniuse-lite@1.0.30001714: {}
|
||||
|
||||
charenc@0.0.2: {}
|
||||
|
||||
colorjs.io@0.5.2: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
@@ -2067,6 +2090,8 @@ snapshots:
|
||||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
crypt@0.0.2: {}
|
||||
|
||||
csstype@3.1.3: {}
|
||||
|
||||
dayjs@1.11.13: {}
|
||||
@@ -2286,6 +2311,8 @@ snapshots:
|
||||
|
||||
immutable@5.1.1: {}
|
||||
|
||||
is-buffer@1.1.6: {}
|
||||
|
||||
is-docker@3.0.0: {}
|
||||
|
||||
is-function@1.0.2: {}
|
||||
@@ -2322,6 +2349,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
kind-of@6.0.3: {}
|
||||
|
||||
kolorist@1.8.0: {}
|
||||
|
||||
lodash-es@4.17.21: {}
|
||||
@@ -2350,6 +2379,12 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
@@ -2400,6 +2435,10 @@ snapshots:
|
||||
is-inside-container: 1.0.0
|
||||
is-wsl: 3.1.0
|
||||
|
||||
option-validator@2.0.6:
|
||||
dependencies:
|
||||
kind-of: 6.0.3
|
||||
|
||||
parse-ms@4.0.0: {}
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
BIN
public/imgs/spa.webp
Normal file
BIN
public/imgs/spa.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
7
public/robots.txt
Normal file
7
public/robots.txt
Normal 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
20
public/sitemap.xml
Normal 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>
|
||||
123
src/api/nba.js
123
src/api/nba.js
@@ -1,45 +1,69 @@
|
||||
import request from "./request";
|
||||
import axios from 'axios'
|
||||
import axios from "axios";
|
||||
import md5 from "md5";
|
||||
|
||||
const SECRET_KEY = "20251125";
|
||||
|
||||
const nbaapi = axios.create({
|
||||
baseURL: 'http://api.new9.me/api',
|
||||
// baseURL: 'http://110.42.255.182:8080',
|
||||
timeout: 2000,
|
||||
})
|
||||
baseURL: "/api",
|
||||
timeout: 5000,
|
||||
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({
|
||||
url: '/urls',
|
||||
method: 'get',
|
||||
url: "/urls",
|
||||
method: "get",
|
||||
headers: {
|
||||
"X-Timestamp": timestamp,
|
||||
"X-Sign": sign,
|
||||
},
|
||||
params: {
|
||||
includeM3u8 : includeM3u8
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('获取直播URL失败:', error);
|
||||
console.error("获取直播URL失败:", error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
const games = async () => {
|
||||
return await nbaapi({
|
||||
url: '/games',
|
||||
method: 'get',
|
||||
url: "/games",
|
||||
method: "get",
|
||||
})
|
||||
.then((response) => {
|
||||
// console.log(response.data); // 调试用
|
||||
return response.data; // 确保返回数据
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('获取赛事数据失败:', error);
|
||||
console.error("获取赛事数据失败:", error);
|
||||
throw error; // 或者返回空数组 return []
|
||||
});
|
||||
};
|
||||
|
||||
const go = async (pwd) => {
|
||||
return await nbaapi({
|
||||
url: '/go',
|
||||
method: 'get',
|
||||
url: "/go",
|
||||
method: "get",
|
||||
params: {
|
||||
// 这里可以添加请求参数
|
||||
pwd: pwd,
|
||||
@@ -50,33 +74,38 @@ const go = async (pwd) => {
|
||||
return response.data; // 确保返回数据
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('获取赛事数据失败:', error);
|
||||
console.error("获取赛事数据失败:", error);
|
||||
throw error; // 或者返回空数组 return []
|
||||
});
|
||||
};
|
||||
|
||||
const schedule = (params) => {
|
||||
return request({
|
||||
url: '/game/schedule',
|
||||
method: 'get',
|
||||
url: "/game/schedule",
|
||||
method: "get",
|
||||
params: params,
|
||||
});
|
||||
};
|
||||
|
||||
const addUrls = async (gameId, urls) => {
|
||||
const payloadUrls = urls.map((item) => ({
|
||||
...item,
|
||||
m3u8_url: item.m3u8_url || item.url,
|
||||
}));
|
||||
|
||||
return await nbaapi({
|
||||
url: '/addUrls',
|
||||
method: 'post',
|
||||
url: "/addUrls",
|
||||
method: "post",
|
||||
data: {
|
||||
gameId: gameId,
|
||||
urls: urls
|
||||
}
|
||||
urls: payloadUrls,
|
||||
},
|
||||
})
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('添加直播URL失败:', error);
|
||||
console.error("添加直播URL失败:", error);
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
@@ -85,16 +114,60 @@ const addUrls = async (gameId, urls) => {
|
||||
const deleteUrlById = async (id) => {
|
||||
return await nbaapi({
|
||||
url: `/delete/${id}`,
|
||||
method: 'get',
|
||||
method: "get",
|
||||
})
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('删除直播URL失败:', error);
|
||||
console.error("删除直播URL失败:", 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
70
src/api/userApi.js
Normal 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
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
BIN
src/assets/imgs/zfb.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
365
src/components/AuthModal - 副本.vue
Normal file
365
src/components/AuthModal - 副本.vue
Normal 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>
|
||||
365
src/components/AuthModal.vue
Normal file
365
src/components/AuthModal.vue
Normal 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>
|
||||
438
src/components/LiveStream - 副本.vue
Normal file
438
src/components/LiveStream - 副本.vue
Normal 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>
|
||||
@@ -1,11 +1,9 @@
|
||||
<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">
|
||||
<!-- <h3>{{ gameData.awayTeam.city }}</h3> -->
|
||||
<p>{{ gameData.awayTeam.name }}</p>
|
||||
<span>{{ gameData.awayTeam.record }}</span>
|
||||
</div>
|
||||
@@ -14,9 +12,7 @@
|
||||
<div class="vs-circle">VS</div>
|
||||
|
||||
<div class="team-info home-team">
|
||||
|
||||
<div class="team-details">
|
||||
<!-- <h3>{{ gameData.homeTeam.city }}</h3> -->
|
||||
<p>{{ gameData.homeTeam.name }}</p>
|
||||
<span>{{ gameData.homeTeam.record }}</span>
|
||||
</div>
|
||||
@@ -24,12 +20,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 播放器 -->
|
||||
<div id="dplayer-live" class="dplayer-container"></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">
|
||||
<!-- <h3>直播源</h3> -->
|
||||
<div class="stream-buttons">
|
||||
<button
|
||||
v-for="stream in allStreams"
|
||||
@@ -40,164 +46,189 @@
|
||||
{{ 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;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>
|
||||
|
||||
<!-- 返回按钮 -->
|
||||
<button class="back-button" @click="goBack">← 返回赛程</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from "vue";
|
||||
import { ref, onMounted, onBeforeUnmount, computed, nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useGameStore } from "@/stores/game";
|
||||
import DPlayer from "dplayer";
|
||||
import Hls from "hls.js";
|
||||
import Flv from "flv.js";
|
||||
|
||||
// 注册全局变量
|
||||
window.flvjs = Flv;
|
||||
window.Hls = Hls;
|
||||
import { fetchLiveUrl } from "@/api/nba";
|
||||
import Hls from "hls.js"; // 仅保留 Hls.js 用于 PC 端
|
||||
|
||||
const router = useRouter();
|
||||
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 allStreams = computed(() => gameStore.allStreams);
|
||||
const gameId = computed(() => gameStore.gameId);
|
||||
const currentStream = computed({
|
||||
get: () => gameStore.currentStream,
|
||||
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 = () => {
|
||||
if (!currentStream.value?.url) return;
|
||||
const url = currentStream.value.url;
|
||||
const video = videoRef.value;
|
||||
|
||||
// 销毁旧实例
|
||||
if (dpInstance.value) {
|
||||
dpInstance.value.destroy();
|
||||
}
|
||||
loadError.value = "";
|
||||
|
||||
dpInstance.value = new DPlayer({
|
||||
container: document.getElementById("dplayer-live"),
|
||||
live: true,
|
||||
autoplay: true,
|
||||
airplay: true,
|
||||
video: {
|
||||
url: currentStream.value.url,
|
||||
type: "custom", // 修改为custom类型
|
||||
customType: {
|
||||
custom: function (video, player) {
|
||||
const url = video.src;
|
||||
// 1. 清理旧实例
|
||||
destroyPlayer();
|
||||
|
||||
// 自动检测协议类型
|
||||
if (url.includes(".m3u8") || url.endsWith("/hls")) {
|
||||
const hls = new Hls();
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
player.on("destroy", () => hls.destroy());
|
||||
} 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默认处理
|
||||
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();
|
||||
});
|
||||
|
||||
// 保持原有尺寸调整逻辑
|
||||
setTimeout(() => {
|
||||
const video = document
|
||||
.getElementById("dplayer-live")
|
||||
?.querySelector("video");
|
||||
if (video) {
|
||||
video.style.cssText = `
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: contain !important;
|
||||
`;
|
||||
video.onerror = () => {
|
||||
loadError.value = "播放失败,请尝试切换其他源";
|
||||
};
|
||||
}
|
||||
}, 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 switchStream = (stream) => {
|
||||
currentStream.value = stream;
|
||||
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: "企鹅体育",
|
||||
tx: "企鹅超清",
|
||||
wl: "纬来体育",
|
||||
mg: "咪咕体育",
|
||||
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];
|
||||
}
|
||||
initPlayer();
|
||||
const initial = currentStream.value || allStreams.value[0];
|
||||
if (initial) {
|
||||
switchStream(initial);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (dpInstance.value) {
|
||||
dpInstance.value.destroy();
|
||||
}
|
||||
destroyPlayer();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -205,7 +236,6 @@ onBeforeUnmount(() => {
|
||||
.live-stream-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
// padding: 20px;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -237,12 +267,6 @@ onBeforeUnmount(() => {
|
||||
.team-details {
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 5px 0;
|
||||
font-size: 1.1rem;
|
||||
@@ -269,19 +293,46 @@ onBeforeUnmount(() => {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dplayer-container {
|
||||
/* 核心播放器容器样式 */
|
||||
.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;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.dplayer-video {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: contain !important;
|
||||
/* 原生 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 {
|
||||
@@ -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 {
|
||||
display: block;
|
||||
width: 200px;
|
||||
margin: 30px auto 0;
|
||||
margin: 10px auto 0;
|
||||
padding: 12px 24px;
|
||||
background: #3498db;
|
||||
color: white;
|
||||
@@ -337,7 +400,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.game-header {
|
||||
// flex-direction: column;
|
||||
gap: 20px;
|
||||
.team-info {
|
||||
display: flex;
|
||||
@@ -353,12 +415,6 @@ onBeforeUnmount(() => {
|
||||
.team-details {
|
||||
text-align: center;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 5px 0;
|
||||
font-size: 1.1rem;
|
||||
|
||||
940
src/components/NBASchedule - 副本.vue
Normal file
940
src/components/NBASchedule - 副本.vue
Normal 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">
|
||||
遨游世界加速器,畅游Google、YouTube、ChatGpt、ins等海外网站,支持多平台使用,仅需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>
|
||||
@@ -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 v-if="scheduleData?.data?.sponsor" class="sponsor-banner">
|
||||
<span>所有内容均来源互联网,有问题请联系邮箱:xdd9@vip.qq.com</span>
|
||||
<!-- <img
|
||||
:src="scheduleData.data.sponsor.logo"
|
||||
:alt="scheduleData.data.sponsor.name"
|
||||
class="sponsor-logo"
|
||||
/> -->
|
||||
<div class="sponsor-banner">
|
||||
<h3>所有内容均来源互联网,反馈邮箱:super@2026123.xyz</h3>
|
||||
<h4>遇到播放问题可以尝试切换浏览器、刷新重试</h4>
|
||||
<h4>推荐使用谷歌浏览器</h4>
|
||||
</div>
|
||||
<div class="ad">
|
||||
<a href="https://1.aysj.xyz/#/register" target="_blank">
|
||||
遨游世界VPN,畅游YouTube、ChatGpt、ins、telegram等海外应用,支持苹果、安卓、电脑使用,仅需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 class="date-navigation">
|
||||
<button
|
||||
@click="changeDate('pre')"
|
||||
@@ -33,70 +47,20 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 比赛列表 - 每行一场比赛 -->
|
||||
<div class="games-list">
|
||||
<div v-for="group in scheduleData?.data?.groups" :key="group.date">
|
||||
<!-- 添加判断:当games数组为空时显示提示信息 -->
|
||||
<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 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
|
||||
class="team home-team"
|
||||
:class="{
|
||||
'tbd-team': !game.teamValid,
|
||||
winner: isWinner(game, 'home'), // 添加判断是否为胜者
|
||||
winner: isWinner(game, 'home'),
|
||||
}"
|
||||
>
|
||||
<img
|
||||
@@ -111,22 +75,87 @@
|
||||
</div>
|
||||
<div class="team-record">
|
||||
<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>
|
||||
</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="game.status === 2">
|
||||
<!-- 只对当天比赛显示直播按钮 -->
|
||||
<!-- 当天的比赛 -->
|
||||
<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
|
||||
v-for="stream in getLiveStreams(game.gameId)"
|
||||
:key="stream.type"
|
||||
@@ -137,22 +166,27 @@
|
||||
{{ getStreamName(stream.type) }}
|
||||
</button>
|
||||
</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>
|
||||
<!-- 当天但暂时没有 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 class="game-footer">
|
||||
<div class="game-arena">
|
||||
<span v-if="game.arenaName">{{ game.arenaName }}</span>
|
||||
@@ -164,96 +198,211 @@
|
||||
</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 { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { urls } from "@/api/nba";
|
||||
import { onMounted } from "vue";
|
||||
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([]);
|
||||
const showZfb = ref(true);
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await urls();
|
||||
urlsData.value = response || [];
|
||||
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) => {
|
||||
// 获取今天的日期(北京时间)
|
||||
const today = new Date();
|
||||
const todayStr = `${today.getFullYear()}-${(today.getMonth() + 1)
|
||||
.toString()
|
||||
.padStart(2, "0")}-${today.getDate().toString().padStart(2, "0")}`;
|
||||
if (!game || !game.startDate) return false;
|
||||
|
||||
// 直接比较 startDate(已经是北京时间)
|
||||
return game.startDate === todayStr;
|
||||
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[gameId]) {
|
||||
if (streamGroup[id]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 获取比赛的直播流
|
||||
const getLiveStreams = (gameId) => {
|
||||
const id = String(gameId); // 转为字符串
|
||||
const id = normalizeGameId(gameId);
|
||||
for (const streamGroup of urlsData.value) {
|
||||
if (streamGroup[id]) return streamGroup[id];
|
||||
if (streamGroup[id]) {
|
||||
return streamGroup[id];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// 获取流名称
|
||||
const getStreamName = (type) => {
|
||||
const names = {
|
||||
tx: "企鹅体育",
|
||||
tx: "企鹅超清",
|
||||
wl: "纬来体育",
|
||||
nba: "高清原声",
|
||||
mg: "咪咕体育",
|
||||
mg: "咪咕高清",
|
||||
zb: "高清直播",
|
||||
};
|
||||
return names[type] || type;
|
||||
};
|
||||
|
||||
// 跳转到直播页面
|
||||
const goToLive = (game, stream) => {
|
||||
// 准备比赛数据
|
||||
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}负`,
|
||||
record: `${game.homeTeamWins}胜${game.homeTeamLosses}负`,
|
||||
},
|
||||
awayTeam: {
|
||||
name: game.awayTeamName,
|
||||
logo: game.awayTeamLogoDark,
|
||||
city: game.awayTeamCity,
|
||||
record: `${game.awayTeamWins}胜-${game.awayTeamLosses}负`,
|
||||
record: `${game.awayTeamWins}胜${game.awayTeamLosses}负`,
|
||||
},
|
||||
gameInfo: {
|
||||
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({
|
||||
gameData,
|
||||
currentStream: stream,
|
||||
gameId: game.gameId,
|
||||
currentStream: { ...stream, url },
|
||||
allStreams: getLiveStreams(game.gameId),
|
||||
});
|
||||
|
||||
// 导航到播放页
|
||||
router.push({
|
||||
name: "Play",
|
||||
params: {
|
||||
gameId: game.gameId,
|
||||
},
|
||||
params: { gameId: game.gameId },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("获取直播地址失败:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
@@ -294,14 +449,10 @@ const props = defineProps({
|
||||
|
||||
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 = [
|
||||
"星期日",
|
||||
"星期一",
|
||||
@@ -312,8 +463,6 @@ const currentDisplayDate = computed(() => {
|
||||
"星期六",
|
||||
];
|
||||
const weekday = weekdays[date.getDay()];
|
||||
|
||||
// 格式化日期为 YYYY年MM月DD日
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
@@ -321,7 +470,6 @@ const currentDisplayDate = computed(() => {
|
||||
return `${year}年${month}月${day}日 ${weekday}`;
|
||||
});
|
||||
|
||||
// 格式化比赛时间
|
||||
const formatGameTime = (utcTime) => {
|
||||
if (!utcTime) return "时间待定";
|
||||
const date = new Date(utcTime);
|
||||
@@ -330,57 +478,60 @@ const formatGameTime = (utcTime) => {
|
||||
return `${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// 获取比赛状态对应的样式类
|
||||
const getStatusClass = (status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return "not-started"; // 未开始
|
||||
return "not-started";
|
||||
case 2:
|
||||
return "in-progress"; // 进行中
|
||||
return "in-progress";
|
||||
case 3:
|
||||
return "finished"; // 已结束
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
const closeZfb = () => {
|
||||
showZfb.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subscribe-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subscribe-btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.nba-schedule-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
padding: 10px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
/* 添加未开播样式居中显示 */
|
||||
.no-live {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -393,40 +544,36 @@ const isWinner = (game, teamType) => {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 赞助商样式 */
|
||||
.sponsor-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 30px;
|
||||
padding: 12px;
|
||||
margin-bottom: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.sponsor-logo {
|
||||
height: 36px;
|
||||
margin-left: 12px;
|
||||
.sponsor-banner h3,
|
||||
.sponsor-banner h4 {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
/* 日期导航样式 */
|
||||
.date-navigation {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.current-date {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #1d428a; /* NBA 蓝色 */
|
||||
color: #1b1f27;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
@@ -453,14 +600,12 @@ const isWinner = (game, teamType) => {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 比赛列表 - 每行一场比赛 */
|
||||
.games-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
/* 比赛卡片样式 - 大气风格 */
|
||||
.game-card {
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 10px;
|
||||
@@ -474,7 +619,6 @@ const isWinner = (game, teamType) => {
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* 比赛时间 */
|
||||
.game-time {
|
||||
padding: 12px 20px;
|
||||
font-weight: 600;
|
||||
@@ -483,23 +627,21 @@ const isWinner = (game, teamType) => {
|
||||
}
|
||||
|
||||
.not-started {
|
||||
background-color: #6c757d; /* 灰色 - 未开始 */
|
||||
background-color: #6c757d;
|
||||
}
|
||||
|
||||
.in-progress {
|
||||
background-color: #dc3545; /* 红色 - 进行中 */
|
||||
background-color: #dc3545;
|
||||
}
|
||||
|
||||
.finished {
|
||||
background-color: #28a745; /* 绿色 - 已结束 */
|
||||
background-color: #3dbe5b;
|
||||
}
|
||||
|
||||
/* 比赛主要内容 */
|
||||
.game-main {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 球队样式 */
|
||||
.team {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -551,12 +693,10 @@ const isWinner = (game, teamType) => {
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* 待定球队样式 */
|
||||
.tbd-team {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 比赛状态 */
|
||||
.game-status {
|
||||
padding: 12px 0;
|
||||
text-align: center;
|
||||
@@ -566,8 +706,8 @@ const isWinner = (game, teamType) => {
|
||||
}
|
||||
|
||||
.game-not-started {
|
||||
color: #5a7cec;
|
||||
font-size: 18px;
|
||||
color: #0008ff;
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.game-in-progress {
|
||||
@@ -588,12 +728,10 @@ const isWinner = (game, teamType) => {
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
/* 主队样式 */
|
||||
.home-team {
|
||||
border-top: 1px solid #f1f3f5;
|
||||
}
|
||||
|
||||
/* 比赛页脚 */
|
||||
.game-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -614,7 +752,6 @@ const isWinner = (game, teamType) => {
|
||||
color: #1d428a;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.game-card {
|
||||
margin: 0 10px;
|
||||
@@ -672,10 +809,11 @@ const isWinner = (game, teamType) => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 新增直播间按钮样式 */
|
||||
.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;
|
||||
@@ -719,7 +857,6 @@ const isWinner = (game, teamType) => {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.live-buttons {
|
||||
flex-direction: column;
|
||||
@@ -730,6 +867,7 @@ const isWinner = (game, teamType) => {
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-games-message {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
@@ -739,20 +877,144 @@ const isWinner = (game, teamType) => {
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
/* 胜者背景色 */
|
||||
|
||||
.team.winner {
|
||||
background-color: rgba(76, 175, 80, 0.1); /* 浅绿色背景 */
|
||||
border-left: 3px solid #74fd79; /* 左侧绿色边框 */
|
||||
background-color: rgba(183, 239, 167, 0.898);
|
||||
border-left: 3px solid #a4b1ee;
|
||||
}
|
||||
|
||||
/* 如果希望更明显的效果,可以调整样式 */
|
||||
.team.winner .team-name {
|
||||
font-weight: bold;
|
||||
color: #2e7d32; /* 深绿色文字 */
|
||||
color: #d4e66a;
|
||||
}
|
||||
|
||||
.team.winner .team-score {
|
||||
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>
|
||||
|
||||
120
src/main.js
120
src/main.js
@@ -4,9 +4,127 @@ import 'element-plus/dist/index.css'
|
||||
import { createPinia } from 'pinia'
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.use(createPinia())
|
||||
app.use(pinia)
|
||||
|
||||
// 应用启动时尝试根据后端 session 恢复登录态(需要后端正确返回 Set-Cookie)
|
||||
const userStore = useUserStore(pinia)
|
||||
userStore.fetchCurrentUser().catch(() => {
|
||||
// 静默失败,维持未登录状态
|
||||
})
|
||||
|
||||
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 防护 ------------------
|
||||
|
||||
@@ -11,23 +11,39 @@ const routes = [
|
||||
path: '/',
|
||||
name: 'Index',
|
||||
component: IndexVue,
|
||||
props: route => ({ query: route.query })
|
||||
props: route => ({ query: route.query }),
|
||||
meta: {
|
||||
title: '首页 - NBA 在线观看与赛程',
|
||||
description: '首页 - 提供最新的NBA赛程、比赛直播入口与集锦,在线观看NBA赛事。'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/play/:gameId',
|
||||
name: 'Play',
|
||||
component: () => import('@/views/Play.vue'),
|
||||
props: true // 启用props接收路由参数
|
||||
props: true, // 启用props接收路由参数
|
||||
meta: {
|
||||
title: '比赛直播 - NBA 在线观看',
|
||||
description: '在线观看赛事直播,进入播放页观看指定比赛的实时直播与回放。'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/lives',
|
||||
name: 'Admin',
|
||||
component: AdminVue,
|
||||
meta: {
|
||||
title: '直播列表 - NBA 在线观看',
|
||||
description: '直播列表 - 列出当前正在进行或即将进行的NBA比赛直播。'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/test',
|
||||
name: 'Test',
|
||||
component: TestVue,
|
||||
meta: {
|
||||
title: '测试页面',
|
||||
description: '测试页面 - 开发与测试用途。'
|
||||
}
|
||||
},
|
||||
// 添加通配符路由,捕获所有未匹配的路径
|
||||
{
|
||||
@@ -42,7 +58,7 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
// 添加全局路由守卫
|
||||
router.beforeEach((to, from) => {
|
||||
router.beforeEach(async (to, from) => {
|
||||
const gameStore = useGameStore()
|
||||
|
||||
// 离开播放页时清理数据
|
||||
@@ -54,6 +70,70 @@ router.beforeEach((to, from) => {
|
||||
if (to.name === 'Play' && !gameStore.currentGame) {
|
||||
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
|
||||
@@ -2,37 +2,38 @@ import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useGameStore = defineStore('game', () => {
|
||||
// 状态
|
||||
const _currentGame = ref(null)
|
||||
const _currentStream = ref(null)
|
||||
const _allStreams = ref([])
|
||||
const _gameId = ref(null)
|
||||
|
||||
// 计算属性(保持响应式)
|
||||
const currentGame = computed(() => _currentGame.value)
|
||||
const allStreams = computed(() => _allStreams.value)
|
||||
const gameId = computed(() => _gameId.value)
|
||||
const currentStream = computed({
|
||||
get: () => _currentStream.value,
|
||||
set: (val) => _currentStream.value = val // 确保可写
|
||||
set: (val) => _currentStream.value = val
|
||||
})
|
||||
|
||||
// 设置当前比赛
|
||||
const setCurrentGame = (data) => {
|
||||
_currentGame.value = data.gameData
|
||||
_currentStream.value = data.currentStream
|
||||
_allStreams.value = data.allStreams
|
||||
_gameId.value = data.gameId
|
||||
}
|
||||
|
||||
// 清除数据
|
||||
const clearGameData = () => {
|
||||
_currentGame.value = null
|
||||
_currentStream.value = null
|
||||
_allStreams.value = []
|
||||
_gameId.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
currentGame,
|
||||
currentStream,
|
||||
allStreams,
|
||||
gameId,
|
||||
setCurrentGame,
|
||||
clearGameData
|
||||
}
|
||||
|
||||
86
src/stores/user.js
Normal file
86
src/stores/user.js
Normal 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,
|
||||
}
|
||||
})
|
||||
625
src/views/Admin - 副本.vue
Normal file
625
src/views/Admin - 副本.vue
Normal 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>
|
||||
@@ -26,12 +26,15 @@
|
||||
<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 class="truncated-url" @click="showFullUrl(getStreamUrl(url))">
|
||||
{{ truncateUrl(getStreamUrl(url)) }}
|
||||
</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> 查看
|
||||
</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)">
|
||||
<i class="el-icon-delete"></i> 删除
|
||||
</button>
|
||||
@@ -54,6 +57,26 @@
|
||||
</div>
|
||||
</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对话框 -->
|
||||
<div v-if="showDialog" class="dialog-overlay">
|
||||
<div class="dialog-content">
|
||||
@@ -75,9 +98,9 @@
|
||||
<div class="form-group">
|
||||
<label>直播地址 {{ index + 1 }}:</label>
|
||||
<input
|
||||
v-model="url.url"
|
||||
v-model="url.m3u8_url"
|
||||
type="text"
|
||||
placeholder="请输入完整的直播URL"
|
||||
placeholder="请输入完整的 m3u8 地址"
|
||||
class="url-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -107,22 +130,26 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
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 urlData = ref([]);
|
||||
const showDialog = ref(false);
|
||||
const selectedGame = ref(null);
|
||||
const newUrls = ref([{ type: "tx", url: "" }]);
|
||||
const newUrls = ref([{ type: "tx", m3u8_url: "" }]);
|
||||
const isSubmitting = ref(false);
|
||||
const urlDialogVisible = ref(false);
|
||||
const currentUrl = ref('');
|
||||
const editDialogVisible = ref(false);
|
||||
const editUrlItem = ref(null);
|
||||
const editUrlValue = ref('');
|
||||
const editSubmitting = ref(false);
|
||||
|
||||
// 初始化数据
|
||||
const initData = async () => {
|
||||
try {
|
||||
const [urlsRes, gamesRes] = await Promise.all([urls(), games()]);
|
||||
const [urlsRes, gamesRes] = await Promise.all([urls("1"), games()]);
|
||||
urlData.value = urlsRes || [];
|
||||
gamesData.value = gamesRes || [];
|
||||
} catch (err) {
|
||||
@@ -132,6 +159,10 @@ const initData = async () => {
|
||||
};
|
||||
|
||||
// URL处理函数
|
||||
const getStreamUrl = (item) => {
|
||||
return item?.m3u8_url || item?.url || "";
|
||||
};
|
||||
|
||||
const truncateUrl = (url) => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
@@ -155,6 +186,76 @@ const copyUrl = (url) => {
|
||||
.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
|
||||
const deleteUrl = async (id) => {
|
||||
let loading = null;
|
||||
@@ -190,7 +291,7 @@ const deleteUrl = async (id) => {
|
||||
// 添加URL相关函数
|
||||
const openAddUrlDialog = (game) => {
|
||||
selectedGame.value = game;
|
||||
newUrls.value = [{ type: "tx", url: "" }];
|
||||
newUrls.value = [{ type: "tx", m3u8_url: "" }];
|
||||
showDialog.value = true;
|
||||
};
|
||||
|
||||
@@ -199,7 +300,7 @@ const closeDialog = () => {
|
||||
};
|
||||
|
||||
const addMoreUrl = () => {
|
||||
newUrls.value.push({ type: "tx", url: "" });
|
||||
newUrls.value.push({ type: "tx", m3u8_url: "" });
|
||||
};
|
||||
|
||||
const removeUrl = (index) => {
|
||||
@@ -217,10 +318,10 @@ const isValidUrl = (url) => {
|
||||
|
||||
const submitUrls = async () => {
|
||||
const validUrls = newUrls.value
|
||||
.filter(item => item.url.trim() !== "")
|
||||
.filter(item => item.m3u8_url.trim() !== "")
|
||||
.map(item => ({
|
||||
type: item.type,
|
||||
url: item.url.trim(),
|
||||
m3u8_url: item.m3u8_url.trim(),
|
||||
}));
|
||||
|
||||
if (validUrls.length === 0) {
|
||||
@@ -229,8 +330,8 @@ const submitUrls = async () => {
|
||||
}
|
||||
|
||||
for (const url of validUrls) {
|
||||
if (!isValidUrl(url.url)) {
|
||||
ElMessage.warning(`直播地址格式不正确: ${url.url}`);
|
||||
if (!isValidUrl(url.m3u8_url)) {
|
||||
ElMessage.warning(`直播地址格式不正确: ${url.m3u8_url}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -297,21 +398,8 @@ const formatTime = (timeString) => {
|
||||
|
||||
// 生命周期钩子
|
||||
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>
|
||||
|
||||
@@ -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 {
|
||||
margin-left: 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 {
|
||||
margin-top: 10px;
|
||||
color: #999;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template>
|
||||
<template>
|
||||
<div>
|
||||
<NBASchedule
|
||||
:scheduleData="scheduleData"
|
||||
@@ -8,17 +8,53 @@
|
||||
/>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ref, onMounted } from "vue";
|
||||
import NBASchedule from "@/components/NBASchedule.vue";
|
||||
import { schedule,games } from "@/api/nba";
|
||||
|
||||
const scheduleData = ref(null);
|
||||
const loading = ref(false);
|
||||
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);
|
||||
@@ -71,5 +107,107 @@
|
||||
fetchScheduleData();
|
||||
</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>
|
||||
|
||||
@@ -15,4 +15,22 @@ export default defineConfig({
|
||||
'@': 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'),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user