Compare commits

...
7 Commits
Author SHA1 Message Date
谢宇宁 c90a2360a8 nuxt.js问题修改 2026-10-23 15:15:43 +07:00
谢宇宁 f96f722186 UI调整 2026-10-23 15:07:10 +07:00
谢宇宁 0d924839f3 发布脚本更新 2026-10-17 10:13:42 +07:00
谢宇宁 41e266ef2e Merge branch 'main' of https://git.mfcg.me/all_user/web_huakui_web 2026-10-17 10:00:01 +07:00
谢宇宁 bbd86e4119 yandex添加 2026-10-17 09:59:58 +07:00
root 169c62f5b0 Merge branch 'main' of https://git.mfcg.me/all_user/web_huakui_web 2026-10-16 20:03:56 +08:00
root 6097965940 服务代理 2026-10-16 15:29:31 +08:00
22 changed files with 1052 additions and 452 deletions
+2
View File
@@ -18,3 +18,5 @@ web_hkdj_h5.md5
/template/pages/sitemaps/*.xml /template/pages/sitemaps/*.xml
/template/pages/sitemap.xml /template/pages/sitemap.xml
/data/sitemaps/*.xml /data/sitemaps/*.xml
web_huakui_h5.tar.gz
web_huakui_h5.md5
+1 -1
View File
@@ -20,7 +20,7 @@ This block is written and re-added by `next dev` — verify at `node_modules/nex
- 成功抓取文件通过原生 Route Handler 返回;Proxy 仅选择成功出口或准备并改写完整 TSX 404,不能直接从 Proxy 输出文件,否则 Next 会过滤 Content-Length。保留 Go ServeFile 的 MIME、原字节、Last-Modified、Content-Length、Accept-Ranges、304/412/206/416、If-Range/多段 Range 和 HEAD;Next compress=false 避免框架自动压缩改变文件响应,外层代理可沿用原传输配置。 - 成功抓取文件通过原生 Route Handler 返回;Proxy 仅选择成功出口或准备并改写完整 TSX 404,不能直接从 Proxy 输出文件,否则 Next 会过滤 Content-Length。保留 Go ServeFile 的 MIME、原字节、Last-Modified、Content-Length、Accept-Ranges、304/412/206/416、If-Range/多段 Range 和 HEAD;Next compress=false 避免框架自动压缩改变文件响应,外层代理可沿用原传输配置。
- 播放器通过 npm 使用 `xgplayer` 与 `xgplayer-hls`;不要恢复 CDN 脚本版 DPlayer/Hls/Swiper 或旧业务脚本。 - 播放器通过 npm 使用 `xgplayer` 与 `xgplayer-hls`;不要恢复 CDN 脚本版 DPlayer/Hls/Swiper 或旧业务脚本。
- `/h5/m3u8/**`、`/enkey`、`/newenkey` 保留服务端媒体转发:签名 query 不重组,UA/IP/Range 保留,上游重定向按原 Go 客户端跟随。签名媒体请求按原合同不附登录 Cookie/Authorization;上游业务 JSON 错误返回 502,不能当播放清单成功响应。 - `/h5/m3u8/**`、`/enkey`、`/newenkey` 保留服务端媒体转发:签名 query 不重组,UA/IP/Range 保留,上游重定向按原 Go 客户端跟随。签名媒体请求按原合同不附登录 Cookie/Authorization;上游业务 JSON 错误返回 502,不能当播放清单成功响应。
- `config/development.env`、`config/test.env`、`config/production.env` 分别管理三套环境,本地覆盖使用忽略提交的 `*.local.env`;进程环境优先。2026-10-16 起开发 API 按用户指定使用 `https://webhuakuiapi.mfcg.me`(替代原 `http://172.104.181.99:30331`),不要擅自切回原站 25319 或旧地址。 - `config/development.env`、`config/test.env`、`config/production.env` 分别管理三套环境,本地覆盖使用忽略提交的 `*.local.env`;进程环境优先。2026-10-16 起开发 API 按用户指定使用 `http://127.0.0.1:20032`(替代原 `http://172.104.181.99:30331`),不要擅自切回原站 25319 或旧地址。
- SSR 请求准备在 `src/proxy.ts` 完成,通过 `src/server/react/handoff.ts` 向 React 交接;保留同请求 Cookie/页面数据一致性。大快照使用私有临时文件,部署多实例时须处理交接存储共享或实例亲和。 - SSR 请求准备在 `src/proxy.ts` 完成,通过 `src/server/react/handoff.ts` 向 React 交接;保留同请求 Cookie/页面数据一致性。大快照使用私有临时文件,部署多实例时须处理交接存储共享或实例亲和。
- 所有“黄果短剧”统一为“花魁短剧”。占位图品牌 logo 透明度固定为 `0.2`。Figma 文件为 `1vUq4wf5xOs8ZOlmt26Q3O`,读取设计使用本地 `FIGMA_TOKEN` 调用 REST API,不能打印令牌。 - 所有“黄果短剧”统一为“花魁短剧”。占位图品牌 logo 透明度固定为 `0.2`。Figma 文件为 `1vUq4wf5xOs8ZOlmt26Q3O`,读取设计使用本地 `FIGMA_TOKEN` 调用 REST API,不能打印令牌。
- 本地 `npm run dev` 默认使用 Webpack;该项目已确认 Next 16.4 Turbopack 开发 HMR 存在循环刷新,未经验证不要改回默认。Charles 调试使用 `npm run dev:charles`,不改业务接口地址。 - 本地 `npm run dev` 默认使用 Webpack;该项目已确认 Next 16.4 Turbopack 开发 HMR 存在循环刷新,未经验证不要改回默认。Charles 调试使用 `npm run dev:charles`,不改业务接口地址。
+7 -21
View File
@@ -68,7 +68,7 @@ Node 原生代理说明:[Node.js HTTP 文档](https://nodejs.org/download/rele
配置优先级:进程环境变量 > `config/<环境>.local.env` > `config/<环境>.env`。本地覆盖文件不会提交。`APP_ENV` 选择业务环境;测试环境的优化构建仍使用 Next.js 要求的 `NODE_ENV=production`。 配置优先级:进程环境变量 > `config/<环境>.local.env` > `config/<环境>.env`。本地覆盖文件不会提交。`APP_ENV` 选择业务环境;测试环境的优化构建仍使用 Next.js 要求的 `NODE_ENV=production`。
开发 `UPSTREAM_API_URL` 使用 `https://webhuakuiapi.mfcg.me`;测试服务与 API 同机,使用 `http://127.0.0.1:30331`,测试应用端口为 `30334`。本地运行测试环境且 API 不在本机时,可在 `config/test.local.env` 覆盖上游地址。正式配置使用同机 API `http://127.0.0.1:26931`、服务端口 `26935`,可由部署环境或 `config/production.local.env` 覆盖。构建不访问上游,启动时检查地址。 开发 `UPSTREAM_API_URL` 使用 `http://127.0.0.1:20032`;测试服务与 API 同机,使用 `http://127.0.0.1:30331`,测试应用端口为 `30334`。本地运行测试环境且 API 不在本机时,可在 `config/test.local.env` 覆盖上游地址。正式配置使用同机 API `http://127.0.0.1:26931`、服务端口 `26935`,可由部署环境或 `config/production.local.env` 覆盖。构建不访问上游,启动时检查地址。
```dotenv ```dotenv
UPSTREAM_API_URL=https://your-api.example UPSTREAM_API_URL=https://your-api.example
@@ -263,7 +263,7 @@ APP_DEEP_LINK_URL=
平台地址未配置时,按钮显示“前往下载页”,沿用上游 `AppAddr` 或 `/app/download/` 跳转;未配置 App 深链时隐藏打开 App 入口。链接保留原始跳转地址,不生成设计示例中的安装包、描述文件或 App Store 地址。 平台地址未配置时,按钮显示“前往下载页”,沿用上游 `AppAddr` 或 `/app/download/` 跳转;未配置 App 深链时隐藏打开 App 入口。链接保留原始跳转地址,不生成设计示例中的安装包、描述文件或 App Store 地址。
谷歌统计(GA4)由 `GOOGLE_ANALYTICS_ID` 配置,三套环境默认 `G-269F3ZFQFQ`,在根布局通过 `next/script` 于页面可交互后加载;留空即关闭。 谷歌统计(GA4)由 `GOOGLE_ANALYTICS_ID` 配置,三套环境默认 `G-269F3ZFQFQ`,在根布局通过 `next/script` 于页面可交互后加载;留空即关闭。Yandex.Metrika 由 `YANDEX_METRIKA_ID` 配置,三套环境默认 `113657403`,同样在根布局加载并带 noscript 像素;留空即关闭。
所有 Telegram 联系入口(页尾、官方导航、回家的路、播放页链接、2257/DMCA、意见反馈,以及 Telegram 形式的「联系我们」客服链接)统一使用 `CONTACT_TELEGRAM_URL`,三套环境默认 `https://t.me/jbd125`,覆盖上游 `tgGroup`、`telegram`、`telegramAdd`。值无效时回退为默认账号。 所有 Telegram 联系入口(页尾、官方导航、回家的路、播放页链接、2257/DMCA、意见反馈,以及 Telegram 形式的「联系我们」客服链接)统一使用 `CONTACT_TELEGRAM_URL`,三套环境默认 `https://t.me/jbd125`,覆盖上游 `tgGroup`、`telegram`、`telegramAdd`。值无效时回退为默认账号。
@@ -337,30 +337,16 @@ npm run deploy:test
## 正式服上传 ## 正式服上传
使用既有跳板机 `running.sh` 链路: 正式服为 `root@172.104.166.87`,Supervisor 程序 `web_huakui_h5` 在 `/web_huakui/h5/app` 执行 `node start.mjs`(端口 26935,Nginx `webhuakuih5.mfcg.me` 代理到该端口,`/enkey`、`/newenkey` 直接走 Go 服务 20031)。
```bash ```bash
npm run deploy:prod -- --dry-run # 只核对配置,不构建、不连接 npm run deploy:prod -- --dry-run # 只核对配置,不构建、不连接
npm run deploy:prod -- --package-only # 检查、构建、生成运行包与 MD5,不上传 npm run deploy:prod -- --package-only # 检查、构建、生成运行包与 MD5,不上传
npm run deploy:prod # 构建、上传并执行正式服 running.sh npm run deploy:prod # 构建、上传、切换并重启
``` ```
脚本执行 `check` → `build` → `bundle` → `bundle:linux`,生成根目录 `web_hkdj_h5.tar.gz` 和 `web_hkdj_h5.md5`(仅一行 MD5 hash)。tar 根部直接包含 `start.mjs`、`server.js`、`.next/`(含静态资源)、`public/`(含 PWA Worker/图标)、`config/production.env`、媒体密钥、必要数据及 `node_modules/`,不套 `dist/production/` 外层目录。 脚本执行 `check` → `build` → `bundle` → `bundle:linux`,生成根目录 `web_huakui_h5.tar.gz` 和 `web_huakui_h5.md5`。tar 根部直接包含 `start.mjs`、`server.js`、`.next/`、`public/`、`config/production.env`、媒体密钥和裁剪后的 `node_modules/`,服务器无需 `npm install`。本地缺少忽略提交的 `config/secrets/m3u8.key` 时,构建前从服务器当前版本复制一份。
本机最低 Node.js 20.9、npm **10.9.2**,推荐 Node.js 22。正式包保留 Next standalone 已跟踪并裁剪的 `node_modules`;只在临时目录安装与 Linux 目标匹配的 Sharp/libvips 两个原生包,以替换 macOS 原生文件,版本、下载地址和 integrity 均取仓库 lockfile。最终输出为 `dist/production-linux-x64-glibc/`,包含裁剪后的 Next/React/Sharp 运行文件、`dotenv`、`@swc/helpers` 和已跟踪的 WASM 回退,不携带完整 Next 安装树、SWC 原生编译器、Playwright 或未跟踪的客户端依赖。包内无需执行 `npm install`,直接 `node start.mjs`;临时安装树不会进入发布包。 上传到 `/web_huakui/h5/` 后,服务器校验 MD5,解压到 `app.new`,沿用当前 `app/` 中的 `config/secrets/m3u8.key` 和 `config/*.local.env`,再将 `app` 改名为 `app.prev`、`app.new` 改名为 `app`,执行 `supervisorctl restart web_huakui_h5`。随后检查本机 `/healthz`(`status=ok`)和首页;失败则把新版本移到 `app.failed`,恢复 `app.prev` 并重启。可用 `DEPLOY_HOST`、`DEPLOY_SSH_PORT`、`DEPLOY_DIR`、`DEPLOY_PROGRAM`、`DEPLOY_TARGET_ARCH`、`DEPLOY_TARGET_LIBC` 覆盖默认值。
默认目标为 Linux **x64/glibc**;可设置 `DEPLOY_TARGET_ARCH=x64|arm64`、`DEPLOY_TARGET_LIBC=glibc|musl`。仓库 package/lock、本机依赖及 `dist/production/` 保持独立。运行包排除 `.env*`、`*.local.env`、日志及 macOS 元数据,密钥随包提供;生产私密覆盖配置需在服务器提供,并由服务器发布脚本保留。 本机最低 Node.js 20.9、npm **10.9.2**,推荐 Node.js 22。默认目标 Linux **x64/glibc**,只在临时目录补装目标平台的 Sharp/libvips 原生包,版本与 integrity 取自 lockfile。
默认上传及触发命令严格对应两种端口约定:
```bash
scp -P 22889 web_hkdj_h5.tar.gz web_hkdj_h5.md5 \
server@116.212.120.2:/home/server/web_hkdj/h5/nuxt/
ssh -p 22 server@116.212.120.2 /home/server/web_hkdj/h5/nuxt/running.sh
```
可通过 `DEPLOY_JUMP_HOST`、`DEPLOY_JUMP_DIR`、`DEPLOY_SCP_PORT`、`DEPLOY_SSH_PORT` 覆盖连接参数。跳板机脚本只能按完整路径执行,不带参数或额外 shell 命令;脚本和正式机 `up_node_h5.sh` 由既有部署链路管理。同仓库使用发布锁防止并发构建/上传覆盖同名包,构建后显示压缩包体积。任何构建、上传或远端执行失败均返回非零,本地包留存;正式脚本输出保存到 `dist/production-running.*`,即使远端返回 0,出现 `BACKOFF`、`FATAL`、`ERROR` 或 `spawn error` 也会判定失败。端口和健康检查由服务器发布脚本负责。正式站 Nginx 应将页面及 Next/BFF/媒体资源代理至 `26935`,公网域名需在发布后另行验证。
正式机 `up_node_h5.sh` 必须完整复制隐藏目录,例如 `cp -rf "$STAGING/." "$APP_DIR/"`;使用 `$STAGING/*` 会漏掉必要的 `.next/`。覆盖前保留 `.local.env`、媒体密钥、`template/pages/sitemaps` 和 `template/pages/sitemap.xml`,服务器已有 XML 优先于包内同名文件;外置 `SITEMAP_DIR` 或挂载目录不得随版本目录删除。复制后应检查 `.next/BUILD_ID` 和 `start.mjs`,启动一次并检查 Supervisor 状态,复制或启动失败须返回非零状态。该远端脚本由既有链路管理,不包含在本仓库。
正式 Next 运行目录使用 `/server/web_hkdj/h5/next/`,Supervisor 的 `directory` 与更新脚本的 `APP_DIR` 必须一致;更新脚本先创建目录,再检查 `cd` 是否成功。不要直接运行在旧 Go 的目录里:Next 会优先读取根部遗留的 `static/`,遮盖新包的 `public/static/`。`start.mjs` 检测到这种混用会拒绝启动;迁移时保留服务器的本地环境覆盖和 sitemap 数据。
+3 -1
View File
@@ -1,5 +1,5 @@
APP_ENV=development APP_ENV=development
UPSTREAM_API_URL=https://webhuakuiapi.mfcg.me UPSTREAM_API_URL=http://127.0.0.1:20032
# UPSTREAM_API_URL=http://172.104.181.99:30331 # UPSTREAM_API_URL=http://172.104.181.99:30331
# The API supplies imgCdn/imgAppUsCdn; this CloudFront source is only the fallback. # The API supplies imgCdn/imgAppUsCdn; this CloudFront source is only the fallback.
# Primary public domain; overrides the backend foreverUrlAddr. # Primary public domain; overrides the backend foreverUrlAddr.
@@ -19,6 +19,8 @@ SEO_BAIDU_SITE_VERIFICATION=
SEO_BAIDU_HTML_FILE= SEO_BAIDU_HTML_FILE=
# Google Analytics 4 measurement ID; leave empty to disable. # Google Analytics 4 measurement ID; leave empty to disable.
GOOGLE_ANALYTICS_ID=G-269F3ZFQFQ GOOGLE_ANALYTICS_ID=G-269F3ZFQFQ
# Yandex.Metrika counter ID; leave empty to disable.
YANDEX_METRIKA_ID=113657403
# Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service). # Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service).
CONTACT_TELEGRAM_URL=https://t.me/jbd125 CONTACT_TELEGRAM_URL=https://t.me/jbd125
APP_IOS_STORE_URL= APP_IOS_STORE_URL=
+4 -2
View File
@@ -1,6 +1,6 @@
APP_ENV=production APP_ENV=production
# Set your production API in config/production.local.env or deployment environment. # Production Go API on the same server (172.104.166.87); override in config/production.local.env if needed.
UPSTREAM_API_URL=http://127.0.0.1:26931 UPSTREAM_API_URL=http://127.0.0.1:20031
# Optional fallback when config/info does not supply an image CDN. # Optional fallback when config/info does not supply an image CDN.
# Primary public domain; overrides the backend foreverUrlAddr. # Primary public domain; overrides the backend foreverUrlAddr.
SITE_URL=https://huakuidj.com SITE_URL=https://huakuidj.com
@@ -15,6 +15,8 @@ SEO_BAIDU_SITE_VERIFICATION=
SEO_BAIDU_HTML_FILE= SEO_BAIDU_HTML_FILE=
# Google Analytics 4 measurement ID; leave empty to disable. # Google Analytics 4 measurement ID; leave empty to disable.
GOOGLE_ANALYTICS_ID=G-269F3ZFQFQ GOOGLE_ANALYTICS_ID=G-269F3ZFQFQ
# Yandex.Metrika counter ID; leave empty to disable.
YANDEX_METRIKA_ID=113657403
# Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service). # Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service).
CONTACT_TELEGRAM_URL=https://t.me/jbd125 CONTACT_TELEGRAM_URL=https://t.me/jbd125
APP_IOS_STORE_URL= APP_IOS_STORE_URL=
+2
View File
@@ -15,6 +15,8 @@ SEO_BAIDU_SITE_VERIFICATION=
SEO_BAIDU_HTML_FILE= SEO_BAIDU_HTML_FILE=
# Google Analytics 4 measurement ID; leave empty to disable. # Google Analytics 4 measurement ID; leave empty to disable.
GOOGLE_ANALYTICS_ID=G-269F3ZFQFQ GOOGLE_ANALYTICS_ID=G-269F3ZFQFQ
# Yandex.Metrika counter ID; leave empty to disable.
YANDEX_METRIKA_ID=113657403
# Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service). # Telegram contact for every TG entry (footer, navigation, return-home, legal pages, customer service).
CONTACT_TELEGRAM_URL=https://t.me/jbd125 CONTACT_TELEGRAM_URL=https://t.me/jbd125
APP_IOS_STORE_URL= APP_IOS_STORE_URL=
+85 -58
View File
@@ -1,76 +1,69 @@
#!/bin/bash #!/bin/bash
# 花魁短剧正式服:检查 → 构建 → Linux 运行包 + MD5 → 跳板机 running.sh。 # 花魁短剧正式服:检查 → 构建 → Linux 运行包 + MD5 → 上传 → 切换 app/ → Supervisor 重启 → 健康检查(失败回滚)。
set -euo pipefail set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
JUMP_HOST="${DEPLOY_JUMP_HOST:-server@116.212.120.2}" HOST="${DEPLOY_HOST:-root@172.104.166.87}"
JUMP_DIR="${DEPLOY_JUMP_DIR:-/home/server/web_hkdj/h5/nuxt}"
# 按正式服约定分别设置:SCP 为 22889,触发 SSH 为默认 22。
SCP_PORT="${DEPLOY_SCP_PORT:-22889}"
SSH_PORT="${DEPLOY_SSH_PORT:-22}" SSH_PORT="${DEPLOY_SSH_PORT:-22}"
REMOTE_DIR="${DEPLOY_DIR:-/web_huakui/h5}"
PROGRAM="${DEPLOY_PROGRAM:-web_huakui_h5}"
TARGET_ARCH="${DEPLOY_TARGET_ARCH:-x64}" TARGET_ARCH="${DEPLOY_TARGET_ARCH:-x64}"
TARGET_LIBC="${DEPLOY_TARGET_LIBC:-glibc}" TARGET_LIBC="${DEPLOY_TARGET_LIBC:-glibc}"
PACKAGE_NAME=web_hkdj_h5.tar.gz PACKAGE_NAME=web_huakui_h5.tar.gz
MD5_NAME=web_hkdj_h5.md5 MD5_NAME=web_huakui_h5.md5
PACKAGE="$SCRIPT_DIR/$PACKAGE_NAME" PACKAGE="$SCRIPT_DIR/$PACKAGE_NAME"
MD5_FILE="$SCRIPT_DIR/$MD5_NAME" MD5_FILE="$SCRIPT_DIR/$MD5_NAME"
RELEASE="$SCRIPT_DIR/dist/production-linux-$TARGET_ARCH-$TARGET_LIBC" RELEASE="$SCRIPT_DIR/dist/production-linux-$TARGET_ARCH-$TARGET_LIBC"
KEY_FILE=config/secrets/m3u8.key
PACKAGE_ONLY=0 PACKAGE_ONLY=0
DRY_RUN=0
fail() { printf '错误:%s\n' "$*" >&2; exit 1; } fail() { printf '错误:%s\n' "$*" >&2; exit 1; }
step() { printf '\n▸ %s\n' "$*"; } step() { printf '\n▸ %s\n' "$*"; }
for argument in "$@"; do for argument in "$@"; do
case "$argument" in case "$argument" in
--dry-run) DEPLOY_DRY_RUN=1 ;; --dry-run) DRY_RUN=1 ;;
--package-only) PACKAGE_ONLY=1 ;; --package-only) PACKAGE_ONLY=1 ;;
-h|--help) -h|--help)
printf '用法:%s [--dry-run | --package-only]\n' "$0" printf '用法:%s [--dry-run | --package-only]\n' "$0"
printf '默认上传 server@116.212.120.2:/home/server/web_hkdj/h5/nuxt/,SCP 22889,SSH 22。\n' printf '默认上传 root@172.104.166.87:/web_huakui/h5/,切换 app/ 后重启 Supervisor 程序 web_huakui_h5。\n'
printf '可覆盖 DEPLOY_JUMP_HOST、DEPLOY_JUMP_DIR、DEPLOY_SCP_PORT、DEPLOY_SSH_PORT、DEPLOY_TARGET_ARCH、DEPLOY_TARGET_LIBC。\n' printf '可覆盖 DEPLOY_HOST、DEPLOY_SSH_PORT、DEPLOY_DIR、DEPLOY_PROGRAM、DEPLOY_TARGET_ARCH、DEPLOY_TARGET_LIBC。\n'
exit 0 ;; exit 0 ;;
*) fail "不支持的参数 $argument;使用 --help 查看用法" ;; *) fail "不支持的参数 ${argument};使用 --help 查看用法" ;;
esac esac
done done
[[ "$JUMP_HOST" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail 'DEPLOY_JUMP_HOST 格式无效' [[ "$HOST" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail 'DEPLOY_HOST 格式无效'
[[ "$JUMP_DIR" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*/?$ ]] || fail 'DEPLOY_JUMP_DIR 必须是普通绝对路径' [[ "$REMOTE_DIR" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*/?$ ]] || fail 'DEPLOY_DIR 必须是普通绝对路径'
JUMP_DIR="${JUMP_DIR%/}" REMOTE_DIR="${REMOTE_DIR%/}"
for port in "$SCP_PORT" "$SSH_PORT"; do [[ "$PROGRAM" =~ ^[a-zA-Z0-9_-]+$ ]] || fail 'DEPLOY_PROGRAM 格式无效'
[[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || fail 'SCP/SSH 端口必须在 1–65535 内' [[ "$SSH_PORT" =~ ^[0-9]+$ ]] && [ "$SSH_PORT" -ge 1 ] && [ "$SSH_PORT" -le 65535 ] || fail 'SSH 端口必须在 1–65535 内'
done
case "$TARGET_ARCH" in x64|arm64) ;; *) fail 'DEPLOY_TARGET_ARCH 仅支持 x64 或 arm64' ;; esac case "$TARGET_ARCH" in x64|arm64) ;; *) fail 'DEPLOY_TARGET_ARCH 仅支持 x64 或 arm64' ;; esac
case "$TARGET_LIBC" in glibc|musl) ;; *) fail 'DEPLOY_TARGET_LIBC 仅支持 glibc 或 musl' ;; esac case "$TARGET_LIBC" in glibc|musl) ;; *) fail 'DEPLOY_TARGET_LIBC 仅支持 glibc 或 musl' ;; esac
for tool in node npm tar; do for tool in node npm tar; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done
command -v "$tool" >/dev/null || fail "缺少命令 $tool"
done
node -e 'const [a,b]=process.versions.node.split(".").map(Number); if(a<20 || a===20 && b<9) process.exit(1)' || fail '需要 Node.js >=20.9,推荐 22' node -e 'const [a,b]=process.versions.node.split(".").map(Number); if(a<20 || a===20 && b<9) process.exit(1)' || fail '需要 Node.js >=20.9,推荐 22'
node -e 'const [a,b,c]=process.argv[1].split(".").map(Number); if(a<10 || a===10 && (b<9 || b===9 && c<2)) process.exit(1)' "$(npm --version)" || fail '跨平台运行依赖打包需要 npm >=10.9.2' node -e 'const [a,b,c]=process.argv[1].split(".").map(Number); if(a<10 || a===10 && (b<9 || b===9 && c<2)) process.exit(1)' "$(npm --version)" || fail '跨平台运行依赖打包需要 npm >=10.9.2'
command -v md5sum >/dev/null || command -v md5 >/dev/null || fail '缺少 md5sum 或 macOS md5' command -v md5sum >/dev/null || command -v md5 >/dev/null || fail '缺少 md5sum 或 macOS md5'
for file in config/production.env config/secrets/m3u8.key package-lock.json scripts/prepare-production-runtime.ts; do for file in config/production.env package-lock.json scripts/prepare-production-runtime.ts; do
[ -s "$file" ] || fail "缺少 $file" [ -s "$file" ] || fail "缺少 $file"
done done
[ -x node_modules/.bin/tsx ] || fail '请先执行 npm ci 安装本地依赖' [ -x node_modules/.bin/tsx ] || fail '请先执行 npm ci 安装本地依赖'
if [ "${DEPLOY_DRY_RUN:-0}" = 1 ]; then if [ "$DRY_RUN" = 1 ]; then
printf 'DRY RUN:正式环境;Linux %s/%s;只核对,不构建、不连接服务器。\n' "$TARGET_ARCH" "$TARGET_LIBC" printf 'DRY RUN:正式环境;Linux %s/%s;只核对,不构建、不连接服务器。\n' "$TARGET_ARCH" "$TARGET_LIBC"
node --input-type=module -e 'import {readFileSync} from "node:fs"; import {parse} from "dotenv"; const c=parse(readFileSync("config/production.env")); console.log(` 服务端口 PORT=${c.PORT};仅打包 config/production.env,不上传本地覆盖。`)' node --input-type=module -e 'import {readFileSync} from "node:fs"; import {parse} from "dotenv"; const c=parse(readFileSync("config/production.env")); console.log(` PORT=${c.PORT};UPSTREAM_API_URL=${c.UPSTREAM_API_URL}`)'
printf ' npm run check → build → bundle → bundle:linux -- %s %s\n' "$TARGET_ARCH" "$TARGET_LIBC" printf ' npm run check → build → bundle → bundle:linux -- %s %s → %s + %s\n' "$TARGET_ARCH" "$TARGET_LIBC" "$PACKAGE_NAME" "$MD5_NAME"
printf ' 保留 standalone 裁剪依赖,仅补目标 Linux Sharp/libvips;不安装完整生产依赖。\n' [ -s "$KEY_FILE" ] || printf ' 本地缺少 %s,构建前从服务器当前版本只读复制(忽略提交)。\n' "$KEY_FILE"
printf ' tar 根目录:start.mjs、server.js、node_modules、.next、public、config 等\n' if [ "$PACKAGE_ONLY" = 0 ]; then
printf ' 生成 %s + %s(纯 MD5 hash)\n' "$PACKAGE_NAME" "$MD5_NAME" printf ' scp → %s:%s/;解压到 app.new,保留服务器密钥与 *.local.env\n' "$HOST" "$REMOTE_DIR"
if [ "$PACKAGE_ONLY" = 1 ]; then printf ' app → app.prev,app.new → app;supervisorctl restart %s;/healthz 失败则回滚\n' "$PROGRAM"
printf ' --package-only:生成后结束,不上传。\n'
else
printf ' scp -P %s %s %s %s:%s/\n' "$SCP_PORT" "$PACKAGE_NAME" "$MD5_NAME" "$JUMP_HOST" "$JUMP_DIR"
printf ' ssh -p %s %s %s/running.sh(不带脚本参数)\n' "$SSH_PORT" "$JUMP_HOST" "$JUMP_DIR"
fi fi
exit 0 exit 0
fi fi
if [ "$PACKAGE_ONLY" = 0 ]; then for tool in ssh scp; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done
for tool in ssh scp tee; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done SSH=(ssh -p "$SSH_PORT" -o ConnectTimeout=15 "$HOST")
fi
# 同仓库只能有一次构建/打包/上传,避免并发覆盖同名包或提前触发 running.sh。 # 同仓库只允许一次构建/上传,避免并发覆盖同名包。
mkdir -p "$SCRIPT_DIR/dist" mkdir -p "$SCRIPT_DIR/dist"
DEPLOY_LOCK="$SCRIPT_DIR/dist/.production-deploy.lock" DEPLOY_LOCK="$SCRIPT_DIR/dist/.production-deploy.lock"
mkdir "$DEPLOY_LOCK" 2>/dev/null || fail "已有正式发布进程或残留锁:${DEPLOY_LOCK};请先确认该进程已结束" mkdir "$DEPLOY_LOCK" 2>/dev/null || fail "已有正式发布进程或残留锁:${DEPLOY_LOCK};请先确认该进程已结束"
@@ -78,14 +71,22 @@ trap 'rmdir "$DEPLOY_LOCK" 2>/dev/null || true' EXIT
trap 'exit 130' INT trap 'exit 130' INT
trap 'exit 143' TERM trap 'exit 143' TERM
if [ ! -s "$KEY_FILE" ]; then
step "本地缺少 ${KEY_FILE},从服务器当前版本复制(目录已忽略提交)"
mkdir -p "$(dirname "$KEY_FILE")"
scp -P "$SSH_PORT" "$HOST:$REMOTE_DIR/app/$KEY_FILE" "$KEY_FILE"
chmod 600 "$KEY_FILE"
[ -s "$KEY_FILE" ] || fail "无法获取 $KEY_FILE"
fi
step '检查类型和单元测试' step '检查类型和单元测试'
npm run check npm run check
step '构建并整理正式环境 standalone' step '构建并整理正式环境 standalone'
npm run build npm run build
npm run bundle npm run bundle
step "准备 standalone 精简运行包,仅补 Linux $TARGET_ARCH/$TARGET_LIBC 图片库" step "准备 Linux $TARGET_ARCH/$TARGET_LIBC 精简运行包"
npm run bundle:linux -- "$TARGET_ARCH" "$TARGET_LIBC" npm run bundle:linux -- "$TARGET_ARCH" "$TARGET_LIBC"
for file in start.mjs server.js .next/BUILD_ID config/production.env config/secrets/m3u8.key runtime-target.json; do for file in start.mjs server.js .next/BUILD_ID config/production.env "$KEY_FILE" runtime-target.json; do
[ -s "$RELEASE/$file" ] || fail "正式运行包缺少 $file" [ -s "$RELEASE/$file" ] || fail "正式运行包缺少 $file"
done done
for directory in node_modules .next/static public; do for directory in node_modules .next/static public; do
@@ -101,31 +102,57 @@ COPYFILE_DISABLE=1 COPY_EXTENDED_ATTRIBUTES_DISABLE=1 tar \
-czf "$PACKAGE" -C "$RELEASE" . -czf "$PACKAGE" -C "$RELEASE" .
chmod 600 "$PACKAGE" chmod 600 "$PACKAGE"
if command -v md5sum >/dev/null; then if command -v md5sum >/dev/null; then
md5sum "$PACKAGE" | awk '{print $1}' > "$MD5_FILE" md5sum "$PACKAGE" | awk '{print $1}' > "$MD5_FIL
E"
else else
md5 -q "$PACKAGE" > "$MD5_FILE" md5 -q "$PACKAGE" > "$MD5_FILE"
fi fi
tar -tzf "$PACKAGE" >/dev/null tar -tzf "$PACKAGE" >/dev/null
printf '运行包已生成:%s;校验文件:%s\n' "$PACKAGE_NAME" "$MD5_NAME" node -e 'console.log(`运行包已生成,体积 ${(require("node:fs").statSync(process.argv[1]).size / 1024 / 1024).toFixed(2)} MiB`)' "$PACKAGE"
node -e 'console.log(`压缩包体积:${(require("node:fs").statSync(process.argv[1]).size / 1024 / 1024).toFixed(2)} MiB`)' "$PACKAGE"
if [ "$PACKAGE_ONLY" = 1 ]; then exit 0; fi if [ "$PACKAGE_ONLY" = 1 ]; then exit 0; fi
step "上传到跳板机 $JUMP_HOST:$JUMP_DIR/" step "上传到 $HOST:$REMOTE_DIR/"
scp -P "$SCP_PORT" "$PACKAGE" "$MD5_FILE" "$JUMP_HOST:$JUMP_DIR/" scp -P "$SSH_PORT" "$PACKAGE" "$MD5_FILE" "$HOST:$REMOTE_DIR/"
step '执行既有正式服 running.sh'
# gatekeeper 按完整 .sh 路径放行,不能附参数、cd、bash 前缀或其它命令。 step "切换版本并重启 $PROGRAM"
RUN_LOG=$(mktemp "$SCRIPT_DIR/dist/production-running.XXXXXX") "${SSH[@]}" "bash -s -- '$REMOTE_DIR' '$PROGRAM' '$PACKAGE_NAME' '$MD5_NAME'" <<'REMOTE'
printf '远端执行日志:%s\n' "$RUN_LOG" set -euo pipefail
if ! ssh -p "$SSH_PORT" "$JUMP_HOST" "$JUMP_DIR/running.sh" 2>&1 | tee "$RUN_LOG"; then DIR=$1 PROGRAM=$2 PACKAGE=$3 MD5=$4
fail "正式服 running.sh 执行失败;日志:${RUN_LOG}" cd "$DIR"
[ "$(md5sum "$PACKAGE" | awk '{print $1}')" = "$(tr -d '[:space:]' < "$MD5")" ] || { echo "MD5 校验失败"; exit 1; }
rm -rf app.new && mkdir app.new
tar -xzf "$PACKAGE" -C app.new
[ -s app.new/start.mjs ] && [ -s app.new/.next/BUILD_ID ] || { echo "运行包内容不完整"; exit 1; }
# 服务器上的密钥和本地覆盖配置优先于包内文件,且不随发布丢失。
if [ -d app ]; then
[ -s app/config/secrets/m3u8.key ] && install -m 600 app/config/secrets/m3u8.key app.new/config/secrets/m3u8.key
for file in app/config/*.local.env; do [ -f "$file" ] && cp -p "$file" app.new/config/; done
fi fi
# 部分既有服务器脚本没有透传 Supervisor 的失败退出码,不能只依据 SSH 返回 0。 PORT=$(sed -n 's/^PORT=//p' app.new/config/production.env | tail -n 1)
if ! node - "$RUN_LOG" <<'NODE' PORT=${PORT:-26935}
const { readFileSync } = require('node:fs'); rm -rf app.prev
const output = readFileSync(process.argv[2], 'utf8').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ''); [ -d app ] && mv app app.prev
if (/\b(?:BACKOFF|FATAL|ERROR)\b|\bspawn\s+error\b/i.test(output)) process.exit(1); mv app.new app
NODE supervisorctl restart "$PROGRAM" >/dev/null || true
then healthy() {
fail "正式服输出包含失败状态,不能确认发布成功;日志:${RUN_LOG}" for _ in $(seq 1 30); do
if curl -fsS -m 4 "http://127.0.0.1:$PORT/healthz" | grep -q '"status":"ok"'; then return 0; fi
sleep 2
done
return 1
}
if healthy && curl -fsS -m 10 -o /dev/null "http://127.0.0.1:$PORT/"; then
supervisorctl status "$PROGRAM"
echo "DEPLOY_OK BUILD_ID=$(cat app/.next/BUILD_ID)"
exit 0
fi fi
printf '\n正式服 running.sh 执行完成,未发现失败状态;日志:%s;本地包已保留供排查。\n' "$RUN_LOG" echo "新版本健康检查失败,回滚到 app.prev"
tail -n 40 "/var/log/$PROGRAM.log" 2>/dev/null || true
if [ -d app.prev ]; then
rm -rf app.failed && mv app app.failed && mv app.prev app
supervisorctl restart "$PROGRAM" >/dev/null || true
healthy && echo "已回滚并恢复服务;失败版本保留在 app.failed" || echo "回滚后服务仍未恢复,请立即人工检查"
fi
exit 1
REMOTE
printf '\n正式服发布完成。\n'
-124
View File
@@ -1,124 +0,0 @@
#!/bin/bash
# 花魁短剧测试服:检查 → 构建 → dist.zip → 上传 → Supervisor 重启/健康检查。
# DEPLOY_DRY_RUN=1 ./deploy_test.sh 仅核对,不构建、不连接服务器。
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$SCRIPT_DIR"
HOST="${DEPLOY_HOST:-root@172.104.181.99}"
SSH_PORT="${DEPLOY_SSH_PORT:-22889}"
REMOTE_DIR="${DEPLOY_DIR:-/server/hkdj_net/h5}"
CHECK_URL="${DEPLOY_CHECK_URL:-https://hkdjneth5.remoces.com}"
PACKAGE="$SCRIPT_DIR/dist.zip"
CHECKSUM="$SCRIPT_DIR/dist.zip.sha256"
fail() { printf '错误:%s\n' "$*" >&2; exit 1; }
step() { printf '\n▸ %s\n' "$*"; }
for argument in "$@"; do
case "$argument" in
--dry-run) DEPLOY_DRY_RUN=1 ;;
-h|--help)
printf '用法:%s [--dry-run]\n默认目标 root@172.104.181.99:22889 /server/hkdj_net/h5/\n可覆盖 DEPLOY_HOST、DEPLOY_SSH_PORT、DEPLOY_DIR、DEPLOY_CHECK_URL;DEPLOY_DRY_RUN=1 等同 --dry-run。\n' "$0"
exit 0 ;;
*) fail "不支持的参数 $argument;使用 --help 查看用法" ;;
esac
done
# 这些值会作为远端路径/命令使用,仅允许普通 SSH 目标和绝对路径。
[[ "$HOST" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail "DEPLOY_HOST 格式无效"
[[ "$SSH_PORT" =~ ^[0-9]+$ ]] && [ "$SSH_PORT" -ge 1 ] && [ "$SSH_PORT" -le 65535 ] || fail "DEPLOY_SSH_PORT 无效"
[[ "$REMOTE_DIR" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*/?$ ]] || fail "DEPLOY_DIR 必须是普通绝对路径"
for tool in node npm zip unzip ssh scp; do
command -v "$tool" >/dev/null || fail "缺少命令 $tool"
done
node -e 'const [a,b]=process.versions.node.split(".").map(Number); if(a<20 || (a===20 && b<9)) process.exit(1)' || fail "需要 Node.js >=20.9,推荐 Node.js 22"
node -e 'try { const u=new URL(process.argv[1]); if (!["http:","https:"].includes(u.protocol) || u.username || u.password || u.search || u.hash || u.pathname!=="/") process.exit(1); } catch { process.exit(1); }' "$CHECK_URL" || fail "DEPLOY_CHECK_URL 必须是 HTTP(S) 站点根地址"
for file in config/test.env config/secrets/m3u8.key deploy/up_next_h5.sh package-lock.json; do
[ -s "$file" ] || fail "缺少 $file"
done
bash -n deploy/up_next_h5.sh
if [ "${DEPLOY_DRY_RUN:-0}" = 1 ]; then
printf 'DRY RUN:测试配置 config/test.env;上游与端口可由服务器 config/test.local.env 覆盖。\n'
# 不输出本地覆盖/进程配置中的凭证;只展示仓库公共配置。
node --input-type=module -e 'import {readFileSync} from "node:fs"; import {parse} from "dotenv"; const c=parse(readFileSync("config/test.env")); console.log(` API=${c.UPSTREAM_API_URL} PORT=${c.PORT}`)'
printf ' npm run check → npm run build:test → npm run bundle:test\n'
printf ' dist.zip(不含 node_modules / 本地覆盖)+ SHA-256 → %s:%s/(SSH %s)\n' "$HOST" "$REMOTE_DIR" "$SSH_PORT"
printf ' ssh 执行 %s/up_next_h5.sh:安装 Linux 运行依赖 → next/ → Supervisor hkdj_next_h5 → 健康检查\n' "$REMOTE_DIR"
printf ' 公网检查 %s:/healthz(test)+ 首页(Next HTML);此模式不访问域名。\n' "$CHECK_URL"
exit 0
fi
step '检查类型和单元测试'
npm run check
step '构建并整理测试环境 standalone'
npm run build:test
npm run bundle:test
for file in start.mjs server.js .next-test/BUILD_ID config/test.env config/secrets/m3u8.key; do
[ -s "dist/test/$file" ] || fail "测试包缺少 $file"
done
[ -d dist/test/.next-test/static ] && [ -d dist/test/public ] || fail '测试包缺少静态资源'
# Next 本机的原生模块不能直接用于 Linux:远端用 lockfile 安装运行依赖。
# dotenv 属于 dependencies,start.mjs 在 npm ci --omit=dev 后仍可加载配置。
cp package.json package-lock.json dist/test/
step '生成 dist.zip 和 SHA-256(保留本地包便于排查)'
rm -f "$PACKAGE" "$CHECKSUM"
(
cd dist/test
COPYFILE_DISABLE=1 zip -q -r "$PACKAGE" . \
-x 'node_modules/*' '*/node_modules/*' '*.local.env' '.env*' '*/.env*' '._*' '*/._*' '*.log'
)
chmod 600 "$PACKAGE"
if command -v sha256sum >/dev/null; then
sha256sum dist.zip > "$CHECKSUM"
else
shasum -a 256 dist.zip > "$CHECKSUM"
fi
unzip -tq "$PACKAGE"
step "上传到 $HOST:$REMOTE_DIR/"
ssh -p "$SSH_PORT" "$HOST" "mkdir -p '$REMOTE_DIR'"
scp -P "$SSH_PORT" "$PACKAGE" "$CHECKSUM" deploy/up_next_h5.sh "$HOST:$REMOTE_DIR/"
step '执行服务器更新脚本'
ssh -p "$SSH_PORT" "$HOST" "/bin/bash '$REMOTE_DIR/up_next_h5.sh'"
step "检查公网测试站 $CHECK_URL"
if ! node --input-type=module - "$CHECK_URL" <<'NODE'
const origin = process.argv[2];
async function read(path) {
const response = await fetch(new URL(path, origin), { signal: AbortSignal.timeout(4000), redirect: 'error', cache: 'no-store' });
if (response.status !== 200) throw new Error(`${path} HTTP ${response.status}`);
let text = '', size = 0;
const decoder = new TextDecoder();
for await (const part of response.body) {
size += part.length;
if (size > 262144) throw new Error(`${path} 响应超过检查上限`);
text += decoder.decode(part, { stream: true });
if (path === '/' && text.includes('/_next/')) break;
}
return { text, type: response.headers.get('content-type') || '' };
}
for (let attempt = 1; attempt <= 3; attempt++) {
try {
const [health, home] = await Promise.all([read('/healthz'), read('/')]);
let status;
try { status = JSON.parse(health.text); } catch { throw new Error('/healthz 返回无效 JSON'); }
if (status?.status !== 'ok' || status?.environment !== 'test') throw new Error('/healthz 未命中 Next 测试服务');
if (!/^text\/html(?:;|$)/i.test(home.type) || !home.text.includes('/_next/')) throw new Error('首页不是 Next HTML');
console.log('公网 /healthz 与 Next 首页检查通过');
process.exit(0);
} catch (error) {
console.error(`公网检查 ${attempt}/3 失败:${error.message}`);
if (attempt < 3) await new Promise(resolve => setTimeout(resolve, 1000));
}
}
process.exit(1);
NODE
then
fail '服务器服务已更新,但域名检查失败,请检查 Nginx 和 Cloudflare 回源配置。'
fi
printf '\n测试服更新完成:%s:%s/next(Supervisor:hkdj_next_h5)\n' "$HOST" "$REMOTE_DIR"
+2 -2
View File
@@ -1,7 +1,7 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
import "./.next-dev/dev/types/routes.d.ts"; import "./.next/types/routes.d.ts";
import "./.next-dev/dev/types/root-params.d.ts"; import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+41 -41
View File
@@ -9,7 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"next": "16.4.0", "next": "16.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
@@ -1021,15 +1021,15 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "16.4.0", "version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.2.tgz",
"integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==", "integrity": "sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/swc-darwin-arm64": { "node_modules/@next/swc-darwin-arm64": {
"version": "16.4.0", "version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.2.tgz",
"integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==", "integrity": "sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1043,9 +1043,9 @@
} }
}, },
"node_modules/@next/swc-darwin-x64": { "node_modules/@next/swc-darwin-x64": {
"version": "16.4.0", "version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.2.tgz",
"integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==", "integrity": "sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1059,9 +1059,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-gnu": { "node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.4.0", "version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.2.tgz",
"integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==", "integrity": "sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1075,9 +1075,9 @@
} }
}, },
"node_modules/@next/swc-linux-arm64-musl": { "node_modules/@next/swc-linux-arm64-musl": {
"version": "16.4.0", "version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.2.tgz",
"integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==", "integrity": "sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1091,9 +1091,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-gnu": { "node_modules/@next/swc-linux-x64-gnu": {
"version": "16.4.0", "version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.2.tgz",
"integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==", "integrity": "sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1107,9 +1107,9 @@
} }
}, },
"node_modules/@next/swc-linux-x64-musl": { "node_modules/@next/swc-linux-x64-musl": {
"version": "16.4.0", "version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.2.tgz",
"integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==", "integrity": "sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1123,9 +1123,9 @@
} }
}, },
"node_modules/@next/swc-win32-arm64-msvc": { "node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.4.0", "version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.2.tgz",
"integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==", "integrity": "sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -1139,9 +1139,9 @@
} }
}, },
"node_modules/@next/swc-win32-x64-msvc": { "node_modules/@next/swc-win32-x64-msvc": {
"version": "16.4.0", "version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz", "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.2.tgz",
"integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==", "integrity": "sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -1627,12 +1627,12 @@
} }
}, },
"node_modules/next": { "node_modules/next": {
"version": "16.4.0", "version": "16.4.2",
"resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz", "resolved": "https://registry.npmjs.org/next/-/next-16.4.2.tgz",
"integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==", "integrity": "sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@next/env": "16.4.0", "@next/env": "16.4.2",
"@swc/helpers": "0.5.23", "@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19", "baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
@@ -1646,15 +1646,15 @@
"node": ">=20.9.0" "node": ">=20.9.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@next/swc-darwin-arm64": "16.4.0", "@next/swc-darwin-arm64": "16.4.2",
"@next/swc-darwin-x64": "16.4.0", "@next/swc-darwin-x64": "16.4.2",
"@next/swc-linux-arm64-gnu": "16.4.0", "@next/swc-linux-arm64-gnu": "16.4.2",
"@next/swc-linux-arm64-musl": "16.4.0", "@next/swc-linux-arm64-musl": "16.4.2",
"@next/swc-linux-x64-gnu": "16.4.0", "@next/swc-linux-x64-gnu": "16.4.2",
"@next/swc-linux-x64-musl": "16.4.0", "@next/swc-linux-x64-musl": "16.4.2",
"@next/swc-win32-arm64-msvc": "16.4.0", "@next/swc-win32-arm64-msvc": "16.4.2",
"@next/swc-win32-x64-msvc": "16.4.0", "@next/swc-win32-x64-msvc": "16.4.2",
"sharp": "^0.35.4" "sharp": "^0.35.5"
}, },
"peerDependencies": { "peerDependencies": {
"@opentelemetry/api": "^1.1.0", "@opentelemetry/api": "^1.1.0",
+1 -1
View File
@@ -40,7 +40,7 @@
}, },
"dependencies": { "dependencies": {
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"next": "16.4.0", "next": "16.4.2",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "19.3.0", "react": "19.3.0",
"react-dom": "19.3.0", "react-dom": "19.3.0",
+3 -4
View File
@@ -415,10 +415,9 @@ try {
name + " action must be removed", name + " action must be removed",
); );
assert.equal(await page.locator(".hk-playback-comments, .hk-playback-composer").count(), 0); assert.equal(await page.locator(".hk-playback-comments, .hk-playback-composer").count(), 0);
assert.deepEqual( // Recommendations stay in the main column; the trending list sits in the right sidebar.
await page.locator(".hk-playback-dual > *").evaluateAll((nodes) => nodes.map((node) => node.className)), assert.equal(await page.locator(".hk-playback-body__main > .hk-playback-like").count(), 1);
["hk-playback-like", "hk-playback-hot"], assert.equal(await page.locator(".hk-playback-body__aside > .hk-playback-hot").count(), 1);
);
assert.match( assert.match(
(await page.locator(".hk-playback-like").textContent()) || "", (await page.locator(".hk-playback-like").textContent()) || "",
/猜你喜欢测试/, /猜你喜欢测试/,
+3 -1
View File
@@ -35,10 +35,12 @@ import "@/styles/pages/mobile-search.css";
import "@/styles/pages/site-navigation.css"; import "@/styles/pages/site-navigation.css";
import "@/styles/pages/home-road.css"; import "@/styles/pages/home-road.css";
import "@/styles/pages/playback.css"; import "@/styles/pages/playback.css";
import "@/styles/overrides/drama-tiles.css";
import "@/styles/overrides/pwa-safe-area.css"; import "@/styles/overrides/pwa-safe-area.css";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { PwaProvider } from "@/components/pwa/PwaProvider"; import { PwaProvider } from "@/components/pwa/PwaProvider";
import { GoogleAnalytics } from "@/components/analytics/GoogleAnalytics"; import { GoogleAnalytics } from "@/components/analytics/GoogleAnalytics";
import { YandexMetrika } from "@/components/analytics/YandexMetrika";
import { serverConfig } from "@/server/config"; import { serverConfig } from "@/server/config";
import { pwaViewport } from "@/lib/pwa/config"; import { pwaViewport } from "@/lib/pwa/config";
import { getRequestPage } from "@/server/react/request-page"; import { getRequestPage } from "@/server/react/request-page";
@@ -83,7 +85,7 @@ export default async function RootLayout({
/> />
)} )}
</head> </head>
<body className={presentation.bodyClass}><PwaProvider />{children}<GoogleAnalytics id={serverConfig().googleAnalyticsID} /></body> <body className={presentation.bodyClass}><PwaProvider />{children}<GoogleAnalytics id={serverConfig().googleAnalyticsID} /><YandexMetrika id={serverConfig().yandexMetrikaID} /></body>
</html> </html>
); );
} }
@@ -0,0 +1,23 @@
import Script from "next/script";
/** Yandex.Metrika counter; loads after hydration, with the official noscript pixel. */
export function YandexMetrika({ id }: { id: string }) {
if (!id) return null;
const src = `https://mc.yandex.ru/metrika/tag.js?id=${id}`;
return (
<>
<Script id="yandex-metrika" strategy="afterInteractive">
{`(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};m[i].l=1*new Date();for(var j=0;j<document.scripts.length;j++){if(document.scripts[j].src===r){return;}}k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})(window,document,'script',${JSON.stringify(src)},'ym');ym(${id},'init',{ssr:true,webvisor:true,clickmap:true,ecommerce:"dataLayer",referrer:document.referrer,url:location.href,accurateTrackBounce:true,trackLinks:true});`}
</Script>
<noscript>
<div>
<img
src={`https://mc.yandex.ru/watch/${id}`}
style={{ position: "absolute", left: "-9999px" }}
alt=""
/>
</div>
</noscript>
</>
);
}
+41 -15
View File
@@ -9,7 +9,6 @@ import {
cardTags, cardTags,
categoryURL, categoryURL,
displayName, displayName,
episodeChip,
episodeLabel, episodeLabel,
episodeStatus, episodeStatus,
externalProps, externalProps,
@@ -49,25 +48,50 @@ export function CardTags({
</> </>
); );
} }
/** Episode chip precedes the theme tags, matching the reference card meta row. */ /** Genre line under the title: slash-separated theme tags (episode info lives on the cover). */
export function DramaCardMeta({ item }: { item: CatalogRecord }) { export function DramaCardMeta({ item }: { item: CatalogRecord }) {
const chip = episodeChip(item); const tags = cardTags(item);
if (!tags.length) return null;
return ( return (
<p className="hk-drama-card__meta hk-drama-card__meta--ep"> <p className="hk-drama-card__meta hk-drama-card__genres">
{chip && ( {tags.map((tag, index) => (
<span <Fragment key={tag.Name}>
className="hk-drama-card__meta-ep" {index > 0 && (
data-status={num(item, "UpdateStatus") === 1 ? "serial" : "done"} <span className="hk-drama-card__genre-sep" aria-hidden="true">
> /
{chip} </span>
</span> )}
)} {tag.URL ? (
<span className="hk-drama-card__meta-tags"> <a href={tag.URL} {...externalProps(tag.URL)}>
<CardTags item={item} /> {tag.Name}
</span> </a>
) : (
<span>{tag.Name}</span>
)}
</Fragment>
))}
</p> </p>
); );
} }
/** Cover corner badge: episode count first, then serial/finished state. */
export function DramaCardEpisodes({ item }: { item: CatalogRecord }) {
const total = num(item, "TotalEpisode"),
serial = num(item, "UpdateStatus") === 1;
if (!total && !serial) return null;
return (
<span
className="hk-drama-card__eps"
data-status={serial ? "serial" : "done"}
>
{total > 0 && (
<>
<b>{total}</b> 集 ·{" "}
</>
)}
{serial ? "连更" : "完结"}
</span>
);
}
export function DramaCard({ export function DramaCard({
item, item,
recent = false, recent = false,
@@ -94,6 +118,7 @@ export function DramaCard({
) : str(item, "Anchor") === "原创" ? ( ) : str(item, "Anchor") === "原创" ? (
<span className="hk-badge hk-badge--original">原创</span> <span className="hk-badge hk-badge--original">原创</span>
) : null} ) : null}
<DramaCardEpisodes item={item} />
</EncryptedBackground> </EncryptedBackground>
<h3 className="hk-drama-card__title"> <h3 className="hk-drama-card__title">
<a href={href}>{name}</a> <a href={href}>{name}</a>
@@ -278,6 +303,7 @@ export function PlayRecommendationCard({ item }: { item: CatalogRecord }) {
href={href} href={href}
src={str(item, "Cover")} src={str(item, "Cover")}
> >
<DramaCardEpisodes item={item} />
</EncryptedBackground> </EncryptedBackground>
<h3 className="hk-drama-card__title"> <h3 className="hk-drama-card__title">
<a href={href}>{displayName(item)}</a> <a href={href}>{displayName(item)}</a>
+211 -166
View File
@@ -25,7 +25,14 @@ import {
import { coinGate } from "./model"; import { coinGate } from "./model";
import { PlaybackStage } from "./PlaybackStage"; import { PlaybackStage } from "./PlaybackStage";
import { AppOnlyDialog } from "./AppOnlyDialog"; import { AppOnlyDialog } from "./AppOnlyDialog";
import { GuessLike, HotList, PlaybackShare, Related, Synopsis } from "./Shared"; import {
GuessLike,
HotList,
PlaybackShare,
Related,
SectionEyebrow,
Synopsis,
} from "./Shared";
export function DramaWatchPage({ data }: { data: Data }) { export function DramaWatchPage({ data }: { data: Data }) {
const p = data.playlet || {}, const p = data.playlet || {},
id = num(p, "ID"), id = num(p, "ID"),
@@ -109,7 +116,7 @@ export function DramaWatchPage({ data }: { data: Data }) {
</a> </a>
<Icon name="chevron-right" className="hk-breadcrumb__sep" /> <Icon name="chevron-right" className="hk-breadcrumb__sep" />
<a className="hk-breadcrumb__item" href="/dramas/"> <a className="hk-breadcrumb__item" href="/dramas/">
短剧 短剧库
</a> </a>
<Icon name="chevron-right" className="hk-breadcrumb__sep" /> <Icon name="chevron-right" className="hk-breadcrumb__sep" />
<a className="hk-breadcrumb__item" href={playURL(get(p, "Slug"), id)}> <a className="hk-breadcrumb__item" href={playURL(get(p, "Slug"), id)}>
@@ -128,178 +135,216 @@ export function DramaWatchPage({ data }: { data: Data }) {
onPlaying={onPlaying} onPlaying={onPlaying}
autoPlay={autoPlay} autoPlay={autoPlay}
/> />
<section className="hk-playback-info" aria-label="剧集信息"> <div className="hk-playback-body">
<div className="hk-playback-meta"> <div className="hk-playback-body__main">
<div className="hk-playback-meta__title-row"> <section className="hk-playback-info" aria-label="剧集信息">
<h1 className="hk-playback-meta__title">{name}</h1> <div className="hk-playback-meta">
{get(p, "IsFeatured") ? ( <p className="hk-playback-meta__eyebrow">
<span className="hk-badge hk-badge--hot">爆剧</span> <span>正在播放</span>
) : anchor === "原创" ? ( 第 {episode} 集{total > 0 ? ` / 共 ${total} 集` : ""}
<span className="hk-badge hk-badge--original">原创</span> </p>
) : null} <div className="hk-playback-meta__title-row">
</div> <h1 className="hk-playback-meta__title">{name}</h1>
<div className="hk-playback-meta__chips"> {get(p, "IsFeatured") ? (
{total > 0 && <span className="hk-playback-chip">全 {total} 集</span>} <span className="hk-badge hk-badge--hot">爆剧</span>
<span className="hk-playback-chip">{status}</span> ) : anchor === "原创" ? (
{year && <span className="hk-playback-chip">{year}</span>} <span className="hk-badge hk-badge--original">原创</span>
<span className="hk-playback-chip">1080P</span> ) : null}
</div> </div>
{["hk-desktop-only", "hk-mobile-only"].map((device) => ( <dl className="hk-playback-facts">
<p className={`hk-playback-meta__stats ${device}`} key={device}> {total > 0 && (
{anchor && !["原创", "已注销"].includes(anchor) && ( <div className="hk-playback-facts__item">
<span className="hk-playback-meta__stat">主演 {anchor}</span> <dt>集数</dt>
)} <dd>{total} 集</dd>
{!!themes.length && ( </div>
<span className="hk-playback-meta__stat"> )}
{device === "hk-desktop-only" ? "题材 " : ""} <div className="hk-playback-facts__item">
{themes.map((tag, index) => ( <dt>状态</dt>
<span key={tag.Name}> <dd data-status={status === "连载中" ? "serial" : "done"}>{status}</dd>
{index ? " " : ""} </div>
{tag.URL ? ( {year && (
<a <div className="hk-playback-facts__item">
className="hk-playback-meta__tag" <dt>上线</dt>
href={tag.URL} <dd>{year} 年</dd>
{...externalProps(tag.URL)} </div>
> )}
{tag.Name} <div className="hk-playback-facts__item">
</a> <dt>画质</dt>
) : ( <dd>1080P</dd>
<span className="hk-playback-meta__tag">{tag.Name}</span> </div>
)} {anchor && !["原创", "已注销"].includes(anchor) && (
</span> <div className="hk-playback-facts__item">
<dt>主演</dt>
<dd>{anchor}</dd>
</div>
)}
{!!themes.length && (
<div className="hk-playback-facts__item hk-playback-facts__item--wide">
<dt>题材</dt>
<dd>
{themes.map((tag, index) => (
<span key={tag.Name}>
{index ? " / " : ""}
{tag.URL ? (
<a
className="hk-playback-meta__tag"
href={tag.URL}
{...externalProps(tag.URL)}
>
{tag.Name}
</a>
) : (
<span className="hk-playback-meta__tag">{tag.Name}</span>
)}
</span>
))}
</dd>
</div>
)}
<div className="hk-playback-facts__item">
<dt>热度</dt>
<dd>{formatLikeCount(get(p, "HeatValue"))}</dd>
</div>
<div className="hk-playback-facts__item">
<dt>收藏</dt>
<dd>{formatLikeCount(get(p, "CountCollect"))}</dd>
</div>
</dl>
</div>
<div className="hk-playback-actions">
<button
className="hk-button hk-button--primary hk-playback-actions__btn hk-playback-actions__btn--play"
type="button"
onClick={play}
>
<Icon name="play_hollow" />
<span>
{mode === "preview"
? "播放预告片"
: data.canWatch
? "立即播放"
: "去 App 看"}
</span>
</button>
<PlaybackShare
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
label
/>
<a
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
href="/feedback/"
aria-label="反馈"
>
<Icon name="message-circle" />
<span className="hk-desktop-only">反馈</span>
</a>
</div>
<Synopsis
name={name}
text={first(get(p, "Desc"), get(p, "Description"))}
tags={namedTags(get(p, "TagIDs"), get(p, "TagNames"), get(p, "TagSlugs"))}
/>
</section>
<section className="hk-playback-episodes" aria-label="全集列表">
<div className="hk-playback-episodes__head">
<div className="hk-playback-episodes__head-left">
<div className="hk-section-heading">
<SectionEyebrow>EPISODES</SectionEyebrow>
<h2 className="hk-playback-episodes__title">全集列表</h2>
</div>
<span className="hk-playback-episodes__count">
共 {total || chapters.length} 集 {status}
</span>
<nav
className="hk-playback-episodes__sort hk-desktop-only"
aria-label="分集排序"
>
{[false, true].map((value) => (
<button
key={String(value)}
className={`hk-playback-episodes__sort-item${descending === value ? " hk-is-active" : ""}`}
type="button"
aria-pressed={descending === value}
onClick={() => setDescending(value)}
>
{value ? "倒序" : "正序"}
</button>
))} ))}
</nav>
</div>
<button
className="hk-playback-episodes__order hk-mobile-only"
type="button"
aria-pressed={descending}
onClick={() => setDescending(!descending)}
>
<span>{descending ? "正序" : "倒序"}</span>
<Icon name="chevron-right" />
</button>
</div>
<div className="hk-playback-episodes__grid">
{(descending ? [...chapters].reverse() : chapters).map(
(chapter, index) => {
const chid = num(chapter, "ID"),
ep =
num(chapter, "CurrentEpisode") ||
(descending ? chapters.length - index : index + 1);
return (
<a
key={chid}
className={`hk-playback-ep${chid === chapterID ? " hk-is-active" : ""}${coinGate(chapter) ? " hk-playback-ep--coin" : ""}`}
href={chapterPlayURL(get(p, "Slug"), id, chid)}
aria-label={`第 ${ep} 集`}
>
<span className="hk-playback-ep__label" aria-hidden="true">EP</span>
<span className="hk-playback-ep__num">{String(ep).padStart(2, "0")}</span>
</a>
);
},
)}
{!chapters.length && (
<span className="hk-playback-ep hk-is-active">
<span className="hk-playback-ep__label" aria-hidden="true">EP</span>
<span className="hk-playback-ep__num">01</span>
</span> </span>
)} )}
<span className="hk-playback-meta__stat"> </div>
热度 {formatLikeCount(get(p, "HeatValue"))} <div className="hk-playback-episodes__legend">
<span className="hk-playback-legend">
<i
className="hk-playback-legend__swatch hk-playback-legend__swatch--free"
aria-hidden="true"
/>
<span className="hk-desktop-only">免费集 在线即看</span>
<span className="hk-mobile-only">免费集在线看</span>
</span> </span>
<span className="hk-playback-meta__stat"> <span className="hk-playback-legend">
收藏 <span>{formatLikeCount(get(p, "CountCollect"))}</span> <Icon
name="lock"
className="hk-playback-legend__swatch hk-playback-legend__swatch--coin"
/>
<span className="hk-desktop-only">App 集 前往 App 免费看</span>
<span className="hk-mobile-only">App 集</span>
</span> </span>
</p> </div>
))} </section>
</div> <PlayLinks data={data} />
<div className="hk-playback-actions"> <div className="hk-playback-promo">
<button <AdBanners
className="hk-button hk-button--primary hk-playback-actions__btn hk-playback-actions__btn--play" items={array(get(data.advitise, "DetailBannerList")).slice(0, 2)}
type="button" />
onClick={play}
>
<Icon name="play_hollow" />
<span>
{mode === "preview"
? "播放预告片"
: data.canWatch
? "立即播放"
: "去 App 看"}
</span>
</button>
<PlaybackShare
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
label
/>
<a
className="hk-button hk-button--ghost hk-playback-actions__btn hk-playback-actions__btn--icon"
href="/feedback/"
aria-label="反馈"
>
<Icon name="message-circle" />
<span className="hk-desktop-only">反馈</span>
</a>
</div>
<Synopsis
text={first(get(p, "Desc"), get(p, "Description"))}
tags={namedTags(get(p, "TagIDs"), get(p, "TagNames"), get(p, "TagSlugs"))}
/>
</section>
<section className="hk-playback-episodes" aria-label="分集">
<div className="hk-playback-episodes__head">
<div className="hk-playback-episodes__head-left">
<h2 className="hk-playback-episodes__title">分集</h2>
<span className="hk-playback-episodes__count">
共 {total || chapters.length} 集 {status}
</span>
<nav
className="hk-playback-episodes__sort hk-desktop-only"
aria-label="分集排序"
>
{[false, true].map((value) => (
<button
key={String(value)}
className={`hk-playback-episodes__sort-item${descending === value ? " hk-is-active" : ""}`}
type="button"
aria-pressed={descending === value}
onClick={() => setDescending(value)}
>
{value ? "倒序" : "正序"}
</button>
))}
</nav>
</div> </div>
<button <Related data={data} />
className="hk-playback-episodes__order hk-mobile-only" <GuessLike data={data} playletId={id} />
type="button" <div className="hk-playback-promo hk-playback-promo--bottom">
aria-pressed={descending} <AdBanners
onClick={() => setDescending(!descending)} items={array(get(data.advitise, "DetailBannerList")).slice(2, 4)}
>
<span>{descending ? "正序" : "倒序"}</span>
<Icon name="chevron-right" />
</button>
</div>
<div className="hk-playback-episodes__grid">
{(descending ? [...chapters].reverse() : chapters).map(
(chapter, index) => {
const chid = num(chapter, "ID"),
ep =
num(chapter, "CurrentEpisode") ||
(descending ? chapters.length - index : index + 1);
return (
<a
key={chid}
className={`hk-playback-ep${chid === chapterID ? " hk-is-active" : ""}${coinGate(chapter) ? " hk-playback-ep--coin" : ""}`}
href={chapterPlayURL(get(p, "Slug"), id, chid)}
>
{ep}
</a>
);
},
)}
{!chapters.length && <span className="hk-playback-ep hk-is-active">1</span>}
</div>
<div className="hk-playback-episodes__legend">
<span className="hk-playback-legend">
<i
className="hk-playback-legend__swatch hk-playback-legend__swatch--free"
aria-hidden="true"
/> />
<span className="hk-desktop-only">免费集 在线即看</span> </div>
<span className="hk-mobile-only">免费集在线看</span> </div>
</span> <div className="hk-playback-body__aside">
<span className="hk-playback-legend"> <HotList data={data} />
<Icon
name="lock"
className="hk-playback-legend__swatch hk-playback-legend__swatch--coin"
/>
<span className="hk-desktop-only">App 集 前往 App 免费看</span>
<span className="hk-mobile-only">App 集</span>
</span>
</div> </div>
</section>
<PlayLinks data={data} />
<div className="hk-playback-promo">
<AdBanners
items={array(get(data.advitise, "DetailBannerList")).slice(0, 2)}
/>
</div>
<Related data={data} />
<section className="hk-playback-dual" aria-label="推荐与热榜">
<GuessLike data={data} playletId={id} />
<HotList data={data} />
</section>
<div className="hk-playback-promo hk-playback-promo--bottom">
<AdBanners
items={array(get(data.advitise, "DetailBannerList")).slice(2, 4)}
/>
</div> </div>
</main> </main>
</div> </div>
+29 -8
View File
@@ -66,12 +66,22 @@ export function PlaybackShare({
</button> </button>
); );
} }
/** Small uppercase label above a section heading; decorative, so hidden from assistive tech. */
export function SectionEyebrow({ children }: { children: string }) {
return (
<span className="hk-section-eyebrow" aria-hidden="true">
{children}
</span>
);
}
export function Synopsis({ export function Synopsis({
text, text,
tags = [], tags = [],
name = "",
}: { }: {
text: string; text: string;
tags?: NamedTag[]; tags?: NamedTag[];
name?: string;
}) { }) {
const [expanded, setExpanded] = useState(false), const [expanded, setExpanded] = useState(false),
[overflow, setOverflow] = useState(false), [overflow, setOverflow] = useState(false),
@@ -94,7 +104,12 @@ export function Synopsis({
return ( return (
<div className={`hk-playback-synopsis${expanded ? " hk-is-expanded" : ""}`}> <div className={`hk-playback-synopsis${expanded ? " hk-is-expanded" : ""}`}>
<div className="hk-playback-synopsis__head"> <div className="hk-playback-synopsis__head">
<h2 className="hk-playback-synopsis__title">剧情简介</h2> <div className="hk-section-heading">
<SectionEyebrow>STORY</SectionEyebrow>
<h2 className="hk-playback-synopsis__title">
{name ? `《${name}》剧情介绍` : "剧情介绍"}
</h2>
</div>
{(overflow || expanded) && ( {(overflow || expanded) && (
<button <button
className="hk-playback-synopsis__more" className="hk-playback-synopsis__more"
@@ -131,9 +146,12 @@ export function Synopsis({
export function HotList({ data }: { data: Data }) { export function HotList({ data }: { data: Data }) {
const items = array(data.hotList); const items = array(data.hotList);
return ( return (
<aside className="hk-playback-hot" aria-label="热播榜单"> <aside className="hk-playback-hot" aria-label="人气排行">
<div className="hk-playback-hot__head"> <div className="hk-playback-hot__head">
<h2 className="hk-playback-hot__title">热播榜单</h2> <div className="hk-section-heading">
<SectionEyebrow>TRENDING</SectionEyebrow>
<h2 className="hk-playback-hot__title">人气排行</h2>
</div>
<a <a
className="hk-playback-hot__more" className="hk-playback-hot__more"
href={String(data.hotMoreURL || "/dramas/all/0/2/")} href={String(data.hotMoreURL || "/dramas/all/0/2/")}
@@ -238,11 +256,11 @@ export function GuessLike({
} }
}; };
return ( return (
<section className="hk-playback-like" aria-label="同类推荐"> <section className="hk-playback-like" aria-label="相似短剧">
<div className="hk-playback-like__head"> <div className="hk-playback-like__head">
<div className="hk-playback-like__titles"> <div className="hk-playback-like__titles hk-section-heading">
<h2 className="hk-playback-like__title">同类推荐</h2> <SectionEyebrow>MORE LIKE THIS</SectionEyebrow>
<span className="hk-playback-like__desc hk-desktop-only">与本剧题材相近的好剧</span> <h2 className="hk-playback-like__title">相似短剧推荐</h2>
</div> </div>
<button <button
type="button" type="button"
@@ -268,7 +286,10 @@ export function Related({ data }: { data: Data }) {
return ( return (
<section className="hk-playback-related hk-mobile-only" aria-label="大家都在看"> <section className="hk-playback-related hk-mobile-only" aria-label="大家都在看">
<div className="hk-playback-related__head"> <div className="hk-playback-related__head">
<h2 className="hk-playback-related__title">大家都在看</h2> <div className="hk-section-heading">
<SectionEyebrow>ALSO WATCHING</SectionEyebrow>
<h2 className="hk-playback-related__title">大家都在看</h2>
</div>
<a className="hk-playback-related__more" href="/featured/"> <a className="hk-playback-related__more" href="/featured/">
更多 更多
<Icon name="chevron-right" /> <Icon name="chevron-right" />
+3 -1
View File
@@ -15,7 +15,7 @@ export function installationURL(value: string | undefined, deepLink = false): st
export function serverConfig() { export function serverConfig() {
const environment = (process.env.APP_ENV || 'development') as AppEnvironment; const environment = (process.env.APP_ENV || 'development') as AppEnvironment;
if (!['development', 'test', 'production'].includes(environment)) throw new Error('Invalid APP_ENV'); if (!['development', 'test', 'production'].includes(environment)) throw new Error('Invalid APP_ENV');
const upstreamURL = (process.env.UPSTREAM_API_URL || (environment === 'development' ? 'https://webhuakuiapi.mfcg.me' : '')).trim().replace(/\/+$/, ''); const upstreamURL = (process.env.UPSTREAM_API_URL || (environment === 'development' ? 'http://127.0.0.1:20032' : '')).trim().replace(/\/+$/, '');
if (upstreamURL && !/^https?:\/\//.test(upstreamURL)) throw new Error('UPSTREAM_API_URL must be an HTTP(S) URL'); if (upstreamURL && !/^https?:\/\//.test(upstreamURL)) throw new Error('UPSTREAM_API_URL must be an HTTP(S) URL');
return { return {
environment, upstreamURL, environment, upstreamURL,
@@ -27,6 +27,8 @@ export function serverConfig() {
navigationOnly: process.env.NAVIGATION_ONLY === 'true', navigationOnly: process.env.NAVIGATION_ONLY === 'true',
/** Google Analytics 4 measurement ID; empty or invalid disables the Google tag. */ /** Google Analytics 4 measurement ID; empty or invalid disables the Google tag. */
googleAnalyticsID: /^G-[A-Z0-9]{4,20}$/.test((process.env.GOOGLE_ANALYTICS_ID || '').trim()) ? (process.env.GOOGLE_ANALYTICS_ID || '').trim() : '', googleAnalyticsID: /^G-[A-Z0-9]{4,20}$/.test((process.env.GOOGLE_ANALYTICS_ID || '').trim()) ? (process.env.GOOGLE_ANALYTICS_ID || '').trim() : '',
/** Yandex.Metrika counter ID; empty or invalid disables the counter. */
yandexMetrikaID: /^\d{4,12}$/.test((process.env.YANDEX_METRIKA_ID || '').trim()) ? (process.env.YANDEX_METRIKA_ID || '').trim() : '',
/** Every Telegram contact entry points here, overriding the upstream tgGroup/telegram/telegramAdd. */ /** Every Telegram contact entry points here, overriding the upstream tgGroup/telegram/telegramAdd. */
contactTelegramURL: installationURL(process.env.CONTACT_TELEGRAM_URL) || 'https://t.me/jbd125', contactTelegramURL: installationURL(process.env.CONTACT_TELEGRAM_URL) || 'https://t.me/jbd125',
installation: { installation: {
+209
View File
@@ -0,0 +1,209 @@
/* 短剧卡片统一外观(全站共用,加载在页面样式之后):
直角偏小的封面 + 细描边,集数/状态角标放封面右下,悬停时封面上浮并出现底部强调条,
标题下方为斜杠分隔的题材行。选择器带父级类名,覆盖各页面零特异性的局部卡片样式。 */
.hk-drama-card {
gap: 8px;
}
.hk-drama-card .hk-drama-card__cover {
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
background-image:
linear-gradient(180deg, rgba(0, 0, 0, 0) 58%, rgba(0, 0, 0, 0.72) 100%),
var(--cover);
transition:
transform 220ms ease,
border-color 220ms ease,
box-shadow 220ms ease;
}
/* 悬停遮罩:去掉居中圆形“立即播放”,改为左下角三角播放键 + 底部强调条 */
.hk-drama-card .hk-drama-card__cover::before {
inset: auto 0 0 0;
height: 3px;
border-radius: 0;
background: var(--accent);
opacity: 0;
transform: scaleX(0);
transform-origin: left center;
transition:
opacity 220ms ease,
transform 260ms ease;
}
.hk-drama-card .hk-drama-card__cover::after {
top: auto;
left: 10px;
bottom: 12px;
width: 30px;
height: 30px;
padding: 0;
border-radius: 50%;
background: var(--accent) url("/static/images/play.svg") 55% 50% / 12px 12px no-repeat;
box-shadow: 0 4px 12px rgba(255, 15, 83, 0.45);
content: "";
transform: translateY(6px);
}
.hk-drama-card .hk-drama-card__cover:focus-visible {
border-color: rgba(255, 15, 83, 0.6);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.4);
}
.hk-drama-card .hk-drama-card__cover:focus-visible::before {
transform: scaleX(1);
}
.hk-drama-card .hk-drama-card__cover:focus-visible::after {
transform: translateY(0);
}
@media (hover: hover) and (pointer: fine) {
.hk-drama-card .hk-drama-card__cover:hover {
border-color: rgba(255, 15, 83, 0.6);
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.4);
transform: translateY(-4px);
}
.hk-drama-card .hk-drama-card__cover:hover::before {
transform: scaleX(1);
}
.hk-drama-card .hk-drama-card__cover:hover::after {
transform: translateY(0);
}
/* 悬停时播放键接管封面底部,角标淡出避免窄卡片上重叠 */
.hk-drama-card .hk-drama-card__cover:hover .hk-drama-card__eps {
opacity: 0;
}
}
/* 封面右下角:集数 · 连更/完结 */
.hk-drama-card .hk-drama-card__eps {
position: absolute;
right: 8px;
bottom: 9px;
z-index: 2;
display: inline-flex;
align-items: center;
gap: 2px;
max-width: calc(100% - 16px);
padding: 2px 7px 2px 6px;
border-radius: 4px;
background: rgba(10, 10, 12, 0.62);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
color: rgba(255, 255, 255, 0.9);
font-size: 11px;
font-weight: 500;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
transition: opacity 180ms ease;
}
.hk-drama-card .hk-drama-card__eps b {
color: #ffffff;
font-size: 12px;
font-weight: 700;
}
.hk-drama-card .hk-drama-card__eps::before {
width: 6px;
height: 6px;
margin-right: 4px;
flex: none;
border-radius: 50%;
background: var(--text-dim);
content: "";
}
.hk-drama-card .hk-drama-card__eps[data-status="serial"]::before {
background: var(--success);
box-shadow: 0 0 0 2px rgba(61, 201, 138, 0.25);
}
/* 标题与题材行 */
.hk-drama-card .hk-drama-card__title {
font-size: 15px;
font-weight: 600;
line-height: 21px;
letter-spacing: 0.2px;
}
.hk-drama-card .hk-drama-card__title a {
color: inherit;
transition: color 160ms ease;
}
.hk-drama-card .hk-drama-card__title a:hover {
color: var(--accent);
}
.hk-drama-card .hk-drama-card__genres {
display: block;
margin: -2px 0 0;
color: var(--text-dim);
font-size: 12px;
line-height: 18px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hk-drama-card .hk-drama-card__genres a {
color: var(--text-muted);
transition: color 160ms ease;
}
.hk-drama-card .hk-drama-card__genres a:hover {
color: var(--accent);
}
.hk-drama-card .hk-drama-card__genre-sep {
margin: 0 5px;
color: rgba(255, 255, 255, 0.22);
}
@media (max-width: 768px) {
.hk-drama-card {
gap: 6px;
}
.hk-drama-card .hk-drama-card__eps {
right: 5px;
bottom: 5px;
gap: 1px;
max-width: calc(100% - 10px);
padding: 1px 5px 1px 4px;
font-size: 10px;
}
.hk-drama-card .hk-drama-card__eps::before {
width: 5px;
height: 5px;
margin-right: 3px;
}
.hk-drama-card .hk-drama-card__eps b {
font-size: 11px;
}
.hk-drama-card .hk-drama-card__title {
font-size: 14px;
line-height: 20px;
}
.hk-drama-card .hk-drama-card__genres {
font-size: 11px;
}
.hk-drama-card .hk-drama-card__genre-sep {
margin: 0 3px;
}
}
+366
View File
@@ -1941,3 +1941,369 @@
border-radius: 12px; border-radius: 12px;
} }
} }
/* ===== 2026-10 播放页改版:主栏 + 右侧人气排行、信息表、EP 选集格、英文眉标题 ===== */
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
align-items: start;
gap: 28px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body__main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body__aside {
position: sticky;
top: 92px;
min-width: 0;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body__aside .hk-playback-hot {
width: auto;
}
/* 面包屑:轻量文字链,当前项强调色 */
:where(html[data-hk-page-styles~="playback"]) .hk-playback-breadcrumb .hk-breadcrumb__item {
color: var(--text-dim);
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-breadcrumb a.hk-breadcrumb__item:hover {
color: var(--text);
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-breadcrumb .hk-breadcrumb__item.hk-is-current {
color: var(--accent);
}
/* 区块标题:英文眉 + 中文标题,取代原竖条装饰 */
:where(html[data-hk-page-styles~="playback"]) .hk-section-heading {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
:where(html[data-hk-page-styles~="playback"]) .hk-section-eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent);
font-family: Inter, var(--font);
font-size: 10px;
font-weight: 700;
line-height: 14px;
letter-spacing: 2px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-section-eyebrow::before {
width: 14px;
height: 1px;
background: currentColor;
content: "";
}
:where(html[data-hk-page-styles~="playback"]) :is(.hk-playback-synopsis__title, .hk-playback-episodes__title, .hk-playback-like__title, .hk-playback-hot__title, .hk-playback-related__title)::before {
display: none;
}
:where(html[data-hk-page-styles~="playback"]) :is(.hk-playback-synopsis__title, .hk-playback-episodes__title, .hk-playback-like__title, .hk-playback-hot__title, .hk-playback-related__title) {
margin: 0;
font-size: 18px;
font-weight: 600;
line-height: 26px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-synopsis__head,
:where(html[data-hk-page-styles~="playback"]) .hk-playback-hot__head,
:where(html[data-hk-page-styles~="playback"]) .hk-playback-like__head,
:where(html[data-hk-page-styles~="playback"]) .hk-playback-related__head {
align-items: flex-end;
}
/* 信息区与选集面板:实色面板 + 顶部强调线,替代渐变卡片 */
:where(html[data-hk-page-styles~="playback"]) .hk-playback-info,
:where(html[data-hk-page-styles~="playback"]) .hk-playback-episodes {
background: var(--bg-elevated);
border: 1px solid var(--line-soft);
border-top: 2px solid var(--accent);
border-radius: 10px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-meta__eyebrow {
margin: 0;
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
line-height: 20px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-meta__eyebrow span {
padding: 1px 8px;
border-radius: 4px;
color: #ffffff;
font-size: 12px;
font-weight: 600;
background: var(--accent);
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-meta__title {
font-size: 26px;
line-height: 34px;
letter-spacing: 0.3px;
}
/* 信息表 */
:where(html[data-hk-page-styles~="playback"]) .hk-playback-facts {
margin: 4px 0 0;
display: flex;
flex-wrap: wrap;
overflow: hidden;
border: 1px solid var(--line-soft);
border-radius: 8px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-facts__item {
flex: 1 1 112px;
min-width: 0;
margin: 0 -1px -1px 0;
padding: 9px 12px;
border-right: 1px solid var(--line-soft);
border-bottom: 1px solid var(--line-soft);
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-facts__item--wide {
flex-basis: 224px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-facts dt {
color: var(--text-dim);
font-size: 11px;
line-height: 16px;
letter-spacing: 1px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-facts dd {
margin: 2px 0 0;
color: var(--text);
font-size: 14px;
font-weight: 500;
line-height: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-facts dd[data-status="serial"] {
color: var(--success);
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-facts .hk-playback-meta__tag {
color: var(--text);
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-facts a.hk-playback-meta__tag:hover {
color: var(--accent);
}
/* 简介标题较长时,“展开”按钮保持单行不被挤压 */
:where(html[data-hk-page-styles~="playback"]) .hk-playback-synopsis__head {
gap: 12px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-synopsis__more {
flex-shrink: 0;
white-space: nowrap;
}
/* 简介标签:描边胶囊,无井号 */
:where(html[data-hk-page-styles~="playback"]) .hk-playback-synopsis {
border-top-style: solid;
border-top-color: var(--line-soft);
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-synopsis__tag {
padding: 2px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: transparent;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-synopsis__tag::before {
content: none;
}
:where(html[data-hk-page-styles~="playback"]) a.hk-playback-synopsis__tag:hover {
border-color: rgba(255, 15, 83, 0.5);
background: transparent;
color: var(--accent);
}
/* EP 选集格:上方小号 EP,下方两位集号;当前集为描边强调 */
:where(html[data-hk-page-styles~="playback"]) .hk-playback-episodes__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
gap: 8px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-ep {
width: auto;
height: 54px;
flex-direction: column;
gap: 1px;
border-radius: 6px;
border: 1px solid var(--line-soft);
background: rgba(255, 255, 255, 0.03);
transition: border-color 160ms ease, background 160ms ease;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-ep__label {
color: var(--text-dim);
font-family: Inter, var(--font);
font-size: 9px;
font-weight: 700;
line-height: 12px;
letter-spacing: 1.5px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-ep__num {
font-family: Inter, var(--font);
font-size: 17px;
font-weight: 700;
line-height: 20px;
}
:where(html[data-hk-page-styles~="playback"]) a.hk-playback-ep:hover {
border-color: rgba(255, 255, 255, 0.25);
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-ep.hk-is-active {
color: var(--accent);
background: rgba(255, 15, 83, 0.08);
border-color: var(--accent);
box-shadow: none;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-ep.hk-is-active .hk-playback-ep__label {
color: var(--accent);
}
/* 主栏内的相似短剧网格 */
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body__main > .hk-playback-like {
padding: 4px 0 0;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body__main .hk-playback-like__grid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 20px 14px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body__main .hk-drama-card--play {
width: auto;
min-width: 0;
}
/* 人气排行:大号斜体序号,前三名渐变字 */
:where(html[data-hk-page-styles~="playback"]) .hk-playback-hot {
border-radius: 10px;
border: 1px solid var(--line-soft);
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-hot__no {
width: 28px;
height: auto;
margin-right: 0;
color: var(--text-dim);
font-family: Inter, var(--font);
font-size: 20px;
font-style: italic;
font-weight: 800;
background: none;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-hot__no.hk-is-top {
color: transparent;
background: linear-gradient(135deg, var(--gold), var(--accent));
-webkit-background-clip: text;
background-clip: text;
}
@media (max-width: 1100px) {
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body {
grid-template-columns: minmax(0, 1fr) 300px;
gap: 20px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body__main .hk-playback-like__grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 768px) {
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 16px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body__aside {
position: static;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body__aside .hk-playback-hot {
margin: 0 var(--h5-gutter);
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body__main > .hk-playback-like {
padding: 0 var(--h5-gutter);
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-body__main .hk-playback-like__grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px 8px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-meta__title {
font-size: 20px;
line-height: 28px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-facts__item {
flex-basis: 30%;
padding: 7px 10px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-facts__item--wide {
flex-basis: 60%;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-facts dd {
font-size: 13px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-episodes__grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 6px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-ep {
height: 46px;
}
:where(html[data-hk-page-styles~="playback"]) .hk-playback-ep__num {
font-size: 15px;
}
:where(html[data-hk-page-styles~="playback"]) :is(.hk-playback-synopsis__title, .hk-playback-episodes__title, .hk-playback-like__title, .hk-playback-hot__title, .hk-playback-related__title) {
font-size: 16px;
line-height: 24px;
}
}
+7 -2
View File
@@ -57,9 +57,14 @@ test("React card variants preserve watch URLs, episode gates, badges and linked
card, card,
/href="\/drama\/都市%20剧\/"|href="\/drama\/%E9%83%BD%E5%B8%82%20%E5%89%A7\/"/, /href="\/drama\/都市%20剧\/"|href="\/drama\/%E9%83%BD%E5%B8%82%20%E5%89%A7\/"/,
); );
// Episode count and serial state sit in the cover corner; the line under the title lists genres.
assert.match( assert.match(
card, card,
/<p class="hk-drama-card__meta hk-drama-card__meta--ep"><span class="hk-drama-card__meta-ep" data-status="serial">连载 · 8 集<\/span><span class="hk-drama-card__meta-tags">/, /<span class="hk-drama-card__eps" data-status="serial"><b>8<\/b> 集 · 连更<\/span><\/a>/,
);
assert.match(
card,
/<p class="hk-drama-card__meta hk-drama-card__genres"><a href="\/tags\/romance\/">爱情<\/a><span class="hk-drama-card__genre-sep" aria-hidden="true">\/<\/span>/,
); );
assert.doesNotMatch(card, /hk-drama-card__ep"/); assert.doesNotMatch(card, /hk-drama-card__ep"/);
assert.match(card, /hk-badge hk-badge--hot/); assert.match(card, /hk-badge hk-badge--hot/);
@@ -72,7 +77,7 @@ test("React card variants preserve watch URLs, episode gates, badges and linked
<PlayRecommendationCard item={item} />, <PlayRecommendationCard item={item} />,
); );
assert.match(recommendation, /hk-drama-card hk-drama-card--play/); assert.match(recommendation, /hk-drama-card hk-drama-card--play/);
assert.match(recommendation, /hk-drama-card__meta-ep" data-status="serial">连载 · 8 集</); assert.match(recommendation, /hk-drama-card__eps" data-status="serial"><b>8<\/b> 集 · 连更</);
assert.doesNotMatch(recommendation, /hk-badge--hot/); assert.doesNotMatch(recommendation, /hk-badge--hot/);
const video = renderToStaticMarkup( const video = renderToStaticMarkup(
<SeriesCard <SeriesCard
+9 -4
View File
@@ -90,10 +90,15 @@ test("React tags and list pages preserve the original Go catalog content and nav
const actual = main(renderPage(Component, data)), const actual = main(renderPage(Component, data)),
expected = main(fixture(name + "-go.html")); expected = main(fixture(name + "-go.html"));
assert.ok(actual, name); assert.ok(actual, name);
// The episode label moved from the cover link into the meta-row chip. // Both sites label episodes on the cover link, in different wording; compare those links without the label.
const coverEpisode = /^(?:全 \d+ 集|更新至 \d+ 集|连载中|已完结)$/; const coverEpisode = /^(?:全 \d+ 集|更新至 \d+ 集|连载中|已完结)$/,
coverBadge = /^(?:\d+ 集 · )?(?:连更|完结)$/;
assert.deepEqual( assert.deepEqual(
anchors(actual), anchors(actual).map((link) =>
link.href.startsWith("/drama/") && coverBadge.test(link.text)
? { ...link, text: "" }
: link,
),
anchors(expected).map((link) => anchors(expected).map((link) =>
link.href.startsWith("/drama/") && coverEpisode.test(link.text) link.href.startsWith("/drama/") && coverEpisode.test(link.text)
? { ...link, text: "" } ? { ...link, text: "" }
@@ -112,7 +117,7 @@ test("React tags and list pages preserve the original Go catalog content and nav
name + " ad placement", name + " ad placement",
); );
assert.match(actual, /花魁 &amp; 第一部/); assert.match(actual, /花魁 &amp; 第一部/);
assert.match(actual, /<span class="hk-drama-card__meta-ep" data-status="done">完结 · 4 集<\/span>/); assert.match(actual, /<span class="hk-drama-card__eps" data-status="done"><b>4<\/b> 集 · 完结<\/span>/);
} }
}); });