Compare commits

...
5 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
19 changed files with 1119 additions and 271 deletions
+2
View File
@@ -18,3 +18,5 @@ web_hkdj_h5.md5
/template/pages/sitemaps/*.xml
/template/pages/sitemap.xml
/data/sitemaps/*.xml
web_huakui_h5.tar.gz
web_huakui_h5.md5
+6 -20
View File
@@ -263,7 +263,7 @@ APP_DEEP_LINK_URL=
平台地址未配置时,按钮显示“前往下载页”,沿用上游 `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`。值无效时回退为默认账号。
@@ -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
npm run deploy:prod -- --dry-run # 只核对配置,不构建、不连接
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 元数据,密钥随包提供;生产私密覆盖配置需在服务器提供,并由服务器发布脚本保留。
默认上传及触发命令严格对应两种端口约定:
```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 数据。
本机最低 Node.js 20.9、npm **10.9.2**,推荐 Node.js 22。默认目标 Linux **x64/glibc**,只在临时目录补装目标平台的 Sharp/libvips 原生包,版本与 integrity 取自 lockfile。
+2
View File
@@ -19,6 +19,8 @@ SEO_BAIDU_SITE_VERIFICATION=
SEO_BAIDU_HTML_FILE=
# Google Analytics 4 measurement ID; leave empty to disable.
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).
CONTACT_TELEGRAM_URL=https://t.me/jbd125
APP_IOS_STORE_URL=
+4 -2
View File
@@ -1,6 +1,6 @@
APP_ENV=production
# Set your production API in config/production.local.env or deployment environment.
UPSTREAM_API_URL=http://127.0.0.1:26931
# 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:20031
# Optional fallback when config/info does not supply an image CDN.
# Primary public domain; overrides the backend foreverUrlAddr.
SITE_URL=https://huakuidj.com
@@ -15,6 +15,8 @@ SEO_BAIDU_SITE_VERIFICATION=
SEO_BAIDU_HTML_FILE=
# Google Analytics 4 measurement ID; leave empty to disable.
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).
CONTACT_TELEGRAM_URL=https://t.me/jbd125
APP_IOS_STORE_URL=
+2
View File
@@ -15,6 +15,8 @@ SEO_BAIDU_SITE_VERIFICATION=
SEO_BAIDU_HTML_FILE=
# Google Analytics 4 measurement ID; leave empty to disable.
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).
CONTACT_TELEGRAM_URL=https://t.me/jbd125
APP_IOS_STORE_URL=
+158 -7
View File
@@ -1,7 +1,158 @@
set -e
rm -rf dist dist.zip
npm run build:prod
zip -q -r dist.zip dist
# https://dis79itlh8uwv.cloudfront.net
scp dist.zip root@172.104.166.87:/tmp/dist.zip
ssh root@172.104.166.87 "set -e && rm -rf /tmp/extract && unzip -q /tmp/dist.zip -d /tmp/extract && cd /server/web_huakui/h5/dist/ && rm -rf ./* && mv /tmp/extract/dist/* . && rm -rf /tmp/extract /tmp/dist.zip && echo DEPLOY_OK"
#!/bin/bash
# 花魁短剧正式服:检查 → 构建 → Linux 运行包 + MD5 → 上传 → 切换 app/ → Supervisor 重启 → 健康检查(失败回滚)。
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$SCRIPT_DIR"
HOST="${DEPLOY_HOST:-root@172.104.166.87}"
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_LIBC="${DEPLOY_TARGET_LIBC:-glibc}"
PACKAGE_NAME=web_huakui_h5.tar.gz
MD5_NAME=web_huakui_h5.md5
PACKAGE="$SCRIPT_DIR/$PACKAGE_NAME"
MD5_FILE="$SCRIPT_DIR/$MD5_NAME"
RELEASE="$SCRIPT_DIR/dist/production-linux-$TARGET_ARCH-$TARGET_LIBC"
KEY_FILE=config/secrets/m3u8.key
PACKAGE_ONLY=0
DRY_RUN=0
fail() { printf '错误:%s\n' "$*" >&2; exit 1; }
step() { printf '\n▸ %s\n' "$*"; }
for argument in "$@"; do
case "$argument" in
--dry-run) DRY_RUN=1 ;;
--package-only) PACKAGE_ONLY=1 ;;
-h|--help)
printf '用法:%s [--dry-run | --package-only]\n' "$0"
printf '默认上传 root@172.104.166.87:/web_huakui/h5/,切换 app/ 后重启 Supervisor 程序 web_huakui_h5。\n'
printf '可覆盖 DEPLOY_HOST、DEPLOY_SSH_PORT、DEPLOY_DIR、DEPLOY_PROGRAM、DEPLOY_TARGET_ARCH、DEPLOY_TARGET_LIBC。\n'
exit 0 ;;
*) fail "不支持的参数 ${argument};使用 --help 查看用法" ;;
esac
done
[[ "$HOST" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail 'DEPLOY_HOST 格式无效'
[[ "$REMOTE_DIR" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*/?$ ]] || fail 'DEPLOY_DIR 必须是普通绝对路径'
REMOTE_DIR="${REMOTE_DIR%/}"
[[ "$PROGRAM" =~ ^[a-zA-Z0-9_-]+$ ]] || fail 'DEPLOY_PROGRAM 格式无效'
[[ "$SSH_PORT" =~ ^[0-9]+$ ]] && [ "$SSH_PORT" -ge 1 ] && [ "$SSH_PORT" -le 65535 ] || fail 'SSH 端口必须在 1–65535 内'
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
for tool in node npm tar; 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,推荐 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'
command -v md5sum >/dev/null || command -v md5 >/dev/null || fail '缺少 md5sum 或 macOS md5'
for file in config/production.env package-lock.json scripts/prepare-production-runtime.ts; do
[ -s "$file" ] || fail "缺少 $file"
done
[ -x node_modules/.bin/tsx ] || fail '请先执行 npm ci 安装本地依赖'
if [ "$DRY_RUN" = 1 ]; then
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};UPSTREAM_API_URL=${c.UPSTREAM_API_URL}`)'
printf ' npm run check → build → bundle → bundle:linux -- %s %s → %s + %s\n' "$TARGET_ARCH" "$TARGET_LIBC" "$PACKAGE_NAME" "$MD5_NAME"
[ -s "$KEY_FILE" ] || printf ' 本地缺少 %s,构建前从服务器当前版本只读复制(忽略提交)。\n' "$KEY_FILE"
if [ "$PACKAGE_ONLY" = 0 ]; then
printf ' scp → %s:%s/;解压到 app.new,保留服务器密钥与 *.local.env\n' "$HOST" "$REMOTE_DIR"
printf ' app → app.prev,app.new → app;supervisorctl restart %s;/healthz 失败则回滚\n' "$PROGRAM"
fi
exit 0
fi
for tool in ssh scp; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done
SSH=(ssh -p "$SSH_PORT" -o ConnectTimeout=15 "$HOST")
# 同仓库只允许一次构建/上传,避免并发覆盖同名包。
mkdir -p "$SCRIPT_DIR/dist"
DEPLOY_LOCK="$SCRIPT_DIR/dist/.production-deploy.lock"
mkdir "$DEPLOY_LOCK" 2>/dev/null || fail "已有正式发布进程或残留锁:${DEPLOY_LOCK};请先确认该进程已结束"
trap 'rmdir "$DEPLOY_LOCK" 2>/dev/null || true' EXIT
trap 'exit 130' INT
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 '检查类型和单元测试'
npm run check
step '构建并整理正式环境 standalone'
npm run build
npm run bundle
step "准备 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 "$KEY_FILE" runtime-target.json; do
[ -s "$RELEASE/$file" ] || fail "正式运行包缺少 $file"
done
for directory in node_modules .next/static public; do
[ -d "$RELEASE/$directory" ] || fail "正式运行包缺少目录 $directory"
done
step "打包 $PACKAGE_NAME"
# -C 保持产物直接位于 tar 根部;禁止 AppleDouble、环境覆盖和日志混入。
COPYFILE_DISABLE=1 COPY_EXTENDED_ATTRIBUTES_DISABLE=1 tar \
--no-xattrs \
--exclude='._*' --exclude='.DS_Store' --exclude='.env*' \
--exclude='*.local.env' --exclude='*.log' \
-czf "$PACKAGE" -C "$RELEASE" .
chmod 600 "$PACKAGE"
if command -v md5sum >/dev/null; then
md5sum "$PACKAGE" | awk '{print $1}' > "$MD5_FIL
E"
else
md5 -q "$PACKAGE" > "$MD5_FILE"
fi
tar -tzf "$PACKAGE" >/dev/null
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
step "上传到 $HOST:$REMOTE_DIR/"
scp -P "$SSH_PORT" "$PACKAGE" "$MD5_FILE" "$HOST:$REMOTE_DIR/"
step "切换版本并重启 $PROGRAM"
"${SSH[@]}" "bash -s -- '$REMOTE_DIR' '$PROGRAM' '$PACKAGE_NAME' '$MD5_NAME'" <<'REMOTE'
set -euo pipefail
DIR=$1 PROGRAM=$2 PACKAGE=$3 MD5=$4
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
PORT=$(sed -n 's/^PORT=//p' app.new/config/production.env | tail -n 1)
PORT=${PORT:-26935}
rm -rf app.prev
[ -d app ] && mv app app.prev
mv app.new app
supervisorctl restart "$PROGRAM" >/dev/null || true
healthy() {
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
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'
+41 -41
View File
@@ -9,7 +9,7 @@
"version": "1.0.0",
"dependencies": {
"dotenv": "^17.2.3",
"next": "16.4.0",
"next": "16.4.2",
"qrcode": "^1.5.4",
"react": "19.3.0",
"react-dom": "19.3.0",
@@ -1021,15 +1021,15 @@
}
},
"node_modules/@next/env": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz",
"integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.2.tgz",
"integrity": "sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz",
"integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.2.tgz",
"integrity": "sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==",
"cpu": [
"arm64"
],
@@ -1043,9 +1043,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz",
"integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.2.tgz",
"integrity": "sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==",
"cpu": [
"x64"
],
@@ -1059,9 +1059,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz",
"integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.2.tgz",
"integrity": "sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==",
"cpu": [
"arm64"
],
@@ -1075,9 +1075,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz",
"integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.2.tgz",
"integrity": "sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==",
"cpu": [
"arm64"
],
@@ -1091,9 +1091,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz",
"integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.2.tgz",
"integrity": "sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==",
"cpu": [
"x64"
],
@@ -1107,9 +1107,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz",
"integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.2.tgz",
"integrity": "sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==",
"cpu": [
"x64"
],
@@ -1123,9 +1123,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz",
"integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.2.tgz",
"integrity": "sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==",
"cpu": [
"arm64"
],
@@ -1139,9 +1139,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz",
"integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.2.tgz",
"integrity": "sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==",
"cpu": [
"x64"
],
@@ -1627,12 +1627,12 @@
}
},
"node_modules/next": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz",
"integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/next/-/next-16.4.2.tgz",
"integrity": "sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==",
"license": "MIT",
"dependencies": {
"@next/env": "16.4.0",
"@next/env": "16.4.2",
"@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -1646,15 +1646,15 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.4.0",
"@next/swc-darwin-x64": "16.4.0",
"@next/swc-linux-arm64-gnu": "16.4.0",
"@next/swc-linux-arm64-musl": "16.4.0",
"@next/swc-linux-x64-gnu": "16.4.0",
"@next/swc-linux-x64-musl": "16.4.0",
"@next/swc-win32-arm64-msvc": "16.4.0",
"@next/swc-win32-x64-msvc": "16.4.0",
"sharp": "^0.35.4"
"@next/swc-darwin-arm64": "16.4.2",
"@next/swc-darwin-x64": "16.4.2",
"@next/swc-linux-arm64-gnu": "16.4.2",
"@next/swc-linux-arm64-musl": "16.4.2",
"@next/swc-linux-x64-gnu": "16.4.2",
"@next/swc-linux-x64-musl": "16.4.2",
"@next/swc-win32-arm64-msvc": "16.4.2",
"@next/swc-win32-x64-msvc": "16.4.2",
"sharp": "^0.35.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
+1 -1
View File
@@ -40,7 +40,7 @@
},
"dependencies": {
"dotenv": "^17.2.3",
"next": "16.4.0",
"next": "16.4.2",
"qrcode": "^1.5.4",
"react": "19.3.0",
"react-dom": "19.3.0",
+3 -4
View File
@@ -415,10 +415,9 @@ try {
name + " action must be removed",
);
assert.equal(await page.locator(".hk-playback-comments, .hk-playback-composer").count(), 0);
assert.deepEqual(
await page.locator(".hk-playback-dual > *").evaluateAll((nodes) => nodes.map((node) => node.className)),
["hk-playback-like", "hk-playback-hot"],
);
// Recommendations stay in the main column; the trending list sits in the right sidebar.
assert.equal(await page.locator(".hk-playback-body__main > .hk-playback-like").count(), 1);
assert.equal(await page.locator(".hk-playback-body__aside > .hk-playback-hot").count(), 1);
assert.match(
(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/home-road.css";
import "@/styles/pages/playback.css";
import "@/styles/overrides/drama-tiles.css";
import "@/styles/overrides/pwa-safe-area.css";
import type { ReactNode } from "react";
import { PwaProvider } from "@/components/pwa/PwaProvider";
import { GoogleAnalytics } from "@/components/analytics/GoogleAnalytics";
import { YandexMetrika } from "@/components/analytics/YandexMetrika";
import { serverConfig } from "@/server/config";
import { pwaViewport } from "@/lib/pwa/config";
import { getRequestPage } from "@/server/react/request-page";
@@ -83,7 +85,7 @@ export default async function RootLayout({
/>
)}
</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>
);
}
@@ -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,
categoryURL,
displayName,
episodeChip,
episodeLabel,
episodeStatus,
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 }) {
const chip = episodeChip(item);
const tags = cardTags(item);
if (!tags.length) return null;
return (
<p className="hk-drama-card__meta hk-drama-card__meta--ep">
{chip && (
<span
className="hk-drama-card__meta-ep"
data-status={num(item, "UpdateStatus") === 1 ? "serial" : "done"}
>
{chip}
</span>
)}
<span className="hk-drama-card__meta-tags">
<CardTags item={item} />
</span>
<p className="hk-drama-card__meta hk-drama-card__genres">
{tags.map((tag, index) => (
<Fragment key={tag.Name}>
{index > 0 && (
<span className="hk-drama-card__genre-sep" aria-hidden="true">
/
</span>
)}
{tag.URL ? (
<a href={tag.URL} {...externalProps(tag.URL)}>
{tag.Name}
</a>
) : (
<span>{tag.Name}</span>
)}
</Fragment>
))}
</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({
item,
recent = false,
@@ -94,6 +118,7 @@ export function DramaCard({
) : str(item, "Anchor") === "原创" ? (
<span className="hk-badge hk-badge--original">原创</span>
) : null}
<DramaCardEpisodes item={item} />
</EncryptedBackground>
<h3 className="hk-drama-card__title">
<a href={href}>{name}</a>
@@ -278,6 +303,7 @@ export function PlayRecommendationCard({ item }: { item: CatalogRecord }) {
href={href}
src={str(item, "Cover")}
>
<DramaCardEpisodes item={item} />
</EncryptedBackground>
<h3 className="hk-drama-card__title">
<a href={href}>{displayName(item)}</a>
+211 -166
View File
@@ -25,7 +25,14 @@ import {
import { coinGate } from "./model";
import { PlaybackStage } from "./PlaybackStage";
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 }) {
const p = data.playlet || {},
id = num(p, "ID"),
@@ -109,7 +116,7 @@ export function DramaWatchPage({ data }: { data: Data }) {
</a>
<Icon name="chevron-right" className="hk-breadcrumb__sep" />
<a className="hk-breadcrumb__item" href="/dramas/">
短剧
短剧库
</a>
<Icon name="chevron-right" className="hk-breadcrumb__sep" />
<a className="hk-breadcrumb__item" href={playURL(get(p, "Slug"), id)}>
@@ -128,178 +135,216 @@ export function DramaWatchPage({ data }: { data: Data }) {
onPlaying={onPlaying}
autoPlay={autoPlay}
/>
<section className="hk-playback-info" aria-label="剧集信息">
<div className="hk-playback-meta">
<div className="hk-playback-meta__title-row">
<h1 className="hk-playback-meta__title">{name}</h1>
{get(p, "IsFeatured") ? (
<span className="hk-badge hk-badge--hot">爆剧</span>
) : anchor === "原创" ? (
<span className="hk-badge hk-badge--original">原创</span>
) : null}
</div>
<div className="hk-playback-meta__chips">
{total > 0 && <span className="hk-playback-chip">全 {total} 集</span>}
<span className="hk-playback-chip">{status}</span>
{year && <span className="hk-playback-chip">{year}</span>}
<span className="hk-playback-chip">1080P</span>
</div>
{["hk-desktop-only", "hk-mobile-only"].map((device) => (
<p className={`hk-playback-meta__stats ${device}`} key={device}>
{anchor && !["原创", "已注销"].includes(anchor) && (
<span className="hk-playback-meta__stat">主演 {anchor}</span>
)}
{!!themes.length && (
<span className="hk-playback-meta__stat">
{device === "hk-desktop-only" ? "题材 " : ""}
{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>
<div className="hk-playback-body">
<div className="hk-playback-body__main">
<section className="hk-playback-info" aria-label="剧集信息">
<div className="hk-playback-meta">
<p className="hk-playback-meta__eyebrow">
<span>正在播放</span>
第 {episode} 集{total > 0 ? ` / 共 ${total} 集` : ""}
</p>
<div className="hk-playback-meta__title-row">
<h1 className="hk-playback-meta__title">{name}</h1>
{get(p, "IsFeatured") ? (
<span className="hk-badge hk-badge--hot">爆剧</span>
) : anchor === "原创" ? (
<span className="hk-badge hk-badge--original">原创</span>
) : null}
</div>
<dl className="hk-playback-facts">
{total > 0 && (
<div className="hk-playback-facts__item">
<dt>集数</dt>
<dd>{total} 集</dd>
</div>
)}
<div className="hk-playback-facts__item">
<dt>状态</dt>
<dd data-status={status === "连载中" ? "serial" : "done"}>{status}</dd>
</div>
{year && (
<div className="hk-playback-facts__item">
<dt>上线</dt>
<dd>{year} 年</dd>
</div>
)}
<div className="hk-playback-facts__item">
<dt>画质</dt>
<dd>1080P</dd>
</div>
{anchor && !["原创", "已注销"].includes(anchor) && (
<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 className="hk-playback-meta__stat">
热度 {formatLikeCount(get(p, "HeatValue"))}
</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>
<span className="hk-mobile-only">免费集在线看</span>
</span>
<span className="hk-playback-meta__stat">
收藏 <span>{formatLikeCount(get(p, "CountCollect"))}</span>
<span className="hk-playback-legend">
<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>
</p>
))}
</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
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>
</section>
<PlayLinks data={data} />
<div className="hk-playback-promo">
<AdBanners
items={array(get(data.advitise, "DetailBannerList")).slice(0, 2)}
/>
</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)}
>
{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"
<Related data={data} />
<GuessLike data={data} playletId={id} />
<div className="hk-playback-promo hk-playback-promo--bottom">
<AdBanners
items={array(get(data.advitise, "DetailBannerList")).slice(2, 4)}
/>
<span className="hk-desktop-only">免费集 在线即看</span>
<span className="hk-mobile-only">免费集在线看</span>
</span>
<span className="hk-playback-legend">
<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>
<div className="hk-playback-body__aside">
<HotList data={data} />
</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>
</main>
</div>
+29 -8
View File
@@ -66,12 +66,22 @@ export function PlaybackShare({
</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({
text,
tags = [],
name = "",
}: {
text: string;
tags?: NamedTag[];
name?: string;
}) {
const [expanded, setExpanded] = useState(false),
[overflow, setOverflow] = useState(false),
@@ -94,7 +104,12 @@ export function Synopsis({
return (
<div className={`hk-playback-synopsis${expanded ? " hk-is-expanded" : ""}`}>
<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) && (
<button
className="hk-playback-synopsis__more"
@@ -131,9 +146,12 @@ export function Synopsis({
export function HotList({ data }: { data: Data }) {
const items = array(data.hotList);
return (
<aside className="hk-playback-hot" aria-label="热播榜单">
<aside className="hk-playback-hot" aria-label="人气排行">
<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
className="hk-playback-hot__more"
href={String(data.hotMoreURL || "/dramas/all/0/2/")}
@@ -238,11 +256,11 @@ export function GuessLike({
}
};
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__titles">
<h2 className="hk-playback-like__title">同类推荐</h2>
<span className="hk-playback-like__desc hk-desktop-only">与本剧题材相近的好剧</span>
<div className="hk-playback-like__titles hk-section-heading">
<SectionEyebrow>MORE LIKE THIS</SectionEyebrow>
<h2 className="hk-playback-like__title">相似短剧推荐</h2>
</div>
<button
type="button"
@@ -268,7 +286,10 @@ export function Related({ data }: { data: Data }) {
return (
<section className="hk-playback-related hk-mobile-only" aria-label="大家都在看">
<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/">
更多
<Icon name="chevron-right" />
+2
View File
@@ -27,6 +27,8 @@ export function serverConfig() {
navigationOnly: process.env.NAVIGATION_ONLY === 'true',
/** 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() : '',
/** 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. */
contactTelegramURL: installationURL(process.env.CONTACT_TELEGRAM_URL) || 'https://t.me/jbd125',
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;
}
}
/* ===== 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,
/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(
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.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} />,
);
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/);
const video = renderToStaticMarkup(
<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)),
expected = main(fixture(name + "-go.html"));
assert.ok(actual, name);
// The episode label moved from the cover link into the meta-row chip.
const coverEpisode = /^(?:全 \d+ 集|更新至 \d+ 集|连载中|已完结)$/;
// Both sites label episodes on the cover link, in different wording; compare those links without the label.
const coverEpisode = /^(?:全 \d+ 集|更新至 \d+ 集|连载中|已完结)$/,
coverBadge = /^(?:\d+ 集 · )?(?:连更|完结)$/;
assert.deepEqual(
anchors(actual),
anchors(actual).map((link) =>
link.href.startsWith("/drama/") && coverBadge.test(link.text)
? { ...link, text: "" }
: link,
),
anchors(expected).map((link) =>
link.href.startsWith("/drama/") && coverEpisode.test(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",
);
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>/);
}
});