This commit is contained in:
谢宇宁
2026-11-06 00:13:27 +07:00
parent f65d10e998
commit 37411fcada
98 changed files with 958 additions and 4598 deletions
+12 -46
View File
@@ -1,6 +1,6 @@
# 禁果短剧 · Next.js
# 黄果短剧 · Next.js
项目编号 `JHA-1050`,应用及埋点 App ID `1050`,npm 包与 `/healthz` 应用标识均为 `jha-1050-jgdj-web`。品牌名称统一为「禁果短剧」。
项目编号 `JHA-1050`,应用及埋点 App ID `1050`,npm 包与 `/healthz` 应用标识均为 `jha-1050-jgdj-web`。品牌名称统一为「黄果短剧」。
部署包、默认目录与 Supervisor 名已统一使用 `jgdj`。服务器地址和端口沿用原配置;首次发布前需准备对应目录、远端更新脚本和服务配置,并核对 Go 上游及后台 `reportSdkConfig` 均属于应用 `1050`。仓库地址登记为新项目的目标路径,本次不修改 Git remote。
@@ -115,10 +115,9 @@ DEPLOY_JUMP_HOST=user@jump-host DEPLOY_JUMP_DIR=/srv/jgdj/h5 bash deploy_prod.sh
- 内容路由优先使用接口返回的英文或拼音 `slug`(字母、数字、连字符、下划线),中文或无效 slug 回退到 ID,例如 `/detail/city-story/`、`/play/city-story/90001/`、`/topics/urban/`、`/tag/urban/`、`/config/editors-choice/`。章节段仍是章节 ID。卡片、轮播、选集、评论/列表分页、解锁后跳转和 SEO 地址使用同一规则;原 ID 地址继续直接访问。有配置的 `moreUrl` 优先级不变。
- 搜索 `/search/关键词/`;分页 `/search/关键词/page/2/`。关键词通过 `encodeURIComponent` 作为单个路径段编码,支持中文和特殊字符。
- 独立导航页 `/navigation/` 从 `config/info` 读取站名、介绍、`navPageList`、永久入口、TG/X 和 App 下载地址。线路按 `line` 排序;旧配置兼容 `dhNewUrlAddr1/2/3` 与最新地址字段。未配置或无效的链接不展示,不使用演示账号或首页链接兜底。
- 个人中心 `/member/center/`,收藏、历史、点赞、已购和设置分别为 `/member/favorites/`、`/member/history/`、`/member/likes/`、`/member/purchased/`、`/member/settings/`。
- 帮助与平台信息 `/info/:slug/`;充值 `/info/deposit/`;VIP `/info/deposit/vip/`。
- 帮助与平台信息 `/info/:slug/`(关于我们、用户协议、隐私政策、常见问题、2257、DMCA)。个人中心、收藏/历史/点赞/已购、账号设置、充值/VIP、意见反馈页面已移除,访问返回 404。
- 所有分页使用独立路径,例如 `/shorts/page/2/`、`/rank/new/page/2/`、`/topics/31/page/2/`、`/member/history/page/2/`、`/info/deposit/vip/page/2/`;详情和播放页的 `/page/2/` 对应评论第 2 页。页码、上一页和下一页均为真实链接,可直接访问、刷新和前进后退。
- 排序通过 `?sort=2` 保留;评论排序使用 `?commentSort=1`。切换排序时链接指向第一页,翻页时保留当前条件;第一页链接使用原页面路径,不执行自动跳转或地址替换。
- 排序通过 `?sort=2` 保留。切换排序时链接指向第一页,翻页时保留当前条件。
页面直接根据 URL 路径加载,不解析或转换旧 hash 路由。站内导航、动态卡片、播放选集、搜索和会员入口均使用路径地址;无效路径返回 404。
@@ -167,18 +166,9 @@ POST 请求按参考项目转发,不在 Next.js 代理中比较 `Origin`、`Ho
有效客户端 IP 优先取 X-Real-IP,其次取 X-Forwarded-For 首个地址,上游只发送 X-Real-IP。公开接口仍不附带账号凭证,私有接口使用当前会话 Authorization;签名媒体和密钥使用 `Accept: */*`,不转发业务 Cookie 或 Authorization。合法 SDK 公共字段保持原值,短剧服务端事件上下文继续按业务路由校验后透传。共享缓存只保存公开响应数据,不保存访客请求头。
## 登录与游客绑定
## 游客会话
登录流程由本项目(`JHA-1050`)的 `src/lib/client/auth.ts` 和 `src/app/api/app/[...path]/route.ts` 实现,保留本站登录弹窗。
- 首次访问自动建立游客会话;已有 Cookie 时先读取 `auth/getInfo` 恢复身份、昵称和余额,接口未返回新 token 时继续使用原 token。
- 游客、登录和绑定使用同一持久设备标识,发送 `devID` 和渠道;首次渠道取 `dc/ch/pc`,后续访问保持不变。绑定支持 `inviteCode/invite/code` 邀请参数。服务端按参考项目发送设备头,并透传有效的客户端 IP。
- 绑定携带当前游客凭证以保留其数据;前端填写账号、密码和确认密码并同意协议,无需邮箱。上游要求的缺省邮箱由服务端生成为 `账号@jgdj.local`;实际已绑定邮箱仍用于找回密码。
- 绑定账号为 6–20 位字母或数字,密码为 8–64 位且包含字母和数字;登录兼容旧账号的 4 位密码及绑定允许的较长账号、密码,支持邮箱登录。
- 登录失败保留原身份;成功后更新账户并重新读取当前页面。自动游客、登录和退出响应串行处理,旧请求的失效结果不会清除客户端刚登录的身份。
- 游客请求明确返回会话失效时重建游客并重试一次;正式账号失效会清理会话并提示登录。登录、注册、找回密码自身的错误不触发游客恢复。
- 退出清理会话,并跳过紧接着一次页面初始化的自动游客登录。当前页面保持未登录,主动登录或绑定可恢复使用。
- 表单提交期间禁用重复提交;支持显示/隐藏密码,关闭和成功后均清除密码值。仅设备与渠道写本地存储,凭证保留在 HttpOnly Cookie。
站点只保留游客会话:`src/lib/client/api.ts` 的 `bootstrapGuestSession` 在进入站点时调用 `auth/guest`(已有凭证时先软读取 `auth/getInfo`),`src/lib/api/page-session.ts` 与 `src/lib/seo-server.ts` 在服务端读取会话 Cookie。登录、注册、找回密码、资料修改、个人中心、金币充值、意见反馈与评论均已移除。
## 已接入
@@ -186,10 +176,10 @@ POST 请求按参考项目转发,不在 Next.js 代理中比较 `Origin`、`Ho
- 短剧运营分类、剧集视频列表、榜单、专题、题材/标签、分页和更多列表。
- 搜索、搜索联想、热搜、猜你喜欢;搜索记录单独保存在当前浏览器。
- 短剧详情、章节列表、章节权限和实际播放地址;HLS 播放、媒体代理和播放上报。
- 游客会话、登录、注册/游客绑定、找回密码、资料与头像更新。
- 个人中心统计、收藏、历史、点赞、已购列表;点赞、收藏、评论、回复、评论点赞。
- 金币/VIP 套餐、支付方式、订单创建、余额查询和逐集解锁。
- 应用列表、下载配置、固定平台说明页、反馈提交。
- 游客会话;点赞、收藏、分享。
- 需付费的剧集不在网页解锁:播放区显示“本集请在 黄果短剧 App 观看”并提供 App 下载按钮,选集中以锁图标标出。
- 详情页与播放页底部“推荐视频”读取 `playlet/guess-like/list`(12 条,排除当前剧)。
- 应用列表、下载配置、固定平台说明页。全站唯一对外联系方式为商务合作 Telegram `@jbd125`(`src/lib/contact.ts`)。
首页“推荐 / 最新 / 热门 / 随机”筛选项保持移除。栏目排序、列表数据、总数与观看权限由接口决定,失败时显示错误或空状态。
@@ -201,23 +191,17 @@ POST 请求按参考项目转发,不在 Next.js 代理中比较 `Origin`、`Ho
搜索结果由本项目(`JHA-1050`)的 `src/lib/client/search.ts` 和 `src/lib/seo-server.ts` 实现:服务端准备会话后调用 `playlet/search`,传入 `keyword/pageNum/pageSize`,默认每页 25 条;展示接口总数、关键词和实际 `pageSize`,剧名优先使用 `playName`,不再本地筛选接口结果。无结果优先展示 `everyoneWatching`,缺少推荐时请求 `playlet/operate/list?type=2`;推荐不计入搜索数量。`/search/` 可直接进入,空关键词跳过搜索接口,翻页使用 `/search/{关键词}/page/{页码}/`,搜索框、热搜、搜索历史和联想词入口均直接生成关键词路径,不通过重定向转换。标题高亮沿用 HTML 的 NFKC、大小写和空白处理;无结果/失败状态使用原稿插图,接口错误保留重试入口,不伪装为零结果。
个人中心、收藏、历史、点赞与已购页共用原稿的头像/金币余额卡片、资料入口、书架导航与卡片列表;个人中心默认展示真实观看历史,分页链接进入历史页。设置页恢复头像编辑、昵称保存和账号安全卡片,资料、头像、密码仍通过原有接口保存;登录/绑定弹窗恢复原稿输入框、眼睛图标与按钮层级。页脚与桌面导航采用新版 HTML 的排版。
“关于我们、用户协议、隐私政策、常见问题、2257、DMCA”六页固定使用 `src/lib/info-pages.ts` 中的原 HTML 文案与结构,不再请求富文本接口,底部入口也保持固定。每次访问均由服务端输出正文、标题和对应样式标记,无须等待首页或富文本接口。保留分节间距、折叠问答、编号段落和 `mailto:` 邮箱链接,反馈链接直接使用 `/info/feedback/`。
“关于我们”的“联系我们”沿用 HTML 分节和段落样式:商务合作读取 `config.telegram`(兼容 `telegramAdd`),用户反馈读取 `config.tgGroup`,空值再取 `newAddressInfo`;不使用邮箱或在线客服地址代替 TG。服务时间等附加说明读取 `config/footer/info` 的 `footerInfo.contactList`。这些内容随正文一起由服务端输出,未配置的项目不展示。
“关于我们”的“联系我们”沿用 HTML 分节和段落样式,只展示唯一的商务合作 Telegram `@jbd125`,不再读取后台邮箱、QQ、X 或交流群配置。
反馈页恢复原稿的问题选项、环境/设备面板、描述计数、上传区域和提交区。实际提交通过 `feedback/add`,环境信息随描述保存;图片通过现有 `upload/image` 上传,支持最多 3 张、每张 5MB 以内的 JPG/PNG/WebP,不展示接口尚未支持的视频上传。上传失败不清空表单,提交时阻止重复写入,离开页面后隔离迟到的结果。
详情页结构按原 HTML 调整:封面与资料卡、热度图标和标签、播放/下载与互动操作栏、三行简介展开/收起、选集横向分段和金币角标、官方链接、评论及嵌套回复。移动端复用原稿的封面、四列操作栏与五列选集布局;无下载配置时只显示播放入口,无推荐数据时不留空侧栏。内容和章节 ID 继续由接口提供,评论分页保持独立路由。
详情页使用全宽电影海报式横幅:右侧约 48% 为清晰海报并向左渐隐,左侧为分类标签、超大标题、“·”分隔的集数/热度/收藏/点赞、#标签、可展开简介与操作(立即观看、下载 App、点赞/收藏/分享)。播放页在播放器左右两侧悬浮上一集/下一集圆形按钮,播放器下方为描边大号集数标题、胶片式横向选集条和“推荐视频”区(`src/app/hg-media.css`)。卡片为 9:16 竖版封面,右上角“1/N”计数或“单集”,左下角方块角标,标题下方点线分隔与热度/题材。
支付和解锁均由用户在页面确认后发起;创建订单不会直接把本地余额改为“支付成功”,余额通过用户信息接口刷新。
充值页按原始 HTML 设计稿展示余额与金币用途卡片、套餐面板、推荐角标、选中标记、独立支付按钮和交易明细。先选择接口套餐,再点击“立即支付”打开确认弹窗;价格、赠送金币与流水收支读取真实字段,不使用原稿的模拟金额和交易记录。桌面端四列、移动端三列套餐,交易明细继续使用独立分页路由。
充值确认弹窗按原 HTML 展示订单摘要、赠送/合计金币、金额徽标、支付渠道图标与选中圆点、“去支付”按钮和确认说明。金币数读取 `baseCoin/giftCoin/totalCoin`,价格按接口分单位换算;仅展示套餐配置的支付方式。切换样式按钮会同步实际下单参数,提交期间禁止重复下单,创建订单后仍需用户完成真实支付。
详情和播放页点击金币集数,直接打开原 HTML 样式的解锁弹窗,读取所选章节的最新权限、余额和价格;价格优先使用 `costCoins`,其次 `singleCoins`。确认后提交该集的 `chapterId/playletId`,成功后刷新余额并进入对应播放页;余额不足进入充值,会员章节进入会员套餐。读取期间禁止支付,支付期间禁止重复提交或切换目标;关闭弹窗、切换集数或离开页面会隔离迟到的读取结果。已允许观看的章节仍使用真实播放链接。
App 下载入口(顶部、悬浮图标、首页底部、详情页和 `/navigation/`)统一校验配置。配置返回前隐藏入口;没有有效的 HTTP(S) 下载链接时不展示。电脑端直接点击链接下载,已移除悬浮二维码面板和触发脚本;移动端保留点击下载弹窗,二维码只在实际点击时生成。
@@ -276,21 +260,3 @@ SMOKE_PUBLIC_ONLY=1 PREVIEW_URL=http://127.0.0.1:21030 npm run smoke:api
- `tests/api-integration.test.mjs`:SSR、鉴权隔离和代理边界测试。
- `tests/routing.test.mjs`:路径解析、搜索关键词编码、hash 地址不转换与原生页面导航测试。
- `src/lib/client/startup.ts` 与 `tests/startup.test.mjs`:年龄确认、广告队列和会话进度,以及控制器与浏览器事件适配测试。
## 埋点
按照 [Web SDK v1.2.0 文档](https://doc2.adcenteryyds.cc/web/getting-started.html) 、[飞书埋点规范](https://djprsdgo4i4n.jp.larksuite.com/wiki/X8sjwbzs2ib01Ak2TWzj9Y94pFd)(已通过 Google Chrome 读取,包含 2026-10-17 更新)为本项目(`JHA-1050`)接入,官方 SDK 固定保存在 `public/scripts/web-sdk-v1.2.0.js`。应用 ID 默认 `1050`,可通过 `NEXT_PUBLIC_TRACKER_APP_ID` 配置,必须与当前站点密文和上游应用一致;初始化优先读取 `config/info.reportSdkConfig`,可选 `NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG` 作为同项目的密文备用配置。缺少配置或 SDK 加载失败时,业务页面继续工作。
- SDK 自动采集生命周期、网络和会话;关闭自动 PV/点击,由接入层按真实路径上报,避免重复。支持首页、榜单、分页、专题、搜索、账户、信息页和独立导航页;登录、游客恢复和退出后同步 UID,渠道沿用首次归因。
- 内容曝光以可见元素为准,关闭的弹窗、被模态弹窗遮挡的页面、后台标签页和轮播副本不计入;关闭弹窗或恢复标签页后重新观察尚未曝光的元素。短剧、专题和普通福利应用均有曝光、点击;内容无父级时 `parent_item_id` 使用 `0`,满足 SDK 的必填非空字符串校验。
- 广告保留接口广告代码和广告位字段,上报 `adImpression/adClick`;页面广告、大图弹窗、宫格弹窗按实际展示位置分别去重,避免同一广告出现在宫格时被首页曝光记录吞掉。宫格仅统计滚动区域内可见的项目。两种弹窗另按参考项目协议上报 `advertising` 的 `show/click/close` 行为;只关闭实际曝光过的项目,同一展示只记一次关闭,关闭不算广告点击。福利应用有 `advitiseId` 时使用该广告 ID 上报,没有广告关联时按普通内容统计。
- 搜索成功后上报关键词与实际总数,结果点击使用含页码的全局位置;未接推荐引擎的追踪字段保留空串。顶部、侧栏、独立入口页渠道和 App 下载入口记录导航点击。
- 短剧使用 `dramaEvent/dramaStatusChange`,记录展示、播放、暂停、快进/快退、完成、分享、切集和成功解锁;独立视频使用 `videoEvent`。终止播放失败分别上报 `ep_failed/video_failed`(播放失败),自动恢复中的中间错误和预告失败不作为正片失败上报。短剧完播同时补报 `ep_play`,进度固定 100%,保留最后一段播放时长。播放时长按实际播放区间累计,排除缓冲和免费预告,进度为百分比,倍速乘以 100。
- 所有同源业务 API 请求携带 SDK 原始公共字段 `X-SDK-Common`,保留 SDK 返回的完整公共字段,包括应用、渠道、UID、数值时间戳、会话、设备、指纹和版本等;注册、登录、充值及 VIP 下单都使用同一份字段格式。字段来自 `WebSDK.getCommonFields()`,不重新生成指纹或会话 ID,等待最多 1.5 秒。
- 点赞、收藏、评论、购买由上游服务端上报,前端传入 `X-Tracker-Request-Id/X-Tracker-Event-Name/X-DC-Request-Context`,包含完整公共字段和目标剧集上下文。Next.js 代理校验应用 ID、事件对应接口和字段结构后透传;游客重试复用原始快照和请求 ID,不额外从客户端发送业务成功事件。
SDK 未初始化时最多缓存 1000 条事件,配置到达后按顺序补发;配置接口的首次请求不等待 SDK 加载,避免互相等待,首次 SDK 脚本失败后在取得配置时再尝试加载。SDK 返回字段校验失败、事件不在允许列表、地址不可用或异常时,仅触发脱敏的 `tracker:diagnostic` 事件,不向浏览器控制台输出。自带 SDK 仅移除了三处控制台警告,保留字段校验、HTTPS 地址限制和配置解密失败兜底;网络重试和持久队列由官方 SDK 负责。
埋点回归使用 SDK 替身及项目自带官方 SDK 的真实字段校验器;浏览器验证使用同一 SDK 的事件地址参数,将签名后的测试记录发送到本机接收端,不向真实统计后台发送测试事件。生产环境还需在广告中心为当前应用的 `reportSdkConfig` 配置业务域名反向代理地址(SDK 的 `batchUrls`),由部署层转发到文档指定的生产批量上报接口,不能让客户端直连生产上报域名。代理需保留请求体、真实来源 IP 和 SDK 签名;目前未变更线上域名或 Nginx 配置。
“我的点赞”入口位于头像菜单和个人中心书架导航,路由为 `/member/likes/`,分页使用 `/member/likes/page/N/`。服务端通过当前账号请求 `GET /api/app/playlet/like/list?pageNum=N&pageSize=24`,列表不进入公开缓存或搜索索引;空列表显示原 HTML 的空状态和发现入口,接口失败显示重试状态。
-2
View File
@@ -5,5 +5,3 @@ PORT=21030
UPSTREAM_API_URL=http://172.104.181.99:26792
API_CHANNEL=
PUBLIC_DATA_CACHE_TTL_MS=0
NEXT_PUBLIC_TRACKER_APP_ID=1050
NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG=
-2
View File
@@ -7,5 +7,3 @@ PORT=21042
UPSTREAM_API_URL=http://139.162.49.252:21040
API_CHANNEL=
PUBLIC_DATA_CACHE_TTL_MS=60000
NEXT_PUBLIC_TRACKER_APP_ID=1050
NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG=
-2
View File
@@ -5,5 +5,3 @@ PORT=21030
UPSTREAM_API_URL=http://172.104.181.99:26792
API_CHANNEL=
PUBLIC_DATA_CACHE_TTL_MS=60000
NEXT_PUBLIC_TRACKER_APP_ID=1050
NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG=
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+2 -2
View File
@@ -95,14 +95,14 @@ function installPlayerControls(screen,currentEpisode=1,actions={}){
const header=document.querySelector('#global-site-header .top'),sidebar=document.getElementById('site-sidebar');if(!header||!sidebar)return;
const mobile=matchMedia('(max-width:760px)'),trigger=document.createElement('button');trigger.type='button';trigger.className='mobile-menu-trigger';trigger.setAttribute('aria-label','打开导航菜单');trigger.setAttribute('aria-controls','mobile-navigation-drawer');trigger.setAttribute('aria-expanded','false');trigger.innerHTML='<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg>';header.prepend(trigger);
const drawer=document.createElement('dialog');drawer.id='mobile-navigation-drawer';drawer.setAttribute('aria-label','网站导航');
drawer.innerHTML='<button type="button" class="mobile-drawer-close" aria-label="关闭导航菜单" autofocus><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 5 14 14M19 5 5 19"/></svg></button><a class="mobile-drawer-brand" href="/" aria-label="禁果短剧首页"></a><nav class="mobile-drawer-links" aria-label="手机端主导航"></nav><div class="mobile-drawer-info"></div><div class="mobile-drawer-social" aria-label="官方社区"></div>';
drawer.innerHTML='<button type="button" class="mobile-drawer-close" aria-label="关闭导航菜单" autofocus><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m5 5 14 14M19 5 5 19"/></svg></button><a class="mobile-drawer-brand" href="/" aria-label="黄果短剧首页"></a><nav class="mobile-drawer-links" aria-label="手机端主导航"></nav><div class="mobile-drawer-info"></div><div class="mobile-drawer-social" aria-label="官方社区"></div>';
const brand=sidebar.querySelector('.sidebar-brand').cloneNode(true);brand.removeAttribute('class');drawer.querySelector('.mobile-drawer-brand').append(brand);
const nav=drawer.querySelector('.mobile-drawer-links');
const sourceNavigation=sidebar.querySelector('[data-site-navigation="sidebar"]');
if(sourceNavigation)nav.innerHTML=sourceNavigation.innerHTML;
nav.querySelectorAll('a[data-page]').forEach(link=>{link.dataset.mobileRoute=link.dataset.page;link.dataset.siteNav='sidebar';});
const pcInfo=sidebar.querySelector('.side-info');if(pcInfo)drawer.querySelector('.mobile-drawer-info').innerHTML=pcInfo.innerHTML;
const footerLogo=drawer.querySelector('.mobile-drawer-info .side-info-wordmark');if(footerLogo){const brandText=document.createElement('span');brandText.className='mobile-drawer-brand-text';brandText.textContent='禁果短剧';footerLogo.replaceWith(brandText);}
const footerLogo=drawer.querySelector('.mobile-drawer-info .side-info-wordmark');if(footerLogo){const brandText=document.createElement('span');brandText.className='mobile-drawer-brand-text';brandText.textContent='黄果短剧';footerLogo.replaceWith(brandText);}
sidebar.querySelectorAll('.sidebar-social a').forEach(link=>drawer.querySelector('.mobile-drawer-social').append(link.cloneNode(true)));
document.body.append(drawer);let timer=null,start=null;
function finishClose(){clearTimeout(timer);timer=null;drawer.classList.remove('is-closing');if(drawer.open)drawer.close();document.body.classList.remove('mobile-drawer-open');trigger.setAttribute('aria-expanded','false')}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function Page({params,searchParams}:PageProps) {
if (!route) notFound();
const {bootstrap,view,session,shared}=await getPageSeo(pathname(path),queryString(await searchParams));
assertPageContent(pathname(path),bootstrap.content);
const title=bootstrap.content.title||({'apps':'福利应用','home-road':'回家的路',center:'个人中心',favorites:'我的收藏',history:'观看历史',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置',deposit:'金币充值',feedback:'意见反馈'} as Record<string,string>)[route.key]||'内容列表';
const title=bootstrap.content.title||({'home-road':'回家的路'} as Record<string,string>)[route.key]||'内容列表';
const fallback:PageView={panel:'channel',title,html:empty(bootstrap.content.failed?'内容暂不可用,请稍后重试':'暂无内容',true)};
return <SiteFrame home={{}} homeLoaded={false} view={view||fallback} seo={bootstrap} session={session} shared={shared} />;
}
+12 -75
View File
@@ -4,7 +4,7 @@ import { isIP } from "node:net";
import { allowedRequest, PUBLIC_PATHS, upstream } from "@/lib/api/server";
import { UPSTREAM_HOP_HEADER } from '@/lib/api/config';
import { ApiError, EXPIRED_CODES } from "@/lib/api/protocol";
import { trackingHeaders } from "@/lib/api/tracking-headers";
import { sdkHeaders } from "@/lib/api/sdk-headers";
import type { Envelope, User } from "@/lib/api/types";
export const runtime = "nodejs";
@@ -14,7 +14,6 @@ const BEARER = "drama_bearer";
const DEVICE = "drama_device";
const REGISTERED = "drama_registered";
const CHANNEL = "drama_channel";
const CREDENTIALS = new Set(['auth/login','auth/register','auth/resetPassword']);
const text = (value: unknown) => typeof value === 'string' ? value.trim() : '';
const deviceId = (value: unknown) => /^[a-zA-Z0-9_-]{1,100}$/.test(text(value)) ? text(value) : '';
const expired = (code: number) => EXPIRED_CODES.has(code) || code === 401;
@@ -32,11 +31,6 @@ function sessionUser(value: unknown, token = '', registered = false): User {
return {...user, token:sessionToken, isRegister:Boolean(user.isRegister) || user.type === 1 || registered};
}
function userIdOf(value: unknown) {
const userId = value && typeof value === 'object' ? (value as User).userId : 0;
return Number.isSafeInteger(userId) && userId > 0 ? userId : 0;
}
async function handler(request: NextRequest, context: {params: Promise<{path: string[]}>}) {
const path = (await context.params).path.join('/');
const method = request.method;
@@ -61,12 +55,11 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
const registered = request.cookies.get(REGISTERED)?.value === '1';
let device = deviceId(request.cookies.get(DEVICE)?.value) || randomUUID();
let channel = text(request.cookies.get(CHANNEL)?.value).slice(0,100);
const establishesDevice = ['auth/guest','auth/login','auth/register'].includes(path);
const establishesDevice = path === 'auth/guest';
const userAgent = request.headers.get('user-agent') || '';
const forwardedIp = (request.headers.get('x-real-ip') || request.headers.get('x-forwarded-for')?.split(',')[0] || '').trim();
const clientIp = isIP(forwardedIp) ? forwardedIp : undefined;
const tracking=trackingHeaders(request.headers,path,method);
const identity=trackingHeaders(request.headers,'auth/guest','POST');
const identity=sdkHeaders(request.headers);
const setContextCookies = (response: NextResponse) => {
if (establishesDevice) {
response.cookies.set(DEVICE,device,{...cookieOptions,httpOnly:false,maxAge:365*86400});
@@ -80,7 +73,7 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
};
// Normalize HTTP 401 into the same recovery path as an expired business envelope.
const call = async (callPath: string, options: Parameters<typeof upstream>[1]): Promise<Envelope<unknown>> => {
try { return await upstream(callPath,{...options,request,clientIp,tracking:callPath.split('?')[0]===path?tracking:identity}); }
try { return await upstream(callPath,{...options,request,clientIp,tracking:identity}); }
catch (error) {
if (error instanceof ApiError && error.code === 401) return {code:401,msg:error.message,data:null};
throw error;
@@ -90,21 +83,16 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
let body: unknown;
if (method === 'POST') {
const size = Number(request.headers.get('content-length') || 0);
if (size > 6*1024*1024) throw new ApiError('文件过大,请上传小于 5MB 的图片',413);
body = request.headers.get('content-type')?.includes('multipart/form-data') ? await request.formData() : await request.json().catch(()=>({}));
if (size > 1024*1024) throw new ApiError('请求内容过大',413);
body = await request.json().catch(()=>({}));
}
const input = body && typeof body === 'object' && !Array.isArray(body) && !(body instanceof FormData) ? body as Record<string,unknown> : {};
const input = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string,unknown> : {};
if (establishesDevice) {
device = deviceId(request.cookies.get(DEVICE)?.value) || deviceId(input.devID) || device;
channel ||= text(input.channel).slice(0,100) || text(process.env.API_CHANNEL).slice(0,100);
}
let session: User | undefined;
let result: Envelope<unknown>;
let switching = false;
let previousUserId = 0;
let switchedDevice = '';
let switchAccount = '';
let switchPassword = '';
if (path === 'auth/guest') {
if (token) {
const info = await call('auth/getInfo',{token,device,userAgent});
@@ -114,71 +102,20 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
session ||= await guest();
result = {code:200,data:session};
} else {
if (path === 'auth/login' || path === 'auth/register') {
const account = text(input[path === 'auth/login' ? 'account' : 'accountNo']);
const password = typeof input.password === 'string' ? input.password : '';
if (!account || !password) throw new ApiError('请填写账号和密码',400);
// Binding keeps the guest. Switching must not: the upstream login finds
// the current visitor by devID and would return that same account.
switching = path === 'auth/login' && input.switchAccount === true;
if (switching && token) {
const info = await call('auth/getInfo',{token,device,userAgent});
if (info.code === 200) previousUserId = userIdOf(info.data);
}
if (switching) switchedDevice = randomUUID();
switchAccount = account;
switchPassword = password;
body = path === 'auth/login'
? {account,password,devID:switchedDevice || device,channel}
: {accountNo:account,password,email:text(input.email) || account+'@jgdj.local',devID:device,channel,...(text(input.agentInviteCode) ? {agentInviteCode:text(input.agentInviteCode).slice(0,100)} : {})};
if (path === 'auth/login' || registered) token = '';
} else if (path === 'auth/resetPassword') {
const accountNo = text(input.accountNo);
const email = text(input.email);
const newPassword = typeof input.newPassword === 'string' ? input.newPassword : '';
if (!accountNo || !email || !newPassword) throw new ApiError('请填写账号、绑定邮箱和新密码',400);
body = {accountNo,email,newPassword};
token = '';
} else if (path === 'auth/profile') {
const next: Record<string, unknown> = {};
for (const key of ['nickName','avatar','introduction','oldPassword','newPassword'] as const) {
if (typeof input[key] === 'string' && input[key]) next[key] = input[key];
}
if (typeof input.privacyMode === 'boolean') next.privacyMode = input.privacyMode;
if (!Object.keys(next).length) throw new ApiError('请填写要修改的内容',400);
body = next;
} else if (path === 'feedback/add') {
const content = typeof input.content === 'string' ? input.content.trim().slice(0,500) : '';
let type = Number(input.type);
if (type === 5) type = 4;
if (!content) throw new ApiError('请填写问题描述',400);
if (![1,2,3,4].includes(type)) throw new ApiError('请选择问题类型',400);
const screenshots = Array.isArray(input.screenshots) ? input.screenshots.filter((item): item is string => typeof item === 'string' && !!item.trim()).map(item => item.trim()).slice(0,3) : [];
const contact = text(input.contact).slice(0,120);
body = {type,content,...(contact ? {contact} : {}),...(screenshots.length ? {screenshots} : {})};
}
result = await call(path+request.nextUrl.search,{method,body,token,device,userAgent});
if (switching && result.code === 200 && previousUserId && userIdOf(result.data) === previousUserId) {
body = {account:switchAccount,password:switchPassword,channel};
result = await call(path+request.nextUrl.search,{method,body,token:'',device:switchedDevice,userAgent});
}
if (switching && result.code === 200) {
if (previousUserId && userIdOf(result.data) === previousUserId) throw new ApiError('切换失败,请确认账号和密码',400);
device = switchedDevice;
}
// Retry only an explicitly rejected guest request, once; never downgrade registered actions.
if (expired(result.code) && !PUBLIC_PATHS.has(path) && !CREDENTIALS.has(path) && path !== 'auth/profile' && path !== 'feedback/add' && path !== 'upload/image' && !registered) {
if (expired(result.code) && !PUBLIC_PATHS.has(path) && !registered) {
session = await guest();
token = session.token!;
result = await call(path+request.nextUrl.search,{method,body,token,device,userAgent});
}
if (result.code === 200 && ['auth/login','auth/register','auth/getInfo'].includes(path)) {
session = sessionUser(result.data,path === 'auth/getInfo' ? token : '',registered || CREDENTIALS.has(path));
if (result.code === 200 && path === 'auth/getInfo') {
session = sessionUser(result.data,token,registered);
result = {...result,data:session};
}
}
const data = result.data && typeof result.data === 'object' && 'token' in result.data ? {...result.data,token:undefined} : result.data;
const isExpired = expired(result.code) && !CREDENTIALS.has(path);
const isExpired = expired(result.code);
const response = NextResponse.json({...result,data},{status:isExpired ? 401 : 200,headers});
if (isExpired) {
// A content or action failure must not erase a cookie that getInfo can still renew.
@@ -197,7 +134,7 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
return response;
} catch (error) {
const code = error instanceof ApiError ? error.code : 502;
const isExpired = expired(code) && !CREDENTIALS.has(path);
const isExpired = expired(code);
const response = NextResponse.json({code,msg:error instanceof Error ? error.message : '请求失败',data:null},{status:isExpired ? 401 : code >= 400 && code <= 599 ? code : 502,headers});
if (isExpired) {
if (path === 'auth/getInfo' || path === 'auth/guest') clearSession(response);
+1 -1
View File
@@ -2,7 +2,7 @@
export default function PageError({retry}:{retry:()=>void}) {
return <main className="route-not-found">
<title>页面暂不可用 - 禁果短剧</title>
<title>页面暂不可用 - 黄果短剧</title>
<meta name="robots" content="noindex,follow" />
<h1>页面暂不可用</h1>
<p>内容服务暂时异常,请稍后重试</p>
-50
View File
@@ -183,11 +183,7 @@
@media(max-width:1180px){.channel-shell .cards{grid-template-columns:repeat(3,minmax(0,1fr))}.road-grid{grid-template-columns:1fr}.channel-rank{gap:14px}}
@media(max-width:760px){.mobile-pages{display:flex;gap:6px;overflow-x:auto;padding:12px 19px;border-bottom:1px solid var(--line);background:var(--bg)}.mobile-pages a{flex-shrink:0;font-size:12px;padding:8px 11px;border-radius:7px;color:var(--muted)}.mobile-pages a[aria-current=page]{color:var(--accent);background:#ec208314}.channel-shell{padding-top:22px!important}.channel-shell .cards{grid-template-columns:repeat(2,minmax(0,1fr));gap:22px 12px}.channel-topics{grid-template-columns:1fr}.channel-rank{grid-template-columns:24px 60px minmax(0,1fr);gap:12px;padding:16px}.channel-rank .score{grid-column:3;font-size:14px}.channel-rank .score small{display:inline;margin-left:8px}.channel-rank h2{font-size:16px}.channel-rank p.rank-desc{display:none}.channel-rank>.number{font-size:24px}.road-banner{padding:24px}.road-banner h2{font-size:24px}}
/* benefits-directory-styles */
.benefits-directory{max-width:1400px}.benefits-directory>.channel-tabs{position:sticky;top:0;z-index:3;background:var(--bg);padding:14px 0;gap:12px;margin-bottom:16px}.benefits-directory>.channel-tabs button{border:1px solid transparent;padding:12px 20px;font-size:14px}.benefits-directory>.channel-tabs button[aria-pressed=true]{border-color:#ec20832c}.benefits-notice{font-size:12px;color:#80867d;margin:0 0 36px}.benefit-section{scroll-margin-top:96px;padding:0 0 34px;margin:0 0 32px;border-bottom:1px solid var(--line)}.benefit-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:24px}.benefit-heading h2{font-size:24px;letter-spacing:.3px;margin:0 0 8px}.benefit-heading p{font-size:14px;line-height:1.8;color:var(--muted);margin:0}.benefit-heading>span{flex-shrink:0;font-size:12px;color:#93998e;border:1px solid var(--line);padding:5px 10px;border-radius:20px}.benefit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.benefit-grid>div{min-width:0}.benefit-card{display:flex;align-items:center;gap:16px;text-align:left;width:100%;height:116px;padding:20px;background:#1c1e1d;border:1px solid #ffffff0a;border-radius:14px;transition:background .2s,border-color .2s,transform .2s}.benefit-card:hover{background:#242622;border-color:#ec20834a;transform:translateY(-2px)}.benefit-icon{position:relative;display:grid;place-items:center;width:64px;height:64px;flex-shrink:0;border-radius:17px;background:linear-gradient(140deg,var(--app-color),#1b1e23);color:#fff5e7;overflow:hidden;box-shadow:inset 0 1px 0 #ffffff38,0 5px 14px #0003}.benefit-icon>span{position:relative;z-index:1;font:600 30px "Songti SC",serif}.benefit-icon:after{content:'';position:absolute;right:-10px;bottom:-14px;width:52px;height:52px;border:1px solid #ffffff3a;border-radius:50%}.benefit-icon i{position:absolute;width:40px;height:40px;border:1px solid #ffffff26;border-radius:50%;left:-14px;top:-9px}.benefit-copy{min-width:0;display:grid;gap:7px}.benefit-copy strong{font-size:16px;font-weight:650;color:var(--text)}.benefit-copy small{font-size:12px;color:#989e92;line-height:1.7}.benefit-arrow{margin-left:auto;color:#808878;font-size:18px}.benefit-card:hover .benefit-arrow{color:var(--accent)}.benefit-expand{display:block;margin:22px auto 0;padding:10px 28px;border:1px solid #ffffff18;border-radius:8px;color:#b5bbad;font-size:12px}.benefit-expand:hover{border-color:#ec208366}.benefit-pending{display:flex;align-items:center;gap:18px;border:1px dashed #ffffff20;border-radius:14px;padding:28px;background:#ffffff02}.benefit-pending>span{width:48px;height:48px;display:grid;place-items:center;border:1px solid var(--line);border-radius:12px;font-size:22px;color:#8c9385}.benefit-pending strong{font-size:14px;color:#a8ae9f;font-weight:500}.benefit-pending p{font-size:12px;color:#767d71;margin:7px 0 0}.benefit-detail{padding:44px;text-align:center}.benefit-detail>.benefit-icon{margin:0 auto 22px;width:80px;height:80px;border-radius:22px}.benefit-detail-label{font-size:12px;color:var(--accent);letter-spacing:2px}.benefit-detail h2{font-size:28px;margin:10px 0}.benefit-detail>p:not(.benefit-detail-label){font-size:14px;color:var(--muted)}.benefit-detail-note{padding:18px;background:#ffffff04;border-radius:10px;font-size:12px;line-height:1.9;color:#a1a899;margin:26px 0 20px}.benefit-disabled{background:#ffffff08;color:#747b6c;padding:12px 22px;border-radius:8px;cursor:not-allowed}
@media(max-width:1180px){.benefit-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.benefit-card{gap:13px;padding:16px}.benefits-directory>.channel-tabs{gap:6px}.benefits-directory>.channel-tabs button{font-size:12px;padding:10px 12px}}
@media(max-width:760px){.benefits-directory>.channel-tabs{flex-wrap:nowrap;overflow-x:auto;margin-left:-19px;margin-right:-19px;padding-left:19px;padding-right:19px}.benefits-directory>.channel-tabs button{flex-shrink:0}.benefit-section{scroll-margin-top:82px}.benefit-heading{align-items:flex-start}.benefit-heading h2{font-size:20px}.benefit-heading p{font-size:12px}.benefit-heading>span{display:none}.benefit-grid{gap:12px}.benefit-card{height:158px;flex-direction:column;justify-content:flex-start;gap:10px;text-align:center;padding:16px 10px}.benefit-icon{width:54px;height:54px;border-radius:15px}.benefit-icon>span{font-size:26px}.benefit-copy{gap:4px}.benefit-copy strong{font-size:14px}.benefit-copy small{font-size:10px}.benefit-arrow{display:none}.benefit-detail{padding:36px 22px}.benefits-notice{line-height:1.8;margin-bottom:28px}}
/* remote-placeholder-covers */
/* Placeholder cover source: https://hongguoduanju.com/ */
@@ -201,7 +197,6 @@
@media(max-width:760px){.hero-art{width:100%;opacity:.65}.dialog-art{background-position:center 24%!important}}
/* tencent-navigation-icons */
.side>nav>a .qq-nav-icon{display:block;width:24px;height:24px;flex:0 0 24px;background-color:currentColor;-webkit-mask-image:var(--qq-icon);mask-image:var(--qq-icon);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center;-webkit-mask-size:contain;mask-size:contain}.side>nav>a.active .qq-nav-icon{-webkit-mask-image:var(--qq-icon-selected);mask-image:var(--qq-icon-selected)}.side>nav>a[data-page="rank"] .qq-nav-icon{-webkit-mask-size:32px 32px;mask-size:32px 32px}.side>nav>a[data-page="home"]{--qq-icon:url("/assets/19ed41602046c136.svg");--qq-icon-selected:url("/assets/813abcae48078ac7.svg")}.side>nav>a[data-page="shorts"]{--qq-icon:url("/assets/f3d55d5dd5a321d3.png");--qq-icon-selected:url("/assets/7d05c412943babef.png")}.side>nav>a[data-page="series"]{--qq-icon:url("/assets/8c1c285ac6bd3b66.svg");--qq-icon-selected:url("/assets/49cffe02110af446.svg")}.side>nav>a[data-page="topics"]{--qq-icon:url("/assets/9903882b2da0df62.svg");--qq-icon-selected:url("/assets/bf82634dbd0f4907.svg")}.side>nav>a[data-page="rank"]{--qq-icon:url("/assets/c80f072eec53a3ca.svg");--qq-icon-selected:url("/assets/c80f072eec53a3ca.svg")}.side>nav>a[data-page="home-road"]{--qq-icon:url("/assets/350ab2f3118693a8.svg");--qq-icon-selected:url("/assets/350ab2f3118693a8.svg")}.side>nav>a[data-page="apps"]{--qq-icon:url("/assets/16bd37861fd6246c.svg");--qq-icon-selected:url("/assets/16bd37861fd6246c.svg")}
/* portrait-cover-ratio */
/* All portrait poster frames use 3:4. Landscape modules retain their sizing. */
@@ -693,7 +688,6 @@ html:not(.sidebar-collapsed) .side .sidebar-header>.sidebar-logo-toggle .brand-w
@media(max-width:760px){#twist-cards{grid-template-columns:repeat(2,minmax(0,1fr));gap:22px 12px}}
.side>nav>a[data-page="rank"]{--qq-icon-selected:url("/assets/2699fa102e73d8c8.svg")}
.side>nav>a[data-page="home-road"]{--qq-icon-selected:url("/assets/92fc7fda98f7db04.svg")}
.side>nav>a[data-page="apps"]{--qq-icon-selected:url("/assets/3482d6856aee853d.svg")}
html .side>nav>a[data-page="rank"].active .qq-nav-icon{-webkit-mask-size:contain;mask-size:contain}
/* martial-two-line-description */
@@ -737,7 +731,6 @@ html .side>nav>a[data-page="rank"].active .qq-nav-icon{-webkit-mask-size:contain
/* consistent-navigation-icons */
.side>nav>a[data-page="rank"].active .qq-nav-icon,
.side>nav>a[data-page="home-road"].active .qq-nav-icon,
.side>nav>a[data-page="apps"].active .qq-nav-icon{-webkit-mask-image:var(--qq-icon);mask-image:var(--qq-icon)}
html .side>nav>a[data-page="rank"].active .qq-nav-icon{-webkit-mask-size:32px 32px;mask-size:32px 32px}
html:not(.sidebar-collapsed) .side>nav>a[data-page="rank"].active .qq-nav-icon{-webkit-mask-size:37px 37px;mask-size:37px 37px}
@@ -949,35 +942,7 @@ html:not(.sidebar-collapsed) .side .sidebar-header>.sidebar-logo-toggle{height:4
@media(max-width:1000px){#channel-page[data-channel="rank"] #channel-content .channel-ranking-cards{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media(max-width:600px){#channel-page[data-channel="rank"] #channel-content .channel-ranking-cards{grid-template-columns:repeat(2,minmax(0,1fr));gap:22px 12px}.rank-home-badge{min-width:38px;height:32px;font-size:20px;border-radius:8px 8px 8px 0}}
/* benefits-reference-layout */
#channel-page[data-channel="apps"] .benefits-directory{max-width:none}
#channel-page[data-channel="apps"] .benefits-directory>.channel-tabs{position:static;gap:14px;padding:0;border:0;margin-bottom:34px;background:transparent}
#channel-page[data-channel="apps"] .channel-tabs button{border:1px solid #ffffff0c;border-radius:9px;background:#202022;padding:12px 22px;font-size:16px;color:#99999f!important}
#channel-page[data-channel="apps"] .channel-tabs button[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:white!important}
#channel-page[data-channel="apps"] .benefit-section{padding:0;margin:0 0 42px;border:0;scroll-margin-top:28px}
#channel-page[data-channel="apps"] .benefit-heading{margin-bottom:20px}
#channel-page[data-channel="apps"] .benefit-heading h2{font-size:22px;margin:0 0 9px}
#channel-page[data-channel="apps"] .benefit-heading p{font-size:14px;color:#85858e;margin:0}
#channel-page[data-channel="apps"] .benefit-grid{display:grid;grid-template-columns:repeat(10,minmax(0,1fr));gap:22px;max-width:1440px}
#channel-page[data-channel="apps"] .benefit-grid>[hidden]{display:none}
#channel-page[data-channel="apps"] .benefit-card{display:flex;flex-direction:column;align-items:center;gap:10px;width:100%;height:auto;min-height:0;min-width:0;padding:0;border:0;border-radius:0;background:transparent;box-shadow:none;text-align:center}
#channel-page[data-channel="apps"] .benefit-card:hover{background:transparent;transform:none}
#channel-page[data-channel="apps"] .benefit-icon{width:100%;height:auto;max-width:110px;aspect-ratio:1;flex:none;border-radius:22%;overflow:hidden;background:transparent;box-shadow:none}
.benefit-icon>img{display:block;width:100%;height:100%;object-fit:cover;transition:transform .25s}
.benefit-card:hover .benefit-icon>img{transform:scale(1.08)}
#channel-page[data-channel="apps"] .benefit-copy{display:block;flex:none;min-width:0;width:100%}
#channel-page[data-channel="apps"] .benefit-copy strong{display:block;font-size:14px;font-weight:500;line-height:1.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#ddd}
#channel-page[data-channel="apps"] .benefit-copy small{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:12px;line-height:1.5;margin-top:4px;color:#818188}
#channel-page[data-channel="apps"] .benefit-card:hover strong{color:var(--accent)}
#channel-page[data-channel="apps"] .benefit-expand{display:block;position:static;clear:both;margin:28px auto 0;padding:8px 18px;border:0;border-radius:999px;background:transparent;font-size:14px;color:#9898a0!important}
#channel-page[data-channel="apps"] .benefit-expand:hover{color:var(--accent)!important;background:#ffffff05}
@media(max-width:1100px){#channel-page[data-channel="apps"] .benefit-grid{grid-template-columns:repeat(5,minmax(0,1fr));gap:22px 18px}}
@media(max-width:600px){#channel-page[data-channel="apps"] .benefit-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:20px 14px}#channel-page[data-channel="apps"] .channel-tabs button{padding:10px 14px;font-size:14px}#channel-page[data-channel="apps"] .benefit-copy strong{font-size:12px}#channel-page[data-channel="apps"] .benefit-heading h2{font-size:18px}}
@media(prefers-reduced-motion:reduce){.benefit-icon>img{transition:none}.benefit-card:hover .benefit-icon>img{transform:none}}
/* benefits-two-visible-rows */
@media(max-width:1100px){#channel-page[data-channel="apps"] .benefit-grid{grid-template-columns:repeat(var(--compact-app-columns),minmax(0,1fr))}}
@media(max-width:600px){#channel-page[data-channel="apps"] .benefit-grid{gap:20px 10px}}
/* home-road-directory-style */
.return-home-directory{display:flex;flex-direction:column;gap:36px;padding-bottom:36px}.return-hero{padding:38px 40px;border:1px solid #ec208325;border-radius:22px;background:radial-gradient(ellipse at top left,#ec208320,transparent 70%),#19191d}.return-eyebrow{font-size:14px;color:#f276b0;letter-spacing:2px}.return-hero h2{font-size:30px;line-height:1.4;margin:15px 0 12px}.return-hero p{font-size:14px;color:#9b98a2;margin:0 0 24px}.return-actions,.return-card-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.return-actions a,.return-actions button{display:inline-flex;gap:18px;padding:13px 24px;border-radius:999px;border:1px solid #ffffff18;background:#ffffff05;font-size:14px;color:white}.return-actions a{background:var(--accent);border-color:var(--accent)}.return-actions a:hover{filter:brightness(1.1)}.return-actions button:hover{background:#ffffff10}
@@ -1947,22 +1912,7 @@ html{scroll-padding-top:calc(var(--global-header-height) + 20px)}
#drama-play-page .play-related .drama-meta{margin:0}
}
/* mobile-benefits-directory-layout */
@media(max-width:760px){
#channel-page[data-channel="apps"] .benefits-directory{width:100%;min-width:0}
#channel-page[data-channel="apps"] .benefits-directory>.channel-tabs{display:flex;flex-wrap:nowrap;width:100%;min-width:0;box-sizing:border-box;margin:0 0 24px;padding:8px 0;gap:8px;overflow-x:auto;scrollbar-width:none;top:var(--global-header-height);background:transparent;backdrop-filter:none}
#channel-page[data-channel="apps"] .benefits-directory>.channel-tabs::-webkit-scrollbar{display:none}
#channel-page[data-channel="apps"] .benefits-directory>.channel-tabs button{flex:0 0 auto;min-height:36px;padding:8px 12px;font-size:12px;line-height:18px;white-space:nowrap;border-radius:999px}
#channel-page[data-channel="apps"] .benefit-heading{margin-bottom:16px}
#channel-page[data-channel="apps"] .benefit-heading h2{font-size:18px;margin-bottom:6px}
#channel-page[data-channel="apps"] .benefit-heading p{font-size:12px;line-height:1.7}
#channel-page[data-channel="apps"] .benefit-section{margin-bottom:28px;scroll-margin-top:calc(var(--global-header-height) + 64px)}
#channel-page[data-channel="apps"] .benefit-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:20px 10px}
#channel-page[data-channel="apps"] .benefit-card{box-sizing:border-box;height:100%;padding:0;gap:8px;border:0;border-radius:0;background:transparent;justify-content:flex-start}
#channel-page[data-channel="apps"] .benefit-card:hover{background:transparent}
#channel-page[data-channel="apps"] .benefit-icon{width:100%;height:auto;max-width:72px;aspect-ratio:1;flex:none}
#channel-page[data-channel="apps"] .benefit-copy strong{font-size:12px;line-height:1.5;white-space:normal;overflow-wrap:anywhere;text-overflow:clip}
#channel-page[data-channel="apps"] .benefit-copy small{display:block;font-size:12px;line-height:1.6;margin-top:5px;white-space:normal;overflow:visible;-webkit-line-clamp:unset}
}
/* mobile-member-ad-scroll */
+148
View File
@@ -0,0 +1,148 @@
/*
* 黄果短剧 media UI: tall gallery card, cinematic detail hero, film-strip player page.
* Loaded after globals.css; selectors are scoped to new class names.
*/
/* ---------- brand logo (PNG 444×108) ---------- */
#global-site-header .top>.hg-header-brand .hg-complete-logo{width:164px;height:40px;aspect-ratio:444/108;object-fit:contain}
.jg-masthead-brand img{width:222px;height:54px;object-fit:contain}
.side-info .side-info-wordmark,#mobile-navigation-drawer .side-info-wordmark{width:110px;height:auto;aspect-ratio:444/108}
.entry-brand-logo{aspect-ratio:444/108}
.side .sidebar-header .sidebar-brand{object-position:left center}
/* ---------- card: 9:16 gallery cover ---------- */
.hg-card{display:flex;flex-direction:column;min-width:0;color:inherit;text-decoration:none;outline:none;-webkit-tap-highlight-color:transparent}
.hg-card-cover{position:relative;display:block;aspect-ratio:9/16;border-radius:16px;overflow:hidden;background:#1b1c1c}
.hg-card-cover::after{content:"";position:absolute;inset:0;z-index:3;border-radius:inherit;box-shadow:inset 0 0 0 1px rgba(255,255,255,.09);pointer-events:none;transition:box-shadow .18s ease}
.hg-card-cover>.ranking-poster-frame{position:absolute;inset:0;display:block;width:100%;height:100%}
.hg-card-cover>.poster-source,.hg-card-cover>.ranking-poster-frame>.poster-source{position:absolute;inset:0;width:100%;height:100%;max-width:none;object-fit:cover;object-position:center top;transform:none!important;transition:none!important}
.hg-card:hover .hg-card-cover::after,.hg-card:focus-visible .hg-card-cover::after{box-shadow:inset 0 0 0 3px var(--accent,#ec2083)}
.hg-card-counter{position:absolute;top:8px;right:8px;z-index:2;padding:2px 7px;border-radius:6px;background:rgba(12,12,12,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;font:600 11px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.03em;font-variant-numeric:tabular-nums}
.hg-card-badge{position:absolute;left:0;bottom:0;z-index:2;display:block;line-height:0}
.hg-card-badge .cover-corner-badge{position:static;inset:auto;display:block;width:auto;height:auto;min-width:0;margin:0;padding:4px 9px;border:0;border-radius:0;box-shadow:none;transform:none;clip-path:none;background:var(--accent,#ec2083);color:#fff;font-size:11px;font-weight:700;line-height:1.4;letter-spacing:.06em}
.hg-card-badge .cover-corner-badge::before,.hg-card-badge .cover-corner-badge::after{content:none;display:none}
.hg-card-badge .cover-corner-badge--hot{background:#ff5b1f}
.hg-card-badge .cover-corner-badge--new{background:#13a77a}
.hg-card-title{margin:10px 0 0;color:#ececec;font-size:14px;font-weight:600;line-height:1.45;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;min-height:2.9em;overflow-wrap:anywhere;transition:color .18s ease}
.hg-card:hover .hg-card-title,.hg-card:focus-visible .hg-card-title{color:var(--accent,#ec2083)}
.hg-card-title .search-keyword{color:var(--accent,#ec2083)}
.hg-card-rule{display:block;height:0;margin:8px 0 7px;border-top:1px dotted rgba(255,255,255,.22)}
.hg-card-meta{display:flex;align-items:center;gap:6px;min-width:0;color:var(--muted,#92958f);font-size:12px;line-height:1.4}
.hg-card-views{display:inline-flex;align-items:center;gap:4px;flex:0 0 auto;font-variant-numeric:tabular-nums}
.hg-card-views svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.8}
.hg-card-dot{opacity:.6}
.hg-card-theme{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ---------- detail: cinematic hero ---------- */
#drama-play-page .cine-hero{position:relative;isolation:isolate;display:flex;align-items:stretch;min-height:420px;border-radius:20px;overflow:hidden;background:#0b0c0c;box-shadow:inset 0 0 0 1px rgba(255,255,255,.06)}
#drama-play-page .cine-hero-art{position:absolute;top:0;right:0;bottom:0;width:48%;z-index:0}
#drama-play-page .cine-hero-image{display:block;width:100%;height:100%;max-width:none;object-fit:cover;object-position:center 22%;-webkit-mask-image:linear-gradient(90deg,transparent 0,rgba(0,0,0,.55) 22%,#000 52%);mask-image:linear-gradient(90deg,transparent 0,rgba(0,0,0,.55) 22%,#000 52%)}
#drama-play-page .cine-hero::after{content:"";position:absolute;inset:auto 0 0 0;height:90px;z-index:0;background:linear-gradient(180deg,transparent,rgba(11,12,12,.65));pointer-events:none}
#drama-play-page .cine-hero-copy{position:relative;z-index:1;display:flex;flex-direction:column;justify-content:center;width:62%;max-width:720px;padding:44px 48px;box-sizing:border-box}
#drama-play-page .cine-kicker{display:block;color:var(--accent,#ec2083);font-size:12px;font-weight:700;letter-spacing:.24em;line-height:1.4}
#drama-play-page .cine-hero h1{margin:12px 0 16px;color:#fff;font-size:clamp(34px,4.1vw,58px);font-weight:800;line-height:1.08;letter-spacing:-.01em;overflow-wrap:anywhere}
#drama-play-page .cine-facts{display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;margin:0;color:#cfcfcf;font-size:14px;line-height:1.6}
#drama-play-page .cine-facts i{font-style:normal;color:#666}
#drama-play-page .cine-tags{display:flex;flex-wrap:wrap;gap:4px 14px;margin:12px 0 0}
#drama-play-page .cine-tags a,#drama-play-page .cine-tags span{color:#9fa39f;font-size:13px;text-decoration:none}
#drama-play-page .cine-tags a:hover{color:var(--accent,#ec2083)}
#drama-play-page .cine-hero .series-description-wrap{max-width:560px;margin:16px 0 24px}
#drama-play-page .cine-hero .series-description{color:#a9a9a9}
#drama-play-page .cine-actions{display:flex;align-items:center;flex-wrap:wrap;gap:14px 26px}
#drama-play-page .cine-play{display:inline-flex;align-items:center;gap:10px;height:48px;padding:0 28px;border-radius:12px;background:var(--accent,#ec2083);color:#fff;font-size:16px;font-weight:700;text-decoration:none;box-shadow:0 10px 26px -12px var(--accent,#ec2083)}
#drama-play-page .cine-play svg{width:18px;height:18px;fill:currentColor}
#drama-play-page .cine-play:hover{filter:brightness(1.08)}
#drama-play-page .cine-download{display:inline-flex;align-items:center;gap:6px;padding-bottom:2px;border-bottom:1px solid rgba(255,255,255,.3);color:#eee;font-size:15px;text-decoration:none;cursor:pointer}
#drama-play-page .cine-download[hidden]{display:none}
#drama-play-page .cine-download svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;transition:transform .18s}
#drama-play-page .cine-download:hover{color:var(--accent,#ec2083);border-color:currentColor}
#drama-play-page .cine-download:hover svg{transform:translateX(3px)}
#drama-play-page .series-unavailable{color:#888;font-size:14px}
/* small outlined text buttons (like / collect / share) */
#drama-play-page .cine-engage.play-detail-actions{display:flex;flex-wrap:wrap;gap:8px;width:auto;margin:20px 0 0}
#drama-play-page .cine-engage.play-detail-actions>button{display:inline-flex;flex:0 0 auto;width:auto;min-width:0;max-width:none;height:30px;min-height:0;padding:0 12px;gap:0;border:1px solid rgba(255,255,255,.24);border-radius:6px;background:transparent;color:#cfcfcf;font-size:12px;line-height:1}
#drama-play-page .cine-engage.play-detail-actions .play-action-icon{display:none}
#drama-play-page .cine-engage.play-detail-actions>button:hover{border-color:var(--accent,#ec2083)}
#drama-play-page .cine-engage.play-detail-actions>button[aria-pressed="true"]{background:transparent!important;border-color:var(--accent,#ec2083)!important;color:var(--accent,#ec2083)!important}
#drama-play-page .cine-engage.play-detail-actions>button[aria-pressed="true"]>span{color:var(--accent,#ec2083)!important}
/* ---------- play: player, edge arrows, app gate ---------- */
#drama-play-page .play-stage{position:relative}
#drama-play-page .play-edge{position:absolute;top:50%;z-index:6;display:grid;place-items:center;width:46px;height:46px;margin-top:-23px;border-radius:50%;background:rgba(10,10,10,.45);border:1px solid rgba(255,255,255,.2);color:#fff;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);opacity:.85;transition:background .18s,opacity .18s}
#drama-play-page .play-edge--prev{left:14px}
#drama-play-page .play-edge--next{right:14px}
#drama-play-page .play-edge svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}
#drama-play-page .play-edge:hover,#drama-play-page .play-edge:focus-visible{background:var(--accent,#ec2083);border-color:transparent;opacity:1}
#drama-play-page .play-app-gate{position:absolute;inset:0;z-index:5;display:grid;place-items:center;padding:24px;text-align:center;background:radial-gradient(circle at 50% 42%,rgba(236,32,131,.2),rgba(0,0,0,.88) 68%)}
#drama-play-page .play-app-gate[hidden]{display:none}
#drama-play-page .play-app-gate-copy[hidden],#drama-play-page .play-app-gate-fallback[hidden]{display:none}
#drama-play-page .play-app-gate-copy strong{display:block;color:#fff;font-size:22px;font-weight:800;line-height:1.35}
#drama-play-page .play-app-gate-copy p{margin:8px 0 18px;color:#bdbdbd;font-size:14px}
#drama-play-page .play-app-gate-btn{display:inline-flex;align-items:center;gap:8px;height:44px;padding:0 24px;border-radius:12px;background:var(--accent,#ec2083);color:#fff;font-size:15px;font-weight:700;text-decoration:none}
#drama-play-page .play-app-gate-btn svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2}
#drama-play-page .play-app-gate-fallback{margin:0;color:#ddd;font-size:15px}
#drama-play-page .play-screen.is-app-gated .play-screen-controls{display:none}
/* headline under the player: outlined episode number */
#drama-play-page .play-headline{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:30px;padding:28px 0 26px;border-bottom:1px solid #ffffff12}
#drama-play-page .play-headline-mark{padding-top:4px;color:transparent;-webkit-text-stroke:2px var(--accent,#ec2083);font:900 112px/.82 "Arial Black",Impact,"Helvetica Neue",system-ui,sans-serif;letter-spacing:-.04em;font-variant-numeric:tabular-nums;user-select:none}
#drama-play-page .play-headline-mark.is-word{font-size:62px;letter-spacing:.02em;padding-top:10px}
#drama-play-page .play-headline h1{margin:8px 0 10px;color:#fff;font-size:28px;line-height:1.3}
#drama-play-page .play-headline .series-description-wrap{margin:14px 0 0;max-width:820px}
/* film-strip episode selector */
#drama-play-page .play-episodes-section{margin-top:28px}
#drama-play-page .film-strip{position:relative;display:flex;gap:10px;overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;scroll-padding:0 14px;padding:20px 14px;border-radius:10px;background-color:#0a0a0b;background-image:radial-gradient(circle,#2f3033 2.6px,transparent 3.2px),radial-gradient(circle,#2f3033 2.6px,transparent 3.2px);background-size:18px 10px,18px 10px;background-repeat:repeat-x;background-position:4px 3px,4px calc(100% - 3px);background-attachment:local;scrollbar-width:thin;scrollbar-color:#3a3a3d transparent}
#drama-play-page .film-strip::-webkit-scrollbar{height:6px}
#drama-play-page .film-strip::-webkit-scrollbar-thumb{background:#3a3a3d;border-radius:3px}
#drama-play-page .film-tile{position:relative;flex:0 0 56px;display:grid;place-items:center;width:56px;height:56px;box-sizing:border-box;scroll-snap-align:center;border:1px solid #ffffff12;border-radius:6px;background:#1e1f20;color:#ddd;font:700 16px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;text-decoration:none;transition:border-color .15s,color .15s}
#drama-play-page .film-tile:hover,#drama-play-page .film-tile:focus-visible{border-color:var(--accent,#ec2083);color:#fff}
#drama-play-page .film-tile.is-current{background:var(--accent,#ec2083);border-color:var(--accent,#ec2083);color:#fff}
#drama-play-page .film-tile.is-locked{opacity:.5}
#drama-play-page .film-tile.is-locked.is-current{opacity:1}
#drama-play-page .film-tile-lock{position:absolute;top:4px;right:4px;width:12px;height:12px;fill:none;stroke:currentColor;stroke-width:2.2}
/* recommendations */
#drama-play-page .play-recommend{margin-top:36px}
#drama-play-page .recommend-heading{position:relative;margin-bottom:20px}
#drama-play-page .recommend-outline{display:block;overflow:hidden;white-space:nowrap;color:transparent;-webkit-text-stroke:1.5px rgba(255,255,255,.2);font:900 66px/1 "Arial Black",Impact,"Helvetica Neue",system-ui,sans-serif;letter-spacing:.03em;user-select:none}
#drama-play-page .recommend-heading h2{position:absolute;left:6px;top:16px;margin:0;color:#fff;font-size:26px;font-weight:800;line-height:1.3}
#drama-play-page .recommend-heading p{margin:6px 0 0;color:var(--muted,#92958f);font-size:14px}
#drama-play-page .recommend-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:24px 16px}
#drama-play-page .play-official{margin-top:32px}
@media (max-width:1200px){
#drama-play-page .recommend-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
}
@media (max-width:900px){
#drama-play-page .recommend-grid{grid-template-columns:repeat(3,minmax(0,1fr))}
#drama-play-page .cine-hero-copy{width:70%;padding:36px 32px}
}
@media (max-width:760px){
#drama-play-page .cine-hero{display:block;min-height:0;border-radius:0;background:transparent;box-shadow:none;overflow:visible}
#drama-play-page .cine-hero::after{content:none}
#drama-play-page .cine-hero-art{position:relative;width:auto;aspect-ratio:16/9;margin:0 -12px;overflow:hidden}
#drama-play-page .cine-hero-image{object-position:center 18%;-webkit-mask-image:linear-gradient(180deg,#000 45%,transparent 100%);mask-image:linear-gradient(180deg,#000 45%,transparent 100%)}
#drama-play-page .cine-hero-copy{width:auto;max-width:none;margin-top:-28px;padding:0}
#drama-play-page .cine-hero h1{margin:8px 0 10px;font-size:28px}
#drama-play-page .cine-actions{gap:12px 20px}
#drama-play-page .cine-play{height:44px;padding:0 22px;font-size:15px}
#drama-play-page .play-edge{width:36px;height:36px;margin-top:-18px}
#drama-play-page .play-edge--prev{left:8px}
#drama-play-page .play-edge--next{right:8px}
#drama-play-page .play-app-gate{padding:12px 54px}
#drama-play-page .play-app-gate-copy strong{font-size:16px}
#drama-play-page .play-app-gate-copy p{margin:6px 0 12px;font-size:12px}
#drama-play-page .play-app-gate-btn{height:38px;padding:0 16px;font-size:13px}
#drama-play-page .play-headline{gap:16px;padding:20px 0}
#drama-play-page .play-headline-mark{font-size:64px;-webkit-text-stroke-width:1.5px}
#drama-play-page .play-headline-mark.is-word{font-size:34px}
#drama-play-page .play-headline h1{font-size:20px}
#drama-play-page .recommend-outline{font-size:44px}
#drama-play-page .recommend-heading h2{top:8px;font-size:20px}
.hg-card-cover{border-radius:12px}
}
@media (max-width:560px){
#drama-play-page .recommend-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 12px}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

-32
View File
@@ -1,32 +0,0 @@
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_42558_134113)">
<path d="M-0.0055542 19.984C-0.0055542 8.93831 8.94875 -0.0159912 19.9944 -0.0159912V-0.0159912C31.0401 -0.0159912 39.9944 8.93831 39.9944 19.984V19.984C39.9944 31.0297 31.0401 39.984 19.9944 39.984V39.984C8.94875 39.984 -0.0055542 31.0297 -0.0055542 19.984V19.984Z" fill="url(#paint0_linear_42558_134113)"/>
<path d="M34.9795 20.2336C34.9651 20.0651 34.9542 19.8931 34.9421 19.7241L34.9358 19.682C34.9231 19.3903 34.8054 18.875 34.7423 18.5923L34.7292 18.5587C34.4771 17.3099 33.6857 16.01 32.8351 15.082C32.3404 14.5425 31.7519 14.0335 31.1963 13.5545C28.6032 11.2953 25.6656 9.45862 22.4934 8.11303C20.9669 7.48079 19.8656 7.11202 18.2159 7.00627C17.9724 7.00179 17.7289 7 17.4849 7C16.8459 7.0242 16.1103 7.1053 15.4776 7.24913C13.6755 7.65913 11.9186 8.63281 10.6423 9.9645C10.0998 10.5309 9.58342 11.199 9.1816 11.8684C7.52878 14.621 6.8857 18.1034 7.01603 21.279C7.1662 24.9353 8.31483 28.9425 11.0969 31.4867C12.9806 33.1943 15.4668 34.0945 18.0148 33.9919C19.8372 33.9149 21.2992 33.3825 22.9624 32.6719C26.0962 31.3379 28.9825 29.4919 31.502 27.2102C32.5704 26.2366 33.5341 25.3189 34.1786 23.9961L34.3454 23.6377C34.7653 22.6107 34.8731 22.0161 34.9772 20.9107C34.9849 20.8892 34.9849 20.8829 34.9948 20.8417C34.9975 20.7014 35.0102 20.3568 34.98 20.2336H34.9795ZM27.4253 21.1329C27.1637 21.568 25.6385 22.8831 25.165 23.2062C24.9445 23.3984 24.6067 23.6466 24.3654 23.8169C22.9061 24.843 21.3331 25.6993 19.6775 26.3692C19.2631 26.539 18.6475 26.7823 18.2065 26.8697C17.8764 26.936 17.5368 26.9423 17.2044 26.8885C15.9268 26.6689 15.2612 25.7163 14.8133 24.6006C14.0616 22.7276 13.9349 20.4922 14.2853 18.5184C14.3926 17.7759 14.6889 16.9174 15.0118 16.2376C15.1935 15.9168 15.3505 15.6368 15.589 15.3513C16.2461 14.5681 17.2946 14.2688 18.2872 14.4543C18.7224 14.5354 19.1661 14.7294 19.5765 14.9006C20.5173 15.2881 21.4318 15.7353 22.3148 16.2403C22.4745 16.3304 22.6594 16.4357 22.8104 16.5401C24.1485 17.3404 25.3882 18.3024 26.5553 19.3307C26.8151 19.5597 27.1105 19.7819 27.3242 20.0548C27.4117 20.1664 27.4776 20.2869 27.5127 20.4245C27.5736 20.662 27.5533 20.9192 27.4257 21.1316L27.4253 21.1329Z" fill="white"/>
<g filter="url(#filter0_d_42558_134113)">
<path d="M20.5 28.7835C25.0621 28.7835 28.7604 25.0852 28.7604 20.5232C28.7604 15.9611 25.0621 12.2628 20.5 12.2628C15.938 12.2628 12.2397 15.9611 12.2397 20.5232C12.2397 25.0852 15.938 28.7835 20.5 28.7835Z" fill="#0F0F0F"/>
<path d="M16.6111 24.3571V17.1369L17.0821 17.7465L15.2763 18.6624L14.9065 17.212L17.3334 15.9523H18.2493V24.3542H16.6082L16.6111 24.3571ZM15.126 24.9783V23.4527H19.7777V24.9783H15.126Z" fill="white"/>
<path d="M23.5916 25.016C23.0224 25.016 22.5052 24.9004 22.0343 24.6664C21.5633 24.4323 21.1877 24.1203 20.9103 23.7245C20.6301 23.3315 20.4914 22.8924 20.4914 22.4099C20.4914 22.1209 20.5318 21.8551 20.6127 21.6095C20.6936 21.364 20.8121 21.1444 20.9681 20.9479C21.1241 20.7543 21.3148 20.5781 21.5402 20.4192C21.7656 20.2632 22.0198 20.1245 22.3087 20.006L22.2076 20.3614C21.988 20.2689 21.7858 20.1591 21.6038 20.032C21.4217 19.9049 21.2657 19.7575 21.1328 19.5929C20.9999 19.4282 20.9017 19.249 20.8352 19.0583C20.7688 18.8677 20.7341 18.6654 20.7341 18.4545C20.7341 17.998 20.8641 17.5848 21.1212 17.2208C21.3784 16.8567 21.7251 16.5649 22.1585 16.3511C22.5919 16.1344 23.0686 16.0275 23.5944 16.0275C24.1203 16.0275 24.6115 16.1344 25.0448 16.3511C25.4782 16.5678 25.8221 16.8567 26.0821 17.2208C26.3392 17.5848 26.4692 17.9951 26.4692 18.4545C26.4692 18.6654 26.4346 18.8677 26.3681 19.0583C26.3017 19.249 26.2063 19.4224 26.0821 19.5784C25.9578 19.7344 25.8047 19.8789 25.6169 20.0118C25.4291 20.1447 25.2153 20.2603 24.9697 20.3614L24.9062 20.0551C25.1778 20.1476 25.4233 20.2718 25.6429 20.4249C25.8625 20.5781 26.0532 20.7543 26.215 20.9595C26.3768 21.1617 26.501 21.3871 26.5906 21.6327C26.6802 21.8782 26.7235 22.1383 26.7235 22.407C26.7235 22.8981 26.5848 23.3402 26.3046 23.736C26.0243 24.129 25.6487 24.441 25.1806 24.6693C24.7126 24.8975 24.1839 25.0131 23.5973 25.0131L23.5916 25.016ZM23.5916 23.5945C23.8805 23.5945 24.1376 23.5396 24.3659 23.4298C24.5941 23.32 24.7733 23.1726 24.9062 22.9848C25.0362 22.7999 25.1026 22.5832 25.1026 22.3376C25.1026 22.0747 25.0362 21.8436 24.9062 21.6442C24.7733 21.4449 24.5941 21.2888 24.3659 21.1733C24.1376 21.0577 23.8776 21.0028 23.5916 21.0028C23.3055 21.0028 23.0599 21.0606 22.8346 21.1733C22.6092 21.2888 22.4301 21.4449 22.2943 21.6442C22.1585 21.8436 22.092 22.0747 22.092 22.3376C22.092 22.5746 22.1585 22.7884 22.2943 22.9733C22.4301 23.1611 22.6092 23.3113 22.8346 23.424C23.0599 23.5396 23.3113 23.5945 23.5916 23.5945ZM23.5916 19.6015C23.8458 19.6015 24.0712 19.5553 24.2705 19.4628C24.4699 19.3704 24.623 19.2433 24.7357 19.0815C24.8455 18.9197 24.9004 18.7347 24.9004 18.5209C24.9004 18.3071 24.8455 18.1078 24.7357 17.9431C24.6259 17.7784 24.4699 17.6455 24.2705 17.5502C24.0712 17.4519 23.8458 17.4028 23.5916 17.4028C23.3373 17.4028 23.1235 17.4519 22.9241 17.5502C22.7248 17.6484 22.5688 17.7784 22.459 17.9431C22.3492 18.1078 22.2943 18.3014 22.2943 18.5209C22.2943 18.7405 22.3492 18.9197 22.459 19.0815C22.5688 19.2433 22.7248 19.3704 22.9241 19.4628C23.1235 19.5553 23.346 19.6015 23.5916 19.6015Z" fill="white"/>
<path d="M20.5 11.5C15.5363 11.5 11.5 15.5363 11.5 20.5C11.5 25.4637 15.5363 29.5 20.5 29.5C25.4637 29.5 29.5 25.4637 29.5 20.5C29.5 15.5363 25.4637 11.5 20.5 11.5ZM28.0583 20.5C28.0583 22.0284 27.5989 23.4528 26.8159 24.6432L16.7266 13.9588C17.839 13.3144 19.1276 12.9417 20.5029 12.9417C24.6692 12.9417 28.0583 16.3308 28.0583 20.4971V20.5ZM20.5 28.0554C16.3337 28.0554 12.9446 24.6663 12.9446 20.5C12.9446 18.2319 13.9501 16.195 15.5392 14.8082L25.9 25.7787C24.5276 27.1828 22.6149 28.0554 20.5029 28.0554H20.5Z" fill="#DB0015"/>
</g>
</g>
<defs>
<filter id="filter0_d_42558_134113" x="10.4177" y="10.4177" width="20.1647" height="20.1647" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="0.541168"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_42558_134113"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_42558_134113" result="shape"/>
</filter>
<linearGradient id="paint0_linear_42558_134113" x1="30.5469" y1="9.57031" x2="5.54686" y2="33.75" gradientUnits="userSpaceOnUse">
<stop stop-color="#89FFEB"/>
<stop offset="0.5" stop-color="#FFA53E"/>
<stop offset="1" stop-color="#FD6119"/>
</linearGradient>
<clipPath id="clip0_42558_134113">
<rect width="40" height="40" rx="20" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 6.8 KiB

+4 -2
View File
@@ -1,12 +1,14 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import "./globals.css";
import "./hg-media.css";
import { SITE_NAME } from "@/lib/brand";
const GTM_ID = "GTM-TXLP67SX";
export const metadata: Metadata = {
title: "禁果短剧 · 精品原创 · 日更爽看",
applicationName: "禁果短剧",
title: `${SITE_NAME} · 精品原创 · 日更爽看`,
applicationName: SITE_NAME,
icons: {icon:[{url:'/favicon.png',sizes:'48x48',type:'image/png'}],apple:[{url:'/apple-touch-icon.png',sizes:'180x180',type:'image/png'}]},
verification: {google: "QJFFfKYHJsNfa-lGTdGjsCJc4rytPw-4wsTty-BM6iU"},
};
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Metadata } from 'next';
export const metadata:Metadata={title:'页面不存在 - 禁果短剧',robots:{index:false,follow:true},alternates:{canonical:null}};
export const metadata:Metadata={title:'页面不存在 - 黄果短剧',robots:{index:false,follow:true},alternates:{canonical:null}};
export default function NotFound() {
return (
+3 -3
View File
@@ -20,16 +20,16 @@ export function AppDownloadDialog({download, qr, status, dialogRef, onClose, onC
</svg>
</button>
<h2 id={"app-download-heading"}>
{"下载禁果短剧 App"}
{"下载黄果短剧 App"}
</h2>
<p className={"app-download-intro"}>
{"扫码下载,随时继续追剧"}
</p>
<div className={"app-download-code"}>
<img src={qr || undefined} id={"app-platform-qr"} width={"192"} height={"192"} alt={"禁果短剧 App 下载二维码"} />
<img src={qr || undefined} id={"app-platform-qr"} width={"192"} height={"192"} alt={"黄果短剧 App 下载二维码"} />
</div>
<p id={"app-platform-caption"}>
{download === undefined ? "" : download ? "扫码下载安装 禁果短剧 App" : "暂未配置 App 下载地址"}
{download === undefined ? "" : download ? "扫码下载安装 黄果短剧 App" : "暂未配置 App 下载地址"}
</p>
<div className={"app-download-link"}>
<input id={"app-platform-link"} value={download || ""} readOnly aria-label={"下载链接"} />
+1 -4
View File
@@ -30,7 +30,7 @@ export function ChannelPages({info,view}: {info?:InfoPageDefinition;view?:PageVi
</main>
<main id={"info-page"} className={"shell info-shell"} hidden={!info} data-info={info?.key} aria-labelledby={"info-title"}>
<nav className={"page-breadcrumb"} id={"info-breadcrumb"} aria-label={"面包屑导航"}>
{info?<><a href="/">首页</a><span>/</span>{info.key==='settings'?<><a href="/member/center/">个人中心</a><span>/</span></>:null}<span aria-current="page">{info.title}</span></>:null}
{info?<><a href="/">首页</a><span>/</span><span aria-current="page">{info.title}</span></>:null}
</nav>
<header className={"info-heading"}>
<small>
@@ -63,9 +63,6 @@ export function ChannelPages({info,view}: {info?:InfoPageDefinition;view?:PageVi
<a href={"/info/dmca/"} data-info={"dmca"}>
{"DMCA"}
</a>
<a href={"/info/feedback/"} data-info={"feedback"}>
{"意见反馈"}
</a>
</nav>
<article className={"info-card"} id={"info-content"} dangerouslySetInnerHTML={info?{__html:info.html}:undefined} />
</div>
+10 -19
View File
@@ -1,46 +1,37 @@
import type { navigationPageData } from '@/lib/navigation-page';
import { BUSINESS_CONTACT } from '@/lib/contact';
import { SITE_NAME, brandText } from '@/lib/brand';
import { footerLinkCurrent, type FooterPolicyLink } from '@/lib/footer-links';
export function Footer({title,content,contacts,links=[],address='/'}:{title?:string;content?:string;contacts?:Pick<ReturnType<typeof navigationPageData>,'telegram'|'tgGroup'|'twitter'>;links?:FooterPolicyLink[];address?:string}={}) {
const brand=title?.trim()||'';
const slogan=content?.trim()||'';
export function Footer({title,content,links=[],address='/'}:{title?:string;content?:string;links?:FooterPolicyLink[];address?:string}={}) {
const brand=brandText(title)||SITE_NAME;
const slogan=brandText(content);
const year=new Date().getFullYear();
return (
<>
<footer id="shared-site-footer" aria-label={"网站底部"}>
<div className="site-footer-inner">
<strong className="site-footer-title">
{brand}{brand?<i aria-hidden="true"/>:null}
{brand}<i aria-hidden="true"/>
</strong>
<p className="site-footer-slogan hg-footer-original-slogan">
{slogan}
</p>
{links.length?<nav className="site-footer-links" aria-label={"平台规则与帮助"}>
{links.map((link,index)=><a key={`${link.id}-${index}`} href={link.href} aria-current={footerLinkCurrent(address,link.href)?'page':undefined} {...(link.external?{target:link.href.startsWith('mailto:')?undefined:'_blank',rel:'noopener noreferrer'}:{})}>
{links.map((link,index)=><a key={`${link.id}-${index}`} href={link.href} aria-current={footerLinkCurrent(address,link.href)?'page':undefined} {...(link.external?{target:'_blank',rel:'noopener noreferrer'}:{})}>
{link.name}
</a>)}
</nav>:null}
<small className="site-footer-copyright">
{`© ${year}${brand?` ${brand} `:''}All rights reserved.`}
{`© ${year} ${SITE_NAME} All rights reserved.`}
</small>
<div className="site-footer-social" aria-label={"联系我们"}>
{contacts?.tgGroup&&<a href={contacts.tgGroup} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"官方社群"} title={"官方社群"}>
<img src={"/assets/c9e57990dff7b688.png"} alt={"官方社群图标"} />
</a>}
{contacts?.telegram&&<a href={contacts.telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"Telegram"} title={"Telegram"}>
<a href={BUSINESS_CONTACT.href} target={"_blank"} rel={"nofollow noopener noreferrer"} aria-label={BUSINESS_CONTACT.label+" Telegram "+BUSINESS_CONTACT.handle} title={BUSINESS_CONTACT.label+" "+BUSINESS_CONTACT.handle}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M21.7 3.3 18.4 20c-.2 1.2-.9 1.5-1.9.9l-5-3.7-2.4 2.3c-.3.3-.5.5-1 .5l.4-5.1 9.3-8.4c.4-.4-.1-.6-.6-.3L5.7 13.4.8 11.9c-1-.3-1-1 .2-1.5L20.2 3c.9-.3 1.7.2 1.5.3Z"}>
</path>
</svg>
</a>}
{contacts?.twitter&&<a href={contacts.twitter} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"X"} title={"X"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M18.9 2H22l-6.8 7.8L23 22h-6.3l-4.9-7.4L5.3 22H2l8.3-9.5L2 2h6.5l4.4 6.8L18.9 2ZM17.8 20h1.8L7.5 4H5.6l12.2 16Z"}>
</path>
</svg>
</a>}
</a>
</div>
</div>
</footer>
+5 -44
View File
@@ -1,14 +1,14 @@
import type { HomeData, User } from "@/lib/api/types";
import type { HomeData } from "@/lib/api/types";
import { renderHome } from "@/lib/client/render";
import type { NavigationEntry } from '@/lib/site-navigation';
import { SiteNavigation } from './SiteNavigation';
export function HomePage({home,error,hideContent=false,navigation=[],address='/',navigationError=false,user=null}: {user?:User|null;home: HomeData; error?: string;hideContent?:boolean;navigation?:NavigationEntry[];address?:string;navigationError?:boolean}) {
export function HomePage({home,error,hideContent=false,navigation=[],address='/',navigationError=false}: {home: HomeData; error?: string;hideContent?:boolean;navigation?:NavigationEntry[];address?:string;navigationError?:boolean}) {
return <>
<div id="global-site-header" className="shell">
<header className={"top"}>
<a className="hg-header-brand" href="/" aria-label="禁果短剧首页"><img className="hg-complete-logo" src="/assets/40803f26f3c0acbb.svg" width={135} height={40} alt="禁果短剧" /></a>
<a className="hg-header-brand" href="/" aria-label="黄果短剧首页"><img className="hg-complete-logo" src="/logo.png" width={164} height={40} alt="黄果短剧" /></a>
<SiteNavigation entries={navigation} address={address} surface="top" error={navigationError} />
<form className={"search"} id={"search"} role={"search"}>
<input id={"query"} aria-controls={"search-discovery"} aria-expanded={"false"} type={"search"} placeholder={"找一部,想看的好剧"} aria-label={"搜索短剧"} autoComplete={"off"} />
@@ -71,26 +71,6 @@ export function HomePage({home,error,hideContent=false,navigation=[],address='/'
</section>
</form>
<div className={"header-actions"} aria-label={"用户服务"}>
<button className={"header-action coin-entry"} data-account-action={"coins"} aria-label={'金币余额 '+(user?.recharge||0)}>
<span className={"coin-symbol"} aria-hidden={"true"}>
<img className={"coin-soft-star"} src={"/assets/897547dcbff56113.svg"} alt={"金币图标"} aria-hidden={"true"} />
</span>
<span className={"coin-caption"}>
{"金币 "}
<span className={"coin-value"}>
{user?.recharge||0}
</span>
</span>
</button>
<div className="jg-history-wrap">
<a className="header-action jg-history-entry" href="/member/history/" id="header-history-button" aria-label="观看历史" aria-controls="header-history-panel" aria-expanded="false">
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>历史</span>
</a>
<section className="jg-history-panel" id="header-history-panel" aria-labelledby="header-history-title" hidden>
<h2 id="header-history-title">观看历史</h2><div className="jg-history-list" id="header-history-list" aria-live="polite"/>
<a className="jg-history-all" href="/member/history/">查看全部 <span aria-hidden="true">›</span></a>
</section>
</div>
<div className={"header-download-wrap"} id={"header-download-wrap"} data-app-download-entry="" hidden>
<a className={"header-action download-entry"} id={"header-download-button"} data-app-download="" target="_blank" rel="sponsored nofollow noopener noreferrer" aria-label={"下载 App"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
@@ -106,34 +86,15 @@ export function HomePage({home,error,hideContent=false,navigation=[],address='/'
</span>
</a>
</div>
<button className={"header-action account-entry "} id={"account-entry"} aria-controls={"profile-popover"} aria-expanded={"false"}>
<span className={"account-avatar"} aria-hidden={"true"}>
<img className={"member-avatar-image"} src={"/assets/8837c3e58159bd48.png"} alt={"默认用户头像"} width={"50"} height={"50"} />
</span>
<span className={"account-copy"}>
<strong>
{user?.nickName||user?.accountNo||'我的'}
</strong>
<small id={"account-status"}>
{user?(user.isRegister||user.type===1?'已登录':'游客账号'):'未登录'}
</small>
</span>
</button>
</div>
<button className={"saved header-saved-proxy"} id={"saved"} hidden>
{"♡ 我的片单 "}
<span id={"count"}>
0
</span>
</button>
</header>
<div className="jg-masthead">
<a className="jg-masthead-brand" href="/" aria-label="禁果短剧首页"><img src="/assets/40803f26f3c0acbb.svg" alt="禁果短剧" width={182} height={54}/><div className="jg-masthead-copy"><span>精品原创 · 日更爽看</span></div></a>
<a className="jg-masthead-brand" href="/" aria-label="黄果短剧首页"><img src="/logo.png" alt="黄果短剧" width={222} height={54}/><div className="jg-masthead-copy"><span>精品原创 · 日更爽看</span></div></a>
<span className="jg-masthead-note">每一种心情,都有一个好故事</span>
</div>
</div>
<main className="shell" hidden={hideContent} aria-labelledby={hideContent?undefined:'home-title'}>
{!hideContent&&<h1 id="home-title" className="home-page-title">禁果短剧 · 精品原创 · 日更爽看</h1>}
{!hideContent&&<h1 id="home-title" className="home-page-title">黄果短剧 · 精品原创 · 日更爽看</h1>}
<div className="home-content" hidden={hideContent} dangerouslySetInnerHTML={{__html:hideContent?'':renderHome(home,error)}} />
</main>
</>;
+12 -47
View File
@@ -1,19 +1,18 @@
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { startTracker } from "@/lib/client/analytics";
import { useRef, useState, type MouseEvent } from "react";
import { BUSINESS_CONTACT } from "@/lib/contact";
import type { Config } from "@/lib/api/types";
import { navigationPageData } from "@/lib/navigation-page";
import { AppDownloadDialog } from "./AppDownloadDialog";
import { createDownloadQr, shouldUseDownloadDialog } from "@/lib/client/download";
export function NavigationPage({config, download, error}: {config: Config; download: string; error?: string}) {
useEffect(()=>{startTracker(config);},[config]);
const dialog = useRef<HTMLDialogElement>(null);
const [status, setStatus] = useState("");
const [qr, setQr] = useState<string>();
const {siteName,description,routes,permanent,telegram,tgGroup,twitter}=navigationPageData(config);
const channelCount=2+Number(!!tgGroup)+Number(!!twitter)+Number(!!download);
const {siteName,description,routes,permanent}=navigationPageData(config);
const channelCount=2+Number(!!download);
const openDownload = async (event: MouseEvent<HTMLAnchorElement>) => {
if (!shouldUseDownloadDialog(event)) return;
event.preventDefault();setStatus("");dialog.current?.showModal();
@@ -27,12 +26,12 @@ export function NavigationPage({config, download, error}: {config: Config; downl
return (
<>
<div id={"entry-navigation"} className={"navigation-page"}>
<img className={"entry-background"} src={"/assets/d99080bbba89c515.png"} alt={"禁果短剧官方入口背景"} aria-hidden={"true"} />
<img className={"entry-background"} src={"/assets/d99080bbba89c515.png"} alt={"黄果短剧官方入口背景"} aria-hidden={"true"} />
<div className={"entry-stage"}>
<main className={"entry-content"} aria-labelledby={"entry-page-title"}>
<header className={"entry-brand"}>
<h1 id="entry-page-title" className="entry-page-title">{siteName+"官方入口"}</h1>
<img className={"entry-brand-logo hg-complete-logo"} src={"/assets/40803f26f3c0acbb.svg"} alt={siteName} width={"500"} height={"176"} />
<img className={"entry-brand-logo hg-complete-logo"} src={"/logo.png"} alt={siteName} width={"444"} height={"108"} />
<p>
{description}
</p>
@@ -68,11 +67,11 @@ export function NavigationPage({config, download, error}: {config: Config; downl
{siteName+" · APP 下载"}
</span>
</a>}
{telegram&&<a className={"entry-contact"} href={telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
{"官方联系:" + telegram}
</a>}
<a className={"entry-contact"} href={BUSINESS_CONTACT.href} target={"_blank"} rel={"nofollow noopener noreferrer"}>
{BUSINESS_CONTACT.label+":"+BUSINESS_CONTACT.handle}
</a>
<nav className={"entry-channels"} aria-label={"官方渠道"} style={{gridTemplateColumns: `repeat(${channelCount}, minmax(0, 1fr))`}}>
{tgGroup&&<a className={"entry-channel"} href={tgGroup} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
<a className={"entry-channel"} href={BUSINESS_CONTACT.href} target={"_blank"} rel={"nofollow noopener noreferrer"}>
<span className={"entry-channel-icon"}>
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"16"} height={"16"} viewBox={"0 0 16 16"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
<g>
@@ -83,23 +82,9 @@ export function NavigationPage({config, download, error}: {config: Config; downl
</svg>
</span>
<span>
{"官方TG群"}
{BUSINESS_CONTACT.label}
</span>
</a>}
{twitter&&<a className={"entry-channel"} href={twitter} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
<span className={"entry-channel-icon"}>
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"16"} height={"16"} viewBox={"0 0 16 16"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
<g>
<path d={"M11.9025 1.90609H13.97L9.45311 7.06859L14.7669 14.0936H10.6062L7.34748 9.83296L3.61872 14.0936H1.54998L6.38123 8.57171L1.28373 1.90609H5.54999L8.49561 5.80046L11.9025 1.90609ZM11.1769 12.8561H12.3225L4.92747 3.07859H3.69809L11.1769 12.8561Z"} fill={"currentColor"}>
</path>
</g>
</svg>
</span>
<span>
{"官方推特"}
</span>
</a>}
</a>
<a className={"entry-channel"} href={"/home-road/"}>
<span className={"entry-channel-icon"}>
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"16"} height={"16"} viewBox={"0 0 16 16"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
@@ -128,26 +113,6 @@ export function NavigationPage({config, download, error}: {config: Config; downl
{"APP"}
</span>
</a>}
<a className={"entry-channel"} href={"/apps/"}>
<span className={"entry-channel-icon"}>
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"16"} height={"16"} viewBox={"0 0 16 16"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
<g>
<path d={"M4.43637 10.3782C4.35322 10.4255 4.25929 10.4505 4.16364 10.4509C4.04345 10.451 3.92659 10.4115 3.8312 10.3384C3.73581 10.2653 3.66725 10.1627 3.63615 10.0466C3.60505 9.93049 3.61317 9.80738 3.65924 9.69638C3.70531 9.58537 3.78675 9.49269 3.89091 9.43273L5.01818 8.78182L4.32364 8.38182C3.91526 8.16333 3.60971 7.79237 3.4735 7.3497C3.33729 6.90702 3.38145 6.42846 3.59636 6.01818C3.71269 5.81966 3.87027 5.64842 4.05848 5.51605C4.24668 5.38368 4.46111 5.29326 4.68727 5.25091C5.00863 5.18723 5.34112 5.20982 5.65091 5.31637V1.81818H2.74182C2.47813 1.81578 2.21657 1.86565 1.97226 1.9649C1.72795 2.06415 1.50572 2.21082 1.31841 2.39644C1.1311 2.58206 0.98242 2.80295 0.880959 3.04635C0.779497 3.28975 0.727263 3.55084 0.727274 3.81455V12.1782C0.726317 12.441 0.777248 12.7013 0.877146 12.9444C0.977044 13.1874 1.12394 13.4084 1.30942 13.5945C1.49489 13.7807 1.71529 13.9284 1.95798 14.0292C2.20066 14.1299 2.46086 14.1818 2.72364 14.1818H5.63273V9.68364L4.43637 10.3782Z"} fill={"currentColor"}>
</path>
<path d={"M4.89091 6.32C4.81646 6.33243 4.74552 6.36059 4.68281 6.4026C4.62011 6.44461 4.56708 6.49951 4.52727 6.56364C4.45835 6.72444 4.45516 6.90584 4.51837 7.06897C4.58159 7.23211 4.70618 7.36399 4.86545 7.43637L5.63273 7.88V6.48364C5.56864 6.4752 5.50685 6.4542 5.45091 6.42182C5.28354 6.31906 5.08374 6.28274 4.89091 6.32ZM13.28 1.81818H6.73455V5.27637C7.00405 5.20499 7.28614 5.19505 7.56 5.24727C7.78608 5.28985 8.00042 5.38036 8.18859 5.51271C8.37676 5.64506 8.5344 5.81618 8.65091 6.01455C8.76587 6.21316 8.83645 6.43432 8.85779 6.66282C8.87914 6.89131 8.85074 7.12171 8.77455 7.33818C8.61873 7.77517 8.30827 8.13996 7.90182 8.36364L7.2 8.78182L8.33091 9.45455C8.43507 9.51451 8.51651 9.60719 8.56258 9.71819C8.60865 9.8292 8.61677 9.95231 8.58567 10.0684C8.55457 10.1845 8.48601 10.2871 8.39062 10.3602C8.29523 10.4333 8.17837 10.4729 8.05818 10.4727C7.96266 10.4714 7.86896 10.4464 7.78545 10.4L6.72727 9.77091V14.1818H13.2727C13.5352 14.1823 13.7952 14.131 14.0378 14.0309C14.2804 13.9308 14.501 13.7838 14.6867 13.5984C14.8725 13.413 15.0199 13.1927 15.1204 12.9503C15.221 12.7078 15.2727 12.4479 15.2727 12.1855V3.82182C15.2737 3.55935 15.2229 3.29926 15.1232 3.05645C15.0235 2.81364 14.877 2.59286 14.6919 2.40676C14.5068 2.22066 14.2868 2.07288 14.0445 1.97189C13.8023 1.87089 13.5425 1.81866 13.28 1.81818Z"} fill={"currentColor"}>
</path>
<path d={"M7.72364 6.99636C7.7495 6.92548 7.75996 6.8499 7.75432 6.77465C7.74868 6.69941 7.72706 6.62623 7.69091 6.56C7.65165 6.4954 7.59878 6.44013 7.53597 6.39805C7.47317 6.35598 7.40195 6.3281 7.32727 6.31636C7.13445 6.27837 6.93438 6.31474 6.76727 6.41818L6.72364 6.43636V7.79273L7.35273 7.42909C7.52486 7.33474 7.65672 7.1809 7.72364 6.99636Z"} fill={"currentColor"}>
</path>
</g>
</svg>
</span>
<span>
{"福利应用"}
</span>
</a>
</nav>
<footer className={"entry-footer"}>
{error && <p role="status">{error}</p>}
-10
View File
@@ -7,16 +7,6 @@ export function PageTools() {
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4m-3 14h2"/></svg>
</a>
</div>
<button className={"page-tool"} id={"floating-history"} type={"button"} aria-label={"历史记录"} title={"历史记录"} aria-haspopup={"dialog"} aria-controls={"history-dialog"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<circle cx={"12"} cy={"12"} r={"9"} fill={"none"} stroke={"currentColor"} strokeWidth={"1.8"}>
</circle>
<path d={"M12 7v5l3 2"} fill={"none"} stroke={"currentColor"} strokeWidth={"1.8"} strokeLinecap={"round"} strokeLinejoin={"round"}>
</path>
</svg>
</button>
<button className={"page-tool"} id={"back-to-top"} type={"button"} aria-label={"回到顶部"} title={"回到顶部"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M5 4h14M12 20V8m-6 6 6-6 6 6"}>
+8 -21
View File
@@ -1,15 +1,15 @@
import type { NavigationEntry } from '@/lib/site-navigation';
import type { navigationPageData } from '@/lib/navigation-page';
import { BUSINESS_CONTACT } from '@/lib/contact';
import { SiteNavigation } from './SiteNavigation';
export function Sidebar({navigation,address,error,contacts}:{navigation:NavigationEntry[];address:string;error?:boolean;contacts?:Pick<ReturnType<typeof navigationPageData>,'telegram'|'tgGroup'|'twitter'>}) {
export function Sidebar({navigation,address,error}:{navigation:NavigationEntry[];address:string;error?:boolean}) {
return (
<>
<aside className={"side"} id={"site-sidebar"} aria-label={"网站侧边栏"}>
<div className={"sidebar-header"}>
<a className={"mark sidebar-logo-toggle"} href={"/"} aria-label={"禁果短剧首页"}>
<a className={"mark sidebar-logo-toggle"} href={"/"} aria-label={"黄果短剧首页"}>
<span className={"sidebar-brand-window"}>
<img className={"sidebar-brand"} src={"/assets/40803f26f3c0acbb.svg"} alt={"禁果短剧"} width={"500"} height={"176"} />
<img className={"sidebar-brand"} src={"/logo.png"} alt={"黄果短剧"} width={"444"} height={"108"} />
</span>
</a>
<button className={"sidebar-toggle"} id={"sidebar-toggle"} type={"button"} aria-controls={"site-sidebar"} aria-expanded={"true"} aria-label={"收起侧边栏"} title={"收起侧边栏"}>
@@ -26,7 +26,7 @@ export function Sidebar({navigation,address,error,contacts}:{navigation:Navigati
<SiteNavigation entries={navigation} address={address} error={error} />
<div className={"side-info"}>
<strong>
<img className={"side-info-wordmark"} src={"/assets/87eeb5bf339a03f0.svg"} alt={"禁果短剧"} width={308} height={73} />
<img className={"side-info-wordmark"} src={"/logo.png"} alt={"黄果短剧"} width={300} height={73} />
<i className={"side-info-brand-dot"} aria-hidden={"true"}>
</i>
@@ -53,12 +53,9 @@ export function Sidebar({navigation,address,error,contacts}:{navigation:Navigati
<a href={"/info/dmca/"} data-info={"dmca"}>
{"DMCA"}
</a>
<a href={"/info/feedback/"} data-info={"feedback"}>
{"意见反馈"}
</a>
</div>
<small>
{"© 2026 禁果短剧"}
{"© 2026 黄果短剧"}
<br />
{"All rights reserved."}
</small>
@@ -74,23 +71,13 @@ export function Sidebar({navigation,address,error,contacts}:{navigation:Navigati
</svg>
</a>
<div className={"sidebar-social"} aria-label={"联系我们"}>
{contacts?.tgGroup&&<a href={contacts.tgGroup} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"官方社群"} title={"官方社群"}>
<img src={"/assets/c9e57990dff7b688.png"} alt={"官方社群图标"} />
</a>}
{contacts?.telegram&&<a href={contacts.telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"Telegram"} title={"Telegram"}>
<a href={BUSINESS_CONTACT.href} target={"_blank"} rel={"nofollow noopener noreferrer"} aria-label={BUSINESS_CONTACT.label+" Telegram "+BUSINESS_CONTACT.handle} title={BUSINESS_CONTACT.label+" "+BUSINESS_CONTACT.handle}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M21.7 3.3 18.4 20c-.2 1.2-.9 1.5-1.9.9l-5-3.7-2.4 2.3c-.3.3-.5.5-1 .5l.4-5.1 9.3-8.4c.4-.4-.1-.6-.6-.3L5.7 13.4.8 11.9c-1-.3-1-1 .2-1.5L20.2 3c.9-.3 1.7.2 1.5.3Z"}>
</path>
</svg>
</a>}
{contacts?.twitter&&<a href={contacts.twitter} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"X"} title={"X"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M18.9 2H22l-6.8 7.8L23 22h-6.3l-4.9-7.4L5.3 22H2l8.3-9.5L2 2h6.5l4.4 6.8L18.9 2ZM17.8 20h1.8L7.5 4H5.6l12.2 16Z"}>
</path>
</svg>
</a>}
</a>
</div>
</aside>
<SiteNavigation entries={navigation} address={address} surface="mobile" />
+1 -98
View File
@@ -9,104 +9,7 @@ export function SiteDialogs() {
<div className={"toast hidden"} id={"toast"} role={"status"}>
</div>
<dialog id={"account-dialog"} aria-labelledby={"account-dialog-title"}>
</dialog>
<dialog id={"history-dialog"} className={"history-dialog"} aria-labelledby={"history-dialog-title"}>
<button className={"close"} id={"history-close"} type={"button"} aria-label={"关闭历史记录"}>
<svg className={"dialog-close-icon"} viewBox={"0 0 24 24"} aria-hidden={"true"} focusable={"false"}>
<path d={"m6 6 12 12M18 6 6 18"}>
</path>
</svg>
</button>
<h2 id={"history-dialog-title"}>
{"历史记录"}
</h2>
<p className={"history-note"}>
{"最近浏览的剧集"}
</p>
<div className={"account-history-list"} id={"history-list"}>
</div>
<button type={"button"} className={"history-clear"} id={"history-clear"}>
{"清空记录"}
</button>
</dialog>
<AppDownloadDialog />
<dialog id={"member-delete-dialog"} aria-labelledby={"member-delete-title"} aria-describedby={"member-delete-description"}>
<form method={"dialog"}>
<h2 id={"member-delete-title"}>
{"确认删除"}
</h2>
<p id={"member-delete-description"}>
</p>
<div className={"member-delete-actions"}>
<button value={"cancel"} autoFocus>
{"取消"}
</button>
<button value={"confirm"}>
{"确认删除"}
</button>
</div>
</form>
</dialog>
<dialog id={"member-auth-dialog"} aria-labelledby={"member-auth-title"}>
</dialog>
<dialog id={"wallet-payment-dialog"} aria-labelledby={"wallet-payment-title"}>
</dialog>
<dialog id={"play-episode-lock"} aria-labelledby={"episode-lock-title"} aria-describedby={"episode-lock-description episode-lock-note"}>
<button type={"button"} className={"unlock-close"} aria-label={"关闭金币解锁弹窗"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"m6 6 12 12M18 6 6 18"}>
</path>
</svg>
</button>
<div className={"unlock-coin"} aria-hidden={"true"}>
<span className={"coin-symbol"} aria-hidden={"true"}>
<img className={"coin-soft-star"} src={"/assets/897547dcbff56113.svg"} alt={"金币图标"} aria-hidden={"true"} />
</span>
</div>
<h2 id={"episode-lock-title"}>
{"使用金币解锁本集"}
</h2>
<p id={"episode-lock-description"} className={"episode-lock-copy"}>
{"解锁后可永久观看本集内容。"}
</p>
<div className={"unlock-cost-summary"}>
<div>
<span>
{"当前余额"}
</span>
<strong id={"episode-lock-balance"}>
{"— 金币"}
</strong>
</div>
<div>
<span>
{"本次消耗"}
</span>
<strong className={"unlock-cost"}>
{"— 金币"}
</strong>
</div>
</div>
<p id={"episode-lock-note"} className={"unlock-confirm-note"}>
{"后续付费剧集仍需逐集确认"}
</p>
<p id={"episode-lock-error"} className={"unlock-error"} role={"alert"} hidden></p>
<button type={"button"} className={"unlock-pay"}>
{"支付 — 金币并播放"}
</button>
<a className={"unlock-recharge"} href={"/info/deposit/"} data-close-dialog="">
{"金币不足?去充值"}
</a>
</dialog>
<dialog id={"startup-age"} className={"startup-popup"} aria-labelledby={"startup-age-title"} aria-describedby={"startup-age-description"}>
<div className={"startup-age-layout"}>
<div className={"startup-age-mark"}>
@@ -150,7 +53,7 @@ export function SiteDialogs() {
</button>
</dialog>
<dialog id={"startup-ad"} className={"startup-popup"} aria-label={"广告推荐"}>
<a className={"startup-ad-media"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} data-track-ad="external-dialog" data-ad-type="interstitial" hidden>
<a className={"startup-ad-media"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} hidden>
<img className={"startup-ad-image"} width={"400"} height={"602"} alt={"广告"} />
</a>
<p className={"startup-ad-loading"} role={"status"} hidden>{"正在加载下一条广告…"}</p>
+3 -5
View File
@@ -2,7 +2,6 @@ import type { HomeData } from '@/lib/api/types';
import type { InfoPageDefinition } from '@/lib/info-pages';
import { getHeaderNavigation } from '@/lib/api/server';
import { navigationEntries } from '@/lib/site-navigation';
import { navigationPageData } from '@/lib/navigation-page';
import { Sidebar } from './Sidebar';
import { HomePage } from './HomePage';
import { ChannelPages } from './ChannelPages';
@@ -20,16 +19,15 @@ export async function SiteFrame({home,error,info,seo,view,homeLoaded=true,sessio
const header=await getHeaderNavigation();
const navigation=navigationEntries(header.data,seo?.environment.origin);
const address=seo?.path||'/';
const contacts=navigationPageData(shared?.config||{});
return <>
<StructuredData seo={seo} />
<Sidebar navigation={navigation} address={address} error={header.error} contacts={contacts} />
<HomePage user={session?.user||view?.bootstrap?.session?.user} navigation={navigation} address={address} navigationError={header.error} home={home} error={error} hideContent={!!info||!!view} />
<Sidebar navigation={navigation} address={address} error={header.error} />
<HomePage navigation={navigation} address={address} navigationError={header.error} home={home} error={error} hideContent={!!info||!!view} />
<ChannelPages info={info} view={view} />
<PlayerPage view={view} />
<PageTools />
<SiteDialogs />
<Footer title={shared?.footer.footerInfo?.brand?.title} content={shared?.footer.footerInfo?.brand?.content} contacts={contacts} links={footerPolicyLinks(shared?.footer)} address={address} />
<Footer title={shared?.footer.footerInfo?.brand?.title} content={shared?.footer.footerInfo?.brand?.content} links={footerPolicyLinks(shared?.footer)} address={address} />
<SiteRuntime home={home} homeLoaded={homeLoaded&&!info} seo={seo} page={view?.bootstrap} navigation={navigation} session={session} shared={shared} />
</>;
}
+2 -3
View File
@@ -6,8 +6,7 @@ const icons:Record<string,string>={
"shorts": "M765 558l-22.93-14.02L725 366h1.44l-1.17-12.3c-24.41-22.2-39.3-35.74-63.71-57.95H265.44c-24.41 22.2-39.3 35.74-63.71 57.95l-37.72 397c25.39 27.92 38.32 42.14 63.71 70.05h471.55c25.39-27.92 38.32-42.14 63.71-70.05l-9.03-95.04 132.04 55.6v-306l-116.13 48.9-4.87 103.85z",
"topics": "M473.6 307.44L373.88 234H190.45c-13.24 14.55-19.99 21.97-33.24 36.52l44.5 470.8 52.39 47.67h536.5l52.31-46.92 28.26-259.47-523.33-12.55 20.98-25.18 508.58-19.49 8.82-80.95c-13.27-14.79-19.92-22.21-33.19-37H473.58z",
"rank": "M710.83 236.25c-109.55-109.55-287.18-109.55-396.73 0-109.55 109.55-109.55 287.18 0 396.73 41.51 41.51 92.8 67.28 146.46 77.33l-14.22 28.06c-42.51 2.32-87.82-4.48-131.06-38.02l-18.72 181.57 215.04-83.69 215.04 83.69-24.78-240.38c3.03-2.78 6.03-5.62 8.97-8.56 109.55-109.55 109.55-287.18 0-396.73z",
"home-road": "M534.27 168v0.36h-46.45l-13.39 461.39h-0.19l-0.99 40.53-1.78 61.34h0.28l-0.17 6.93-24.29-15.19-6.63-91.65c-146.16 11.32-255.42 56.61-255.42 110.77 0 62.68 146.35 113.5 326.88 113.5S839 805.16 839 742.48c0-58.26-126.42-106.25-289.19-112.74l-4.84-166.76h346.86l-80.71-147.5 80.71-147.5H534.27z",
"apps": "M178.74 474.5h46.77v0.28l33.03-0.28h305.41l-25.85 16.77-312.59 22.53V812h570.47V474.5h46.76v-102h-664v102zM662 243l-119 61-204.78-75.63L287 340l195.97 23 261.77-25L662 243z"
"home-road": "M534.27 168v0.36h-46.45l-13.39 461.39h-0.19l-0.99 40.53-1.78 61.34h0.28l-0.17 6.93-24.29-15.19-6.63-91.65c-146.16 11.32-255.42 56.61-255.42 110.77 0 62.68 146.35 113.5 326.88 113.5S839 805.16 839 742.48c0-58.26-126.42-106.25-289.19-112.74l-4.84-166.76h346.86l-80.71-147.5 80.71-147.5H534.27z"
};
function NavigationIcon({item}:{item:NavigationEntry}) {
@@ -17,7 +16,7 @@ function NavigationIcon({item}:{item:NavigationEntry}) {
function NavigationLink({item,address,surface}:{item:NavigationEntry;address:string;surface:NavigationSurface}) {
const active=navigationIsActive(item.href,address,surface);
return <a href={item.href} data-site-nav={surface} data-nav-id={item.id} data-page={surface==='top'?undefined:item.key} data-hg-page={surface==='top'?item.key:undefined} className={active?'active':undefined} aria-current={active?'page':undefined} aria-label={item.name} title={item.name} target={item.external?'_blank':undefined} rel={item.external?'sponsored nofollow noopener noreferrer':undefined}>
return <a href={item.href} data-site-nav={surface} data-page={surface==='top'?undefined:item.key} data-hg-page={surface==='top'?item.key:undefined} className={active?'active':undefined} aria-current={active?'page':undefined} aria-label={item.name} title={item.name} target={item.external?'_blank':undefined} rel={item.external?'sponsored nofollow noopener noreferrer':undefined}>
{surface==='sidebar'||surface==='top'?<NavigationIcon item={item}/>:null}<span className={surface==='sidebar'?'side-nav-label':undefined}>{item.name}</span>
</a>;
}
+1 -2
View File
@@ -3,7 +3,6 @@
import Script from "next/script";
import type { HomeData } from "@/lib/api/types";
import { startApplication } from "@/lib/client/application";
import { startTracker } from "@/lib/client/analytics";
import type { SeoBootstrap } from '@/lib/seo';
import type { PageBootstrap, SiteSharedData } from '@/lib/page-data';
import type { NavigationEntry } from '@/lib/site-navigation';
@@ -11,6 +10,6 @@ import type { NavigationEntry } from '@/lib/site-navigation';
/** Initialize the reference interactions once, after React finishes hydration. */
export function SiteRuntime({home,homeLoaded=true,seo,page,session,shared}: {home: HomeData;homeLoaded?:boolean;seo?:SeoBootstrap;page?:PageBootstrap;navigation?:NavigationEntry[];session?:PageBootstrap['session'];shared?:SiteSharedData}) {
return <Script src="/scripts/site-runtime.js" strategy="afterInteractive" onReady={()=>{
startTracker();startApplication(home,homeLoaded,seo,page,session,shared);
startApplication(home,homeLoaded,seo,page,session,shared);
}} />;
}
-26
View File
@@ -1,26 +0,0 @@
import type { Drama, Package, PageData } from './api/types';
import { recordList } from './api/protocol';
import { publicDrama } from './player-data';
import type { WalletInitialData } from './page-data';
export function accountList(data:PageData<Drama>):PageData<Drama> {
return {list:recordList<Drama>(data.list).map(publicDrama),totalCount:typeof data.totalCount==='number'?data.totalCount:0};
}
export function accountPackages(data:WalletInitialData['packages']):WalletInitialData['packages'] {
const items=(value:unknown)=>recordList<Package>(value).map(item=>{
const result:Record<string,unknown>={};
for(const key of ['id','packageType','price','originalPrice','baseCoin','giftCoin','totalCoin','durationDays','durationHours'] as const)if(typeof item[key]==='number'&&Number.isFinite(item[key]))result[key]=item[key];
for(const key of ['name','subTitle'] as const)if(typeof item[key]==='string')result[key]=item[key];
result.payMethods=recordList<{type:string;typeName:string}>(item.payMethods).map(method=>({type:String(method.type||''),typeName:String(method.typeName||'')}));
return result as unknown as Package;
});
return {coinList:items(data.coinList),vipList:items(data.vipList)};
}
export function accountTransactions(data:WalletInitialData['transactions']):WalletInitialData['transactions'] {
return {totalCount:typeof data.totalCount==='number'?data.totalCount:0,...(data.error?{error:'余额明细暂不可用,请稍后刷新'}:{}),list:recordList<Record<string,unknown>>(data.list).map(item=>{
const result:Record<string,unknown>={};
for(const key of ['changeAmount','amount','coin','changeCoin','flowDir'])if(typeof item[key]==='number'&&Number.isFinite(item[key]))result[key]=item[key];
for(const key of ['title','tranTypeName','name','remark','typeName','createdAt','time'])if(typeof item[key]==='string')result[key]=item[key];
return result;
})};
}
-381
View File
@@ -1,381 +0,0 @@
/**
* 广告等事件上报前补齐公共字段。
* web-sdk 只带 device / user_agent,device_brand、device_model 固定为空,
* 且没有 system_name、system_version。口径对齐 jha-1040 tracker.js。
*/
export type ReportDevice = {
device_brand: string;
device_model: string;
system_name: string;
system_version: string;
};
type ClientHints = {
platform?: string;
mobile?: boolean | null;
brands?: string;
};
type ClientEnv = {
touchPoints?: number;
hints?: ClientHints;
platform?: string;
language?: string;
appVersion?: string;
};
const SDK_VERSION_FALLBACK = "1.2.0";
const FP_VERSION_FALLBACK = "1.0.0";
const APP_VERSION_FALLBACK = "1.0.0";
declare global {
interface Window {
__trackerReportPatch?: boolean;
}
}
function liveEnv(): ClientEnv {
if (typeof navigator === "undefined") return {};
const nav = navigator as Navigator & {
userAgentData?: { platform?: string; mobile?: boolean; brands?: Array<{ brand?: string; version?: string }> };
deviceMemory?: number;
};
const hints = nav.userAgentData;
return {
touchPoints: nav.maxTouchPoints || 0,
platform: nav.platform || "",
language: nav.language || "",
appVersion: nav.appVersion || "",
hints: hints
? {
platform: hints.platform,
mobile: typeof hints.mobile === "boolean" ? hints.mobile : null,
brands: Array.isArray(hints.brands)
? hints.brands.map((item) => `${item.brand || ""}/${item.version || ""}`).join(" ")
: "",
}
: undefined,
};
}
function guessAndroidBrand(model: string, ua: string) {
const value = `${model} ${ua}`;
const brands: Array<[RegExp, string]> = [
[/Build\/HONOR|\bHONOR\b/i, "Honor"],
[/Build\/HUAWEI|HUAWEI|HW-|HMA-|ELS-|ANA-|\bJAD-|\bNOH-/i, "Huawei"],
[/\b[A-Z]{2,4}-(AL|AN|TL|EM|DL)\d{2}\b/, "Huawei"],
[/SM-|samsung|Galaxy/i, "Samsung"],
[/Pixel/i, "Google"],
[/Redmi|POCO|XiaoMi|\bMi\b|Mi\s?\d/i, "Xiaomi"],
[/\b2[0-9]{3,4}[A-Z][A-Z0-9]+\b/, "Xiaomi"],
[/OPPO|\bCPH\d{3,4}|\bPB\w{2}|\bP[A-Z]{2}\d{2}\b/, "OPPO"],
[/vivo|\bV\d{4}[A-Z]?\b/i, "vivo"],
[/OnePlus|ONEPLUS|\bGM19|\bHD19|\bKB20|\bLE21/i, "OnePlus"],
[/realme|\bRMX\d{3,4}/i, "realme"],
[/Meizu/i, "Meizu"],
[/Lenovo/i, "Lenovo"],
[/\bZTE\b/i, "ZTE"],
[/Nokia/i, "Nokia"],
[/Sony|\bSO-|\bXQ-/i, "Sony"],
[/Motorola|\bmoto\b/i, "Motorola"],
[/asus/i, "ASUS"],
];
return brands.find(([pattern]) => pattern.test(value))?.[1] || "";
}
function parseBotInfo(ua: string): ReportDevice | null {
const known: Array<[RegExp, string, string]> = [
[/bingbot/i, "Bing", "bingbot"],
[/googlebot/i, "Google", "Googlebot"],
[/baiduspider/i, "Baidu", "Baiduspider"],
[/yandex(bot|\.com\/bots)/i, "Yandex", "YandexBot"],
[/duckduckbot/i, "DuckDuckGo", "DuckDuckBot"],
[/bytespider/i, "ByteDance", "Bytespider"],
[/petalbot/i, "Huawei", "PetalBot"],
[/applebot/i, "Apple", "Applebot"],
[/sogou.{0,6}spider/i, "Sogou", "Sogou Spider"],
[/360spider/i, "360", "360Spider"],
[/semrushbot/i, "Semrush", "SemrushBot"],
[/ahrefsbot/i, "Ahrefs", "AhrefsBot"],
[/facebookexternalhit/i, "Facebook", "facebookexternalhit"],
];
const version = ua.match(/(?:bot|spider)[/ ]v?([\d.]+)/i)?.[1] || "";
for (const [pattern, brand, model] of known) {
if (pattern.test(ua)) {
return { device_brand: brand, device_model: model, system_name: "Bot", system_version: version };
}
}
const generic = ua.match(/([A-Za-z][A-Za-z]*bot)\/([\d.]+)/) || ua.match(/([A-Za-z][A-Za-z]*spider)\/?([\d.]*)/i);
if (!generic) return null;
return { device_brand: "", device_model: generic[1], system_name: "Bot", system_version: generic[2] || "" };
}
function clientHints(env: ClientEnv): ClientHints {
return env.hints || { platform: "", mobile: null, brands: "" };
}
export function buildUAFallback(env: ClientEnv = liveEnv()) {
if (typeof navigator !== "undefined" && navigator.userAgent) return navigator.userAgent;
const hints = clientHints(env);
const parts = [hints.platform, hints.brands, env.platform, env.appVersion, env.language].filter(Boolean);
return parts.join("; ").replace(/\s+/g, " ").trim() || "unknown";
}
function extractAndroidModel(ua: string) {
const code = ua.match(/;\s*([A-Z][A-Z0-9]{1,5}-[A-Z0-9]{2,})/);
if (code) return code[1].trim();
const matched = ua.match(/;\s*([^;()]+?)\s+Build\//) || ua.match(/;\s*([^;()]+?)\)\s*(?:AppleWebKit|Gecko|$)/);
let model = matched?.[1]?.trim() || "";
model = model
.replace(/\bwv\b/i, "")
.replace(/^U\s+/i, "")
.replace(/\bzh[-_][A-Za-z-]+\b/gi, "")
.replace(/\s+/g, " ")
.trim();
if (
/^(rv:|Mobile$|Tablet$)/i.test(model) ||
/OpenHarmony|HarmonyOS|HMSCore|^Android\b|^Linux\b/i.test(model) ||
!/[A-Za-z0-9]/.test(model)
) {
return "";
}
return model;
}
export function guessDeviceType(ua: string, touchPoints = 0) {
if (/Android/i.test(ua)) return "Android";
if (/iPhone|iPad|iPod/i.test(ua)) return "iOS";
if (/Macintosh/.test(ua) && touchPoints > 1) return "iOS";
return "PC";
}
export function parseUAClientInfo(ua?: string, env: ClientEnv = liveEnv()): ReportDevice {
const source = ua ?? (typeof navigator === "undefined" ? "" : navigator.userAgent || "");
const out: ReportDevice = { device_brand: "", device_model: "", system_name: "", system_version: "" };
try {
const bot = parseBotInfo(source);
if (bot) {
out.device_brand = bot.device_brand;
out.device_model = bot.device_model;
out.system_name = bot.system_name;
out.system_version = bot.system_version;
} else if (/iPhone|iPad|iPod/.test(source)) {
out.device_brand = "Apple";
out.device_model = /iPad/.test(source) ? "iPad" : /iPod/.test(source) ? "iPod" : "iPhone";
out.system_name = "iOS";
const version = source.match(/OS (\d+[_.]\d+(?:[_.]\d+)?)/);
if (version) out.system_version = version[1].replace(/_/g, ".");
} else if (/OpenHarmony|HarmonyOS|ArkWeb/i.test(source)) {
out.system_name = "HarmonyOS";
const version = source.match(/(?:OpenHarmony|HarmonyOS)\s+([\d.]+)/i) || source.match(/Android\s+([\d.]+)/);
if (version) out.system_version = version[1];
out.device_model = extractAndroidModel(source) || (/Tablet/i.test(source) ? "Tablet" : "Phone");
out.device_brand = guessAndroidBrand(out.device_model, source);
} else if (/Android/.test(source)) {
out.system_name = "Android";
const version = source.match(/Android\s+([\d.]+)/);
if (version) out.system_version = version[1];
const model = extractAndroidModel(source) || (/Tablet/i.test(source) ? "Tablet" : "Mobile");
out.device_model = model;
out.device_brand = guessAndroidBrand(model, source);
} else if (/Windows NT/.test(source)) {
out.system_name = "Windows";
const version = source.match(/Windows NT ([\d.]+)/);
const names: Record<string, string> = { "10.0": "10", "6.3": "8.1", "6.2": "8", "6.1": "7" };
if (version) out.system_version = names[version[1]] || version[1];
out.device_model = "PC";
} else if (/Windows/i.test(source)) {
out.system_name = "Windows";
out.device_model = "PC";
} else if (/CrOS/.test(source)) {
out.system_name = "Chrome OS";
out.device_model = "PC";
const version = source.match(/CrOS\s+\S+\s+([\d.]+)/);
if (version) out.system_version = version[1];
} else if (/Macintosh|Mac OS X/.test(source)) {
const touchIPad = /Macintosh/.test(source) && (env.touchPoints || 0) > 1;
if (touchIPad) {
out.system_name = "iOS";
out.device_brand = "Apple";
out.device_model = "iPad";
const version = source.match(/Version\/(\d+[_.]\d+(?:[_.]\d+)?)/);
if (version) out.system_version = version[1].replace(/_/g, ".");
} else {
out.system_name = "macOS";
out.device_brand = "Apple";
out.device_model = "Mac";
const version = source.match(/Mac OS X (\d+[_.]\d+(?:[_.]\d+)?)/);
if (version) out.system_version = version[1].replace(/_/g, ".");
}
} else if (/Linux/.test(source)) {
out.system_name = "Linux";
out.device_model = "PC";
}
if (!out.system_name || !out.device_model) {
const hints = clientHints(env);
if (!out.system_name && hints.platform) out.system_name = hints.platform;
if (!out.device_model) {
if (hints.mobile === false) out.device_model = "PC";
else if (hints.mobile === true) out.device_model = "Mobile";
}
if (!out.device_brand && hints.platform === "macOS") out.device_brand = "Apple";
}
if (!out.system_name) {
const device = guessDeviceType(source, env.touchPoints || 0);
out.system_name = device === "Android" ? "Android" : device === "iOS" ? "iOS" : device === "PC" ? "PC" : "Unknown";
}
if (!out.system_version) out.system_version = "0.0.0";
if (!out.device_model) out.device_model = "Unknown";
} catch {
/* 解析失败时走下面的空值兜底 */
}
if (!out.system_name) out.system_name = "Unknown";
if (!out.system_version) out.system_version = "0.0.0";
if (!out.device_model) out.device_model = "Unknown";
return out;
}
function fingerprintFallback(ua: string, env: ClientEnv) {
try {
const screenInfo = typeof screen === "undefined" ? undefined : screen;
const parts = [
ua,
env.platform || "",
env.language || "",
`${screenInfo?.width || ""}x${screenInfo?.height || ""}`,
screenInfo?.colorDepth || "",
new Date().getTimezoneOffset(),
typeof navigator === "undefined" ? "" : navigator.hardwareConcurrency || "",
].join("||");
let hash = 2166136261 >>> 0;
for (let index = 0; index < parts.length; index += 1) {
hash ^= parts.charCodeAt(index);
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
}
return `fp_fb_${hash.toString(16).padStart(8, "0")}`;
} catch {
return "fp_fb_unknown";
}
}
function text(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
/** 给批量上报 JSON 补上设备与系统字段。没有改动时返回 null。 */
export function augmentReportBody(body: string, options: { ua?: string; env?: ClientEnv; appVersion?: string } = {}) {
try {
if (!body) return null;
const records = JSON.parse(body) as unknown;
if (!Array.isArray(records) || !records.length) return null;
const env = options.env || liveEnv();
const ua = options.ua ?? (typeof navigator === "undefined" ? "" : navigator.userAgent || "");
const info = parseUAClientInfo(ua, env);
const uaFallback = ua || buildUAFallback(env);
const device = guessDeviceType(ua, env.touchPoints || 0);
const fingerprint = fingerprintFallback(ua, env);
const appVersion = options.appVersion?.trim() || APP_VERSION_FALLBACK;
let changed = false;
for (const record of records) {
if (!record || typeof record !== "object") continue;
const item = record as Record<string, unknown>;
const fill = (key: string, value: string) => {
if (!text(item[key]) && value) {
item[key] = value;
changed = true;
}
};
fill("device_brand", info.device_brand);
fill("device_model", info.device_model);
fill("system_name", info.system_name);
fill("system_version", info.system_version);
fill("device", device);
if (!text(item.system_name)) {
item.system_name = "Unknown";
changed = true;
}
if (!text(item.system_version)) {
item.system_version = "0.0.0";
changed = true;
}
fill("device_fingerprint", fingerprint);
fill("fp_version", FP_VERSION_FALLBACK);
fill("sdk_version", SDK_VERSION_FALLBACK);
fill("app_version", appVersion);
fill("user_agent", uaFallback);
}
return changed ? JSON.stringify(records) : null;
} catch {
return null;
}
}
function isReportUrl(url: string) {
return /eventTracking\/batchReport/i.test(url);
}
/** 在 SDK 发出 batchReport 前补齐公共字段。重复调用只安装一次。 */
export function installReportFieldPatch(appVersion = APP_VERSION_FALLBACK) {
if (typeof window === "undefined" || window.__trackerReportPatch) return;
window.__trackerReportPatch = true;
const version = appVersion.trim() || APP_VERSION_FALLBACK;
if (typeof window.fetch === "function") {
const origFetch = window.fetch.bind(window);
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
try {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (isReportUrl(url) && init && typeof init.body === "string") {
const next = augmentReportBody(init.body, { appVersion: version });
if (next != null) init = { ...init, body: next };
}
} catch {
/* 补齐失败时走原始请求 */
}
return origFetch(input, init);
};
}
if (typeof XMLHttpRequest === "undefined") return;
if (typeof XMLHttpRequest === "undefined") return;
const proto = XMLHttpRequest.prototype;
const origOpen = proto.open;
const origSend = proto.send;
type ReportXHR = XMLHttpRequest & { __trackerReportUrl?: boolean };
proto.open = function (this: ReportXHR, method: string, url: string | URL, async?: boolean, username?: string | null, password?: string | null) {
try {
this.__trackerReportUrl = isReportUrl(String(url));
} catch {
this.__trackerReportUrl = false;
}
return origOpen.call(this, method, url, async ?? true, username, password);
};
proto.send = function (this: ReportXHR, body?: Document | XMLHttpRequestBodyInit | null) {
try {
if (this.__trackerReportUrl && typeof body === "string") {
const next = augmentReportBody(body, { appVersion: version });
if (next != null) return origSend.call(this, next);
}
} catch {
/* 补齐失败时走原始请求 */
}
return origSend.call(this, body);
};
if (typeof navigator.sendBeacon === "function") {
const origBeacon = navigator.sendBeacon.bind(navigator);
navigator.sendBeacon = (url: string | URL, data?: BodyInit | null) => {
try {
if (isReportUrl(String(url)) && typeof data === "string") {
const next = augmentReportBody(data, { appVersion: version });
if (next != null) data = next;
}
} catch {
/* 补齐失败时走原始请求 */
}
return origBeacon(url, data);
};
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { isIP } from 'node:net';
import { buildUserAgent, userAgentSid } from './user-agent';
import { trackingHeaders } from './tracking-headers';
import { sdkHeaders } from './sdk-headers';
type HeaderSource=Pick<Headers,'get'>;
@@ -11,7 +11,7 @@ export function clientIp(source:HeaderSource):string {
return isIP(forwarded)?forwarded:'';
}
/** Session device from drama_device; business DevID must not be replaced by the SDK fingerprint. */
/** Session device from drama_device; business DevID always uses the visitor device id. */
export function businessDeviceId(cookie:string):string {
const raw=cookie.split(';').map(part=>part.trim()).find(part=>part.startsWith('drama_device='))?.slice('drama_device='.length)||'';
try{
@@ -37,6 +37,6 @@ export function clientHeaders(source:HeaderSource|undefined,path='',method='GET'
};
if(ua)headers['User-Agent']=ua;
if(ip)headers['X-Real-IP']=ip;
if(source)Object.assign(headers,trackingHeaders(source,path,method));
if(source)Object.assign(headers,sdkHeaders(source));
return headers;
}
+17
View File
@@ -0,0 +1,17 @@
const object=(value:unknown):value is Record<string,unknown>=>!!value&&typeof value==='object'&&!Array.isArray(value);
const string=(value:unknown,max:number)=>typeof value==='string'&&value.length<=max&&!/[\x00-\x1f\x7f]/.test(value);
/** Visitor identity fields the browser may send. Analytics events and contexts are not forwarded. */
const SDK_FIELD_LIMITS:Record<string,number>={sid:50,device_id:50,app_version:128};
/** Forward the visitor device snapshot (X-SDK-Common) only; it carries the guest device id. */
export function sdkHeaders(source:Pick<Headers,'get'>):Record<string,string>{
const sdkHeader=source.get('x-sdk-common')||'';
if(!sdkHeader||sdkHeader.length>4096||!/^[a-z\d+/]+=*$/i.test(sdkHeader))return {};
try{
const fields:unknown=JSON.parse(Buffer.from(sdkHeader,'base64').toString('utf8'));
if(!object(fields))return {};
const common=Object.fromEntries(Object.entries(fields).filter(([key,value])=>key in SDK_FIELD_LIMITS&&string(value,SDK_FIELD_LIMITS[key])));
if(!common.device_id)return {};
return {'X-SDK-Common':Buffer.from(JSON.stringify(common)).toString('base64')};
}catch{return {};}
}
+7 -10
View File
@@ -5,28 +5,25 @@ import { headers } from 'next/headers';
import { publicCachePolicy, publicDataCache } from './public-cache';
import { getApiBaseUrl, usesCurrentApiOrigin, UPSTREAM_HOP_HEADER, type ApiRequestContext } from './config';
import { clientHeaders } from './client-headers';
import { withoutRemovedNavigation } from '../site-navigation';
export { DEFAULT_API_BASE_URL } from './config';
export const PUBLIC_PATHS = new Set([
"playlet/home", "config/info", "config/footer/info", "config/richtext/list",
"navigation/header", "theme/list", "playlet/tag/options", "translation/category/list",
"recharge/package/list", "applist/list", "playlet/all/list", "playlet/operate/list", "playlet/home-config/list",
"playlet/all/list", "playlet/operate/list", "playlet/home-config/list",
"playlet/hot/rank/list", "playlet/topic/list", "playlet/topic/detail", "playlet/tag/list",
"playlet/guess-like/list", "playlet/hot-like",
]);
const GET_PATHS = [
...PUBLIC_PATHS, "auth/getInfo", "playlet/detail", "playlet/chapter/list", "playlet/chapter/detail",
"playlet/search", "playlet/search/suggest", "playlet/collect/list", "playlet/chapter/collect/list",
"playlet/history/list", "playlet/like/list", "playlet/bought/list", "user/center/data", "coin/orders", "coin/trans/list",
"vip/orders", "comment/list",
"playlet/search", "playlet/search/suggest",
];
const POST_PATHS = [
"auth/guest", "auth/login", "auth/register", "auth/resetPassword", "auth/profile",
"auth/guest",
"playlet/collect/set", "playlet/like/set", "playlet/chapter/collect/set", "playlet/chapter/like/set",
"playlet/chapter/unlock", "playlet/chapter/watch/report", "playlet/forward/heat",
"playlet/history/clear", "playlet/history/delete", "comment/send", "comment/like/set",
"coin/buy", "vip/pay", "feedback/add", "upload/image",
"playlet/chapter/watch/report", "playlet/forward/heat",
];
export function allowedRequest(path: string, method: string): boolean {
return (method === "GET" ? GET_PATHS : method === "POST" ? POST_PATHS : []).includes(path);
@@ -56,7 +53,7 @@ async function requestUpstream<T>(url:URL,options:Parameters<typeof upstream>[1]
[UPSTREAM_HOP_HEADER]: '1',
};
if (options.token) headers.Authorization = options.token;
for(const key of ['X-SDK-Common','X-Tracker-Request-Id','X-Tracker-Event-Name','X-DC-Request-Context'])if(options.tracking?.[key])headers[key]=options.tracking[key];
if(options.tracking?.['X-SDK-Common'])headers['X-SDK-Common']=options.tracking['X-SDK-Common'];
const multipart = options.body instanceof FormData;
if (options.body && !multipart) headers["Content-Type"] = "application/json";
let response: Response;
@@ -91,6 +88,6 @@ export const getHeaderNavigation=cache(async ():Promise<{data:HeaderNavigation;e
try{
const response=await upstream<HeaderNavigation>('navigation/header',{timeoutMs:5000});
if(response.code!==200||!response.data||typeof response.data!=='object')throw new Error('导航加载失败');
return {data:response.data};
return {data:withoutRemovedNavigation(response.data)};
}catch{return {data:{categoryList:[]},error:true};}
});
-38
View File
@@ -1,38 +0,0 @@
import { COMMON_KEYS, COMMON_FIELD_LIMITS, SDK_FIELD_KEYS, SERVER_EVENTS, TRACKER_APP_ID } from '../tracking';
const object=(value:unknown):value is Record<string,unknown>=>!!value&&typeof value==='object'&&!Array.isArray(value);
const string=(value:unknown,max:number)=>typeof value==='string'&&value.length<=max&&!/[\x00-\x1f\x7f]/.test(value);
const DRAMA_KEYS=['media_id','drama_id','episode','video_id','recommend_trace_id'];
/** Forward only the SDK snapshot and the event associated with this business route. */
export function trackingHeaders(source:Pick<Headers,'get'>,path:string,method:string):Record<string,string>{
const headers:Record<string,string>={};
const sdkHeader=source.get('x-sdk-common')||'';
if(sdkHeader&&sdkHeader.length<=16384&&/^[a-z\d+/]+=*$/i.test(sdkHeader)){
try{
const fields:unknown=JSON.parse(Buffer.from(sdkHeader,'base64').toString('utf8'));
const validField=(key:string,value:unknown)=>key==='client_ts'?Number.isSafeInteger(value)&&Number(value)>0:string(value,COMMON_FIELD_LIMITS[key]);
if(object(fields)&&SDK_FIELD_KEYS.every(key=>!Object.hasOwn(fields,key)||validField(key,fields[key]))){
const common=Object.fromEntries(Object.entries(fields).filter(([key])=>SDK_FIELD_KEYS.includes(key as typeof SDK_FIELD_KEYS[number])));
headers['X-SDK-Common']=Buffer.from(JSON.stringify(common)).toString('base64');
}
}catch{}
}
const event=SERVER_EVENTS[path],requestId=source.get('x-tracker-request-id')||'',packed=source.get('x-dc-request-context')||'';
if(method!=='POST'||!event||source.get('x-tracker-event-name')!==event||!(/^[a-f\d]{32}$/i.test(requestId))||!packed||packed.length>8192||!/^[a-z\d_-]+$/i.test(packed))return headers;
try{
const raw=Buffer.from(packed,'base64url');if(raw.length>6144)return headers;
const context=JSON.parse(raw.toString('utf8'));
if(!object(context)||context.v!==1||context.request_id!==requestId||context.event_name!==event||!object(context.common)||!object(context.drama))return headers;
const {common,drama}=context;
if(Object.keys(common).length!==COMMON_KEYS.length||!COMMON_KEYS.every(key=>Object.hasOwn(common,key))||Object.keys(drama).length!==DRAMA_KEYS.length||!DRAMA_KEYS.every(key=>Object.hasOwn(drama,key)))return headers;
if(!COMMON_KEYS.every(key=>key==='client_ts'?Number.isSafeInteger(common[key])&&Number(common[key])>0:string(common[key],COMMON_FIELD_LIMITS[key])))return headers;
if(common.app_id!==TRACKER_APP_ID||!common.device_id||!['','Android','iOS','PC','OTHER'].includes(String(common.device)))return headers;
if(common.uid&&!/^[a-z\d_]{4,32}$/i.test(String(common.uid)))return headers;
if(!/^[a-z\d_.-]{1,50}$/i.test(String(common.channel)))return headers;
if(!DRAMA_KEYS.every(key=>key==='episode'?Number.isSafeInteger(drama[key])&&Number(drama[key])>0:string(drama[key],key==='recommend_trace_id'?2048:512))||!drama.drama_id)return headers;
headers['X-Tracker-Request-Id']=requestId;headers['X-Tracker-Event-Name']=event;
headers['X-DC-Request-Context']=Buffer.from(JSON.stringify({v:1,request_id:requestId,event_name:event,common,drama})).toString('base64url');
}catch{}
return headers;
}
+1 -2
View File
@@ -22,13 +22,12 @@ export interface HomeData { modules?: HomeModule[]; homeBannerData?: Drama[]; ho
export interface User { userId: number; accountNo?: string; nickName?: string; avatar?: string; introduction?: string; email?: string; isRegister?: boolean; type?: number; recharge?: number; vipType?: number; currentVipExpire?: string; hasPassword?: boolean; token?: string }
export interface Chapter { mediaId?: string; mediaTime?: number; width?: number; height?: number; mediaSize?: number; countBrowse?: number; countLike?: number; countCollect?: number; countComment?: number; ratio?: number; seoInfo?: SeoInfo; id: number; playletId?: number; currentEpisode?: number; title?: string; videoUrl?: string; canWatch?: boolean; needUnlock?: boolean; needVip?: boolean; isUnlocked?: boolean; vipValid?: boolean; payType?: number; singleCoins?: number; costCoins?: number; balance?: number; isCollect?: number; isLike?: number }
export interface DramaDetail extends Drama { preview?: string; video?: {url?: string; cover?: string; videoTime?: number; width?: number; height?: number}; relatedRecommendData?: Drama[]; playletChapterInfo?: Chapter[]; advitise?: Advertisements }
export interface Comment { id: number; text: string; userName?: string; userAvatar?: string; createdAt?: string; children?: Comment[]; isLike?: number; countLike?: number; parentsId?: number; replyCommentId?: number; replyUserName?: string; commentCount?: number; level?: number }
export interface PageData<T> { id?:number; slug?:string; config?:HomeModule; topicList?:Topic[]; topicGroups?:TopicGroup[]; list?: T[]; pageNum?: number; pageSize?: number; totalCount?: number; name?: string; title?: string; topicInfo?: Topic; desc?: string; everyoneWatching?: Drama[]; moreTopics?: Topic[]; themeList?: Topic[]; videoList?: Drama[]; advitise?: Advertisements; seoInfo?: SeoInfo; [key:string]: unknown }
export interface AppItem { id: number; categoryId?: number; advitiseId?: string; location?: number; name: string; logo?: string; intro?: string; description?: string; url?: string }
export interface AppCategory { id: number; code?: number; title: string; intro?: string; appList?: AppItem[] }
export interface AppData { category?: AppCategory; categoryList?: AppCategory[] }
export interface Package { id: number; name: string; subTitle?: string; packageType: number; price: number; originalPrice?: number; durationHours?: number; baseCoin?: number; giftCoin?: number; totalCoin?: number; durationDays?: number; payMethods?: {type:string; typeName:string}[] }
export interface Config { reportSdkConfig?: string; config?: Record<string, unknown>; newAddressInfo?: Record<string, unknown>; navPageList?: {line:number; url:string}[]; advitise?: Advertisements; seoInfo?: SeoInfo | string; seoNavInfo?: Record<string,unknown> | string }
export interface Config { config?: Record<string, unknown>; newAddressInfo?: Record<string, unknown>; navPageList?: {line:number; url:string}[]; advitise?: Advertisements; seoInfo?: SeoInfo | string; seoNavInfo?: Record<string,unknown> | string }
export interface FooterContact { name?: string; value?: string }
export interface FooterNavItem { id?: number; name?: string; urlValue?: string; translationList?: FooterNavItem[] | null }
export interface FooterInfoResponse { footerInfo?: { brand?: {title?: string; content?: string}; contactList?: FooterContact[] | null; navList?: FooterNavItem[] | null } }
+9
View File
@@ -0,0 +1,9 @@
/** 站点品牌名。后台文案、页面标题与页脚统一使用。 */
export const SITE_NAME = "黄果短剧";
/** 后台文案里残留的旧站名统一换成 SITE_NAME。 */
const LEGACY_SITE_NAMES = /禁果短剧|91短剧|黄豆魔改短剧|黄豆短剧/g;
export function brandText(value?: string | null): string {
return (value || "").trim().replace(LEGACY_SITE_NAMES, SITE_NAME);
}
-268
View File
@@ -1,268 +0,0 @@
import type { MemberInitialData, WalletInitialData } from "../page-data";
import type { Advertisement, Drama, Package, PageData, User } from "../api/types";
import { recordList, safeUrl } from "../api/protocol";
import type { SiteContext } from "./context";
import { ads, card, closeButton, empty, escape, imageAttributes, pagination } from "./render";
import type { PageHref } from './render';
import { trackingPage } from '../tracking';
import { authFormPayload } from './auth';
const passwordEye=(revealed=false)=>`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>${revealed?'<path d="m3 3 18 18"/>':''}</svg>`;
export function bindAccountUI(app: SiteContext) {
const dialog=document.getElementById('member-auth-dialog') as HTMLDialogElement;
dialog.addEventListener('click',event=>{
const toggle=(event.target as Element).closest<HTMLButtonElement>('[data-auth-password-toggle]');
if(!toggle)return;
const input=dialog.querySelector<HTMLInputElement>('#'+toggle.dataset.authPasswordToggle)!;
const show=input.type==='password';input.type=show?'text':'password';
toggle.innerHTML=passwordEye(show);toggle.setAttribute('aria-label',show?'隐藏密码':'显示密码');
});
dialog.addEventListener('input',()=>{const error=dialog.querySelector('#auth-error');if(error)error.textContent='';});
dialog.addEventListener('submit',event=>{
event.preventDefault();const form=event.target as HTMLFormElement;
if(form.dataset.submitting||!form.reportValidity())return;
const mode=form.dataset.mode||'login';const error=form.querySelector('#auth-error')!;
const switching=form.dataset.authSwitch==='1';
let payload:Record<string,string>;
try{payload=authFormPayload(mode,Object.fromEntries(new FormData(form)));}
catch(reason){error.textContent=reason instanceof Error?reason.message:'请检查输入';return;}
const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;
const label=button.textContent;form.dataset.submitting='1';button.disabled=true;
button.textContent=mode==='register'?'绑定中…':mode==='resetPassword'?'提交中…':switching?'切换中…':'登录中…';error.textContent='';
const previousUserId=app.api.user?.userId;
void app.api.post<User>('auth/'+mode,switching?{...payload,switchAccount:true}:payload).then(async user=>{
if(switching&&previousUserId&&user?.userId===previousUserId){if(form.isConnected)error.textContent='切换失败,请确认账号和密码';return;}
form.querySelectorAll<HTMLInputElement>('[data-auth-password]').forEach(input=>input.value='');
if(mode==='resetPassword'){app.notify('密码已更新,请重新登录');if(form.isConnected)app.openAuth();return;}
if(form.isConnected)dialog.close();
if(mode==='register'){
// Bind issues a new JWT and invalidates the guest token. Confirm getInfo with the new
// bearer, then stay on the settings page — a full reload would replay the stale
// HttpOnly drama_session, hit getInfo 1000, and open the login dialog.
await app.api.refreshUser();
const state=document.querySelector('[data-bind-state]');if(state)state.textContent='已绑定';
app.notify('绑定成功');
return;
}
app.notify(switching?'已切换账号':'登录成功');
await app.api.refreshUser().catch(()=>{});
await app.route();
}).catch(reason=>{if(form.isConnected)error.textContent=reason instanceof Error?reason.message:'请求失败,请重试';})
.finally(()=>{delete form.dataset.submitting;button.disabled=false;button.textContent=label;});
});
dialog.addEventListener('close',()=>{dialog.querySelectorAll<HTMLInputElement>('[data-auth-password]').forEach(input=>input.value='');});
}
export function openAuth(mode='login') {
const switching=mode==='switch';
if(mode==='bind')mode='register';
if(!['login','register','resetPassword'].includes(mode))mode='login';
const dialog=document.getElementById('member-auth-dialog') as HTMLDialogElement;
dialog.classList.toggle('auth-bind-mode',mode==='register');
const menu=document.getElementById('profile-popover');if(menu)menu.hidden=true;
const heading=mode==='register'?'绑定账号':mode==='resetPassword'?'找回密码':switching?'切换账号':'账号登录';
const passwordField=(id:string,name:string,label:string,confirm=false)=>`<label class="auth-field" for="${id}">${label}</label><div class="auth-input-row auth-password-row${mode==='register'?' auth-icon-row':''}">${mode==='register'?'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>':''}<input id="${id}" name="${name}" type="password" data-auth-password autocomplete="${mode==='login'?'current-password':'new-password'}" minlength="${mode==='login'?4:8}" maxlength="64" placeholder="请输入${label}" required><button class="auth-password-toggle" type="button" data-auth-password-toggle="${id}" aria-label="显示${confirm?'确认':''}密码">${passwordEye()}</button></div>`;
dialog.innerHTML=`${closeButton(dialog.id)}<h2 id="member-auth-title">${heading}</h2><p class="auth-subtitle">${mode==='register'?'绑定后可保留当前游客的收藏与观看记录':mode==='resetPassword'?'通过账号和已绑定邮箱找回密码。':switching?'请输入其他账号和密码完成切换。':'登录后同步收藏、观看记录与已购剧集。'}</p><form data-mode="${mode}"${switching?' data-auth-switch="1"':''}><label class="auth-field" for="auth-account">${mode==='login'?'账号或邮箱':'账号'}</label><div class="auth-input-row${mode==='register'?' auth-icon-row':''}">${mode==='register'?'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/></svg>':''}<input id="auth-account" name="account" autocomplete="username" required maxlength="${mode==='register'?20:254}" placeholder="${mode==='register'?'6 到 20 位字母或数字':'请输入账号'+(mode==='login'?'或邮箱':'')}"></div>${mode==='resetPassword'?'<label class="auth-field" for="auth-email">绑定邮箱</label><div class="auth-input-row"><input id="auth-email" name="email" type="email" autocomplete="email" required></div>':''}${passwordField('auth-password','password',mode==='resetPassword'?'新密码':'密码')}${mode!=='login'?passwordField('auth-password-confirm','passwordConfirm','确认密码',true):''}${mode==='register'?'<p class="auth-note">密码需为 8–64 位,同时包含字母和数字。</p><label class="auth-bind-agreement"><input name="agree" type="checkbox" required><span>我已年满 18 岁并同意 <a href="/info/user-agreement/">《用户协议》</a> 和 <a href="/info/privacy-policy/">《隐私政策》</a></span></label>':''}<p id="auth-error" class="auth-error" role="alert" aria-live="polite"></p><button class="auth-submit" type="submit">${heading}</button><p class="auth-footer"><button type="button" data-auth-mode="login">登录</button> · <button type="button" data-auth-mode="register">绑定账号</button> · <button type="button" data-auth-mode="resetPassword">找回密码</button></p></form>`;
if(!dialog.open)dialog.showModal();
dialog.querySelector<HTMLInputElement>('#auth-account')?.focus();
}
const profileIcons={
user:'M718.196364 139.636364h-418.909091a209.454545 209.454545 0 0 0-209.454546 209.454545v330.938182a209.454545 209.454545 0 0 0 209.454546 209.454545h418.909091a209.454545 209.454545 0 0 0 209.454545-209.454545V349.090909a209.454545 209.454545 0 0 0-209.454545-209.454545z m-223.650909 465.454545a43.52 43.52 0 0 1-43.752728 43.287273h-119.621818A43.52 43.52 0 0 1 287.418182 605.090909a43.52 43.52 0 0 1 43.752727-43.287273h119.621818A43.52 43.52 0 0 1 494.545455 605.090909z m162.90909-159.418182h-325.818181a43.287273 43.287273 0 1 1 0-86.574545h325.818181a43.287273 43.287273 0 1 1 0 86.574545z',
gift:'M508.509091 69.818182L135.912727 176.872727v418.909091a157.789091 157.789091 0 0 0 48.64 114.501818l46.545455 44.683637 7.447273 6.749091 14.196363 13.265454 13.963637 12.8 13.730909 12.101818 13.265454 11.636364 13.032727 11.170909c16.058182 13.498182 32.349091 26.530909 49.105455 39.098182l11.636364 8.378182c57.018182 41.425455 103.563636 60.741818 140.8 60.741818s83.549091-20.247273 139.636363-60.974546l11.636364-8.378181c16.756364-12.567273 33.28-25.6 49.105454-39.098182l13.032728-11.170909 13.498182-11.636364 13.498181-12.101818 13.963637-12.8 14.429091-13.265455 14.429091-13.730909 39.330909-37.701818a159.650909 159.650909 0 0 0 48.872727-115.2V176.872727z m0 659.549091a51.898182 51.898182 0 0 1 0-103.563637 51.898182 51.898182 0 0 1 0 103.563637z m52.363636-139.636364h-105.192727l-9.076364-319.534545h122.414546z',
center:'M750.08 741.469091A363.985455 363.985455 0 0 0 884.363636 459.636364 369.338182 369.338182 0 0 0 512 93.090909 369.570909 369.570909 0 0 0 139.636364 459.636364a363.52 363.52 0 0 0 134.516363 281.832727A312.785455 312.785455 0 0 0 214.341818 884.363636 41.658182 41.658182 0 0 0 256 930.909091h513.163636a41.425455 41.425455 0 0 0 40.494546-46.545455 312.552727 312.552727 0 0 0-59.578182-142.894545z m-128.930909-192a126.836364 126.836364 0 0 1-218.763636 4.189091 36.770909 36.770909 0 0 1 13.032727-50.501818 37.701818 37.701818 0 0 1 51.432727 12.8 51.2 51.2 0 0 0 88.436364-1.861819 37.701818 37.701818 0 0 1 50.967272-14.661818 36.538182 36.538182 0 0 1 14.894546 50.036364z',
bookmark:'M857.6 327.68l-104.494545-34.210909a178.036364 178.036364 0 0 1-90.763637-66.56l-64-89.367273a104.96 104.96 0 0 0-23.272727-23.272727 106.123636 106.123636 0 0 0-148.48 23.272727l-64.930909 89.367273a179.665455 179.665455 0 0 1-90.763637 66.792727l-104.494545 33.978182a106.821818 106.821818 0 0 0-53.061818 162.909091l93.090909 128.930909a35.84 35.84 0 0 1 6.981818 21.643636v158.952728a109.381818 109.381818 0 0 0 5.12 33.512727 105.658182 105.658182 0 0 0 133.818182 67.956364l128-39.33091a107.985455 107.985455 0 0 1 64.232727 0l127.534546 39.33091a106.123636 106.123636 0 0 0 139.636363-101.469091v-158.254546a35.84 35.84 0 0 1 6.981818-21.643636l93.09091-128.930909a104.494545 104.494545 0 0 0 15.127272-29.556364 106.821818 106.821818 0 0 0-69.352727-134.050909z m-192 83.549091a34.909091 34.909091 0 0 1-48.174545 10.938182l-46.545455-29.090909a34.909091 34.909091 0 0 1 37.003636-59.112728l46.545455 29.090909a34.909091 34.909091 0 0 1 11.170909 48.174546z',
like:'M512 896l-58-53C248 656 112 533 112 382c0-123 97-220 220-220 70 0 137 33 180 85 43-52 110-85 180-85 123 0 220 97 220 220 0 151-136 274-342 461z',
clock:'M512 46.545455a465.454545 465.454545 0 1 0 465.454545 465.454545A465.454545 465.454545 0 0 0 512 46.545455z m34.909091 479.883636L398.894545 674.909091a35.607273 35.607273 0 0 1-24.901818 10.24A35.141818 35.141818 0 0 1 349.090909 626.501818l128.698182-128.930909V302.545455a34.909091 34.909091 0 0 1 69.818182 0z',
coin:'M896 272.756364h-15.592727l-535.272728-175.476364A106.821818 106.821818 0 0 0 209.454545 200.145455v72.610909H153.6a83.781818 83.781818 0 0 0-83.781818 83.781818v490.356363A84.014545 84.014545 0 0 0 153.6 930.909091h716.567273A84.014545 84.014545 0 0 0 954.181818 846.894545V330.938182a58.181818 58.181818 0 0 0-58.181818-58.181818zM279.272727 200.145455a37.934545 37.934545 0 0 1 14.894546-30.021819 34.909091 34.909091 0 0 1 30.72-6.050909l331.869091 108.683637H279.272727z m35.141818 435.2a54.923636 54.923636 0 1 1 55.389091-54.923637 54.923636 54.923636 0 0 1-56.087272 54.923637z',
switch:'M861.090909 494.778182a84.712727 84.712727 0 0 0-52.130909-64.698182l-97.745455-40.261818 64.232728-99.374546a84.247273 84.247273 0 0 0 6.749091-79.592727 87.505455 87.505455 0 0 0-65.163637-51.432727L361.425455 94.487273a87.272727 87.272727 0 0 0-100.538182 62.836363l-95.418182 372.363637a81.221818 81.221818 0 0 0 10.007273 63.534545 85.178182 85.178182 0 0 0 55.389091 37.469091l91.22909 18.152727-27.694545 183.621819a84.247273 84.247273 0 0 0 43.52 86.574545 88.669091 88.669091 0 0 0 43.287273 11.403636 87.272727 87.272727 0 0 0 57.716363-21.643636l392.843637-337.454545A83.316364 83.316364 0 0 0 861.090909 494.778182z m-256 53.992727l-59.112727 71.447273a34.443636 34.443636 0 0 1-26.996364 12.8 35.141818 35.141818 0 0 1-26.763636-57.250909l59.112727-71.447273A34.909091 34.909091 0 1 1 605.090909 548.770909z',
} as const;
const profileChevron='<span class="profile-next" aria-hidden="true"><svg class="ui-chevron-right" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false"><path d="m9 5 7 7-7 7"/></svg></span>';
const profileIcon=(name:keyof typeof profileIcons)=>`<svg class="account-collection-icon" viewBox="0 0 1024 1024" aria-hidden="true"><path d="${profileIcons[name]}"/></svg>`;
const profileCount=(id:string,value?:number)=>`<span class="profile-count" id="${id}">${typeof value==='number'&&Number.isFinite(value)&&value>=0?Math.floor(value).toLocaleString('zh-CN'):''}</span>`;
const profileLink=(href:string,icon:string,label:string,count='')=>`<a class="profile-row" href="${href}">${icon}<span class="profile-row-label">${label}</span>${count}${profileChevron}</a>`;
const profileAction=(mode:string,icon:string,label:string,footer=false)=>`<button type="button" data-auth-mode="${mode}"${footer?' class="profile-switch"':''}>${icon}<span class="profile-row-label">${label}</span>${footer?'':profileChevron}</button>`;
export interface AccountMenuStats { collectCount?: number; likeCount?: number; watchCount?: number; boughtChapterCount?: number }
export function accountMenuMarkup(user:User|null|undefined,stats?:AccountMenuStats):string {
const name=user?.nickName||user?.accountNo||'游客';
const coins=typeof user?.recharge==='number'&&Number.isFinite(user.recharge)&&user.recharge>=0?Math.floor(user.recharge):undefined;
// Row order matches 禁果短剧_2组.html: login → bind → center → favorites → likes → history → coins → switch.
return `<div class="profile-popover-head"><a class="profile-avatar-link" href="/member/center/" aria-label="进入个人中心"><img ${imageAttributes(user?.avatar,'/icon.svg')} width="44" height="44" alt="${escape(name)}的头像"></a><div><strong>${escape(name)}</strong><small>${user?'ID '+user.userId:'登录后同步个人数据'}</small></div></div>${profileAction('login',profileIcon('user'),'账号登录')}${profileAction('bind',profileIcon('gift'),'绑定账号')}${profileLink('/member/center/',profileIcon('center'),'个人中心')}${profileLink('/member/favorites/',profileIcon('bookmark'),'我的收藏',profileCount('profile-saved-count',stats?.collectCount))}${profileLink('/member/likes/',profileIcon('like'),'我的点赞',profileCount('profile-likes-count',stats?.likeCount))}${profileLink('/member/history/',profileIcon('clock'),'观看历史')}${profileLink('/info/deposit/',profileIcon('coin'),'金币充值',profileCount('profile-coin-count',coins))}${profileAction('switch',profileIcon('switch'),'切换账号',true)}`;
}
const settingsChevron='<span aria-hidden="true"><svg class="ui-chevron-right" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false"><path d="m9 5 7 7-7 7"/></svg></span>';
export function settingsMarkup(user:User):string {
const bound=user.isRegister===true||user.type===1||user.hasPassword===true;
return `<div class="member-settings"><section class="member-settings-card"><h2>个人资料</h2><img class="member-settings-avatar" ${imageAttributes(user.avatar,'/icon.svg')} width="88" height="88" alt="当前头像"><form id="member-nickname-form"><label for="member-nickname-input">昵称</label><div class="member-nickname-editor"><input id="member-nickname-input" name="nickname" type="text" maxlength="20" required autocomplete="nickname" aria-describedby="member-nickname-hint" value="${escape(user.nickName)}"><button type="submit">保存修改</button></div><p id="member-nickname-hint">支持 1–20 个字符</p><p id="member-nickname-status" role="status"></p></form></section><section class="member-settings-card"><h2>账号与安全</h2><button type="button" class="member-settings-action" data-member-settings="bind" data-auth-mode="register"><span><strong>账号绑定</strong><small>设置账号和密码,方便下次登录</small></span><span class="member-settings-trailing"><span data-bind-state>${bound?'已绑定':'未绑定'}</span> ${settingsChevron}</span></button><button type="button" class="member-settings-action" data-member-settings="switch" data-auth-mode="switch"><span><strong>切换账号</strong><small>使用其他账号登录</small></span>${settingsChevron}</button></section></div>`;
}
export function memberMarkup(user:User,tab:string,data:PageData<Drama>={},home:SiteContext['home']={},pageNum=1,pageHref:PageHref=()=>'/member/history/'):string {
const active=tab==='center'?'history':tab;
const libraryPageHref:PageHref=tab==='center'?page=>page>1?`/member/history/page/${page}/`:'/member/history/':pageHref;
const tabs=[['history','观看历史'],['favorites','我的收藏'],['likes','我的点赞'],['purchased','已购剧集']];
const iconAds=ads(recordList<Advertisement>(home?.advitise?.nineGridList).filter(item=>safeUrl(item.images)).slice(0,16),'member-ad-tile');
const header=`${iconAds?`<section class="member-advertisements" aria-label="广告推荐"><span class="member-ad-label">广告</span><div class="member-ad-grid">${iconAds}</div></section>`:''}<section class="member-summary"><div class="member-identity"><img ${imageAttributes(user.avatar,'/icon.svg')} alt="我的头像"><div><div class="member-name-row"><h2>${escape(user.nickName||user.accountNo||'游客')}</h2><a class="member-settings-link" href="/member/settings/">账号设置 <span aria-hidden="true">›</span></a></div><p>ID:${user.userId}</p></div></div><a class="member-wallet" href="/info/deposit/"><span>金币余额 <small>(金币)</small></span><strong><span class="coin-symbol" aria-hidden="true"><img class="coin-soft-star" src="/assets/897547dcbff56113.svg" alt="金币图标"></span>${(user.recharge||0).toLocaleString('zh-CN')}</strong><span class="member-wallet-link">金币充值 <span aria-hidden="true">↗</span></span></a></section>`;
const nav='<nav class="member-library-tabs" aria-label="我的短剧">'+tabs.map(([key,label])=>`<a href="/member/${key}/" ${key===active?'aria-current="page"':''}>${key===active?`<h2 id="member-library-title" class="member-library-title">${label}</h2>`:label}</a>`).join('')+'</nav>';
if(tab==='settings')return settingsMarkup(user);
const noRecords=active==='likes'?'<div class="member-library-empty" role="status"><p>暂无点赞的短剧</p><a href="/shorts/">去发现好剧</a></div>':empty(active==='history'?'暂无观看历史':active==='purchased'?'暂无已购剧集':'暂无收藏');
const content=`<section class="member-library" aria-labelledby="member-library-title">${nav}<div class="member-library-toolbar"><span role="status">共 ${data.totalCount||0} 部短剧</span></div><div class="member-library-cards" id="member-library-panel">${recordList<Drama>(data.list).map(item=>`<div class="member-library-item">${card(item)}</div>`).join('')||noRecords}</div>${pagination(data.totalCount||0,pageNum,libraryPageHref)}</section>`;
return `<div class="member-center">${header}${content}</div>`;
}
export async function renderMember(app:SiteContext,tab:string,pageNum:number,pageHref:PageHref,initial?:MemberInitialData,preserveMarkup=false) {
const signal=app.signal;
const user=initial?.user||await app.api.ensureSession();
if(!initial)await app.api.get<Record<string,number>>('user/center/data',{},signal);
if(signal.aborted)return;
const root=document.getElementById('info-content')!;
const active=tab==='center'?'history':tab;
let data=initial?.list;
if(tab!=='settings'&&!data){
const paths:Record<string,string>={favorites:'playlet/collect/list',history:'playlet/history/list',likes:'playlet/like/list',purchased:'playlet/bought/list'};
data=await app.api.get<PageData<Drama>>(paths[active],{pageNum,pageSize:24},signal);
}
if(signal.aborted)return;
if(data)app.updateSeo?.({totalCount:data.totalCount,pageSize:24});
if(!preserveMarkup)root.innerHTML=memberMarkup(user,tab,data,app.home,pageNum,pageHref);
if(tab!=='settings')return;
root.querySelector('#member-nickname-form')?.addEventListener('submit',event=>{
event.preventDefault();const form=event.target as HTMLFormElement;if(!form.reportValidity()||form.dataset.submitting)return;
const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;button.disabled=true;
const status=form.querySelector('#member-nickname-status');if(status)status.textContent='';
form.dataset.submitting='1';
void (async()=>{
const data=new FormData(form);const nickName=String(data.get('nickname')??data.get('nickName')??'').trim();
const payload:Record<string,unknown>={nickName};if(user.introduction)payload.introduction=user.introduction;
if(signal.aborted)return;
await app.api.post('auth/profile',payload);await app.api.refreshUser();if(!signal.aborted){app.notify('昵称已保存');void app.route();}
})().catch(error=>{if(!signal.aborted&&status)status.textContent=error.message;}).finally(()=>{button.disabled=false;delete form.dataset.submitting;});
},{signal});
}
function paymentMethods(item:Package) {
return recordList<{type:string;typeName:string}>(item.payMethods).filter((method,index,methods)=>typeof method.type==='string'&&method.type.trim()&&methods.findIndex(other=>other.type===method.type)===index);
}
const count=(value:unknown):value is number=>typeof value==='number'&&Number.isFinite(value)&&value>=0;
const packagePrice=(item:Package)=>(item.price/100).toFixed(2).replace(/\.00$/,'');
function packageCoins(item:Package) {
const gift=count(item.giftCoin)?item.giftCoin:0;
const base=count(item.baseCoin)?item.baseCoin:count(item.totalCoin)&&item.totalCoin>=gift?item.totalCoin-gift:undefined;
const total=count(item.totalCoin)?item.totalCoin:base===undefined?undefined:base+gift;
return {base,gift,total};
}
export function paymentDialogMarkup(item:Package,vip:boolean):string {
const methods=paymentMethods(item);
const {base,gift,total}=packageCoins(item);
const number=(value:number)=>String(value);
const logos:Record<string,string>={alipay:'5652fcead17188e8',wechat:'2bead9ea5c435c98',usdt:'7848816d05aa8591'};
const logo=(method:{type:string;typeName:string})=>{
const type=method.type.toLowerCase();
const key=type.includes('alipay')?'alipay':/wechat|wxpay|weixin/.test(type)?'wechat':type.includes('usdt')?'usdt':'';
return key?`<img class="wallet-pay-logo" src="/assets/${logos[key]}.svg" width="36" height="36" alt="${escape(({alipay:"支付宝",wechat:"微信",usdt:"USDT"})[key])}支付图标" aria-hidden="true">`:'<span class="wallet-pay-logo wallet-pay-logo--other" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M3 10h18M7 15h3"/></svg></span>';
};
return `<button type="button" class="wallet-dialog-close" data-close="wallet-payment-dialog" aria-label="关闭支付弹窗"><svg class="dialog-close-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
<div class="wallet-payment-heading"><h2 id="wallet-payment-title" class="wallet-payment-title">确认支付</h2><span>${vip?'会员畅享':'金币充值'}</span></div>
<div class="wallet-order"><div class="wallet-order-copy"><strong>${escape(!vip&&base!==undefined?number(base)+' 金币':item.name)}</strong><span>${escape(item.subTitle||(vip?'支付成功后开通会员':'支付成功后即时到账'))}</span>${gift>0?`<small>赠送 ${number(gift)} 金币${total!==undefined?' · 合计 '+number(total)+' 金币':''}</small>`:''}</div><b>¥${packagePrice(item)}</b></div>
<form id="payment-form"><h3 class="wallet-method-title">选择支付方式</h3><input type="hidden" name="payMode" value="${escape(methods[0]?.type||'')}">
<div class="wallet-methods" role="group" aria-label="支付方式">${methods.map((method,index)=>`<button type="button" data-wallet-method="${escape(method.type)}" aria-pressed="${index===0}">${logo(method)}<span class="wallet-method-name">${escape(method.typeName||method.type)}</span><span class="wallet-method-check" aria-hidden="true">${index===0?'✓':''}</span></button>`).join('')||'<p class="wallet-payment-status" role="status">当前套餐暂无可用支付方式</p>'}</div>
<button class="wallet-submit wallet-payment-go" type="submit" ${methods.length?'':'disabled'}>去支付</button><p class="wallet-payment-note">点击支付即表示已确认订单金额与支付方式</p><p class="api-error wallet-payment-status" role="alert" aria-live="polite"></p>
</form>`;
}
export function walletMarkup(user:User|null,packages:WalletInitialData['packages'],transactions:WalletInitialData['transactions'],vip:boolean,pageNum:number,pageHref:PageHref):string {
const items=recordList<Package>(vip?packages.vipList:packages.coinList).filter(item=>Number.isSafeInteger(item.id)&&item.id>0&&count(item.price));
let selected=items[0];
const title=(item:Package)=>!vip&&packageCoins(item).base!==undefined?packageCoins(item).base+'金币':item.name;
const selectionText=(item:Package)=>`已选择 ${title(item)}${packageCoins(item).gift>0?' + 赠送'+packageCoins(item).gift+'金币':''} · 点击后选择支付方式`;
const coin=(className:string)=>`<span class="coin-symbol ${className}" aria-hidden="true"><img class="coin-soft-star" src="/assets/897547dcbff56113.svg" alt="金币图标" aria-hidden="true"></span>`;
const balance=count(user?.recharge)?user!.recharge.toLocaleString('zh-CN'):'0';
const transactionRows=recordList<Record<string,unknown>>(transactions.list).map(row=>{
const numeric=(value:unknown)=>typeof value==='number'&&Number.isFinite(value)?value:undefined;
const change=numeric(row.changeAmount);
const amount=change!==undefined&&change!==0?change:numeric(row.amount)??numeric(row.coin)??numeric(row.changeCoin)??change??0;
const expense=Number(row.flowDir)===-1||amount<0;
const name=row.title||row.tranTypeName||row.name||row.remark||row.typeName||'金币变动';
const time=String(row.createdAt??row.time??'');
const date=new Date(time);
const displayTime=time.replace(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}:\d{2}).*$/,'$1/$2/$3 $4');
return `<div class="wallet-transaction-row"><div><strong>${escape(name)}</strong>${time?`<time${Number.isFinite(date.getTime())?` datetime="${date.toISOString()}"`:''}>${escape(displayTime)}</time>`:''}</div><span class="wallet-transaction-amount ${expense?'expense':'income'}">${amount===0?'0':(expense?'−':'+')+Math.abs(amount).toLocaleString('zh-CN')}</span></div>`;
}).join('');
return `<div class="wallet-page">
<section class="wallet-balance-card" aria-label="金币余额与用途"><div class="wallet-balance-summary">${coin('wallet-balance-coin')}<div class="wallet-balance-copy"><span>当前余额</span><div class="wallet-balance-amount"><strong>${balance}</strong><b>金币</b></div><p>按剧集原定价按集解锁,解锁后永久有效</p></div></div>
<div class="wallet-usage"><h2>金币怎么用</h2>
<div class="wallet-usage-item"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V6a4 4 0 0 1 8 0v4"/></svg><div><strong>解锁单集内容</strong><p>按剧集标定价消耗金币</p></div></div>
<div class="wallet-usage-item"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m13 2-9 12h7l-1 8 10-13h-8l1-7Z"/></svg><div><strong>奖励金币优先使用</strong><p>解锁剧集先自动优先扣除任务获得的奖励金币</p></div></div>
<div class="wallet-usage-item"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="6"/><path d="m8 13-1 9 5-3 5 3-1-9"/></svg><div><strong>已解锁剧集不再扣费</strong><p>已解锁的剧集可随时回看,不会重复消耗金币</p></div></div>
</div></section>
${vip?'<nav class="channel-tabs" aria-label="充值分类"><a href="/info/deposit/">金币充值</a><a href="/info/deposit/vip/" aria-current="page">会员畅享</a><a href="/member/purchased/">已购剧集</a></nav>':''}
<h2 class="wallet-section-title">购买套餐</h2><section class="wallet-pack-panel" aria-label="购买套餐"><div class="wallet-pack-heading"><strong>${vip?'请选择会员套餐':'请选择充值金额'}</strong><span>${vip?'支付成功后开通会员':'按集解锁,自由使用'}</span></div>
<div class="wallet-pack-grid" role="group" aria-label="${vip?'会员':'金币'}套餐">${items.map((item,index)=>{
const {gift}=packageCoins(item);
const bonus=gift>0?`赠送 ${gift}金币`:item.subTitle||(vip&&item.durationDays?`${item.durationDays}天会员`: '');
return `<button type="button" class="wallet-package wallet-mobile-package" data-buy-package="${item.id}" aria-pressed="${item===selected}" aria-label="${escape(title(item)+(bonus?','+bonus:'')+',人民币'+packagePrice(item)+'元')}">${coin('wallet-package-coin')}<span class="wallet-mobile-coins">${escape(title(item))}</span><span class="wallet-mobile-bonus">${escape(bonus)}</span><span class="wallet-mobile-price"><small>¥</small><strong>${packagePrice(item)}</strong></span>${index===0?'<span class="wallet-double-badge"><img src="/assets/0f0ef061135a13f0.svg" width="37.0911" height="37.0911" alt="推荐套餐标识" aria-hidden="true"><span>推荐</span></span>':''}<img class="wallet-mobile-selected" src="/assets/2fdedce06a90a6a1.svg" width="24" height="24" alt="套餐选中标识" aria-hidden="true"></button>`;
}).join('')||`<div class="wallet-pack-empty">${empty('暂无可购买套餐')}</div>`}</div></section>
<div class="wallet-pay-area"><button type="button" class="wallet-submit" data-wallet-action="pay" ${selected?'':'disabled'}>${selected?'立即支付 ¥'+packagePrice(selected):'暂无可购买套餐'}</button><p id="wallet-selection" role="status">${selected?escape(selectionText(selected)):''}</p></div>
<section class="wallet-transactions"><h2 class="wallet-section-title">交易明细</h2><div class="wallet-transaction-list" aria-label="金币交易记录">${transactionRows||empty('error' in transactions?String(transactions.error):'暂无交易明细')}</div>${pagination(transactions.totalCount||0,pageNum,pageHref,10)}</section>
<p class="wallet-support">如遇充值问题请咨询 <a href="/info/feedback/">在线客服</a></p></div>`;
}
export async function renderWallet(app:SiteContext,vip:boolean,pageNum:number,pageHref:PageHref,initial?:WalletInitialData,preserveMarkup=false) {
const signal=app.signal;
const user=initial?.user||await app.api.ensureSession();
if(signal.aborted)return;
const [packages,transactions]=initial?[initial.packages,initial.transactions]:await Promise.all([
app.api.get<{coinList?:Package[];vipList?:Package[]}>('recharge/package/list',{},signal),
app.api.get<PageData<Record<string,unknown>>>('coin/trans/list',{page:pageNum,pageSize:10,type:0},signal).catch(()=>({list:[],totalCount:0,error:'余额明细暂不可用,请稍后刷新'})),
]);
if(signal.aborted)return;
const items=recordList<Package>(vip?packages.vipList:packages.coinList).filter(item=>Number.isSafeInteger(item.id)&&item.id>0&&count(item.price));
app.updateSeo?.({totalCount:transactions.totalCount,pageSize:10});
let selected=items[0];
const title=(item:Package)=>!vip&&packageCoins(item).base!==undefined?packageCoins(item).base+'金币':item.name;
const selectionText=(item:Package)=>`已选择 ${title(item)}${packageCoins(item).gift>0?' + 赠送'+packageCoins(item).gift+'金币':''} · 点击后选择支付方式`;
const root=document.getElementById('info-content')!;
if(!preserveMarkup)root.innerHTML=walletMarkup(user,packages,transactions,vip,pageNum,pageHref);
const openPayment=(item:Package)=>{
const dialog=document.getElementById('wallet-payment-dialog') as HTMLDialogElement;
const methods=paymentMethods(item);
dialog.innerHTML=paymentDialogMarkup(item,vip);
if(!dialog.open)dialog.showModal();
const paymentForm=dialog.querySelector<HTMLFormElement>('form')!;
const choices=Array.from(paymentForm.querySelectorAll<HTMLButtonElement>('[data-wallet-method]'));
paymentForm.addEventListener('click',choiceEvent=>{
const choice=(choiceEvent.target as Element).closest<HTMLButtonElement>('[data-wallet-method]');
if(!choice||choice.disabled||!methods.some(method=>method.type===choice.dataset.walletMethod))return;
paymentForm.querySelector<HTMLInputElement>('[name="payMode"]')!.value=choice.dataset.walletMethod!;
choices.forEach(button=>{const selected=button===choice;button.setAttribute('aria-pressed',String(selected));button.querySelector('.wallet-method-check')!.textContent=selected?'✓':'';});
paymentForm.querySelector('.api-error')!.textContent='';
},{signal});
paymentForm.addEventListener('submit',submit=>{
submit.preventDefault();const form=submit.target as HTMLFormElement;if(!form.reportValidity()||form.dataset.submitting)return;
const mode=new FormData(form).get('payMode');if(!methods.some(method=>method.type===mode))return;
const confirm=form.querySelector<HTMLButtonElement>('[type="submit"]')!;confirm.disabled=true;form.dataset.submitting='1';choices.forEach(choice=>choice.disabled=true);
const page=trackingPage(typeof location==='undefined'?'/info/deposit/':location.pathname);
void app.api.post<{payUrl?:string;tradeNo?:string}>(vip?'vip/pay':'coin/buy',{packageId:item.id,payMode:mode,sourcePageKey:page.key,sourcePageName:page.name}).then(result=>{
if(signal.aborted||!form.isConnected)return;
const url=safeUrl(result.payUrl);if(!url)throw new Error('支付服务未返回有效地址,请稍后重试');
form.innerHTML=`<p class="wallet-payment-status" role="status">订单 ${escape(result.tradeNo)} 已创建,请在支付页面完成付款。</p><a class="primary wallet-payment-link" href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer">打开支付页面</a><button type="button" class="wallet-submit wallet-payment-go" data-payment-refresh>我已完成支付,刷新余额</button>`;
form.querySelector('[data-payment-refresh]')!.addEventListener('click',()=>{void app.api.refreshUser().then(()=>{dialog.close();app.notify('余额已刷新');void app.route();}).catch(error=>app.notify(error.message));},{signal});
}).catch(error=>{if(signal.aborted||!form.isConnected)return;form.querySelector('.api-error')!.textContent=error.message;confirm.disabled=false;delete form.dataset.submitting;choices.forEach(choice=>choice.disabled=false);});
},{signal});
};
root.addEventListener('click',event=>{
const button=(event.target as Element).closest<HTMLElement>('[data-buy-package],[data-wallet-action]');if(!button)return;
if(button.dataset.buyPackage){
const item=items.find(item=>item.id===Number(button.dataset.buyPackage));if(!item)return;
selected=item;
root.querySelectorAll<HTMLElement>('[data-buy-package]').forEach(card=>card.setAttribute('aria-pressed',String(Number(card.dataset.buyPackage)===item.id)));
const pay=root.querySelector<HTMLButtonElement>('[data-wallet-action="pay"]');if(pay)pay.textContent='立即支付 ¥'+packagePrice(item);
const status=root.querySelector('#wallet-selection');if(status)status.textContent=selectionText(item);
}else if(button.dataset.walletAction==='pay'&&selected){openPayment(selected);}
},{signal});
}
-290
View File
@@ -1,290 +0,0 @@
import type { Chapter, Config, DramaDetail, User } from '../api/types';
import { base64JSON, COMMON_KEYS, dramaContext, SDK_FIELD_KEYS, SERVER_EVENTS, TRACKER_APP_ID, trackingPage, trackingText, type Fields } from '../tracking';
import { authMetadata } from './auth';
import { buildSdkUserAgent } from '../api/user-agent';
import { installReportFieldPatch, parseUAClientInfo } from '../analytics/report-fields';
function uaExtras(fields:Fields):Fields {
const parsed=parseUAClientInfo();
const text=(value:unknown)=>String(value??'').trim();
const useful=(value:string)=>value!==''&&value!=='Unknown'&&value!=='0.0.0';
const extra:Fields={};
if(!text(fields.device_brand)&&useful(parsed.device_brand))extra.device_brand=parsed.device_brand;
if(!text(fields.device_model)&&useful(parsed.device_model))extra.device_model=parsed.device_model;
if(!text(fields.system_name)&&useful(parsed.system_name)&&parsed.system_name!=='PC')extra.system_name=parsed.system_name;
if(!text(fields.system_version)&&useful(parsed.system_version))extra.system_version=parsed.system_version;
return extra;
}
interface WebSDK {
init(options:Record<string,unknown>):unknown;
track:Record<string,(payload:Fields)=>unknown>;
getCommonFields(keys?:string[]):Promise<Fields>;
setUid(uid:string):unknown;
setChannel(channel:string):unknown;
notifyPageLoad():void;
flush():Promise<unknown>;
}
declare global {interface Window {WebSDK?:WebSDK;tracker?:SiteTracker}}
const number=(value:unknown)=>Math.max(0,Math.round(Number(value)||0));
const clamp=(value:number)=>Math.min(100,Math.max(0,Math.round(value)));
const channelValue=(value:string)=>/^[a-z\d_.-]{1,50}$/i.test(value)&&!['self','natural','official','channel','0','-','unknown'].includes(value.toLowerCase())?value:'organic';
const uidValue=(user:User|null)=>{const uid=trackingText(user?.userId);return /^[a-z\d_]{4,32}$/i.test(uid)?uid:'';};
const waitLimit=<T>(promise:Promise<T>,fallback:T,ms=1500)=>new Promise<T>(resolve=>{
const timer=setTimeout(()=>resolve(fallback),ms);
promise.then(value=>{clearTimeout(timer);resolve(value);},()=>{clearTimeout(timer);resolve(fallback);});
});
/** SDK adapter: named v1.2.0 events only; business requests own server-side mutations. */
export class SiteTracker {
private sdkReady:Promise<void>;
private config:Config={};
private initialized=false;
private initializing=false;
private queue:{method:string;payload:Fields}[]=[];
private user:User|null=null;
private path='';
private page=trackingPage(location.pathname);
private referrer={key:'direct',name:'直接访问'};
private shown=new Set<string>();
private adShown=new Set<string>();
private popupClosed=new Set<string>();
private observed=new WeakSet<Element>();
private observer?:IntersectionObserver;
private diagnostics=new Set<string>();
private drama?:Fields;
private chapters:Chapter[]=[];
private isVideo=false;
private playback=new WeakMap<HTMLVideoElement,{started:number;elapsed:number;position:number;seekFrom?:number;failed?:boolean}>();
constructor(){
installReportFieldPatch('1.0.0');
try{if(document.referrer){const url=new URL(document.referrer);this.referrer=url.origin===location.origin?trackingPage(url.pathname):{key:'external',name:url.host};}}catch{}
this.sdkReady=this.loadSDK().catch(()=>{});
window.addEventListener('site:route',()=>this.pageView());
window.addEventListener('pagehide',()=>{this.reportProgress();void this.flush();});
document.addEventListener('visibilitychange',()=>{if(document.hidden){this.reportProgress();void this.flush();}else this.observe();});
document.addEventListener('click',event=>this.click(event),true);
document.addEventListener('external-ad:track',event=>{const {element,eventType}=(event as CustomEvent).detail||{};this.externalAd(element,eventType);});
document.addEventListener('dialog-ad:track',event=>{const {element,eventType}=(event as CustomEvent).detail||{};this.advertising(element,eventType);});
document.addEventListener('tracker:refresh',()=>this.observe());
document.addEventListener('close',()=>this.observe(),true);
for(const event of ['play','playing','pause','waiting','timeupdate','seeking','seeked','ended'])document.addEventListener(event,event=>this.videoEvent(event),true);
if(typeof IntersectionObserver!=='undefined')this.observer=new IntersectionObserver(entries=>{
for(const entry of entries)if(entry.isIntersecting){
this.observer?.unobserve(entry.target);
if(this.visible(entry.target))this.impression(entry.target as HTMLElement);else this.observed.delete(entry.target);
}
},{threshold:0.1});
if(typeof MutationObserver!=='undefined')new MutationObserver(()=>this.observe()).observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:['hidden','open','inert','aria-hidden']});
this.pageView();this.observe();
}
private loadSDK():Promise<void>{
if(window.WebSDK)return Promise.resolve();
return new Promise((resolve,reject)=>{
const script=document.createElement('script');script.src='/scripts/web-sdk-v1.2.0.js';script.async=true;
script.onload=()=>window.WebSDK?resolve():reject(new Error('sdk_unavailable'));
script.onerror=()=>{script.remove();reject(new Error('sdk_load_failed'));};
document.head.append(script);
});
}
private diagnose(method:string,result:unknown){
const value=result&&typeof result==='object'?result as Record<string,unknown>:{};
const reason=trackingText(value.reason)||'sdk_exception',key=method+'|'+reason;
if(this.diagnostics.has(key))return;
this.diagnostics.add(key);
const detail={method,reason,...(Array.isArray(value.missingFields)?{missingFields:value.missingFields}:{}),...(Array.isArray(value.typeErrorFields)?{typeErrorFields:value.typeErrorFields}:{})};
try{document.dispatchEvent(new CustomEvent('tracker:diagnostic',{detail}));}catch{}
}
configure(config:Config){
this.config=config;
if(this.initialized||this.initializing)return;
const encryptedConfig=trackingText(config.reportSdkConfig)||process.env.NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG||'';
if(!encryptedConfig)return;
this.initializing=true;
void this.sdkReady.then(async()=>{
if(!window.WebSDK){this.sdkReady=this.loadSDK();await this.sdkReady;}
if(!window.WebSDK)throw new Error('sdk_unavailable');
await window.WebSDK.init({appId:TRACKER_APP_ID,uid:uidValue(this.user),channel:channelValue(authMetadata().channel),encryptedConfig,appVersion:'1.0.0',disableAutoEvents:['app_page_view','page_click']});
this.initialized=true;
this.syncUser(this.user);
for(const event of this.queue.splice(0))this.invoke(event.method,event.payload);
}).catch(()=>this.diagnose('init',{reason:'sdk_initialization_failed'})).finally(()=>{this.initializing=false;});
}
private invoke(method:string,payload:Fields){
if(!this.initialized){
if(this.queue.length>=1000){this.diagnose(method,{reason:'pending_queue_full'});return;}
this.queue.push({method,payload:{...payload}});return;
}
try{
const track=window.WebSDK?.track[method];
if(typeof track!=='function'){this.diagnose(method,{reason:'unknown_event_method'});return;}
void Promise.resolve(track(payload)).then(result=>{
if(result&&typeof result==='object'&&'ok' in result&&result.ok===false)this.diagnose(method,result);
}).catch(()=>this.diagnose(method,{reason:'sdk_exception'}));
}catch{this.diagnose(method,{reason:'sdk_exception'});}
}
syncUser(user:User|null){
this.user=user;
try{if(this.initialized)window.WebSDK?.setUid(uidValue(user));}catch{}
}
pageView(){
const path=location.pathname+location.search;if(path===this.path)return;
if(this.path){this.reportProgress();this.referrer=this.page;try{window.WebSDK?.notifyPageLoad();}catch{}}
this.path=path;this.page=trackingPage(location.pathname);this.drama=undefined;this.chapters=[];
this.shown.clear();this.observed=new WeakSet();this.observer?.disconnect();
this.invoke('appPageView',{...this.pageFields(),user_type:this.user?.vipType?'vip':'normal',referrer_page_key:this.referrer.key,referrer_page_name:this.referrer.name,current_page_key:this.page.key,current_page_name:this.page.name,page_load_time:number(performance.getEntriesByType?.('navigation')[0]?.duration),recommend_trace_id:''});
this.observe();
}
resumePage(){
try{window.WebSDK?.notifyPageLoad();}catch{}
this.path='';this.pageView();
}
private pageFields():Fields{return {page_key:this.page.key,page_name:this.page.name};}
private visible(node:Element){
if(document.hidden||node.closest('[hidden],[inert],[aria-hidden="true"],dialog:not([open])')||!node.getClientRects().length)return false;
const modal=document.querySelector('dialog:modal');
return !modal||modal.contains(node);
}
private observe(){
document.querySelectorAll<HTMLElement>('[data-track-ad="external"],[data-track-ad="external-dialog"],[data-track-item]').forEach(node=>{
if(!this.visible(node)){
if(this.observed.has(node)){this.observer?.unobserve(node);this.observed.delete(node);}
return;
}
if(this.observed.has(node))return;
this.observed.add(node);if(this.observer)this.observer.observe(node);else this.impression(node);
});
}
private item(node:HTMLElement):Fields {
return {...this.pageFields(),item_id:node.dataset.itemId||'',item_type:node.dataset.itemType||'short_drama',item_name:node.dataset.itemName||'',parent_item_id:node.dataset.parentItemId||'0',recommend_trace_id:node.dataset.recommendTraceId||'',recommend_id:node.dataset.recommendId||'',recommend_trace_info:node.dataset.recommendTraceInfo||''};
}
private impression(node:HTMLElement){
if(node.hasAttribute('data-ad-clone'))return;
if(node.hasAttribute('data-track-ad')){this.externalAd(node,'show');return;}
const key='item|'+node.dataset.itemType+'|'+node.dataset.itemId+'|'+(node.dataset.parentItemId||'');
if(!node.dataset.itemId||this.shown.has(key))return;
this.shown.add(key);this.invoke('appPageitemExposure',{...this.item(node),referrer_page_key:this.referrer.key,referrer_page_name:this.referrer.name});
}
externalAd(node:HTMLElement,eventType:string){
if(!node||!['show','click'].includes(eventType)||!node.dataset.adId)return;
if(eventType==='show'&&!this.visible(node)){this.observed.delete(node);return;}
const payload={...this.pageFields(),ad_slot_key:node.dataset.adSlotKey||this.page.key+'_ad',ad_slot_name:node.dataset.adSlotName||this.page.name+'广告位',ad_id:node.dataset.adId,creative_id:node.dataset.creativeId||'',ad_type:node.dataset.adType||'banner'};
const key=this.adKey(node);
if(!this.adShown.has(key)){this.adShown.add(key);this.invoke('adImpression',payload);this.advertising(node,'show');}
if(eventType==='click'&&node.dataset.adClickable==='1'){this.invoke('adClick',payload);this.advertising(node,'click');}
}
private adKey(node:HTMLElement){return this.path+'|'+(node.dataset.adPlacement||'inline')+'|'+(node.dataset.adSlotKey||this.page.key+'_ad')+'|'+node.dataset.adId;}
advertising(node:HTMLElement,eventType:string){
if(!node?.dataset.adId||!['startup-ad','startup-grid'].includes(node.dataset.adPlacement||'')||!['show','click','close'].includes(eventType))return;
const key=this.adKey(node);
if(eventType==='close'){
if(!this.adShown.has(key)||this.popupClosed.has(key))return;
this.popupClosed.add(key);
}else if(eventType==='show')this.popupClosed.delete(key);
this.invoke('advertising',{event_type:eventType,advertising_key:node.dataset.adPlacement==='startup-grid'?'grid_ad':'dialog_ad',advertising_name:node.dataset.adSlotName||'弹窗广告',advertising_id:node.dataset.adId});
}
search(keyword:string,total:number,page:number){if(keyword&&page===1)this.invoke('keywordSearch',{keyword,search_result_count:total,search_content_type:'',search_trace_id:'',search_id:''});}
private click(event:MouseEvent){
const target=event.target instanceof Element?event.target:null;if(!target)return;
if(!target.closest('[data-close],.startup-close'))this.invoke('pageClick',{...this.pageFields(),click_page_x:number(event.clientX),click_page_y:number(event.clientY),click_x_percent:clamp(event.clientX/(innerWidth||1)*100),click_y_percent:clamp(event.clientY/(innerHeight||1)*100),screen_width:number(screen.width),screen_height:number(screen.height),recommend_trace_id:''});
const nav=target.closest<HTMLAnchorElement>('a[data-page],a[data-hg-page],a[data-mobile-route],a[data-info],a[data-track-navigation],a[data-app-download],.entry-routes a,.entry-channels a');
if(nav&&nav.hasAttribute('href'))this.invoke('navigation',{navigation_key:'nav_'+(nav.dataset.trackNavigation||nav.dataset.page||nav.dataset.hgPage||nav.dataset.mobileRoute||nav.dataset.info||(nav.hasAttribute('data-app-download')?'app_download':trackingPage(new URL(nav.href,location.href).pathname).key)).replace(/-/g,'_'),navigation_name:(nav.getAttribute('aria-label')||nav.textContent||'导航').trim().slice(0,255)});
const ad=target.closest<HTMLElement>('[data-track-ad="external"]');if(ad)this.externalAd(ad,'click');
const switcher=target.closest<HTMLElement>('[data-drama-switch]');
if(switcher)this.dramaStatusChange({id:Number(switcher.dataset.targetVideoId),currentEpisode:Number(switcher.dataset.targetEpisode)});
const item=target.closest<HTMLElement>('[data-track-item]');
if(item){
const items=Array.from(item.closest('.cards,.topic-recommendation-grid,.benefit-grid,.hero')?.querySelectorAll('[data-track-item]')||[]),position=items.indexOf(item)+1;
this.invoke('appPageitemClick',{...this.item(item),position:position||1});
const results=item.closest<HTMLElement>('[data-search-results]');
if(results)this.invoke('keywordClick',{keyword:results.dataset.searchKeyword||'',click_item_id:item.dataset.itemId||'',click_item_type_key:'short_drama',click_item_type_name:'短剧',click_position:(number(results.dataset.searchPage)-1)*number(results.dataset.searchPageSize)+(position||1),search_trace_id:''});
}
}
setDrama(drama:DramaDetail,chapter:Chapter|null|undefined,chapters:Chapter[],isVideo=false){
this.drama=dramaContext(drama,chapter);this.chapters=chapters;this.isVideo=isVideo;
if(isVideo)this.drama.video_id=String(drama.id);
if(this.drama.video_id)this.dramaEvent('ep_view');
}
private video(){return document.querySelector<HTMLVideoElement>('#drama-play-page:not([hidden]) .play-screen video');}
private main(video:HTMLVideoElement){return video===this.video()&&video.closest<HTMLElement>('[data-play-mode]')?.dataset.playMode!=='preview';}
private state(video:HTMLVideoElement){let state=this.playback.get(video);if(!state){state={started:0,elapsed:0,position:0};this.playback.set(video,state);}return state;}
private settle(video:HTMLVideoElement){const s=this.state(video);if(s.started){s.elapsed+=Math.max(0,performance.now()-s.started);s.started=0;}}
dramaEvent(behavior:string,video?:HTMLVideoElement,options:{forceProgress?:number;keepDuration?:boolean}={}){
if(!this.drama?.video_id)return;
const s=video?this.state(video):undefined;
const duration=number(video?.duration&&Number.isFinite(video.duration)?video.duration:this.drama.drama_duration);
const elapsed=s?s.elapsed+(s.started?Math.max(0,performance.now()-s.started):0):0;
const names:Record<string,string>={ep_view:'展示',ep_play:'播放',ep_pause:'暂停',ep_forward:'快进',ep_rewind:'快退',ep_complete:'播放完成',ep_share:'分享',ep_failed:'播放失败'};
const metrics={play_duration:number(elapsed/1000),play_progress:options.forceProgress!==undefined?clamp(options.forceProgress):behavior==='ep_complete'?100:duration?clamp((video?.currentTime||0)/duration*100):0,play_speed:number((video?.playbackRate||1)*100)};
if(this.isVideo){const d=this.drama;this.invoke('videoEvent',{...metrics,media_id:d.media_id,video_id:d.video_id,video_title:d.drama_title,video_type_id:d.drama_type_id,video_type_name:d.drama_type_name,video_content_type:'video',recommend_trace_id:d.recommend_trace_id,video_tag_key:d.drama_tag_key,video_tag_name:d.drama_tag_name,video_duration:duration,video_behavior_key:behavior.replace('ep_','video_'),video_behavior_name:behavior==='ep_view'?'视频展示':names[behavior]||behavior});}
else this.invoke('dramaEvent',{...this.drama,...metrics,drama_duration:duration,play_behavior_key:behavior,play_behavior_name:names[behavior]||behavior});
if(s&&!options.keepDuration){s.elapsed=0;if(s.started)s.started=performance.now();}
}
private videoEvent(event:Event){
const video=event.target;if(!(video instanceof HTMLVideoElement)||!this.main(video))return;
const s=this.state(video);
if(event.type==='play')this.dramaEvent('ep_play',video);
if(event.type==='playing'){s.failed=false;if(!s.started)s.started=performance.now();}
if(event.type==='waiting')this.settle(video);
if(event.type==='pause'){const played=s.started||s.elapsed;this.settle(video);if(played&&!video.ended&&!video.seeking)this.dramaEvent('ep_pause',video);}
if(event.type==='timeupdate'&&!video.seeking)s.position=video.currentTime;
if(event.type==='seeking'){this.settle(video);s.seekFrom??=s.position;}
if(event.type==='seeked'&&s.seekFrom!==undefined){const from=s.seekFrom;s.seekFrom=undefined;if(Math.abs(video.currentTime-from)>=0.5)this.dramaEvent(video.currentTime>from?'ep_forward':'ep_rewind',video);s.position=video.currentTime;if(!video.paused&&!video.ended)s.started=performance.now();}
if(event.type==='ended'){
this.settle(video);this.dramaEvent('ep_complete',video,{keepDuration:!this.isVideo});
if(!this.isVideo)this.dramaEvent('ep_play',video,{forceProgress:100});
}
}
playbackFailed(video:HTMLVideoElement){
if(!this.main(video)||!this.drama?.video_id)return;
const state=this.state(video);if(state.failed)return;
state.failed=true;this.settle(video);this.dramaEvent('ep_failed',video);
}
prepareDramaModeChange(){const video=this.video();if(video){this.reportProgress();this.playback.delete(video);}}
private reportProgress(){const video=this.video();if(video&&this.main(video)){const s=this.state(video);if(s.started||s.elapsed){this.settle(video);this.dramaEvent('ep_play',video);if(!video.paused&&!video.ended)s.started=performance.now();}}}
dramaStatusChange(chapter:Chapter,behavior?:string){
if(!this.drama||this.isVideo)return;
const episode=Math.max(1,chapter.currentEpisode||1),previous=Number(this.drama.episode);
const key=behavior||(episode===previous+1?'ep_next':episode===previous-1?'ep_previous':'ep_switch');
const names:Record<string,string>={ep_previous:'上一集',ep_next:'下一集',ep_switch:'切换剧集',ep_unlock:'解锁',ep_auto_play:'自动播放'};
if(!names[key]||(!behavior&&String(chapter.id)===this.drama.video_id))return;
this.invoke('dramaStatusChange',{media_id:this.drama.media_id,drama_id:this.drama.drama_id,drama_title:this.drama.drama_title,episode,video_id:String(chapter.id),pre_episode:previous,drama_behavior_key:key,drama_behavior_name:names[key]});
}
async getCommonFields(){await this.sdkReady;try{return await window.WebSDK?.getCommonFields()||{};}catch{return {};}}
async requestHeaders(path:string,method:string,body:unknown):Promise<Record<string,string>>{
// This request supplies the SDK configuration; it must not wait for that SDK to load first.
if(path==='config/info'&&!this.initialized)return {};
const input=body&&typeof body==='object'&&!(body instanceof FormData)?body as Record<string,unknown>:{};
const snapshot=this.drama?{...this.drama}:undefined,chapters=this.chapters.slice(),isVideo=this.isVideo;
return waitLimit((async()=>{
const fields=await this.getCommonFields();
// 请求头 DevID 继续用登录态设备。X-SDK-Common 保留 SDK 原值,SDK 没有设备号时才用登录态补上。
const loginDevice=authMetadata().devID||'';
const sdkDevice=trackingText(fields.device_id)||loginDevice;
const headers:Record<string,string>={
'X-User-Agent':buildSdkUserAgent({...fields,...uaExtras(fields),device_id:loginDevice||sdkDevice}),
'X-SDK-Common':base64JSON(Object.fromEntries(SDK_FIELD_KEYS.filter(key=>Object.hasOwn(fields,key)||key==='device_id'&&!!sdkDevice).map(key=>[key,key==='device_id'?fields.device_id||sdkDevice:fields[key]]))),
};
const event=method==='POST'?SERVER_EVENTS[path]:'';
if(!event||fields.app_id!==TRACKER_APP_ID||!sdkDevice||path==='comment/send'&&![10,11].includes(Number(input.objectType)))return headers;
const chapterId=Number(input.chapterId||(Number(input.objectType)===11?input.objectId:0));
const id=trackingText(input.playletId||input.id||(Number(input.objectType)===10?input.objectId:0)||(!isVideo?snapshot?.drama_id:''));
if(!id||id==='0')return headers;
const same=id===snapshot?.drama_id&&!isVideo,chapter=same?chapters.find(ch=>ch.id===chapterId):undefined;
const drama={media_id:same?snapshot?.media_id||'':'',drama_id:id,episode:chapter?.currentEpisode||(same?number(snapshot?.episode):1)||1,video_id:chapterId?String(chapterId):same?snapshot?.video_id||'':'',recommend_trace_id:same?snapshot?.recommend_trace_id||'':''};
const requestId=crypto.randomUUID().replace(/-/g,'');
const common=Object.fromEntries(COMMON_KEYS.map(key=>[key,key==='client_ts'?Number(fields[key]):fields[key]??'']));
return {...headers,'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':event,'X-DC-Request-Context':base64JSON({v:1,request_id:requestId,event_name:event,common,drama},true)};
})(),{});
}
async flush(){try{await Promise.resolve();if(this.initialized)await window.WebSDK?.flush();}catch{}}
}
export function startTracker(config?:Config){
if(typeof window==='undefined')return;
window.tracker??=new SiteTracker();if(config)window.tracker.configure(config);
return window.tracker;
}
+7 -22
View File
@@ -1,9 +1,10 @@
import { ApiError, decodeEnvelope, EXPIRED_CODES } from "../api/protocol";
import type { User } from "../api/types";
import { authMetadata, readBearerToken, writeBearerToken } from "./auth";
import { visitorHeaders } from "./visitor-headers";
export type Query = Record<string, string | number | boolean | undefined>;
const PUBLIC = /^(?:config\/|navigation\/|theme\/|applist\/|recharge\/|translation\/|playlet\/home$|playlet\/tag\/options$)/;
const PUBLIC = /^(?:config\/|navigation\/|theme\/|translation\/|playlet\/home$|playlet\/tag\/options$)/;
const PUBLIC_CONTENT = new Set(["playlet/all/list","playlet/operate/list","playlet/home-config/list","playlet/hot/rank/list","playlet/topic/list","playlet/topic/detail","playlet/tag/list","playlet/guess-like/list","playlet/hot-like"]);
const tokenOf = (user: User | null | undefined) => typeof user?.token === 'string' ? user.token.trim() : '';
@@ -52,13 +53,13 @@ export class ApiClient extends EventTarget {
Object.entries(query).forEach(([key,value])=>{if(value!==undefined && value!=="")params.set(key,String(value));});
const multipart = body instanceof FormData;
const method=body===undefined?'GET':'POST';
const tracking=typeof window==='undefined'?{}:await window.tracker?.requestHeaders(path,method,body)||{};
const identity=typeof window==='undefined'?{}:visitorHeaders(authMetadata().devID);
const response = await fetch(`/api/app/${path}${params.size?"?"+params:""}`, {
method, credentials:"include", cache:"no-store", signal,
headers: {
Accept:"application/json",
...tracking,
// Authorization must win over any tracking header collision.
...identity,
// Authorization must win over any identity header collision.
...(this.bearer ? {Authorization: this.bearer} : {}),
...(body===undefined||multipart?{}:{"Content-Type":"application/json"}),
},
@@ -67,7 +68,7 @@ export class ApiClient extends EventTarget {
const result = decodeEnvelope<T>(await response.json());
if (!response.ok || result.code!==200) {
const mayConfirm = options?.confirmSession !== false;
if (mayConfirm && (EXPIRED_CODES.has(result.code) || response.status===401) && !/^auth\/(login|register|resetPassword)$/.test(path) && version === this.version) {
if (mayConfirm && (EXPIRED_CODES.has(result.code) || response.status===401) && version === this.version) {
const registered = response.headers.get('X-Auth-State') === 'login-required' || this.user?.isRegister || this.user?.type === 1;
// Only an identity check may sign the header out. A single list or action
// failure keeps the visible account until getInfo confirms the cookie is gone.
@@ -192,7 +193,6 @@ export class ApiClient extends EventTarget {
this.rememberBearer(user);
// Never expose bearer on the public user snapshot (matches BFF stripping token from JSON).
this.user = user ? {...user, token: undefined} : null;
if(typeof window!=='undefined')window.tracker?.syncUser(this.user);
if (user) { this.requiresLogin = false; this.prompted = false; }
this.dispatchEvent(new Event('userchange'));
}
@@ -203,7 +203,6 @@ export class ApiClient extends EventTarget {
if (!user) {
this.version++;
this.user = null;
if(typeof window!=='undefined')window.tracker?.syncUser(null);
this.dispatchEvent(new Event('userchange'));
return;
}
@@ -224,7 +223,6 @@ export class ApiClient extends EventTarget {
this.clearBearer();
this.version++;
this.user = null;
if(typeof window!=='undefined')window.tracker?.syncUser(null);
this.dispatchEvent(new Event('userchange'));
}
async get<T>(path: string, query: Query = {}, signal?: AbortSignal): Promise<T> {
@@ -238,20 +236,7 @@ export class ApiClient extends EventTarget {
catch { return; }
}
async post<T>(path: string, body: unknown): Promise<T> {
if (['auth/login','auth/register'].includes(path)) {
if (path === 'auth/register' && !this.user && !this.requiresLogin) await this.ensureSession();
// Binding must send the current guest Authorization; without it Go creates a disconnected account.
if (path === 'auth/register' && !this.bearer) await this.initializeSession();
const payload = {...authMetadata(), ...body as object};
return this.authenticate(async () => {
const data = await this.raw<User>(path,{},payload);
// register issues a new JWT and invalidates the guest token; prefer response token.
const nextToken = tokenOf(data) || this.bearer;
this.setUser({...data, ...(nextToken ? {token: nextToken} : {})});
return data as T;
});
}
if(!/^auth\/(guest|resetPassword)$/.test(path))await this.ensureSession();
if(path!=='auth/guest')await this.ensureSession();
return this.raw(path,{},body);
}
async refreshUser(options?: {soft?: boolean}) {
+9 -90
View File
@@ -5,7 +5,6 @@ import type { SiteContext } from "./context";
import { recordList, safeUrl } from "../api/protocol";
import { activeBanners, card, empty, escape, hero } from "./render";
import { configureSite, copyRoadAddress, downloadUrl } from "./config";
import { accountMenuMarkup, bindAccountUI, openAuth, renderMember, renderWallet, type AccountMenuStats } from "./account";
import { renderPlayer } from "./player";
import { contentPath, navigationSection, paginationPath, parseSitePath, searchPath, samePageAddress, topNavigationSection } from "../routing";
import { bindPaginationJumps, navigateTo, ROUTE_EVENT } from "./navigation";
@@ -14,13 +13,11 @@ import { createDownloadQr, shouldUseDownloadDialog } from "./download";
import { processImage, startImageLoading } from "./images";
import { bindHeroSwipe } from "./hero-swipe";
import { bindPromotionMarquee } from './promo-marquee';
import { bindHeaderHistory } from './header-history';
import { scrollToAppCategory } from "./apps";
import { renderFeedbackPage } from "./feedback";
import { positionSearchDiscovery, searchPicksMarkup, searchSuggestionsMarkup } from "./search";
import { infoContactSection } from "../info-pages";
import { buildPageHead, clientSeoEnvironment, resolveSeoEnvironment, type SeoBootstrap, type SeoContent, type SeoEnvironment } from '../seo';
import { applyPageHead } from './seo';
import { SITE_NAME, brandText } from '../brand';
import type { PageBootstrap, SiteSharedData } from '../page-data';
declare global { interface Window { __dramaApiApp?: Application } }
@@ -57,7 +54,6 @@ export class Application implements SiteContext {
private syncHead(){applyPageHead(buildPageHead(location.pathname,location.search,this.seoEnvironment,this.seoContent));}
current(){return !this.signal.aborted;}
notify(message:string){const el=$('#toast');el.textContent=message;el.classList.remove('hidden');clearTimeout(this.toastTimer);this.toastTimer=setTimeout(()=>el.classList.add('hidden'),2600);}
openAuth(mode='login'){openAuth(mode);}
navigate(path:string){navigateTo(path);}
async init(){
@@ -68,15 +64,12 @@ export class Application implements SiteContext {
if(session?.user)this.api.seedSession(session.user);
else if(session?.state==='login-required')this.api.seedSession(null);
startImageLoading();
this.bindEvents();this.startupDialogs=startStartupDialogs();this.createAccountMenu();bindAccountUI(this);
bindHeaderHistory(this);
this.bindEvents();this.startupDialogs=startStartupDialogs();
window.addEventListener('pagehide',event=>this.hidePage(event.persisted));
window.addEventListener('pageshow',event=>{if(event.persisted)this.restorePage();});
this.api.addEventListener('userchange',()=>{this.updateAccount();void this.refreshAccountStats();});
this.api.addEventListener('expired',()=>{this.updateAccount();this.openAuth();});
// Match jha-1048 GuestAuthBootstrap: establish guest (+ soft getInfo) immediately on enter,
// in parallel with shared config, before client routes touch private APIs.
const bootSession=this.api.bootstrapGuestSession().then(()=>this.updateAccount()).catch(error=>this.notify(error.message));
const bootSession=this.api.bootstrapGuestSession().catch(error=>this.notify(error.message));
await Promise.all([bootSession,this.initializeShared()]);
this.startFloatingTools();
await this.route();
@@ -87,8 +80,6 @@ export class Application implements SiteContext {
if(shared){
this.footerContacts=recordList<FooterContact>(shared.footer.footerInfo?.contactList);
await this.applyConfig(shared.config);
// SDK configuration is fetched separately and is never serialized into page HTML.
if(shared.trackerConfigured)void this.api.get<Config>('config/info').then(config=>window.tracker?.configure(config)).catch(()=>{});
}else{
this.api.get<Config>('config/info').then(config=>this.applyConfig(config)).catch(error=>this.notify(error.message));
void this.loadFooter();
@@ -99,7 +90,6 @@ export class Application implements SiteContext {
this.config=config;this.startupDialogs?.setAdvertisements(config.advitise?.dialogList||[],config.advitise?.nineGridList||[]);
this.seoEnvironment=clientSeoEnvironment(this.seoEnvironment,config);
if(this.routeKey)this.syncHead();
window.tracker?.configure(config);
this.refreshInfoContacts();await configureSite(config);
}
private refreshInfoContacts(){
@@ -113,14 +103,13 @@ export class Application implements SiteContext {
}
private hidePage(persisted:boolean){
const route=parseSitePath(location.pathname,location.search);
const interactive=route?.group==='detail'||route?.group==='play'||route?.group==='member'||route?.group==='info'&&['deposit','settings','user-center','feedback'].includes(route.key);
const interactive=route?.group==='detail'||route?.group==='play';
if(!persisted||interactive||document.querySelector('dialog[open]')){this.disposePage();return;}
this.stopHeroAutoplay();this.heroSequence++;this.feature=this.renderedFeature;this.disposeHeroFade?.();
}
private restorePage(){
if(this.signal.aborted){window.location.reload();return;}
this.restartHeroAutoplay();
window.tracker?.resumePage();
// Restore the document immediately, then refresh the header identity using current cookies.
void this.api.revalidateSession().catch(()=>{});
}
@@ -191,25 +180,10 @@ export class Application implements SiteContext {
}
// Error HTML stays visible too; missing initial data must never trigger a client page fetch.
if(!page||!samePageAddress(page.path,this.routeKey))return;
const pageHref=(value:number)=>paginationPath(this.routeKey,value);
if(page.panel==='player'&&page.player){
this.disposePlayer=await renderPlayer(this,page.player.detail.id,route.group==='play'?Number(route.value):undefined,false,{initial:page.player,preserveMarkup:true,page:route.page||1,commentSort:new URLSearchParams(location.search).get('commentSort')==='1'?1:2,pageHref:(value,sort)=>paginationPath(contentPath(route.group==='play'?'play':'detail',page.player!.detail,route.group==='play'?Number(route.value):undefined)+location.search,value,{commentSort:sort===2?null:sort})});
this.disposePlayer=await renderPlayer(this,page.player.detail.id,route.group==='play'?Number(route.value):undefined,false,{initial:page.player,preserveMarkup:true});
}else if(page.panel==='search'&&page.search){
$('#query').setAttribute('value',page.search.keyword);
window.tracker?.search(page.search.keyword,page.search.total,page.search.page);
}else if(page.panel==='info'){
if(page.info?.member)await renderMember(this,page.info.key,route.page||1,pageHref,page.info.member,true);
else if(page.info?.wallet)await renderWallet(this,route.value==='vip',route.page||1,pageHref,page.info.wallet,true);
else if(page.info?.feedback)renderFeedbackPage(this,true);
else if(page.session?.state!=='login-required'){
// Cold SSR only painted "正在准备账号"; guest bootstrap already finished in init().
if(route.group==='member'||(route.group==='info'&&['settings','user-center'].includes(route.key))){
const tab=route.group==='member'?route.key:route.key==='settings'?'settings':'center';
await renderMember(this,tab,route.page||1,pageHref);
}else if(route.group==='info'&&route.key==='deposit'){
await renderWallet(this,route.value==='vip',route.page||1,pageHref);
}
}
}
}
@@ -219,13 +193,9 @@ export class Application implements SiteContext {
document.addEventListener('click',event=>{void (async()=>{
const el=(event.target as Element).closest<HTMLElement>('button,a');if(!el)return;
if(el.dataset.close){(document.getElementById(el.dataset.close) as HTMLDialogElement)?.close();return;}
if(el.dataset.authMode){this.openAuth(el.dataset.authMode);return;}
if(el.dataset.memberRoute){this.navigate('/member/'+el.dataset.memberRoute+'/');return;}
if(el.dataset.sort){const sort=Number(el.dataset.sort);this.navigate(paginationPath(location.pathname+location.search,1,{sort:sort===1?null:sort}));return;}
if(el.dataset.appCategory){scrollToAppCategory($('#channel-content'),el.dataset.appCategory);return;}
if(el.dataset.feature!==undefined||el.dataset.heroStep){this.selectHero(el.dataset.feature!==undefined?Number(el.dataset.feature):this.feature+Number(el.dataset.heroStep));this.restartHeroAutoplay();return;}
if(el.dataset.apiAction==='retry'){window.location.reload();return;}
if(el.dataset.accountAction==='coins'){this.navigate('/info/deposit/');return;}
if(el.dataset.roadCopy){await copyRoadAddress(el);return;}
if(el.hasAttribute('data-app-download')){
if(!downloadUrl(this.config)){event.preventDefault();return;}
@@ -234,7 +204,6 @@ export class Application implements SiteContext {
}
if(el.id==='app-copy-link'){const url=downloadUrl(this.config);if(!url)throw new Error('暂未配置下载地址');await navigator.clipboard.writeText(url);$('#app-copy-status').textContent='链接已复制';return;}
if(el.id==='app-download-close'){($<HTMLDialogElement>('#app-download-dialog')).close();return;}
if(el.id==='floating-history'){this.navigate('/member/history/');return;}
if(el.id==='back-to-top'){window.scrollTo({top:0,behavior:'smooth'});return;}
if(el.id==='search-panel-close'){this.closeSearchDiscovery();return;}
if(el.id==='search-clear'){localStorage.removeItem('91-search-history');await this.searchDiscovery();return;}
@@ -257,55 +226,7 @@ export class Application implements SiteContext {
const url=downloadUrl(this.config);if(!url)return;
const dialog=$<HTMLDialogElement>('#app-download-dialog');if(!dialog.open)dialog.showModal();
const qr=await createDownloadQr(url);
if(dialog.open&&downloadUrl(this.config)===url){const image=$<HTMLImageElement>('#app-platform-qr');image.src=qr;image.alt='禁果短剧 App 下载二维码';}
}
private accountStats?:AccountMenuStats;
private accountStatsUser?:number;
private accountStatsSeq=0;
private createAccountMenu(){
const panel=document.createElement('div');panel.id='profile-popover';panel.hidden=true;panel.setAttribute('aria-label','账户菜单');document.body.append(panel);
const entry=$('#account-entry');
const close=()=>{panel.hidden=true;entry.setAttribute('aria-expanded','false');};
const position=()=>{const rect=entry.getBoundingClientRect();panel.style.right=Math.max(12,innerWidth-rect.right)+'px';panel.style.top=Math.min(rect.bottom+12,innerHeight-120)+'px';};
entry.addEventListener('click',()=>{
if(!panel.hidden){close();return;}
this.updateAccount();position();panel.hidden=false;entry.setAttribute('aria-expanded','true');
void this.refreshAccountStats();
});
panel.addEventListener('click',event=>{if((event.target as Element).closest('a,button'))close();});
document.addEventListener('click',event=>{if(!entry.contains(event.target as Node)&&!panel.contains(event.target as Node))close();});
document.addEventListener('keydown',event=>{
if(event.key==='Escape'&&!panel.hidden){close();entry.focus();}
if(event.key==='ArrowDown'&&document.activeElement===entry&&!panel.hidden){event.preventDefault();panel.querySelector<HTMLElement>('a,button')?.focus();}
});
document.addEventListener('focusin',event=>{if(!panel.hidden&&!entry.contains(event.target as Node)&&!panel.contains(event.target as Node))close();});
window.addEventListener('resize',()=>{if(!panel.hidden)position();});
window.addEventListener('scroll',()=>close(),{passive:true});
this.updateAccount();
}
private async refreshAccountStats(){
const userId=this.api.user?.userId;if(!userId)return;
const seq=++this.accountStatsSeq;
// Menu counts are decorative: never confirmSession/getInfo from a failed stats read.
const stats=await this.api.tryGet<AccountMenuStats>('user/center/data',{},this.signal);
if(!stats||seq!==this.accountStatsSeq||this.signal.aborted||this.api.user?.userId!==userId)return;
this.accountStats=stats;this.accountStatsUser=userId;
const panel=document.getElementById('profile-popover');if(!panel)return;
const write=(id:string,value?:number)=>{const el=panel.querySelector('#'+id);if(el&&typeof value==='number'&&Number.isFinite(value)&&value>=0)el.textContent=Math.floor(value).toLocaleString('zh-CN');};
write('profile-saved-count',stats.collectCount);write('profile-likes-count',stats.likeCount);write('profile-coin-count',this.api.user?.recharge);
}
private updateAccount(){
const user=this.api.user;const name=user?.nickName||user?.accountNo||'游客';
const entry=$('#account-entry');entry.setAttribute('aria-label',`${name},展开账户菜单`);
entry.querySelector('strong')!.textContent=name;$('#account-status').textContent=user?(user.isRegister||user.type===1?'已登录':'游客账号'):'未登录';
const avatarUrl=safeUrl(user?.avatar)||'/icon.svg';
const avatar=$<HTMLImageElement>('#account-entry img');void processImage(avatarUrl,avatar,'img','/icon.svg');
// Keep the comment composer in sync with the signed-in/guest header avatar.
document.querySelectorAll<HTMLImageElement>('.play-compose-avatar').forEach(node=>void processImage(avatarUrl,node,'img','/icon.svg'));
$('.coin-value').textContent=user?String(user.recharge||0):'—';$('.coin-entry').setAttribute('aria-label',user?`金币余额 ${user.recharge||0}`:'金币余额,登录后查看');
const panel=document.getElementById('profile-popover');if(!panel)return;
if(user?.userId!==this.accountStatsUser)this.accountStats=undefined;
panel.innerHTML=accountMenuMarkup(user,this.accountStats);
if(dialog.open&&downloadUrl(this.config)===url){const image=$<HTMLImageElement>('#app-platform-qr');image.src=qr;image.alt=`${SITE_NAME} App 下载二维码`;}
}
private async loadDiscovery(){
@@ -343,13 +264,11 @@ export class Application implements SiteContext {
const data=await this.api.get<FooterInfoResponse>('config/footer/info');
const footer=data?.footerInfo;if(!footer)return;
this.footerContacts=recordList<FooterContact>(footer.contactList);this.refreshInfoContacts();
const brand=footer.brand?.title?.trim()||'';
const brand=brandText(footer.brand?.title)||SITE_NAME;
const titleNode=document.querySelector('.site-footer-title');
if(titleNode){titleNode.textContent=brand;if(brand){const mark=document.createElement('i');mark.setAttribute('aria-hidden','true');titleNode.append(mark);}}
if(titleNode){titleNode.textContent=brand;const mark=document.createElement('i');mark.setAttribute('aria-hidden','true');titleNode.append(mark);}
const slogan=document.querySelector('.hg-footer-original-slogan');
if(slogan)slogan.textContent=footer.brand?.content?.trim()||'';
const copyright=document.querySelector('.site-footer-copyright');
if(copyright)copyright.textContent=`© ${new Date().getFullYear()}${brand?` ${brand} `:''}All rights reserved.`;
if(slogan)slogan.textContent=brandText(footer.brand?.content);
}catch{}
}
}
-56
View File
@@ -1,56 +0,0 @@
import type { AppCategory, AppData, AppItem } from '../api/types';
import { recordList, safeUrl } from '../api/protocol';
import type { ApiClient } from './api';
import { empty, escape, imageAttributes } from './render';
function appItems(value:unknown):AppItem[]{
return recordList<AppItem>(value).filter(item=>typeof item.name==='string'&&item.name.trim());
}
/** The initial response may include only the selected category's applications. */
export async function loadAppCategories(api:Pick<ApiClient,'get'>,signal:AbortSignal):Promise<AppCategory[]>{
const data=await api.get<AppData>('applist/list',{categoryId:0},signal);
if(signal.aborted)return [];
const current=recordList<AppCategory>([data?.category])[0];
const categories=recordList<AppCategory>(data?.categoryList);
if(current&&!categories.some(category=>Number(category.id)===Number(current.id)))categories.push(current);
const seen=new Set<number>();
const ordered=categories.filter(category=>{
const id=Number(category.id);
if(!Number.isSafeInteger(id)||id<0||seen.has(id))return false;
seen.add(id);return true;
});
const results=await Promise.allSettled(ordered.map(async category=>{
const id=Number(category.id);
const isCurrent=current&&Number(current.id)===id&&Array.isArray(current.appList);
let detail=isCurrent?current:category;
// Empty lists in the category index may be defaults; verify their detail before hiding them.
if(!Array.isArray(detail.appList)||!isCurrent&&!appItems(detail.appList).length){
const response=await api.get<AppData>('applist/list',{categoryId:id},signal);
detail=recordList<AppCategory>([response?.category,...recordList<AppCategory>(response?.categoryList)]).find(item=>Number(item.id)===id&&Array.isArray(item.appList))||category;
}
return {...category,...detail,id,appList:appItems(detail.appList)};
}));
if(signal.aborted)return [];
const available=results.flatMap(result=>result.status==='fulfilled'&&result.value.appList.length?[result.value]:[]);
if(!available.length){const failure=results.find(result=>result.status==='rejected');if(failure?.status==='rejected')throw failure.reason;}
return available;
}
export function renderAppDirectory(categories:AppCategory[]):string{
const visible=categories.map(category=>({...category,appList:appItems(category.appList)})).filter(category=>category.appList.length);
if(!visible.length)return empty('暂无福利应用');
return `<div class="benefits-directory"><nav class="channel-tabs" aria-label="福利应用分类">${visible.map((category,index)=>`<button type="button" data-app-category="${category.id}" aria-controls="apps-${category.id}" aria-pressed="${index===0}">${escape(category.title)}</button>`).join('')}</nav>${visible.map(category=>`<section class="benefit-section" id="apps-${category.id}" aria-labelledby="apps-title-${category.id}"><header class="benefit-heading"><div><h2 id="apps-title-${category.id}">${escape(category.title)}</h2>${category.intro?`<p>${escape(category.intro)}</p>`:''}</div></header><div class="benefit-grid" style="--compact-app-columns:${Math.max(1,Math.ceil(Math.min(category.appList.length,20)/2))}">${category.appList.map(app=>`<a class="benefit-card" ${appTracking(app,category.id)} href="${escape(safeUrl(app.url)||'/apps/')}" target="_blank" rel="${safeUrl(app.url)?"sponsored nofollow noopener noreferrer":"noopener noreferrer"}"><span class="benefit-icon"><img ${imageAttributes(app.logo)} alt="${escape(app.name.trim())}应用图标" loading="lazy"></span><span class="benefit-copy"><strong>${escape(app.name)}</strong><small>${escape(app.intro||app.description)}</small></span></a>`).join('')}</div></section>`).join('')}</div>`;
}
function appTracking(app:AppItem,categoryId:number):string{
if(app.advitiseId)return `data-track-ad="external" data-ad-id="${escape(app.advitiseId)}" data-ad-clickable="${safeUrl(app.url)?1:0}" data-ad-slot-key="${escape(app.location||'apps_'+categoryId)}" data-ad-slot-name="福利应用" data-ad-type="icon"`;
return app.id?`data-track-item data-item-id="${escape(app.id)}" data-item-name="${escape(app.name)}" data-item-type="app" data-parent-item-id="${escape(categoryId)}"`:'';
}
export function scrollToAppCategory(root:HTMLElement,id:string){
if(!/^\d+$/.test(id))return;
const section=root.querySelector<HTMLElement>(`#apps-${id}`);if(!section)return;
root.querySelectorAll<HTMLButtonElement>('[data-app-category]').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.appCategory===id)));
section.scrollIntoView({behavior:window.matchMedia?.('(prefers-reduced-motion: reduce)').matches?'auto':'smooth',block:'start'});
}
+6 -15
View File
@@ -3,6 +3,7 @@ import type { Config } from "../api/types";
import { escape } from "./render";
import { downloadUrl } from "../app-download";
import { configureDownloadLinks } from "./download";
import { BUSINESS_CONTACT } from "../contact";
export { downloadUrl };
export function configureSite(config: Config) {
@@ -12,19 +13,14 @@ export function configureSite(config: Config) {
const input = document.querySelector<HTMLInputElement>("#app-platform-link");
if(input)input.value=download;
document.querySelector(".app-download-demo")?.remove();
const caption=document.getElementById("app-platform-caption");if(caption)caption.textContent=download?"扫码下载安装 禁果短剧 App":"暂未配置 App 下载地址";
const channels: Record<string,unknown> = {"官方推特":settings.twitterAddr,"官方TG群":settings.tgGroup,"官方联系":settings.telegram,"Telegram":settings.telegram,"官方社群":settings.tgGroup,"X":settings.twitterAddr};
document.querySelectorAll<HTMLAnchorElement>('a[target="_blank"]:not([data-track-ad])').forEach(link=>{
const label=link.getAttribute('aria-label')||link.textContent||"";
for(const [key,url] of Object.entries(channels))if(label.includes(key)&&safeUrl(url)){link.href=safeUrl(url);link.rel='sponsored nofollow noopener noreferrer';if(label.startsWith('官方联系:'))link.textContent='官方联系:'+safeUrl(url);break;}
});
const caption=document.getElementById("app-platform-caption");if(caption)caption.textContent=download?"扫码下载安装 黄果短剧 App":"暂未配置 App 下载地址";
}
export function homeRoad(config: Config, pageUrl = '/home-road/'): string {
const values={...config.newAddressInfo,...config.config};
const icons={rocket:'3651a2618990bf95',link:'ba17d56edfb047e2',shield:'d001d1cc6c2f0ce6',help:'d59d2d7390d88f08',devices:'d4ebee2aa88ba9a2',iphone:'a0c6451fadcb7738',android:'8b1153736cc5dca1',desktop:'11bae9823678ebdc',email:'2a8580c42090f36c',tg:'9a5b6e9db9de76b8',contact:'228a0af1662081ac',twitter:'72f702ca215f597d'};
type Icon=keyof typeof icons;
const iconLabels:Record<Icon,string>={rocket:'备用访问地址',link:'永久访问入口',shield:'官方联系',help:'访问帮助',devices:'App 下载与设备',iphone:'iPhone 设备',android:'Android 设备',desktop:'电脑设备',email:'联系邮箱',tg:'Telegram 官方交流群',contact:'官方联系',twitter:'X 官方社区'};
const iconLabels:Record<Icon,string>={rocket:'备用访问地址',link:'永久访问入口',shield:'官方联系',help:'访问帮助',devices:'App 下载与设备',iphone:'iPhone 设备',android:'Android 设备',desktop:'电脑设备',email:'联系邮箱',tg:'Telegram',contact:'官方联系',twitter:'X 官方社区'};
const icon=(key:Icon,size=30)=>`<img${size===30?' class="return-section-icon"':''} src="/assets/${icons[key]}.svg" width="${size}" height="${size}" alt="${iconLabels[key]}图标" aria-hidden="true">`;
const heading=(title:string,key?:Icon,note='')=>`<div class="return-section-head">${key?icon(key):''}<h2>${title}</h2>${note?`<span>${note}</span>`:''}</div>`;
const external=(url:string,label:string)=>`<a href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer">${escape(label)} <span aria-hidden="true">↗</span></a>`;
@@ -35,18 +31,13 @@ export function homeRoad(config: Config, pageUrl = '/home-road/'): string {
const backups=[...new Set(configured.length?configured:navigation)].slice(0,3);
const forever=safeUrl(values.foreverUrlAddr),primary=backups[0]||forever;
const permanent=[['永久入口',forever],['主站入口',primary],['备用中转',backups[1]]].filter((row,index,rows)=>row[1]&&rows.findIndex(other=>other[1]===row[1])===index);
const contacts:string[]=[];
const contact=(key:Icon,title:string,description:string,url:string,app=false)=>`<a class="return-contact-card" href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer"${app?' data-app-download data-app-download-entry':''}><span class="return-contact-icon">${icon(key,28)}</span><span><strong>${title}</strong><small>${description}</small></span><span class="return-contact-arrow" aria-hidden="true">↗</span></a>`;
const email=[values.emailAddr,values.email].find(value=>typeof value==='string'&&/^[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+$/.test(value.trim()));
if(typeof email==='string')contacts.push(`<button type="button" class="return-contact-card" data-road-copy="${escape(email.trim())}"><span class="return-contact-icon">${icon('email',28)}</span><span><strong>联系邮箱</strong><small>复制邮箱地址</small></span><span class="return-contact-arrow" aria-hidden="true">↗</span></button>`);
const channels:[Icon,string,string,unknown][]=[['tg','官方交流群','查看更新与入口通知',values.tgGroup],['contact','官方联系','联系官方团队',values.telegram],['twitter','X 社区','关注公告与动态',values.twitterAddr]];
for(const [key,title,description,value] of channels){const url=safeUrl(value);if(url)contacts.push(contact(key,title,description,url));}
const app=downloadUrl(config);if(app)contacts.push(contact('devices','App 下载','下载客户端,随时追剧',app,true));
// 保持联系只保留唯一的商务合作入口(Telegram @jbd125),不再展示邮箱 / 交流群 / X。
const contacts=[`<a class="return-contact-card" href="${escape(BUSINESS_CONTACT.href)}" target="_blank" rel="nofollow noopener noreferrer"><span class="return-contact-icon">${icon('tg',28)}</span><span><strong>${BUSINESS_CONTACT.label}</strong><small>Telegram ${BUSINESS_CONTACT.handle}</small></span><span class="return-contact-arrow" aria-hidden="true">↗</span></a>`];
return `<div class="return-home-directory">
<section class="return-hero"><div class="return-hero-copy"><span class="return-eyebrow">保存入口 · 随时回来</span><h2>把回家的路,留在身边</h2><p>收藏常用入口,访问不畅时换一条路线。</p></div><div class="return-actions">${primary?external(primary,'进入主站'):''}${copy(safeUrl(pageUrl)||'/home-road/','复制本页地址')}</div></section>
<section class="return-section">${heading('备用访问地址','rocket',`${backups.length} 条备用入口`)}${backups.length?`<div class="return-address-grid">${backups.map((url,index)=>`<article class="return-address-card"><span class="return-card-label">备用地址 ${String(index+1).padStart(2,'0')}</span><strong>${escape(display(url))}</strong><div class="return-card-actions">${external(url,'立即访问')}${copy(url)}</div></article>`).join('')}</div>`:'<p class="return-source-note">暂未配置备用访问地址</p>'}</section>
<section class="return-section">${heading('永久访问入口','link','建议收藏')}${permanent.length?`<div class="return-permanent">${permanent.map(([label,url])=>`<div class="return-permanent-row"><span>${label}</span>${external(url,display(url))}${copy(url)}</div>`).join('')}</div>`:'<p class="return-source-note">暂未配置永久访问入口</p>'}</section>
<section class="return-section">${heading('保持联系','shield')}${contacts.length?`<div class="return-contact-grid">${contacts.join('')}</div>`:'<p class="return-source-note">暂未配置官方联系方式</p>'}</section>
<section class="return-section">${heading('保持联系','shield')}${contacts.length?`<div class="return-contact-grid">${contacts.join('')}</div>`:''}</section>
<section class="return-section">${heading('遇到访问问题?','help')}<div class="return-help-grid">${[['01','页面打不开','刷新页面或切换网络,再试其他入口。'],['02','跳转不正常','核对域名拼写,从已保存的入口重新打开。'],['03','地址已失效','查看本页或社区发布的地址更新。']].map(([number,title,description])=>`<article><span>${number}</span><h3>${title}</h3><p>${description}</p></article>`).join('')}</div></section>
<section class="return-section">${heading('保存到你的设备','devices')}<div class="return-help-grid">${([['iphone','iPhone 用户','在 Safari 中打开,通过分享菜单添加书签。'],['android','Android 用户','在浏览器菜单中选择收藏或添加书签。'],['desktop','电脑 / 桌面端','使用 Ctrl+D;Mac 使用 Command+D 保存书签。']] as [Icon,string,string][]).map(([key,title,description])=>`<article><h3 class="return-device-heading">${icon(key,28)}${title}</h3><p>${description}</p></article>`).join('')}</div></section>
<section class="return-section return-faq">${heading('常见问题')}<details><summary>应该保存哪个地址?</summary><p>收藏本页,并额外保留一个备用入口。</p></details><details><summary>复制地址后怎么保存?</summary><p>粘贴到备忘录,或在浏览器中打开后添加书签。</p></details><details><summary>备用地址也打不开怎么办?</summary><p>更换网络后重试,也可通过社区查看新发布的入口。</p></details></section>
-1
View File
@@ -11,6 +11,5 @@ export interface SiteContext {
current(): boolean;
route(): Promise<void>;
navigate(path: string): void;
openAuth(mode?: string): void;
updateSeo?(content:SeoContent):void;
}
+55 -30
View File
@@ -2,13 +2,13 @@ import { contentPath, contentSegment } from '../routing';
import type { Chapter, Config, DramaDetail } from "../api/types";
import { formatCount, recordList, safeUrl } from "../api/protocol";
import { escape, imageAttributes, title } from "./render";
import { SITE_NAME } from "../brand";
// Original HTML action icons, kept as SVG paths so active colors follow the stylesheet.
const actionPaths = {
"save": "M857.6 327.68l-104.494545-34.210909a178.036364 178.036364 0 0 1-90.763637-66.56l-64-89.367273a104.96 104.96 0 0 0-23.272727-23.272727 106.123636 106.123636 0 0 0-148.48 23.272727l-64.930909 89.367273a179.665455 179.665455 0 0 1-90.763637 66.792727l-104.494545 33.978182a106.821818 106.821818 0 0 0-53.061818 162.909091l93.090909 128.930909a35.84 35.84 0 0 1 6.981818 21.643636v158.952728a109.381818 109.381818 0 0 0 5.12 33.512727 105.658182 105.658182 0 0 0 133.818182 67.956364l128-39.33091a107.985455 107.985455 0 0 1 64.232727 0l127.534546 39.33091a106.123636 106.123636 0 0 0 139.636363-101.469091v-158.254546a35.84 35.84 0 0 1 6.981818-21.643636l93.09091-128.930909a104.494545 104.494545 0 0 0 15.127272-29.556364 106.821818 106.821818 0 0 0-69.352727-134.050909z m-192 83.549091a34.909091 34.909091 0 0 1-48.174545 10.938182l-46.545455-29.090909a34.909091 34.909091 0 0 1 37.003636-59.112728l46.545455 29.090909a34.909091 34.909091 0 0 1 11.170909 48.174546z",
"like": "M855.738182 198.283636A252.043636 252.043636 0 0 0 670.254545 116.363636a225.28 225.28 0 0 0-160.814545 63.069091 248.32 248.32 0 0 0-152.901818-53.294545A251.578182 251.578182 0 0 0 171.287273 209.454545 287.883636 287.883636 0 0 0 162.909091 588.567273l256 275.083636a122.181818 122.181818 0 0 0 90.065454 39.563636 121.716364 121.716364 0 0 0 89.832728-39.563636l256-272.989091 2.792727-3.723636a290.443636 290.443636 0 0 0-1.861818-388.654546z",
"share": "M910.661818 117.992727a114.734545 114.734545 0 0 0-113.803636-16.756363L134.050909 363.752727a138.472727 138.472727 0 0 0-15.592727 250.181818l97.978182 54.225455 1.861818 162.909091a87.272727 87.272727 0 0 0 137.309091 69.818182L482.210909 814.545455l180.363636 99.374545a138.472727 138.472727 0 0 0 203.869091-101.236364L954.181818 224.814545a113.803636 113.803636 0 0 0-43.52-106.821818z",
"feedback": "M505.949091 38.4a465.454545 465.454545 0 1 0 465.454545 465.454545 465.454545 465.454545 0 0 0-465.454545-465.454545z m0 721.454545a46.545455 46.545455 0 1 1 46.545454-46.545454 46.545455 46.545455 0 0 1-46.545454 46.545454z m43.287273-188.276363a11.636364 11.636364 0 0 0-8.378182 11.170909v25.832727a34.909091 34.909091 0 0 1-69.818182 0V581.818182a81.454545 81.454545 0 0 1 58.88-78.196364 87.04 87.04 0 0 0 60.741818-102.865454A86.807273 86.807273 0 0 0 433.803636 372.363636a90.298182 90.298182 0 0 0-11.403636 24.436364 34.909091 34.909091 0 0 1-67.025455-19.549091 162.909091 162.909091 0 0 1 20.712728-43.985454 156.625455 156.625455 0 1 1 173.149091 238.312727z",
"phone": "M756.363636 69.818182h-488.727272A128.232727 128.232727 0 0 0 139.636364 197.818182v628.363636a128.232727 128.232727 0 0 0 128 128h488.727272a128 128 0 0 0 128-128v-628.363636A128 128 0 0 0 756.363636 69.818182z m-139.636363 698.181818h-209.454546a34.909091 34.909091 0 0 1 0-69.818182h209.454546a34.909091 34.909091 0 0 1 0 69.818182z"
} as const;
@@ -16,7 +16,8 @@ export function detailActionIcon(kind: keyof typeof actionPaths): string {
return `<svg class="play-action-icon" viewBox="0 0 1024 1024" aria-hidden="true"><path d="${actionPaths[kind]}"/></svg>`;
}
function genreMarkup(drama: DramaDetail): string {
/** "#标签" links: tags first, then themes, without duplicates. */
export function hashTags(drama: DramaDetail): string {
const items: {name: string; href: string}[] = [];
const seen = new Set<string>();
const add = (names: unknown, ids: number[] | undefined, slugs: string[] | undefined, kind: 'tag' | 'theme') => {
@@ -32,36 +33,62 @@ function genreMarkup(drama: DramaDetail): string {
add(drama.tagNames, drama.tagIds, drama.tagSlugs, 'tag');
add(drama.themeNames, drama.themeIds, drama.themeSlugs, 'theme');
if (!items.length) return '';
return `<p class="series-genres">${items.map((item, index) => `${index ? ' · ' : ''}${item.href ? `<a href="${escape(item.href)}">${escape(item.name)}</a>` : escape(item.name)}`).join('')}</p>`;
return `<p class="cine-tags" aria-label="剧集标签">${items.map(item => item.href ? `<a href="${escape(item.href)}">#${escape(item.name)}</a>` : `<span>#${escape(item.name)}</span>`).join('')}</p>`;
}
function seriesFacts(drama: DramaDetail): string {
const facts: string[] = [];
if (typeof drama.totalEpisode === 'number' && Number.isSafeInteger(drama.totalEpisode) && drama.totalEpisode > 0) facts.push(`全${drama.totalEpisode}集`);
if (drama.updateStatus === 1) facts.push('连载中');
else if (drama.updateStatus === 2) facts.push('已完结');
if (typeof drama.countBrowse === 'number' && Number.isFinite(drama.countBrowse) && drama.countBrowse > 0) facts.push(`${formatCount(drama.countBrowse)}次观看`);
const actor = typeof drama.anchor === 'string' ? drama.anchor.trim() : '';
if (actor) facts.push(`演员 ${actor}`);
return facts.length ? `<p class="series-facts">${facts.map(item => `<span>${escape(item)}</span>`).join('')}</p>` : '';
/** Single videos have at most one episode. */
export function isSingleVideo(drama: DramaDetail, chapters: Chapter[] = []): boolean {
const total = typeof drama.totalEpisode === 'number' && drama.totalEpisode > 0 ? drama.totalEpisode : chapters.length;
return total <= 1;
}
export function overviewMarkup(drama: DramaDetail, first: Chapter | undefined, actions: string): string {
const badge = `${drama.isFeatured ? '<span class="series-badge series-badge--hot">爆剧</span>' : ''}${drama.isNew ? '<span class="series-badge series-badge--new">新剧</span>' : ''}${drama.showOriginal ? '<span class="series-badge series-badge--original">原创</span>' : ''}${drama.operateType === 2 ? '<span class="series-badge series-badge--comic">漫剧</span>' : ''}`;
return `<div class="series-overview">
<img class="series-poster" ${imageAttributes(drama.cover || drama.coverImg)} width="640" height="914" alt="${escape(title(drama))}封面">
<div class="series-info">
<div class="series-title-row"><h1 id="play-detail-title">${escape(title(drama))}</h1><span class="series-heat"><img class="series-heat-icon" src="/assets/9df0afe903e8d542.svg" width="16" height="16" alt="剧集热度图标" aria-hidden="true">${formatCount(drama.heatValue)}热度</span></div>
<div class="series-tags" aria-label="剧集标签">${badge}<span class="series-stat" data-engage-count="collect" aria-live="polite">${formatCount(drama.countCollect)}收藏</span><span class="series-stat" data-engage-count="like" aria-live="polite">${formatCount(drama.countLike)}点赞</span></div>
${seriesFacts(drama)}
${genreMarkup(drama)}
<div class="series-description-wrap"><p class="series-description" id="series-description-text">${escape(drama.desc || drama.description)}</p><button class="series-description-toggle" type="button" aria-expanded="false" aria-controls="series-description-text" hidden>展开</button></div>
<div class="series-action-bar"><div class="series-actions">
${first ? `<a class="primary detail-play" href="${contentPath('play',drama,first.id)}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg><span>立即播放</span></a>` : '<span class="series-unavailable">暂无可用剧集</span>'}
<a class="series-download" data-app-download-entry data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer" hidden><svg class="series-download-phone" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4M11 19h2"/></svg><span>下载 App</span></a>
</div>${actions}</div>
export function statusLabel(drama: DramaDetail): string {
return drama.updateStatus === 1 ? '连载中' : drama.updateStatus === 2 ? '已完结' : '';
}
/** Inline "·" facts row: 集数 (series), 热度, 收藏, 点赞. */
export function factsRow(drama: DramaDetail, chapters: Chapter[] = []): string {
const total = drama.totalEpisode || chapters.length;
const facts = [
isSingleVideo(drama, chapters) ? '' : `<span>${drama.updateStatus === 1 ? `更新至${chapters.length || total}集` : `全${total}集`}</span>`,
`<span>${formatCount(drama.heatValue)}热度</span>`,
`<span data-engage-count="collect" aria-live="polite">${formatCount(drama.countCollect)}收藏</span>`,
`<span data-engage-count="like" aria-live="polite">${formatCount(drama.countLike)}点赞</span>`,
].filter(Boolean);
return `<p class="cine-facts">${facts.join('<i aria-hidden="true">·</i>')}</p>`;
}
/** Like / collect / share as small outlined text buttons. */
export function engageButtons(drama: DramaDetail): string {
const liked = drama.isLike === 1, collected = drama.isCollect === 1;
return `<div class="play-detail-actions cine-engage"><button type="button" data-engage="like" aria-pressed="${liked}">${detailActionIcon('like')}<span>${liked ? '已点赞' : '点赞'}</span></button><button type="button" data-engage="collect" aria-pressed="${collected}">${detailActionIcon('save')}<span>${collected ? '已收藏' : '收藏'}</span></button><button type="button" data-share>${detailActionIcon('share')}<span>分享</span></button></div>`;
}
export function descriptionBlock(drama: DramaDetail): string {
return `<div class="series-description-wrap"><p class="series-description" id="series-description-text">${escape(drama.desc || drama.description)}</p><button class="series-description-toggle" type="button" aria-expanded="false" aria-controls="series-description-text" hidden>展开</button></div>`;
}
/** Cinematic hero: sharp poster on the right, faded into the dark stage; copy on the left. */
export function overviewMarkup(drama: DramaDetail, first: Chapter | undefined, chapters: Chapter[] = []): string {
const single = isSingleVideo(drama, chapters);
const status = statusLabel(drama);
const kicker = single ? '视频' : `短剧${status ? ` · ${status}` : ''}`;
const start = first && (first.currentEpisode || 1) > 1 ? `从第 ${first.currentEpisode} 集开始` : '立即观看';
return `<section class="cine-hero" aria-labelledby="play-detail-title">
<div class="cine-hero-art"><img class="cine-hero-image" ${imageAttributes(drama.cover || drama.coverImg || drama.horizontalCover)} width="720" height="1280" alt="${escape(title(drama))}封面" fetchpriority="high" decoding="async"></div>
<div class="cine-hero-copy">
<span class="cine-kicker">${escape(kicker)}</span>
<h1 id="play-detail-title">${escape(title(drama))}</h1>
${factsRow(drama, chapters)}
${hashTags(drama)}
${descriptionBlock(drama)}
<div class="cine-actions">
${first ? `<a class="cine-play detail-play" href="${contentPath('play',drama,first.id)}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg><span>${start}</span></a>` : '<span class="series-unavailable">暂无可用剧集</span>'}
<a class="cine-download" data-app-download-entry data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer" hidden><span>下载 App</span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg></a>
</div>
${engageButtons(drama)}
</div>
</div>`;
</section>`;
}
export function officialLinks(config: Config): string {
@@ -69,13 +96,11 @@ export function officialLinks(config: Config): string {
const primary = safeUrl(settings.newUrlAddr) || recordList<{url?:unknown}>(config.navPageList).map(item => safeUrl(item.url)).find(Boolean) || '';
const permanent = safeUrl(settings.foreverUrlAddr);
const group = safeUrl(settings.tgGroup);
const contact = safeUrl(settings.telegram);
const external = (url: string, label = url) => `<a href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer">${escape(label)}</a>`;
return `<section class="play-official" aria-label="官方链接">
${primary ? `<div class="play-official-line play-official-inline"><span>官方入口:</span>${external(primary)}</div>` : ''}
${permanent ? `<div class="play-official-line play-official-inline"><span>永久地址:</span>${external(permanent)}</div>` : ''}
${group ? `<div class="play-official-line">${external(group,'点击加入禁果短剧官方 Telegram 群')}</div>` : ''}
<div class="play-official-line play-official-inline"><span>官方客服:</span>${contact ? external(contact,'立即联系') : '<a href="/info/feedback/">立即联系</a>'}</div>
${group ? `<div class="play-official-line">${external(group,`点击加入${SITE_NAME}官方 Telegram 群`)}</div>` : ''}
</section>`;
}
+1
View File
@@ -4,6 +4,7 @@ import { downloadUrl } from '../app-download';
export function configureDownloadLinks(config: Config, root: ParentNode = document) {
const url = downloadUrl(config);
root.querySelectorAll<HTMLElement>('[data-app-download-entry]').forEach(entry => {entry.hidden = !url;});
root.querySelectorAll<HTMLElement>('[data-app-download-fallback]').forEach(entry => {entry.hidden = !!url;});
root.querySelectorAll<HTMLAnchorElement>('a[data-app-download]').forEach(link => {
if (url) {link.href = url;link.rel = 'sponsored nofollow noopener noreferrer';} else link.removeAttribute('href');
});
-282
View File
@@ -1,282 +0,0 @@
import type { SiteContext } from "./context";
import { escape } from "./render";
export function feedbackPayload(data:FormData,screenshots:string[]=[]) {
const value=(key:string)=>String(data.get(key)||'').trim();
const description=value('content');
if(description.length<10||description.length>500)throw new Error('请填写 10–500 字的问题描述');
const labels:Record<string,string>={category:'问题类型',frequency:'发生频率',province:'所在地区',city:'城市',carrier:'运营商',network:'网络类型',device:'设备类型',model:'设备型号',version:'App 版本'};
const details=Object.entries(labels).filter(([key])=>value(key)).map(([key,label])=>`${label}:${value(key)}`).join('\n');
const category=value('category');
// 1 功能建议,2 播放问题,3 账号问题,4 其他。
const type=category==='登录异常'?3:category==='其他问题'||category==='闪退 / 崩溃'?4:2;
const remaining=500-description.length;
const context=remaining>2&&details?'\n\n'+details.slice(0,remaining-2):'';
const payload:{type:number;content:string;contact:string;screenshots?:string[]}={type,content:description+context,contact:value('contact')};
if(screenshots.length)payload.screenshots=screenshots;
return payload;
}
export function renderFeedbackPage(app:SiteContext,preserveMarkup=false) {
const root=document.getElementById('info-content')!;if(!preserveMarkup)root.innerHTML=feedbackMarkup();
const form=root.querySelector<HTMLFormElement>('#feedback-form')!,signal=app.signal;
const input=form.querySelector<HTMLInputElement>('#feedback-files')!,list=form.querySelector('#feedback-file-list')!,error=form.querySelector('#feedback-file-error')!,status=form.querySelector('#feedback-status')!;
let files:File[]=[],previews:string[]=[];
const uploaded=new Map<File,string>();
const clearPreviews=()=>{previews.forEach(url=>URL.revokeObjectURL(url));previews=[];};
const renderFiles=()=>{
clearPreviews();
list.innerHTML=files.map((file,index)=>{const url=URL.createObjectURL(file);previews.push(url);return `<div class="feedback-attachment"><img src="${url}" alt="${escape(file.name || "反馈图片")}预览"><span>${escape(file.name)}</span><button type="button" data-remove-feedback-file="${index}" aria-label="移除${escape(file.name)}">×</button></div>`;}).join('');
const box=input.closest?.('.feedback-upload-box') as HTMLElement|null|undefined;
if(box)box.hidden=files.length>=3;
if(!form.dataset.submitting)input.disabled=files.length>=3;
};
signal.addEventListener('abort',clearPreviews,{once:true});
input.addEventListener('change',()=>{
if(form.dataset.submitting)return;
const selected=Array.from(input.files||[]);
input.value='';
if(!selected.length)return;
if(selected.some(file=>file.size>5*1024*1024||!['image/jpeg','image/png','image/webp'].includes(file.type))){error.textContent='请上传 5MB 以内的 JPG、PNG 或 WebP 图片';return;}
if(files.length+selected.length>3){error.textContent='最多上传 3 张图片';return;}
error.textContent='';
files=[...files,...selected];renderFiles();
},{signal});
list.addEventListener('click',event=>{
const button=(event.target as Element).closest<HTMLElement>('[data-remove-feedback-file]');if(!button||form.dataset.submitting)return;
files.splice(Number(button.dataset.removeFeedbackFile),1);input.value='';renderFiles();
},{signal});
const content=form.querySelector<HTMLTextAreaElement>('[name="content"]')!;
content.addEventListener('input',()=>{form.querySelector('#feedback-count')!.textContent=content.value.length+' / 500';},{signal});
form.addEventListener('submit',event=>{
event.preventDefault();if(form.dataset.submitting||!form.reportValidity())return;
const data=new FormData(form),attachments=[...files];
try{feedbackPayload(data);}catch(reason){status.textContent=reason instanceof Error?reason.message:'请检查反馈内容';return;}
const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;
button.disabled=true;input.disabled=true;form.dataset.submitting='1';status.textContent='提交中…';
void (async()=>{
const screenshots:string[]=[];
for(const file of attachments){
if(signal.aborted)return;
let path=uploaded.get(file);
if(!path){const body=new FormData();body.append('file',file);body.append('watermark','0');const result=await app.api.post<{sourceNameFs?:string}>('upload/image',body);if(!result.sourceNameFs)throw new Error('图片上传失败,请重试');path=result.sourceNameFs;uploaded.set(file,path);}
screenshots.push(path);
}
if(signal.aborted)return;
await app.api.post('feedback/add',feedbackPayload(data,screenshots));
if(signal.aborted)return;
form.reset();files=[];uploaded.clear();renderFiles();error.textContent='';form.querySelector('#feedback-count')!.textContent='0 / 500';status.textContent='反馈已提交,感谢您的反馈!';
})().catch(reason=>{if(!signal.aborted)status.textContent=reason instanceof Error?reason.message:'反馈提交失败,请重试';}).finally(()=>{button.disabled=false;input.disabled=files.length>=3;delete form.dataset.submitting;});
},{signal});
}
export const feedbackMarkup=()=>`<form id="feedback-form" class="feedback-form feedback-reference">
<fieldset class="feedback-part">
<legend>问题类型 <em>*</em>
</legend>
<div class="feedback-options">
<label class="feedback-option">
<input type="radio" name="category" value="播放卡顿 / 缓冲" required>
<span>播放卡顿 / 缓冲</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="无法播放" required>
<span>无法播放</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="画质模糊" required>
<span>画质模糊</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="音画不同步" required>
<span>音画不同步</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="加载失败" required>
<span>加载失败</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="闪退 / 崩溃" required>
<span>闪退 / 崩溃</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="登录异常" required>
<span>登录异常</span>
</label>
<label class="feedback-option">
<input type="radio" name="category" value="其他问题" required>
<span>其他问题</span>
</label>
</div>
</fieldset>
<fieldset class="feedback-part">
<legend>问题发生频率 <em>*</em>
</legend>
<div class="feedback-options">
<label class="feedback-option">
<input type="radio" name="frequency" value="每次都会" required>
<span>每次都会</span>
</label>
<label class="feedback-option">
<input type="radio" name="frequency" value="经常出现" required>
<span>经常出现</span>
</label>
<label class="feedback-option">
<input type="radio" name="frequency" value="偶尔出现" required>
<span>偶尔出现</span>
</label>
<label class="feedback-option">
<input type="radio" name="frequency" value="仅出现一次" required>
<span>仅出现一次</span>
</label>
</div>
</fieldset>
<fieldset class="feedback-part">
<legend>网络环境信息 <em>*</em>
</legend>
<div class="feedback-panel">
<label>所在地址</label>
<div class="feedback-address">
<select name="province" aria-label="所在省份" required>
<option value="">所在省份</option>
<option>北京</option>
<option>天津</option>
<option>河北</option>
<option>山西</option>
<option>内蒙古</option>
<option>辽宁</option>
<option>吉林</option>
<option>黑龙江</option>
<option>上海</option>
<option>江苏</option>
<option>浙江</option>
<option>安徽</option>
<option>福建</option>
<option>江西</option>
<option>山东</option>
<option>河南</option>
<option>湖北</option>
<option>湖南</option>
<option>广东</option>
<option>广西</option>
<option>海南</option>
<option>重庆</option>
<option>四川</option>
<option>贵州</option>
<option>云南</option>
<option>西藏</option>
<option>陕西</option>
<option>甘肃</option>
<option>青海</option>
<option>宁夏</option>
<option>新疆</option>
<option>香港</option>
<option>澳门</option>
<option>台湾</option>
<option>海外</option>
</select>
<input name="city" maxlength="100" aria-label="城市(选填)" placeholder="城市(选填)">
</div>
<label>运营商<select name="carrier" required>
<option value="">选择运营商</option>
<option>中国移动</option>
<option>中国联通</option>
<option>中国电信</option>
<option>中国广电</option>
<option>其他</option>
</select>
</label>
<span class="feedback-field-label">网络类型</span>
<div class="feedback-options">
<label class="feedback-option">
<input type="radio" name="network" value="Wi-Fi" required>
<span>Wi-Fi</span>
</label>
<label class="feedback-option">
<input type="radio" name="network" value="5G" required>
<span>5G</span>
</label>
<label class="feedback-option">
<input type="radio" name="network" value="4G" required>
<span>4G</span>
</label>
<label class="feedback-option">
<input type="radio" name="network" value="其他" required>
<span>其他</span>
</label>
</div>
</div>
</fieldset>
<fieldset class="feedback-part">
<legend>设备信息 <em>*</em>
</legend>
<div class="feedback-panel">
<span class="feedback-field-label">设备类型</span>
<div class="feedback-options">
<label class="feedback-option">
<input type="radio" name="device" value="iPhone" required>
<span>iPhone</span>
</label>
<label class="feedback-option">
<input type="radio" name="device" value="Android" required>
<span>Android</span>
</label>
<label class="feedback-option">
<input type="radio" name="device" value="iPad" required>
<span>iPad</span>
</label>
<label class="feedback-option">
<input type="radio" name="device" value="电脑 / 其他" required>
<span>电脑 / 其他</span>
</label>
</div>
<div class="feedback-address">
<label>设备型号(选填)<input name="model" maxlength="100" placeholder="例如:iPhone 15、小米 14">
</label>
<label>App 版本号(选填)<input name="version" maxlength="100" placeholder="例如:v1.2.1">
</label>
</div>
</div>
</fieldset>
<fieldset class="feedback-part">
<legend>问题描述 <em>*</em>
</legend>
<div class="feedback-description-wrap">
<textarea name="content" aria-label="问题描述" minlength="10" maxlength="500" required placeholder="请详细描述您遇到的问题,包括观看的内容、具体表现和发生时间(至少 10 字)">
</textarea>
<span id="feedback-count">0 / 500</span>
</div>
</fieldset>
<fieldset class="feedback-part">
<legend>上传图片</legend>
<div class="feedback-uploads">
<label class="feedback-upload-box">
<input type="file" id="feedback-files" accept="image/jpeg,image/png,image/webp" multiple>
<span class="feedback-upload-plus" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M12 5v14M5 12h14"/>
</svg>
</span>
<strong>上传图片</strong>
<small>支持 JPG / PNG / WebP,最多 3 张</small>
</label>
<div id="feedback-file-list">
</div>
</div>
<p class="feedback-file-note">每张图片不超过 5MB</p>
<p id="feedback-file-error" role="alert">
</p>
<input type="hidden" name="attachments" id="feedback-attachments">
</fieldset>
<fieldset class="feedback-part">
<legend>联系方式 <em>*</em>
</legend>
<input name="contact" aria-label="联系方式" required maxlength="120" placeholder="请输入 Email / Telegram / X 联系方式">
</fieldset>
<div class="feedback-submit-area">
<button type="submit" class="feedback-submit-button">提交反馈</button>
<p>提交后请留意您填写的联系方式。</p>
<span class="feedback-status" id="feedback-status" role="status">
</span>
</div>
</form>`;
-51
View File
@@ -1,51 +0,0 @@
import type { Drama, PageData } from '../api/types';
import { recordList } from '../api/protocol';
import { contentPath } from '../routing';
import { escape, imageAttributes, title } from './render';
import type { SiteContext } from './context';
export function headerHistoryMarkup(value:unknown):string {
const seen=new Set<number>();
const items=recordList<Drama>(value).filter(item=>Number.isSafeInteger(item.id)&&item.id>0&&!seen.has(item.id)&&!!seen.add(item.id)).slice(0,9);
return items.map(item=>`<a class="jg-history-item" href="${contentPath('detail',item)}" aria-label="继续观看${escape(title(item))}"><span class="jg-history-thumb"><img ${imageAttributes(item.cover||item.coverImg||item.horizontalCover)} alt="${escape(title(item))}封面" loading="lazy" decoding="async"></span><span class="jg-history-item-copy"><strong>${escape(title(item))}</strong><small>${item.totalEpisode?`全 ${escape(item.totalEpisode)} 集`:'最近观看'}</small></span></a>`).join('')||'<div class="jg-history-empty"><strong>暂无观看历史</strong><span>看过的好故事,会留在这里</span></div>';
}
/** Load private history only when the desktop panel opens; never persist account data locally. */
export function bindHeaderHistory(app:SiteContext):void {
const wrap=document.querySelector<HTMLElement>('.jg-history-wrap');
const trigger=document.getElementById('header-history-button'),panel=document.getElementById('header-history-panel'),list=document.getElementById('header-history-list');
if(!wrap||!trigger||!panel||!list)return;
const {signal}=app;
let timer:ReturnType<typeof setTimeout>|undefined,sequence=0;
const close=()=>{clearTimeout(timer);sequence++;panel.hidden=true;wrap.classList.remove('is-open');trigger.setAttribute('aria-expanded','false');};
const fit=()=>{
panel.style.setProperty('--history-panel-scale','1');
const available=Math.max(160,window.innerHeight-panel.getBoundingClientRect().top-16);
panel.style.setProperty('--history-panel-scale',String(Math.min(1,available/Math.max(1,panel.offsetHeight))));
};
const open=async()=>{
clearTimeout(timer);
if(!window.matchMedia('(min-width:761px)').matches||!panel.hidden)return;
const current=++sequence;panel.hidden=false;wrap.classList.add('is-open');trigger.setAttribute('aria-expanded','true');
list.innerHTML='<div class="jg-history-empty" role="status">正在加载观看历史…</div>';fit();
try{
await app.api.ensureSession();
const data=await app.api.get<PageData<Drama>>('playlet/history/list',{pageNum:1,pageSize:9},signal);
if(signal.aborted||sequence!==current)return;
list.innerHTML=headerHistoryMarkup(data.list);fit();
}catch{
if(signal.aborted||sequence!==current)return;
list.innerHTML='<div class="jg-history-empty" role="status"><strong>观看历史暂不可用</strong><span>请前往观看历史页面重试</span></div>';fit();
}
};
const scheduleClose=()=>{clearTimeout(timer);timer=setTimeout(()=>{if(!wrap.contains(document.activeElement))close();},160);};
wrap.addEventListener('pointerenter',()=>{void open();},{signal});
wrap.addEventListener('pointerleave',scheduleClose,{signal});
wrap.addEventListener('focusin',()=>{void open();},{signal});
wrap.addEventListener('focusout',event=>{if(!wrap.contains(event.relatedTarget as Node|null))scheduleClose();},{signal});
wrap.addEventListener('keydown',event=>{if(event.key==='Escape'){close();event.stopPropagation();}},{signal});
document.addEventListener('pointerdown',event=>{if(!wrap.contains(event.target as Node))close();},{signal});
window.addEventListener('resize',()=>{if(window.innerWidth<=760)close();else if(!panel.hidden)fit();},{signal});
app.api.addEventListener('userchange',close,{signal});
signal.addEventListener('abort',close,{once:true});
}
+55 -65
View File
@@ -1,87 +1,77 @@
import { contentPath, contentSegment } from '../routing';
import type { Chapter, Comment, Config, Drama, DramaDetail, HomeData, PageData, User } from '../api/types';
import { formatCount, recordList } from '../api/protocol';
import { ads, card, empty, escape, imageAttributes, pagination, promotionRow, title } from './render';
import { detailActionIcon, officialLinks, overviewMarkup } from './detail';
import { contentPath } from '../routing';
import type { Chapter, Config, Drama, DramaDetail, HomeData } from '../api/types';
import { recordList } from '../api/protocol';
import { ads, card, empty, escape, imageAttributes, promotionRow, title } from './render';
import { descriptionBlock, engageButtons, factsRow, hashTags, isSingleVideo, officialLinks, overviewMarkup, statusLabel } from './detail';
import { chapterAccess, chapterListGate, chapterRequiresPayment, initialPlayMode, INLINE_VIDEO_ATTRIBUTES, playbackUrl } from './playback';
import { downloadUrl } from '../app-download';
import { SITE_NAME } from '../brand';
/** Local guest avatar used while encrypted CDN avatars decrypt. */
export const COMMENT_AVATAR_FALLBACK='/assets/8837c3e58159bd48.png';
/** Recommendation shelf size (guess-like list, current item excluded). */
export const RECOMMEND_PAGE_SIZE=12;
export interface PlayerViewOptions {
chapter?:Chapter|null; isVideo?:boolean; page?:number; commentSort?:number;
comments?:PageData<Comment>; config?:Config; home?:HomeData; user?:User|null;
pageHref?:(page:number,sort:number)=>string;
chapter?:Chapter|null; isVideo?:boolean; config?:Config; home?:HomeData;
}
export function orderedChapters(value:unknown):Chapter[] {
return recordList<Chapter>(value).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).sort((a,b)=>(a.currentEpisode||0)-(b.currentEpisode||0));
}
const lockIcon='<svg class="film-tile-lock" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>';
const arrow=(direction:'prev'|'next')=>`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${direction==='prev'?'m15 5-7 7 7 7':'m9 5 7 7-7 7'}"/></svg>`;
/** Locked episodes are watched in the App: overlay inside the player with a download button. */
export function appGateMarkup(drama:Drama,config:Config={}):string {
const url=downloadUrl(config);
return `<div class="play-app-gate" hidden><div class="play-app-gate-copy" data-app-download-entry${url?'':' hidden'}><strong>本集请在 ${escape(SITE_NAME)} App 观看</strong><p>下载 App 即可继续观看《${escape(title(drama))}》及全部剧集</p><a class="primary play-app-gate-btn" data-app-download${url?` href="${escape(url)}"`:''} target="_blank" rel="sponsored nofollow noopener noreferrer"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4M11 19h2"/></svg><span>下载 App 继续观看</span></a></div><p class="play-app-gate-fallback" data-app-download-fallback${url?' hidden':''}>本集视频暂未开放,请稍后再试</p></div>`;
}
/** "推荐视频" shelf: guess-like items, current drama excluded. */
export function recommendationsMarkup(raw:DramaDetail):string {
const items=recordList<Drama>(raw.relatedRecommendData).filter(item=>item.id!==raw.id).slice(0,RECOMMEND_PAGE_SIZE);
return `<section class="play-recommend" aria-labelledby="play-recommend-title"><div class="recommend-heading"><span class="recommend-outline" aria-hidden="true">RECOMMEND</span><h2 id="play-recommend-title">推荐视频</h2><p>看了《${escape(title(raw))}》的人还在看</p></div>${items.length?`<div class="recommend-grid">${items.map(item=>card(item)).join('')}</div>`:empty('暂无推荐视频')}</section>`;
}
/** One horizontal film strip with every episode; locked tiles still link to their play page. */
export function episodeItems(id:number,ordered:Chapter[],chapter:Chapter|null,slug?:string):string {
return ordered.map((item,index)=>{
const number=item.currentEpisode||index+1;
const locked=!!chapterListGate(item),current=item.id===chapter?.id;
return `<a class="film-tile${current?' is-current':''}${locked?' is-locked':''}" href="${contentPath('play',{id,slug},item.id)}" data-drama-switch data-target-episode="${number}" data-target-video-id="${item.id}"${current?' aria-current="true"':''} aria-label="第 ${number} 集${locked?',请在 App 观看':''}"><span class="film-tile-no">${String(number).padStart(2,'0')}</span>${locked?lockIcon:''}</a>`;
}).join('')||empty('暂无剧集');
}
export function episodeStrip(raw:DramaDetail,ordered:Chapter[],chapter:Chapter|null):string {
const status=statusLabel(raw);
return `<section class="play-episodes-section" aria-labelledby="play-episodes-title"><div class="play-section-heading"><h2 id="play-episodes-title">选集</h2><span>全${raw.totalEpisode||ordered.length}集${status?` · ${status}`:''}</span></div><div class="film-strip" id="play-episode-list" aria-label="剧集列表">${episodeItems(raw.id,ordered,chapter,raw.slug)}</div></section>`;
}
export function playerMarkup(raw:DramaDetail,ordered:Chapter[],options:PlayerViewOptions={}):string {
const {chapter=null,isVideo=false,comments,config={},home={},user}=options;
const id=raw.id,overview=!chapter&&!isVideo,first=ordered.find(item=>item.currentEpisode===1)||ordered[0];
const displayTitle=title(raw),heading=chapter?`${displayTitle} - 第${chapter.currentEpisode||1}集`:displayTitle,liked=raw.isLike===1,collected=raw.isCollect===1;
const commentPage=options.page||1,commentSort=options.commentSort===1?1:2;
const basePath=contentPath(isVideo?'video':chapter?'play':'detail',raw,chapter?.id);
const pageHref=options.pageHref||((page:number,sort:number)=>`${basePath}${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`);
const {chapter=null,isVideo=false,config={},home={}}=options;
const overview=!chapter&&!isVideo,first=ordered.find(item=>item.currentEpisode===1)||ordered[0];
const single=isVideo||isSingleVideo(raw,ordered);
const displayTitle=title(raw),heading=chapter&&!single?`${displayTitle} - 第${chapter.currentEpisode||1}集`:displayTitle;
const access=isVideo?{allowed:!!playbackUrl(raw.video?.url),gate:playbackUrl(raw.video?.url)?'':'unavailable',source:playbackUrl(raw.video?.url)} as const:chapterAccess(chapter);
const previewSource=isVideo?'':playbackUrl(raw.preview),mode=initialPlayMode(access,raw.preview);
const modeTabs=previewSource?`<div class="channel-tabs play-mode-tabs" role="group" aria-label="播放内容"><button type="button" data-play-mode="main" aria-pressed="${mode==='main'}">正片</button><button type="button" data-play-mode="preview" aria-pressed="${mode==='preview'}">免费预告</button></div>`:'';
const initialEpisodePage=chapter?Math.floor(Math.max(0,ordered.findIndex(item=>item.id===chapter.id))/45):0;
const actions=()=>`<div class="play-detail-actions"><button type="button" data-engage="like" aria-pressed="${liked}">${detailActionIcon('like')}<span>${liked?'已点赞':'点赞'}</span></button><button type="button" data-engage="collect" aria-pressed="${collected}">${detailActionIcon('save')}<span>${collected?'已收藏':'收藏'}</span></button><button type="button" data-share>${detailActionIcon('share')}<span>分享</span></button><a href="/info/feedback/">${detailActionIcon('feedback')}<span>反馈</span></a></div>`;
const genres=(Array.isArray(raw.tagNames)?raw.tagNames:Array.isArray(raw.themeNames)?raw.themeNames:[]).filter(tag=>typeof tag==='string');
const useTags=Array.isArray(raw.tagNames);
const tags=genres.map((tag,i)=>{
const identity={id:(useTags?raw.tagIds:raw.themeIds)?.[i],slug:(useTags?raw.tagSlugs:raw.themeSlugs)?.[i]};
return contentSegment(identity)?`<a href="${contentPath(useTags?'tag':'topics',identity)}">${escape(tag)}</a>`:`<span>${escape(tag)}</span>`;
}).join('');
const related=recordList<Drama>(raw.relatedRecommendData).filter(item=>item.id!==id).slice(0,6);
const screen=()=>`${modeTabs}<div class="play-screen${chapter&&chapterRequiresPayment(chapter)?' play-paywalled-content':''}" data-play-mode="${mode}"><video ${INLINE_VIDEO_ATTRIBUTES} preload="metadata" ${imageAttributes(raw.cover||raw.coverImg,undefined,'poster')} aria-label="${escape(displayTitle)}"></video><div class="play-screen-overlay"><button type="button" aria-label="播放">▶</button><p class="play-source-status" role="status">正在准备视频…</p></div><div class="play-screen-controls"><button data-play-action="next" ${!chapter||ordered.at(-1)?.id===chapter.id?'disabled':''}>下一集</button></div></div>`;
const position=chapter?ordered.findIndex(item=>item.id===chapter.id):-1;
const previous=!single&&position>0?ordered[position-1]:undefined,next=!single&&position>=0?ordered[position+1]:undefined;
const edge=(item:Chapter|undefined,direction:'prev'|'next')=>item?`<a class="play-edge play-edge--${direction}" href="${contentPath('play',raw,item.id)}" data-drama-switch aria-label="${direction==='prev'?'上一集':'下一集'}:第 ${item.currentEpisode||''} 集">${arrow(direction)}</a>`:'';
const screen=()=>`${modeTabs}<div class="play-stage"><div class="play-screen${chapter&&chapterRequiresPayment(chapter)?' play-paywalled-content':''}" data-play-mode="${mode}"><video ${INLINE_VIDEO_ATTRIBUTES} preload="metadata" ${imageAttributes(raw.cover||raw.coverImg,undefined,'poster')} aria-label="${escape(displayTitle)}"></video><div class="play-screen-overlay"><button type="button" aria-label="播放">▶</button><p class="play-source-status" role="status">正在准备视频…</p></div>${appGateMarkup(raw,config)}<div class="play-screen-controls"><button data-play-action="next" ${!next?'disabled':''}>下一集</button></div></div>${edge(previous,'prev')}${edge(next,'next')}</div>`;
const mark=single?'VIDEO':String(chapter?.currentEpisode||1).padStart(2,'0');
const headline=()=>`<section class="play-detail-info play-headline"><div class="play-headline-mark${single?' is-word':''}" aria-hidden="true">${mark}</div><div class="play-headline-copy"><span class="cine-kicker">${single?'视频':`第 ${chapter?.currentEpisode||1} 集 / 共 ${raw.totalEpisode||ordered.length} 集`}</span><h1 id="play-detail-title">${escape(heading)}</h1>${factsRow(raw,ordered)}${hashTags(raw)}${descriptionBlock(raw)}${engageButtons(raw)}</div></section>`;
const applicationAds=promotionRow(raw.advitise?.nineGridList||home.advitise?.nineGridList);
const bannerAds=ads(raw.advitise?.detailbannerList,'play-banner-ad');
return `
<nav class="page-breadcrumb" aria-label="面包屑导航"><a href="/">首页</a><span>/</span><a href="/${isVideo?'series':'shorts'}/">${isVideo?'剧集':'短剧'}</a><span>/</span>${overview||isVideo?`<span aria-current="page">${escape(displayTitle)}</span>`:`<a href="${contentPath('detail',raw)}">${escape(displayTitle)}</a><span>/</span><span aria-current="page">第${chapter?.currentEpisode||1}集</span>`}</nav>
<div class="play-detail-layout${related.length?'':' play-detail-layout--single'}"><div class="play-detail-main">
${overview?overviewMarkup(raw,first,actions()):screen()}
<div class="play-detail-layout play-detail-layout--single"><div class="play-detail-main">
${overview?overviewMarkup(raw,first,ordered):screen()}
${overview?'':headline()}
${applicationAds?`<section class="play-advertisements" aria-label="推荐应用广告">${applicationAds}</section>`:''}
${overview?'':`<section class="play-detail-info">${chapter?`<span class="play-episode-caption">正在观看 第 ${chapter.currentEpisode||1} 集</span>`:''}<h1 id="play-detail-title">${escape(heading)}</h1>${tags?`<div class="play-detail-tags"><span>分类标签:</span>${tags}</div>`:''}<div class="play-detail-meta">${!isVideo?`<span>集数:全${raw.totalEpisode||ordered.length}集</span>`:''}<span>${formatCount(raw.heatValue)}热度</span><span data-engage-count="collect" aria-live="polite">${formatCount(raw.countCollect)}收藏</span></div><p class="play-detail-description">简介:${escape(raw.desc||raw.description||'')}</p>${actions()}</section>`}
${!isVideo?`<section class="play-episodes-section"><div class="play-section-heading"><h2>剧集</h2><span>全${raw.totalEpisode||ordered.length}集</span></div><div class="play-episode-pages" role="group" aria-label="集数分段">${episodeTabs(ordered,45,initialEpisodePage)}</div><div class="play-episode-grid" id="play-episode-list">${episodeItems(id,ordered,chapter,45,initialEpisodePage,raw.slug)}</div></section>`:''}
${!single?episodeStrip(raw,ordered,chapter):''}
${bannerAds?`<section class="play-banner-ads" aria-label="推广广告">${bannerAds}</section>`:''}
${recommendationsMarkup(raw)}
${officialLinks(config)}
<section class="play-comments"><div class="play-section-heading"><h2 id="play-comments-title">评论${comments?` (${comments.totalCount||0})`:""}</h2><div class="play-sort" role="group" aria-label="评论排序"><button type="button" data-comment-sort="1" aria-pressed="${commentSort===1}">热门</button><button type="button" data-comment-sort="2" aria-pressed="${commentSort===2}">最新</button></div></div>
<form class="play-comment-form"><img class="play-compose-avatar" ${imageAttributes(user?.avatar,'/icon.svg')} alt="我的头像" width="38" height="38" decoding="async" data-lazy-eager="1"><div class="play-compose-body"><div id="reply-context"></div><textarea name="comment" aria-label="评论内容" maxlength="500" placeholder="说说你对这部剧的看法…" required></textarea><div class="play-comment-footer"><span><span id="play-comment-count">0</span> / 500</span><button type="submit">发表评论</button></div><p class="api-error" id="comment-error" role="alert"></p></div></form><div class="play-comment-list">${comments?commentListMarkup(comments,commentPage,commentSort,pageHref):empty('暂无评论')}</div>
</section>
</div>${related.length?`<aside class="play-related"><h2>热门短剧</h2><div class="play-related-list">${related.map(item=>card(item)).join('')}</div></aside>`:''}</div>`;
}
export function episodeTabs(ordered:Chapter[],size:number,episodePage:number):string {
const pages=Math.ceil(ordered.length/size);
return pages?`<div class="episode-page-track">${Array.from({length:pages},(_,i)=>`<button type="button" data-episode-page="${i}" aria-pressed="${episodePage===i}" aria-controls="play-episode-list">${i*size+1}–${Math.min((i+1)*size,ordered.length)}</button>`).join('')}</div>`:'';
}
export function episodeItems(id:number,ordered:Chapter[],chapter:Chapter|null,size:number,episodePage:number,slug?:string):string {
return ordered.slice(episodePage*size,(episodePage+1)*size).map((item,index)=>{
const number=item.currentEpisode||episodePage*size+index+1;
const gate=chapterListGate(item),locked=!!gate;
const content=`<span>${number}</span>${gate==='coins'?'<span class="episode-lock-corner" aria-hidden="true"><img class="coin-soft-star" src="/assets/episode-coin.svg" width="16" height="16" alt=""></span>':''}${item.id===chapter?.id?'<span class="episode-playing" aria-hidden="true"><i></i><i></i><i></i></span>':''}`;
if(locked)return `<button type="button" class="play-episode-locked" data-locked-chapter="${item.id}" aria-label="第 ${number} 集,${gate==='coins'?'金币解锁':'会员观看'}">${content}</button>`;
return `<a href="${contentPath('play',{id,slug},item.id)}" data-drama-switch data-target-episode="${number}" data-target-video-id="${item.id}" ${item.id===chapter?.id?'aria-current="true"':''} aria-label="播放第 ${number} 集">${content}</a>`;
}).join('')||empty('暂无剧集');
}
export function commentMarkup(item: Comment, child=false): string {
const children=recordList<Comment>(item.children);
const date=item.createdAt?new Date(item.createdAt):null;
const timestamp=date&&Number.isFinite(date.getTime())?`<time datetime="${date.toISOString()}">${escape(date.toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'}))}</time>`:'';
const replyName=typeof item.replyUserName==='string'?item.replyUserName.trim():'';
const reply=replyName?`<span class="play-reply-target">回复 @${escape(replyName)}</span>`:'';
const liked=item.isLike===1;
const count=typeof item.countLike==='number'&&Number.isFinite(item.countLike)?Math.max(0,Math.floor(item.countLike)):0;
const parent=typeof item.parentsId==='number'&&item.parentsId>0?item.parentsId:item.id;
return `<article class="play-comment-item${child?' play-comment-child':''}"><img class="play-comment-avatar" ${imageAttributes(item.userAvatar,COMMENT_AVATAR_FALLBACK)} alt="${escape(item.userName||'用户')}的头像" width="${child?30:40}" height="${child?30:40}" loading="lazy" decoding="async" data-lazy-eager="1"><div class="play-comment-body"><strong>${escape(item.userName||'用户')}</strong>${reply}<p>${escape(item.text)}</p><div class="play-comment-meta">${timestamp}<button type="button" data-comment-like="${item.id}" data-comment-count="${count}" aria-pressed="${liked}" aria-label="点赞 ${escape(item.userName||'用户')} 的评论"><span data-request-label>${liked?'♥':'♡'} ${formatCount(count)}</span></button><button type="button" data-reply="${item.id}" data-parent="${parent}" data-reply-name="${escape(item.userName)}">回复</button></div>${children.length?`<div class="play-comment-replies" aria-label="评论回复">${children.map(comment=>commentMarkup(comment,true)).join('')}</div>`:''}</div></article>`;
}
export function commentListMarkup(data:PageData<Comment>,page:number,sort:number,href:(page:number,sort:number)=>string):string {
return (typeof data.error==='string'?empty(data.error,true):recordList<Comment>(data.list).map(item=>commentMarkup(item)).join('')||empty('暂无评论,来说说你的看法吧'))+pagination(data.totalCount||0,page,value=>href(value,sort),10);
</div></div>`;
}
+36 -99
View File
@@ -1,56 +1,55 @@
import { contentPath } from '../routing';
import type { Chapter, Comment, Drama, DramaDetail, PageData } from "../api/types";
import type { Chapter, Drama, DramaDetail, PageData } from "../api/types";
import type { SiteContext } from "./context";
import { empty, escape } from "./render";
import { configureDownloadLinks } from "./download";
import { bindSeriesDescription } from "./detail";
import { bindChapterUnlock } from "./unlock";
import { chapterAccess, chapterUnlockCost, initialPlayMode, MediaPlayback, playbackUrl, type PlayMode, type PlayerActions } from './playback';
import { chapterAccess, initialPlayMode, MediaPlayback, playbackUrl, type PlayMode, type PlayerActions } from './playback';
import { dramaSeoContent } from '../seo';
import type { PlayerInitialData } from '../page-data';
import { commentListMarkup, episodeItems, episodeTabs, orderedChapters, playerMarkup } from './player-markup';
import { publicComment } from '../player-data';
import { formatCount, recordList } from '../api/protocol';
import { orderedChapters, playerMarkup, RECOMMEND_PAGE_SIZE } from './player-markup';
import { formatCount } from '../api/protocol';
import { applyEngagementSuccess, beginButtonRequest, type EngagementKind } from './request-feedback';
declare global { interface Window { installPlayerControls?: (screen: HTMLElement, episode?: number, actions?: PlayerActions)=>void } }
interface PlayerPagination {page?:number;commentSort?:number;pageHref?:(page:number,sort:number)=>string;initial?:PlayerInitialData;preserveMarkup?:boolean}
interface PlayerOptions {initial?:PlayerInitialData;preserveMarkup?:boolean}
export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,isVideo=false,paging:PlayerPagination={}):Promise<()=>void> {
/** Keep the current film-strip tile visible without scrolling the page itself. */
function revealCurrentEpisode(page:HTMLElement){
const strip=page.querySelector<HTMLElement>('.film-strip'),current=strip?.querySelector<HTMLElement>('.film-tile.is-current');
if(!strip||!current)return;
const left=current.offsetLeft-(strip.clientWidth-current.offsetWidth)/2;
strip.scrollLeft=Math.max(0,left);
}
export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,isVideo=false,options:PlayerOptions={}):Promise<()=>void> {
if(isVideo)throw new Error('当前接口暂未提供剧集内容');
const signal=app.signal;
const page=document.getElementById('drama-play-page')!;
const raw = paging.initial?.detail || await app.api.get<DramaDetail>('playlet/detail',{id},signal);
const raw = options.initial?.detail || await app.api.get<DramaDetail>('playlet/detail',{id},signal);
if(signal.aborted)return ()=>{};
if(!raw?.id||raw.id!==id)throw new Error('该剧集不存在或已下架');
const [chapters,related]=await Promise.all([
paging.initial?Promise.resolve(paging.initial.chapters):app.api.get<{list?:Chapter[]}>('playlet/chapter/list',{playletId:id},signal).then(data=>data?.list).catch(error=>{if(signal.aborted)throw error;return raw.playletChapterInfo;}),
paging.initial?Promise.resolve(undefined):app.api.get<PageData<Drama>>('playlet/guess-like/list',{playletId:id,pageNum:1,pageSize:6},signal).catch(error=>{if(signal.aborted)throw error;return undefined;}),
options.initial?Promise.resolve(options.initial.chapters):app.api.get<{list?:Chapter[]}>('playlet/chapter/list',{playletId:id},signal).then(data=>data?.list).catch(error=>{if(signal.aborted)throw error;return raw.playletChapterInfo;}),
options.initial?Promise.resolve(undefined):app.api.get<PageData<Drama>>('playlet/guess-like/list',{playletId:id,pageNum:1,pageSize:RECOMMEND_PAGE_SIZE},signal).catch(error=>{if(signal.aborted)throw error;return undefined;}),
]);
if(!paging.initial)raw.relatedRecommendData=related?.list||[];
if(!options.initial)raw.relatedRecommendData=related?.list||[];
if(signal.aborted)return ()=>{};
const ordered=orderedChapters(chapters ?? raw.playletChapterInfo);
const first=ordered.find(item=>item.currentEpisode===1)||ordered[0];
let chapter: Chapter | null = null;
if(chapterId){
if(ordered.length&&!ordered.some(item=>item.id===chapterId))throw new Error('该章节不存在或已下架');
chapter=paging.initial?.chapter || await app.api.get<Chapter>('playlet/chapter/detail',{playletId:id,chapterId},signal);
chapter=options.initial?.chapter || await app.api.get<Chapter>('playlet/chapter/detail',{playletId:id,chapterId},signal);
if(!signal.aborted&&(!chapter||chapter.id!==chapterId||chapter.playletId&&chapter.playletId!==id))throw new Error('章节信息不匹配,请重新选择');
}
if(signal.aborted)return ()=>{};
const overview=!chapterId&&!isVideo;
const unlockCost=chapterUnlockCost(chapter);
const source=isVideo?playbackUrl(raw.video?.url):'';
const access=isVideo?{allowed:!!source,gate:source?'':'unavailable',source} as const:chapterAccess(chapter);
const previewSource=isVideo?'':playbackUrl(raw.preview);
let mode:PlayMode=initialPlayMode(access,raw.preview);
page.classList.toggle('is-overview',overview);
const commentPage=paging.page||1,commentSort=paging.commentSort===1?1:2;
const basePath=contentPath(isVideo?'video':chapterId?'play':'detail',raw,chapterId);
const pageHref=paging.pageHref||((page:number,sort:number)=>`${basePath}${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`);
app.updateSeo?.({...dramaSeoContent(raw,chapter||undefined),failed:false,unavailable:isVideo&&!source});
const objectId=id, objectType=10;
const engagement=raw;
const pendingEngagements=new Set<EngagementKind>();
function syncEngagement(kind:EngagementKind,buttons:HTMLButtonElement[]){
@@ -62,74 +61,37 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
const value=kind==='like'?engagement.countLike:engagement.countCollect;
page.querySelectorAll<HTMLElement>(`[data-engage-count="${kind}"]`).forEach(node=>{node.textContent=`${formatCount(value)}${kind==='like'?'点赞':'收藏'}`;});
}
if(!paging.preserveMarkup)page.innerHTML=playerMarkup(raw,ordered,{chapter,isVideo,page:commentPage,commentSort,pageHref,config:app.config,home:app.home,user:app.api.user,comments:paging.initial?.comments});
if(!options.preserveMarkup)page.innerHTML=playerMarkup(raw,ordered,{chapter,isVideo,config:app.config,home:app.home});
configureDownloadLinks(app.config, page);
window.tracker?.setDrama({...raw,playletChapterInfo:ordered},chapter||first,ordered,isVideo);
const disposeDescription=bindSeriesDescription(page,signal);
let episodeSize=matchMedia('(max-width:760px)').matches?15:45;
let episodePage=chapter?Math.floor(Math.max(0,ordered.findIndex(item=>item.id===chapter!.id))/episodeSize):0;
function renderEpisodes(){
const size=matchMedia('(max-width:760px)').matches?15:45;
if(size!==episodeSize){episodePage=Math.floor(episodePage*episodeSize/size);episodeSize=size;}
const pages=Math.ceil(ordered.length/size);
episodePage=Math.min(Math.max(0,episodePage),Math.max(0,pages-1));
const segment=page.querySelector('.play-episode-pages'),grid=page.querySelector('.play-episode-grid');
if(!grid||!segment)return;
segment.innerHTML=episodeTabs(ordered,size,episodePage);
const track=segment.querySelector<HTMLElement>('.episode-page-track'),selected=segment.querySelector<HTMLElement>('[aria-pressed="true"]');
if(track&&selected){
const bounds=track.getBoundingClientRect(),tab=selected.getBoundingClientRect();
if(tab.left<bounds.left)track.scrollLeft+=tab.left-bounds.left;
else if(tab.right>bounds.right)track.scrollLeft+=tab.right-bounds.right;
}
grid.innerHTML=episodeItems(id,ordered,chapter,size,episodePage,raw.slug);
}
renderEpisodes();
window.addEventListener('resize',renderEpisodes,{signal});
let commentSeq=0,replyParent=0,replyId=0;
let initialComments=paging.initial?.comments;
async function comments(){
const seq=++commentSeq;
const list=page.querySelector<HTMLElement>('.play-comment-list')!;
try{
const existing=initialComments;initialComments=undefined;
const raw=existing || await app.api.get<PageData<Comment>>('comment/list',{objectId,objectType,pageNum:commentPage,pageSize:10,sortOrder:commentSort},signal);
if(signal.aborted||seq!==commentSeq)return;
const data=existing?raw:{...raw,list:recordList<Comment>(raw.list).map(item=>publicComment(item))};
app.updateSeo?.({totalCount:data.totalCount,pageSize:10});
page.querySelector('#play-comments-title')!.textContent=`评论 (${data.totalCount||0})`;
if(!(existing&&paging.preserveMarkup))list.innerHTML=commentListMarkup(data,commentPage,commentSort,pageHref);
}catch(error){if(!signal.aborted&&seq===commentSeq)list.innerHTML=empty(error instanceof Error?error.message:'评论加载失败');}
}
void comments();
revealCurrentEpisode(page);
const video=page.querySelector<HTMLVideoElement>('video');
const chapterUnlock=bindChapterUnlock(app,id,chapter,raw.slug);
let media:MediaPlayback|undefined;
let reported=false;
if(video){
const box=page.querySelector<HTMLElement>('.play-screen')!;
const status=page.querySelector<HTMLElement>('.play-source-status')!;
media=new MediaPlayback(video,{signal,status:message=>{status.textContent=message;},error:()=>{window.tracker?.playbackFailed(video);box.dispatchEvent(new Event('playback:error'));}});
const toggle=async()=>{if(mode==='main'&&!access.allowed){chapterUnlock.openCurrent();return;}await media?.toggle();};
window.installPlayerControls?.(box,chapter?.currentEpisode||1,{toggle,retry:async()=>{if(mode==='main'&&!access.allowed){chapterUnlock.openCurrent();return;}window.tracker?.prepareDramaModeChange();await media?.retry();}});
const gate=box.querySelector<HTMLElement>('.play-app-gate');
const appOnly=['coins','vip'].includes(access.gate);
media=new MediaPlayback(video,{signal,status:message=>{status.textContent=message;},error:()=>{box.dispatchEvent(new Event('playback:error'));}});
const blocked=()=>mode==='main'&&!access.allowed;
const toggle=async()=>{if(blocked())return;await media?.toggle();};
window.installPlayerControls?.(box,chapter?.currentEpisode||1,{toggle,retry:async()=>{if(blocked())return;await media?.retry();}});
const position=ordered.findIndex(item=>item.id===chapter?.id);
const previous=box.querySelector<HTMLButtonElement>('[data-play-action="previous"]');
const next=box.querySelector<HTMLButtonElement>('[data-play-action="next"]');
if(previous)previous.disabled=position<=0;if(next)next.disabled=position<0||position>=ordered.length-1;
const unlock=document.createElement('button');unlock.type='button';unlock.className='primary';unlock.dataset.showUnlock='';
unlock.textContent=access.gate==='vip'?'查看会员套餐':`使用 ${unlockCost} 金币解锁`;
box.querySelector('.play-screen-overlay')!.append(unlock);
function setMode(nextMode:PlayMode){
window.tracker?.prepareDramaModeChange();
mode=nextMode;box.dataset.playMode=mode;
page.querySelectorAll<HTMLButtonElement>('[data-play-mode]').forEach(button=>{if(button.tagName==='BUTTON')button.setAttribute('aria-pressed',String(button.dataset.playMode===mode));});
const blocked=mode==='main'&&!access.allowed;
unlock.hidden=!blocked||!['coins','vip'].includes(access.gate);
const locked=blocked()&&appOnly;
if(gate)gate.hidden=!locked;
box.classList.toggle('is-app-gated',locked);
box.querySelector('.ui-empty-state')?.remove();
const center=box.querySelector<HTMLButtonElement>('[data-media="toggle"]');if(center)center.hidden=false;
status.hidden=false;
const center=box.querySelector<HTMLButtonElement>('[data-media="toggle"]');if(center)center.hidden=locked;
status.hidden=locked;
void media!.setSource(mode==='preview'?previewSource:access.allowed?access.source:'');
if(blocked)status.textContent=access.gate==='vip'?'开通会员后可观看本集':access.gate==='coins'?'本集需要金币解锁':'本集暂不可观看';
if(blocked())status.textContent=appOnly?'请在 App 观看':'本集暂不可观看';
else if(!(mode==='preview'?previewSource:access.source))status.textContent='该剧集暂未提供视频源';
else if(mode==='preview')status.textContent='免费预告 · 点击播放';
}
@@ -144,11 +106,7 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
}
page.addEventListener('click',event=>{void (async()=>{
const element=(event.target as Element).closest<HTMLButtonElement>('button');if(!element||element.disabled)return;
if(element.dataset.lockedChapter){const selected=ordered.find(item=>item.id===Number(element.dataset.lockedChapter));if(selected)await chapterUnlock.openChapter(selected);return;}
if(element.dataset.episodePage){episodePage=Number(element.dataset.episodePage);renderEpisodes();}
if(element.dataset.episodePage==='0'){episodePage=0;renderEpisodes();}
if(element.hasAttribute('data-show-unlock'))chapterUnlock.openCurrent();
if(element.dataset.playAction){const index=ordered.findIndex(item=>item.id===chapter?.id);const next=ordered[index+(element.dataset.playAction==='next'?1:-1)];if(next){window.tracker?.dramaStatusChange(next);app.navigate(contentPath('play',raw,next.id));}}
if(element.dataset.playAction){const index=ordered.findIndex(item=>item.id===chapter?.id);const next=ordered[index+(element.dataset.playAction==='next'?1:-1)];if(next)app.navigate(contentPath('play',raw,next.id));}
if(element.dataset.engage){
const kind=element.dataset.engage;if(kind!=='like'&&kind!=='collect'||pendingEngagements.has(kind))return;
const active=kind==='like'?engagement.isLike===1:engagement.isCollect===1;
@@ -163,28 +121,7 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
}finally{pendingEngagements.delete(kind);finish.forEach(done=>done());}
return;
}
if(element.hasAttribute('data-share')){const finish=beginButtonRequest(element,'复制中');try{await navigator.clipboard.writeText(location.href);if(!signal.aborted){window.tracker?.dramaEvent('ep_share');app.notify('页面链接已复制');}}finally{finish();}return;}
if(element.dataset.commentSort){const sort=Number(element.dataset.commentSort);if(sort===1||sort===2)app.navigate(pageHref(1,sort));return;}
if(element.dataset.reply){replyId=Number(element.dataset.reply);replyParent=Number(element.dataset.parent);page.querySelector('#reply-context')!.innerHTML=`<div class="play-reply-context"><span>回复 @${escape(element.dataset.replyName)}</span><button type="button" data-cancel-reply>取消回复</button></div>`;page.querySelector<HTMLTextAreaElement>('textarea')!.focus();}
if(element.hasAttribute('data-cancel-reply')){replyId=replyParent=0;page.querySelector('#reply-context')!.textContent='';}
if(element.dataset.commentLike){
const active=element.getAttribute('aria-pressed')==='true',state={isLike:active?1:0,countLike:Number(element.dataset.commentCount)||0};
const finish=beginButtonRequest(element);
try{
const result=await app.api.post<unknown>('comment/like/set',{commentId:Number(element.dataset.commentLike),isLike:!active});
if(signal.aborted)return;
applyEngagementSuccess(state,'like',active,result);
element.setAttribute('aria-pressed',String(state.isLike===1));element.dataset.commentCount=String(state.countLike);
const label=element.querySelector('[data-request-label]')||element;label.textContent=`${state.isLike===1?'♥':'♡'} ${formatCount(state.countLike)}`;
}finally{finish();}
}
if(element.hasAttribute('data-share')){const finish=beginButtonRequest(element,'复制中');try{await navigator.clipboard.writeText(location.href);if(!signal.aborted)app.notify('页面链接已复制');}finally{finish();}return;}
})().catch(error=>app.notify(error instanceof Error?error.message:'操作失败'));},{signal});
page.querySelector('textarea[name="comment"]')?.addEventListener('input',event=>{const counter=page.querySelector('#play-comment-count');if(counter)counter.textContent=String((event.target as HTMLTextAreaElement).value.length);},{signal});
page.querySelector('.play-comment-form')!.addEventListener('submit',event=>{
event.preventDefault();const form=event.target as HTMLFormElement;const input=form.elements.namedItem('comment') as HTMLTextAreaElement;const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;
if(button.disabled||!input.value.trim())return;
const finish=beginButtonRequest(button,'发送中');
void app.api.post('comment/send',{objectId,objectType,text:input.value.trim(),parentsId:replyParent,replyCommentId:replyId}).then(async()=>{if(signal.aborted)return;input.value='';const counter=page.querySelector('#play-comment-count');if(counter)counter.textContent='0';replyId=replyParent=0;page.querySelector('#reply-context')!.textContent='';page.querySelector('#comment-error')!.textContent='';app.notify('评论已发表');if(commentPage>1){app.navigate(pageHref(1,commentSort));return;}await comments();}).catch(error=>{if(!signal.aborted)page.querySelector('#comment-error')!.textContent=error.message;}).finally(finish);
},{signal});
return ()=>{disposeDescription();chapterUnlock.dispose();media?.dispose();};
return ()=>{disposeDescription();media?.dispose();};
}
+13 -8
View File
@@ -9,7 +9,6 @@ export function imageAttributes(value: unknown, fallback = PLACEHOLDER, target:
return `${target}="${escape(fallback)}"${source ? ` data-${target}="${escape(source)}" data-fallback="${escape(fallback)}"` : ''}`;
}
export const title = (drama: Drama): string => drama.title || drama.playName || "未命名剧集";
export const itemAttributes=(item:Drama,video=false)=>`data-track-item data-item-id="${escape(item.id)}" data-item-type="${video?'video':'short_drama'}" data-item-name="${escape(title(item))}" data-recommend-trace-id="${escape(item.recommendTraceId)}" data-recommend-id="${escape(item.recommendId)}" data-recommend-trace-info="${escape(item.recommendTraceInfo)}"`;
export function activeBanners(home: HomeData | null | undefined): Drama[] {
const items=recordList<Drama>(home?.homeBannerData);
return (items.some(item=>item.showOnBanner)?items.filter(item=>item.showOnBanner):items).slice(0,6);
@@ -51,7 +50,7 @@ export function card(drama: Drama, video = false, options: {rank?:number;keyword
const text=title(drama),keyword=options.keyword?.trim();
const heading=keyword?highlightSearchTitle(text,keyword):escape(text);
const headingTag=options.headingLevel===2?'h2':'h3';
const attributes=`${itemAttributes(drama,video)} href="${contentPath(video?"video":"detail",drama)}"`;
const attributes=`href="${contentPath(video?"video":"detail",drama)}"`;
const episodes=!video&&drama.totalEpisode?`<span class="drama-episodes">${escape(drama.totalEpisode)}集</span>`:'';
const description=`<span class="drama-description">${escape(drama.desc || drama.description || "")}</span>`;
const meta=`<span class="drama-meta"><span class="drama-tags">${tags.slice(0,2).map(tag=>`<span>${escape(tag)}</span>`).join("")}</span><span class="drama-heat"><span class="drama-heat-icon" aria-hidden="true"></span>${formatCount(drama.countLike ?? drama.heatValue ?? 0)}</span></span>`;
@@ -60,7 +59,12 @@ export function card(drama: Drama, video = false, options: {rank?:number;keyword
const number=rank?String(rank).padStart(2,'0'):'';
return `<a class="ranked-card drama-data-card" ${attributes} aria-label="${rank?`第 ${rank} 名,`:''}查看${escape(text)}"><span class="media portrait">${poster}${badge}${episodes}<span class="mobile-cover-rank" aria-hidden="true">${number}</span></span><span class="rank-caption"><strong aria-hidden="true">${number}</strong><span><${headingTag}>${heading}</${headingTag}></span></span>${description}${meta}</a>`;
}
return `<a class="card drama-data-card" ${attributes} aria-label="查看${escape(text)}"><span class="cover">${rank?`<span class="ranking-poster-frame">${poster}</span>`:poster}${badge}${episodes}${rank?`<span class="rank-home-badge" data-rank="${rank}" aria-label="第 ${rank} 名">${String(rank).padStart(2,'0')}</span>`:''}</span><${headingTag}>${heading}</${headingTag}>${description}${meta}</a>`;
const total=typeof drama.totalEpisode==='number'&&Number.isFinite(drama.totalEpisode)?Math.max(0,Math.floor(drama.totalEpisode)):0;
const counter=video||total<=1?'单集':`1/${total}${drama.updateStatus===1?'+':''}`;
const counterLabel=video||total<=1?'单集视频':`共 ${total} 集${drama.updateStatus===1?',更新中':''}`;
const theme=(Array.isArray(drama.themeNames)?drama.themeNames:[]).find(name=>typeof name==='string'&&name.trim())||tags[0]||'';
const views=`<span class="hg-card-views"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>${formatCount(drama.heatValue ?? drama.countBrowse ?? drama.countLike ?? 0)}</span>`;
return `<a class="hg-card" ${attributes} aria-label="查看${escape(text)}"><span class="hg-card-cover">${rank?`<span class="ranking-poster-frame">${poster}</span>`:poster}<span class="hg-card-counter" aria-label="${counterLabel}">${escape(counter)}</span>${badge?`<span class="hg-card-badge">${badge}</span>`:''}${rank?`<span class="rank-home-badge" data-rank="${rank}" aria-label="第 ${rank} 名">${String(rank).padStart(2,'0')}</span>`:''}</span><${headingTag} class="hg-card-title">${heading}</${headingTag}><span class="hg-card-rule" aria-hidden="true"></span><span class="hg-card-meta">${views}${theme?`<span class="hg-card-dot" aria-hidden="true">·</span><span class="hg-card-theme">${escape(theme)}</span>`:''}</span></a>`;
}
export function hero(value: unknown, selected = 0): string {
@@ -68,7 +72,7 @@ export function hero(value: unknown, selected = 0): string {
if (!items.length) return empty("暂无推荐剧集");
const index=items[selected]?selected:0,current=items[index];
const href=contentPath('detail',current);
return `<a class="hero-feature-art" ${itemAttributes(current)} href="${href}" aria-label="查看${escape(title(current))}"><img class="hero-art" ${imageAttributes(current.banner || current.horizontalCover || current.cover)} alt="${escape(title(current))}推荐海报" fetchpriority="high" decoding="async"></a><div class="hero-feature-bottom"><div class="hero-feature-title-row"><h2 class="hero-feature-title"><a ${itemAttributes(current)} href="${href}">${escape(title(current))}</a></h2><div class="hero-feature-controls"><button type="button" data-hero-step="-1" aria-label="上一张推荐"${items.length<2?' disabled':''}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14 5-7 7 7 7"/></svg></button><button type="button" data-hero-step="1" aria-label="下一张推荐"${items.length<2?' disabled':''}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m10 5 7 7-7 7"/></svg></button></div></div><div class="hero-feature-dots" role="group" aria-label="选择轮播推荐">${items.map((item,i)=>`<button type="button" data-feature="${i}" aria-label="切换到第 ${i+1} 张轮播:${escape(title(item))}" aria-pressed="${i===index}"><span></span></button>`).join('')}</div></div>`;
return `<a class="hero-feature-art" href="${href}" aria-label="查看${escape(title(current))}"><img class="hero-art" ${imageAttributes(current.banner || current.horizontalCover || current.cover)} alt="${escape(title(current))}推荐海报" fetchpriority="high" decoding="async"></a><div class="hero-feature-bottom"><div class="hero-feature-title-row"><h2 class="hero-feature-title"><a href="${href}">${escape(title(current))}</a></h2><div class="hero-feature-controls"><button type="button" data-hero-step="-1" aria-label="上一张推荐"${items.length<2?' disabled':''}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14 5-7 7 7 7"/></svg></button><button type="button" data-hero-step="1" aria-label="下一张推荐"${items.length<2?' disabled':''}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m10 5 7 7-7 7"/></svg></button></div></div><div class="hero-feature-dots" role="group" aria-label="选择轮播推荐">${items.map((item,i)=>`<button type="button" data-feature="${i}" aria-label="切换到第 ${i+1} 张轮播:${escape(title(item))}" aria-pressed="${i===index}"><span></span></button>`).join('')}</div></div>`;
}
/** The fixed recommendation grid uses real enabled homepage content, independently of the carousel. */
@@ -82,7 +86,7 @@ export function heroFeed(value:unknown):string {
const items=recordList<Drama>(value);if(!items.length)return '';
return `<div class="hero-feed-grid" aria-label="精选横版作品">${items.map(item=>{
const tags=(Array.isArray(item.tagNames)?item.tagNames:Array.isArray(item.themeNames)?item.themeNames:[]).filter(tag=>typeof tag==='string');
return `<a class="hero-feed-card drama-data-card" ${itemAttributes(item)} href="${contentPath('detail',item)}" aria-label="查看${escape(title(item))}"><span class="hero-feed-cover"><img class="poster-source" ${imageAttributes(item.horizontalCover||item.banner||item.cover||item.coverImg)} alt="${escape(title(item))}封面" loading="lazy" decoding="async">${item.totalEpisode?`<span class="drama-episodes">${escape(item.totalEpisode)}集</span>`:''}</span><strong title="${escape(title(item))}">${escape(title(item))}</strong><span class="drama-meta"><span class="drama-tags">${tags.slice(0,2).map(tag=>`<span>${escape(tag)}</span>`).join('')}</span></span></a>`;
return `<a class="hero-feed-card drama-data-card" href="${contentPath('detail',item)}" aria-label="查看${escape(title(item))}"><span class="hero-feed-cover"><img class="poster-source" ${imageAttributes(item.horizontalCover||item.banner||item.cover||item.coverImg)} alt="${escape(title(item))}封面" loading="lazy" decoding="async">${item.totalEpisode?`<span class="drama-episodes">${escape(item.totalEpisode)}集</span>`:''}</span><strong title="${escape(title(item))}">${escape(title(item))}</strong><span class="drama-meta"><span class="drama-tags">${tags.slice(0,2).map(tag=>`<span>${escape(tag)}</span>`).join('')}</span></span></a>`;
}).join('')}</div>`;
}
@@ -92,7 +96,7 @@ export function topicCard(topic: Topic, theme = false, headingLevel:2|3=3,kind:'
const count=Number(topic.playletCount);
const hasCount=Number.isFinite(count)&&count>0;
const headingTag=headingLevel===2?'h2':'h3';
return `<a class="topic-recommendation-card" data-track-item data-item-id="${escape(topic.id)}" data-item-type="topic" data-item-name="${escape(topic.name||'精选专题')}" href="${contentPath(kind,topic)}" aria-label="${hasCount?`查看${escape(label)},${count}部短剧`:`查看${escape(label)}`}"><img ${imageAttributes(topic.icon || topic.covers?.[0] || topic.list?.[0]?.cover)} alt="${escape(label)}封面" loading="lazy" decoding="async"><span class="topic-recommendation-copy">${hasCount?`<small>${count}部短剧</small>`:''}<${headingTag}>${escape(topic.name)}</${headingTag}></span></a>`;
return `<a class="topic-recommendation-card" href="${contentPath(kind,topic)}" aria-label="${hasCount?`查看${escape(label)},${count}部短剧`:`查看${escape(label)}`}"><img ${imageAttributes(topic.icon || topic.covers?.[0] || topic.list?.[0]?.cover)} alt="${escape(label)}封面" loading="lazy" decoding="async"><span class="topic-recommendation-copy">${hasCount?`<small>${count}部短剧</small>`:''}<${headingTag}>${escape(topic.name)}</${headingTag}></span></a>`;
}
export function ads(items: unknown = [], className = "promo-app"): string {
const tile=className==='promo-tile';
@@ -100,7 +104,8 @@ export function ads(items: unknown = [], className = "promo-app"): string {
return recordList<Advertisement>(items).filter(item=>safeUrl(item.images)).map(item=>{
const label=escape(String(item.title || "").trim() || "推广广告");
const image=`<img ${imageAttributes(item.images)} alt="${label}" loading="lazy"${tile||iconTile?` width="${tile?132:76}" height="${tile?132:76}" decoding="async"`:''}>`;
return `<a class="${escape(className)}" data-track-ad="external" data-ad-id="${escape(item.advertiseCode||item.id)}" data-ad-clickable="${safeUrl(item.link)?1:0}" data-ad-slot-key="${escape(item.advertiseLocationCode||String(item.location||(tile?'promo-app':className)))}" data-ad-slot-name="${escape(item.advertiseLocationName||"推广广告")}" data-ad-type="${className==="promo-app"||tile||iconTile?"icon":"banner"}" data-creative-id="" href="${escape(safeUrl(item.link)||"/apps/")}" target="_blank" rel="sponsored nofollow noopener noreferrer">${tile?`<span class="promo-tile-image">${image}</span><strong>${escape(item.title)}</strong>`:`${image}<span>${escape(item.title)}</span>`}</a>`;
const link=safeUrl(item.link);
return `<a class="${escape(className)}"${link?` href="${escape(link)}" target="_blank" rel="sponsored nofollow noopener noreferrer"`:''}>${tile?`<span class="promo-tile-image">${image}</span><strong>${escape(item.title)}</strong>`:`${image}<span>${escape(item.title)}</span>`}</a>`;
}).join("");
}
@@ -143,7 +148,7 @@ export function renderHome(home: HomeData | null | undefined, error?: string): s
const banners = activeBanners(home);
const blocks = homeBlocks(home);
const announcement = recordList<{content?: string; title?: string}>(home?.announcements).map(item=>item.content||item.title).filter(Boolean).join(" · ");
return `<section class="hero-grid"><div class="hero${homeRecommendations(home).length?'':' hero--single'}" id="hero"><div class="hero-feature-carousel" id="hero-feature-carousel" aria-roledescription="轮播" aria-label="精选推荐">${hero(banners)}</div>${heroFeed(homeRecommendations(home))}</div></section><section class="home-promotions" aria-label="公告与推荐应用">${announcement?`<div class="announcement-bar"><span class="announcement-label" aria-hidden="true"><svg class="announcement-solid-icon" viewBox="0 0 1024 1024" aria-hidden="true" fill="currentColor"><path d="M407.505455 311.621818a113.570909 113.570909 0 0 0-189.207273 40.261818L52.363636 807.796364a90.763636 90.763636 0 0 0 113.338182 118.225454l456.145455-152.901818a116.363636 116.363636 0 0 0 46.545454-189.672727zM519.912727 325.818182h6.050909a37.701818 37.701818 0 0 0 36.770909-31.650909c23.272727-143.592727 6.516364-215.04 5.818182-218.065455A37.003636 37.003636 0 0 0 523.869091 46.545455a38.167273 38.167273 0 0 0-27.927273 46.545454s13.265455 62.370909-6.749091 188.741818A37.701818 37.701818 0 0 0 519.912727 325.818182zM658.850909 347.694545a37.003636 37.003636 0 0 0 50.967273-13.730909 335.592727 335.592727 0 0 0 31.883636-126.603636 37.701818 37.701818 0 0 0-35.374545-39.563636 37.236364 37.236364 0 0 0-39.098182 35.607272 268.334545 268.334545 0 0 1-21.876364 93.090909 37.701818 37.701818 0 0 0 13.498182 51.2zM721.454545 454.283636a37.701818 37.701818 0 0 0 23.272728 71.912728 85.876364 85.876364 0 0 1 69.818182 11.636363 37.003636 37.003636 0 0 0 52.363636-6.749091 37.934545 37.934545 0 0 0-5.818182-52.829091 159.650909 159.650909 0 0 0-139.636364-23.970909z"/><path d="M837.818182 246.690909a70.749091 69.818182 90 1 0 139.636363 0 70.749091 69.818182 90 1 0-139.636363 0Z"/></svg></span><div class="announcement-viewport" tabindex="0" aria-label="${escape(announcement)}"><div class="announcement-track" aria-hidden="true"><span>${escape(announcement)} · </span><span>${escape(announcement)} · </span></div></div></div>`:""}${promotionRow(home?.advitise?.nineGridList)}</section><div id="home-blocks">${homeBlocksMarkup(blocks,home?.advitise?.detailbannerList,error)}</div><section class="topic-section popular-searches home-trending-topic"><div class="section-head"><h2>大家都在搜</h2></div><nav class="rank-search-tags" id="popular-search-tags" aria-label="大家都在搜关键词">${recordList<{keyword:string}>(home?.hotSearch).map(item=>`<a class="popular-search-tag" href="${escape(searchPath(item.keyword||''))}">${escape(item.keyword)}</a>`).join('')}</nav></section><section class="bottom-app-prompt" data-app-download-entry hidden><div class="bottom-app-copy"><div class="bottom-app-eyebrow">禁果短剧 App</div><h2>在 App 上继续追剧,离线缓存随时看</h2><p>竖屏沉浸播放 · 离线缓存 · 弹幕互动</p></div><a class="bottom-app-download" data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer">点击下载</a></section>`;
return `<section class="hero-grid"><div class="hero${homeRecommendations(home).length?'':' hero--single'}" id="hero"><div class="hero-feature-carousel" id="hero-feature-carousel" aria-roledescription="轮播" aria-label="精选推荐">${hero(banners)}</div>${heroFeed(homeRecommendations(home))}</div></section><section class="home-promotions" aria-label="公告与推荐应用">${announcement?`<div class="announcement-bar"><span class="announcement-label" aria-hidden="true"><svg class="announcement-solid-icon" viewBox="0 0 1024 1024" aria-hidden="true" fill="currentColor"><path d="M407.505455 311.621818a113.570909 113.570909 0 0 0-189.207273 40.261818L52.363636 807.796364a90.763636 90.763636 0 0 0 113.338182 118.225454l456.145455-152.901818a116.363636 116.363636 0 0 0 46.545454-189.672727zM519.912727 325.818182h6.050909a37.701818 37.701818 0 0 0 36.770909-31.650909c23.272727-143.592727 6.516364-215.04 5.818182-218.065455A37.003636 37.003636 0 0 0 523.869091 46.545455a38.167273 38.167273 0 0 0-27.927273 46.545454s13.265455 62.370909-6.749091 188.741818A37.701818 37.701818 0 0 0 519.912727 325.818182zM658.850909 347.694545a37.003636 37.003636 0 0 0 50.967273-13.730909 335.592727 335.592727 0 0 0 31.883636-126.603636 37.701818 37.701818 0 0 0-35.374545-39.563636 37.236364 37.236364 0 0 0-39.098182 35.607272 268.334545 268.334545 0 0 1-21.876364 93.090909 37.701818 37.701818 0 0 0 13.498182 51.2zM721.454545 454.283636a37.701818 37.701818 0 0 0 23.272728 71.912728 85.876364 85.876364 0 0 1 69.818182 11.636363 37.003636 37.003636 0 0 0 52.363636-6.749091 37.934545 37.934545 0 0 0-5.818182-52.829091 159.650909 159.650909 0 0 0-139.636364-23.970909z"/><path d="M837.818182 246.690909a70.749091 69.818182 90 1 0 139.636363 0 70.749091 69.818182 90 1 0-139.636363 0Z"/></svg></span><div class="announcement-viewport" tabindex="0" aria-label="${escape(announcement)}"><div class="announcement-track" aria-hidden="true"><span>${escape(announcement)} · </span><span>${escape(announcement)} · </span></div></div></div>`:""}${promotionRow(home?.advitise?.nineGridList)}</section><div id="home-blocks">${homeBlocksMarkup(blocks,home?.advitise?.detailbannerList,error)}</div><section class="topic-section popular-searches home-trending-topic"><div class="section-head"><h2>大家都在搜</h2></div><nav class="rank-search-tags" id="popular-search-tags" aria-label="大家都在搜关键词">${recordList<{keyword:string}>(home?.hotSearch).map(item=>`<a class="popular-search-tag" href="${escape(searchPath(item.keyword||''))}">${escape(item.keyword)}</a>`).join('')}</nav></section><section class="bottom-app-prompt" data-app-download-entry hidden><div class="bottom-app-copy"><div class="bottom-app-eyebrow">黄果短剧 App</div><h2>在 App 上继续追剧,离线缓存随时看</h2><p>竖屏沉浸播放 · 离线缓存 · 弹幕互动</p></div><a class="bottom-app-download" data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer">点击下载</a></section>`;
}
export type PageHref=(page:number)=>string;
+2 -2
View File
@@ -2,7 +2,7 @@ import { contentPath, searchPath } from '../routing';
import type { Drama, PageData } from '../api/types';
import { recordList } from '../api/protocol';
import type { ApiClient } from './api';
import { card, coverCornerBadge, escape, imageAttributes, itemAttributes, pagination, title, type PageHref } from './render';
import { card, coverCornerBadge, escape, imageAttributes, pagination, title, type PageHref } from './render';
export const SEARCH_PAGE_SIZE=25;
@@ -21,7 +21,7 @@ export function searchPicksMarkup(value:unknown):string {
return recordList<Drama>(value).slice(0,3).map(item=>{
const name=title(item);
const badge=coverCornerBadge(item);
return `<a class="search-pick" ${itemAttributes(item)} href="${contentPath('detail',item)}" aria-label="查看${escape(name)}"><span class="search-pick-cover"><img class="poster-source" ${imageAttributes(item.cover||item.coverImg||item.horizontalCover)} alt="${escape(name)}封面" width="160" height="240" loading="lazy" decoding="async">${badge}${item.totalEpisode?`<span class="drama-episodes">${escape(item.totalEpisode)}集</span>`:''}</span><span class="search-pick-body"><strong>${escape(name)}</strong><small>${escape(item.desc||item.description||'')}</small></span></a>`;
return `<a class="search-pick" href="${contentPath('detail',item)}" aria-label="查看${escape(name)}"><span class="search-pick-cover"><img class="poster-source" ${imageAttributes(item.cover||item.coverImg||item.horizontalCover)} alt="${escape(name)}封面" width="160" height="240" loading="lazy" decoding="async">${badge}${item.totalEpisode?`<span class="drama-episodes">${escape(item.totalEpisode)}集</span>`:''}</span><span class="search-pick-body"><strong>${escape(name)}</strong><small>${escape(item.desc||item.description||'')}</small></span></a>`;
}).join('');
}
+3 -29
View File
@@ -205,15 +205,6 @@ export function startStartupDialogs(): StartupDialogs {
const media = ad.querySelector<HTMLAnchorElement>('.startup-ad-media')!;
const loading = ad.querySelector<HTMLElement>('.startup-ad-loading')!;
const close = ad.querySelector<HTMLButtonElement>('.startup-close')!;
function track(eventType: string, element: HTMLElement = media) {
try {
if (eventType === 'close') {
if (window.tracker?.advertising) window.tracker.advertising(element, eventType);
else document.dispatchEvent(new CustomEvent('dialog-ad:track', {detail: {element, eventType}}));
} else if (window.tracker?.externalAd) window.tracker.externalAd(element, eventType);
else document.dispatchEvent(new CustomEvent('external-ad:track', {detail: {element, eventType}}));
} catch { /* Tracking does not interrupt display or dismissal. */ }
}
const view: StartupView = {
showAge() {if (!age.open) age.showModal();},
closeAge() {
@@ -236,19 +227,12 @@ export function startStartupDialogs(): StartupDialogs {
image.alt = image.title = item.title?.trim() || '推荐广告';
media.setAttribute('aria-label', item.title || '查看广告');
if (item.link) media.href = item.link; else media.removeAttribute('href');
media.dataset.adId = item.advertiseCode || item.id || item.images;
media.dataset.adClickable = item.link ? '1' : '0';
media.dataset.adLocation = String(item.location || '');
media.dataset.adSlotKey = item.advertiseLocationCode || String(item.location || '');
media.dataset.adSlotName = item.advertiseLocationName || '弹窗广告位';
media.dataset.adPlacement = 'startup-ad';
media.hidden = false; loading.hidden = true; close.disabled = false;
ad.removeAttribute('aria-busy');
ad.setAttribute('aria-label', item.title || '广告推荐');
if (!ad.open) ad.showModal();
track('show');
},
hideAdvertisement() {if (ad.open) {if (!media.hidden) track('close'); ad.close();} media.hidden = true; loading.hidden = true; ad.removeAttribute('aria-busy');},
hideAdvertisement() {if (ad.open) ad.close(); media.hidden = true; loading.hidden = true; ad.removeAttribute('aria-busy');},
async loadGrid(items, signal) {
const links = new Map<Advertisement, HTMLAnchorElement>();
const loaded = await Promise.all(items.map(async item => {
@@ -258,14 +242,6 @@ export function startStartupDialogs(): StartupDialogs {
link.rel = 'sponsored nofollow noopener noreferrer';
link.setAttribute('aria-label', item.title || '查看推荐应用');
if (item.link) link.href = item.link;
link.dataset.trackAd = 'external';
link.dataset.adPlacement = 'startup-grid';
link.dataset.adType = 'icon';
link.dataset.adId = item.advertiseCode || item.id || item.images;
link.dataset.adClickable = item.link ? '1' : '0';
link.dataset.adLocation = String(item.location || '');
link.dataset.adSlotKey = item.advertiseLocationCode || String(item.location || 'nineGridList');
link.dataset.adSlotName = item.advertiseLocationName || '宫格弹窗广告位';
const icon = document.createElement('img');
icon.width = icon.height = 80;
icon.alt = item.title || '推荐应用';
@@ -287,9 +263,8 @@ export function startStartupDialogs(): StartupDialogs {
promotions.replaceChildren(...items.map(item => gridLinks.get(item)!));
promotions.scrollTop = 0;
if (!grid.open) grid.showModal();
document.dispatchEvent(new Event('tracker:refresh'));
},
hideGrid() {if (grid.open) {promotions.querySelectorAll<HTMLElement>('.startup-promotion').forEach(link => track('close', link)); grid.close();}},
hideGrid() {if (grid.open) grid.close();},
};
const storage: SessionStore = {
getItem: key => window.sessionStorage.getItem(key),
@@ -300,7 +275,7 @@ export function startStartupDialogs(): StartupDialogs {
age.addEventListener('cancel', event => event.preventDefault());
age.querySelector('.startup-age-confirm')!.addEventListener('click', () => startup.confirmAge());
age.querySelectorAll('.startup-age-leave,.startup-close').forEach(button => button.addEventListener('click', () => location.assign('https://www.google.com')));
function nextAdvertisement() {if (ad.open && !media.hidden) track('close'); startup.nextAdvertisement();}
function nextAdvertisement() {startup.nextAdvertisement();}
close.addEventListener('click', nextAdvertisement);
ad.addEventListener('cancel', event => {event.preventDefault(); nextAdvertisement();});
grid.querySelector('.startup-close')!.addEventListener('click', () => startup.dismissGrid());
@@ -314,6 +289,5 @@ export function startStartupDialogs(): StartupDialogs {
dialog.addEventListener('pointerdown', () => dialog.classList.remove('startup-keyboard-focus'));
dialog.addEventListener('close', () => dialog.classList.remove('startup-keyboard-focus'));
});
media.addEventListener('click', () => {if (ad.open && !media.hidden && media.hasAttribute('href')) track('click');});
return startup;
}
-136
View File
@@ -1,136 +0,0 @@
import { contentPath } from '../routing';
import type { Chapter } from '../api/types';
import { ApiError, EXPIRED_CODES } from '../api/protocol';
import type { SiteContext } from './context';
import { chapterAccess, chapterUnlockCost } from './playback';
export function bindChapterUnlock(app: SiteContext, playletId: number, currentChapter: Chapter | null, slug?:string) {
const signal = app.signal;
const dialog = document.getElementById('play-episode-lock') as HTMLDialogElement;
const pay = dialog.querySelector<HTMLButtonElement>('.unlock-pay')!;
const close = dialog.querySelector<HTMLButtonElement>('.unlock-close')!;
const error = dialog.querySelector<HTMLElement>('#episode-lock-error')!;
const description = dialog.querySelector<HTMLElement>('#episode-lock-description')!;
let selected: Chapter | null = null;
let lookup = 0;
let unlocking = false;
const showError = (message = '') => { error.textContent = message; error.hidden = !message; };
const play = (chapter: Chapter) => app.navigate(contentPath('play',{id:playletId,slug},chapter.id));
const recharge = () => { dialog.close(); app.navigate('/info/deposit/'); };
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0;
const balance = (chapter: Chapter) => {
if (finite(chapter.balance) && chapter.balance > 0) return chapter.balance;
if (finite(app.api.user?.recharge)) return app.api.user.recharge;
return finite(chapter.balance) ? chapter.balance : 0;
};
const syncWallet = async (chapter: Chapter) => {
if (finite(chapter.balance) && chapter.balance > 0) return;
await app.api.refreshUser?.().catch(() => {});
};
const failed = (reason: unknown) => {
if (reason instanceof ApiError && (reason.code === 401 || EXPIRED_CODES.has(reason.code))) {
dialog.close(); app.openAuth(); return;
}
showError(reason instanceof Error ? reason.message : '读取章节失败,请重试');
};
function present(chapter: Chapter) {
if (signal.aborted) return;
const access = chapterAccess(chapter);
if (access.allowed) { dialog.close(); play(chapter); return; }
if (access.gate === 'vip') { dialog.close(); app.navigate('/info/deposit/vip/'); return; }
if (access.gate !== 'coins') { selected = null; pay.disabled = true; showError('本集暂不可解锁,请稍后重试'); return; }
selected = chapter;
const cost = chapterUnlockCost(chapter), coins = balance(chapter);
description.textContent = `第 ${chapter.currentEpisode || 1} 集 · 解锁后可永久观看本集内容。`;
dialog.querySelector('#episode-lock-balance')!.textContent = `${coins} 金币`;
dialog.querySelector('.unlock-cost')!.textContent = `${cost} 金币`;
pay.textContent = coins < cost ? '金币不足,去充值' : cost > 0 ? `支付 ${cost} 金币并播放` : '解锁并播放';
pay.setAttribute('aria-label',coins < cost ? `金币不足,充值后解锁第 ${chapter.currentEpisode || 1} 集` : `确认解锁第 ${chapter.currentEpisode || 1} 集,消耗 ${cost} 金币`);
pay.disabled = unlocking;
close.disabled = unlocking;
showError();
if (!dialog.open) dialog.showModal();
}
async function openChapter(summary: Chapter) {
if (signal.aborted || unlocking) return;
if (dialog.open && dialog.dataset.chapterId === String(summary.id)) return;
const sequence = ++lookup;
selected = null;
dialog.dataset.chapterId = String(summary.id);
description.textContent = `正在读取第 ${summary.currentEpisode || 1} 集解锁信息…`;
dialog.querySelector('#episode-lock-balance')!.textContent = '— 金币';
dialog.querySelector('.unlock-cost')!.textContent = '— 金币';
pay.textContent = '读取中…'; pay.disabled = true; close.disabled = false;
showError();
if (!dialog.open) dialog.showModal();
try {
const chapter = await app.api.get<Chapter>('playlet/chapter/detail',{playletId,chapterId:summary.id},signal);
if (signal.aborted || sequence !== lookup || !dialog.open) return;
if (!chapter || chapter.id !== summary.id || chapter.playletId && chapter.playletId !== playletId) throw new Error('章节信息不匹配,请重新选择');
await syncWallet(chapter);
if (signal.aborted || sequence !== lookup || !dialog.open) return;
present(chapter);
} catch (reason) { if (!signal.aborted && sequence === lookup && dialog.open) failed(reason); }
}
function openCurrent() {
if (!currentChapter || signal.aborted || unlocking || chapterAccess(currentChapter).allowed) return;
const chapter = currentChapter;
const sequence = ++lookup;
dialog.dataset.chapterId = String(chapter.id);
void syncWallet(chapter).then(() => {
if (!signal.aborted && sequence === lookup) present(chapter);
});
}
close.addEventListener('click',() => { if (!unlocking) dialog.close(); },{signal});
dialog.addEventListener('cancel',event => { if (unlocking) event.preventDefault(); },{signal});
dialog.addEventListener('close',() => { lookup++; selected = null; },{signal});
dialog.addEventListener('click',event => {
if (event.target !== dialog || unlocking) return;
const bounds = dialog.getBoundingClientRect();
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dialog.close();
},{signal});
dialog.querySelector('.unlock-recharge')?.addEventListener('click',event => { if (unlocking) event.preventDefault(); else dialog.close(); },{signal});
pay.addEventListener('click',() => { void (async () => {
if (signal.aborted || unlocking || pay.disabled || !dialog.open || !selected) return;
const chapter = selected;
if (balance(chapter) < chapterUnlockCost(chapter)) { recharge(); return; }
const confirmText = pay.textContent;
unlocking = true; pay.disabled = true; close.disabled = true; pay.textContent = '解锁中…';
dialog.setAttribute('aria-busy','true'); showError();
try {
const result = await app.api.post<{balance?:number}>('playlet/chapter/unlock',{chapterId:chapter.id,playletId});
if (signal.aborted) return;
if (app.api.user && typeof result?.balance === 'number' && Number.isFinite(result.balance) && result.balance >= 0) app.api.setUser({...app.api.user,recharge:result.balance});
await app.api.refreshUser().catch(() => {});
if (signal.aborted) return;
dialog.close(); app.notify('解锁成功');
window.tracker?.dramaStatusChange(chapter,'ep_unlock');
if (chapter.id === currentChapter?.id) await app.route();
else play(chapter);
} catch (reason) {
if (signal.aborted) return;
if ((reason instanceof ApiError && reason.code === 400) || (reason instanceof Error && /余额.*不足|金币.*不足/.test(reason.message))) { recharge(); return; }
failed(reason);
} finally {
unlocking = false;
if (!signal.aborted) {
dialog.removeAttribute('aria-busy'); close.disabled = false;
pay.disabled = !selected || !dialog.open;
if (selected && dialog.open) pay.textContent = confirmText;
}
}
})(); },{signal});
const dispose = () => {
lookup++; selected = null;
dialog.removeAttribute('aria-busy'); close.disabled = false; pay.disabled = true;
if (dialog.open) dialog.close();
};
signal.addEventListener('abort',dispose,{once:true});
return {openChapter,openCurrent,dispose};
}
+25
View File
@@ -0,0 +1,25 @@
import { buildSdkUserAgent, buildUserAgent } from '../api/user-agent';
/** Encode JSON as base64 (UTF-8 safe) for header transport. */
function base64JSON(value: unknown): string {
const bytes = new TextEncoder().encode(JSON.stringify(value));
return btoa(Array.from(bytes, byte => String.fromCharCode(byte)).join(''));
}
/**
* 业务请求头沿用原 SDK 字段的格式,但设备号只使用访客设备号(drama_device / 游客 devID)。
* 不再加载统计 SDK,也不再附带任何埋点事件上下文。
*/
export function visitorHeaders(devID = '', userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent): Record<string, string> {
const parsed = Object.fromEntries(buildUserAgent(userAgent).split(';').map(part => {
const index = part.indexOf('=');
return index < 0 ? [part, ''] : [part.slice(0, index), part.slice(index + 1)];
}));
const system = parsed.system_name || '';
const device = system === 'Android' ? 'Android' : system === 'iOS' ? 'iOS' : ['Windows', 'macOS', 'Linux'].includes(system) ? 'PC' : 'OTHER';
const headers: Record<string, string> = {
'X-User-Agent': buildSdkUserAgent({device_id: devID, device, device_brand: parsed.device_brand, device_model: parsed.device_model, system_name: system, system_version: parsed.system_version, app_version: '1.0.0', sid: ''}),
};
if (devID) headers['X-SDK-Common'] = base64JSON({sid: '', device_id: devID, app_version: '1.0.0'});
return headers;
}
+6
View File
@@ -0,0 +1,6 @@
/** 全站唯一对外联系方式:商务合作(Telegram @jbd125)。不再读取后台的邮箱 / QQ / X / 交流群配置。 */
export const BUSINESS_CONTACT = {
label: "商务合作",
handle: "@jbd125",
href: "https://t.me/jbd125",
} as const;
+13 -3
View File
@@ -32,7 +32,8 @@ export function footerLinkHref(urlValue: string | undefined): string {
const raw = (urlValue || '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw;
if (raw.startsWith('mailto:')) return raw;
// Email / customer-service channels are no longer offered.
if (raw.startsWith('mailto:')) return '';
if (/^[a-z][a-z\d+.-]*:/i.test(raw)) return '';
const path = (raw.startsWith('/') ? raw : `/${raw}`).replace(/^\/richetext(?=\/|$)/i, '/richtext');
const titled = path.match(/^\/richtext\/([^?#]+?)\/?$/i);
@@ -46,16 +47,25 @@ export function footerLinkHref(urlValue: string | undefined): string {
return path.endsWith('/') ? path : `${path}/`;
}
/** Removed site features: user center, wallet/recharge, customer-service feedback and the welfare app directory. */
const REMOVED_PAGE = /^\/(?:member|apps|application|info\/(?:feedback|user-center|settings|deposit))(?:\/|$)/i;
const REMOVED_NAME = /客服|反馈|个人中心|充值|账号设置|福利应用/;
/** True for configured links that point at pages this site no longer provides. */
export function removedFooterLink(name: string, href: string): boolean {
return REMOVED_NAME.test(name) || REMOVED_PAGE.test(href);
}
export function footerPolicyLinks(data: FooterInfoResponse | null | undefined): FooterPolicyLink[] {
return footerNavItems(data).flatMap((item, index) => {
const name = item.name?.trim() || '';
const href = footerLinkHref(item.urlValue);
if (!name || !href) return [];
if (!name || !href || removedFooterLink(name, href)) return [];
return [{
id: typeof item.id === 'number' && Number.isSafeInteger(item.id) ? item.id : index,
name,
href,
external: /^https?:\/\//i.test(href) || href.startsWith('mailto:'),
external: /^https?:\/\//i.test(href),
}];
});
}
+12 -48
View File
@@ -1,6 +1,8 @@
import type { Config, FooterContact } from "./api/types";
/** Original HTML copy and markup; the about page's contacts use site configuration. */
import { BUSINESS_CONTACT } from './contact';
/** Original HTML copy and markup; the about page shows the single business contact. */
export interface InfoPageDefinition {key:string;title:string;html:string}
const EMPTY_CONTACT_SECTION='<section class="about-section" data-site-contacts hidden></section>';
@@ -9,7 +11,7 @@ export const INFO_PAGES:Readonly<Record<string,InfoPageDefinition>>={
"about-us": {key:"about-us",title:"关于我们",html:`
<section class="about-section">
<h2>品牌介绍</h2>
<p>禁果短剧为喜爱短剧的观众汇集多种题材与精选作品,让找剧、追剧和观看变得轻松便捷。</p>
<p>黄果短剧为喜爱短剧的观众汇集多种题材与精选作品,让找剧、追剧和观看变得轻松便捷。</p>
<p>我们相信故事能够带来共鸣,也希望让更多创作者的作品与喜欢它们的观众相遇。</p>
</section>
<section class="about-section">
@@ -22,7 +24,7 @@ ${EMPTY_CONTACT_SECTION}
"user-agreement": {key:"user-agreement",title:"用户协议",html:`
<section class="about-section">
<h2>使用协议</h2>
<p>使用条款 欢迎您光临【禁果短剧】(以下简称本网站),本网站由「禁果短剧」(以下简称禁果短剧)经营。</p>
<p>使用条款 欢迎您光临【黄果短剧】(以下简称本网站),本网站由「黄果短剧」(以下简称黄果短剧)经营。</p>
<p>本网站重视每一位使用者所享有的服务,特此说明本网站的使用政策,以保障您的权益。</p>
<p>请您仔细阅读本使用条款的内容:</p>
</section>
@@ -120,7 +122,7 @@ ${EMPTY_CONTACT_SECTION}
`},
"privacy-policy": {key:"privacy-policy",title:"隐私政策",html:`
<section class="about-section">
<p>禁果短剧(以下简称本公司)非常重视您的隐私权,并依据《个人资料保护法》(以下简称个资法)的规定,向您告知下列有关隐私权保护政策的更多内容。</p>
<p>黄果短剧(以下简称本公司)非常重视您的隐私权,并依据《个人资料保护法》(以下简称个资法)的规定,向您告知下列有关隐私权保护政策的更多内容。</p>
</section>
<section class="about-section">
<h2>一、本公司使用您个人资料的目的</h2>
@@ -187,10 +189,6 @@ ${EMPTY_CONTACT_SECTION}
<summary>VIP 和金币是否可以购买?</summary>
<p>这些标识用于展示剧集类型。目前没有充值、订阅或购买功能。</p>
</details>
<details>
<summary>如何反馈问题?</summary>
<p>前往<a href="/info/feedback/">意见反馈</a>,填写问题类型、设备和详细描述,再将反馈保存到本地。当前版本没有线上提交功能。</p>
</details>
`},
"2257": {key:"2257",title:"2257 合规声明",html:`
<section class="compliance-section">
@@ -218,7 +216,7 @@ ${EMPTY_CONTACT_SECTION}
<span class="compliance-number" aria-hidden="true">04</span>
<div>
<h2>合规说明</h2>
<p>如发现疑似违规内容,请通过<a href="/info/feedback/">意见反馈</a>提供线索,以便核查处理。具体义务以适用法律法规为准。</p>
<p>如发现疑似违规内容,请通过<a href="https://t.me/jbd125" target="_blank" rel="nofollow noopener noreferrer">商务合作 Telegram @jbd125</a>提供线索,以便核查处理。具体义务以适用法律法规为准。</p>
</div>
</section>
`},
@@ -248,50 +246,16 @@ ${EMPTY_CONTACT_SECTION}
<span class="compliance-number" aria-hidden="true">04</span>
<div>
<h2>联系我们</h2>
<p>版权通知及反通知请通过本站<a href="/info/feedback/">意见反馈</a>提交,并一并提供核查所需的资料。</p>
<p>版权通知及反通知请通过<a href="https://t.me/jbd125" target="_blank" rel="nofollow noopener noreferrer">商务合作 Telegram @jbd125</a>提交,并一并提供核查所需的资料。</p>
</div>
</section>
`},
};
const contactText=(value:unknown)=>typeof value==='string'?value.trim().replace(/立即(?:联系|加入)/g,'').replace(/^[\s::]+|[\s::]+$/g,''):'';
const escapeContact=(value:string)=>value.replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]!));
function contactValue(value:unknown):{text:string;href:string}|undefined {
const text=contactText(value);
if(!text||/[\x00-\x1f\x7f]/.test(text)||/mailto:|[^/\s@]+@[^/\s@]+\./i.test(text))return;
if(/^tel:\+?[\d() .-]+$/i.test(text))return {text:text.slice(4),href:text};
const url=text.startsWith('//')?'https:'+text:/^[\w.-]+\.[a-z]{2,}(?:[/?#]|$)/i.test(text)?'https://'+text:text;
try{
const parsed=new URL(url);
if(['https:','http:'].includes(parsed.protocol)&&!parsed.username&&!parsed.password)return {text,href:parsed.href};
return;
}catch{
if(/^[\w-]+:/i.test(text))return;
return {text,href:''};
}
}
export function infoContactSection(config:Config={},contacts:FooterContact[]|null=[]):string {
const settings=config.config,addresses=config.newAddressInfo;
const telegram=(...values:unknown[])=>values.map(value=>{
const text=contactText(value);
return contactValue(/^@[a-z\d_]{4,}$/i.test(text)?'https://t.me/'+text.slice(1):text);
}).find(value=>value?.href&&/^https?:/.test(value.href));
const business=telegram(settings?.telegram,settings?.telegramAdd,addresses?.telegram,addresses?.telegramAdd);
const group=telegram(settings?.tgGroup,addresses?.tgGroup);
const rows:{name:string;text:string;href:string}[]=[];
if(business)rows.push({name:'商务合作',...business});
if(group)rows.push({name:'用户反馈',...group});
for(const contact of Array.isArray(contacts)?contacts:[]){
if(!contact||typeof contact!=='object')continue;
const name=contactText(contact.name);if(!name)continue;
if(/商务|商户|合作|反馈|客服|support|官方群|telegram|tg群|邮箱|邮件|e-?mail/i.test(name))continue;
const value=contactValue(contact.value);
if(value)rows.push({name,...value});
}
if(!rows.length)return EMPTY_CONTACT_SECTION;
return `<section class="about-section" data-site-contacts><h2>联系我们</h2>${rows.map(({name,text,href})=>`<p>${escapeContact(name)}:${href?`<a href="${escapeContact(href)}"${/^https?:/i.test(href)?' target="_blank" rel="sponsored nofollow noopener noreferrer"':''}>${escapeContact(text)}</a>`:escapeContact(text)}</p>`).join('')}</section>`;
/** 关于我们页只展示唯一的商务合作联系方式;后台配置的邮箱 / QQ / 交流群等不再展示。 */
export function infoContactSection(_config:Config={},_contacts:FooterContact[]|null=[]):string {
const {label,handle,href}=BUSINESS_CONTACT;
return `<section class="about-section" data-site-contacts><h2>联系我们</h2><p>${label}:<a href="${href}" target="_blank" rel="nofollow noopener noreferrer">Telegram ${handle}</a></p></section>`;
}
export function getInfoPage(key:string,config:Config={},contacts:FooterContact[]|null=[]):InfoPageDefinition|undefined {
+4 -6
View File
@@ -1,5 +1,6 @@
import type { Config } from './api/types';
import { recordList, safeUrl } from './api/protocol';
import { SITE_NAME, brandText } from './brand';
const object=(value:unknown):Record<string,unknown>=>{
if(typeof value==='string'){try{return object(JSON.parse(value));}catch{return {};}}
@@ -7,7 +8,6 @@ const object=(value:unknown):Record<string,unknown>=>{
};
const text=(value:unknown)=>typeof value==='string'?value.replace(/<[^>]*>/g,'').replace(/[\u0000-\u001f\u007f]/g,' ').replace(/\s+/g,' ').trim():'';
const firstText=(...values:unknown[])=>values.map(text).find(Boolean)||'';
const telegramUrl=(value:unknown)=>safeUrl(typeof value==='string'&&/^@[a-z\d_]{4,}$/i.test(value.trim())?'https://t.me/'+value.trim().slice(1):value);
const lineLabel=(line:number)=>'线路'+(['','一','二','三','四','五','六','七','八','九','十'][line]||line);
/** Navigation uses only configured destinations; missing values never link to a demo site. */
@@ -15,7 +15,8 @@ export function navigationPageData(config:Config) {
const settings=object(config.config),addresses=object(config.newAddressInfo);
const seo=object(config.seoInfo),navigationSeo=object(config.seoNavInfo);
const url=(key:string)=>[settings[key],addresses[key]].map(value=>safeUrl(value)).find(Boolean)||'';
const siteName=firstText(navigationSeo.dhSiteName,navigationSeo.siteName,settings.dhSiteName,seo.webSiteName,settings.webSiteName)||'禁果短剧';
const configuredName=firstText(navigationSeo.dhSiteName,navigationSeo.siteName,settings.dhSiteName,seo.webSiteName,settings.webSiteName);
const siteName=brandText(configuredName)||SITE_NAME;
const seen=new Set<number>();
let routes=recordList<{line?:unknown;url?:unknown}>(config.navPageList).flatMap(item=>{
const line=Number(item.line),href=safeUrl(item.url);
@@ -30,10 +31,7 @@ export function navigationPageData(config:Config) {
}
return {
siteName,
description:firstText(navigationSeo.dhDescription,navigationSeo.description,settings.dhDescription)||'全网最新最火短剧,禁果为你呈现',
description:brandText(firstText(navigationSeo.dhDescription,navigationSeo.description,settings.dhDescription))||`全网最新最火短剧,${SITE_NAME}为你呈现`,
routes,permanent:url('foreverUrlAddr'),
telegram:[settings.telegram,settings.telegramAdd,addresses.telegram,addresses.telegramAdd].map(telegramUrl).find(Boolean)||'',
tgGroup:[settings.tgGroup,addresses.tgGroup].map(telegramUrl).find(Boolean)||'',
twitter:url('twitterAddr'),
};
}
+3 -7
View File
@@ -1,15 +1,11 @@
import type { Chapter, Comment, Config, Drama, DramaDetail, FooterInfoResponse, Package, PageData, User } from './api/types';
import type { Chapter, Config, DramaDetail, FooterInfoResponse, User } from './api/types';
export interface SiteSharedData {config:Config;footer:FooterInfoResponse;trackerConfigured:boolean}
export interface MemberInitialData {user:User;list?:PageData<Drama>}
export interface WalletInitialData {user:User;packages:{coinList?:Package[];vipList?:Package[]};transactions:PageData<Record<string,unknown>>}
export interface SiteSharedData {config:Config;footer:FooterInfoResponse}
export interface PlayerInitialData {
detail:DramaDetail;
chapters:Chapter[];
chapter:Chapter|null;
comments?:PageData<Comment>;
}
/** Only the first route can consume this snapshot; later navigation loads fresh data. */
@@ -20,7 +16,7 @@ export interface PageBootstrap {
subtitle?:string;
player?:PlayerInitialData;
search?:{keyword:string;total:number;page:number;pageSize:number};
info?:{key:string;member?:MemberInitialData;wallet?:WalletInitialData;feedback?:boolean};
info?:{key:string};
session?:{user:User|null;state:string};
}
+4 -24
View File
@@ -1,4 +1,4 @@
import type { Advertisement, Advertisements, Chapter, Comment, DramaDetail, PageData } from './api/types';
import type { Advertisement, Advertisements, Chapter, DramaDetail } from './api/types';
import type { PlayerInitialData } from './page-data';
import { recordList } from './api/protocol';
import { normalizeSeo } from './seo';
@@ -41,29 +41,9 @@ function publicChapter(data:Chapter):Chapter {
return result;
}
function commentAvatar(data:Record<string,unknown>):string {
for(const key of ['userAvatar','avatar']){
const value=data[key];
if(typeof value==='string'&&value.trim())return value.trim();
}
return '';
}
/** Strip tokens and keep avatar URLs for client decrypt via data-src. */
export function publicComment(data:Comment,depth=0):Comment {
const source=data as unknown as Record<string,unknown>;
const result=fields(data,['text','userName','userAvatar','createdAt','replyUserName'],['id','isLike','countLike','parentsId','replyCommentId','commentCount','level']) as unknown as Comment;
const avatar=commentAvatar(source);
if(avatar)result.userAvatar=avatar;
if(depth<4)result.children=recordList<Comment>(data.children).map(item=>publicComment(item,depth+1));
return result;
}
/** Explicit DTO: no auth tokens, SDK configuration, or arbitrary upstream properties. */
export function playerInitialData(raw:DramaDetail,chapters:Chapter[],chapter:Chapter|null,comments?:PageData<Comment>):PlayerInitialData {
export function playerInitialData(raw:DramaDetail,chapters:Chapter[],chapter:Chapter|null):PlayerInitialData {
const detail=publicDrama(raw);
detail.relatedRecommendData=recordList<DramaDetail>(raw.relatedRecommendData).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).slice(0,6).map(publicDrama);
return {detail,chapters:chapters.map(publicChapter),chapter:chapter?publicChapter(chapter):null,
...(comments?{comments:{list:recordList<Comment>(comments.list).map(item=>publicComment(item)),...(typeof comments.error==='string'?{error:comments.error.slice(0,300)}:{}),totalCount:typeof comments.totalCount==='number'?comments.totalCount:0}}:{}),
};
detail.relatedRecommendData=recordList<DramaDetail>(raw.relatedRecommendData).filter(item=>Number.isSafeInteger(item.id)&&item.id>0&&item.id!==raw.id).slice(0,12).map(publicDrama);
return {detail,chapters:chapters.map(publicChapter),chapter:chapter?publicChapter(chapter):null};
}
+3 -9
View File
@@ -15,9 +15,8 @@ export function getRankPage(slug='hot') {
return RANK_PAGES.find(page=>page.slug===slug);
}
const channels = new Set(['shorts', 'topics', 'rank', 'apps', 'home-road']);
const members = new Set(['center', 'favorites', 'history', 'likes', 'purchased', 'settings']);
const information = new Set(['about-us', 'user-agreement', 'privacy-policy', 'help-center', '2257', 'dmca', 'feedback', 'user-center', 'settings', 'deposit']);
const channels = new Set(['shorts', 'topics', 'rank', 'home-road']);
const information = new Set(['about-us', 'user-agreement', 'privacy-policy', 'help-center', '2257', 'dmca']);
const id = (value?: string) => value !== undefined && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value)>0;
const richTextTitle = (value?: string) => {
const title = (value || '').trim();
@@ -66,19 +65,14 @@ function baseRouteFromSegments(parts: string[]): SiteRoute | null {
if (group === 'detail' && parts.length === 2 && contentKey(key)) return {group, key};
if (group === 'play' && parts.length === 3 && contentKey(key) && id(value)) return {group, key, value};
if (group === 'search' && parts.length === 2 && key.trim()) return {group, key};
if (group === 'member' && parts.length === 2 && members.has(key)) return {group, key};
if (group === 'info' && parts.length === 2 && information.has(key)) return {group, key};
if (group === 'info' && key === 'deposit' && value === 'vip' && parts.length === 3) return {group, key, value};
if ((group === 'richtext' || group === 'richetext') && parts.length === 2 && richTextTitle(key)) return {group: 'richtext', key: richTextTitle(key)};
return null;
}
export function supportsPagination(route:SiteRoute):boolean {
if(route.group==='page')return ['shorts','topics','rank','theme','tag','config'].includes(route.key);
if(route.group==='member')return ['favorites','history','likes','purchased'].includes(route.key);
if(route.group==='info')return route.key==='deposit';
if(route.group==='search')return true;
return ['detail','play'].includes(route.group);
return route.group==='search';
}
export function routeFromSegments(parts:string[]):SiteRoute|null {
+15 -53
View File
@@ -4,27 +4,24 @@ import type { Metadata } from 'next';
import { randomUUID } from 'node:crypto';
import { isIP } from 'node:net';
import { getHeaderNavigation, getHome, getServerApiBaseUrl, upstream } from './api/server';
import type { Config, DramaDetail, Chapter, Comment, Drama, FooterInfoResponse, NavigationCategory, PageData, RichTextListResponse, User } from './api/types';
import { withoutRemovedNavigation } from './site-navigation';
import type { Config, DramaDetail, Chapter, Drama, FooterInfoResponse, NavigationCategory, PageData, RichTextListResponse, User } from './api/types';
import { ApiError, EXPIRED_CODES, recordList } from './api/protocol';
import { contentPath, contentQuery, matchesContentKey, parseSitePath } from './routing';
import { getInfoPage } from './info-pages';
import { buildPageHead, dramaSeoContent, homeSeoContent, infoSeoContent, listSeoContent, resolveSeoEnvironment, type PageHead, type SeoBootstrap, type SeoContent } from './seo';
import { SeoSnapshotStore } from './seo-snapshot.server';
import type { PageBootstrap, PageView, WalletInitialData } from './page-data';
import type { PageBootstrap, PageView } from './page-data';
import type { ApiClient } from './client/api';
import { playerInitialData } from './player-data';
import { loadChannel, channelView } from './client/channel';
import { orderedChapters, playerMarkup } from './client/player-markup';
import { orderedChapters, playerMarkup, RECOMMEND_PAGE_SIZE } from './client/player-markup';
import { loadSearchResults, searchErrorMarkup, searchResultsMarkup } from './client/search';
import { paginationPath } from './routing';
import { empty, escape } from './client/render';
import { loadAppCategories, renderAppDirectory } from './client/apps';
import { homeRoad } from './client/config';
import { feedbackMarkup } from './client/feedback';
import { memberMarkup, walletMarkup } from './client/account';
import { publicUser } from './api/public-user';
import { SSR_SESSION_HEADER, SSR_USER_HEADER } from './api/page-session';
import { accountList, accountPackages, accountTransactions } from './account-data';
import { publicSiteData } from './site-shared-data';
const snapshotStores=new Map<string,SeoSnapshotStore>();
@@ -51,7 +48,7 @@ export const getSeoConfig=cache(async ():Promise<{config:Config;error?:string}>=
const getCategories=cache(async ():Promise<NavigationCategory[]|undefined>=>{
try{
const result=await upstream<{categoryList?:NavigationCategory[]}>('translation/category/list',{timeoutMs:5000});
return result.code===200&&Array.isArray(result.data?.categoryList)?result.data.categoryList:undefined;
return result.code===200&&Array.isArray(result.data?.categoryList)?withoutRemovedNavigation({categoryList:result.data.categoryList}).categoryList!:undefined;
}catch{return undefined;}
});
const getFooterInfo=cache(async ():Promise<FooterInfoResponse>=>{
@@ -118,34 +115,6 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
const query:Record<string,string|number>={pageNum:route.page||1,pageSize:24};
try{
if(!route.group){const home=await getHome();return {...homeSeoContent(home.data),failed:!!home.error};}
if(route.group==='member'||route.group==='info'&&['deposit','user-center','settings'].includes(route.key)){
const wallet=route.key==='deposit',tab=route.group==='member'?route.key:route.key==='settings'?'settings':'center';
const title=wallet?(route.value==='vip'?'会员畅享':'金币充值'):({center:'个人中心',history:'观看历史',favorites:'我的收藏',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置'} as Record<string,string>)[tab];
const session=await preparedSession(),pageHref=(page:number)=>paginationPath(address,page);
if(!session.user){
// jha-1048: cold visits have no SSR guest. login-required stays signed-out;
// unavailable waits for client GuestAuthBootstrap instead of a hard 500.
const html=session.state==='login-required'
? empty('请先登录账号')+'<button type="button" class="primary" data-auth-mode="login">登录账号</button>'
: empty('正在准备账号,请稍候…');
return {view:infoView(address,route.key,title,html,{session})};
}
try{
if(wallet){
const [packages,transactions]=await Promise.all([
contentRequest<WalletInitialData['packages']>('recharge/package/list',{}),
contentRequest<WalletInitialData['transactions']>('coin/trans/list',{page:route.page||1,pageSize:10,type:0},true).catch(()=>({list:[],totalCount:0,error:true})),
]);
const initial:WalletInitialData={user:session.user,packages:accountPackages(packages),transactions:accountTransactions(transactions)};
return {...(transactions.error?{failed:(route.page||1)>1}:{totalCount:initial.transactions.totalCount}),pageSize:10,view:infoView(address,'deposit',title,walletMarkup(session.user,initial.packages,initial.transactions,route.value==='vip',route.page||1,pageHref),{session,info:{key:'deposit',wallet:initial}})};
}
const active=tab==='center'?'history':tab,paths:Record<string,string>={history:'playlet/history/list',favorites:'playlet/collect/list',likes:'playlet/like/list',purchased:'playlet/bought/list'};
const list=tab==='settings'?undefined:accountList(await contentRequest<PageData<Drama>>(paths[active],query,true));
const initial={user:session.user,list};
const shared=await getSiteSharedData();
return {totalCount:list?.totalCount,pageSize:24,view:infoView(address,tab,title,memberMarkup(session.user,tab,list,{advitise:shared.config.advitise},route.page||1,pageHref),{session,info:{key:tab,member:initial}})};
}catch{return {failed:true,view:infoView(address,route.key,title,empty('页面暂不可用,请稍后重试',true),{session})};}
}
if(route.group==='richtext'){
const query=new URLSearchParams({titles:route.key});
const result=await upstream<RichTextListResponse>(`config/richtext/list?${query}`,{timeoutMs:5000});
@@ -159,7 +128,6 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
return {title:heading,description:description||undefined,indexable:!!body,view:infoView(address,'richtext',heading,html)};
}
if(route.group==='info'){
if(route.key==='feedback')return {view:infoView(address,'feedback','意见反馈',feedbackMarkup(),{info:{key:'feedback',feedback:true}})};
const configuration=await getSeoConfig();
const footer=route.key==='about-us'?await getFooterInfo():undefined;
const info=getInfoPage(route.key,configuration.config,footer?.footerInfo?.contactList||[]);
@@ -173,7 +141,6 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
return {totalCount:total,pageSize,view:{panel:'search',title:'搜索结果',html:searchResultsMarkup(results,value=>paginationPath(address,value)),bootstrap:{path:address,panel:'search',title:'搜索结果',search:{keyword,total,page,pageSize}}}};
}
if(['detail','play'].includes(route.group)){
const commentSort=params.get('commentSort')==='1'?1:2;
const detailRequest=contentRequest<DramaDetail>('playlet/detail',contentQuery(route.key),true);
const playletQuery=contentQuery(route.key,'playletId','playletSlug');
// Slug-aware chapter reads stay parallel. Only ID-only endpoints wait for slug resolution.
@@ -181,13 +148,12 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
if(!matchesContentKey(detail,route.key)||!Number.isSafeInteger(detail.id)||detail.id<=0)throw new ApiError('剧集不存在',404);
return detail.id;
});
const [detail,chapterResult,chapters,comments,configuration,recommendations]=await Promise.all([
const [detail,chapterResult,chapters,configuration,recommendations]=await Promise.all([
detailRequest,
route.group==='play'?contentRequest<Chapter>('playlet/chapter/detail',{...playletQuery,chapterId:route.value!},true):Promise.resolve(undefined),
contentRequest<{list?:Chapter[]}>('playlet/chapter/list',playletQuery,true).then(data=>data.list).catch(()=>undefined),
resolvedId.then(objectId=>contentRequest<PageData<Comment>>('comment/list',{objectId,objectType:10,pageNum:route.page||1,pageSize:10,sortOrder:commentSort},true)).catch(()=>({list:[],totalCount:0,error:'评论暂不可用,请稍后重试'})),
getSeoConfig(),
resolvedId.then(playletId=>contentRequest<PageData<Drama>>('playlet/guess-like/list',{playletId,pageNum:1,pageSize:6})).catch(()=>undefined),
resolvedId.then(playletId=>contentRequest<PageData<Drama>>('playlet/guess-like/list',{playletId,pageNum:1,pageSize:RECOMMEND_PAGE_SIZE})).catch(()=>undefined),
]);
let chapter:Chapter|undefined;
if(route.group==='play'){
@@ -198,23 +164,19 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
const content=dramaSeoContent(detail,chapter);
if(!matchesContentKey(detail,route.key))return {...content,missing:true};
if(!content.title)return {failed:true};
if(comments&&!comments.error){content.totalCount=comments.totalCount;content.pageSize=10;}
if(comments?.error&&(route.page||1)>1)content.failed=true;
const ordered=orderedChapters(chapters??detail.playletChapterInfo);
detail.relatedRecommendData=recommendations?.list||[];
const initial=playerInitialData(detail,ordered,chapter||null,comments);
const initial=playerInitialData(detail,ordered,chapter||null);
const title=content.title||'剧集详情',overview=route.group==='detail';
const pageHref=(page:number,sort:number)=>paginationPath(contentPath(overview?'detail':'play',detail,chapter?.id)+search,page,{commentSort:sort===2?null:sort});
const session=await preparedSession();
return {...content,view:{panel:'player',title,overview,html:playerMarkup(initial.detail,initial.chapters,{chapter:initial.chapter,isVideo:false,page:route.page||1,commentSort,comments:initial.comments,config:configuration.config,user:session.user,pageHref}),bootstrap:{path:address,panel:'player',title,player:initial}}};
return {...content,view:{panel:'player',title,overview,html:playerMarkup(initial.detail,initial.chapters,{chapter:initial.chapter,isVideo:false,config:configuration.config}),bootstrap:{path:address,panel:'player',title,player:initial}}};
}
if(route.group!=='page')return {};
const api:Pick<ApiClient,'get'>={get:(endpoint,values={})=>contentRequest(endpoint,Object.fromEntries(Object.entries(values).filter(([,value])=>value!==undefined).map(([key,value])=>[key,String(value)])))};
if(['apps','home-road'].includes(route.key)){
const title=route.key==='apps'?'福利应用':'回家的路';
const configuration=route.key==='home-road'?await getSeoConfig():undefined;
if(route.key==='home-road'){
const title='回家的路';
const configuration=await getSeoConfig();
if(configuration?.error)throw new Error('入口配置暂不可用');
const html=route.key==='apps'?renderAppDirectory(await loadAppCategories(api,new AbortController().signal)):homeRoad(configuration!.config);
const html=homeRoad(configuration.config);
return {view:{panel:'channel',channel:route.key,title,html,bootstrap:{path:address,panel:'channel',title}}};
}
const [data,home,tags]=await Promise.all([
@@ -233,8 +195,8 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
return {...listSeoContent(data,route,home?.data),view:{panel:'channel',channel:route.key,...rendered,bootstrap:{path:address,panel:'channel',title:rendered.title,subtitle:rendered.subtitle}}};
}catch(error){
if(error instanceof ApiError&&(error.code===401||EXPIRED_CODES.has(error.code))&&(await preparedSession()).state==='login-required'){
const title='登录后继续访问';
return {loginRequired:true,view:{panel:'channel',title,html:empty('请先登录账号')+'<button type="button" class="primary" data-auth-mode="login">登录账号</button>'}};
const title='内容暂不可用';
return {loginRequired:true,view:{panel:'channel',title,html:empty('内容暂不可用,请稍后重试',true)}};
}
const resource=route.group==='detail'||route.group==='play'||route.group==='page'&&['theme','tag','config'].includes(route.key)||route.group==='page'&&route.key==='topics'&&!!route.value;
// The current service uses 5009 ("无数据") for missing short dramas, episodes and topics.
+16 -18
View File
@@ -1,5 +1,6 @@
import type { Chapter, Config, DramaDetail, HomeData, NavigationCategory, PageData } from './api/types';
import type { InfoPageDefinition } from './info-pages';
import { SITE_NAME, brandText } from './brand';
import { chapterAccess, chapterRequiresPayment } from './client/playback';
import { contentPath, getRankPage, navigationSection, paginationPath, parseSitePath, supportsPagination, type ContentIdentity, type SiteRoute } from './routing';
import { buildStructuredData, dramaSchemaData, faqFromHtml, homeSchemaItems, listSchemaItems, schemaUrl, type SchemaGraph, type SchemaDrama, type SchemaItem, type SchemaQuestion } from './structured-data';
@@ -63,7 +64,7 @@ export function resolveSeoEnvironment(config:Config,categories:NavigationCategor
const current=seoEnvironment(config,categories??previous?.categories??[]);
if(!current.origin&&previous?.origin)return previous;
return {...current,
site:mergeSeo(current.site,previous?.site||normalizeSeo({siteName:'禁果短剧'})),
site:mergeSeo(current.site,previous?.site||normalizeSeo({siteName:SITE_NAME})),
navigation:mergeSeo(current.navigation,previous?.navigation||normalizeSeo({})),
};
}
@@ -73,21 +74,18 @@ export function clientSeoEnvironment(current:SeoEnvironment,config:Config):SeoEn
return current.origin?current:resolveSeoEnvironment(config,current.categories,current);
}
const infoTitles:Record<string,string>={'about-us':'关于我们','user-agreement':'用户协议','privacy-policy':'隐私政策','help-center':'常见问题','2257':'2257',dmca:'DMCA',feedback:'意见反馈','user-center':'个人中心',settings:'账号设置',deposit:'金币充值'};
const labels:Record<string,string>={shorts:'短剧',topics:'专题推荐',rank:'热播榜',apps:'福利应用','home-road':'回家的路',theme:'题材短剧',tag:'标签短剧',config:'更多好剧',center:'个人中心',favorites:'我的收藏',history:'观看历史',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置'};
const infoTitles:Record<string,string>={'about-us':'关于我们','user-agreement':'用户协议','privacy-policy':'隐私政策','help-center':'常见问题','2257':'2257',dmca:'DMCA'};
const labels:Record<string,string>={shorts:'短剧',topics:'专题推荐',rank:'热播榜','home-road':'回家的路',theme:'题材短剧',tag:'标签短剧',config:'更多好剧'};
const descriptions:Record<string,string>={
shorts:'精选短剧与最新更新,都市甜宠、古装仙侠、悬疑逆袭,发现值得追的精彩故事。',
topics:'精选短剧专题与主题合集,按专题发现值得追的好剧。',
rank:'查看热播榜、新剧榜和追剧榜,发现大家正在看的热门短剧。',
'about-us':'了解禁果短剧的品牌介绍、平台服务与联系方式。',
'user-agreement':'了解禁果短剧的用户协议、使用条款与服务规则。',
'privacy-policy':'了解禁果短剧如何收集、使用及保护个人信息。',
'help-center':'查阅禁果短剧的观看、账户与服务常见问题。',
'2257':'禁果短剧的2257合规说明与相关信息。',dmca:'禁果短剧的版权保护政策与DMCA通知说明。',
'about-us':'了解黄果短剧的品牌介绍、平台服务与商务合作方式。',
'user-agreement':'了解黄果短剧的用户协议、使用条款与服务规则。',
'privacy-policy':'了解黄果短剧如何收集、使用及保护个人信息。',
'help-center':'查阅黄果短剧的观看、账户与服务常见问题。',
'2257':'黄果短剧的2257合规说明与相关信息。',dmca:'黄果短剧的版权保护政策与DMCA通知说明。',
};
function privateRoute(route:SiteRoute):boolean {
return route.group==='member'||route.group==='info'&&['user-center','settings','deposit','feedback'].includes(route.key);
}
function canonicalPath(path:string):string {
if(path==='/')return '/';
return path.replace(/\/page\/1\/?$/,'/').replace(/\/?$/,'/');
@@ -161,7 +159,7 @@ export function pageResponseStatus(pathname:string,content:SeoContent={}):200|40
export function buildPageHead(pathname:string,search:string,environment:SeoEnvironment,content:SeoContent={}):PageHead {
const navigation=pathname.replace(/\/$/,'')==='/navigation';
const route=navigation?{group:'navigation',key:''}:parseSitePath(pathname,search);
const siteName=first(environment.site.siteName,navigation?environment.navigation.siteName:'',normalizeSeo(content.seoInfo).siteName,'禁果短剧');
const siteName=brandText(first(environment.site.siteName,navigation?environment.navigation.siteName:'',normalizeSeo(content.seoInfo).siteName))||SITE_NAME;
const page=route?.page||1;
const params=new URLSearchParams(search);
const sort=Number(params.get('sort')||1);
@@ -184,13 +182,13 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
if(fallback)seo=mergeSeo(seo,fallback);
}
const ranking=route?.group==='page'&&route.key==='rank'?getRankPage(route.value):undefined;
const pageTitle=!route?'页面不存在':!route.group?`${siteName} · 精品原创 · 日更爽看`:navigation?'官方入口':route.group==='search'?(route.key.trim()?`${route.key.trim()} · 搜索结果`:'搜索短剧'):route.group==='info'?(route.key==='deposit'&&route.value==='vip'?'会员畅享':content.title||infoTitles[route.key]):isContent?(content.title||'剧集详情'):ranking?.title||content.title||labels[route.key]||'内容列表';
let title=seo.title||pageTitle||siteName;
const pageTitle=!route?'页面不存在':!route.group?`${siteName} · 精品原创 · 日更爽看`:navigation?'官方入口':route.group==='search'?(route.key.trim()?`${route.key.trim()} · 搜索结果`:'搜索短剧'):route.group==='info'?(content.title||infoTitles[route.key]):isContent?(content.title||'剧集详情'):ranking?.title||content.title||labels[route.key]||'内容列表';
let title=brandText(seo.title)||pageTitle||siteName;
const episode=route?.group==='play'&&content.episode?`第${content.episode}集`:'';
if(episode&&!title.includes(episode))title+=` - ${episode}`;
let description=seo.description||content.description||(!route?.group?`${siteName},发现值得一看的短剧。都市甜宠、古装仙侠、悬疑逆袭,精彩故事随心看。`:isContent?`在${siteName}查看《${content.title||'精选剧集'}》的剧集介绍、更新与观看信息。`:(descriptions[route.key]||`${pageTitle},${siteName}为你提供最新内容与相关服务。`).replaceAll('禁果短剧',siteName));
let description=brandText(seo.description)||content.description||(!route?.group?`${siteName},发现值得一看的短剧。都市甜宠、古装仙侠、悬疑逆袭,精彩故事随心看。`:isContent?`在${siteName}查看《${content.title||'精选剧集'}》的剧集介绍、更新与观看信息。`:(descriptions[route.key]||`${pageTitle},${siteName}为你提供最新内容与相关服务。`).replaceAll('黄果短剧',siteName));
if(episode&&!description.includes(episode))description+=` - ${episode}`;
let keywords=seo.keywords.length?seo.keywords:[...new Set([content.title||pageTitle,...(content.keywords||[]),'在线短剧',siteName].filter(Boolean))];
let keywords=seo.keywords.length?seo.keywords.map(brandText):[...new Set([content.title||pageTitle,...(content.keywords||[]),'在线短剧',siteName].filter(Boolean))];
if(ranking){
if(!title.includes(ranking.title))title+=` - ${ranking.title}`;
if(!description.includes(ranking.title))description+=` - ${ranking.title}`;
@@ -213,7 +211,7 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
if(!description.endsWith(` | ${siteName}`))description+=` | ${siteName}`;
}else if(!navApplied&&!title.includes(siteName))title+=` - ${siteName}`;
let indexable=!!route&&(!route.group||route.group==='page'&&!['apps','home-road'].includes(route.key)||isContent||route.group==='info'&&!!descriptions[route.key]||route.group==='richtext'&&content.indexable===true);
let indexable=!!route&&(!route.group||route.group==='page'&&route.key!=='home-road'||isContent||route.group==='info'&&!!descriptions[route.key]||route.group==='richtext'&&content.indexable===true);
if(route?.group==='page'&&!(route.key==='shorts'?[1,2,3,4]:[1,2,3]).includes(sort))indexable=false;
if(route?.group==='page'&&sort!==1&&route.key!=='rank'&&route.key!=='config')indexable=false;
if(route?.group==='page'&&route.key==='theme'&&params.has('updateStatus')&&params.get('updateStatus')!=='0')indexable=false;
@@ -227,7 +225,7 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
const outOfRange=totalPages!==undefined&&page>totalPages;
if(route?.group==='page'&&totalCount===0)indexable=false;
if(content.failed||content.missing||content.unavailable||content.loginRequired||outOfRange)indexable=false;
const robots=indexable?'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1':route&&privateRoute(route)?'noindex,nofollow':'noindex,follow';
const robots=indexable?'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1':'noindex,follow';
const origin=environment.origin;
const absolute=(path:string)=>origin?new URL(path,origin).href:'';
const canonical=indexable?absolute(canonicalPagePath(pathname,content)):'';
+20 -4
View File
@@ -6,8 +6,11 @@ export interface NavigationEntry { id:string; name:string; href:string; key:stri
export type NavigationSurface='top'|'sidebar'|'mobile';
// Resolve API navigation values directly to native routes; these are link rules, not redirects.
const aliases:Record<string,string>={'/hot':'/rank/hot/','/home':'/','/playlet':'/shorts/','/list':'/shorts/','/topic':'/topics/','/theme':'/topics/','/application':'/apps/'};
const emptyPaths:Record<string,string>={'首页':'/','回家的路':'/home-road/','福利应用':'/apps/'};
const aliases:Record<string,string>={'/hot':'/rank/hot/','/home':'/','/playlet':'/shorts/','/list':'/shorts/','/topic':'/topics/','/theme':'/topics/'};
const emptyPaths:Record<string,string>={'首页':'/','回家的路':'/home-road/'};
/** Removed features (welfare app directory) stay absent even if backend navigation still links to them. */
const REMOVED_NAV_PATH=/^\/(?:apps|application)(?:\/|$)/i;
const REMOVED_NAV_NAME=/福利应用/;
function destination(value:unknown,name:string,origin:string):{href:string;external:boolean}|undefined {
const raw=typeof value==='string'?value.trim():'';
@@ -20,15 +23,28 @@ function destination(value:unknown,name:string,origin:string):{href:string;exter
if(url.hash)return;
url.pathname=aliases[url.pathname.replace(/\/$/,'')]||url.pathname;
// Removed standalone video pages stay absent even if stale configuration still contains them.
if(/^\/(?:series|video)(?:\/|$)/.test(url.pathname))return;
if(/^\/(?:series|video)(?:\/|$)/.test(url.pathname)||REMOVED_NAV_PATH.test(url.pathname))return;
const route=parseSitePath(url.pathname);
const path=route&&url.pathname!=='/'?url.pathname.replace(/\/?$/,'/'):url.pathname;
return {href:path+url.search,external:false};
}
function removedNavigationItem(item:NavigationCategory):boolean {
const name=typeof item?.name==='string'?item.name.trim():'';
let path=typeof item?.urlValue==='string'?item.urlValue.trim():'';
try{path=new URL(path,'https://site.invalid').pathname;}catch{/* keep the raw value */}
return REMOVED_NAV_NAME.test(name)||REMOVED_NAV_PATH.test(path);
}
/** Drop removed-feature entries from the backend payload before it is rendered or serialized into the page. */
export function withoutRemovedNavigation(data:HeaderNavigation):HeaderNavigation {
const clean=(list:unknown):NavigationCategory[]=>recordList<NavigationCategory>(list).filter(item=>!removedNavigationItem(item)).map(item=>Array.isArray(item.translationList)?{...item,translationList:clean(item.translationList)}:item);
return {...data,categoryList:clean(data?.categoryList)};
}
export function navigationEntries(data:HeaderNavigation,origin=''):NavigationEntry[] {
const make=(item:NavigationCategory,index:number,parent=''):NavigationEntry|undefined=>{
const name=typeof item.name==='string'?item.name.trim():'';if(!name)return;
const name=typeof item.name==='string'?item.name.trim():'';if(!name||REMOVED_NAV_NAME.test(name))return;
const id=parent+String(item.id??index),target=destination(item.urlValue,name,origin);
const children=recordList<NavigationCategory>(item.translationList).flatMap((child,index)=>{
const entry=make({...child,translationList:undefined},index,id+'-');return entry?[entry]:[];
+14 -7
View File
@@ -1,25 +1,33 @@
import type { Config, FooterInfoResponse, FooterNavItem, Advertisement } from './api/types';
import { recordList } from './api/protocol';
import type { SiteSharedData } from './page-data';
import { footerLinkHref, removedFooterLink } from './footer-links';
function footerNavItem(item: FooterNavItem): FooterNavItem {
const output: FooterNavItem = {};
if (typeof item.id === 'number' && Number.isSafeInteger(item.id)) output.id = item.id;
if (typeof item.name === 'string') output.name = item.name.slice(0, 200);
if (typeof item.urlValue === 'string') output.urlValue = item.urlValue.slice(0, 2048);
const children = recordList<FooterNavItem>(item.translationList).map(footerNavItem).filter(child => child.name || child.urlValue);
const children = recordList<FooterNavItem>(item.translationList).filter(keptFooterItem).map(footerNavItem).filter(child => child.name || child.urlValue);
if (children.length) output.translationList = children;
return output;
}
function footerNavList(value: unknown): FooterNavItem[] {
return recordList<FooterNavItem>(value).map(footerNavItem).filter(item => item.name || item.urlValue || item.translationList?.length);
/** Links to removed pages (user center, recharge, feedback) and email links never reach the page. */
function keptFooterItem(item: FooterNavItem): boolean {
const name = item.name?.trim() || '', raw = item.urlValue?.trim() || '';
if (/^mailto:/i.test(raw)) return false;
return !removedFooterLink(name, raw ? footerLinkHref(raw) : '');
}
/** Only fields used by visible site controls enter the client snapshot. SDK config stays server-side. */
function footerNavList(value: unknown): FooterNavItem[] {
return recordList<FooterNavItem>(value).filter(keptFooterItem).map(footerNavItem).filter(item => item.name || item.urlValue || item.translationList?.length);
}
/** Only fields used by visible site controls enter the client snapshot. Contacts and SDK settings never enter the page. */
export function publicSiteData(source:Config,footer:FooterInfoResponse={}):SiteSharedData {
const section=(value:Record<string,unknown>|undefined)=>Object.fromEntries([
'foreverUrlAddr','newUrlAddr','newUrlAddr1','newUrlAddr2','tgGroup','telegram','telegramAdd','twitterAddr','appAddr','officialAppAddr','customerServiceHost','emailAddr','email',
'foreverUrlAddr','newUrlAddr','newUrlAddr1','newUrlAddr2','tgGroup','appAddr','officialAppAddr',
].filter(key=>typeof value?.[key]==='string').map(key=>[key,String(value![key]).slice(0,4096)]));
const advitise=Object.fromEntries(['dialogList','nineGridList','detailTopList','detailbannerList','bottomBanner'].map(key=>[key,recordList<Advertisement>(source.advitise?.[key]).map(item=>{
const output:Record<string,unknown>={};
@@ -29,7 +37,6 @@ export function publicSiteData(source:Config,footer:FooterInfoResponse={}):SiteS
})]));
return {
config:{config:section(source.config),newAddressInfo:section(source.newAddressInfo),advitise,navPageList:recordList<{line:number;url:string}>(source.navPageList).map(item=>({line:Number(item.line)||0,url:typeof item.url==='string'?item.url:''}))},
footer:{footerInfo:{brand:{title:typeof footer.footerInfo?.brand?.title==='string'?footer.footerInfo.brand.title.slice(0,200):'',content:typeof footer.footerInfo?.brand?.content==='string'?footer.footerInfo.brand.content:''},contactList:recordList<{name:string;value:string}>(footer.footerInfo?.contactList).map(item=>({name:typeof item.name==='string'?item.name:'',value:typeof item.value==='string'?item.value:''})),navList:footerNavList(footer.footerInfo?.navList)}},
trackerConfigured:typeof source.reportSdkConfig==='string'&&!!source.reportSdkConfig.trim(),
footer:{footerInfo:{brand:{title:typeof footer.footerInfo?.brand?.title==='string'?footer.footerInfo.brand.title.slice(0,200):'',content:typeof footer.footerInfo?.brand?.content==='string'?footer.footerInfo.brand.content:''},contactList:[],navList:footerNavList(footer.footerInfo?.navList)}},
};
}
-35
View File
@@ -1,35 +0,0 @@
import type { Chapter, DramaDetail } from './api/types';
export const TRACKER_APP_ID=process.env.NEXT_PUBLIC_TRACKER_APP_ID||'1050';
export const SDK_COMMON_KEYS=['sid','device_id','device','device_fingerprint','fp_version','app_version'] as const;
export const COMMON_KEYS=['app_id','uid','channel','sid','client_ts','device','device_id','device_brand','device_model','device_fingerprint','fp_version','user_agent','sdk_version','app_version'] as const;
export const SDK_FIELD_KEYS=[...COMMON_KEYS,'system_name','system_version'] as const;
export const COMMON_FIELD_LIMITS:Record<string,number>={app_id:50,uid:32,channel:50,sid:50,device:30,device_id:50,device_brand:100,device_model:100,device_fingerprint:128,fp_version:128,user_agent:4096,sdk_version:128,app_version:128,system_name:100,system_version:100};
export const SERVER_EVENTS:Record<string,string>={
'playlet/like/set':'drama_like','playlet/chapter/like/set':'drama_like',
'playlet/collect/set':'drama_collect','playlet/chapter/collect/set':'drama_collect',
'comment/send':'drama_comment','playlet/chapter/unlock':'drama_purchase',
};
export type Fields=Record<string,string|number>;
export interface TrackingPage {key:string;name:string}
export const trackingText=(value:unknown)=>typeof value==='string'||typeof value==='number'?String(value).trim():'';
export function trackingPage(path:string):TrackingPage {
const parts=path.split('/').filter(Boolean),key=parts[0]||'home';
if(key==='rank')return {key:'rank_'+(parts[1]||'hot'),name:({'hot':'热播榜',new:'新剧榜',follow:'追剧榜'} as Record<string,string>)[parts[1]||'hot']||'排行榜'};
if(key==='topics'&&parts[1]&&parts[1]!=='page')return {key:'topic_detail',name:'专题详情'};
if(key==='richtext'||key==='richetext')return {key:'rich_text',name:'富文本页'};
const slug=['info','member'].includes(key)?parts[1]:key;
const names:Record<string,string>={home:'首页',shorts:'短剧列表',series:'剧集列表',topics:'专题推荐',theme:'题材短剧',tag:'标签短剧',config:'更多好剧',detail:'短剧详情',play:'短剧播放页',video:'剧集播放页',search:'搜索结果页',navigation:'官方入口',apps:'福利应用','home-road':'回家的路',center:'个人中心','user-center':'个人中心',settings:'账号设置',favorites:'我的收藏',history:'观看历史',likes:'我的点赞',purchased:'已购剧集',deposit:'充值中心','about-us':'关于我们','privacy-policy':'隐私政策','user-agreement':'用户协议','help-center':'常见问题',feedback:'意见反馈','2257':'2257合规声明',dmca:'DMCA版权声明'};
return {key:names[slug]?slug.replace(/-/g,'_'):'internal_other',name:names[slug]||'站内其他页面'};
}
export function dramaContext(drama:DramaDetail,chapter?:Chapter|null):Fields {
return {media_id:trackingText(chapter?.mediaId||drama.mediaId),drama_id:String(drama.id),drama_title:drama.playName||drama.title||'未命名短剧',episode:Math.max(1,chapter?.currentEpisode||1),total_episode:Math.max(1,drama.totalEpisode||drama.playletChapterInfo?.length||1),video_id:chapter?String(chapter.id):'',drama_type_id:trackingText(drama.themeIds?.[0])||'default',drama_type_name:drama.themeNames?.[0]||'默认分类',drama_content_type:'short_drama',recommend_trace_id:trackingText(drama.recommendTraceId),drama_tag_key:drama.tagIds?.join(',')||'default',drama_tag_name:drama.tagNames?.join(',')||'默认标签',drama_duration:Math.max(0,chapter?.mediaTime||0)};
}
export function base64JSON(value:unknown,url=false):string {
const bytes=new TextEncoder().encode(JSON.stringify(value));
const encoded=btoa(Array.from(bytes,byte=>String.fromCharCode(byte)).join(''));
return url?encoded.replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''):encoded;
}
-280
View File
@@ -1,280 +0,0 @@
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
import {runInNewContext} from 'node:vm';
// Run the bundled SDK's real named-event validators without initializing it:
// accepted events stay in its pre-init queue and never contact an analytics server.
const sdkLogs=[];
const sdkSandbox={console:{warn:(...args)=>sdkLogs.push(args)}};
runInNewContext(await readFile(new URL('../public/scripts/web-sdk-v1.2.0.js',import.meta.url),'utf8'),sdkSandbox);
async function moduleUrl(file,replace=code=>code){
const code=ts.transpileModule(await readFile(new URL(file,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
return 'data:text/javascript;base64,'+Buffer.from(replace(code)).toString('base64');
}
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
const reportUrl=await moduleUrl('../src/lib/analytics/report-fields.ts');
const {TRACKER_APP_ID,COMMON_KEYS,SDK_COMMON_KEYS,base64JSON,trackingPage}=await import(trackingUrl);
const authUrl='data:text/javascript,export function authMetadata(){return {channel:"paid_1050",devID:"business-device",deviceId:"business-device"}}';
const userAgentUrl=await moduleUrl('../src/lib/api/user-agent.ts');
const analyticsUrl=await moduleUrl('../src/lib/client/analytics.ts',code=>code.replace("'../tracking'",JSON.stringify(trackingUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../api/user-agent'",JSON.stringify(userAgentUrl)).replace("'../analytics/report-fields'",JSON.stringify(reportUrl)));
const {SiteTracker}=await import(analyticsUrl);
const {trackingHeaders}=await import(await moduleUrl('../src/lib/api/tracking-headers.ts',code=>code.replace("'../tracking'",JSON.stringify(trackingUrl))));
const flush=()=>new Promise(resolve=>setImmediate(resolve));
const common=()=>({app_id:TRACKER_APP_ID,uid:'',channel:'paid_1050',sid:'sid-raw',client_ts:Math.floor(Date.now()/1000),device:'PC',device_id:'sdk-device',device_brand:'',device_model:'',device_fingerprint:'raw-fingerprint',fp_version:'3.0.1',user_agent:'test UA',sdk_version:'1.2.0',app_version:'1.0.0'});
class Element extends EventTarget {
dataset={};hidden=false;attributes=new Map();
getClientRects(){return this.hidden?[]:[{}];}
closest(selector){
if(selector.includes('[hidden]'))return this.hidden||this.closedDialog?this:null;
if(selector==='[data-track-item]'&&this.hasAttribute('data-track-item'))return this;
if(selector==='[data-track-ad="external"]'&&this.getAttribute('data-track-ad')==='external')return this;
if(selector.includes('a[data-app-download]')&&this.hasAttribute('data-app-download'))return this;
if(selector.includes('a[data-track-navigation]')&&this.dataset.trackNavigation)return this;
return this.parents?.[selector]||null;
}
getAttribute(name){return this.attributes.get(name)||null;}
hasAttribute(name){return this.attributes.has(name);}
}
class Video extends Element {currentTime=0;duration=100;playbackRate=1.25;paused=false;ended=false;seeking=false;}
function fixture(){
const saved=new Map(),events=[],calls=[],nodes=[],results=[],diagnostics=[],observed=new Set(),consoleCalls=[];
let clock=1000,video,intersection,mutation,modal=null;
const fields=common();
const sdk={init(options){calls.push(options);},track:new Proxy({}, {get:(_,method)=>async payload=>{events.push({method,payload});const result=JSON.parse(JSON.stringify(await sdkSandbox.WebSDK.track[method](payload)));results.push({method,...result});return result;}}),getCommonFields:async()=>({...fields}),setUid(uid){fields.uid=uid;},setChannel(channel){fields.channel=channel;},notifyPageLoad(){calls.push('route');},flush:async()=>{calls.push('flush');}};
const win=new EventTarget();win.WebSDK=sdk;win.console={warn:(...args)=>consoleCalls.push(args)};
const document=new EventTarget();Object.assign(document,{referrer:'https://search.example/',documentElement:new Element(),head:{append(){}},querySelector:selector=>selector==='dialog:modal'?modal:video||null,querySelectorAll:()=>nodes});
document.addEventListener('tracker:diagnostic',event=>diagnostics.push(event.detail));
const values={window:win,document,location:{pathname:'/',search:'',origin:'https://site.example',href:'https://site.example/'},performance:{now:()=>clock,getEntriesByType:()=>[{duration:42}]},Element,HTMLVideoElement:Video,IntersectionObserver:class{constructor(callback){intersection=callback;}observe(node){observed.add(node);}unobserve(node){observed.delete(node);}disconnect(){observed.clear();}},MutationObserver:class{constructor(callback){mutation=callback;}observe(){}},innerWidth:1000,innerHeight:800,screen:{width:1000,height:800}};
for(const [key,value] of Object.entries(values)){saved.set(key,Object.getOwnPropertyDescriptor(globalThis,key));Object.defineProperty(globalThis,key,{configurable:true,writable:true,value});}
const tracker=new SiteTracker();win.tracker=tracker;
return {tracker,events,calls,fields,nodes,win,document,results,diagnostics,observed,sdk,consoleCalls,location:values.location,mutate:()=>mutation([]),setModal:value=>{modal=value;},setClock:value=>{clock=value;},setVideo:value=>{video=value;},intersect:node=>intersection([{target:node,isIntersecting:true}]),close(){for(const [key,descriptor] of saved)if(descriptor)Object.defineProperty(globalThis,key,descriptor);else delete globalThis[key];}};
}
const drama={id:23456,title:'短剧标题',totalEpisode:12,themeIds:[8],themeNames:['都市'],tagIds:[9],tagNames:['爱情']};
const chapters=[{id:90001,currentEpisode:1,mediaTime:100},{id:90002,currentEpisode:2,mediaTime:100}];
function advertisement(placement='inline'){
const node=new Element();node.attributes.set('data-track-ad','external');
node.dataset={adId:'same-code',adSlotKey:'slot-3',adSlotName:'推荐应用',adType:'icon',adClickable:'1',adPlacement:placement};
return node;
}
test('real SDK accepts content exposures, clicks, search and navigation fields including the root parent id',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
const item=new Element();item.attributes.set('data-track-item','');item.dataset={itemId:'23456',itemType:'short_drama',itemName:'剧名'};
f.intersect(item);f.tracker.click({target:item,clientX:10,clientY:10});
f.tracker.search('短剧',28,1);
const nav=new Element();nav.dataset.trackNavigation='tg_group';nav.textContent='官方TG群';nav.href='https://t.me/example';nav.attributes.set('href',nav.href);
f.tracker.click({target:nav,clientX:10,clientY:10});
await flush();
for(const method of ['appPageView','appPageitemExposure','appPageitemClick','keywordSearch','navigation','pageClick'])assert.ok(f.results.some(result=>result.method===method&&result.ok),method);
assert.ok(f.events.filter(e=>e.method.startsWith('appPageitem')).every(e=>e.payload.parent_item_id==='0'));
assert.deepEqual(f.results.filter(result=>!result.ok),[]);assert.deepEqual(f.diagnostics,[]);
}finally{f.close();}
});
test('home, large-popup and grid-popup advertisements report independently without duplicating each placement',async()=>{
const f=fixture();try{
const nodes=['inline','startup-ad','startup-grid'].map(advertisement);
// Popup events can arrive before the asynchronous SDK configuration.
for(const node of nodes){f.tracker.externalAd(node,'show');f.tracker.externalAd(node,'show');f.tracker.externalAd(node,'click');}
assert.equal(f.events.length,0);
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
assert.equal(f.events.filter(e=>e.method==='adImpression').length,3);assert.equal(f.events.filter(e=>e.method==='adClick').length,3);
assert.ok(f.events.filter(e=>e.method==='adImpression').every(e=>e.payload.ad_slot_key==='slot-3'),'retain API advertisement slot codes');
assert.deepEqual(f.results.filter(result=>!result.ok),[]);
const count=f.events.length;f.tracker.externalAd(nodes[1],'close');assert.equal(f.events.length,count,'closing is not an ad click');
for(const node of nodes.slice(1)){f.tracker.advertising(node,'close');f.tracker.advertising(node,'close');}
const popup=f.events.filter(e=>e.method==='advertising');
assert.equal(popup.filter(e=>e.payload.event_type==='show').length,2);
assert.equal(popup.filter(e=>e.payload.event_type==='click').length,2);
assert.equal(popup.filter(e=>e.payload.event_type==='close').length,2);
await flush();assert.deepEqual(f.results.filter(result=>!result.ok),[]);
}finally{f.close();}
});
test('closed dialogs and obscured background content are observed again when the modal closes',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
const background=advertisement(),grid=advertisement('startup-grid'),offscreen=advertisement('offscreen');offscreen.dataset.adId='offscreen';
f.nodes.push(background,grid,offscreen);grid.closedDialog=true;f.mutate();
const modal={contains:node=>node===grid||node===offscreen};f.setModal(modal);grid.closedDialog=false;f.mutate();
assert.equal(f.observed.has(background),false);assert.ok(f.observed.has(grid));
f.intersect(background);assert.equal(f.events.filter(e=>e.method==='adImpression').length,0);
f.intersect(grid);assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);
assert.equal(f.events.some(e=>e.payload.ad_id==='offscreen'),false,'a scrollable grid must only expose intersecting tiles');
f.intersect(offscreen);assert.equal(f.events.filter(e=>e.method==='adImpression').length,2);
grid.closedDialog=true;offscreen.closedDialog=true;f.setModal(null);f.mutate();
assert.ok(f.observed.has(background));f.intersect(background);assert.equal(f.events.filter(e=>e.method==='adImpression').length,3);
f.document.hidden=true;const hidden=advertisement('hidden');hidden.dataset.adId='hidden';const before=f.events.length;
f.tracker.externalAd(hidden,'show');f.intersect(hidden);assert.equal(f.events.length,before);
f.nodes.push(hidden);f.document.hidden=false;f.document.dispatchEvent(new Event('visibilitychange'));
assert.ok(f.observed.has(hidden));f.intersect(hidden);assert.equal(f.events.filter(e=>e.method==='adImpression').length,4);
}finally{f.close();}
});
test('SDK validation errors and denied event codes produce sanitized diagnostics instead of disappearing silently',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
const broken=new Element();broken.attributes.set('data-track-item','');broken.dataset={itemId:'7',itemType:'short_drama'};
f.intersect(broken);await flush();assert.equal(f.diagnostics[0].reason,'event_validation_failed');assert.ok(f.diagnostics[0].missingFields.includes('item_name'));
f.sdk.track={adImpression:async()=>({ok:false,reason:'event_code_not_allowed',encryptedConfig:'never-log-cipher',payload:{uid:'never-log-uid'}})};
f.tracker.externalAd(advertisement(),'show');await flush();
assert.equal(f.diagnostics.at(-1).reason,'event_code_not_allowed');assert.ok(!JSON.stringify(f.diagnostics).includes('never-log'));
f.tracker.externalAd(advertisement('next'),'show');await flush();assert.equal(f.diagnostics.filter(d=>d.reason==='event_code_not_allowed').length,1);
assert.deepEqual(f.consoleCalls,[]);assert.deepEqual(sdkLogs,[]);
}finally{f.close();}
});
test('bootstrap SDK configuration requests bypass SDK readiness, and delayed config preserves more than 200 queued events',async()=>{
const f=fixture();try{
f.sdk.getCommonFields=()=>{throw Error('configuration must not depend on SDK fields');};
assert.deepEqual(await f.tracker.requestHeaders('config/info','GET',{}),{});
for(let n=0;n<220;n++){const node=advertisement();node.dataset.adId='ad-'+n;f.tracker.externalAd(node,'show');}
f.tracker.externalAd(advertisement('startup-grid'),'click');
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
assert.equal(f.events.filter(e=>e.method==='adImpression').length,221);assert.equal(f.events.filter(e=>e.method==='adClick').length,1);
assert.deepEqual(f.results.filter(result=>!result.ok),[]);
}finally{f.close();}
});
test('all playback behavior and episode status payloads pass the real SDK validator',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
f.tracker.setDrama(drama,chapters[0],chapters);
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
for(const behavior of ['ep_play','ep_pause','ep_forward','ep_rewind','ep_complete','ep_share','ep_failed'])f.tracker.dramaEvent(behavior,video);
for(const behavior of ['ep_previous','ep_next','ep_switch','ep_unlock','ep_auto_play'])f.tracker.dramaStatusChange(chapters[1],behavior);
await flush();assert.deepEqual(f.results.filter(result=>!result.ok),[]);assert.deepEqual(f.diagnostics,[]);
assert.ok(f.results.some(result=>result.method==='dramaEvent'));assert.ok(f.results.some(result=>result.method==='dramaStatusChange'));
}finally{f.close();}
});
test('SDK loading can recover after the first script was unavailable and a failed init leaves queued events retryable',async()=>{
const f=fixture();try{
delete f.win.WebSDK;let loaded=0;
f.document.createElement=()=>({remove(){}});
f.document.head.append=script=>{loaded++;queueMicrotask(()=>{f.win.WebSDK=f.sdk;script.onload();});};
const initialize=f.sdk.init;let fail=true;
f.sdk.init=options=>{if(fail)throw Error('private-config-must-not-appear');return initialize(options);};
f.tracker.externalAd(advertisement('startup-ad'),'show');f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
assert.equal(loaded,1);assert.equal(f.events.length,0);assert.equal(f.diagnostics[0].reason,'sdk_initialization_failed');
fail=false;f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
assert.equal(f.events.filter(e=>e.method==='appPageView').length,1);assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);
assert.ok(!JSON.stringify(f.diagnostics).includes('private-config'));
}finally{f.close();}
});
test('SDK initializes once from dynamic config, queues the first PV and tracks each native route once',async()=>{
const f=fixture();try{
f.tracker.configure({});await flush();assert.equal(f.calls.length,0);assert.equal(f.events.length,0);
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
assert.equal(f.calls[0].appId,TRACKER_APP_ID);assert.equal(f.calls[0].encryptedConfig,'configured-cipher');assert.deepEqual(f.calls[0].disableAutoEvents,['app_page_view','page_click']);
assert.equal(f.events.filter(e=>e.method==='appPageView').length,1);
f.tracker.configure({reportSdkConfig:'configured-cipher'});f.tracker.pageView();assert.equal(f.events.length,1);
f.location.pathname='/rank/new/page/2/';f.tracker.pageView();
const pv=f.events.at(-1);assert.equal(pv.payload.page_key,'rank_new');assert.equal(pv.payload.referrer_page_key,'home');assert.equal(f.calls.filter(c=>c==='route').length,1);
f.tracker.syncUser({userId:123456});assert.equal(f.fields.uid,'123456');f.tracker.syncUser(null);assert.equal(f.fields.uid,'');
for(const path of ['/navigation/','/info/about-us/','/member/settings/','/topics/3/','/video/8/','/richtext/关于我们/','/richetext/关于我们/'])assert.notEqual(trackingPage(path).key,'internal_other');
assert.equal(trackingPage('/richtext/关于我们/').key,'rich_text');
const count=f.events.filter(e=>e.method==='appPageView').length;
f.tracker.resumePage();assert.equal(f.events.filter(e=>e.method==='appPageView').length,count+1);
f.tracker.pageView();assert.equal(f.events.filter(e=>e.method==='appPageView').length,count+1,'ordinary bindings must not duplicate the restored page view');
}finally{f.close();}
});
test('ad visibility and clicks deduplicate across DOM replacements, resetting only on a new page',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
const ad=new Element();ad.attributes.set('data-track-ad','external');ad.dataset={adId:'advertise-code',adSlotKey:'slot',adSlotName:'广告位',adType:'banner',adClickable:'1'};
ad.hidden=true;f.intersect(ad);assert.equal(f.events.filter(e=>e.method==='adImpression').length,0);
ad.hidden=false;f.intersect(ad);f.tracker.externalAd(ad,'show');f.tracker.externalAd(ad,'click');
assert.equal(f.events.filter(e=>e.method==='adImpression').length,1);assert.equal(f.events.filter(e=>e.method==='adClick').length,1);
f.location.pathname='/shorts/';f.tracker.pageView();f.tracker.externalAd(ad,'show');assert.equal(f.events.filter(e=>e.method==='adImpression').length,2);
f.location.pathname='/';f.tracker.pageView();f.tracker.externalAd(ad,'show');assert.equal(f.events.filter(e=>e.method==='adImpression').length,2,'returning to a previously seen tab must not repeat its ads');
}finally{f.close();}
});
test('play durations exclude buffering and preview, speed is scaled, and seek/completion/status use named events',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
f.tracker.setDrama(drama,chapters[0],chapters);
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
const emit=type=>f.tracker.videoEvent({type,target:video});
emit('play');emit('playing');f.setClock(5000);emit('waiting');f.setClock(15000);emit('playing');f.setClock(18000);video.paused=true;video.currentTime=30;emit('pause');
const pause=f.events.filter(e=>e.payload.play_behavior_key==='ep_pause').at(-1);assert.equal(pause.payload.play_duration,7);assert.equal(pause.payload.play_speed,125);assert.equal(pause.payload.play_progress,30);
const count=f.events.length;mode.dataset.playMode='preview';emit('play');emit('playing');assert.equal(f.events.length,count);mode.dataset.playMode='main';
video.currentTime=20;emit('timeupdate');emit('seeking');video.currentTime=60;emit('seeked');assert.equal(f.events.at(-1).payload.play_behavior_key,'ep_forward');
video.currentTime=100;video.ended=true;emit('ended');assert.equal(f.events.at(-2).payload.play_behavior_key,'ep_complete');assert.equal(f.events.at(-1).payload.play_behavior_key,'ep_play');assert.equal(f.events.at(-1).payload.play_progress,100);
f.tracker.dramaStatusChange(chapters[1]);assert.equal(f.events.at(-1).payload.drama_behavior_key,'ep_next');f.tracker.dramaStatusChange(chapters[1],'ep_unlock');assert.equal(f.events.at(-1).payload.drama_behavior_key,'ep_unlock');
assert.ok(!f.events.some(e=>['dramaLike','dramaCollect','dramaPurchase','dramaComment'].includes(e.method)));
}finally{f.close();}
});
test('request headers preserve SDK values and snapshot the selected purchase chapter independently of route changes',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();f.tracker.setDrama(drama,chapters[0],chapters);
const pending=f.tracker.requestHeaders('playlet/chapter/unlock','POST',{playletId:23456,chapterId:90002});
f.location.pathname='/shorts/';f.tracker.pageView();
const headers=await pending,context=JSON.parse(Buffer.from(headers['X-DC-Request-Context'],'base64url'));
assert.deepEqual(context.common,f.fields);assert.equal(context.drama.drama_id,'23456');assert.equal(context.drama.video_id,'90002');assert.equal(context.drama.episode,2);
assert.equal(context.event_name,'drama_purchase');assert.match(context.request_id,/^[a-f0-9]{32}$/);assert.equal(context.request_id,headers['X-Tracker-Request-Id']);
assert.deepEqual(JSON.parse(Buffer.from(headers['X-SDK-Common'],'base64')),f.fields);
const videoComment=await f.tracker.requestHeaders('comment/send','POST',{objectId:2,objectType:20});assert.equal(videoComment['X-DC-Request-Context'],undefined);
const order=await f.tracker.requestHeaders('coin/buy','POST',{});assert.ok(order['X-SDK-Common']);assert.equal(order['X-Tracker-Event-Name'],undefined);
}finally{f.close();}
});
test('terminal playback errors report failed once per attempt, exclude preview and use the video dictionary',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();
f.tracker.setDrama(drama,chapters[0],chapters);
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
f.tracker.videoEvent({type:'playing',target:video});f.setClock(5000);video.currentTime=30;
f.tracker.playbackFailed(video);f.tracker.playbackFailed(video);
const failed=f.events.filter(e=>e.payload.play_behavior_key==='ep_failed');assert.equal(failed.length,1);assert.equal(failed[0].payload.play_behavior_name,'播放失败');assert.equal(failed[0].payload.play_duration,4);
mode.dataset.playMode='preview';f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.filter(e=>e.payload.play_behavior_key==='ep_failed').length,1);
mode.dataset.playMode='main';f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.filter(e=>e.payload.play_behavior_key==='ep_failed').length,2);
f.tracker.setDrama(drama,null,[],true);assert.equal(f.events.at(-1).payload.video_behavior_name,'视频展示');
f.tracker.prepareDramaModeChange();f.tracker.playbackFailed(video);assert.equal(f.events.at(-1).method,'videoEvent');assert.equal(f.events.at(-1).payload.video_behavior_key,'video_failed');assert.equal(f.events.at(-1).payload.video_behavior_name,'播放失败');
}finally{f.close();}
});
test('completion includes 100 percent play progress while preserving the final playback interval',async()=>{
const f=fixture();try{
f.tracker.configure({reportSdkConfig:'configured-cipher'});await flush();f.tracker.setDrama(drama,chapters[0],chapters);
const video=new Video(),mode=new Element();mode.dataset.playMode='main';video.parents={'[data-play-mode]':mode};f.setVideo(video);
f.tracker.videoEvent({type:'playing',target:video});f.setClock(9000);video.currentTime=100;video.ended=true;f.tracker.videoEvent({type:'ended',target:video});
const [complete,progress]=f.events.slice(-2);assert.equal(complete.payload.play_behavior_key,'ep_complete');assert.equal(progress.payload.play_behavior_key,'ep_play');
assert.equal(complete.payload.play_progress,100);assert.equal(progress.payload.play_progress,100);assert.equal(complete.payload.play_duration,8);assert.equal(progress.payload.play_duration,8);
const count=f.events.length;f.tracker.reportProgress();assert.equal(f.events.length,count,'the final playback interval must be consumed after progress reporting');
}finally{f.close();}
});
test('all SDK public fields reach login and order requests unchanged including numeric timestamps and optional system metadata',async()=>{
const f=fixture();try{
Object.assign(f.fields,{device_brand:'品牌',device_model:'model',system_name:'test-system',system_version:'1.2.3'});
for(const path of ['auth/login','auth/register','coin/buy','vip/pay']){
const headers=await f.tracker.requestHeaders(path,'POST',{}),forwarded=trackingHeaders(new Headers(headers),path,'POST');
assert.deepEqual(JSON.parse(Buffer.from(forwarded['X-SDK-Common'],'base64')),f.fields);assert.equal(forwarded['X-DC-Request-Context'],undefined);
assert.match(headers['X-User-Agent'],/^DevID=business-device;DevType=PC;SysType=h5_pc;Ver=1.0.0;BuildID=web;/);
}
}finally{f.close();}
});
test('the BFF accepts only the correct route, app, common fields and context schema without blocking business data',()=>{
const fields=common(),requestId='1234567890abcdef1234567890abcdef';
const context={v:1,request_id:requestId,event_name:'drama_purchase',common:fields,drama:{media_id:'',drama_id:'23456',episode:2,video_id:'90002',recommend_trace_id:''}};
const source=new Headers({'X-SDK-Common':base64JSON(Object.fromEntries(SDK_COMMON_KEYS.map(k=>[k,fields[k]]))),'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':'drama_purchase','X-DC-Request-Context':base64JSON(context,true)});
const forwarded=trackingHeaders(source,'playlet/chapter/unlock','POST');assert.deepEqual(JSON.parse(Buffer.from(forwarded['X-DC-Request-Context'],'base64url')),context);
assert.equal(JSON.parse(Buffer.from(forwarded['X-SDK-Common'],'base64')).sid,'sid-raw');assert.equal(forwarded.Authorization,undefined);
for(const [path,method] of [['playlet/collect/set','POST'],['config/info','GET'],['playlet/chapter/unlock','GET']])assert.equal(trackingHeaders(source,path,method)['X-DC-Request-Context'],undefined);
for(const invalid of [{...context,request_id:'other'},{...context,common:{...fields,app_id:'JHA-1050'}},{...context,common:{...fields,token:'secret'}},{...context,drama:{...context.drama,episode:0}}]){
const headers=new Headers(source);headers.set('X-DC-Request-Context',base64JSON(invalid,true));const result=trackingHeaders(headers,'playlet/chapter/unlock','POST');assert.equal(result['X-DC-Request-Context'],undefined);assert.ok(result['X-SDK-Common']);
}
assert.deepEqual(trackingHeaders(new Headers({'X-SDK-Common':'invalid','X-DC-Request-Context':'x'.repeat(9000)}),'playlet/chapter/unlock','POST'),{});
});
+5 -2
View File
@@ -46,9 +46,12 @@ const config = await compile("config");
const protocol = await compile("protocol");
const cache = await compile("public-cache");
sharedDependencies["./user-agent"] = await compile("user-agent");
sharedDependencies["../tracking"] = await compile("../tracking");
sharedDependencies["./tracking-headers"] = await compile("tracking-headers");
sharedDependencies["./sdk-headers"] = await compile("sdk-headers");
sharedDependencies["./client-headers"] = await compile("client-headers");
sharedDependencies["../site-navigation"] = await compile("../site-navigation", {
"./api/protocol": protocol,
"./routing": await compile("../routing"),
});
test("development uses its configured domain and supports legacy overrides", () => {
delete environment.UPSTREAM_API_URL;
+4 -4
View File
@@ -24,11 +24,11 @@ test('proxy methods and public endpoints are backed by the reviewed API contract
for(const method of ['GET','POST'])for(const path of ['video/detail','video/all/list','playlet/hot/list','playlet/home/config/detail/list','statcenter/sync'])assert.equal(server.allowedRequest(path,method),false,method+' '+path);
});
test('the documented likes list is an authenticated GET endpoint',()=>{
assert.equal(server.allowedRequest('playlet/like/list','GET'),true);
assert.equal(server.allowedRequest('playlet/like/list','POST'),false);
test('member list, coin, recharge, feedback and account endpoints are no longer proxied',()=>{
for(const path of ['playlet/like/list','playlet/collect/list','playlet/history/list','coin/list','recharge/list','feedback/add','user/upload','auth/login','auth/register'])
for(const method of ['GET','POST'])assert.equal(server.allowedRequest(path,method),false,method+' '+path);
assert.equal(server.PUBLIC_PATHS.has('playlet/like/list'),false);
assert.deepEqual(contract['GET playlet/like/list'].query,['pageNum','pageSize']);
assert.equal(server.allowedRequest('auth/guest','POST'),true);
});
test('all public list routes send only supported query fields with their native page numbers',()=>{
File diff suppressed because it is too large Load Diff
-91
View File
@@ -1,91 +0,0 @@
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
async function moduleUrl(file,replace=code=>code){
const filename=new URL(file,import.meta.url);
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
}
const protocol=await moduleUrl('../src/lib/api/protocol.ts');
const render=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocol)));
const apps=await moduleUrl('../src/lib/client/apps.ts',code=>code.replace("'../api/protocol'",JSON.stringify(protocol)).replace("'./render'",JSON.stringify(render)));
const {loadAppCategories,renderAppDirectory,scrollToAppCategory}=await import(apps);
const app=id=>({id,name:'应用 '+id,url:'https://example.com/app/'+id,logo:'https://example.com/icon.png'});
const category=(id,appList)=>({id,title:'分类 '+id,...(appList===undefined?{}:{appList})});
test('benefit applications report linked advertisements with their API ids and ordinary applications as content',()=>{
const html=renderAppDirectory([category(3,[app(11),{...app(12),advitiseId:'ad-12',location:8}])]);
assert.match(html,/data-track-item data-item-id="11" data-item-name="应用 11" data-item-type="app" data-parent-item-id="3"/);
assert.match(html,/data-track-ad="external" data-ad-id="ad-12" data-ad-clickable="1" data-ad-slot-key="8" data-ad-slot-name="福利应用" data-ad-type="icon"/);
});
test('loads every missing category once, preserves directory order, and omits empty categories',async()=>{
const calls=[],controller=new AbortController();
const api={async get(path,query,signal){
assert.equal(path,'applist/list');assert.equal(signal,controller.signal);calls.push(query.categoryId);
if(query.categoryId===0)return {categoryList:[category(2,[]),category(1),category(3,[]),category(4),category(2)],category:category(1,[app(11)])};
return {category:category(query.categoryId,query.categoryId===2?[app(22)]:[])};
}};
const result=await loadAppCategories(api,controller.signal);
assert.deepEqual(calls,[0,2,3,4]);assert.deepEqual(result.map(item=>item.id),[2,1]);
const html=renderAppDirectory(result);
for(const id of [2,1]){assert.ok(html.includes(`aria-controls="apps-${id}"`));assert.ok(html.includes(`id="apps-${id}"`));}
for(const id of [3,4]){assert.ok(!html.includes(`data-app-category="${id}"`));assert.ok(!html.includes(`id="apps-${id}"`));}
assert.equal((html.match(/class="benefit-section"/g)||[]).length,2);
});
test('uses embedded applications without additional requests and escapes API text and links',async()=>{
let calls=0;
const result=await loadAppCategories({async get(){calls++;return {categoryList:[{...category(7,[{...app(1),name:'应用 <script>',url:'https://example.com/?a=1&b=2'}]),title:'分类 <img>'},category(8,[app(2)])]};}},new AbortController().signal);
assert.equal(calls,1);
const html=renderAppDirectory(result);
assert.ok(html.includes('分类 &lt;img&gt;'));assert.ok(html.includes('应用 &lt;script&gt;'));assert.ok(html.includes('?a=1&amp;b=2'));
assert.ok(html.includes('data-src="https://example.com/icon.png"'));assert.ok(!html.includes('<script>'));
});
test('empty and malformed application lists create no category options or empty sections',async()=>{
for(const data of [null,{}, {categoryList:null},{categoryList:[null,{},category(1,[]),category(2,[null,{},false]),category(3,'invalid')]}]){
const result=await loadAppCategories({async get(){return data;}},new AbortController().signal);
assert.deepEqual(result,[]);
const html=renderAppDirectory(result);assert.ok(html.includes('暂无福利应用'));
assert.ok(!html.includes('data-app-category'));assert.ok(!html.includes('benefit-section'));
}
});
test('keeps available groups after a category failure and reports failure when none can load',async()=>{
const unavailable=new Error('category unavailable');
for(const available of [true,false]){
const api={async get(_,query){
if(query.categoryId===0)return {categoryList:[category(1),category(2)],category:available?category(1,[app(1)]):undefined};
throw unavailable;
}};
if(available)assert.deepEqual((await loadAppCategories(api,new AbortController().signal)).map(item=>item.id),[1]);
else await assert.rejects(loadAppCategories(api,new AbortController().signal),unavailable);
}
});
test('aborted navigation discards late category results',async()=>{
const controller=new AbortController();let finish;
const result=loadAppCategories({async get(_,query){
if(query.categoryId===0)return {categoryList:[category(1)]};
return new Promise(resolve=>{finish=()=>resolve({category:category(1,[app(1)])});});
}},controller.signal);
await new Promise(resolve=>setImmediate(resolve));controller.abort();finish();
assert.deepEqual(await result,[]);
});
test('category selection scrolls to existing sections and updates selection without replacing content',()=>{
const previousWindow=globalThis.window,scrolls=[];
const buttons=[1,2].map(id=>({dataset:{appCategory:String(id)},setAttribute(key,value){this[key]=value;}}));
const root={querySelector:selector=>selector==='#apps-2'?{scrollIntoView:options=>scrolls.push(options)}:null,querySelectorAll:()=>buttons};
try{
globalThis.window={matchMedia:()=>({matches:false})};
scrollToAppCategory(root,'2');assert.deepEqual(scrolls,[{behavior:'smooth',block:'start'}]);
assert.equal(buttons[0]['aria-pressed'],'false');assert.equal(buttons[1]['aria-pressed'],'true');
globalThis.window.matchMedia=()=>({matches:true});scrollToAppCategory(root,'2');assert.equal(scrolls[1].behavior,'auto');
scrollToAppCategory(root,'3');scrollToAppCategory(root,'invalid');assert.equal(scrolls.length,2);
}finally{globalThis.window=previousWindow;}
});
+2 -163
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
@@ -7,7 +8,7 @@ import ts from 'typescript';
async function moduleUrl(file,replace=code=>code){
const filename=new URL(file,import.meta.url);
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
@@ -23,22 +24,6 @@ const response=(data,code=200,headers={})=>new Response(JSON.stringify({code,dat
const deferred=()=>{let resolve;const promise=new Promise(done=>resolve=done);return {promise,resolve};};
async function withFetch(fetcher,work){const old=globalThis.fetch;globalThis.fetch=fetcher;try{await work();}finally{globalThis.fetch=old;}}
test('login accepts account/email, trims identifiers, and preserves password bytes',()=>{
assert.deepEqual(authFormPayload('login',{account:' test ',password:' 1234 '}),{account:'test',password:' 1234 '});
assert.deepEqual(authFormPayload('login',{account:'me@example.com',password:'1234'}),{account:'me@example.com',password:'1234'});
assert.throws(()=>authFormPayload('login',{account:'bad@',password:'1234'}),/有效邮箱/);
assert.throws(()=>authFormPayload('login',{account:'test',password:'123'}),/密码/);
const account='abcdefghijklmnopqrst',password='SafePassword12345678';
assert.equal(authFormPayload('login',{account,password}).account,account);
});
test('guest binding validates confirmation and agreement without requiring an email',()=>{
const values={account:' player01 ',password:'Secure123',passwordConfirm:'Secure123',agree:'on'};
assert.deepEqual(authFormPayload('register',values),{accountNo:'player01',password:'Secure123'});
for(const change of [{account:'a_b'},{password:'abcdefgh',passwordConfirm:'abcdefgh'},{passwordConfirm:'Wrong123'},{agree:''}])assert.throws(()=>authFormPayload('register',{...values,...change}));
assert.deepEqual(authFormPayload('resetPassword',{account:' player01 ',email:' me@example.com ',password:'NewPass123',passwordConfirm:'NewPass123'}),{accountNo:'player01',email:'me@example.com',newPassword:'NewPass123'});
});
test('device identity, first channel and invite survive page navigation without persisting credentials',async()=>{
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
const storage=new Map([['uuid','old-device']]);
@@ -100,36 +85,6 @@ test('search and suggestions wait for a shared guest session before requesting p
});
});
test('slow guest restoration finishes before login and binding waits for a guest identity',async()=>{
const api=await client(),gate=deferred(),calls=[];
await withFetch(async(url,options)=>{
calls.push({url,body:JSON.parse(options.body)});
if(url.endsWith('/auth/guest')){await gate.promise;return response(guest);}
return response(member);
},async()=>{
const restore=api.initializeSession(),login=api.post('auth/login',{account:'player',password:'Secure123'});
await Promise.resolve();assert.equal(calls.length,1);gate.resolve();await Promise.all([restore,login]);
assert.equal(calls[1].body.devID,calls[0].body.devID);assert.equal(api.user.userId,8);
const binding=await client();await binding.post('auth/register',{accountNo:'player',password:'Secure123'});
assert.deepEqual(calls.slice(2).map(call=>call.url),['/api/app/auth/guest','/api/app/auth/register']);
assert.equal(binding.user.userId,8);
});
});
test('invalid credentials preserve the current identity; late expired requests cannot clear a new login',async()=>{
const api=await client();api.setUser(guest);
const gate=deferred();let invalid=true;
await withFetch(async url=>{
if(url.endsWith('/auth/login'))return invalid?response(null,1000):response(member);
await gate.promise;return response(null,1000);
},async()=>{
await assert.rejects(api.post('auth/login',{account:'player',password:'bad'}));assert.equal(api.user.userId,7);
const pending=api.get('user/center/data');await Promise.resolve();invalid=false;
await api.post('auth/login',{account:'player',password:'Secure123'});gate.resolve();await assert.rejects(pending);
assert.equal(api.user.userId,8);
});
});
test('registered expiry prompts once and blocks automatic guest downgrade',async()=>{
const api=await client();api.setUser(member);let prompts=0,calls=0;api.addEventListener('expired',()=>prompts++);
await withFetch(async()=>{calls++;return response(null,1000,{'X-Auth-State':'login-required'});},async()=>{
@@ -176,46 +131,6 @@ test('restoring an expired registered cookie prompts for login even before a use
});
});
test('auth dialog renders binding confirmation, blocks duplicate submission, and clears revealed passwords on close',async()=>{
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
const {bindAccountUI,openAuth}=await import(accountUrl);
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
const listeners={},error={textContent:''},button={textContent:'绑定账号',disabled:false};
const password={value:'Secure123',type:'password'},confirmation={value:'Secure123',type:'password'};
const form={dataset:{mode:'register'},isConnected:true,reportValidity:()=>true,
querySelector:selector=>selector==='#auth-error'?error:button,querySelectorAll:()=>[password,confirmation]};
const dialog={id:'member-auth-dialog',innerHTML:'',open:false,classList:{toggle(){}},querySelector:selector=>selector==='#auth-account'?{focus(){}}:selector==='#auth-password'?password:null,
querySelectorAll:()=>[password,confirmation],addEventListener:(name,handler)=>listeners[name]=handler,showModal(){this.open=true;},close(){this.open=false;listeners.close();}};
const gate=deferred();let submitted=0,routed=0,refreshed=0;const bindState={textContent:'未绑定'};
const app={api:{post:async(path,payload)=>{submitted++;assert.equal(path,'auth/register');assert.equal(payload.accountNo,'player01');await gate.promise;},refreshUser:async()=>{refreshed++;}},notify(){},async route(){routed++;}};
globalThis.document={getElementById:id=>id==='member-auth-dialog'?dialog:null,querySelector:selector=>selector==='[data-bind-state]'?bindState:null};
globalThis.FormData=class extends OldFormData{constructor(){super();for(const [key,value] of Object.entries({account:'player01',password:'Secure123',passwordConfirm:'Secure123',agree:'on'}))this.append(key,value);}};
try{
bindAccountUI(app);openAuth('bind');assert.ok(dialog.open);
assert.ok(dialog.innerHTML.includes('name="passwordConfirm"'));assert.ok(!dialog.innerHTML.includes('name="email"'));
const toggle={dataset:{authPasswordToggle:'auth-password'},setAttribute(){}};
listeners.click({target:{closest:()=>toggle}});assert.equal(password.type,'text');
const event={target:form,preventDefault(){}};listeners.submit(event);listeners.submit(event);
assert.equal(submitted,1);assert.equal(button.disabled,true);
gate.resolve();await new Promise(resolve=>setImmediate(resolve));
// Bind stays on the settings page — no full reload that would replay a stale drama_session.
assert.equal(routed,0);assert.equal(refreshed,1);assert.equal(button.disabled,false);assert.equal(password.value,'');assert.equal(confirmation.value,'');assert.equal(bindState.textContent,'已绑定');
openAuth('switch');assert.ok(dialog.innerHTML.includes('切换账号'));
assert.ok(dialog.innerHTML.includes('data-mode="login"'));assert.ok(dialog.innerHTML.includes('data-auth-switch="1"'));
form.dataset.mode='login';form.dataset.authSwitch='1';
const current={userId:7};app.api.user=current;
app.api.post=async(path,payload)=>{assert.equal(path,'auth/login');assert.equal(payload.switchAccount,true);assert.equal(payload.account,'player01');return {userId:9};};
listeners.submit(event);await new Promise(resolve=>setImmediate(resolve));
assert.equal(routed,1);assert.equal(refreshed,2);
app.api.post=async()=>current;
listeners.submit(event);await new Promise(resolve=>setImmediate(resolve));
assert.equal(routed,1);assert.equal(error.textContent,'切换失败,请确认账号和密码');
}finally{globalThis.document=oldDocument;globalThis.FormData=OldFormData;}
});
test('a server-prepared session is reused without another guest handshake and a sign-in snapshot blocks automatic downgrade',async()=>{
await withFetch(()=>assert.fail('prepared sessions must not refetch auth'),async()=>{
const ready=await client();ready.seedSession(member);assert.equal((await ready.initializeSession()).userId,member.userId);
@@ -421,61 +336,6 @@ test('registered expiry still never recovers as a guest',async()=>{
});
});
test('binding replaces the guest bearer with the new register token and getInfo uses it',async()=>{
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
const storage=new Map([['uuid','bind-device']]);
let cookieJar='drama_device=bind-device';
Object.defineProperty(globalThis,'document',{configurable:true,value:{
get cookie(){return cookieJar;},
set cookie(value){
const [pair]=String(value).split(';');
const sep=pair.indexOf('=');
const name=pair.slice(0,sep);
const body=pair.slice(sep+1);
const parts=cookieJar?cookieJar.split('; ').filter(item=>!item.startsWith(name+'=')):[];
if(body&&!String(value).includes('Max-Age=0'))parts.push(`${name}=${body}`);
cookieJar=parts.join('; ');
},
}});
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}});
Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}});
try{
const api=await client();const calls=[];
await withFetch(async(url,options)=>{
const path=String(url);
const authorization=options.headers?.Authorization||options.headers?.authorization;
calls.push({path,authorization});
if(path.endsWith('/auth/guest'))return response({...guest,token:'guest-before-bind'});
if(path.endsWith('/auth/register')){
assert.equal(authorization,'guest-before-bind');
// Upstream invalidates the guest JWT and returns a new registered token.
return response({...member,token:'bound-after-register',accountNo:'player01'});
}
if(path.endsWith('/auth/getInfo')){
// Bootstrap uses the guest token; post-bind refresh must use the new register token.
if(authorization==='guest-before-bind')return response({...guest,token:''});
assert.equal(authorization,'bound-after-register');
return response({...member,accountNo:'player01',token:''});
}
return response({});
},async()=>{
await api.bootstrapGuestSession();
await api.post('auth/register',{accountNo:'player01',password:'Secure123'});
await api.refreshUser();
assert.equal(api.user.accountNo,'player01');
assert.equal(api.user.type,1);
assert.equal(storage.get('drama_bearer'),'bound-after-register');
assert.ok(calls.some(call=>call.path.endsWith('/auth/register')&&call.authorization==='guest-before-bind'));
assert.ok(calls.some(call=>call.path.endsWith('/auth/getInfo')&&call.authorization==='bound-after-register'));
});
}finally{
for(const [key,descriptor] of Object.entries(originals)){
if(descriptor)Object.defineProperty(globalThis,key,descriptor);
else delete globalThis[key];
}
}
});
test('persisted bearer restores getInfo after reload without opening the login dialog',async()=>{
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
const storage=new Map([['drama_bearer','bound-jwt'],['uuid','bind-device']]);
@@ -505,24 +365,3 @@ test('persisted bearer restores getInfo after reload without opening the login d
}
});
test('soft bootstrap getInfo failure after bind never opens the login dialog',async()=>{
const originals=Object.fromEntries(['document','localStorage','location'].map(key=>[key,Object.getOwnPropertyDescriptor(globalThis,key)]));
const storage=new Map([['drama_bearer','stale-bound-jwt']]);
Object.defineProperty(globalThis,'document',{configurable:true,value:{cookie:''}});
Object.defineProperty(globalThis,'localStorage',{configurable:true,value:{getItem:key=>storage.get(key)??null,setItem:(key,value)=>storage.set(key,String(value)),removeItem:key=>storage.delete(key)}});
Object.defineProperty(globalThis,'location',{configurable:true,value:{protocol:'https:',search:''}});
try{
const api=await client();api.seedSession(member);let prompts=0;api.addEventListener('expired',()=>prompts++);
await withFetch(async()=>response(null,1000),async()=>{
assert.equal((await api.bootstrapGuestSession())?.userId,member.userId);
assert.equal(prompts,0);
assert.equal(api.user.userId,member.userId);
assert.equal(storage.has('drama_bearer'),false);
});
}finally{
for(const [key,descriptor] of Object.entries(originals)){
if(descriptor)Object.defineProperty(globalThis,key,descriptor);
else delete globalThis[key];
}
}
});
+5 -4
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
@@ -6,12 +7,12 @@ import ts from 'typescript';
async function moduleUrl(path,imports={}){
let code=ts.transpileModule(await readFile(new URL('../src/lib/'+path+'.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
for(const [name,url] of Object.entries(imports))code=code.replace("'"+name+"'",JSON.stringify(url));
return 'data:text/javascript;base64,'+Buffer.from(code).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(code)).toString('base64');
}
const uaUrl=await moduleUrl('api/user-agent');
const {buildUserAgent,buildSdkUserAgent,userAgentSid}=await import(uaUrl);
const trackingUrl=await moduleUrl('api/tracking-headers',{'../tracking':await moduleUrl('tracking')});
const {clientHeaders}=await import(await moduleUrl('api/client-headers',{'./user-agent':uaUrl,'./tracking-headers':trackingUrl}));
const sdkUrl=await moduleUrl('api/sdk-headers');
const {clientHeaders}=await import(await moduleUrl('api/client-headers',{'./user-agent':uaUrl,'./sdk-headers':sdkUrl}));
// Generated by running the reference Go BuildXUserAgent function without changing its logic.
const samples=JSON.parse(await readFile(new URL('./fixtures/request-user-agents.json',import.meta.url),'utf8'));
@@ -24,7 +25,7 @@ test('existing X-User-Agent wins over SDK fields and network headers remain sepa
const fields={sid:'sdk-sid',device_id:'sdk-device',device:'Android',device_fingerprint:'raw',fp_version:'3',app_version:'1.0.0'};
const headers=clientHeaders(new Headers({'User-Agent':' Browser UA ','X-User-Agent':xua,'X-Real-IP':'203.0.113.9','X-Forwarded-For':'203.0.113.8, 10.0.0.1','X-SDK-Common':Buffer.from(JSON.stringify(fields)).toString('base64'),Cookie:'drama_session=private',Authorization:'private'}));
assert.equal(headers['User-Agent'],'Browser UA');assert.equal(headers['X-User-Agent'],xua);assert.equal(headers['X-Real-IP'],'203.0.113.9');
assert.deepEqual(JSON.parse(Buffer.from(headers['X-SDK-Common'],'base64')),fields);
assert.deepEqual(JSON.parse(Buffer.from(headers['X-SDK-Common'],'base64')),{sid:'sdk-sid',device_id:'sdk-device',app_version:'1.0.0'});
for(const key of ['Cookie','Authorization','X-Forwarded-For'])assert.equal(headers[key],undefined);
});
+21 -14
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
@@ -7,37 +8,43 @@ import ts from 'typescript';
async function moduleUrl(file,replace=code=>code){
const filename=new URL(file,import.meta.url);
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)));
const {overviewMarkup,officialLinks,bindSeriesDescription,detailActionIcon}=await import(detailUrl);
test('overview retains API data, encrypted image loading and real chapter links in the original layout',()=>{
const html=overviewMarkup({id:23456,title:'剧名 <script>',coverImg:'https://cdn.example/encrypted.webp',description:'真实简介 <img onerror=alert(1)>',themeNames:['古装','爱情'],isNew:true,countCollect:23000,countLike:100,heatValue:999},{id:90042,currentEpisode:1},'<div class="play-detail-actions">actions</div>');
test('cinematic overview keeps API data, encrypted image loading and real chapter links',()=>{
const html=overviewMarkup({id:23456,title:'剧名 <script>',coverImg:'https://cdn.example/encrypted.webp',description:'真实简介 <img onerror=alert(1)>',themeNames:['古装','爱情'],totalEpisode:12,updateStatus:1,isNew:true,countCollect:23000,countLike:100,heatValue:999},{id:90042,currentEpisode:1});
assert.ok(html.includes('剧名 &lt;script&gt;'));assert.ok(!html.includes('<script>'));
assert.ok(html.includes('真实简介 &lt;img onerror=alert(1)&gt;'));assert.ok(html.includes('古装 · 爱情'));
assert.ok(html.includes('真实简介 &lt;img onerror=alert(1)&gt;'));assert.ok(html.includes('#古装'));assert.ok(html.includes('#爱情'));
assert.ok(html.includes('class="cine-hero"'));assert.ok(html.includes('class="cine-hero-image"'));assert.ok(!html.includes('series-poster'));
assert.ok(html.includes('data-src="https://cdn.example/encrypted.webp"'));assert.ok(!/\ssrc="https:\/\/cdn\.example/.test(html));
assert.ok(html.includes('href="/play/23456/90042/"'));assert.ok(html.includes('series-badge--new'));
assert.ok(html.includes('<div class="series-action-bar"><div class="series-actions">'));
assert.ok(html.includes('<span class="cine-kicker">短剧 · 连载中</span>'));assert.ok(html.includes('<h1 id="play-detail-title">'));
assert.ok(html.includes('href="/play/23456/90042/"'));assert.ok(html.includes('<span>立即观看</span>'));
for(const text of ['999热度','2.3万收藏','100点赞','data-engage="like"','data-engage="collect"','data-share'])assert.ok(html.includes(text),text);
assert.ok(html.includes('aria-controls="series-description-text"'));assert.ok(html.includes('data-app-download-entry'));
assert.ok(!overviewMarkup({id:7,title:'无章节'},undefined,'').includes('href="/play/'));
const rich=overviewMarkup({id:8,title:'完结剧',totalEpisode:10,updateStatus:2,countBrowse:167,anchor:'演员 <b>',showOriginal:true,operateType:2,tagNames:['家庭'],tagSlugs:['jiating'],themeNames:['家庭']},{id:1},'');
for(const text of ['全10集','已完结','167次观看','演员 演员 &lt;b&gt;','series-badge--original','series-badge--comic','href="/tag/jiating/"'])assert.ok(rich.includes(text),text);
assert.equal(rich.split('家庭').length-1,1);
assert.ok(!overviewMarkup({id:7,title:'无章节'},undefined).includes('href="/play/'));
const later=overviewMarkup({id:9,title:'从中间开始',totalEpisode:30},{id:5,currentEpisode:3});
assert.ok(later.includes('从第 3 集开始'));
const single=overviewMarkup({id:8,title:'单集',totalEpisode:1,tagNames:['家庭'],tagSlugs:['jiating'],themeNames:['家庭']},{id:1});
assert.ok(single.includes('<span class="cine-kicker">视频</span>'));assert.ok(!single.includes('全1集'));
assert.ok(single.includes('href="/tag/jiating/"'));assert.equal(single.split('#家庭').length-1,1);
});
test('official links use configured safe URLs and retain the original feedback entrance',()=>{
test('official links use configured safe URLs without a customer-service entrance',()=>{
const html=officialLinks({config:{newUrlAddr:'https://entry.example',foreverUrlAddr:'https://permanent.example',tgGroup:'https://t.me/example',telegram:'https://contact.example'}});
for(const url of ['https://entry.example/','https://permanent.example/','https://t.me/example','https://contact.example/'])assert.ok(html.includes(`href="${url}"`));
for(const url of ['https://entry.example/','https://permanent.example/','https://t.me/example'])assert.ok(html.includes(`href="${url}"`));
assert.ok(html.includes('点击加入黄果短剧官方 Telegram 群'));
for(const text of ['官方客服','https://contact.example','/info/feedback/'])assert.ok(!html.includes(text),text);
const fallback=officialLinks({config:{newUrlAddr:'javascript:alert(1)',tgGroup:'javascript:alert(1)'},navPageList:[{line:1,url:'https://backup.example'}]});
assert.ok(fallback.includes('href="https://backup.example/"'));assert.ok(fallback.includes('href="/info/feedback/"'));assert.ok(!fallback.includes('javascript:'));
assert.ok(fallback.includes('href="https://backup.example/"'));assert.ok(!fallback.includes('javascript:'));
assert.ok(!fallback.includes('huangguo.me'));assert.ok(!fallback.includes('huangguo.tv'));
});
test('original action SVGs remain accessible and inherit the active colors',()=>{
for(const kind of ['like','save','share','feedback']){
for(const kind of ['like','save','share']){
const html=detailActionIcon(kind);assert.match(html,/<svg class="play-action-icon"/);assert.match(html,/aria-hidden="true"/);assert.ok(html.includes('<path d="'));
}
});
+1 -1
View File
@@ -285,7 +285,7 @@ test("project profiles and Next configuration keep the requested API and port wi
command.env.PORT,
environment === "production" ? "21042" : "21030",
);
assert.equal(command.env.NEXT_PUBLIC_TRACKER_APP_ID, "1050");
assert.equal(command.env.NEXT_PUBLIC_TRACKER_APP_ID, undefined);
const ignored = spawnSync(
"git",
["check-ignore", "--no-index", `config/${environment}.local.env`],
+2 -1
View File
@@ -1,8 +1,9 @@
import {linkShared} from './shared-modules.mjs';
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
const compile=async path=>ts.transpileModule(await readFile(new URL('../../'+path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
const moduleUrl=source=>'data:text/javascript;base64,'+Buffer.from(source).toString('base64');
const moduleUrl=source=>'data:text/javascript;base64,'+Buffer.from(linkShared(source)).toString('base64');
const routingUrl=moduleUrl(await compile('src/lib/routing.ts'));
const protocolUrl=moduleUrl(await compile('src/lib/api/protocol.ts'));
const playbackUrl=moduleUrl((await compile('src/lib/client/playback.ts')).replace("'../api/protocol'",JSON.stringify(protocolUrl)));
+23
View File
@@ -0,0 +1,23 @@
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
// Dependency-free site modules (brand, contact, footer filtering, download address, visitor headers)
// are linked into transpiled test modules by specifier, whatever relative depth they are imported from.
const compile=async path=>ts.transpileModule(await readFile(new URL('../../src/lib/'+path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
const url=source=>'data:text/javascript;base64,'+Buffer.from(source).toString('base64');
const protocol=url(await compile('api/protocol.ts'));
const userAgent=url(await compile('api/user-agent.ts'));
const modules={
brand:url(await compile('brand.ts')),
contact:url(await compile('contact.ts')),
'footer-links':url(await compile('footer-links.ts')),
'app-download':url((await compile('app-download.ts')).replace(/(['"])\.\/api\/protocol\1/g,JSON.stringify(protocol))),
'visitor-headers':url((await compile('client/visitor-headers.ts')).replace(/(['"])\.\.\/api\/user-agent\1/g,JSON.stringify(userAgent))),
};
export function linkShared(code){
return code.replace(/(['"])(?:\.\.?\/)+(brand|contact|footer-links|app-download|visitor-headers)\1/g,(_,quote,name)=>JSON.stringify(modules[name]));
}
/** Namespace of a shared module, for CommonJS test harnesses that resolve `require` by hand. */
export const sharedModule=name=>import(modules[name]);
+7 -4
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
@@ -5,7 +6,7 @@ import ts from 'typescript';
async function moduleUrl(file,replace=code=>code){
const source=await readFile(new URL(file,import.meta.url),'utf8');
return 'data:text/javascript;base64,'+Buffer.from(replace(ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText)).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText))).toString('base64');
}
const protocol=await moduleUrl('../src/lib/api/protocol.ts');
const {navigationPageData}=await import(await moduleUrl('../src/lib/navigation-page.ts',code=>code.replace("'./api/protocol'",JSON.stringify(protocol))));
@@ -20,13 +21,15 @@ test('legacy navigation fields and address sections supply valid configured dest
assert.deepEqual(data.routes.map(route=>route.href),['https://nav-one.test/','https://nav-two.test/']);
assert.equal(data.permanent,'https://permanent.test/entry');
const empty=navigationPageData({navPageList:null});
assert.deepEqual(empty.routes,[]);for(const key of ['permanent','telegram','tgGroup','twitter'])assert.equal(empty[key],'');
assert.deepEqual(empty.routes,[]);assert.equal(empty.permanent,'');for(const key of ['telegram','tgGroup','twitter'])assert.equal(key in empty,false);
assert.equal(empty.siteName,'黄果短剧');
});
test('navigation identity and contacts use configuration, support TG handles, and omit demo accounts',()=>{
test('navigation identity uses configuration, rebrands legacy names and never exposes configured contacts',()=>{
const data=navigationPageData({seoNavInfo:JSON.stringify({dhSiteName:'配置导航站',dhDescription:'配置说明'}),config:{telegram:'javascript:bad',telegramAdd:'@business_91',tgGroup:'',twitterAddr:'data:text/html,bad'},newAddressInfo:{tgGroup:'@group_91',twitterAddr:'https://x.com/configured'}});
assert.equal(data.siteName,'配置导航站');assert.equal(data.description,'配置说明');
assert.equal(data.telegram,'https://t.me/business_91');assert.equal(data.tgGroup,'https://t.me/group_91');assert.equal(data.twitter,'https://x.com/configured');
for(const key of ['telegram','tgGroup','twitter'])assert.equal(key in data,false);
assert.equal(navigationPageData({config:{dhSiteName:'禁果短剧'}}).siteName,'黄果短剧');
assert.equal(navigationPageData({config:{dhSiteName:'配置名称'}}).siteName,'配置名称');
assert.equal(navigationPageData({seoInfo:{webSiteName:'站点名称'}}).siteName,'站点名称');
});
+5 -189
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
@@ -10,17 +11,11 @@ if(typeof globalThis.File==='undefined')globalThis.File=NodeFile;
async function moduleUrl(file,replace=code=>code){
const filename=new URL(file,import.meta.url);
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
const feedbackUrl=await moduleUrl('../src/lib/client/feedback.ts',code=>code.replace('"./render"',JSON.stringify(renderUrl)));
const {card}=await import(renderUrl);
const {accountMenuMarkup,settingsMarkup,memberMarkup,renderMember}=await import(accountUrl);
const {feedbackPayload,feedbackMarkup,renderFeedbackPage}=await import(feedbackUrl);
const flush=()=>new Promise(resolve=>setImmediate(resolve));
test('ranking cards retain original poster frames and global positions, and search highlights only escaped titles',()=>{
@@ -35,186 +30,7 @@ test('ranking cards retain original poster frames and global positions, and sear
assert.ok(search.includes('aria-label="查看测试 &lt;短剧&gt;"'));
});
test('personal pages use actual library data and route links inside the original summary and tabs',async()=>{
const oldDocument=globalThis.document,root={innerHTML:''},calls=[];
const user={userId:88,nickName:'真实昵称 <script>',recharge:2400,avatar:'https://cdn.example/avatar.webp'};
const app={signal:new AbortController().signal,home:{},api:{ensureSession:async()=>user,get:async(path,query)=>{calls.push({path,query});return {list:[{id:7,title:'已看过的短剧'}],totalCount:72};}}};
globalThis.document={getElementById:()=>root};
try{
await renderMember(app,'center',1,()=>'/member/center/');
assert.equal(calls.at(-1).path,'playlet/history/list');
for(const className of ['member-name-row','member-settings-link','member-wallet','coin-soft-star','member-library-tabs','member-library-toolbar','member-library-cards','member-library-item'])assert.ok(root.innerHTML.includes(className),className);
assert.ok(root.innerHTML.includes('真实昵称 &lt;script&gt;'));assert.ok(root.innerHTML.includes('2,400'));assert.ok(root.innerHTML.includes('ID:88'));
assert.ok(root.innerHTML.includes('href="/member/settings/"'));assert.ok(root.innerHTML.includes('href="/member/history/page/2/"'));assert.ok(!root.innerHTML.includes('/member/center/page/'));
assert.ok(root.innerHTML.includes('href="/member/favorites/"'));assert.ok(root.innerHTML.includes('href="/member/likes/"'));assert.ok(root.innerHTML.includes('href="/member/purchased/"'));
await renderMember(app,'favorites',2,page=>`/member/favorites/page/${page}/`);
assert.equal(calls.at(-1).path,'playlet/collect/list');assert.equal(calls.at(-1).query.pageNum,2);
assert.ok(root.innerHTML.includes('href="/member/favorites/" aria-current="page"'));
assert.ok(!root.innerHTML.includes('吴秀英'));assert.ok(!root.innerHTML.includes('1,280'));
const icons=Array.from({length:18},(_,index)=>({id:`icon-${index}`,title:`图标${String(index).padStart(2,'0')}`,images:`https://cdn.example/${index}.png`,link:`https://example.com/${index}`}));
const marked=memberMarkup(user,'center',{list:[],totalCount:0},{advitise:{nineGridList:[null,{id:'bad',title:'坏图',images:'javascript:alert(1)'},...icons]}});
assert.equal((marked.match(/class="member-ad-tile"/g)||[]).length,16);
assert.ok(marked.indexOf('member-ad-grid')<marked.indexOf('member-summary'));
assert.ok(marked.includes('图标00'));assert.ok(marked.includes('图标15'));
assert.ok(!marked.includes('图标16'));assert.ok(!marked.includes('javascript:'));
assert.ok(marked.includes('data-ad-type="icon"'));assert.ok(marked.includes('width="76"'));
assert.ok(!memberMarkup(user,'center',{}).includes('member-ad-grid'));
assert.ok(!memberMarkup(user,'settings',{},{advitise:{nineGridList:icons}}).includes('member-ad-tile'));
}finally{globalThis.document=oldDocument;}
});
test('likes use the protected list endpoint, native pagination and the reference library markup',async()=>{
const previous=globalThis.document,root={innerHTML:''},calls=[];
const user={userId:8,nickName:'点赞用户'};
const app={signal:new AbortController().signal,home:{},api:{ensureSession:async()=>user,get:async(path,query)=>{
calls.push({path,query});return {list:[{id:7,slug:'liked-story',title:'点赞 <短剧>',totalEpisode:8}],totalCount:72};
}}};
globalThis.document={getElementById:()=>root};
try{
await renderMember(app,'likes',2,page=>page===1?'/member/likes/':`/member/likes/page/${page}/`);
assert.deepEqual(calls.at(-1),{path:'playlet/like/list',query:{pageNum:2,pageSize:24}});
assert.ok(root.innerHTML.includes('href="/member/likes/" aria-current="page"><h2 id="member-library-title"'));
assert.ok(root.innerHTML.includes('共 72 部短剧'));assert.ok(root.innerHTML.includes('href="/member/likes/page/3/"'));
assert.ok(root.innerHTML.includes('href="/detail/liked-story/"'));assert.ok(root.innerHTML.includes('点赞 &lt;短剧&gt;'));
for(const tab of ['history','favorites','likes','purchased'])assert.ok(root.innerHTML.includes(`href="/member/${tab}/"`));
const initial={user,list:{list:[],totalCount:0}};calls.length=0;
await renderMember(app,'likes',1,()=>'/member/likes/',initial);
assert.equal(calls.length,0,'SSR data must be reused without a second list request');
assert.ok(root.innerHTML.includes('member-library-empty'));assert.ok(root.innerHTML.includes('暂无点赞的短剧'));
assert.ok(root.innerHTML.includes('href="/shorts/">去发现好剧</a>'));
assert.ok(!memberMarkup(user,'likes',{list:null,totalCount:0}).includes('undefined'));
}finally{globalThis.document=previous;}
});
test('the account menu exposes a native likes entry for guests and registered users',()=>{
for(const user of [null,{userId:8,nickName:'用户 <script>',isRegister:true}]){
const html=accountMenuMarkup(user);
assert.ok(html.includes('href="/member/likes/"'));
assert.ok(html.includes('我的点赞'));
assert.ok(html.includes('class="profile-row"'));
assert.ok(html.includes('account-collection-icon'));
assert.ok(html.includes('data-auth-mode="login"'));
assert.ok(html.includes('data-auth-mode="bind"'));
assert.ok(html.includes('class="profile-switch"'));
assert.ok(html.indexOf('/member/favorites/')<html.indexOf('/member/likes/'));
assert.ok(html.indexOf('/member/likes/')<html.indexOf('/member/history/'));
assert.ok(html.indexOf('/member/history/')<html.indexOf('/info/deposit/'));
assert.ok(html.indexOf('/info/deposit/')<html.indexOf('data-auth-mode="switch"'));
assert.ok(!html.includes('已购剧集'));
assert.ok(!html.includes('账号设置'));
assert.ok(!html.includes('退出登录'));
assert.ok(!html.includes('data-logout'));
assert.ok(!html.includes('<script>'));
if(user)assert.ok(html.includes('用户 &lt;script&gt;'));
}
const counted=accountMenuMarkup({userId:8,nickName:'甲',recharge:1280},{collectCount:3,likeCount:5});
assert.ok(counted.includes('id="profile-saved-count">3'));
assert.ok(counted.includes('id="profile-likes-count">5'));
assert.ok(!counted.includes('profile-watch-count'));
assert.ok(!counted.includes('profile-bought-count'));
assert.ok(counted.includes('id="profile-coin-count">1,280')||counted.includes('id="profile-coin-count">1280'));
assert.ok(!counted.includes('data-logout'));
});
test('settings preserve actual profile fields and auth actions inside the reference cards',()=>{
const html=settingsMarkup({userId:8,nickName:'用户 <script>',introduction:'简介 <img>',avatar:'https://cdn.example/avatar.webp',isRegister:true,hasPassword:true});
for(const text of ['member-settings-card','member-nickname-editor','id="member-nickname-form"','账号与安全','data-member-settings="bind"','data-auth-mode="register"','data-member-settings="switch"','data-auth-mode="switch"','ui-chevron-right','maxlength="20"','支持 1–20 个字符'])assert.ok(html.includes(text),text);
assert.ok(html.includes('value="用户 &lt;script&gt;"'));assert.ok(html.includes('data-bind-state>已绑定'));
assert.ok(settingsMarkup({userId:9,nickName:'游客',type:0,isRegister:false,hasPassword:false}).includes('data-bind-state>未绑定'));
assert.ok(settingsMarkup({userId:10,nickName:'会员',type:1}).includes('data-bind-state>已绑定'));
assert.ok(!html.includes('个人简介'));assert.ok(!html.includes('data-logout'));
assert.ok(!html.includes('修改密码'));assert.ok(!html.includes('id="security-form"'));assert.ok(!html.includes('保存密码'));
assert.ok(html.includes('class="member-settings-avatar"'));assert.ok(html.includes('alt="当前头像"'));assert.ok(html.includes('data-src="https://cdn.example/avatar.webp"'));
assert.ok(!html.includes('更换头像'));assert.ok(!html.includes('member-avatar-file'));assert.ok(!html.includes('data-avatar-action'));
});
test('feedback options map to the real API type and keep environment details with actual attachments',()=>{
const html=feedbackMarkup();
for(const text of ['feedback-reference','feedback-options','feedback-panel','feedback-description-wrap','feedback-upload-box','feedback-submit-button'])assert.ok(html.includes(text),text);
assert.ok(!html.includes('video/mp4'));assert.ok(!html.includes('保存到本地'));
const data=new FormData();for(const [key,value] of Object.entries({category:'登录异常',content:'播放时一直缓冲,无法正常观看视频。',contact:' me@example.com ',network:'Wi-Fi',device:'iPhone'}))data.set(key,value);
const body=feedbackPayload(data,['/files/real.png']);assert.equal(body.type,3);assert.equal(body.contact,'me@example.com');assert.deepEqual(body.screenshots,['/files/real.png']);
assert.ok(body.content.includes('网络类型:Wi-Fi'));assert.ok(body.content.includes('设备类型:iPhone'));
data.set('category','无法播放');assert.equal(feedbackPayload(data).type,2);data.set('category','其他问题');assert.equal(feedbackPayload(data).type,4);data.set('category','闪退 / 崩溃');assert.equal(feedbackPayload(data).type,4);
data.set('content','长'.repeat(500));assert.equal(feedbackPayload(data).content,'长'.repeat(500));
data.set('content','中'.repeat(490));assert.equal(feedbackPayload(data).content.length,500);
data.set('content','太短');assert.throws(()=>feedbackPayload(data),/10–500/);
});
class Element extends EventTarget {
nodes=new Map();dataset={};innerHTML='';textContent='';disabled=false;value='';files=[];
querySelector(selector){return this.nodes.get(selector)||null;}
querySelectorAll(){return [];}
reportValidity(){return true;}
reset(){this.resetCount=(this.resetCount||0)+1;}
}
function emit(element,type){element.dispatchEvent(new Event(type,{cancelable:true}));}
test('nickname settings save the nickname once and keep an existing introduction',async()=>{
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
const controller=new AbortController(),root=new Element(),profile=new Element(),status=new Element();
const values=new Map([[profile,{nickname:'新昵称'}]]);
profile.nodes.set('[type="submit"]',new Element());profile.nodes.set('#member-nickname-status',status);
root.nodes.set('#member-nickname-form',profile);
const calls=[];let finish,refreshes=0,routes=0;
const app={signal:controller.signal,home:{},api:{ensureSession:async()=>({userId:8,introduction:'原简介'}),get:async()=>({}),post:async(path,body)=>{calls.push({path,body});if(calls.length===1)await new Promise(resolve=>finish=resolve);},refreshUser:async()=>refreshes++},notify(){},async route(){routes++;}};
globalThis.document={getElementById:()=>root};globalThis.FormData=class extends OldFormData{constructor(form){super();if(form)for(const [key,value] of Object.entries(values.get(form)))this.set(key,value);}};
try{
await renderMember(app,'settings',1,()=>'/member/settings/');
emit(profile,'submit');emit(profile,'submit');assert.equal(calls.length,1);assert.equal(profile.querySelector('[type="submit"]').disabled,true);
finish();await flush();assert.deepEqual(calls[0],{path:'auth/profile',body:{nickName:'新昵称',introduction:'原简介'}});assert.equal(refreshes,1);assert.equal(routes,1);
}finally{controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}
});
function feedbackFixture(post){
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
const controller=new AbortController(),root=new Element(),form=new Element(),input=new Element(),list=new Element(),status=new Element(),error=new Element(),content=new Element(),count=new Element(),submit=new Element();
const values={content:'播放视频时持续缓冲,无法正常观看。',category:'无法播放',contact:'me@example.com',network:'Wi-Fi'};
root.nodes.set('#feedback-form',form);
for(const [selector,node] of [['#feedback-files',input],['#feedback-file-list',list],['#feedback-status',status],['#feedback-file-error',error],['[name="content"]',content],['#feedback-count',count],['[type="submit"]',submit]])form.nodes.set(selector,node);
globalThis.document={getElementById:()=>root};globalThis.FormData=class extends OldFormData{constructor(form){super();if(form)for(const [key,value] of Object.entries(values))this.set(key,value);}};
const calls=[];renderFeedbackPage({signal:controller.signal,api:{post:async(path,body)=>{calls.push({path,body});return post(path,body);}}});
return {controller,form,input,list,status,error,submit,calls,dispose(){controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}};
}
test('reference feedback form uploads images only on submit and blocks duplicate feedback writes',async()=>{
let finish;const f=feedbackFixture(path=>path==='upload/image'?new Promise(resolve=>finish=resolve):Promise.resolve({}));
try{
f.input.files=[new File(['image'],'shot.png',{type:'image/png'})];emit(f.input,'change');assert.equal(f.calls.length,0);assert.ok(f.list.innerHTML.includes('shot.png'));
emit(f.form,'submit');emit(f.form,'submit');assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,'upload/image');assert.equal(f.submit.disabled,true);
finish({sourceNameFs:'/uploaded/shot.png'});await flush();
assert.equal(f.calls.length,2);assert.equal(f.calls[1].path,'feedback/add');assert.deepEqual(f.calls[1].body.screenshots,['/uploaded/shot.png']);assert.equal(f.calls[1].body.type,2);
assert.equal(f.form.resetCount,1);assert.match(f.status.textContent,/反馈已提交/);assert.equal(f.submit.disabled,false);
}finally{f.dispose();}
});
test('later feedback images are kept alongside earlier ones until three are selected',()=>{
const f=feedbackFixture(async()=>({}));
const image=(name)=>new File(['image'],name,{type:'image/png'});
try{
f.input.files=[image('one.png')];emit(f.input,'change');
f.input.files=[image('two.png')];emit(f.input,'change');
assert.ok(f.list.innerHTML.includes('one.png'));assert.ok(f.list.innerHTML.includes('two.png'));assert.equal(f.error.textContent,'');
f.input.files=[image('three.png'),image('four.png')];emit(f.input,'change');
assert.match(f.error.textContent,/最多上传 3 张/);assert.ok(!f.list.innerHTML.includes('three.png'));assert.ok(f.list.innerHTML.includes('one.png'));
f.input.files=[image('three.png')];emit(f.input,'change');
assert.equal(f.error.textContent,'');assert.ok(f.list.innerHTML.includes('three.png'));assert.equal(f.input.disabled,true);
f.list.closest=()=>({dataset:{removeFeedbackFile:'0'}});
f.list.dispatchEvent(new Event('click'));
assert.ok(!f.list.innerHTML.includes('one.png'));assert.ok(f.list.innerHTML.includes('two.png'));assert.equal(f.input.disabled,false);
}finally{f.dispose();}
});
test('unsupported files, failed uploads and navigation cannot silently submit feedback',async()=>{
const failed=feedbackFixture(async()=>({}));
try{
failed.input.files=[new File(['bad'],'clip.mp4',{type:'video/mp4'})];emit(failed.input,'change');assert.match(failed.error.textContent,/JPG/);assert.equal(failed.calls.length,0);
failed.input.files=[new File(['valid'],'shot.png',{type:'image/png'})];emit(failed.input,'change');emit(failed.form,'submit');await flush();
assert.deepEqual(failed.calls.map(call=>call.path),['upload/image']);assert.match(failed.status.textContent,/上传失败/);assert.equal(failed.submit.disabled,false);assert.equal(failed.form.resetCount,undefined);
}finally{failed.dispose();}
let finish;const late=feedbackFixture(()=>new Promise(resolve=>finish=resolve));
try{
late.input.files=[new File(['valid'],'shot.png',{type:'image/png'})];emit(late.input,'change');emit(late.form,'submit');late.controller.abort();finish({sourceNameFs:'/uploaded/late.png'});await flush();
assert.deepEqual(late.calls.map(call=>call.path),['upload/image']);assert.equal(late.form.resetCount,undefined);
}finally{late.dispose();}
test('member, settings and feedback client modules are removed from the site',async()=>{
const {access}=await import('node:fs/promises');
for(const file of ['account.ts','feedback.ts','header-history.ts','unlock.ts','analytics.ts'])await assert.rejects(access(new URL('../src/lib/client/'+file,import.meta.url)),file);
});
+5 -32
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
@@ -7,7 +8,7 @@ import ts from 'typescript';
async function moduleUrl(file,replace=code=>code){
const filename=new URL(file,import.meta.url);
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
}
const routingUrl=await moduleUrl('../src/lib/routing.ts');
const {routeFromSegments,parseSitePath,paginationPath,internalPagePath,searchPath}=await import(routingUrl);
@@ -15,14 +16,10 @@ const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const {pagination}=await import(renderUrl);
const {bindPaginationJumps}=await import(await moduleUrl('../src/lib/client/navigation.ts'));
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
const {renderMember,renderWallet}=await import(accountUrl);
test('every paginated page type resolves directly from the page suffix',()=>{
const keyword='都市 / page/7/?#%2F';
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+encodeURIComponent(keyword)+'/', '/member/favorites/','/member/history/','/member/likes/','/member/purchased/','/info/deposit/','/info/deposit/vip/','/detail/23456/','/play/23456/90001/'];
const bases=['/shorts/','/topics/','/topics/7/','/theme/8/','/tag/9/','/config/31/','/rank/','/rank/hot/','/rank/new/','/rank/follow/','/search/'+encodeURIComponent(keyword)+'/'];
for(const base of bases){
for(const page of [1,2,37]){
const path=base+'page/'+page+'/';
@@ -37,7 +34,8 @@ test('every paginated page type resolves directly from the page suffix',()=>{
test('invalid page numbers, nested page suffixes, and non-paginated sections are rejected',()=>{
for(const value of ['0','-1','1.5','02','NaN','9007199254740992'])assert.equal(parseSitePath(`/shorts/page/${value}/`),null,value);
for(const base of ['/','/apps/','/home-road/','/navigation/','/member/center/','/member/settings/','/info/about-us/'])assert.equal(parseSitePath(base+'page/2/'),null,base);
for(const base of ['/','/apps/','/home-road/','/navigation/','/info/about-us/','/detail/23456/','/play/23456/90001/'])assert.equal(parseSitePath(base+'page/2/'),null,base);
for(const base of ['/member/center/','/member/favorites/','/info/deposit/','/info/feedback/'])assert.equal(parseSitePath(base),null,base);
assert.equal(parseSitePath('/shorts/page/2/page/3/'),null);
assert.throws(()=>paginationPath('/shorts/',0));assert.throws(()=>paginationPath('/apps/',2));
});
@@ -50,7 +48,6 @@ test('page links retain filters and encoded search terms and sorting resets to t
assert.equal(paginationPath(base,2),searchPath('都市 / 爱情?#%',2));
const url=new URL(paginationPath(base,2),'https://drama.example');
assert.equal(parseSitePath(url.pathname,url.search).key,'都市 / 爱情?#%');
assert.equal(paginationPath('/play/7/90001/page/3/?commentSort=1',1,{commentSort:null}),'/play/7/90001/');
});
test('pagination renders real page links with correct boundaries, current page, and bounded page numbers',()=>{
@@ -94,27 +91,3 @@ test('jump forms navigate to independent routes, retain canonical slugs and filt
if(previousInput===undefined)delete globalThis.HTMLInputElement;else globalThis.HTMLInputElement=previousInput;
}
});
test('personal library and wallet requests and page links use the route page',async()=>{
const previousDocument=globalThis.document,calls=[];
const root={innerHTML:'',querySelector:()=>null,addEventListener(){}};
const user={userId:7,nickName:'测试用户'};
const api={user,ensureSession:async()=>user,async get(path,query){
calls.push({path,...query});
if(path==='user/center/data')return {};
if(path==='recharge/package/list')return {coinList:[],vipList:[]};
return {list:[],totalCount:120};
}};
const app={api,signal:new AbortController().signal};
globalThis.document={getElementById:()=>root};
try{
for(const [tab,endpoint] of [['favorites','playlet/collect/list'],['history','playlet/history/list'],['likes','playlet/like/list'],['purchased','playlet/bought/list']]){
await renderMember(app,tab,3,page=>paginationPath(`/member/${tab}/page/3/`,page));
const call=calls.at(-1);assert.equal(call.path,endpoint);assert.equal(call.pageNum,3);
assert.ok(root.innerHTML.includes(`href="/member/${tab}/page/2/"`));
}
await renderWallet(app,true,3,page=>paginationPath('/info/deposit/vip/page/3/',page));
assert.equal(calls.at(-1).path,'coin/trans/list');assert.equal(calls.at(-1).page,3);
assert.ok(root.innerHTML.includes('href="/info/deposit/vip/page/4/"'));
}finally{if(previousDocument===undefined)delete globalThis.document;else globalThis.document=previousDocument;}
});
-140
View File
@@ -1,140 +0,0 @@
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import ts from 'typescript';
async function moduleUrl(file,replace=code=>code){
const filename=new URL(file,import.meta.url);
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
const authUrl=await moduleUrl('../src/lib/client/auth.ts');
const trackingUrl=await moduleUrl('../src/lib/tracking.ts');
const accountUrl=await moduleUrl('../src/lib/client/account.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)).replace('"./render"',JSON.stringify(renderUrl)).replace("'./auth'",JSON.stringify(authUrl)).replace("'../tracking'",JSON.stringify(trackingUrl)));
const {paymentDialogMarkup,renderWallet}=await import(accountUrl);
const coinPackage={id:31,name:'金币套餐',packageType:2,price:10000,baseCoin:100,giftCoin:999,totalCoin:1099,payMethods:[{type:'alipay',typeName:'支付宝'},{type:'wechat',typeName:'微信'}]};
const flush=()=>new Promise(resolve=>setImmediate(resolve));
test('confirmation displays actual base/gift/total coins and the price in yuan with original payment icons',()=>{
const html=paymentDialogMarkup(coinPackage,false);
assert.ok(html.includes('<strong>100 金币</strong>'));assert.ok(html.includes('赠送 999 金币 · 合计 1099 金币'));
assert.ok(html.includes('<b>¥100</b>'));assert.ok(html.includes('5652fcead17188e8.svg'));assert.ok(html.includes('2bead9ea5c435c98.svg'));
assert.ok(!html.includes('7848816d05aa8591.svg'));assert.ok(!html.includes('type="radio"'));
assert.ok(html.includes('name="payMode" value="alipay"'));assert.ok(html.includes('class="wallet-payment-title"'));
const derived=paymentDialogMarkup({...coinPackage,baseCoin:undefined,price:12345},false);
assert.ok(derived.includes('<strong>100 金币</strong>'));assert.ok(derived.includes('<b>¥123.45</b>'));
});
test('VIP and missing payment data never introduce demo bonuses or unconfigured channels',()=>{
const vip=paymentDialogMarkup({id:7,name:'月卡 <script>',packageType:1,price:3000},true);
assert.ok(vip.includes('会员畅享'));assert.ok(vip.includes('月卡 &lt;script&gt;'));assert.ok(!vip.includes('赠送 999'));
assert.ok(vip.includes('暂无可用支付方式'));assert.match(vip,/<button class="wallet-submit wallet-payment-go" type="submit" disabled>/);
assert.ok(!vip.includes('data-wallet-method='));
const unknown=paymentDialogMarkup({...coinPackage,payMethods:[null,{type:'',typeName:'空'},{type:'union',typeName:'银联 <img>'},{type:'union',typeName:'重复'}]},false);
assert.equal((unknown.match(/data-wallet-method=/g)||[]).length,1);assert.ok(unknown.includes('银联 &lt;img&gt;'));assert.ok(unknown.includes('wallet-pay-logo--other'));
});
class Element extends EventTarget {
dataset={};attrs=new Map();nodes=new Map();lists=new Map();innerHTML='';textContent='';disabled=false;isConnected=true;value='';
querySelector(selector){return this.nodes.get(selector)||null;}
querySelectorAll(selector){return this.lists.get(selector)||[];}
setAttribute(key,value){this.attrs.set(key,String(value));}
getAttribute(key){return this.attrs.get(key)||null;}
closest(){return this;}
reportValidity(){return true;}
}
function emit(element,type,target=element){const event=new Event(type,{cancelable:true});Object.defineProperty(event,'target',{value:target});element.dispatchEvent(event);}
async function fixture({vip=false,post=async()=>({payUrl:'https://pay.example/checkout',tradeNo:'order-1'}),methods=coinPackage.payMethods,packages,transactions={list:[],totalCount:0},transactionError=false,balance=90,openDialog=true}={}){
const oldDocument=globalThis.document,OldFormData=globalThis.FormData;
const controller=new AbortController(),root=new Element(),dialog=new Element(),form=new Element(),hidden=new Element(),confirm=new Element(),error=new Element(),refresh=new Element();
const item={...coinPackage,payMethods:methods};hidden.value=methods[0]?.type||'';
const items=packages??[item],pay=new Element(),selection=new Element();pay.dataset.walletAction='pay';
const packageButtons=items.map((item,index)=>{const button=new Element();button.dataset.buyPackage=String(item.id);button.setAttribute('aria-pressed',String(index===0));return button;});
root.nodes.set('[data-wallet-action="pay"]',pay);root.nodes.set('#wallet-selection',selection);root.lists.set('[data-buy-package]',packageButtons);
const choices=methods.map(method=>{const button=new Element();button.dataset.walletMethod=method.type;button.nodes.set('.wallet-method-check',new Element());return button;});
form.nodes.set('[name="payMode"]',hidden);form.nodes.set('[type="submit"]',confirm);form.nodes.set('.api-error',error);form.nodes.set('[data-payment-refresh]',refresh);form.lists.set('[data-wallet-method]',choices);
dialog.nodes.set('form',form);dialog.open=false;dialog.showModal=()=>dialog.open=true;dialog.close=()=>dialog.open=false;
const posts=[];let refreshes=0,routes=0;
const user={recharge:balance};
const app={signal:controller.signal,api:{user,ensureSession:async()=>user,get:async path=>{if(path==='recharge/package/list')return {coinList:items,vipList:items};if(transactionError)throw new Error('offline');return transactions;},post:async(path,body)=>{posts.push({path,body});return post();},refreshUser:async()=>refreshes++},notify(){},async route(){routes++;}};
globalThis.document={getElementById:id=>id==='info-content'?root:dialog};
globalThis.FormData=class extends OldFormData{constructor(form){super();this.append('payMode',form.querySelector('[name="payMode"]').value);}};
await renderWallet(app,vip,1,()=>'/info/deposit/');
if(openDialog)emit(root,'click',pay);
return {app,root,dialog,form,hidden,confirm,error,refresh,choices,posts,pay,selection,packageButtons,get refreshes(){return refreshes;},get routes(){return routes;},dispose(){controller.abort();globalThis.document=oldDocument;globalThis.FormData=OldFormData;}};
}
test('recharge page uses the HTML balance, package and transaction layout with actual API values',async()=>{
const f=await fixture({openDialog:false,balance:2400,packages:[{...coinPackage,baseCoin:250,giftCoin:25,totalCoin:275,price:12345}],transactions:{list:[
{title:'解锁 <短剧>',createdAt:'2026-10-20T13:24:00+08:00',flowDir:-1,amount:30,changeAmount:0},
{tranTypeName:'充值到账',createdAt:'2026-10-20 12:00:00',flowDir:1,amount:100,changeAmount:125},
{remark:'余额校验',flowDir:1,amount:0,coin:999},
],totalCount:3}});
try{
const html=f.root.innerHTML;
for(const className of ['wallet-balance-card','wallet-balance-summary','wallet-usage','wallet-pack-panel','wallet-pack-heading','wallet-package-coin','wallet-double-badge','wallet-mobile-selected','wallet-pay-area','wallet-transaction-list'])assert.ok(html.includes(className),className);
assert.ok(html.includes('<strong>2,400</strong><b>金币</b>'));assert.ok(html.includes('>250金币</span>'));assert.ok(html.includes('>赠送 25金币</span>'));assert.ok(html.includes('立即支付 ¥123.45'));
assert.ok(html.includes('已选择 250金币 + 赠送25金币'));assert.ok(html.includes('aria-pressed="true"'));
assert.ok(html.includes('解锁 &lt;短剧&gt;'));assert.ok(html.includes('2026/10/20 13:24'));assert.ok(html.includes('datetime="2026-10-20T05:24:00.000Z"'));
assert.ok(html.includes('expense">−30</span>'));assert.ok(html.includes('income">+125</span>'));assert.ok(html.includes('income">0</span>'));
assert.ok(html.includes('href="/info/feedback/"'));assert.ok(!html.includes('模拟'));assert.ok(!html.includes('首次充值赠送10%'));assert.ok(!html.includes('1,280'));
assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
}finally{f.dispose();}
});
test('package click only updates selection and the pay action confirms the selected package ID',async()=>{
const second={...coinPackage,id:84,baseCoin:300,giftCoin:0,totalCoin:300,price:4599};
const f=await fixture({openDialog:false,packages:[coinPackage,second]});
try{
emit(f.root,'click',f.packageButtons[1]);
assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
assert.equal(f.packageButtons[0].getAttribute('aria-pressed'),'false');assert.equal(f.packageButtons[1].getAttribute('aria-pressed'),'true');
assert.equal(f.pay.textContent,'立即支付 ¥45.99');assert.equal(f.selection.textContent,'已选择 300金币 · 点击后选择支付方式');
emit(f.root,'click',f.pay);assert.equal(f.dialog.open,true);assert.ok(f.dialog.innerHTML.includes('<strong>300 金币</strong>'));assert.ok(f.dialog.innerHTML.includes('<b>¥45.99</b>'));
emit(f.form,'submit');await flush();assert.deepEqual(f.posts,[{path:'coin/buy',body:{packageId:84,payMode:'alipay',sourcePageKey:'deposit',sourcePageName:'充值中心'}}]);
}finally{f.dispose();}
});
test('empty packages and unavailable transactions show empty states without sample data or an order',async()=>{
const f=await fixture({openDialog:false,packages:[],transactionError:true,balance:0});
try{
assert.ok(f.root.innerHTML.includes('<strong>0</strong><b>金币</b>'));assert.ok(f.root.innerHTML.includes('暂无可购买套餐'));assert.ok(f.root.innerHTML.includes('余额明细暂不可用,请稍后刷新'));
assert.match(f.root.innerHTML, /data-wallet-action="pay" disabled/);assert.ok(!f.root.innerHTML.includes('data-buy-package='));assert.ok(!f.root.innerHTML.includes('wallet-transaction-row'));
emit(f.root,'click',f.pay);assert.equal(f.dialog.open,false);assert.equal(f.posts.length,0);
}finally{f.dispose();}
});
test('selecting a styled method updates the actual order payload and duplicate submission is blocked',async()=>{
let finish;const f=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
try{
assert.equal(f.dialog.open,true);assert.equal(f.posts.length,0);
emit(f.form,'click',f.choices[1]);assert.equal(f.hidden.value,'wechat');assert.equal(f.choices[1].getAttribute('aria-pressed'),'true');assert.equal(f.choices[1].querySelector('.wallet-method-check').textContent,'✓');
emit(f.form,'submit');emit(f.form,'submit');assert.deepEqual(f.posts,[{path:'coin/buy',body:{packageId:31,payMode:'wechat',sourcePageKey:'deposit',sourcePageName:'充值中心'}}]);
assert.equal(f.confirm.disabled,true);emit(f.form,'click',f.choices[0]);assert.equal(f.hidden.value,'wechat');
finish({payUrl:'https://pay.example/checkout',tradeNo:'order-1'});await flush();
assert.ok(f.form.innerHTML.includes('打开支付页面'));assert.equal(f.refreshes,0);assert.equal(f.app.api.user.recharge,90);
emit(f.refresh,'click');await flush();assert.equal(f.refreshes,1);assert.equal(f.dialog.open,false);assert.equal(f.routes,1);
}finally{f.dispose();}
});
test('VIP uses its existing endpoint and an invalid payment URL leaves the form available to retry',async()=>{
const f=await fixture({vip:true,post:async()=>({payUrl:'javascript:alert(1)'})});
try{
emit(f.form,'submit');await flush();assert.deepEqual(f.posts,[{path:'vip/pay',body:{packageId:31,payMode:'alipay',sourcePageKey:'deposit',sourcePageName:'充值中心'}}]);
assert.match(f.error.textContent,/有效地址/);assert.equal(f.confirm.disabled,false);assert.equal(f.choices[0].disabled,false);assert.equal(f.refreshes,0);
assert.ok(!f.form.innerHTML.includes('javascript:'));
}finally{f.dispose();}
});
test('missing methods cannot create an order and a replaced form ignores late order responses',async()=>{
const empty=await fixture({methods:[]});
try{emit(empty.form,'submit');assert.equal(empty.posts.length,0);}finally{empty.dispose();}
let finish;const late=await fixture({post:()=>new Promise(resolve=>finish=resolve)});
try{
emit(late.form,'submit');late.form.isConnected=false;finish({payUrl:'https://pay.example',tradeNo:'old-order'});await flush();
assert.equal(late.form.innerHTML,'');assert.equal(late.refreshes,0);
}finally{late.dispose();}
});
+50 -237
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
@@ -10,7 +11,7 @@ import {seoUrl} from './helpers/seo.mjs';
async function moduleUrl(path,transform=code=>code){
const source=await readFile(new URL(path,import.meta.url),'utf8');
const {outputText}=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(transform(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(transform(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const playbackModuleUrl=await moduleUrl('../src/lib/client/playback.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
@@ -19,7 +20,6 @@ const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace
const appDownloadUrl=await moduleUrl('../src/lib/app-download.ts',code=>code.replace(/from ['"]\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
const downloadUrl=await moduleUrl('../src/lib/client/download.ts',code=>code.replace(/from ['"]\.\.\/app-download['"]/,`from '${appDownloadUrl}'`));
const detailUrl=await moduleUrl('../src/lib/client/detail.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`));
const unlockUrl=await moduleUrl('../src/lib/client/unlock.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`).replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
const feedbackUrl=await moduleUrl('../src/lib/client/request-feedback.ts');
const markupUrl=await moduleUrl('../src/lib/client/player-markup.ts',code=>code
.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`)
@@ -39,7 +39,6 @@ const playerUrl=await moduleUrl('../src/lib/client/player.ts',code=>code
.replace(/from ['"]\.\/render['"]/,`from '${renderUrl}'`)
.replace(/from ['"]\.\/download['"]/,`from '${downloadUrl}'`)
.replace(/from ['"]\.\/detail['"]/,`from '${detailUrl}'`)
.replace(/from ['"]\.\/unlock['"]/,`from '${unlockUrl}'`)
.replace(/from ['"]\.\/request-feedback['"]/,`from '${feedbackUrl}'`)
.replace(/from ['"]\.\/playback['"]/,`from '${playbackModuleUrl}'`));
const {renderPlayer}=await import(playerUrl);
@@ -391,83 +390,41 @@ test('play page like follows the drama status and writes playlet/like/set',async
}finally{dispose?.();f.dispose();}
});
test('comment likes update their local count after success without refetching the comment list',async()=>{
const f=playerFixture(),button=new Element('BUTTON'),label=new Element('SPAN');let dispose,resolve;
f.page.nodes.delete('video');button.dataset.commentLike='8';button.dataset.commentCount='9';button.setAttribute('aria-pressed','false');label.textContent='♡ 9';button.nodes.set('[data-request-label]',label);
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise(yes=>resolve=yes);};
try{
dispose=await renderPlayer(f.app,23456);await flush();const reads=f.getCalls.filter(call=>call.path==='comment/list').length;
f.click(button);f.click(button);assert.equal(f.posts.length,1);assert.equal(label.textContent,'请求中');assert.equal(button.disabled,true);
resolve(null);await flush();assert.equal(label.textContent,'♥ 10');assert.equal(button.getAttribute('aria-pressed'),'true');assert.equal(button.disabled,false);
f.click(button);assert.equal(f.posts.at(-1).body.isLike,false);resolve({countLike:9});await flush();assert.equal(label.textContent,'♡ 9');
assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,reads);
}finally{dispose?.();f.dispose();}
});
test('comment submission displays sending feedback, blocks duplicate submits and retains text when the write fails',async()=>{
const f=playerFixture(),input=new Element('TEXTAREA'),button=new Element('BUTTON'),context=new Element(),error=new Element();let dispose,resolve,reject;
f.page.nodes.delete('video');button.textContent='发表评论';input.value='评论内容';
const form=f.page.querySelector('form');form.elements={namedItem:()=>input};form.nodes.set('[type="submit"]',button);f.page.nodes.set('#reply-context',context);f.page.nodes.set('#comment-error',error);
const submit=()=>{const event=new Event('submit',{cancelable:true});Object.defineProperty(event,'target',{value:form});form.dispatchEvent(event);};
f.app.api.post=(path,body)=>{f.posts.push({path,body});return new Promise((yes,no)=>{resolve=yes;reject=no;});};
try{
dispose=await renderPlayer(f.app,23456);await flush();
submit();submit();assert.equal(f.posts.length,1);assert.equal(button.textContent,'发送中');assert.equal(button.disabled,true);
reject(new Error('发送失败'));await flush();assert.equal(input.value,'评论内容');assert.equal(button.textContent,'发表评论');assert.equal(error.textContent,'发送失败');assert.equal(button.disabled,false);assert.deepEqual(f.notifications,[]);
assert.deepEqual(f.posts[0],{path:'comment/send',body:{objectId:23456,objectType:10,text:'评论内容',parentsId:0,replyCommentId:0}});
submit();resolve({});await flush();assert.equal(input.value,'');assert.equal(button.textContent,'发表评论');assert.equal(button.disabled,false);assert.equal(error.textContent,'');assert.deepEqual(f.notifications,['评论已发表']);
}finally{dispose?.();f.dispose();}
});
test('comment pagination uses page links and URL-provided page and sorting',async()=>{
const f=playerFixture({allowed:true,commentData:{list:[],totalCount:95}});let dispose;
const href=(page,sort)=>`/play/23456/90042/${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`;
try{
dispose=await renderPlayer(f.app,23456,90042,false,{page:3,commentSort:1,pageHref:href});await flush();
const request=f.getCalls.find(call=>call.path==='comment/list');
assert.equal(request.query.objectId,23456);assert.equal(request.query.objectType,10);
assert.equal(request.query.pageNum,3);assert.equal(request.query.sortOrder,1);assert.equal(request.query.pageSize,10);
const html=f.page.querySelector('.play-comment-list').innerHTML;
assert.ok(html.includes('href="/play/23456/90042/page/4/?commentSort=1"'));
assert.ok(!html.includes('data-comment-page'));assert.ok(!html.includes('data-page-number'));
assert.ok(f.page.innerHTML.includes('data-comment-sort="1" aria-pressed="true"'));
const sort=new Element('BUTTON');sort.dataset.commentSort='2';f.click(sort);await flush();
assert.equal(f.navigations.at(-1),'/play/23456/90042/');
assert.equal(f.getCalls.filter(call=>call.path==='comment/list').length,1);
}finally{dispose?.();f.dispose();}
});
test('slug playback links survive client episode rendering, next controls, comments and confirmed unlocking with numeric write IDs',async()=>{
const f=playerFixture({allowed:true,detail:{slug:'city-story',tagIds:[9],tagSlugs:['urban'],tagNames:['都市']},commentData:{list:[],totalCount:25}});let dispose;
test('slug playback links survive client episode rendering and next controls; locked tiles still link to their play page',async()=>{
const f=playerFixture({allowed:true,detail:{slug:'city-story',tagIds:[9],tagSlugs:['urban'],tagNames:['都市']}});let dispose;
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
assert.ok(f.page.innerHTML.includes('href="/detail/city-story/"'));
assert.ok(f.page.innerHTML.includes('href="/tag/urban/"'));
assert.ok(f.page.querySelector('.play-episode-grid').innerHTML.includes('href="/play/city-story/90042/"'));
assert.ok(f.page.querySelector('.play-comment-list').innerHTML.includes('href="/play/city-story/90042/page/2/"'));
assert.ok(f.page.innerHTML.includes('href="/play/city-story/90042/"'));
assert.ok(!f.page.innerHTML.includes('/page/2/'));
const next=new Element('BUTTON');next.dataset.playAction='next';f.click(next);await flush();
assert.equal(f.navigations.at(-1),'/play/city-story/91200/');
}finally{dispose?.();f.dispose();}
const paid=playerFixture({detail:{slug:'city-story'},chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId)});let stop;
paid.page.nodes.delete('video');
try{
stop=await renderPlayer(paid.app,23456);paid.click(lockedButton(90042));await flush();
paid.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.deepEqual(paid.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}});
assert.equal(paid.navigations.at(-1),'/play/city-story/90042/');
stop=await renderPlayer(paid.app,23456);await flush();
const html=paid.page.innerHTML;
assert.ok(html.includes('class="film-tile is-locked" href="/play/city-story/90042/"'));
assert.ok(html.includes('aria-label="第 2 集,请在 App 观看"'));
assert.ok(!html.includes('data-locked-chapter'));assert.ok(!html.includes('金币'));
assert.equal(paid.posts.length,0);
}finally{stop?.();paid.dispose();}
});
test('locked chapters offer only the free preview; choosing main opens confirmation and never auto-unlocks or reports preview views',async()=>{
test('locked chapters offer only the free preview; choosing main shows the App gate and never unlocks or reports',async()=>{
const f=playerFixture();let dispose;
const gate=new Element();gate.hidden=true;f.box.nodes.set('.play-app-gate',gate);
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
assert.equal(f.box.dataset.playMode,'preview');assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.video.plays,0);
assert.ok(f.page.innerHTML.includes('本集请在 黄果短剧 App 观看'));assert.ok(f.page.innerHTML.includes('下载 App 继续观看'));
assert.equal(f.box.dataset.playMode,'preview');assert.equal(f.video.src,'https://cdn.example/preview.mp4');assert.equal(f.video.plays,0);assert.equal(gate.hidden,true);
await f.controlActions.toggle();assert.equal(f.video.plays,1);assert.equal(f.posts.length,0);
f.mainButton.dispatchEvent(new Event('click'));await flush();
assert.equal(f.video.src,'');assert.equal(f.box.dataset.playMode,'main');
await f.controlActions.toggle();assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'12 金币');
assert.equal(gate.hidden,false);assert.equal(f.status.textContent,'请在 App 观看');
await f.controlActions.toggle();assert.equal(f.posts.length,0);assert.equal(f.video.plays,1);
}finally{dispose?.();f.dispose();}
});
@@ -486,18 +443,7 @@ test('allowed chapters play main despite stale lock flags; only main playback re
}finally{dispose?.();f.dispose();}
});
test('unlocking requires an explicit confirmation, prevents double submission, then refreshes account and chapter data',async()=>{
let finish;const f=playerFixture({preview:false,unlock:()=>new Promise(resolve=>finish=resolve)});let dispose;
try{
dispose=await renderPlayer(f.app,23456,90042);await f.controlActions.toggle();assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));pay.dispatchEvent(new Event('click'));
f.lock.close();await f.controlActions.toggle();pay.dispatchEvent(new Event('click'));
assert.equal(f.posts.length,1);assert.deepEqual(f.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}});
finish({canWatch:true});await flush();assert.equal(f.lock.open,false);assert.equal(f.refreshes,1);assert.equal(f.routes,1);
}finally{dispose?.();f.dispose();}
});
test('actual player controls delegate play and retry to the media engine and allow unlock clicks to reach the page',async()=>{
test('actual player controls delegate play and retry to the media engine and let page buttons reach the page',async()=>{
const source=await readFile(new URL('../public/scripts/site-runtime.js',import.meta.url),'utf8');
const start=source.indexOf('function installPlayerControls('),end=source.indexOf('/* ---- sticky-search-header-script ---- */',start);
const install=vm.runInNewContext(source.slice(start,end)+'\ninstallPlayerControls;', {document:{},emptyStateMarkup:()=>'<div>重试</div>'});
@@ -514,55 +460,40 @@ test('actual player controls delegate play and retry to the media engine and all
});
test('detail overview keeps original action groups, actual chapter identifiers and styled comment replies',async()=>{
const f=playerFixture({detail:{description:'接口简介',isNew:true,cover:'https://cdn.example/poster.webp'},commentData:{list:[{id:1,userName:'用户 <script>',avatar:'https://cdn.example/a.webp',text:'父评论',children:[{id:2,parentsId:1,replyCommentId:1,replyUserName:'用户 <script>',userAvatar:'https://cdn.example/b.webp',text:'回复'}]}],totalCount:2}});let dispose;
f.app.api.user={recharge:90,avatar:'https://cdn.example/me.webp'};
test('detail overview keeps the cinematic hero, actual chapter identifiers and recommended videos without comments',async()=>{
const f=playerFixture({detail:{description:'接口简介',isNew:true,cover:'https://cdn.example/poster.webp'}});let dispose;
f.page.nodes.delete('video');
const related=[{id:23456,title:'当前剧'},{id:7,title:'推荐 <一>'},{id:8,title:'推荐二'}];
f.app.api.get=(get=>async(path,query)=>path==='playlet/guess-like/list'?(f.getCalls.push({path,query}),{list:related}):get(path,query))(f.app.api.get);
try{
dispose=await renderPlayer(f.app,23456);await flush();
assert.ok(f.page.innerHTML.includes('href="/play/23456/90001/"'));assert.ok(f.page.innerHTML.includes('series-action-bar'));
assert.ok(f.page.innerHTML.includes('接口简介'));assert.ok(f.page.innerHTML.includes('class="play-official"'));
const html=f.page.innerHTML;
assert.ok(html.includes('href="/play/23456/90001/"'));assert.ok(html.includes('class="cine-hero"'));
assert.ok(html.includes('接口简介'));assert.ok(html.includes('class="play-official"'));
assert.ok(!f.getCalls.some(call=>call.path==='playlet/chapter/detail'));
assert.ok(f.page.innerHTML.includes('class="play-compose-avatar"'));
assert.ok(f.page.innerHTML.includes('data-src="https://cdn.example/me.webp"'));
const comments=f.page.querySelector('.play-comment-list').innerHTML;
assert.ok(comments.includes('class="play-comment-body"'));assert.ok(comments.includes('class="play-comment-replies"'));
assert.ok(comments.includes('play-comment-item play-comment-child'));assert.ok(comments.includes('用户 &lt;script&gt;'));
assert.ok(comments.includes('class="play-reply-target">回复 @用户 &lt;script&gt;</span>'));
assert.ok(comments.includes('class="play-comment-avatar"'));
assert.ok(comments.includes('data-src="https://cdn.example/a.webp"'));
assert.ok(comments.includes('data-src="https://cdn.example/b.webp"'));
assert.ok(comments.includes('data-lazy-eager="1"'));
assert.deepEqual(f.getCalls.find(call=>call.path==='playlet/guess-like/list').query,{playletId:23456,pageNum:1,pageSize:12});
assert.ok(html.includes('<h2 id="play-recommend-title">推荐视频</h2>'));assert.ok(html.includes('看了《测试剧集》的人还在看'));
assert.ok(html.includes('推荐 &lt;一&gt;'));assert.ok(!html.includes('href="/detail/23456/"'));
for(const text of ['评论','play-comment','反馈','客服'])assert.ok(!html.includes(text),text);
assert.ok(html.indexOf('play-recommend')<html.indexOf('play-official'));
const like=new Element('BUTTON');like.dataset.engage='like';like.nodes.set('span',new Element());
f.click(like);await flush();assert.deepEqual(f.posts.at(-1),{path:'playlet/like/set',body:{id:23456,isLike:true}});
assert.equal(like.querySelector('span').textContent,'已点赞');
}finally{dispose?.();f.dispose();}
});
test('comment composer avatar follows the signed-in account avatar and falls back like the header',()=>{
const withUser=playerMarkup({id:1,title:'剧'},[],{user:{userId:7,avatar:'https://cdn.example/account.webp'}});
assert.ok(withUser.includes('class="play-compose-avatar"'));
assert.ok(withUser.includes('data-src="https://cdn.example/account.webp"'));
assert.ok(withUser.includes('src="/icon.svg"'));
const guest=playerMarkup({id:1,title:'剧'},[],{user:null});
assert.ok(guest.includes('class="play-compose-avatar"'));
assert.ok(guest.includes('src="/icon.svg"'));
assert.ok(!guest.includes('data-src='));
});
test('episode segments keep their anchor across desktop/mobile layout changes and show original lock and playing markers',async()=>{
test('the film strip lists every episode with current and locked tiles',async()=>{
const chapterList=Array.from({length:130},(_,index)=>({id:90001+index,currentEpisode:index+1,canWatch:index<10,payType:index<10?0:1}));
const f=playerFixture({allowed:true,chapterList});let dispose;
try{
dispose=await renderPlayer(f.app,23456,90042);await flush();
const grid=f.page.querySelector('.play-episode-grid'),segments=f.page.querySelector('.play-episode-pages');
assert.ok(segments.innerHTML.includes('class="episode-page-track"'));assert.ok(grid.innerHTML.includes('class="episode-playing"'));
assert.ok(grid.innerHTML.includes('class="coin-soft-star"'));
const tab=new Element('BUTTON');tab.dataset.episodePage='2';f.click(tab);await flush();assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));
globalThis.matchMedia=()=>({matches:true});window.dispatchEvent(new Event('resize'));
assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));assert.ok(!grid.innerHTML.includes('data-locked-chapter="90106"'));
globalThis.matchMedia=()=>({matches:false});window.dispatchEvent(new Event('resize'));
assert.ok(grid.innerHTML.includes('data-locked-chapter="90091"'));assert.ok(grid.innerHTML.includes('data-locked-chapter="90130"'));
const html=f.page.innerHTML;
assert.ok(html.includes('class="film-strip"'));
assert.equal((html.match(/class="film-tile[" ]/g)||[]).length,130);
assert.ok(html.includes('class="film-tile is-current is-locked" href="/play/23456/90042/"'));assert.ok(html.includes('aria-current="true"'));
assert.ok(html.includes('class="film-tile is-locked" href="/play/23456/90130/"'));
assert.ok(!html.includes('class="film-tile is-locked" href="/play/23456/90010/"'));
assert.ok(html.includes('>03</span>'));assert.ok(html.includes('>42</span>'));
}finally{dispose?.();f.dispose();}
});
@@ -577,145 +508,27 @@ const lockedList=[{id:90001,currentEpisode:1,canWatch:true},{id:90042,currentEpi
const lockedChapter=(id=90042,extra={})=>({id,playletId:23456,currentEpisode:id===90042?2:3,needUnlock:true,payType:1,costCoins:12,singleCoins:30,balance:90,...extra});
const lockedButton=id=>{const button=new Element('BUTTON');button.dataset.lockedChapter=String(id);return button;};
test('clicking a coin episode on detail opens the original dialog, fetches live pricing, and waits for confirmation before navigating',async()=>{
const f=playerFixture({chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId),unlock:async()=>({balance:78})});let dispose;
f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);await flush();
const grid=f.page.querySelector('.play-episode-grid').innerHTML;
assert.ok(grid.includes('class="play-episode-locked" data-locked-chapter="90042"'));assert.ok(!grid.includes('href="/play/23456/90042/"'));
f.click(lockedButton(90042));assert.equal(f.lock.open,true);assert.equal(f.lock.querySelector('.unlock-pay').disabled,true);
f.click(lockedButton(90042));await flush();
assert.equal(f.getCalls.filter(call=>call.path==='playlet/chapter/detail').length,1);
assert.deepEqual(f.getCalls.find(call=>call.path==='playlet/chapter/detail').query,{playletId:23456,chapterId:90042});
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'12 金币');assert.equal(f.lock.querySelector('#episode-lock-balance').textContent,'90 金币');
assert.match(f.lock.querySelector('#episode-lock-description').textContent,/第 2 集/);assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.deepEqual(f.posts,[{path:'playlet/chapter/unlock',body:{chapterId:90042,playletId:23456}}]);
assert.equal(f.app.api.user.recharge,78);assert.equal(f.refreshes,1);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/play/23456/90042/']);
}finally{dispose?.();f.dispose();}
});
test('a late chapter response cannot replace the newer episode selected for payment',async()=>{
const pending=new Map();
const f=playerFixture({chapterList:lockedList,loadChapter:query=>new Promise(resolve=>pending.set(query.chapterId,resolve))});let dispose;
f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));f.click(lockedButton(91200));
pending.get(91200)(lockedChapter(91200,{costCoins:9}));await flush();
pending.get(90042)(lockedChapter(90042,{costCoins:99}));await flush();
assert.equal(f.lock.querySelector('.unlock-cost').textContent,'9 金币');assert.match(f.lock.querySelector('#episode-lock-description').textContent,/第 3 集/);
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.deepEqual(f.posts[0],{path:'playlet/chapter/unlock',body:{chapterId:91200,playletId:23456}});assert.deepEqual(f.navigations,['/play/23456/91200/']);
}finally{dispose?.();f.dispose();}
});
test('closing a pending dialog or leaving the route prevents stale requests from reopening or submitting it',async()=>{
for(const leave of [false,true]){
let finish;const f=playerFixture({chapterList:lockedList,loadChapter:()=>new Promise(resolve=>finish=resolve)});let dispose;
f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));
if(leave)f.dispose();else f.lock.close();
finish(lockedChapter());await flush();
assert.equal(f.lock.open,false);f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
}finally{dispose?.();f.dispose();}
}
});
test('already permitted and VIP chapters follow fresh access results instead of charging coins',async()=>{
for(const [data,path] of [[lockedChapter(90042,{canWatch:true}),'/play/23456/90042/'],[lockedChapter(90042,{needUnlock:false,needVip:true,payType:2}),'/info/deposit/vip/']]){
const f=playerFixture({chapterList:lockedList,loadChapter:()=>data});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
assert.equal(f.lock.open,false);assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[path]);
}finally{dispose?.();f.dispose();}
}
});
test('a zero chapter balance shows the account coin balance',async()=>{
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(90042,{balance:0})});let dispose;f.page.nodes.delete('video');
f.app.api.user.recharge=1000;
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
assert.equal(f.lock.querySelector('#episode-lock-balance').textContent,'1000 金币');
assert.equal(f.lock.querySelector('.unlock-pay').textContent,'支付 12 金币并播放');
assert.equal(f.refreshes,1);
}finally{dispose?.();f.dispose();}
});
test('insufficient displayed balance offers recharge and never sends an unlock request',async()=>{
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(90042,{balance:3})});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
assert.equal(f.lock.querySelector('.unlock-pay').textContent,'金币不足,去充值');
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();
assert.equal(f.posts.length,0);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/info/deposit/']);
}finally{dispose?.();f.dispose();}
});
test('mismatched chapter information leaves payment disabled without navigating or charging',async()=>{
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(91200)});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
assert.equal(f.lock.querySelector('.unlock-pay').disabled,true);assert.match(f.lock.querySelector('#episode-lock-error').textContent,/不匹配/);
f.lock.querySelector('.unlock-pay').dispatchEvent(new Event('click'));await flush();assert.equal(f.posts.length,0);assert.deepEqual(f.navigations,[]);
}finally{dispose?.();f.dispose();}
});
test('payment errors remain visible and retryable; balance and auth errors follow reference recovery',async()=>{
const {ApiError}=await import(protocolUrl);
for(const [error,path] of [[new Error('网络暂不可用'),null],[new ApiError('金币余额不足',400),'/info/deposit/'],[new ApiError('登录已过期',1000),'login']]){
let attempts=0,authPrompts=0;
const f=playerFixture({chapterList:lockedList,loadChapter:()=>lockedChapter(),unlock:async()=>{attempts++;if(attempts===1)throw error;return {balance:78};}});let dispose;f.page.nodes.delete('video');f.app.openAuth=()=>authPrompts++;
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));await flush();
assert.equal(f.refreshes,0);
if(path==='login'){assert.equal(authPrompts,1);assert.equal(f.lock.open,false);}
else if(path){assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,[path]);}
else{
assert.equal(f.lock.open,true);assert.equal(f.lock.querySelector('#episode-lock-error').textContent,error.message);assert.equal(f.lock.querySelector('#episode-lock-error').hidden,false);assert.equal(pay.disabled,false);
pay.dispatchEvent(new Event('click'));await flush();assert.equal(f.posts.length,2);assert.equal(f.lock.open,false);assert.deepEqual(f.navigations,['/play/23456/90042/']);
}
}finally{dispose?.();f.dispose();}
}
});
test('a pending payment cannot be repeated or retargeted to a different episode',async()=>{
let finish;const f=playerFixture({chapterList:lockedList,loadChapter:query=>lockedChapter(query.chapterId),unlock:()=>new Promise(resolve=>finish=resolve)});let dispose;f.page.nodes.delete('video');
try{
dispose=await renderPlayer(f.app,23456);f.click(lockedButton(90042));await flush();
const pay=f.lock.querySelector('.unlock-pay');pay.dispatchEvent(new Event('click'));pay.dispatchEvent(new Event('click'));f.click(lockedButton(91200));await flush();
const cancel=new Event('cancel',{cancelable:true});f.lock.dispatchEvent(cancel);
assert.equal(cancel.defaultPrevented,true);assert.equal(f.lock.querySelector('.unlock-close').disabled,true);
assert.equal(f.posts.length,1);assert.equal(f.getCalls.filter(call=>call.path==='playlet/chapter/detail').length,1);
finish({balance:78});await flush();assert.deepEqual(f.navigations,['/play/23456/90042/']);assert.equal(f.lock.open,false);
}finally{dispose?.();f.dispose();}
});
test('server player data excludes credentials and denied sources while retaining free episodes, comments and artwork',()=>{
test('server player data excludes credentials and denied sources while retaining free episodes, recommendations and artwork',()=>{
const locked={id:90042,currentEpisode:2,countLike:27,countCollect:8,countComment:3,ratio:0.5625,seoInfo:{title:'章节独立标题'},canWatch:false,needUnlock:true,payType:1,videoUrl:'https://cdn.example/secret-locked.mp4',token:'chapter-secret'};
const free={id:90001,currentEpisode:1,canWatch:true,videoUrl:'https://cdn.example/free.mp4'};
const initial=playerInitialData({id:23456,title:'公开剧名',cover:'https://cdn.example/cover.webp',token:'detail-secret',reportSdkConfig:{secret:'sdk-secret'},advitise:{nineGridList:[{id:'a',images:'https://cdn.example/ad.webp',title:'广告',link:'https://example.com',secret:'ad-secret'}]},relatedRecommendData:[{id:7,title:'相关剧集',token:'related-secret'}]},[free,locked],locked,{list:[{id:1,text:'评论正文',token:'comment-secret',children:[{id:2,text:'回复',replyUserName:'甲',replyCommentId:1,parentsId:1,token:'child-secret'}]}],totalCount:12});
const related=[{id:23456,title:'自身'},...Array.from({length:14},(_,index)=>({id:100+index,title:'相关剧集'+index,token:'related-secret'}))];
const initial=playerInitialData({id:23456,title:'公开剧名',cover:'https://cdn.example/cover.webp',token:'detail-secret',reportSdkConfig:{secret:'sdk-secret'},advitise:{nineGridList:[{id:'a',images:'https://cdn.example/ad.webp',title:'广告',link:'https://example.com',secret:'ad-secret'}]},relatedRecommendData:related},[free,locked],locked);
const json=JSON.stringify(initial);
for(const value of ['detail-secret','chapter-secret','sdk-secret','ad-secret','related-secret','comment-secret','child-secret','secret-locked.mp4'])assert.ok(!json.includes(value),value);
for(const value of ['detail-secret','chapter-secret','sdk-secret','ad-secret','related-secret','secret-locked.mp4'])assert.ok(!json.includes(value),value);
assert.equal(initial.chapters[0].videoUrl,free.videoUrl);assert.equal(initial.chapter.canWatch,false);assert.equal(initial.chapter.needUnlock,true);
assert.equal(initial.chapter.countLike,27);assert.equal(initial.chapter.ratio,0.5625);assert.equal(initial.chapter.seoInfo.title,'章节独立标题');
assert.equal(initial.comments.list[0].text,'评论正文');assert.equal(initial.comments.list[0].children[0].text,'回复');assert.equal(initial.comments.list[0].children[0].replyUserName,'甲');assert.equal(initial.detail.advitise.nineGridList[0].images,'https://cdn.example/ad.webp');
assert.equal(initial.comments,undefined);assert.equal(initial.detail.advitise.nineGridList[0].images,'https://cdn.example/ad.webp');
assert.equal(initial.detail.relatedRecommendData.length,12);assert.ok(!initial.detail.relatedRecommendData.some(item=>item.id===23456));
});
test('player attaches to server markup without repeating initial reads and still verifies coin pricing on click',async()=>{
test('player attaches to server markup without repeating initial reads',async()=>{
const f=playerFixture();let dispose;
const locked={...f.chapter,videoUrl:undefined};
const initial={detail:{id:23456,title:'首屏详情'},chapters:[{id:90001,currentEpisode:1,canWatch:true},locked],chapter:null,comments:{list:[{id:1,text:'首屏评论'}],totalCount:1}};
const initial={detail:{id:23456,title:'首屏详情'},chapters:[{id:90001,currentEpisode:1,canWatch:true},locked],chapter:null};
try{
f.page.innerHTML='<article>首屏详情</article>';f.page.querySelector('.play-comment-list').innerHTML='<p>首屏评论</p>';
f.page.innerHTML='<article>首屏详情</article>';
dispose=await renderPlayer(f.app,23456,undefined,false,{initial,preserveMarkup:true});await flush();
assert.equal(f.getCalls.length,0);assert.equal(f.page.innerHTML,'<article>首屏详情</article>');assert.equal(f.page.querySelector('.play-comment-list').innerHTML,'<p>首屏评论</p>');
f.click(lockedButton(90042));await flush();
assert.equal(f.getCalls.length,1);assert.equal(f.getCalls[0].path,'playlet/chapter/detail');assert.equal(f.lock.open,true);assert.equal(f.posts.length,0);
assert.equal(f.getCalls.length,0);assert.equal(f.page.innerHTML,'<article>首屏详情</article>');assert.equal(f.posts.length,0);
}finally{dispose?.();f.dispose();}
});
+5 -2
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
@@ -5,7 +6,7 @@ import ts from 'typescript';
async function moduleURL(path,replace=value=>value){
const code=ts.transpileModule(await readFile(new URL(path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}}).outputText;
return 'data:text/javascript;base64,'+Buffer.from(replace(code)).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(code))).toString('base64');
}
const {PublicDataCache,publicCachePolicy}=await import(await moduleURL('../src/lib/api/public-cache.ts'));
const protocol=await moduleURL('../src/lib/api/protocol.ts');
@@ -48,7 +49,9 @@ test('cache bounds and endpoint policies keep private data and indefinite growth
test('shared browser snapshot contains visible configuration and footer fields without SDK or unknown secrets',()=>{
const data=publicSiteData({reportSdkConfig:'private-sdk-cipher',token:'private-token',config:{officialAppAddr:'https://app.test/download',telegram:'https://t.me/business',apiKey:'private-api-key'},advitise:{dialogList:[{id:'a',title:'广告',images:'https://ad.test/image',link:'https://ad.test',token:'private-ad-token'}],unknown:[{secret:'private-slot'}]}},{footerInfo:{brand:{title:'黄果短剧',content:'真实页脚',token:'private-brand-token'},contactList:[{name:'服务时间',value:'每天',token:'private-contact-token'}],navList:[{id:1,name:'规则',urlValue:'/ignored/',token:'private-nav-token',translationList:[{id:2,name:'关于我们',urlValue:'/richetext/关于我们',secret:'private-nav-child'}]}]}});
const text=JSON.stringify(data);for(const secret of ['private-sdk-cipher','private-token','private-api-key','private-ad-token','private-slot','private-contact-token','private-nav-token','private-nav-child','private-brand-token'])assert.ok(!text.includes(secret));
assert.equal(data.trackerConfigured,true);assert.equal(data.config.config.officialAppAddr,'https://app.test/download');assert.equal(data.footer.footerInfo.brand.title,'黄果短剧');assert.equal(data.footer.footerInfo.brand.content,'真实页脚');
assert.equal('trackerConfigured' in data,false);assert.ok(!text.includes('t.me/business'));assert.deepEqual(data.footer.footerInfo.contactList,[]);assert.equal(data.config.config.officialAppAddr,'https://app.test/download');assert.equal(data.footer.footerInfo.brand.title,'黄果短剧');assert.equal(data.footer.footerInfo.brand.content,'真实页脚');
assert.equal(data.footer.footerInfo.navList[0].translationList[0].name,'关于我们');assert.equal(data.footer.footerInfo.navList[0].translationList[0].urlValue,'/richetext/关于我们');
assert.doesNotThrow(()=>publicSiteData({config:null,advitise:null},{footerInfo:null}));
const filtered=publicSiteData({},{footerInfo:{navList:[{name:'帮助',translationList:[{name:'关于我们',urlValue:'/richtext/关于我们'},{name:'意见反馈',urlValue:'/info/feedback'},{name:'在线客服',urlValue:'https://help.test'},{name:'邮箱',urlValue:'mailto:a@b.test'},{name:'充值',urlValue:'/info/deposit/'}]}]}});
assert.deepEqual(filtered.footer.footerInfo.navList[0].translationList.map(item=>item.name),['关于我们']);
});
+27 -55
View File
@@ -1,4 +1,5 @@
import {routingModuleUrl} from './helpers/routing.mjs';
import {linkShared,sharedModule} from './helpers/shared-modules.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
@@ -18,7 +19,8 @@ const renderUrl='data:text/javascript;base64,'+Buffer.from(renderCode.replace("'
const searchCode=ts.transpileModule(await readFile(new URL('../src/lib/client/search.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
const search=await import('data:text/javascript;base64,'+Buffer.from(searchCode.replace("'../routing'",JSON.stringify(routingModuleUrl)).replace("'../api/protocol'",JSON.stringify(protocolUrl)).replace("'./render'",JSON.stringify(renderUrl))).toString('base64'));
const infoCode=ts.transpileModule(await readFile(new URL('../src/lib/info-pages.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
const infoPages=await import('data:text/javascript;base64,'+Buffer.from(infoCode).toString('base64'));
const infoPages=await import('data:text/javascript;base64,'+Buffer.from(linkShared(infoCode)).toString('base64'));
const brandModule=await sharedModule('brand');
const siteNavigationCode=ts.transpileModule(await readFile(new URL('../src/lib/site-navigation.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
const applicationFile=new URL('../src/lib/client/application.ts',import.meta.url);
const applicationCode=ts.transpileModule(await readFile(applicationFile,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2022}}).outputText;
@@ -38,6 +40,7 @@ function fixture(load=async()=>({list:[{id:1}],totalCount:72,name:'接口通用
const exports={},channelExports={},siteNavigationExports={};
const require=specifier=>{
if(specifier==='../site-navigation')return siteNavigationExports;
if(specifier==='../brand')return brandModule;
if(specifier==='./routing')return routing;
if(specifier==='./api/protocol')return protocol;
if(specifier==='./channel')return channelExports;
@@ -51,8 +54,6 @@ function fixture(load=async()=>({list:[{id:1}],totalCount:72,name:'接口通用
if(specifier==='./config')return {configureSite:async()=>{}};
if(specifier==='./hero-swipe')return {bindHeroSwipe(){}};
if(specifier==='./promo-marquee')return {bindPromotionMarquee:root=>{calls.push({path:'bindPromotionMarquee',root});return ()=>calls.push({path:'disposePromotionMarquee',root});}};
if(specifier==='./account')return {renderMember:async(...args)=>calls.push({path:'renderMember',args}),renderWallet:async(...args)=>calls.push({path:'renderWallet',args})};
if(specifier==='./feedback')return {renderFeedbackPage:(...args)=>calls.push({path:'renderFeedbackPage',args})};
if(specifier==='./player')return {renderPlayer:async(_app,id,chapterId,isVideo,options)=>{calls.push({path:'renderPlayer',id,chapterId,isVideo,...options});return ()=>{};}};
if(specifier==='./render')return {escape:String,empty:message=>`<p>${message}</p>`,loading:()=>'<p>加载中</p>',activeBanners:()=>[],topicCard:()=>'<a href="/topics/1/">专题</a>',renderHome:()=>'<div data-home-loaded>接口首页</div>',card:(item,video,options)=>{cardCalls.push({item,video,options});return '<a href="/detail/1/">剧集</a>';},pagination:(_,page)=>`<span>第 ${page} 页</span>`};
return {};
@@ -64,7 +65,7 @@ function fixture(load=async()=>({list:[{id:1}],totalCount:72,name:'接口通用
}
test('all native pages bind every square ad row and clean up old loops before rebinding',async()=>{
for(const pathname of ['/','/shorts/','/detail/7/','/play/7/8/','/member/favorites/','/info/deposit/']){
for(const pathname of ['/','/shorts/','/detail/7/','/play/7/8/','/info/about-us/']){
const rows=[{name:'first row'},{name:'second row'}],f=fixture(undefined,{promotionRows:rows});
f.location.pathname=pathname;await f.app.route();
assert.deepEqual(f.calls.filter(call=>call.path==='bindPromotionMarquee').map(call=>call.root),rows,pathname);
@@ -74,38 +75,29 @@ test('all native pages bind every square ad row and clean up old loops before re
}
});
test('about uses business Telegram and the feedback group while retaining configured service hours',()=>{
test('about shows only the single business Telegram contact, ignoring backend mail, feedback and service channels',()=>{
const page=infoPages.getInfoPage('about-us',{config:{telegram:'https://t.me/business_team',tgGroup:'https://t.me/feedback_group',gmailAddr:'ignored@example.com',customerServiceHost:'https://ignored.example.com'}},[
{name:'商务合作',value:'business@example.com'},{name:'用户反馈',value:'mailto:support@example.com'},
{name:'服务时间',value:'周一至周五 09:00–18:00'},{name:'邮箱',value:'ignored@example.com'},
]);
assert.ok(page.html.includes('商务合作:<a href="https://t.me/business_team"'));
assert.ok(page.html.includes('用户反馈:<a href="https://t.me/feedback_group"'));
assert.ok(page.html.includes('<p>服务时间:周一至周五 09:00–18:00</p>'));
assert.ok(!page.html.includes('mailto:'));assert.ok(!page.html.includes('@example.com'));assert.ok(!page.html.includes('ignored.example.com'));
assert.ok(page.html.includes('商务合作:<a href="https://t.me/jbd125"'));assert.ok(page.html.includes('Telegram @jbd125'));
for(const text of ['用户反馈','feedback_group','business_team','mailto:','@example.com','ignored.example.com','客服'])assert.ok(!page.html.includes(text),text);
});
test('Telegram contacts prefer site configuration, fall back to address configuration and hide absent channels',()=>{
const html=infoPages.infoContactSection({config:{telegram:'',tgGroup:''},newAddressInfo:{telegram:'@business_team',tgGroup:'https://t.me/feedback_group'}},null);
assert.ok(html.includes('商务合作:<a href="https://t.me/business_team"'));assert.ok(html.includes('用户反馈:<a href="https://t.me/feedback_group"'));
assert.ok(!html.includes('服务时间'));
const preferred=infoPages.infoContactSection({config:{telegram:'https://t.me/primary'},newAddressInfo:{telegram:'https://t.me/fallback'}});
assert.ok(preferred.includes('t.me/primary'));assert.ok(!preferred.includes('t.me/fallback'));assert.ok(!preferred.includes('用户反馈'));
assert.ok(infoPages.infoContactSection({config:{gmailAddr:'help@example.com',customerServiceHost:'https://ignored.example.com'}}).includes(' hidden'));
assert.ok(infoPages.infoContactSection({config:{telegramAdd:'https://t.me/legacy'}}).includes('t.me/legacy'));
test('the contact section is identical whatever the backend configures',()=>{
const expected=infoPages.infoContactSection();
for(const config of [{config:{telegram:'',tgGroup:''},newAddressInfo:{telegram:'@business_team',tgGroup:'https://t.me/feedback_group'}},{config:{telegram:'https://t.me/primary'}},{config:{gmailAddr:'help@example.com'}},{config:{telegramAdd:'https://t.me/legacy'}}])
assert.equal(infoPages.infoContactSection(config),expected);
assert.ok(expected.includes('href="https://t.me/jbd125"'));assert.ok(!expected.includes(' hidden'));
});
test('configured contacts escape text and reject executable or malformed addresses',()=>{
test('configured contact rows never reach the page, so executable addresses cannot be injected',()=>{
const html=infoPages.infoContactSection({},[
{name:'<img onerror=alert(1)>',value:'<script>alert(1)</script>'},
{name:'注入',value:'javascript:alert(1)'},
{name:'数据链接',value:'data:text/html,<script>alert(1)</script>'},
{name:'正常链接',value:'https://example.com/?a=1&b=2'},null,
{name:'数据链接',value:'data:text/html,<script>alert(1)</script>'},null,
]);
assert.ok(html.includes('&lt;script&gt;'));assert.ok(html.includes('&lt;img onerror=alert(1)&gt;'));
assert.ok(!html.includes('<script'));assert.ok(!html.includes('javascript:'));assert.ok(!html.includes('data:text'));
assert.ok(html.includes('href="https://example.com/?a=1&amp;b=2" target="_blank" rel="sponsored nofollow noopener noreferrer"'));
assert.ok(infoPages.infoContactSection({config:{gmailAddr:'javascript:bad@example.com'}}).includes(' hidden'));
for(const text of ['<script','<img','javascript:','data:text'])assert.ok(!html.includes(text),text);
});
test('ranking data and native page links derive from the server route and preserve global positions',()=>{
@@ -132,32 +124,12 @@ test('initial server channel and search content is retained without page request
}
});
test('server player data binds once with its comment route and never refetches the initial content',async()=>{
const player={detail:{id:23456,title:'剧集'},chapters:[],chapter:null,comments:{list:[],totalCount:0}};
const path='/play/23456/90001/page/2/',f=fixture(()=>assert.fail('no page fetch'),{page:{path:path+'?commentSort=1',panel:'player',title:'剧集',player}});
f.location.pathname=path;f.location.search='?commentSort=1';await f.app.route();
assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,'renderPlayer');assert.equal(f.calls[0].initial,player);assert.equal(f.calls[0].preserveMarkup,true);assert.equal(f.calls[0].page,2);assert.equal(f.calls[0].commentSort,1);
assert.equal(f.calls[0].pageHref(3,1),'/play/23456/90001/page/3/?commentSort=1');await f.app.route();assert.equal(f.calls.length,1);assert.equal(f.reloads.length,1);
});
test('private and feedback pages only bind interactions to their prepared snapshots',async()=>{
for(const [path,info,handler] of [['/member/settings/',{key:'settings',member:{user:{userId:7}}},'renderMember'],['/info/deposit/',{key:'deposit',wallet:{user:{userId:7},packages:{coinList:[]},transactions:{list:[]}}},'renderWallet'],['/info/feedback/',{key:'feedback',feedback:true},'renderFeedbackPage']]){
const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'info',title:'完整页面',info}});f.location.pathname=path;await f.app.route();
assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,handler);assert.equal(f.calls[0].args.at(-1),true);
}
});
test('cold unavailable member and wallet pages client-render after guest bootstrap',async()=>{
for(const [path,handler,tab] of [['/member/center/','renderMember','center'],['/member/history/','renderMember','history'],['/info/deposit/','renderWallet',undefined],['/info/deposit/vip/','renderWallet',undefined],['/member/settings/','renderMember','settings']]){
const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'info',title:'完整页面',info:{key:path.includes('deposit')?'deposit':tab},session:{user:null,state:'unavailable'}}});
f.location.pathname=path;await f.app.route();
assert.equal(f.calls.length,1,path);assert.equal(f.calls[0].path,handler,path);
if(handler==='renderMember')assert.equal(f.calls[0].args[1],tab);
if(path==='/info/deposit/vip/')assert.equal(f.calls[0].args[1],true);
if(path==='/info/deposit/')assert.equal(f.calls[0].args[1],false);
}
const signedOut=fixture(()=>assert.fail('login-required must keep SSR'),{page:{path:'/member/center/',panel:'info',title:'完整页面',info:{key:'center'},session:{user:null,state:'login-required'}}});
signedOut.location.pathname='/member/center/';await signedOut.app.route();assert.equal(signedOut.calls.length,0);
test('server player data binds once and never refetches the initial content',async()=>{
const player={detail:{id:23456,title:'剧集'},chapters:[],chapter:null};
const path='/play/23456/90001/',f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'player',title:'剧集',player}});
f.location.pathname=path;await f.app.route();
assert.equal(f.calls.length,1);assert.equal(f.calls[0].path,'renderPlayer');assert.equal(f.calls[0].initial,player);assert.equal(f.calls[0].preserveMarkup,true);
await f.app.route();assert.equal(f.calls.length,1);assert.equal(f.reloads.length,1);
});
test('missing or mismatched snapshots leave server error HTML visible without a client retry',async()=>{
@@ -169,18 +141,18 @@ test('client configuration preserves the server content metadata and canonical',
const path='/topics/7/',environment=seo.resolveSeoEnvironment({config:{foreverUrlAddr:'https://canonical.drama.test'}}),content={title:'服务端专题',description:'服务端简介',totalCount:72,pageSize:24};
const f=fixture(()=>assert.fail('no page fetch'),{page:{path,panel:'channel',title:'服务端专题'},seo:{environment,content,path}});f.location.pathname=path;await f.app.route();
await f.app.applyConfig({config:{foreverUrlAddr:'https://other.drama.test'}});
assert.equal(f.document.title,'服务端专题 - 禁果短剧');assert.equal(f.document.value('link[rel="canonical"]'),'https://canonical.drama.test/topics/7/');
assert.equal(f.document.title,'服务端专题 - 黄果短剧');assert.equal(f.document.value('link[rel="canonical"]'),'https://canonical.drama.test/topics/7/');
});
test('shared server configuration initializes downloads and contacts without refetching page configuration or footer',async()=>{
const shared={config:{config:{officialAppAddr:'https://app.test/download'}},footer:{footerInfo:{contactList:[{name:'服务时间',value:'每天'}]}},trackerConfigured:false};
const shared={config:{config:{officialAppAddr:'https://app.test/download'}},footer:{footerInfo:{contactList:[{name:'服务时间',value:'每天'}]}}};
const f=fixture(()=>assert.fail('public settings already arrived in HTML'),{shared});
await f.app.initializeShared();assert.equal(f.app.config.config.officialAppAddr,'https://app.test/download');assert.equal(f.calls.length,0);
});
test('public back/forward documents keep working without reload; interactive private pages revalidate as fresh documents',async()=>{
test('public back/forward documents keep working without reload',async()=>{
const f=fixture();f.location.pathname='/shorts/';f.app.hidePage(true);assert.equal(f.app.signal.aborted,false);
f.app.restorePage();assert.equal(f.reloads.length,0);assert.deepEqual(f.calls.map(item=>item.path),['restore-session']);
const privatePage=fixture();privatePage.location.pathname='/info/deposit/';privatePage.app.hidePage(true);assert.equal(privatePage.app.signal.aborted,true);
privatePage.app.restorePage();assert.equal(privatePage.reloads.length,1);
const removed=fixture();removed.location.pathname='/info/about-us/';removed.app.hidePage(true);assert.equal(removed.app.signal.aborted,false);
removed.app.restorePage();assert.equal(removed.reloads.length,0);
});
+27 -26
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
@@ -7,16 +8,16 @@ import ts from 'typescript';
async function moduleUrl(path,transform=code=>code){
const source=await readFile(new URL(path,import.meta.url),'utf8');
const {outputText}=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(transform(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(transform(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+new URL(path,import.meta.url).pathname).toString('base64');
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace(/from ['"]\.\.\/api\/protocol['"]/,`from '${protocolUrl}'`));
const {ads,promotionRow,renderHome,activeBanners,hero,homeRecommendations,heroFeed,card,coverCornerBadge,topicCard,imageAttributes,blockSection}=await import(renderUrl);
const historyUrl=await moduleUrl('../src/lib/client/header-history.ts',code=>code.replace("'../api/protocol'",JSON.stringify(protocolUrl)).replace("'./render'",JSON.stringify(renderUrl)));
const {headerHistoryMarkup}=await import(historyUrl);
const drama={id:23456,title:'正常剧集 <script>alert(1)</script>',cover:'https://example.com/poster.webp',tagNames:['都市']};
const advert={id:'ad-one',title:'有效广告',images:'https://example.com/ad.webp',link:'https://example.com/landing'};
const itemCount=html=>(html.match(/class="(?:hg-card|curated-card drama-data-card|ranked-card drama-data-card|topic-recommendation-card)"/g)||[]).length;
test('Swagger homepage modules render actual titles, drama cards and theme destinations',()=>{
const html=renderHome({modules:[{id:31,title:'新接口短剧栏目',slug:'editors',moduleType:1,list:[drama]},{id:32,title:'新接口题材',moduleType:2,topicList:[{id:7,slug:'urban',name:'都市题材',list:[drama]}]}]});
for(const text of ['新接口短剧栏目','新接口题材','href="/config/editors/"','href="/theme/urban/"','href="/detail/23456/"'])assert.ok(html.includes(text),text);
@@ -25,9 +26,17 @@ test('Swagger homepage modules render actual titles, drama cards and theme desti
assert.ok(!emptyModules.includes('旧缓存不应复活'));
});
test('topic cards include escaped metadata for content exposure and click tracking',()=>{
test('topic cards escape metadata and carry no analytics attributes',()=>{
const html=topicCard({id:7,name:'都市 <甜宠>',icon:'https://example.com/topic.png'});
assert.match(html,/data-track-item data-item-id="7" data-item-type="topic" data-item-name="都市 &lt;甜宠&gt;"/);
assert.ok(html.includes('都市 &lt;甜宠&gt;'));assert.ok(!html.includes('data-track'));assert.ok(!html.includes('data-item-'));
});
test('drama cards use the 9:16 gallery layout with episode counter, square badge and views/theme meta',()=>{
const series=card({id:5,title:'连载剧',totalEpisode:12,updateStatus:1,isFeatured:true,heatValue:12345,themeNames:['都市'],cover:'https://example.com/c.webp'});
for(const text of ['class="hg-card"','class="hg-card-cover"','>1/12+<','class="hg-card-badge"','cover-corner-badge--hot','class="hg-card-rule"','class="hg-card-views"','1.2万','class="hg-card-theme">都市<'])assert.ok(series.includes(text),text);
assert.ok(!series.includes('drama-description'));assert.ok(!series.includes('data-track'));
assert.ok(card({id:6,title:'完结',totalEpisode:30,updateStatus:2}).includes('>1/30<'));
assert.ok(card({id:7,title:'单集',totalEpisode:1}).includes('>单集<'));
});
test('API images use data sources and local placeholders until decrypted, including video posters',()=>{
@@ -53,8 +62,8 @@ test('shared square ad rows keep real ad data and links and omit empty placement
const html=promotionRow([advert,{...advert,id:'ad-two',title:'第二个广告'}]);
assert.ok(html.includes('class="promo-marquee" data-promotion-row'));
assert.equal((html.match(/class="promo-tile"/g)||[]).length,2);
assert.ok(html.includes('data-ad-id="ad-one"'));assert.ok(html.includes('data-ad-id="ad-two"'));
assert.ok(html.includes('href="https://example.com/landing"'));assert.ok(html.includes('data-ad-type="icon"'));
assert.ok(html.includes('alt="第二个广告"'));assert.ok(!html.includes('data-ad-'));
assert.ok(html.includes('href="https://example.com/landing"'));
assert.ok(html.includes('width="132" height="132"'));assert.ok(html.includes('alt="有效广告"'));
for(const value of [null,{},[],[null]])assert.equal(promotionRow(value),'');
});
@@ -64,7 +73,7 @@ test('ads ignores malformed entries and keeps valid ads in order with escaped te
assert.equal((html.match(/class="promo-app"/g)||[]).length,2);
assert.ok(html.indexOf('有效广告')<html.indexOf('第二条'));
assert.ok(html.includes('href="https://example.com/landing"'));
assert.ok(html.includes('href="/apps/"'));assert.ok(html.includes('第二条 &lt;script&gt;'));
assert.ok(!html.includes('/apps/'));assert.ok(html.includes('<a class="promo-app"><img'));assert.ok(html.includes('第二条 &lt;script&gt;'));
assert.ok(!html.includes('javascript:'));assert.ok(!html.includes('<script>'));
});
@@ -88,7 +97,7 @@ test('dynamic home sections share a grid without inheriting the prototype editor
for(const id of [31,32,34])assert.ok(html.includes(`href="/config/${id}/"`));
for(const id of [23456,2,3])assert.ok(html.includes(`href="/detail/${id}/"`));
assert.ok(!html.includes('href="/video/4/"'));assert.ok(!html.includes('href="/config/33/"'));assert.ok(html.includes('href="/topics/8/"'));
assert.equal((html.split('id="home-blocks"')[1].match(/class="drama-episodes"/g)||[]).length,1);
assert.equal((html.split('id="home-blocks"')[1].match(/class="hg-card-counter"/g)||[]).length,3);
assert.ok(html.indexOf('必看好剧')<html.indexOf('热播榜'));
});
@@ -101,7 +110,7 @@ test('homepage banner ads follow each second topic section and keep real links',
assert.ok(blocks.indexOf('栏目二')<blocks.indexOf('横幅 a')&&blocks.indexOf('横幅 a')<blocks.indexOf('横幅 c')&&blocks.indexOf('横幅 c')<blocks.indexOf('栏目三'));
assert.ok(blocks.indexOf('栏目四')<blocks.indexOf('横幅 d')&&blocks.indexOf('横幅 d')<blocks.indexOf('横幅 e')&&blocks.indexOf('横幅 e')<blocks.indexOf('栏目五'));
assert.ok(!blocks.includes('横幅 f'));assert.ok(!blocks.includes('横幅 b'));assert.ok(!blocks.includes('javascript:'));
assert.ok(blocks.includes('data-ad-type="banner"'));assert.ok(blocks.includes('href="https://example.com/a"'));
assert.ok(!blocks.includes('data-ad-'));assert.ok(blocks.includes('href="https://example.com/a"'));
assert.ok(!renderHome({homeConfigData:[block(1,'栏目一'),block(2,'栏目二')]}).includes('wide-ad-row'));
assert.ok(!renderHome({homeConfigData:[block(1,'单独栏目')],advitise:{detailbannerList:[banner('a')]}}).includes('wide-ad-row'));
});
@@ -140,7 +149,7 @@ test('A–E previews use API limits while retaining backend order and full sourc
const topics=list.map(item=>({id:item.id,name:'专题 '+item.id,slug:item.slug}));
const block={id:1,name:'任意分类',type:style==='E'?3:1,status:true,style,limit,list,themeList:topics,topicKind:'theme'};
const html=blockSection(block);
assert.equal((html.match(/data-track-item/g)||[]).length,limit,style);
assert.equal(itemCount(html),limit,style);
const links=[...html.matchAll(/href="\/(?:detail|theme)\/story-(\d+)\/"/g)].map(match=>Number(match[1]));
assert.deepEqual(links,Array.from({length:limit},(_,index)=>index),style);
assert.equal(list.length,14);assert.equal(topics.length,14);
@@ -166,7 +175,7 @@ test('category limits apply to current and legacy modules, including unknown sty
{homeConfigData:[{id:1,name:'分类',style,limit,type:topic?3:1,status:true,list:items,themeList:items}]}];
for(const home of sources){
const html=renderHome(home).split('id="home-blocks"')[1].split('</section>')[0];
assert.equal((html.match(/data-track-item/g)||[]).length,Math.min(limit,items.length),String(style)+' '+limit);
assert.equal(itemCount(html),Math.min(limit,items.length),String(style)+' '+limit);
if(limit===0)assert.ok(html.includes('暂时没有内容'));
}
}
@@ -177,10 +186,10 @@ test('missing or invalid category limits retain all valid returned items without
const list=Array.from({length:14},(_,index)=>({...drama,id:100+index}));
for(const limit of [undefined,null,-1,1.5,NaN,Infinity,'3','',false,{},Number.MAX_SAFE_INTEGER+1]){
const html=blockSection({id:1,name:'分类',type:1,status:true,style:'B',limit,list:[null,false,...list]});
assert.equal((html.match(/data-track-item/g)||[]).length,14,String(limit));
assert.equal(itemCount(html),14,String(limit));
}
const html=blockSection({id:1,name:'分类',type:1,status:true,style:'C',limit:2,list:[null,list[0],false,list[1],list[2]]});
assert.equal((html.match(/data-track-item/g)||[]).length,2);
assert.equal(itemCount(html),2);
assert.ok(html.includes('/detail/100/'));assert.ok(html.includes('/detail/101/'));assert.ok(!html.includes('/detail/102/'));
});
@@ -189,7 +198,7 @@ test('missing, reserved, malformed and incompatible category styles preserve the
for(const style of [undefined,'','F','Z','AA',0,{},'E','A" onclick="bad']){
const html=blockSection({id:1,name:'热门好剧',type:1,status:true,style,list});
assert.ok(!html.includes('data-home-style='));assert.ok(html.includes('class="cards"'));
assert.equal((html.match(/class="card drama-data-card"/g)||[]).length,12);
assert.equal((html.match(/class="hg-card"/g)||[]).length,12);
assert.ok(!html.includes('onclick='));
}
const topics=list.map(item=>({id:item.id,name:'专题'}));
@@ -211,10 +220,11 @@ test('cover corner badges prefer 原创 over 爆剧 over 上新',()=>{
assert.ok(card({...drama,isNew:true}).includes('>上新</span>'));
});
test('styled category cards keep escaped metadata, native links, tracking and empty states',()=>{
test('styled category cards keep escaped metadata, native links and empty states',()=>{
for(const style of ['C','D']){
const html=blockSection({id:1,name:'分类',type:1,status:true,style,label:'新剧 <b>',list:[null,{...drama,desc:'简介 <script>',recommendTraceId:'trace-123',totalEpisode:36,isNew:true,tagNames:['都市 <img>']}]});
for(const value of ['data-recommend-trace-id="trace-123"','data-src="https://example.com/poster.webp"','loading="lazy"','cover-corner-badge--new','上新','36集','简介 &lt;script&gt;','都市 &lt;img&gt;'])assert.ok(html.includes(value),style+' '+value);
assert.ok(!html.includes('data-recommend-trace-id'));
for(const value of ['data-src="https://example.com/poster.webp"','loading="lazy"','cover-corner-badge--new','上新','36集','简介 &lt;script&gt;','都市 &lt;img&gt;'])assert.ok(html.includes(value),style+' '+value);
assert.ok(!html.includes('<script>'));assert.ok(!html.includes('<b>'));
if(style==='C'){assert.ok(html.includes('class="pick">最新上架</span>'));assert.ok(!html.includes('新剧 &lt;b&gt;'));assert.ok(!html.includes('class="pick">新剧'));}
}
@@ -270,12 +280,3 @@ test('homepage landscape recommendations use enabled API content, deduplicate ID
});
test('header history shows only valid unique account records, escapes titles and keeps native detail links',()=>{
const records=Array.from({length:12},(_,index)=>({...drama,id:index+1,slug:'recent-'+index,totalEpisode:index+3}));
const html=headerHistoryMarkup([null,{id:-1},records[0],records[0],...records.slice(1)]);
assert.equal((html.match(/class="jg-history-item"/g)||[]).length,9);
assert.ok(html.includes('href="/detail/recent-0/"'));assert.ok(html.includes('全 3 集'));
assert.ok(html.includes('正常剧集 &lt;script&gt;'));assert.ok(!html.includes('<script>'));
assert.ok(html.includes('data-src="https://example.com/poster.webp"'));
assert.ok(headerHistoryMarkup(null).includes('暂无观看历史'));
});
+7 -7
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
@@ -6,7 +7,7 @@ import ts from 'typescript';
async function moduleUrl(file,transform=code=>code){
const source=await readFile(new URL(file,import.meta.url),'utf8');
const code=ts.transpileModule(source,{fileName:new URL(file,import.meta.url).pathname,compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022,jsx:ts.JsxEmit.ReactJSX}}).outputText;
return 'data:text/javascript;base64,'+Buffer.from(transform(code)).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(transform(code))).toString('base64');
}
const protocol=await moduleUrl('../src/lib/api/protocol.ts'),auth=await moduleUrl('../src/lib/client/auth.ts');
const apiUrl=await moduleUrl('../src/lib/client/api.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocol)).replace('"./auth"',JSON.stringify(auth)));
@@ -15,7 +16,7 @@ const startupUrl='data:text/javascript,export const calls=[];export function sta
const startup=await import(startupUrl);
const scriptUrl='data:text/javascript,export default function Script(){}';
const runtimeUrl=await moduleUrl('../src/components/site/SiteRuntime.tsx',code=>{
const imports={'react/jsx-runtime':import.meta.resolve('react/jsx-runtime'),'next/script':scriptUrl,'@/lib/client/application':startupUrl,'@/lib/client/analytics':startupUrl};
const imports={'react/jsx-runtime':import.meta.resolve('react/jsx-runtime'),'next/script':scriptUrl,'@/lib/client/application':startupUrl};
for(const [path,url] of Object.entries(imports))for(const quote of ["'",'"'])code=code.replace(quote+path+quote,JSON.stringify(url));
return code;
});
@@ -55,16 +56,15 @@ test('API business failures, invalid JSON and network failures remain rejected w
});
});
test('home, player, navigation and wallet hydration starts normally without logging server data or refetching',async()=>{
test('home, player and info hydration starts the application only, without analytics, logging or refetching',async()=>{
await browser(async()=>{
globalThis.fetch=()=>assert.fail('hydration must use existing server data');
const wallet={user:{userId:77,recharge:90,token:'session-secret'},packages:{coinList:[{id:31,name:'金币套餐',price:10000}],vipList:[]},transactions:{list:[{id:12,amount:30}],totalCount:18}};
const home={homeConfigData:[{id:31,moreUrl:'/topics/'}]},navigation=[{name:'短剧',url:'/shorts/'}];
for(const page of [{path:'/',panel:'home'},{path:'/detail/7/',player:{title:'短剧'}},{path:'/info/deposit/page/2/',panel:'info',info:{key:'deposit',wallet}}]){
for(const page of [{path:'/',panel:'home'},{path:'/detail/7/',player:{title:'短剧'}},{path:'/info/about-us/',panel:'info',info:{key:'about-us'}}]){
const start=startup.calls.length;
SiteRuntime({home,homeLoaded:true,page,navigation}).props.onReady();
assert.equal(startup.calls[start],'tracker');assert.equal(startup.calls[start+1][0],home);assert.equal(startup.calls[start+1][3],page);
assert.equal(startup.calls.length,start+1);assert.equal(startup.calls[start][0],home);assert.equal(startup.calls[start][3],page);
}
assert.equal(wallet.user.token,'session-secret');
assert.ok(!startup.calls.includes('tracker'));
});
});
+9 -9
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
@@ -7,7 +8,7 @@ import ts from 'typescript';
async function moduleUrl(path, transform=code=>code){
const source=await readFile(new URL(path,import.meta.url),'utf8');
const {outputText}=ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(transform(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(transform(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))).toString('base64');
}
const routingUrl=await moduleUrl('../src/lib/routing.ts');
const {parseSitePath,routeFromSegments,navigationSection,internalPagePath,getRankPage,RANK_PAGES,samePageAddress}=await import(routingUrl);
@@ -32,11 +33,9 @@ test('clean paths select the matching page, IDs, and navigation section',()=>{
assert.deepEqual(parseSitePath('/shorts/'),{group:'page',key:'shorts'});
assert.deepEqual(parseSitePath('/topics/31/'),{group:'page',key:'topics',value:'31'});
assert.deepEqual(parseSitePath('/play/23456/90001/'),{group:'play',key:'23456',value:'90001'});
assert.deepEqual(parseSitePath('/info/deposit/vip/'),{group:'info',key:'deposit',value:'vip'});
assert.deepEqual(parseSitePath('/member/history/'),{group:'member',key:'history'});
assert.deepEqual(parseSitePath('/member/likes/'),{group:'member',key:'likes'});
assert.deepEqual(parseSitePath('/member/likes/page/2/'),{group:'member',key:'likes',page:2});
for(const [path,section] of [['/','home'],['/shorts/','shorts'],['/topics/31/','topics'],['/detail/7/','shorts'],['/member/center/','']]){
assert.deepEqual(parseSitePath('/info/about-us/'),{group:'info',key:'about-us'});
for(const path of ['/info/deposit/vip/','/info/deposit/','/info/feedback/','/info/user-center/','/info/settings/','/member/history/','/member/likes/','/member/likes/page/2/','/member/center/'])assert.equal(parseSitePath(path),null,path);
for(const [path,section] of [['/','home'],['/shorts/','shorts'],['/topics/31/','topics'],['/detail/7/','shorts']]){
assert.equal(navigationSection(parseSitePath(path)),section,path);
}
});
@@ -86,7 +85,8 @@ test('hash page links are not converted or treated as page destinations',()=>{
test('only recognized internal page destinations are handled by the application',()=>{
const base='https://drama.example/detail/7';
assert.equal(internalPagePath('/shorts/',base),'/shorts/');
assert.equal(internalPagePath('https://drama.example/member/history/',base),'/member/history/');
assert.equal(internalPagePath('https://drama.example/info/about-us/',base),'/info/about-us/');
assert.equal(internalPagePath('https://drama.example/member/history/',base),null);
assert.equal(internalPagePath('/search/'+encodeURIComponent('爱情 / 逆袭')+'/',base),'/search/'+encodeURIComponent('爱情 / 逆袭')+'/');
for(const href of ['https://external.example/shorts','javascript:alert(1)','/api/app/playlet/home','/assets/poster.webp','#comments','/unknown','/play/7/','/shorts/extra/','/member/unknown/','/detail/0/']){
assert.equal(internalPagePath(href,base),null,href);
@@ -99,8 +99,8 @@ test('page navigation requests a complete server document while leaving hashes,
try{
for(const path of ['#comments','/#page/shorts','https://external.example/shorts','/series/'])navigateTo(path);
assert.deepEqual(calls,[]);
for(const path of ['/play/23456/90001/','/member/history/','/rank/new/','/shorts/page/2/?sort=2'])navigateTo(path);
assert.deepEqual(calls,['/play/23456/90001/','/member/history/','/rank/new/','/shorts/page/2/?sort=2']);
for(const path of ['/play/23456/90001/','/info/about-us/','/rank/new/','/shorts/page/2/?sort=2'])navigateTo(path);
assert.deepEqual(calls,['/play/23456/90001/','/info/about-us/','/rank/new/','/shorts/page/2/?sort=2']);
assert.equal(globalThis.window.location.href,'https://drama.example/shorts/','the old document stays visible until the new response arrives');
}finally{globalThis.window=previous;}
});
+4 -3
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {routingModuleUrl} from './helpers/routing.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
@@ -7,7 +8,7 @@ import ts from 'typescript';
async function moduleUrl(file,replace=code=>code){
const filename=new URL(file,import.meta.url);
const {outputText}=ts.transpileModule(await readFile(filename,'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
return 'data:text/javascript;base64,'+Buffer.from(replace(outputText).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(replace(outputText)).replace(/(['"])\.\.\/routing\1/g,JSON.stringify(routingModuleUrl))+'\n//# sourceURL='+filename.pathname).toString('base64');
}
const protocolUrl=await moduleUrl('../src/lib/api/protocol.ts');
const renderUrl=await moduleUrl('../src/lib/client/render.ts',code=>code.replace('"../api/protocol"',JSON.stringify(protocolUrl)));
@@ -103,10 +104,10 @@ test('search suggestions open the returned drama while hot keywords open search
for(const value of [null,{},[null]])assert.ok(searchSuggestionsMarkup(value,'词').includes('暂无搜索建议'));
});
test('dropdown recommendations keep compact cards, real detail links and tracking without full-page metadata',()=>{
test('dropdown recommendations keep compact cards and real detail links without analytics or full-page metadata',()=>{
const html=searchPicksMarkup([null,...Array.from({length:4},(_,index)=>({...drama,id:7+index,slug:'story-'+index,isNew:true,desc:'简介 <script>',countLike:99}))]);
assert.equal((html.match(/class="search-pick"/g)||[]).length,3);
assert.ok(html.includes('href="/detail/story-0/"'));assert.ok(html.includes('data-track-item data-item-id="7"'));
assert.ok(html.includes('href="/detail/story-0/"'));assert.ok(!html.includes('data-track'));
assert.ok(html.includes('class="search-pick-cover"'));assert.ok(html.includes('width="160" height="240"'));
assert.ok(html.includes('data-src="https://cdn.example/encrypted.webp"'));assert.ok(html.includes('alt="SEO 标题封面"'));
assert.ok(html.includes('简介 &lt;script&gt;'));assert.ok(html.includes('cover-corner-badge--new'));assert.ok(html.includes('>上新</span>'));assert.ok(html.includes('12集'));
+7 -10
View File
@@ -65,7 +65,7 @@ test('configuration failures and missing permanent origins retain the last valid
test('cold starts never guess a canonical and the browser preserves the server identity',()=>{
const cold=seo.resolveSeoEnvironment({},[]);
assert.equal(cold.origin,'');assert.equal(cold.site.siteName,'禁果短剧');
assert.equal(cold.origin,'');assert.equal(cold.site.siteName,'黄果短剧');
const head=seo.buildPageHead('/shorts/','',cold,{totalCount:72});
assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined);assert.equal(head.next,undefined);
assert.equal(seo.clientSeoEnvironment(environment,{config:{foreverUrlAddr:'https://other.drama.test'},seoInfo:{webSiteName:'其他品牌'}}),environment);
@@ -87,13 +87,12 @@ test('canonical is query-free, every pagination page identifies itself, and firs
assert.equal(page('/shorts/page/1/',{totalCount:72}).canonical,'https://drama.example.net/shorts/');
const overflow=page('/shorts/page/4/',{totalCount:72,pageSize:24});assert.equal(overflow.canonical,undefined);assert.match(overflow.robots,/noindex/);assert.equal(overflow.previous,undefined);
for(const totalCount of [0,1,24]){const head=page('/shorts/',{totalCount});assert.equal(head.previous,undefined);assert.equal(head.next,undefined);}
const center=page('/member/center/',{totalCount:72});assert.equal(center.previous,undefined);assert.equal(center.next,undefined);
for(const totalCount of [-1,NaN,'72']){const head=page('/shorts/',{totalCount});assert.equal(head.next,undefined);}
});
test('private/tools/search/error pages do not inherit public canonical or indexability',()=>{
for(const path of ['/search/爱情/','/navigation/','/home-road/','/apps/','/unknown-page/']){const head=page(path);assert.match(head.robots,/noindex,follow/);assert.equal(head.canonical,undefined,path);}
for(const path of ['/member/center/','/member/likes/','/member/likes/page/2/','/member/favorites/page/2/','/info/deposit/','/info/feedback/'])assert.equal(page(path).robots,'noindex,nofollow',path);
for(const path of ['/member/center/','/member/likes/','/info/deposit/','/info/feedback/']){const head=page(path);assert.equal(head.robots,'noindex,follow',path);assert.equal(head.canonical,undefined,path);assert.equal(seo.pageResponseStatus(path,{}),404,path);}
assert.match(page('/shorts/',{},'?sort=2').robots,/noindex/);assert.equal(page('/shorts/',{},'?sort=2').canonical,undefined);
for(const content of [{failed:true},{missing:true},{unavailable:true}])assert.equal(page('/detail/9/',content).canonical,undefined);
for(const path of ['/info/about-us/','/info/privacy-policy/','/rank/hot/','/rank/new/','/rank/follow/'])assert.ok(page(path).canonical,path);
@@ -114,9 +113,9 @@ test('client navigation updates SSR head once and clears old canonical/OG URL/pa
assert.equal(doc.title,last.title);assert.equal(doc.value('meta[property="og:site_name"]'),'91剧场');
assert.equal(doc.value('link[rel="canonical"]'),last.canonical);assert.equal(doc.value('meta[property="og:url"]'),last.canonical);
assert.equal(doc.value('link[rel="next"]'),undefined);assert.equal(doc.value('link[rel="prev"]'),last.previous);
const privatePage=page('/info/deposit/');clientSeo.applyPageHead(privatePage,doc);
const privatePage=page('/unknown-page/');clientSeo.applyPageHead(privatePage,doc);
assert.equal(doc.value('link[rel="canonical"]'),undefined);assert.equal(doc.value('meta[property="og:url"]'),undefined);assert.equal(doc.value('link[rel="prev"]'),undefined);
assert.equal(doc.value('meta[name="robots"]'),'noindex,nofollow');assert.equal(doc.value('meta[name="googlebot"]'),'noindex,nofollow');
assert.equal(doc.value('meta[name="robots"]'),'noindex,follow');assert.equal(doc.value('meta[name="googlebot"]'),'noindex,follow');
assert.equal(doc.value('meta[name="twitter:title"]'),privatePage.title);
});
@@ -141,7 +140,7 @@ test('topic aliases and numeric ID variants share a primary canonical without ch
assert.equal(page('/search/007/').canonical,undefined);
});
test('empty public lists and comment pagination are noindex while preserving successful and error statuses',()=>{
test('empty public lists are noindex while preserving successful and error statuses; detail/play pages are not paginated',()=>{
for(const path of ['/shorts/','/topics/','/topics/7/','/theme/7/','/tag/9/','/rank/hot/']){
const content={totalCount:0},head=page(path,content);
assert.equal(head.robots,'noindex,follow',path);assert.equal(head.canonical,undefined);assert.equal(head.jsonLd,undefined);
@@ -153,10 +152,8 @@ test('empty public lists and comment pagination are noindex while preserving suc
const playable=seo.dramaSeoContent({id:9,title:'作品名'},{id:90,currentEpisode:3,videoUrl:'https://media.example.net/3.mp4'});
for(const [path,data] of [['/detail/9/',drama],['/play/9/90/',playable]]){
assert.ok(page(path,{...data,totalCount:0,pageSize:10}).robots.startsWith('index,'),path);
const content={...data,totalCount:35,pageSize:10},paged=page(path+'page/2/',content);
assert.equal(seo.pageResponseStatus(path+'page/2/',content),200);
assert.equal(paged.robots,'noindex,follow');
for(const key of ['canonical','previous','next','jsonLd'])assert.equal(paged[key],undefined,key);
const content={...data,totalCount:35,pageSize:10};
assert.equal(seo.pageResponseStatus(path+'page/2/',content),404,'detail and play pages no longer paginate comments');
}
});
+16 -2
View File
@@ -8,7 +8,7 @@ async function moduleUrl(file,replace=code=>code){
return 'data:text/javascript;base64,'+Buffer.from(replace(code)).toString('base64');
}
const routing=await moduleUrl('../src/lib/routing.ts'),protocol=await moduleUrl('../src/lib/api/protocol.ts');
const {navigationEntries,navigationIsActive,mobileNavigationEntries}=await import(await moduleUrl('../src/lib/site-navigation.ts',code=>code.replace("'./api/protocol'",JSON.stringify(protocol)).replace("'./routing'",JSON.stringify(routing))));
const {navigationEntries,navigationIsActive,mobileNavigationEntries,withoutRemovedNavigation}=await import(await moduleUrl('../src/lib/site-navigation.ts',code=>code.replace("'./api/protocol'",JSON.stringify(protocol)).replace("'./routing'",JSON.stringify(routing))));
const item=(id,name,urlValue,extra={})=>({id,name,urlValue,categoryType:1,...extra});
test('API order, labels, icons and nested destinations are preserved while footer and removed video entries stay absent',()=>{
@@ -20,7 +20,14 @@ test('API order, labels, icons and nested destinations are preserved while foote
test('known API aliases and empty system entries generate native destinations directly without redirects',()=>{
const entries=navigationEntries({categoryList:[item(1,'首页',''),item(2,'排行','/hot'),item(3,'回家的路',''),item(4,'福利应用',''),item(5,'名字由后台决定','/shorts?sort=2'),item(6,'未知空地址','')]});
assert.deepEqual(entries.map(entry=>entry.href),['/','/rank/hot/','/home-road/','/apps/','/shorts/?sort=2']);
assert.deepEqual(entries.map(entry=>entry.href),['/','/rank/hot/','/home-road/','/shorts/?sort=2']);
});
test('backend navigation entries for the removed welfare app directory are filtered out',()=>{
const entries=navigationEntries({categoryList:[item(1,'短剧','/shorts/'),item(2,'应用','/apps/'),item(3,'应用二','/application'),item(4,'福利应用','https://drama.test/apps/'),item(5,'福利应用','/topics/'),item(6,'目录','',{categoryType:2,translationList:[{id:7,name:'应用',urlValue:'/apps/'},{id:8,name:'专题',urlValue:'/topics/'}]})]},'https://drama.test');
assert.deepEqual(entries.map(entry=>entry.name),['短剧','目录']);
assert.deepEqual(entries[1].children.map(entry=>entry.href),['/topics/']);
assert.ok(!JSON.stringify(entries).includes('/apps'));
});
test('links reject executable addresses, credentials and removed routes, and keep external URLs external',()=>{
@@ -38,3 +45,10 @@ test('active navigation follows configured destinations, independent pages, pagi
assert.equal(navigationIsActive('/shorts/?sort=2','/shorts/page/2/?sort=2&campaign=test'),true);assert.equal(navigationIsActive('/shorts/?sort=2','/shorts/?sort=1'),false);
assert.equal(navigationIsActive('https://example.net/shorts/','/shorts/'),false);assert.equal(navigationIsActive('/series/','/series/'),false);
});
test('the serialized backend navigation payload no longer carries welfare-app entries',()=>{
const data=withoutRemovedNavigation({categoryList:[{id:1,name:'短剧',urlValue:'/shorts/',categoryType:1},{id:2,name:'福利应用',urlValue:'',categoryType:1},{id:3,name:'应用',urlValue:'https://drama.test/apps/',categoryType:1},{id:4,name:'目录',urlValue:'',categoryType:2,translationList:[{id:5,name:'应用',urlValue:'/application'},{id:6,name:'专题',urlValue:'/topics/'}]}]});
assert.deepEqual(data.categoryList.map(item=>item.id),[1,4]);
assert.deepEqual(data.categoryList[1].translationList.map(item=>item.id),[6]);
assert.deepEqual(withoutRemovedNavigation(null).categoryList,[]);
});
+3 -1
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
@@ -10,7 +11,7 @@ async function moduleUrl(path,imports={}){
const {outputText}=ts.transpileModule(await readFile(new URL('../src/lib/'+path,import.meta.url),'utf8'),{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext}});
let code=outputText;
for(const [key,url] of Object.entries({'../routing':routingModuleUrl,...imports}))for(const quote of ["'",'"'])code=code.replace(quote+key+quote,JSON.stringify(url));
return 'data:text/javascript;base64,'+Buffer.from(code).toString('base64');
return 'data:text/javascript;base64,'+Buffer.from(linkShared(code)).toString('base64');
}
const protocol=await moduleUrl('api/protocol.ts');
const renderUrl=await moduleUrl('client/render.ts',{'../api/protocol':protocol});
@@ -26,6 +27,7 @@ test('slug-first paths encode one segment once, preserve ID fallback and accept
const path=routes.contentPath(kind,{id:9,slug:' '+key+' '});
assert.equal(path,`/${kind}/${encodeURIComponent(key)}/`);
assert.equal(routes.parseSitePath(path)[kind==='detail'?'key':'value'],key);
if(kind==='detail'){assert.equal(routes.parseSitePath(path+'page/2/'),null);continue;}
assert.equal(routes.paginationPath(path,2),path+'page/2/');
assert.equal(routes.parseSitePath(path+'page/2/').page,2);
}
+6 -7
View File
@@ -218,7 +218,7 @@ test('image loading supports cached images, errors, timeouts and cancellation',a
}finally{if(previous===undefined)delete globalThis.window;else globalThis.window=previous;}
});
test('dialog controls enforce age confirmation, advance ads on Escape, track clicks, and use the reference exit',async()=>{
test('dialog controls enforce age confirmation, advance ads on Escape, emit no analytics, and use the reference exit',async()=>{
class Dialog extends FakeElement {
open=false;nodes=new Map();
showModal(){this.open=true;}
@@ -244,14 +244,14 @@ test('dialog controls enforce age confirmation, advance ads on Escape, track cli
assert.equal(age.open,true);assert.equal(popup.open,false);
const cancelAge=new Event('cancel',{cancelable:true});age.dispatchEvent(cancelAge);assert.equal(cancelAge.defaultPrevented,true);assert.equal(age.open,true);
enter.click();assert.equal(age.open,false);assert.equal(popup.open,true);assert.equal(media.href,'https://example.com/first');assert.equal(store.getItem('hg_age_ok'),'1');
media.click();assert.deepEqual(tracks,['show','click']);assert.equal(popup.open,true);
media.click();assert.deepEqual(tracks,[]);assert.equal(popup.open,true);
const cancelAd=new Event('cancel',{cancelable:true});popup.dispatchEvent(cancelAd);await flush();
assert.equal(cancelAd.defaultPrevented,true);assert.equal(media.href,'https://example.com/second');assert.equal(popup.open,true);
close.click();assert.equal(popup.open,false);assert.equal(store.getItem('dialogAdDismissed'),'2|first|second');
assert.deepEqual(tracks,['show','click','show']);
assert.equal(grid.open,true);assert.equal(promotions.children.length,1);assert.equal(media.dataset.adPlacement,'startup-ad');
assert.deepEqual(tracks,[],'startup ads emit no analytics events');
assert.equal(grid.open,true);assert.equal(promotions.children.length,1);assert.equal(media.dataset.adPlacement,undefined);
const link=promotions.children[0];assert.equal(link.href,'https://example.com/app');
assert.equal(link.rel,'sponsored nofollow noopener noreferrer');assert.equal(link.dataset.adType,'icon');assert.equal(link.dataset.trackAd,'external');assert.equal(link.dataset.adPlacement,'startup-grid');
assert.equal(link.rel,'sponsored nofollow noopener noreferrer');assert.equal(link.dataset.adType,undefined);assert.equal(link.dataset.trackAd,undefined);assert.equal(link.dataset.adPlacement,undefined);
assert.equal(link.children[0].alt,'app');assert.equal(link.children[1].textContent,'app');
const keyboard=new Event('keydown');keyboard.key='Tab';grid.dispatchEvent(keyboard);assert.equal(grid.classList.contains('startup-keyboard-focus'),true);
grid.dispatchEvent(new Event('pointerdown'));assert.equal(grid.classList.contains('startup-keyboard-focus'),false);
@@ -268,8 +268,7 @@ test('dialog controls enforce age confirmation, advance ads on Escape, track cli
};
controller.setAdvertisements([],[ad('broken'),{...ad('no-link'),link:'javascript:alert(1)',title:'<script>inert text</script>',advertiseCode:'grid-code',advertiseLocationCode:'slot-3',advertiseLocationName:'应用推广',location:3},ad('last')]);await flush();
assert.equal(grid.open,true);assert.equal(promotions.children.length,2);assert.equal(decoded.length,3);
const noLink=promotions.children[0];assert.equal(noLink.hasAttribute('href'),false);assert.equal(noLink.dataset.adClickable,'0');
assert.equal(noLink.dataset.adId,'grid-code');assert.equal(noLink.dataset.adSlotKey,'slot-3');assert.equal(noLink.dataset.adSlotName,'应用推广');
const noLink=promotions.children[0];assert.equal(noLink.hasAttribute('href'),false);assert.deepEqual(Object.keys(noLink.dataset),[]);
assert.equal(noLink.children[0].alt,'<script>inert text</script>');assert.equal(noLink.children[1].textContent,'<script>inert text</script>');
assert.equal(promotions.children[1].href,'https://example.com/last');
const inertClick=new Event('click');Object.defineProperty(inertClick,'target',{value:noLink.children[0]});promotions.dispatchEvent(inertClick);assert.equal(grid.open,true);
+3 -2
View File
@@ -1,3 +1,4 @@
import {linkShared} from './helpers/shared-modules.mjs';
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
@@ -106,10 +107,10 @@ test('removed standalone video routes have no canonical or structured data',()=>
test('about and FAQ use visible local content, while private/search/tools/failed pages output no graph',async()=>{
const code=ts.transpileModule(await readFile(new URL('../src/lib/info-pages.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
const info=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
const info=await import('data:text/javascript;base64,'+Buffer.from(linkShared(code)).toString('base64'));
const about=head('/info/about-us/',seo.infoSeoContent(info.getInfoPage('about-us')));assert.equal(nodes(about,'AboutPage').length,1);
const faqPage=info.getInfoPage('help-center'),content=seo.infoSeoContent(faqPage),page=head('/info/help-center/',content);
assert.equal(nodes(page,'FAQPage')[0].mainEntity.length,6);
assert.equal(nodes(page,'FAQPage')[0].mainEntity.length,5);assert.ok(!faqPage.html.includes('反馈'));
for(const question of nodes(page,'FAQPage')[0].mainEntity){assert.ok(faqPage.html.includes(question.name));assert.ok(question.acceptedAnswer.text);}
for(const path of ['/search/爱情/','/navigation/','/home-road/','/apps/','/member/favorites/','/info/deposit/','/unknown/'])assert.equal(head(path,content).jsonLd,undefined,path);
assert.equal(head('/shorts/',content,'?sort=2').jsonLd,undefined);assert.equal(head('/detail/9/',{...content,failed:true}).jsonLd,undefined);