Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-11-05 23:07:59 +07:00
co-authored by Claude Opus 5.5
commit f65d10e998
377 changed files with 31301 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { routeFromSegments } from '@/lib/routing';
import { getPageSeo, metadataFromHead, queryString } from '@/lib/seo-server';
import { SiteFrame } from '@/components/site/SiteFrame';
import { empty } from '@/lib/client/render';
import type { PageView } from '@/lib/page-data';
import { assertPageContent } from '@/lib/page-response';
export const dynamic = 'force-dynamic';
type PageProps={params:Promise<{path:string[]}>;searchParams:Promise<Record<string,string|string[]|undefined>>};
const pathname=(path:string[])=>'/'+path.map(encodeURIComponent).join('/')+'/';
export async function generateMetadata({params,searchParams}:PageProps):Promise<Metadata> {
const {path}=await params;
if(!routeFromSegments(path))notFound();
const result=await getPageSeo(pathname(path),queryString(await searchParams));
assertPageContent(pathname(path),result.bootstrap.content);
return metadataFromHead(result.head);
}
export default async function Page({params,searchParams}:PageProps) {
// Next 16.4 getDynamicParam encodes page props; generateMetadata receives decoded params.
// Decode at this boundary so both calls share the same URL, data and SEO cache entry.
const {path:encodedPath} = await params;
const path=encodedPath.map(decodeURIComponent);
const route=routeFromSegments(path);
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 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} />;
}
+4
View File
@@ -0,0 +1,4 @@
// Compatibility alias for older clients; current browser requests use /api/app/*.
export { GET, POST } from '../app/[...path]/route';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
+211
View File
@@ -0,0 +1,211 @@
import { NextRequest, NextResponse } from "next/server";
import { randomUUID } from "node:crypto";
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 type { Envelope, User } from "@/lib/api/types";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const AUTH = "drama_session";
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;
/** Prefer HttpOnly session, then client mirror cookie, then Authorization (jha-1048 style). */
const requestToken = (request: NextRequest) =>
request.cookies.get(AUTH)?.value ||
request.cookies.get(BEARER)?.value ||
text(request.headers.get('Authorization'));
function sessionUser(value: unknown, token = '', registered = false): User {
if (!value || typeof value !== 'object' || !Number.isSafeInteger((value as User).userId) || (value as User).userId <= 0) throw new ApiError('登录响应缺少用户信息,请稍后重试',502);
const user = value as User;
const sessionToken = text(user.token) || token;
if (!sessionToken) throw new ApiError('登录响应缺少凭证,请稍后重试',502);
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;
const headers = {'Cache-Control':'no-store'};
if (request.headers.has(UPSTREAM_HOP_HEADER)) return NextResponse.json({code:502,msg:'上游请求返回本站,请检查 Nginx 的 /api/app/ 后端转发配置',data:null},{status:502,headers:{...headers,[UPSTREAM_HOP_HEADER]:'1'}});
if (!allowedRequest(path,method)) return NextResponse.json({code:404,msg:'接口不存在',data:null},{status:404,headers});
const secure = request.nextUrl.protocol === 'https:';
const cookieOptions = {httpOnly:true,sameSite:'lax' as const,secure,path:'/',maxAge:7*86400};
const clearSession = (response: NextResponse) => {
response.cookies.set(AUTH,'',{...cookieOptions,maxAge:0});
response.cookies.set(BEARER,'',{...cookieOptions,httpOnly:false,maxAge:0});
response.cookies.set(REGISTERED,'',{...cookieOptions,maxAge:0});
};
const writeSession = (response: NextResponse, sessionToken: string, isRegistered: boolean) => {
response.cookies.set(AUTH,sessionToken,cookieOptions);
// Client-visible mirror so ensureGuestSession can skip guest like jha-1048 localStorage token.
response.cookies.set(BEARER,sessionToken,{...cookieOptions,httpOnly:false});
response.cookies.set(REGISTERED,isRegistered ? '1' : '0',cookieOptions);
};
let token = PUBLIC_PATHS.has(path) ? '' : requestToken(request);
const incomingToken = token;
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 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 setContextCookies = (response: NextResponse) => {
if (establishesDevice) {
response.cookies.set(DEVICE,device,{...cookieOptions,httpOnly:false,maxAge:365*86400});
if (channel) response.cookies.set(CHANNEL,channel,{...cookieOptions,httpOnly:false,maxAge:365*86400});
}
};
const guest = async () => {
const result = await upstream<User>('auth/guest',{request,method:'POST',body:{devID:device,deviceId:device,channel,mac:''},device,userAgent,clientIp,tracking:identity});
if (result.code !== 200) throw new ApiError(result.tip || result.msg || '游客登录失败',result.code);
return sessionUser(result.data);
};
// 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}); }
catch (error) {
if (error instanceof ApiError && error.code === 401) return {code:401,msg:error.message,data:null};
throw error;
}
};
try {
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(()=>({}));
}
const input = body && typeof body === 'object' && !Array.isArray(body) && !(body instanceof FormData) ? 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});
if (info.code === 200) session = sessionUser(info.data,token,registered);
else if (!expired(info.code) || registered) throw new ApiError(info.tip || info.msg || '登录已过期,请重新登录',info.code);
}
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) {
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));
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 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.
if (path === 'auth/getInfo' || path === 'auth/guest') clearSession(response);
if (registered) response.headers.set('X-Auth-State','login-required');
} else if (session?.token) {
if (session.token !== incomingToken) {
writeSession(response, session.token, !!(session.isRegister || session.type === 1 || registered));
} else if (!request.cookies.get(BEARER)?.value) {
// Same HttpOnly session: only backfill the client-visible mirror (jha-1048 cookie),
// so a stale refresh cannot overwrite a newer login cookie.
response.cookies.set(BEARER, session.token, {...cookieOptions, httpOnly:false});
}
}
setContextCookies(response);
return response;
} catch (error) {
const code = error instanceof ApiError ? error.code : 502;
const isExpired = expired(code) && !CREDENTIALS.has(path);
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);
if (registered) response.headers.set('X-Auth-State','login-required');
}
setContextCookies(response);
return response;
}
}
export const GET = handler;
export const POST = handler;
+5
View File
@@ -0,0 +1,5 @@
import { proxyMedia } from '@/lib/api/media';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export function GET(request: Request) {return proxyMedia(request,'/enkey',true);}
+12
View File
@@ -0,0 +1,12 @@
'use client';
export default function PageError({retry}:{retry:()=>void}) {
return <main className="route-not-found">
<title>页面暂不可用 - 禁果短剧</title>
<meta name="robots" content="noindex,follow" />
<h1>页面暂不可用</h1>
<p>内容服务暂时异常,请稍后重试</p>
<button type="button" className="primary" onClick={retry}>重新加载</button>
<a href="/">返回首页</a>
</main>;
}
+2789
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -0,0 +1,10 @@
import { proxyMedia } from '@/lib/api/media';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function GET(request: Request, context: {params: Promise<{path: string[]}>}) {
const {path} = await context.params;
if (path.some(part => !part || part === '..' || part === '.' || /[\\/?#\u0000-\u001f]/.test(part))) return new Response(null,{status:400});
return proxyMedia(request,'/h5/m3u8/'+path.map(encodeURIComponent).join('/'));
}
+6
View File
@@ -0,0 +1,6 @@
export const dynamic='force-dynamic';
export function GET() {
return Response.json({status:'ok',environment:process.env.APP_ENV||'production',application:'jha-1050-jgdj-web'},
{headers:{'Cache-Control':'no-store','X-Robots-Tag':'noindex, nofollow'}});
}
+32
View File
@@ -0,0 +1,32 @@
<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>

After

Width:  |  Height:  |  Size: 6.8 KiB

+33
View File
@@ -0,0 +1,33 @@
import type { Metadata, Viewport } from "next";
import Script from "next/script";
import "./globals.css";
const GTM_ID = "GTM-TXLP67SX";
export const metadata: Metadata = {
title: "禁果短剧 · 精品原创 · 日更爽看",
applicationName: "禁果短剧",
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"},
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#131414",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="zh-CN" suppressHydrationWarning>
<body>
<Script id="gtm" strategy="afterInteractive">{`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','${GTM_ID}');window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());`}</Script>
<noscript>
<iframe src={`https://www.googletagmanager.com/ns.html?id=${GTM_ID}`} height={0} width={0} style={{display:"none",visibility:"hidden"}} title="Google Tag Manager" />
</noscript>
{children}
</body>
</html>
);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
import { NavigationPage } from '@/components/site/NavigationPage';
import { getPageSeo, getSeoConfig, metadataFromHead } from '@/lib/seo-server';
import { downloadUrl } from '@/lib/app-download';
export const dynamic = 'force-dynamic';
export async function generateMetadata():Promise<Metadata>{return metadataFromHead((await getPageSeo('/navigation/')).head);}
export default async function Page() {
const {config,error}=await getSeoConfig();
if(error)throw new Error('入口配置暂不可用,请稍后重试');
return <NavigationPage config={config} download={downloadUrl(config)} error={error} />;
}
+5
View File
@@ -0,0 +1,5 @@
import { proxyMedia } from '@/lib/api/media';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export function GET(request: Request) {return proxyMedia(request,'/newenkey',true);}
+13
View File
@@ -0,0 +1,13 @@
import type { Metadata } from 'next';
export const metadata:Metadata={title:'页面不存在 - 禁果短剧',robots:{index:false,follow:true},alternates:{canonical:null}};
export default function NotFound() {
return (
<main className="route-not-found">
<h1>404</h1>
<p>页面不存在或地址有误</p>
<a className="primary" href="/">返回首页</a>
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { SiteFrame } from "@/components/site/SiteFrame";
import { getHome } from "@/lib/api/server";
import { getPageSeo, metadataFromHead, queryString } from '@/lib/seo-server';
import { assertPageContent } from '@/lib/page-response';
export const dynamic = "force-dynamic";
type PageProps={searchParams:Promise<Record<string,string|string[]|undefined>>};
export async function generateMetadata({searchParams}:PageProps){
const result=await getPageSeo('/',queryString(await searchParams));
assertPageContent('/',result.bootstrap.content);
return metadataFromHead(result.head);
}
export default async function Page({searchParams}:PageProps) {
const search=queryString(await searchParams);
const [{data:home,error},{bootstrap,session,shared}] = await Promise.all([getHome(),getPageSeo('/',search)]);
assertPageContent('/',bootstrap.content);
return <SiteFrame home={home} error={error} seo={bootstrap} session={session} shared={shared} />;
}
+16
View File
@@ -0,0 +1,16 @@
import type { MetadataRoute } from 'next';
import { getSeoOrigin } from '@/lib/seo-server';
export const dynamic = 'force-dynamic';
/** Keep HTML crawlable so crawlers can read its canonical and noindex directives. */
export default async function robots(): Promise<MetadataRoute.Robots> {
const environment=process.env.APP_ENV||(process.env.NODE_ENV==='development'?'development':'production');
const origin=environment==='production'?await getSeoOrigin():'';
return {
rules:environment==='production'
?{userAgent:'*',allow:'/',disallow:['/api/','/healthz']}
:{userAgent:'*',disallow:'/'},
...(origin?{sitemap:new URL('/sitemap.xml',origin).href}:{}),
};
}
+48
View File
@@ -0,0 +1,48 @@
import type { Ref } from "react";
interface AppDownloadDialogProps {
download?: string;
qr?: string;
status?: string;
dialogRef?: Ref<HTMLDialogElement>;
onClose?: () => void;
onCopy?: () => void;
}
export function AppDownloadDialog({download, qr, status, dialogRef, onClose, onCopy}: AppDownloadDialogProps) {
return (
<dialog ref={dialogRef} id={"app-download-dialog"} className={"app-download-dialog"} aria-labelledby={"app-download-heading"}>
<button type={"button"} className={"close"} id={"app-download-close"} onClick={onClose} 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={"app-download-heading"}>
{"下载禁果短剧 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 下载二维码"} />
</div>
<p id={"app-platform-caption"}>
{download === undefined ? "" : download ? "扫码下载安装 禁果短剧 App" : "暂未配置 App 下载地址"}
</p>
<div className={"app-download-link"}>
<input id={"app-platform-link"} value={download || ""} readOnly aria-label={"下载链接"} />
<button type={"button"} id={"app-copy-link"} onClick={onCopy}>
{"复制链接"}
</button>
</div>
<p className={"app-copy-status"} id={"app-copy-status"} role={"status"} aria-live={"polite"}>
{status}
</p>
<p className={"app-download-demo"} hidden={download !== undefined}>
{"下载地址加载中"}
</p>
</dialog>
);
}
+76
View File
@@ -0,0 +1,76 @@
import type { InfoPageDefinition } from '@/lib/info-pages';
import type { PageView } from '@/lib/page-data';
import { parseSitePath } from '@/lib/routing';
export function ChannelPages({info,view}: {info?:InfoPageDefinition;view?:PageView}={}) {
const channel=view?.panel==='channel'?view:undefined;
const route=channel?.bootstrap?parseSitePath(channel.bootstrap.path.split('?')[0]):null;
const topicDetail=route?.group==='page'&&(route.key==='theme'||route.key==='topics'&&!!route.value);
info ||= view?.panel==='info'?{key:view.bootstrap?.info?.key||'',title:view.title,html:view.html}:undefined;
const ChannelTitle=channel?'h1':'div';
const InfoTitle=info?'h1':'div';
return (
<>
<main id={"channel-page"} className={"shell channel-shell"} hidden={!channel} data-channel={channel?.channel} aria-labelledby={"channel-title"}>
<nav className={"page-breadcrumb"} id={"channel-breadcrumb"} aria-label={"面包屑导航"}>
{channel?<><a href="/">首页</a><span>/</span>{topicDetail?<><a href="/topics/">专题</a><span>/</span></>:null}<span aria-current="page">{channel.title}</span></>:null}
</nav>
<header className={"info-heading"}>
<small id={"channel-kicker"}>
</small>
<ChannelTitle id={"channel-title"} tabIndex={-1}>
{channel?.title}
</ChannelTitle>
<p id={"channel-subtitle"}>
{channel?.subtitle}
</p>
</header>
<div id={"channel-content"} dangerouslySetInnerHTML={channel?{__html:channel.html}:undefined} />
</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}
</nav>
<header className={"info-heading"}>
<small>
{"INFORMATION & SUPPORT"}
</small>
<InfoTitle id={"info-title"} tabIndex={-1}>
{info?.title}
</InfoTitle>
<p id={"info-subtitle"}>
</p>
</header>
<div className={"info-layout"}>
<nav className={"info-nav"} aria-label={"信息页面"} hidden={!!info}>
<a href={"/info/about-us/"} data-info={"about-us"}>
{"关于我们"}
</a>
<a href={"/info/user-agreement/"} data-info={"user-agreement"}>
{"用户协议"}
</a>
<a href={"/info/privacy-policy/"} data-info={"privacy-policy"}>
{"隐私政策"}
</a>
<a href={"/info/help-center/"} data-info={"help-center"}>
{"常见问题"}
</a>
<a href={"/info/2257/"} data-info={"2257"}>
{"2257"}
</a>
<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>
</main>
<main id={"search-results-page"} className={"shell"} hidden={view?.panel!=='search'} aria-labelledby={"search-results-title"} dangerouslySetInnerHTML={view?.panel==='search'?{__html:view.html}:undefined} />
</>
);
}
+49
View File
@@ -0,0 +1,49 @@
import type { navigationPageData } from '@/lib/navigation-page';
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()||'';
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}
</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'}:{})}>
{link.name}
</a>)}
</nav>:null}
<small className="site-footer-copyright">
{`© ${year}${brand?` ${brand} `:''}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"}>
<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>}
</div>
</div>
</footer>
</>
);
}
+140
View File
@@ -0,0 +1,140 @@
import type { HomeData, User } 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}) {
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>
<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"} />
<button type={"submit"} aria-label={"执行搜索"}>
<svg className={"search-icon-solid"} aria-hidden={"true"} width={"20"} height={"20"} viewBox={"0 0 20 20"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
<path d={"M14.6843 14.7115C15.0064 14.392 15.5225 14.3919 15.8445 14.7115L17.9734 16.4303H18.0105C18.4411 16.8656 18.441 17.5711 18.0105 18.0065C17.5798 18.4418 16.8816 18.4418 16.4509 18.0065L14.6843 15.9821C14.5174 15.8139 14.4237 15.5855 14.4236 15.3473C14.4236 15.109 14.5173 14.8798 14.6843 14.7115ZM8.81519 1.66663C10.7108 1.66673 12.5285 2.42807 13.8689 3.78284C15.2093 5.1377 15.9626 6.97518 15.9626 8.89124C15.9626 12.8813 12.7627 16.1166 8.81519 16.1168C4.86744 16.1168 1.66675 12.8815 1.66675 8.89124C1.66692 4.90116 4.86754 1.66663 8.81519 1.66663Z"} fill={"white"}>
</path>
</svg>
</button>
<section id={"search-discovery"} className={"search-discovery"} aria-label={"搜索推荐"} hidden>
<div className={"search-history-section"}>
<div className={"search-discovery-heading"}>
<h2>
{"搜索记录"}
</h2>
<div className={"search-history-actions"}>
<button type={"button"} id={"search-clear"}>
{"清除"}
</button>
<button type={"button"} id={"search-panel-close"} aria-label={"关闭搜索推荐"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<circle cx={"12"} cy={"12"} r={"9"}>
</circle>
<path d={"m9 9 6 6m0-6-6 6"}>
</path>
</svg>
</button>
</div>
</div>
<div className={"search-history-tags"} id={"search-history-tags"}>
</div>
</div>
<div className={"search-ranking-section"}>
<div className={"search-discovery-heading"}>
<h2 id={"search-suggestion-title"}>
{"热搜榜"}
</h2>
</div>
<div className={"search-suggestion-list"} id={"search-suggestion-list"} aria-live={"polite"}>
</div>
</div>
<div className={"search-picks-section"}>
<div className={"search-discovery-heading"}>
<h2>
{"猜你喜欢"}
</h2>
<button type={"button"} id={"search-picks-refresh"}>
{"换一换"}
</button>
</div>
<div className={"search-picks-grid"} id={"search-picks-grid"}>
</div>
</div>
</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"}>
<rect x={"6"} y={"2"} width={"12"} height={"20"} rx={"3"}>
</rect>
<path d={"M10 5h4m-3 14h2"}>
</path>
</svg>
<span>
{"下载 App"}
</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>
<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>}
<div className="home-content" hidden={hideContent} dangerouslySetInnerHTML={{__html:hideContent?'':renderHome(home,error)}} />
</main>
</>;
}
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { startTracker } from "@/lib/client/analytics";
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 openDownload = async (event: MouseEvent<HTMLAnchorElement>) => {
if (!shouldUseDownloadDialog(event)) return;
event.preventDefault();setStatus("");dialog.current?.showModal();
try {setQr(await createDownloadQr(download));} catch {setStatus("二维码生成失败,请复制下载链接");}
};
const copyDownload = async () => {
if (!download) {setStatus("暂未配置 App 下载地址"); return;}
try {await navigator.clipboard.writeText(download); setStatus("链接已复制");}
catch {setStatus("复制失败,请手动复制下载链接");}
};
return (
<>
<div id={"entry-navigation"} className={"navigation-page"}>
<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"} />
<p>
{description}
</p>
<strong>
{"精品原创 · 日更爽看"}
</strong>
</header>
<nav className={"entry-routes"} aria-label={"访问线路"}>
{routes.map((route,index)=><a key={route.line} className={"entry-route"} href={route.href} rel="sponsored nofollow noopener noreferrer">
<span className={"entry-route-copy"}>
<b>{siteName+" · "+route.label}</b>
<small>{index===0?"推荐入口":"备用入口"}</small>
</span>
<span className={"entry-route-cta"}>
{"立即进入"}
<img src={"/assets/2d24f60e409ee818.svg"} width={"5"} height={"3.5"} alt={"进入"+route.label} aria-hidden={"true"} />
</span>
<img className={"entry-route-glow"} src={"/assets/974b889940739032.svg"} width={"292"} height={"263"} alt={"访问线路背景光效"} aria-hidden={"true"} />
</a>)}
{!routes.length&&<p className="entry-route-empty" role="status">暂未配置访问线路</p>}
{permanent&&<a className={"entry-permanent"} href={permanent} rel="sponsored nofollow noopener noreferrer">
<span>
{"永久入口"}
</span>
<span>
{"收藏回家的路,获取最新地址"}
</span>
</a>}
</nav>
{download && <a className={"entry-download"} href={download} target="_blank" rel="sponsored nofollow noopener noreferrer" data-app-download="" onClick={event => {void openDownload(event);}}>
<img src={"/assets/cea8731b980a14a0.png"} alt={siteName+" App 下载横幅"} />
<span>
{siteName+" · APP 下载"}
</span>
</a>}
{telegram&&<a className={"entry-contact"} href={telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
{"官方联系:" + telegram}
</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"}>
<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={"M14.2291 1.84364C13.9819 1.64734 13.6874 1.51976 13.3752 1.47379C13.0629 1.42781 12.7441 1.46508 12.4509 1.58182L2.09451 5.68364C1.71253 5.83484 1.38148 6.09166 1.14009 6.42409C0.898696 6.75651 0.756934 7.15079 0.731378 7.56082C0.705823 7.97084 0.797532 8.37967 0.99578 8.7395C1.19403 9.09932 1.49062 9.39527 1.85088 9.59273L3.38179 10.44L3.41088 12.9855C3.41562 13.2326 3.48748 13.4739 3.61877 13.6834C3.75005 13.8929 3.93582 14.0627 4.1562 14.1748C4.37658 14.2869 4.62328 14.3369 4.8699 14.3196C5.11653 14.3022 5.3538 14.2182 5.55633 14.0764L7.53451 12.7273L10.3527 14.28C10.6588 14.4488 11.0013 14.5409 11.3508 14.5484C11.7003 14.5559 12.0465 14.4786 12.3596 14.3231C12.6727 14.1676 12.9434 13.9386 13.1487 13.6556C13.354 13.3726 13.4876 13.0441 13.5382 12.6982L14.9091 3.51273C14.9547 3.19916 14.9157 2.8791 14.7962 2.58565C14.6766 2.2922 14.4808 2.03603 14.2291 1.84364Z"} fill={"currentColor"}>
</path>
</g>
</svg>
</span>
<span>
{"官方TG群"}
</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 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"}>
<g>
<path d={"M13.5164 4.22909L9.11636 1.74909C8.77667 1.5559 8.3926 1.45433 8.00182 1.45433C7.61104 1.45433 7.22696 1.5559 6.88727 1.74909L2.48364 4.22909C2.17124 4.40646 1.91143 4.66345 1.73066 4.97389C1.54989 5.28433 1.45462 5.63713 1.45455 5.99636L2.01455 12.5745C2.0267 13.0959 2.24022 13.5924 2.61037 13.9598C2.98051 14.3273 3.47852 14.5371 4 14.5455H12C12.5241 14.54 13.0256 14.3316 13.3993 13.9641C13.7729 13.5965 13.9896 13.0985 14.0036 12.5745L14.5455 6.07636V5.99636C14.5461 5.63698 14.4512 5.28388 14.2703 4.97331C14.0895 4.66274 13.8293 4.40588 13.5164 4.22909ZM9.15273 11.7636H6.86909C6.72443 11.7636 6.58569 11.7062 6.4834 11.6039C6.3811 11.5016 6.32364 11.3628 6.32364 11.2182C6.32364 11.0735 6.3811 10.9348 6.4834 10.8325C6.58569 10.7302 6.72443 10.6727 6.86909 10.6727H9.14545C9.29012 10.6727 9.42886 10.7302 9.53115 10.8325C9.63344 10.9348 9.69091 11.0735 9.69091 11.2182C9.69091 11.3628 9.63344 11.5016 9.53115 11.6039C9.42886 11.7062 9.29012 11.7636 9.14545 11.7636H9.15273Z"} fill={"currentColor"}>
</path>
</g>
</svg>
</span>
<span>
{"回家的路"}
</span>
</a>
{download && <a className={"entry-channel"} href={download} target="_blank" rel="sponsored nofollow noopener noreferrer" data-app-download="" onClick={event => {void openDownload(event);}}>
<span className={"entry-channel-icon"}>
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"18"} height={"18"} viewBox={"0 0 18 18"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
<g>
<path d={"M13.2955 1.22727H4.70455C4.10814 1.22835 3.53647 1.46575 3.11475 1.88748C2.69303 2.3092 2.45563 2.88087 2.45455 3.47727V14.5227C2.45563 15.1191 2.69303 15.6908 3.11475 16.1125C3.53647 16.5342 4.10814 16.7716 4.70455 16.7727H13.2955C13.8922 16.7727 14.4645 16.5357 14.8864 16.1137C15.3084 15.6918 15.5455 15.1195 15.5455 14.5227V3.47727C15.5455 2.88053 15.3084 2.30824 14.8864 1.88628C14.4645 1.46432 13.8922 1.22727 13.2955 1.22727ZM10.8409 13.5H7.15909C6.99634 13.5 6.84026 13.4353 6.72518 13.3203C6.61011 13.2052 6.54546 13.0491 6.54546 12.8864C6.54546 12.7236 6.61011 12.5675 6.72518 12.4525C6.84026 12.3374 6.99634 12.2727 7.15909 12.2727H10.8409C11.0037 12.2727 11.1597 12.3374 11.2748 12.4525C11.3899 12.5675 11.4545 12.7236 11.4545 12.8864C11.4545 13.0491 11.3899 13.2052 11.2748 13.3203C11.1597 13.4353 11.0037 13.5 10.8409 13.5Z"} fill={"currentColor"}>
</path>
</g>
</svg>
</span>
<span>
{"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>}
<p>
{"本站仅提供视频内容与产品服务"}
<br />
{"请遵守当地法律法规,仅限成年人访问"}
</p>
<p>
{"© 2026 "+siteName+" · 官方发布页"}
</p>
</footer>
</main>
</div>
</div>
{download && <AppDownloadDialog download={download} qr={qr} status={status} dialogRef={dialog} onClose={() => dialog.current?.close()} onCopy={() => {void copyDownload();}} />}
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
export function PageTools() {
return (
<>
<aside className={"page-tools"} id={"page-tools"} aria-label={"页面快捷工具"} aria-hidden={"true"} inert>
<div className={"page-tool-download"} id={"page-tool-download"} data-app-download-entry="" hidden>
<a className={"page-tool"} id={"floating-download"} data-app-download="" target="_blank" rel="sponsored nofollow noopener noreferrer" aria-label={"下载 App"}>
<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"}>
</path>
</svg>
</button>
</aside>
</>
);
}
+9
View File
@@ -0,0 +1,9 @@
import type { PageView } from '@/lib/page-data';
export function PlayerPage({view}:{view?:PageView}) {
return (
<>
<main id={"drama-play-page"} className={`shell${view?.overview?' is-overview':''}`} hidden={view?.panel!=='player'} aria-labelledby={"play-detail-title"} dangerouslySetInnerHTML={view?.panel==='player'?{__html:view.html}:undefined} />
</>
);
}
+99
View File
@@ -0,0 +1,99 @@
import type { NavigationEntry } from '@/lib/site-navigation';
import type { navigationPageData } from '@/lib/navigation-page';
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'>}) {
return (
<>
<aside className={"side"} id={"site-sidebar"} aria-label={"网站侧边栏"}>
<div className={"sidebar-header"}>
<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"} />
</span>
</a>
<button className={"sidebar-toggle"} id={"sidebar-toggle"} type={"button"} aria-controls={"site-sidebar"} aria-expanded={"true"} aria-label={"收起侧边栏"} title={"收起侧边栏"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<rect x={"3"} y={"4"} width={"18"} height={"16"} rx={"3"}>
</rect>
<path d={"M9 4v16"}>
</path>
</svg>
</button>
</div>
<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} />
<i className={"side-info-brand-dot"} aria-hidden={"true"}>
</i>
</strong>
<p className={"side-info-slogan"}>
{"精品原创 · 日更爽看"}
</p>
<div className={"policy-links"} aria-label={"平台规则与帮助"}>
<a href={"/info/about-us/"} data-info={"about-us"}>
{"关于我们"}
</a>
<a href={"/info/user-agreement/"} data-info={"user-agreement"}>
{"用户协议"}
</a>
<a href={"/info/privacy-policy/"} data-info={"privacy-policy"}>
{"隐私政策"}
</a>
<a href={"/info/help-center/"} data-info={"help-center"}>
{"常见问题"}
</a>
<a href={"/info/2257/"} data-info={"2257"}>
{"2257"}
</a>
<a href={"/info/dmca/"} data-info={"dmca"}>
{"DMCA"}
</a>
<a href={"/info/feedback/"} data-info={"feedback"}>
{"意见反馈"}
</a>
</div>
<small>
{"© 2026 禁果短剧"}
<br />
{"All rights reserved."}
</small>
</div>
<a className={"collapsed-help"} href={"/info/help-center/"} title={"规则与帮助"} aria-label={"规则与帮助"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<circle cx={"12"} cy={"12"} r={"9"}>
</circle>
<path d={"M9.5 9a2.5 2.5 0 0 1 5 0c0 2-2.5 2-2.5 4m0 3v.1"}>
</path>
</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"}>
<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>}
</div>
</aside>
<SiteNavigation entries={navigation} address={address} surface="mobile" />
</>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { AppDownloadDialog } from "./AppDownloadDialog";
export function SiteDialogs() {
return (
<>
<dialog id={"detail"} aria-label={"短剧预览"}>
</dialog>
<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"}>
<img className={"startup-age-desktop-icon"} src={"/assets/ceabbe508bcc39ab.svg"} width={"96"} height={"96"} alt={"18岁以上"} />
<img className={"startup-age-mobile-icon"} src={"/assets/f948f2f92ec05f6f.svg"} width={"78"} height={"78"} alt={"18岁以上"} />
</div>
<div className={"startup-age-copy"}>
<div className={"startup-age-desktop-copy"}>
<h2 id={"startup-age-title"}>
{"本网站仅限成年人访问"}
</h2>
<div id={"startup-age-description"}>
<p>
{"本网站包含仅限18岁及以上成年人浏览的内容。若您未满18岁,或所在地法律不允许您浏览此类内容,请立即离开。"}
</p>
<p>
{"继续进入即表示您确认已年满18岁,并达到所在地的法定成年年龄。"}
</p>
</div>
</div>
<div className={"startup-age-mobile-copy"}>
<h2 id={"startup-age-mobile-title"}>
{"这是个成人网站"}
</h2>
<p id={"startup-age-mobile-description"}>
{"本网站包含有年龄限制的内容,包括裸体和露骨色情素材的内容。 点击继续即表示您确认您已年满 18 岁,或在您访问本网站时所在的司法管辖区已是成年人"}
</p>
</div>
<div className={"startup-age-actions"}>
<button className={"startup-age-confirm"} type={"button"} autoFocus>
{"我已年满18岁"}
</button>
<button className={"startup-age-leave"} type={"button"}>
{"离开网站"}
</button>
</div>
</div>
</div>
<button className={"startup-close"} type={"button"} aria-label={"离开网站"}>
<img src={"/assets/189d7323288bd44d.svg"} width={"36"} height={"36"} alt={"离开网站"} aria-hidden={"true"} />
</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>
<img className={"startup-ad-image"} width={"400"} height={"602"} alt={"广告"} />
</a>
<p className={"startup-ad-loading"} role={"status"} hidden>{"正在加载下一条广告…"}</p>
<button className={"startup-close"} type={"button"} aria-label={"关闭广告"}>
<picture>
<source media={"(max-width:760px)"} srcSet={"/assets/189d7323288bd44d.svg"} />
<img src={"/assets/189d7323288bd44d.svg"} width={"36"} height={"36"} alt={"关闭广告"} aria-hidden={"true"} />
</picture>
</button>
</dialog>
<dialog id="startup-grid" className="startup-popup" aria-label="应用推广">
<div className="startup-promotions" id="startup-promotions"></div>
<button className="startup-close" type="button" aria-label="关闭应用推广">
<img src="/assets/189d7323288bd44d.svg" width="36" height="36" alt="关闭应用推广" aria-hidden="true" />
</button>
</dialog>
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
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';
import { PlayerPage } from './PlayerPage';
import { PageTools } from './PageTools';
import { SiteDialogs } from './SiteDialogs';
import { Footer } from './Footer';
import { footerPolicyLinks } from '@/lib/footer-links';
import { SiteRuntime } from './SiteRuntime';
import type { SeoBootstrap } from '@/lib/seo';
import { StructuredData } from './StructuredData';
import type { PageBootstrap, PageView, SiteSharedData } from '@/lib/page-data';
export async function SiteFrame({home,error,info,seo,view,homeLoaded=true,session,shared}: {home:HomeData;error?:string;info?:InfoPageDefinition;seo?:SeoBootstrap;view?:PageView;homeLoaded?:boolean;session?:PageBootstrap['session'];shared?:SiteSharedData}) {
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} />
<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} />
<SiteRuntime home={home} homeLoaded={homeLoaded&&!info} seo={seo} page={view?.bootstrap} navigation={navigation} session={session} shared={shared} />
</>;
}
+34
View File
@@ -0,0 +1,34 @@
import type { NavigationEntry, NavigationSurface } from '@/lib/site-navigation';
import { mobileNavigationEntries, navigationIsActive } from '@/lib/site-navigation';
const icons:Record<string,string>={
"home": "M778.85 226.8H639.62v58.49l65.87 54.05-17.61 5.61-183.52-107.69-318.02 186.62V815H404.6V601.86h199.53V815H822.4V423.88l-43.55-25.55V226.8z",
"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"
};
function NavigationIcon({item}:{item:NavigationEntry}) {
const path=Object.hasOwn(icons,item.key)?icons[item.key]:'';
return item.icon?<img className="site-navigation-icon" src="/placeholder.png" data-src={item.icon} alt={item.name+'导航图标'} width={24} height={24} loading="lazy"/>:<svg className="nav-collection-icon" viewBox={path?'0 0 1024 1024':'0 0 24 24'} aria-hidden="true" focusable="false"><path d={path||'M4 4h7v7H4zM13 4h7v7h-7zM4 13h7v7H4zM13 13h7v7h-7z'}/></svg>;
}
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}>
{surface==='sidebar'||surface==='top'?<NavigationIcon item={item}/>:null}<span className={surface==='sidebar'?'side-nav-label':undefined}>{item.name}</span>
</a>;
}
export function SiteNavigation({entries,address='/',surface='sidebar',error=false}:{entries:NavigationEntry[];address?:string;surface?:NavigationSurface;error?:boolean}) {
const items=surface==='mobile'?mobileNavigationEntries(entries):entries;
return <nav className={surface==='top'?'jg-primary-nav':surface==='mobile'?'mobile-pages':undefined} aria-label={surface==='top'?'顶部主导航':surface==='mobile'?'移动端主导航':'主导航'} data-site-navigation={surface}>
{items.map(item=>item.children.length?<details className="site-nav-group" key={item.id} open={surface==='sidebar'&&item.children.some(child=>navigationIsActive(child.href,address,surface))||undefined}>
<summary>{surface==='sidebar'?<NavigationIcon item={item}/>:null}<span className={surface==='sidebar'?'side-nav-label':undefined}>{item.name}</span><span className="site-nav-caret" aria-hidden="true">⌄</span></summary>
<div className="site-nav-children">{item.href?<NavigationLink item={{...item,children:[]}} address={address} surface={surface}/>:null}{item.children.map(child=><NavigationLink key={child.id} item={child} address={address} surface={surface}/>)}</div>
</details>:<NavigationLink key={item.id} item={item} address={address} surface={surface}/>)}
{error?<span className="site-navigation-status" role="status">导航暂不可用</span>:null}
</nav>;
}
+16
View File
@@ -0,0 +1,16 @@
"use client";
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';
/** 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);
}} />;
}
+12
View File
@@ -0,0 +1,12 @@
import { buildPageHead, type SeoBootstrap } from '@/lib/seo';
import { serializeStructuredData } from '@/lib/structured-data';
/** A native script is rendered in the initial HTML, before client routing starts. */
export function StructuredData({seo}:{seo?:SeoBootstrap}) {
if(!seo)return null;
const query=seo.path.indexOf('?');
const pathname=query<0?seo.path:seo.path.slice(0,query),search=query<0?'':seo.path.slice(query);
const data=buildPageHead(pathname,search,seo.environment,seo.content).jsonLd;
if(!data)return null;
return <script id="site-json-ld" type="application/ld+json" data-site-json-ld="" dangerouslySetInnerHTML={{__html:serializeStructuredData(data)}} />;
}
+826
View File
@@ -0,0 +1,826 @@
{
"b71aedc5cfcc6bd5.svg": {
"type": "image/svg+xml",
"bytes": 13415
},
"fdd6fe924244b1f4.jpg": {
"type": "image/jpeg",
"bytes": 103208
},
"19ed41602046c136.svg": {
"type": "image/svg+xml",
"bytes": 294
},
"813abcae48078ac7.svg": {
"type": "image/svg+xml",
"bytes": 320
},
"f3d55d5dd5a321d3.png": {
"type": "image/png",
"bytes": 351
},
"7d05c412943babef.png": {
"type": "image/png",
"bytes": 299
},
"8c1c285ac6bd3b66.svg": {
"type": "image/svg+xml",
"bytes": 323
},
"49cffe02110af446.svg": {
"type": "image/svg+xml",
"bytes": 535
},
"9903882b2da0df62.svg": {
"type": "image/svg+xml",
"bytes": 445
},
"bf82634dbd0f4907.svg": {
"type": "image/svg+xml",
"bytes": 522
},
"c80f072eec53a3ca.svg": {
"type": "image/svg+xml",
"bytes": 980
},
"350ab2f3118693a8.svg": {
"type": "image/svg+xml",
"bytes": 403
},
"16bd37861fd6246c.svg": {
"type": "image/svg+xml",
"bytes": 1038
},
"ce2f84f155d32b0b.png": {
"type": "image/png",
"bytes": 361101
},
"a6ba4b9749786513.png": {
"type": "image/png",
"bytes": 379964
},
"51e809aac4f1e40a.png": {
"type": "image/png",
"bytes": 428525
},
"2d8f1898b668e45e.png": {
"type": "image/png",
"bytes": 377372
},
"b57b58bd55aeee91.png": {
"type": "image/png",
"bytes": 369923
},
"1f0d06c643b6660a.png": {
"type": "image/png",
"bytes": 520101
},
"55f4c0c888a3cee3.png": {
"type": "image/png",
"bytes": 390291
},
"a0cb8cb4c545a0bc.png": {
"type": "image/png",
"bytes": 340922
},
"c568d83ba6e559ac.png": {
"type": "image/png",
"bytes": 412139
},
"47632de987649cda.png": {
"type": "image/png",
"bytes": 438195
},
"9bb14f2dd28ab426.png": {
"type": "image/png",
"bytes": 432638
},
"eb4bee364040817e.png": {
"type": "image/png",
"bytes": 438938
},
"2699fa102e73d8c8.svg": {
"type": "image/svg+xml",
"bytes": 234
},
"92fc7fda98f7db04.svg": {
"type": "image/svg+xml",
"bytes": 217
},
"3482d6856aee853d.svg": {
"type": "image/svg+xml",
"bytes": 271
},
"d2182785de825f85.svg": {
"type": "image/svg+xml",
"bytes": 388
},
"69304455ce1e30ae.svg": {
"type": "image/svg+xml",
"bytes": 393
},
"160ae2de19037355.svg": {
"type": "image/svg+xml",
"bytes": 497
},
"5178e628105b82b4.svg": {
"type": "image/svg+xml",
"bytes": 8997
},
"c9e57990dff7b688.png": {
"type": "image/png",
"bytes": 538
},
"4532d28d6a61c426.svg": {
"type": "image/svg+xml",
"bytes": 1549
},
"a8076c86caaa5bd7.png": {
"type": "image/png",
"bytes": 3936
},
"8837c3e58159bd48.png": {
"type": "image/png",
"bytes": 594853
},
"90e5136c083143d0.svg": {
"type": "image/svg+xml",
"bytes": 907
},
"2d4b4cd3fe1e8795.svg": {
"type": "image/svg+xml",
"bytes": 386
},
"01a1c7fc200c39f2.jpg": {
"type": "image/jpeg",
"bytes": 108105
},
"abe9b9f85f00e37d.jpg": {
"type": "image/jpeg",
"bytes": 119450
},
"720b7aa5a05841ef.jpg": {
"type": "image/jpeg",
"bytes": 124678
},
"98454e9d9e2ef0a1.jpg": {
"type": "image/jpeg",
"bytes": 71719
},
"92a6e44715e728e2.jpg": {
"type": "image/jpeg",
"bytes": 137549
},
"bd44ab8fafaca01f.webp": {
"type": "image/webp",
"bytes": 367788
},
"8466240296112fb5.jpg": {
"type": "image/jpeg",
"bytes": 45808
},
"b73eaf71d4ec80f9.webp": {
"type": "image/webp",
"bytes": 309804
},
"09bbacc482165f7f.jpg": {
"type": "image/jpeg",
"bytes": 39842
},
"dc0366b46a5478f2.webp": {
"type": "image/webp",
"bytes": 258656
},
"bcf06f98cbfa83ee.jpg": {
"type": "image/jpeg",
"bytes": 42172
},
"98ddead2e56f2360.webp": {
"type": "image/webp",
"bytes": 172382
},
"f4a2543ba4fb506b.jpg": {
"type": "image/jpeg",
"bytes": 27603
},
"dff7559d1b382481.webp": {
"type": "image/webp",
"bytes": 266980
},
"b651ac584424e2f2.jpg": {
"type": "image/jpeg",
"bytes": 45194
},
"93d679135d26da60.webp": {
"type": "image/webp",
"bytes": 143570
},
"723fbfaeabe6ca96.jpg": {
"type": "image/jpeg",
"bytes": 26464
},
"a8b9176037760d37.webp": {
"type": "image/webp",
"bytes": 289266
},
"867ae921f79dbe99.webp": {
"type": "image/webp",
"bytes": 333784
},
"96c24a25fd78210c.webp": {
"type": "image/webp",
"bytes": 448958
},
"f90a5717b5768f43.webp": {
"type": "image/webp",
"bytes": 389824
},
"e26ed8036e427ca3.webp": {
"type": "image/webp",
"bytes": 163936
},
"573d7e27b3f6b69b.webp": {
"type": "image/webp",
"bytes": 316158
},
"f2f13d8c5b693bac.webp": {
"type": "image/webp",
"bytes": 256746
},
"4b2300563a47e13e.webp": {
"type": "image/webp",
"bytes": 237728
},
"6594cfd4c90de958.png": {
"type": "image/png",
"bytes": 171582
},
"26bbd61b3e089f86.png": {
"type": "image/png",
"bytes": 65981
},
"d4f324df023454a6.png": {
"type": "image/png",
"bytes": 352560
},
"dc5a4f514353d4c3.png": {
"type": "image/png",
"bytes": 401875
},
"cb1ba9ad10c4d7e0.png": {
"type": "image/png",
"bytes": 128151
},
"f6fd1c865977e25d.png": {
"type": "image/png",
"bytes": 209862
},
"13536e3a41e2f59f.png": {
"type": "image/png",
"bytes": 111921
},
"e41e657a9f3adc5c.png": {
"type": "image/png",
"bytes": 200466
},
"1e03fc1be2cc2dc4.png": {
"type": "image/png",
"bytes": 88342
},
"fd58cd99b50a8dbe.png": {
"type": "image/png",
"bytes": 190795
},
"56f0435f9c47fec3.png": {
"type": "image/png",
"bytes": 335837
},
"93b149e40991d773.png": {
"type": "image/png",
"bytes": 360796
},
"8292db6251d1e129.png": {
"type": "image/png",
"bytes": 142422
},
"812c622409947995.png": {
"type": "image/png",
"bytes": 201172
},
"e4baa97000c6f0fe.png": {
"type": "image/png",
"bytes": 391208
},
"200f9c8479491fb3.png": {
"type": "image/png",
"bytes": 375769
},
"63867cb14798be9f.png": {
"type": "image/png",
"bytes": 161391
},
"dfda4dcfd6a1c3a3.png": {
"type": "image/png",
"bytes": 214326
},
"348a414d41099579.png": {
"type": "image/png",
"bytes": 137365
},
"7c8623882a4c28b7.png": {
"type": "image/png",
"bytes": 191164
},
"addb3b51206a8978.png": {
"type": "image/png",
"bytes": 167950
},
"f7534a32a91cf37b.png": {
"type": "image/png",
"bytes": 61024
},
"cd8b6ba1e9378a10.png": {
"type": "image/png",
"bytes": 322956
},
"1ed1c03f29c9aecc.png": {
"type": "image/png",
"bytes": 299653
},
"14372da0677bc8a2.png": {
"type": "image/png",
"bytes": 246419
},
"cf6f51dd8344bdcf.png": {
"type": "image/png",
"bytes": 504623
},
"71ec6a19ef0e1b01.png": {
"type": "image/png",
"bytes": 336781
},
"0a24767770663c9e.png": {
"type": "image/png",
"bytes": 462611
},
"8b9bfab6cb2e72b9.png": {
"type": "image/png",
"bytes": 352862
},
"3651a2618990bf95.svg": {
"type": "image/svg+xml",
"bytes": 1410
},
"ba17d56edfb047e2.svg": {
"type": "image/svg+xml",
"bytes": 1023
},
"d001d1cc6c2f0ce6.svg": {
"type": "image/svg+xml",
"bytes": 1729
},
"d59d2d7390d88f08.svg": {
"type": "image/svg+xml",
"bytes": 3082
},
"d4ebee2aa88ba9a2.svg": {
"type": "image/svg+xml",
"bytes": 1137
},
"a0c6451fadcb7738.svg": {
"type": "image/svg+xml",
"bytes": 1018
},
"8b1153736cc5dca1.svg": {
"type": "image/svg+xml",
"bytes": 1210
},
"11bae9823678ebdc.svg": {
"type": "image/svg+xml",
"bytes": 2257
},
"2a8580c42090f36c.svg": {
"type": "image/svg+xml",
"bytes": 1062
},
"9a5b6e9db9de76b8.svg": {
"type": "image/svg+xml",
"bytes": 1549
},
"228a0af1662081ac.svg": {
"type": "image/svg+xml",
"bytes": 1781
},
"72f702ca215f597d.svg": {
"type": "image/svg+xml",
"bytes": 474
},
"6dd2c76631816aa2.svg": {
"type": "image/svg+xml",
"bytes": 4440
},
"388ac2896091bdb8.png": {
"type": "image/png",
"bytes": 173198
},
"f19227c0a214f999.png": {
"type": "image/png",
"bytes": 176629
},
"026444e7f0debb8a.png": {
"type": "image/png",
"bytes": 188730
},
"226b263bb72d177e.png": {
"type": "image/png",
"bytes": 150083
},
"2326b439717b8919.png": {
"type": "image/png",
"bytes": 4036
},
"5d32f20e4a4c4b95.jpg": {
"type": "image/jpeg",
"bytes": 148383
},
"0c829f3186dd2aa8.jpg": {
"type": "image/jpeg",
"bytes": 116726
},
"2bad2bb0d4ffcbe9.jpg": {
"type": "image/jpeg",
"bytes": 86934
},
"c8cb1f1f87814ea6.jpg": {
"type": "image/jpeg",
"bytes": 95906
},
"67b33e5481de36bf.jpg": {
"type": "image/jpeg",
"bytes": 160776
},
"b572feb468a57fe7.jpg": {
"type": "image/jpeg",
"bytes": 121966
},
"e2e6cfa02b692824.jpg": {
"type": "image/jpeg",
"bytes": 115060
},
"d4e6ff3babd3ac8c.jpg": {
"type": "image/jpeg",
"bytes": 162053
},
"d88cd6f4785506cf.jpg": {
"type": "image/jpeg",
"bytes": 80125
},
"d11925d521c7a7a7.webp": {
"type": "image/webp",
"bytes": 82790
},
"fd1d767246850d19.webp": {
"type": "image/webp",
"bytes": 88558
},
"01c359ce005dc785.webp": {
"type": "image/webp",
"bytes": 62052
},
"28b276f54dfea226.webp": {
"type": "image/webp",
"bytes": 70478
},
"ee7917333f5f4d49.webp": {
"type": "image/webp",
"bytes": 84448
},
"c612eedfded8baed.webp": {
"type": "image/webp",
"bytes": 86156
},
"c468611c26ac6aa5.webp": {
"type": "image/webp",
"bytes": 70012
},
"43059b3dd536fae4.webp": {
"type": "image/webp",
"bytes": 109492
},
"afc7eccbc7ad2064.webp": {
"type": "image/webp",
"bytes": 75310
},
"81a3adb37f20adbd.webp": {
"type": "image/webp",
"bytes": 122182
},
"180fca29d93502d0.webp": {
"type": "image/webp",
"bytes": 70950
},
"eb003aa14e68556b.webp": {
"type": "image/webp",
"bytes": 100104
},
"a0f163d35a28d0c6.webp": {
"type": "image/webp",
"bytes": 64888
},
"27160b85d8347743.webp": {
"type": "image/webp",
"bytes": 74406
},
"27877a11ee8315b3.webp": {
"type": "image/webp",
"bytes": 76998
},
"f73e1cf1db670dc9.webp": {
"type": "image/webp",
"bytes": 76484
},
"cd2c7b933f47bf37.webp": {
"type": "image/webp",
"bytes": 91530
},
"6600cde1d7a82529.webp": {
"type": "image/webp",
"bytes": 117614
},
"e01d276237418eec.webp": {
"type": "image/webp",
"bytes": 108258
},
"7ae5ed16a625703c.webp": {
"type": "image/webp",
"bytes": 96430
},
"6472cc642da82b83.webp": {
"type": "image/webp",
"bytes": 79660
},
"2864103ed160195a.webp": {
"type": "image/webp",
"bytes": 129286
},
"d4b3692a7af5718a.webp": {
"type": "image/webp",
"bytes": 119046
},
"6822ddc74c30d987.webp": {
"type": "image/webp",
"bytes": 86910
},
"af4370e9c87f0f75.webp": {
"type": "image/webp",
"bytes": 48058
},
"2419ebe0d21ada3d.webp": {
"type": "image/webp",
"bytes": 79450
},
"2a92e7ab0cae2c3a.webp": {
"type": "image/webp",
"bytes": 99938
},
"c792d0645e28b789.webp": {
"type": "image/webp",
"bytes": 102032
},
"febda32da630fa68.webp": {
"type": "image/webp",
"bytes": 136546
},
"a95a5642fd778796.webp": {
"type": "image/webp",
"bytes": 72204
},
"5dc6629665de4078.webp": {
"type": "image/webp",
"bytes": 126222
},
"2c77547c257b8f70.webp": {
"type": "image/webp",
"bytes": 92482
},
"6af0eb8975b2a879.webp": {
"type": "image/webp",
"bytes": 139066
},
"255fc2eddadf06d2.webp": {
"type": "image/webp",
"bytes": 132040
},
"537b2be75c1688b3.webp": {
"type": "image/webp",
"bytes": 136286
},
"0ee5776b9c09c740.webp": {
"type": "image/webp",
"bytes": 92782
},
"35b4e9820e5272f1.webp": {
"type": "image/webp",
"bytes": 63326
},
"0dcb9a25191e606e.webp": {
"type": "image/webp",
"bytes": 136674
},
"3f0f8131360bc200.webp": {
"type": "image/webp",
"bytes": 114538
},
"f92464cf99366ea2.webp": {
"type": "image/webp",
"bytes": 93710
},
"d66d6df7a026c4e6.webp": {
"type": "image/webp",
"bytes": 80522
},
"0e23b0d87a4b6e04.webp": {
"type": "image/webp",
"bytes": 60886
},
"22ec0b5db3ff7cd5.webp": {
"type": "image/webp",
"bytes": 109280
},
"2ff6d2000e27356c.webp": {
"type": "image/webp",
"bytes": 130872
},
"366eac80d0309a2e.webp": {
"type": "image/webp",
"bytes": 138944
},
"de7a7edd54482f6e.webp": {
"type": "image/webp",
"bytes": 123090
},
"c1cb918e48d35bd3.webp": {
"type": "image/webp",
"bytes": 97386
},
"144f33adfb6673f6.svg": {
"type": "image/svg+xml",
"bytes": 422
},
"929f93aa7c28c5c7.svg": {
"type": "image/svg+xml",
"bytes": 1035
},
"0f0ef061135a13f0.svg": {
"type": "image/svg+xml",
"bytes": 1788
},
"2fdedce06a90a6a1.svg": {
"type": "image/svg+xml",
"bytes": 1283
},
"5652fcead17188e8.svg": {
"type": "image/svg+xml",
"bytes": 1016
},
"2bead9ea5c435c98.svg": {
"type": "image/svg+xml",
"bytes": 1620
},
"7848816d05aa8591.svg": {
"type": "image/svg+xml",
"bytes": 1140
},
"48deec4cd52f5397.png": {
"type": "image/png",
"bytes": 533460
},
"56bfd436158003be.png": {
"type": "image/png",
"bytes": 831776
},
"f0e3cca7f186c715.png": {
"type": "image/png",
"bytes": 559815
},
"bd8f7fd04bc0b709.png": {
"type": "image/png",
"bytes": 623376
},
"33256436588298b0.png": {
"type": "image/png",
"bytes": 265674
},
"bd69a2c88e8d17cc.png": {
"type": "image/png",
"bytes": 493411
},
"9df0afe903e8d542.svg": {
"type": "image/svg+xml",
"bytes": 832
},
"7f137587edc271d0.png": {
"type": "image/png",
"bytes": 2076486
},
"fcbe47a58d6fd9e7.svg": {
"type": "image/svg+xml",
"bytes": 243
},
"6fa3b8cccc07ed57.svg": {
"type": "image/svg+xml",
"bytes": 665
},
"cea8731b980a14a0.png": {
"type": "image/png",
"bytes": 446903
},
"18da69acb7776e92.svg": {
"type": "image/svg+xml",
"bytes": 2543
},
"29b976d622f7c5d1.svg": {
"type": "image/svg+xml",
"bytes": 4323
},
"189d7323288bd44d.svg": {
"type": "image/svg+xml",
"bytes": 583
},
"7ac5104cd3d3e49c.png": {
"type": "image/png",
"bytes": 1063186
},
"6b84a86651a478fe.svg": {
"type": "image/svg+xml",
"bytes": 782
},
"8eb3c37a26ca792b.svg": {
"type": "image/svg+xml",
"bytes": 1906
},
"b90a9ab6918d47c7.svg": {
"type": "image/svg+xml",
"bytes": 806
},
"897547dcbff56113.svg": {
"type": "image/svg+xml",
"bytes": 3521
},
"87eeb5bf339a03f0.svg": {
"type": "image/svg+xml",
"bytes": 3037
},
"5206304b0955dedc.svg": {
"type": "image/svg+xml",
"bytes": 6968
},
"40803f26f3c0acbb.svg": {
"type": "image/svg+xml",
"bytes": 9890
},
"9ccddd39c1fec40c.svg": {
"type": "image/svg+xml",
"bytes": 1322913
},
"ceabbe508bcc39ab.svg": {
"type": "image/svg+xml",
"bytes": 2543
},
"f948f2f92ec05f6f.svg": {
"type": "image/svg+xml",
"bytes": 4323
},
"d99080bbba89c515.png": {
"type": "image/png",
"bytes": 2063045
},
"2d24f60e409ee818.svg": {
"type": "image/svg+xml",
"bytes": 243
},
"974b889940739032.svg": {
"type": "image/svg+xml",
"bytes": 665
},
"f2ef01f956fdcd80.png": {
"type": "image/png",
"bytes": 470046
},
"77dd91d1ecd3d16c.png": {
"type": "image/png",
"bytes": 376038
},
"7c46ae4811eea7e9.png": {
"type": "image/png",
"bytes": 431758
},
"2a5a7edaf95ff943.png": {
"type": "image/png",
"bytes": 422774
},
"fce0ea0b76f1a631.svg": {
"type": "image/svg+xml",
"bytes": 491
}
}
+26
View File
@@ -0,0 +1,26 @@
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
@@ -0,0 +1,381 @@
/**
* 广告等事件上报前补齐公共字段。
* 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);
};
}
}
+42
View File
@@ -0,0 +1,42 @@
import { isIP } from 'node:net';
import { buildUserAgent, userAgentSid } from './user-agent';
import { trackingHeaders } from './tracking-headers';
type HeaderSource=Pick<Headers,'get'>;
export function clientIp(source:HeaderSource):string {
const real=source.get('x-real-ip')?.trim()||'';
if(isIP(real))return real;
const forwarded=source.get('x-forwarded-for')?.split(',')[0]?.trim()||'';
return isIP(forwarded)?forwarded:'';
}
/** Session device from drama_device; business DevID must not be replaced by the SDK fingerprint. */
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{
const value=decodeURIComponent(raw).trim();
return /^[a-zA-Z0-9_-]{1,100}$/.test(value)?value:'';
}catch{return '';}
}
/** Keep SDK XUA shape but pin DevID to the guest/login device used for Authorization. */
export function withBusinessDevId(xua:string,device:string):string {
if(!device||!/(?:^|;)\s*DevID=/.test(xua))return xua;
return xua.replace(/(?:^|;)\s*DevID=[^;]*/,(match)=>match.replace(/DevID=[^;]*/,'DevID='+device));
}
/** Share the reference site's network/UA rules across SSR, API and media requests. */
export function clientHeaders(source:HeaderSource|undefined,path='',method='GET',overrides:{userAgent?:string;clientIp?:string}={}):Record<string,string> {
const ua=(source?.get('user-agent')||overrides.userAgent||'').trim();
const ip=source?clientIp(source):overrides.clientIp||'';
const cookie=source?.get('cookie')||'';
const xua=source?.get('x-user-agent')?.trim()||buildUserAgent(ua,userAgentSid(cookie));
const headers:Record<string,string>={
'X-User-Agent':withBusinessDevId(xua,businessDeviceId(cookie)),
};
if(ua)headers['User-Agent']=ua;
if(ip)headers['X-Real-IP']=ip;
if(source)Object.assign(headers,trackingHeaders(source,path,method));
return headers;
}
+36
View File
@@ -0,0 +1,36 @@
export const DEFAULT_API_BASE_URL = 'http://172.104.181.99:26792';
export const UPSTREAM_HOP_HEADER = 'x-jgdj-upstream';
export interface ApiRequestContext {
headers: Pick<Headers, 'get'>;
url?: string;
}
function configuredApiUrl(): string {
const environment = process.env.APP_ENV || (process.env.NODE_ENV === 'production' ? 'production' : 'development');
const fallback = environment === 'production' ? 'http://139.162.49.252:21040' : DEFAULT_API_BASE_URL;
return (process.env.UPSTREAM_API_URL ?? process.env.API_BASE_URL ?? fallback).trim();
}
export function usesCurrentApiOrigin(): boolean {
return configuredApiUrl() === 'current';
}
/** Resolve each visitor's origin at request time; never store a request host globally. */
export function getApiBaseUrl(request?: ApiRequestContext): string {
const value = configuredApiUrl();
if (value === 'current') {
const incoming = request?.url ? new URL(request.url) : undefined;
const host = (request?.headers.get('host') || request?.headers.get('x-forwarded-host')?.split(',')[0] || incoming?.host || '').trim();
const protocol = (request?.headers.get('x-forwarded-proto')?.split(',')[0] || incoming?.protocol.replace(':', '') || 'https').trim();
if (!host || /[\s,/\\?#@]/.test(host) || !['http', 'https'].includes(protocol)) throw new Error('当前域名接口模式缺少有效的请求域名或协议');
const origin = new URL(protocol + '://' + host);
if (origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash) throw new Error('当前域名接口地址无效');
return origin.origin;
}
try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('Invalid upstream');
return url.href.replace(/\/+$/, '');
} catch { throw new Error('UPSTREAM_API_URL 必须是有效的 HTTP(S) 接口地址'); }
}
+73
View File
@@ -0,0 +1,73 @@
import { clientHeaders } from './client-headers';
import { getApiBaseUrl, UPSTREAM_HOP_HEADER } from './config';
const CACHE = {'Cache-Control': 'no-store'};
const REDIRECTS = new Set([301,302,303,307,308]);
/** APIs differ on the key route's trailing slash. Follow only that same-origin change. */
async function fetchMedia(target: URL, init: RequestInit, key: boolean): Promise<Response> {
const response = await fetch(target, {...init,redirect:key?'manual':'error'});
if (!key || !REDIRECTS.has(response.status)) return response;
const location = response.headers.get('location');
await response.body?.cancel();
const destination = location ? new URL(location,target) : undefined;
const pathname = target.pathname.replace(/\/$/,'');
if (!destination || !/^\/(?:new)?enkey$/.test(pathname) || destination.origin !== target.origin ||
destination.username || destination.password || destination.hash || destination.pathname === target.pathname ||
destination.pathname.replace(/\/$/,'') !== pathname || destination.search && destination.search !== target.search) {
throw new Error('Invalid key redirect');
}
// Keep signed bytes intact, including servers whose canonical redirect omits the query.
destination.search = target.search;
const result = await fetch(destination,{...init,redirect:'manual'});
if (REDIRECTS.has(result.status)) {
await result.body?.cancel();
throw new Error('Repeated key redirect');
}
return result;
}
/** Signed media URLs carry their own token. Never forward application credentials. */
export async function proxyMedia(request: Request, path: string, key = false): Promise<Response> {
const cache = key ? {...CACHE,'Access-Control-Allow-Origin':'*'} : CACHE;
if (request.headers.has(UPSTREAM_HOP_HEADER)) return new Response('上游媒体请求返回本站,请检查 Nginx 的媒体转发配置', {status:502,headers:cache});
const headers = new Headers({Accept: '*/*',...clientHeaders(request.headers,path,request.method),[UPSTREAM_HOP_HEADER]:'1'});
const range = request.headers.get('range');
if (range && !key) headers.set('Range', range);
try {
const target = new URL(path, getApiBaseUrl(request));
target.search = new URL(request.url).search;
const response = await fetchMedia(target, {
headers, cache: 'no-store',
signal: AbortSignal.any([request.signal, AbortSignal.timeout(25000)]),
},key);
if (!response.ok) {
await response.body?.cancel();
return new Response(null, {status: response.status, headers: cache});
}
const type = response.headers.get('content-type') || '';
const playlist = /\.m3u8$/i.test(target.pathname) || /mpegurl/i.test(type);
const output = new Headers(cache);
for (const name of ['Content-Range','Accept-Ranges']) {
const value = response.headers.get(name);if (value) output.set(name,value);
}
if (key || /json/i.test(type) || playlist && response.status === 200) {
const buffer = await response.arrayBuffer();
const text = new TextDecoder().decode(buffer);
let businessError = false;
if (/^\s*\{/.test(text)) {
try {const code = JSON.parse(text).code;businessError = code != null && ![0,200].includes(Number(code));}
catch {businessError = !key || /json/i.test(type);}
}
if (businessError || /json/i.test(type) || key && buffer.byteLength !== 16 || playlist && !text.trimStart().startsWith('#EXTM3U')) {
return new Response('视频资源暂不可用', {status: 502, headers: cache});
}
output.set('Content-Type', key ? 'application/octet-stream' : 'application/vnd.apple.mpegurl');
return new Response(buffer, {status: response.status, headers: output});
}
output.set('Content-Type', /\.ts$/i.test(target.pathname) ? 'video/mp2t' : /\.mp4$/i.test(target.pathname) ? 'video/mp4' : type || 'application/octet-stream');
return new Response(response.body, {status: response.status, headers: output});
} catch {
return new Response('视频资源请求失败', {status: 502, headers: cache});
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextResponse, type NextRequest } from 'next/server';
import { randomUUID } from 'node:crypto';
import { isIP } from 'node:net';
import { upstream } from './server';
import { EXPIRED_CODES } from './protocol';
import { publicUser } from './public-user';
import type { User } from './types';
export const SSR_USER_HEADER='x-drama-ssr-user';
export const SSR_SESSION_HEADER='x-drama-ssr-session';
const expired=(code:number)=>code===401||EXPIRED_CODES.has(code);
/**
* Align with jha-1048 fetchServerAuthInfo:
* - SSR only refreshes getInfo when a cookie token exists
* - never call auth/guest here (client GuestAuthBootstrap owns guest login)
* - reading drama_bearer (client-written) keeps Nginx→Go deployments in sync
*/
export async function preparePageSession(request:NextRequest,required:boolean) {
const headers=new Headers(request.headers);
headers.delete(SSR_USER_HEADER);headers.delete(SSR_SESSION_HEADER);
if(!required)return NextResponse.next({request:{headers}});
const cookies=new Map(request.cookies.getAll().map(item=>[item.name,item.value]));
const secure=request.nextUrl.protocol==='https:',options={httpOnly:true,sameSite:'lax' as const,secure,path:'/',maxAge:7*86400};
const device=/^[a-zA-Z0-9_-]{1,100}$/.test(cookies.get('drama_device')||'')?cookies.get('drama_device')!:randomUUID();
const channel=(cookies.get('drama_channel')||request.nextUrl.searchParams.get('dc')||request.nextUrl.searchParams.get('ch')||request.nextUrl.searchParams.get('pc')||process.env.API_CHANNEL||'').trim().slice(0,100);
const address=(request.headers.get('x-real-ip')||request.headers.get('x-forwarded-for')?.split(',')[0]||'').trim();
const identity={request,device,userAgent:request.headers.get('user-agent')||'',clientIp:isIP(address)?address:undefined,timeoutMs:5000};
// Prefer BFF HttpOnly session; fall back to client drama_bearer (jha-1048 cookie pattern).
let token=cookies.get('drama_session')||cookies.get('drama_bearer')||'';
let registered=cookies.get('drama_registered')==='1';
let user:User|null=null;
let state=registered?'login-required':'unavailable';
const updates=new Map<string,string>();
const set=(key:string,value:string)=>{if(value)cookies.set(key,value);else cookies.delete(key);updates.set(key,value);};
set('drama_device',device);if(channel)set('drama_channel',channel);
try{
if(token){
let result;
try{result=await upstream<User>('auth/getInfo',{...identity,token});}
catch(error){if(error&&typeof error==='object'&&'code' in error&&expired(Number(error.code)))result={code:Number(error.code),data:null};else throw error;}
if(result.code===200){user=publicUser(result.data);if(!user)throw new Error('账号信息无效');token=result.data?.token||token;}
else if(expired(result.code)){
set('drama_session','');
set('drama_bearer','');
set('drama_registered','');
token='';
if(registered)state='login-required';
}
else throw new Error('账号服务暂不可用');
}
// jha-1048: no cookie token → leave unavailable; client ensureGuestSession will guest.
if(user){
registered=user.isRegister===true||user.type===1||registered;
if(registered)user={...user,isRegister:true};
// Mirror token to drama_bearer (jha-1048 cookie) so client skips guest on the next paint.
set('drama_session',token);set('drama_bearer',token);set('drama_registered',registered?'1':'0');set('drama_device',device);if(channel)set('drama_channel',channel);
headers.set(SSR_USER_HEADER,Buffer.from(JSON.stringify(user)).toString('base64url'));state='ready';
}
}catch{/* Render a retry state; authentication failure must never expose a partial private page. */}
headers.set(SSR_SESSION_HEADER,state);
headers.set('cookie',[...cookies].map(([key,value])=>`${key}=${encodeURIComponent(value)}`).join('; '));
const response=NextResponse.next({request:{headers}});
response.headers.set('Cache-Control','private, no-store');
for(const [key,value] of updates){
const clientVisible=key==='drama_device'||key==='drama_channel'||key==='drama_bearer';
response.cookies.set(key,value,{...options,...(clientVisible?{httpOnly:false,maxAge:key==='drama_bearer'?7*86400:365*86400}:{}),...(!value?{maxAge:0}:{})});
}
return response;
}
+59
View File
@@ -0,0 +1,59 @@
import type { Envelope, HomeBlock, HomeData, HomeDisplayStyle, HomeModule } from "./types";
export const EXPIRED_CODES = new Set([1000, 1002, 1012, 6001]);
export class ApiError extends Error {
constructor(message: string, public code = 500) { super(message); this.name = "ApiError"; }
}
export function recordList<T extends object>(value: unknown): T[] {
return Array.isArray(value) ? value.filter(item => item !== null && typeof item === 'object' && !Array.isArray(item)) : [];
}
/** A–D display dramas; E displays topics. Reserved or incompatible styles keep the default layout. */
export function homeBlockStyle(block:Pick<HomeBlock,'style'|'type'>):HomeDisplayStyle|undefined {
const style=typeof block.style==='string'?block.style.trim().toUpperCase():'';
if(block.type===3)return style==='E'?'E':undefined;
if(block.type===1&&['A','B','C','D'].includes(style))return style as HomeDisplayStyle;
return undefined;
}
/** Share preview limits between visible homepage cards and structured data; full lists remain intact. */
export function homePreviewItems<T extends object>(block:HomeBlock,value:unknown):T[] {
const limit=typeof block.limit==='number'&&Number.isSafeInteger(block.limit)&&block.limit>=0?block.limit:undefined;
return recordList<T>(value).slice(0,limit);
}
/** Prefer the current Swagger modules; legacy snapshots remain readable during rollout. */
export function homeBlocks(home:HomeData|null|undefined):HomeBlock[] {
if(home&&Object.hasOwn(home,'modules'))return recordList<HomeModule>(home.modules).filter(module=>[1,2].includes(module.moduleType)).map(module=>({id:module.id,name:module.title,slug:module.slug,desc:module.desc,style:module.style,label:module.label,limit:module.limit,rank:module.rank,type:module.moduleType===2?3:1,status:true,list:recordList(module.list),themeList:recordList(module.topicList),topicKind:'theme'}));
return recordList<HomeBlock>(home?.homeConfigData).filter(block=>block.status&&[1,3].includes(block.type));
}
// The reference service accepts null, empty, and double-encoded JSON data.
export function decodeEnvelope<T>(value: unknown): Envelope<T> {
if (!value || typeof value !== "object" || !Number.isFinite(Number((value as {code?: unknown}).code))) {
throw new ApiError("接口返回格式不正确,请稍后重试", 502);
}
const envelope = value as Envelope<unknown>;
let data = envelope.data;
if (typeof data === "string") {
const text = data.trim();
if (!text) data = null;
else if (/^[{[]/.test(text)) {
try { data = JSON.parse(text); } catch { throw new ApiError("接口数据解析失败", 502); }
}
}
return { code: Number(envelope.code), data: data as T, msg: envelope.msg, tip: envelope.tip };
}
export function safeUrl(value: unknown, base?: string): string {
if (typeof value !== "string" || !value.trim()) return "";
try {
const url = new URL(value, base);
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password ? url.href : "";
} catch { return ""; }
}
export function formatCount(value = 0): string {
return value >= 10000 ? `${(value / 10000).toFixed(1).replace(/\.0$/, "")}万` : Math.max(0, value).toLocaleString("zh-CN");
}
+51
View File
@@ -0,0 +1,51 @@
interface Entry {expires:number;value:unknown}
/** Public API payloads only. Never store credentials, personalized data or response cookies. */
export class PublicDataCache {
private entries=new Map<string,Entry>();
private pending=new Map<string,Promise<unknown>>();
constructor(private clock=Date.now,private limit=128){}
async read<T>(key:string,ttl:number,load:()=>Promise<T>,options:{stale?:boolean;accept?:(value:T)=>boolean}={}):Promise<T> {
if(ttl<=0)return load();
const saved=this.entries.get(key);
if(saved&&saved.expires>this.clock())return structuredClone(saved.value) as T;
let task=this.pending.get(key) as Promise<T>|undefined;
if(!task){
task=(async()=>{
try{
const value=await load();
if(options.accept&&!options.accept(value))return options.stale&&saved?structuredClone(saved.value) as T:value;
this.entries.delete(key);
this.entries.set(key,{expires:this.clock()+ttl,value:structuredClone(value)});
while(this.entries.size>this.limit)this.entries.delete(this.entries.keys().next().value!);
return value;
}catch(error){if(options.stale&&saved)return structuredClone(saved.value) as T;throw error;}
})();
this.pending.set(key,task);
void task.finally(()=>{if(this.pending.get(key)===task)this.pending.delete(key);}).catch(()=>{});
}
return structuredClone(await task);
}
}
const policies:Record<string,{ttl:number;stale?:boolean}>={
'config/info':{ttl:60000,stale:true},
'config/footer/info':{ttl:60000},
'config/richtext/list':{ttl:60000},
'navigation/header':{ttl:60000},
'translation/category/list':{ttl:60000},
'playlet/tag/options':{ttl:60000},
'playlet/home':{ttl:30000,stale:true},
};
/** A bounded override permits deterministic tests and explicit cache disabling. */
export function publicCachePolicy(path:string,override=process.env.PUBLIC_DATA_CACHE_TTL_MS) {
const policy=policies[path];if(!policy)return;
const value=override===undefined?policy.ttl:Number(override);
return {...policy,ttl:Number.isFinite(value)?Math.max(0,Math.min(value,policy.ttl)):policy.ttl};
}
// Reuse the same cache when Next loads this module for pages and Route Handlers.
const registry=globalThis as typeof globalThis&{__dramaPublicCache?:PublicDataCache};
export const publicDataCache=registry.__dramaPublicCache??=new PublicDataCache();
+13
View File
@@ -0,0 +1,13 @@
import type { User } from './types';
/** Explicit account DTO: credentials and arbitrary upstream properties never enter HTML. */
export function publicUser(value:unknown):User|null {
if(!value||typeof value!=='object')return null;
const source=value as Record<string,unknown>;
if(!Number.isSafeInteger(source.userId)||Number(source.userId)<=0)return null;
const user:Record<string,unknown>={userId:source.userId};
for(const key of ['accountNo','nickName','avatar','introduction','email','currentVipExpire'])if(typeof source[key]==='string')user[key]=source[key].slice(0,key==='avatar'?2048:300);
for(const key of ['type','recharge','vipType'])if(typeof source[key]==='number'&&Number.isFinite(source[key]))user[key]=source[key];
for(const key of ['isRegister','hasPassword'])if(typeof source[key]==='boolean')user[key]=source[key];
return user as unknown as User;
}
+96
View File
@@ -0,0 +1,96 @@
import { ApiError, decodeEnvelope } from "./protocol";
import type { Envelope, HeaderNavigation, HomeData } from "./types";
import { cache } from "react";
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';
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/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",
];
const POST_PATHS = [
"auth/guest", "auth/login", "auth/register", "auth/resetPassword", "auth/profile",
"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",
];
export function allowedRequest(path: string, method: string): boolean {
return (method === "GET" ? GET_PATHS : method === "POST" ? POST_PATHS : []).includes(path);
}
export async function getServerApiBaseUrl(request?: ApiRequestContext): Promise<string> {
return getApiBaseUrl(request || (usesCurrentApiOrigin() ? {headers: await headers()} : undefined));
}
export async function upstream<T>(path: string, options: {method?: string; body?: unknown; token?: string; device?: string; userAgent?: string; clientIp?: string; tracking?:Record<string,string>; timeoutMs?:number; request?:ApiRequestContext} = {}): Promise<Envelope<T>> {
if(!options.request){
// Public SSR reads need the visitor's UA/IP too. Scripts may run outside a request.
try{options={...options,request:{headers:await headers()}};}catch{}
}
const url=new URL(`/api/app/${path}`,await getServerApiBaseUrl(options.request));
const policy=publicCachePolicy(url.pathname.slice('/api/app/'.length));
if(policy&&(!options.method||options.method==='GET')&&!options.token&&options.body===undefined&&!Object.keys(options.tracking||{}).length){
url.searchParams.sort();
return publicDataCache.read(url.href,policy.ttl,()=>requestUpstream<T>(url,options),{stale:policy.stale,accept:result=>result.code===200&&!!result.data&&typeof result.data==='object'&&!Array.isArray(result.data)});
}
return requestUpstream<T>(url,options);
}
async function requestUpstream<T>(url:URL,options:Parameters<typeof upstream>[1]={}):Promise<Envelope<T>> {
const headers: Record<string,string> = {
Accept: "application/json", ...clientHeaders(options.request?.headers,url.pathname.replace('/api/app/',''),options.method||'GET',options),
[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];
const multipart = options.body instanceof FormData;
if (options.body && !multipart) headers["Content-Type"] = "application/json";
let response: Response;
try {
response = await fetch(url, {
method: options.method || "GET", headers, cache: "no-store", redirect: "error",
body: options.body ? multipart ? options.body as FormData : JSON.stringify(options.body) : undefined,
signal: AbortSignal.timeout(options.timeoutMs||20000),
});
} catch (error) {
const timeout = error instanceof Error && (error.name === "TimeoutError" || error.name === "AbortError");
throw new ApiError(timeout ? "接口请求超时,请稍后重试" : "接口连接失败,请稍后重试", timeout ? 504 : 502);
}
if (!response.ok) {
if (response.status === 502 && response.headers.get(UPSTREAM_HOP_HEADER) === '1') throw new ApiError('上游请求返回本站,请检查 Nginx 的 /api/app/ 后端转发配置',502);
throw new ApiError(`接口暂时不可用(${response.status})`, response.status);
}
try { return decodeEnvelope<T>(await response.json()); }
catch (error) { if (error instanceof ApiError) throw error; throw new ApiError("接口返回数据无法解析", 502); }
}
export const getHome = cache(async (): Promise<{data: HomeData; error?: string}> => {
try {
const result = await upstream<HomeData>("playlet/home");
if (result.code !== 200) throw new ApiError(result.tip || result.msg || "首页加载失败",result.code);
if (!result.data || typeof result.data !== 'object' || Array.isArray(result.data)) throw new ApiError('首页返回数据无效',502);
return {data: result.data};
} catch (error) { return {data: {}, error: error instanceof Error ? error.message : "首页加载失败"}; }
});
export const getHeaderNavigation=cache(async ():Promise<{data:HeaderNavigation;error?:boolean}>=>{
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};
}catch{return {data:{categoryList:[]},error:true};}
});
+38
View File
@@ -0,0 +1,38 @@
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;
}
+37
View File
@@ -0,0 +1,37 @@
export interface SeoInfo { title?: string; description?: string; keywords?: string[] | string; keyworld?: string[] | string; webTitle?: string; webDescription?: string; webKeyworld?: string[] | string; webSiteName?: string; canonicalUrl?: string }
export interface NavigationCategory { id?:number; name?: string; icon?:string; urlValue?: string; categoryType?: number; translationList?:NavigationCategory[]|null; seoInfo?: SeoInfo }
export interface HeaderNavigation { categoryList?:NavigationCategory[]|null }
export interface Advertisement { id: string; title: string; images: string; link: string; advertiseCode?: string; location?: number; advertiseLocationCode?: string; advertiseLocationName?: string }
export type Advertisements = Record<string, Advertisement[] | null>;
export interface Drama {
mediaId?: string; themeIds?: number[]; recommendTraceId?: string; recommendId?: string; recommendTraceInfo?: string;
id: number; title: string; playName?: string; slug?: string; desc?: string; description?: string;
cover?: string; horizontalCover?: string; banner?: string; coverImg?: string;
totalEpisode?: number; tagNames?: string[]; tagIds?: number[]; themeNames?: string[]; tagSlugs?: string[]; themeSlugs?: string[];
countLike?: number; countCollect?: number; countBrowse?: number; heatValue?: number;
isNew?: boolean; isFeatured?: boolean; showOnBanner?: boolean; isOriginal?: boolean; isRecommend?: boolean; showOriginal?: boolean; isCollect?: number; isLike?: number;
onlineAt?: string; mediaCreatedAt?: string; mediaUpdatedAt?: string; createdAt?: string; updatedAt?: string; addedTime?: string; anchor?: string; watchTimes?: number;
updateStatus?: number; operateType?: number; seoInfo?: SeoInfo;
}
export interface Topic { id: number; name: string; desc?: string; icon?: string; covers?: string[]; playletCount?: number; list?: Drama[]; slug?: string; seoInfo?: SeoInfo }
export type HomeDisplayStyle = 'A'|'B'|'C'|'D'|'E';
export interface HomeBlock { id: number; name: string; slug?: string; desc?: string; style?: string; label?: string; limit?: number; moreUrl?: string; moreCategoryId?: number; rank?: number; type: number; status: boolean; sortType?: number; list?: Drama[]; videoList?: Drama[]; themeList?: Topic[]; topicKind?: 'topics'|'theme'; seoInfo?: SeoInfo }
export interface HomeModule { id:number; title:string; slug?:string; desc?:string; moduleType:number; dataType?:number; rank?:number; categoryId?:number; label?:string; style?:string; limit?:number; cardBackgroundImage?:string; recommendType?:number; list?:Drama[]; topicList?:Topic[] }
export interface TopicGroup {id:number;name:string;desc?:string;rank?:number;totalCount:number;list?:Topic[]}
export interface HomeData { modules?: HomeModule[]; homeBannerData?: Drama[]; homeConfigData?: HomeBlock[]; hotSearch?: {keyword:string;count?:number}[]; announcements?: {id:number; title:string; content:string}[]; advitise?: Advertisements; seoInfo?: SeoInfo }
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 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 } }
export interface RichTextItem { id?: number; title?: string; desc?: string; content?: string }
export interface RichTextListResponse { list?: RichTextItem[] | null }
export interface Envelope<T> { code: number; data: T; msg?: string; tip?: string }
+46
View File
@@ -0,0 +1,46 @@
/** Match jha-1045-hgdjnet-web/pub/common/useragent/build.go. */
export function buildUserAgent(rawUA:string,sid='',version='1.0.0'):string {
const ua=rawUA.trim();
let brand='',model='',device='desktop',system='Other',systemVersion='';
if(/iPhone|iPad|iPod/.test(ua)){
brand='Apple';system='iOS';systemVersion=(ua.match(/OS (\d+[_\d]*) like Mac OS X/)?.[1]||'').replaceAll('_','.');
model=ua.includes('iPad')?'iPad':ua.includes('iPod')?'iPod':'iPhone';device=model==='iPad'?'tablet':'mobile';
}else if(ua.includes('Android')){
system='Android';systemVersion=ua.match(/Android ([\d.]+)/)?.[1]||'';
model=(ua.match(/Android\s+[\d.]+;\s*([^;)]+?)(?:\s+Build\/|\))/)?.[1]||'').trim();
const value=model+' '+ua;
const brands:[RegExp,string][]=[[/Build\/HONOR|\bHONOR\b/i,'Honor'],[/Build\/HUAWEI|HUAWEI|HW-/i,'Huawei'],[/SM-|samsung|Galaxy/i,'Samsung'],[/Pixel/i,'Google'],[/Redmi|POCO|XiaoMi|\bMi\b/i,'Xiaomi'],[/OPPO|\bCPH\d{3,4}/i,'OPPO'],[/vivo|\bV\d{4}/i,'vivo'],[/OnePlus|ONEPLUS/i,'OnePlus'],[/realme|\bRMX\d{3,4}/i,'realme']];
brand=brands.find(([pattern])=>pattern.test(value))?.[1]||'';device=ua.includes('Mobile')?'mobile':'tablet';
}else if(ua.includes('Windows')){
system='Windows';systemVersion=ua.match(/Windows NT ([\d.]+)/)?.[1]||'';
}else if(/Mac OS X|Macintosh/.test(ua)){
system='macOS';brand='Apple';model='Mac';systemVersion=(ua.match(/Mac OS X ([\d_]+)/)?.[1]||'').replaceAll('_','.');
}else if(ua.includes('Linux'))system='Linux';
const browsers:[RegExp,string][]=[[/Edg\/([\d.]+)/i,'Edge'],[/(?:OPR|Opera)\/([\d.]+)/i,'Opera'],[/Chrome\/([\d.]+)/i,'Chrome'],[/Version\/([\d.]+).*Safari/i,'Safari'],[/Firefox\/([\d.]+)/i,'Firefox'],[/(?:MSIE\s|Trident.*rv:)([\d.]+)/i,'IE']];
let build='';
for(const [pattern,name] of browsers){const match=ua.match(pattern);if(match){build=name+' '+match[1];break;}}
const sys=/android/i.test(ua)?'h5_android':/iphone|ipad|ipod/i.test(ua)?'h5_ios':'h5_pc';
return `DevType=${[brand,model,device].filter(Boolean).join(' ')};SysType=${sys};Ver=${version.trim()||'1.0.0'};BuildID=${build};device_brand=${brand};device_model=${model};system_name=${system};system_version=${systemVersion};sid=${sid.trim()}`;
}
/** The browser SDK uses its original identity fields, as in common/request.js. */
export function buildSdkUserAgent(fields:Record<string,unknown>):string {
const clean=(value:unknown)=>String(value??'').replace(/[^\x20-\x7e]|[;=]/g,' ').trim();
const device=clean(fields.device),system=clean(fields.system_name);
const sys=device==='Android'||/android/i.test(system)?'h5_android':device==='iOS'||/ios|iphone|ipad/i.test(system)?'h5_ios':'h5_pc';
const devType=device||[clean(fields.device_brand),clean(fields.device_model)].filter(Boolean).join(' ')||'OTHER';
const parts=[`DevID=${clean(fields.device_id)}`,`DevType=${devType}`,`SysType=${sys}`,`Ver=${clean(fields.app_version||'1.0.0')}`,'BuildID=web',`device_brand=${clean(fields.device_brand)}`,`device_model=${clean(fields.device_model)}`];
if(system)parts.push(`system_name=${system}`);
const systemVersion=clean(fields.system_version);
if(systemVersion)parts.push(`system_version=${systemVersion}`);
parts.push(`sid=${clean(fields.sid)}`);
return parts.join(';');
}
export function userAgentSid(cookie:string):string {
const values=new Map(cookie.split(';').map(part=>{const index=part.indexOf('=');return index<0?['','']:[part.slice(0,index).trim(),part.slice(index+1).trim()];}));
const sid=values.get('sid');if(sid)return sid;
const token=values.get('_s_token_')||'';
if(token.startsWith('{')){try{const value=JSON.parse(token).sid;if(typeof value==='string')return value;}catch{}}
return token;
}
+7
View File
@@ -0,0 +1,7 @@
import type { Config } from './api/types';
import { safeUrl } from './api/protocol';
export function downloadUrl(config: Config): string {
const candidates = [config.config?.officialAppAddr, config.config?.appAddr, config.newAddressInfo?.officialAppAddr, config.newAddressInfo?.appAddr];
return candidates.map(value => safeUrl(value)).find(Boolean) || '';
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+268
View File
@@ -0,0 +1,268 @@
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
@@ -0,0 +1,290 @@
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;
}
+268
View File
@@ -0,0 +1,268 @@
import { ApiError, decodeEnvelope, EXPIRED_CODES } from "../api/protocol";
import type { User } from "../api/types";
import { authMetadata, readBearerToken, writeBearerToken } from "./auth";
export type Query = Record<string, string | number | boolean | undefined>;
const PUBLIC = /^(?:config\/|navigation\/|theme\/|applist\/|recharge\/|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() : '';
export class ApiClient extends EventTarget {
user: User | null = null;
private session: Promise<User | null> | null = null;
private authTail: Promise<unknown> = Promise.resolve();
private version = 0;
private requiresLogin = false;
private prompted = false;
private sessionCheck: Promise<void> | null = null;
/**
* Bearer kept when Nginx forwards /api/app to Go (token in body).
* Persisted so bind/login survive reloads; BFF cookie mode leaves this empty.
*/
private bearer = readBearerToken();
// Serialize responses which set session cookies so a slow guest response cannot replace a login.
private authenticate<T>(work: () => Promise<T>): Promise<T> {
const job = this.authTail.then(work);
this.authTail = job.catch(() => {});
return job;
}
private promptLogin() {
if (!this.prompted) { this.prompted = true; this.dispatchEvent(new Event('expired')); }
}
private rememberBearer(user: User | null | undefined) {
const token = tokenOf(user);
// getInfo often returns an empty token field — keep the current bearer (jha-1048).
// auth/register returns a *new* token and invalidates the guest JWT; that new value must win.
if (token) {
this.bearer = token;
writeBearerToken(token);
} else if (!user) {
this.bearer = '';
writeBearerToken('');
}
}
private clearBearer() {
this.bearer = '';
writeBearerToken('');
}
async raw<T>(path: string, query: Query = {}, body?: unknown, signal?: AbortSignal, options?: {confirmSession?: boolean; recovered?: boolean}): Promise<T> {
const version = this.version;
const params = new URLSearchParams();
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 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.
...(this.bearer ? {Authorization: this.bearer} : {}),
...(body===undefined||multipart?{}:{"Content-Type":"application/json"}),
},
body: body===undefined?undefined:multipart?body as FormData:JSON.stringify(body),
});
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) {
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.
if (path === 'auth/getInfo' || path === 'auth/guest') {
this.setUser(null);
this.requiresLogin = !!registered;
if (registered) this.promptLogin();
} else if (!registered && !options?.recovered) {
// When Nginx forwards /api/app to Go, the Next BFF cannot refresh the cookie.
// Mirror that recovery once on the client: guest (captures bearer) then retry.
try {
const recovered = await this.authenticate(async () => {
const user = await this.raw<User | null>('auth/guest', {}, {...authMetadata(), automatic: true}, signal, {confirmSession: false});
if (user) this.setUser(user);
return user;
});
if (recovered) return this.raw(path, query, body, signal, {...options, confirmSession: false, recovered: true});
} catch { /* fall through to confirmSession */ }
void this.confirmSession();
} else void this.confirmSession();
}
const nested=result.data&&typeof result.data==='object'&&!Array.isArray(result.data)?result.data as {message?:unknown;msg?:unknown;tip?:unknown}:null;
const nestedText=[nested?.message,nested?.msg,nested?.tip].find((item):item is string=>typeof item==='string'&&!!item.trim());
const detail = result.tip || result.msg || (typeof result.data === 'string' ? result.data.trim() : '') || nestedText?.trim() || '';
throw new ApiError(detail || "请求失败,请稍后重试",result.code);
}
return result.data;
}
private confirmSession() {
if (this.sessionCheck || this.requiresLogin) return;
const version = this.version;
const job = this.authenticate(async () => {
if (this.requiresLogin || (version !== this.version && this.user)) return;
try {
const user = await this.raw<User>('auth/getInfo');
if (!this.requiresLogin && (version === this.version || this.user == null)) this.setUser(user);
} catch { /* getInfo expiry already cleared the account */ }
});
this.sessionCheck = job;
void job.finally(() => { if (this.sessionCheck === job) this.sessionCheck = null; });
}
private restoreSession(automatic: boolean): Promise<User | null> {
if (this.user) return Promise.resolve(this.user);
if (this.requiresLogin) {
if (automatic) return Promise.resolve(null);
this.promptLogin();
return Promise.reject(new ApiError('请先登录账号',401));
}
if (!this.session) {
const job = this.authenticate(async () => {
const user = await this.raw<User | null>('auth/guest',{}, {...authMetadata(), automatic});
this.setUser(user);
if (!user) this.requiresLogin = true;
return user;
});
this.session = job;
void job.finally(() => { if (this.session === job) this.session = null; }).catch(() => {});
}
return this.session;
}
initializeSession() { return this.restoreSession(true); }
/**
* Enter-site bootstrap aligned with jha-1048 GuestAuthBootstrap / ensureGuestSession:
* - already have a token (localStorage/cookie drama_bearer) → soft getInfo only, never auth/guest
* - no token → auth/guest, then soft getInfo
* Soft getInfo must never open the login dialog.
*/
async bootstrapGuestSession() {
if (this.requiresLogin) return null;
// Cookie may have been minted by SSR/BFF after the ApiClient constructed — re-read like 1048.
if (!this.bearer) this.bearer = readBearerToken();
const registered = !!(this.user?.isRegister || this.user?.type === 1);
const establishGuest = () => this.authenticate(async () => {
const next = await this.raw<User | null>('auth/guest', {}, {...authMetadata(), automatic: true});
if (next) this.setUser(next);
else {
this.requiresLogin = true;
if (!this.user || !(this.user.isRegister || this.user.type === 1)) this.setUser(null);
}
});
// 1048 ensureGuestSession: existing token means already logged in — skip guest.
if (this.bearer) {
try {
await this.refreshUser({soft: true});
return this.user;
} catch {
this.clearBearer();
// Bound SSR seed must not be downgraded via guest after a soft getInfo miss.
if (this.user?.isRegister || this.user?.type === 1) return this.user;
if (this.requiresLogin) return null;
// Dead guest token → fall through and guest again like a first visit.
}
}
// Registered SSR seed without client bearer cannot call getInfo under direct-Go.
if (registered && !this.bearer) return this.user;
// 1048: no local session → POST auth/guest once, then soft getInfo.
if (!this.bearer) await establishGuest();
// Guest through BFF may only set cookies; pick up the client-visible mirror before getInfo.
if (!this.bearer) this.bearer = readBearerToken();
if (!this.user) return null;
try {
await this.refreshUser({soft: true});
return this.user;
} catch {
this.clearBearer();
if (this.user) return this.user;
if (this.requiresLogin) return null;
return this.initializeSession();
}
}
async ensureSession(): Promise<User> {
const user = await this.restoreSession(false);
if (!user) { this.promptLogin(); throw new ApiError('请先登录账号',401); }
return user;
}
setUser(user: User | null) {
this.version++;
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'));
}
seedSession(user:User|null){
// login-required must block automatic guest login, but must not wipe a persisted bind token
// before bootstrapGuestSession has a chance to restore the account via getInfo.
this.requiresLogin=!user;
if (!user) {
this.version++;
this.user = null;
if(typeof window!=='undefined')window.tracker?.syncUser(null);
this.dispatchEvent(new Event('userchange'));
return;
}
this.setUser(user);
}
async revalidateSession(){
const version=this.version;
return this.authenticate(async()=>{
const user=await this.raw<User|null>('auth/guest',{}, {...authMetadata(),automatic:true});
if(version===this.version)this.seedSession(user);
return this.user;
});
}
reset() {
this.requiresLogin = true;
this.prompted = false;
this.session = null;
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> {
if(!PUBLIC.test(path)&&!PUBLIC_CONTENT.has(path))await this.ensureSession();
return this.raw(path,query,undefined,signal);
}
/** Best-effort private read for non-critical UI. Expiry must not trigger getInfo or sign the user out. */
async tryGet<T>(path: string, query: Query = {}, signal?: AbortSignal): Promise<T | undefined> {
if (!this.user) return;
try { return await this.raw(path, query, undefined, signal, {confirmSession: false}); }
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();
return this.raw(path,{},body);
}
async refreshUser(options?: {soft?: boolean}) {
// Bearer mode can refresh before a display user exists (post-bind reload).
// BFF cookie mode still goes through ensureSession when no bearer is present.
if (!this.bearer) await this.ensureSession();
const version = this.version;
const user = await this.raw<User>('auth/getInfo', {}, undefined, undefined, {
// Soft refresh is used by enter-site bootstrap — never open the login dialog there.
confirmSession: options?.soft ? false : true,
});
if (version === this.version) this.setUser(user);
}
}
+361
View File
@@ -0,0 +1,361 @@
import { navigationIsActive } from '../site-navigation';
import { ApiClient } from "./api";
import type { Config, Drama, FooterContact, FooterInfoResponse, HomeData, PageData } from "../api/types";
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";
import { startStartupDialogs, type StartupDialogs } from "./startup";
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 type { PageBootstrap, SiteSharedData } from '../page-data';
declare global { interface Window { __dramaApiApp?: Application } }
const $=<T extends Element=HTMLElement>(selector:string)=>document.querySelector<T>(selector)!;
export class Application implements SiteContext {
api=new ApiClient();
config:Config={};
home:HomeData;
signal:AbortSignal;
private controller=new AbortController();
private disposePlayer?:()=>void;
private toastTimer?:ReturnType<typeof setTimeout>;
private routeKey='';
private feature=0;
private renderedFeature=0;
private heroSequence=0;
private disposeHeroFade?:()=>void;
private heroTimer?:ReturnType<typeof setInterval>;
private disposePromotions?:()=>void;
private hot: {hotSearch?:{keyword?:string;name?:string}[];guessLike?:Drama[]}={};
private searchTimer?:ReturnType<typeof setTimeout>;
private searchSeq=0;
private initialized=false;
private startupDialogs?:StartupDialogs;
private footerContacts:FooterContact[]=[];
private seoEnvironment:SeoEnvironment;
private seoContent:SeoContent={};
private initialSeo?:SeoBootstrap;
private initialPage?:PageBootstrap;
constructor(home:HomeData,private homeLoaded=true,seo?:SeoBootstrap,page?:PageBootstrap,private initialSession?:PageBootstrap['session'],private initialShared?:SiteSharedData){this.initialPage=page;this.home=home;this.signal=this.controller.signal;this.initialSeo=seo;this.seoEnvironment=seo?.environment||resolveSeoEnvironment({},[]);}
updateSeo(content:SeoContent){this.seoContent={...this.seoContent,...content};this.syncHead();}
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(){
if(this.initialized)return;this.initialized=true;
const session=this.initialSession||this.initialPage?.session;
// Reuse a ready SSR identity. Only a login-required snapshot may block automatic guest login;
// unavailable/empty snapshots must still fall through to auth/guest on first paint.
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);
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));
await Promise.all([bootSession,this.initializeShared()]);
this.startFloatingTools();
await this.route();
}
private async initializeShared(){
const shared=this.initialShared;
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();
}
}
private async applyConfig(config:Config){
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(){
const target=document.querySelector<HTMLElement>('#info-content [data-site-contacts]');
if(target)target.outerHTML=infoContactSection(this.config,this.footerContacts);
}
private disposePage(){
this.stopHeroAutoplay();this.heroSequence++;this.feature=this.renderedFeature;this.controller.abort();
this.disposeHeroFade?.();this.disposePromotions?.();this.disposePlayer?.();
}
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);
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(()=>{});
}
private async selectHero(index:number){
const banners=activeBanners(this.home),target=$('#hero-feature-carousel');
if(!banners.length||!target)return;
const selected=(index%banners.length+banners.length)%banners.length;
if(selected===this.feature)return;
this.feature=selected;
const sequence=++this.heroSequence,signal=this.signal;
const incoming=document.createElement('div');incoming.innerHTML=hero(banners,selected);
const artwork=incoming.querySelector<HTMLImageElement>('.hero-art');
if(artwork){
const source=artwork.getAttribute('data-src');
if(source)await processImage(source,artwork);
if(artwork.decode)await artwork.decode().catch(()=>{});
}
if(sequence!==this.heroSequence||signal.aborted||!target.isConnected)return;
this.disposeHeroFade?.();
const previous=target.cloneNode(true) as HTMLElement;
previous.removeAttribute('id');previous.classList.add('hero-outgoing');
previous.setAttribute('aria-hidden','true');previous.inert=true;
previous.querySelectorAll('[data-src]').forEach(image=>image.removeAttribute('data-src'));
target.replaceChildren(...incoming.childNodes);this.renderedFeature=selected;
this.restartHeroAutoplay();
if(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches||typeof previous.animate!=='function')return;
target.append(previous);
const animation=previous.animate([{opacity:1},{opacity:0}],{duration:500,easing:'ease-in-out',fill:'forwards'});
const dispose=()=>{animation.cancel();previous.remove();if(this.disposeHeroFade===dispose)this.disposeHeroFade=undefined;};
this.disposeHeroFade=dispose;
void animation.finished.then(dispose,dispose);
}
private stopHeroAutoplay(){clearInterval(this.heroTimer);this.heroTimer=undefined;}
private bindPromotions(){
this.disposePromotions?.();
const dispose=Array.from(document.querySelectorAll<HTMLElement>('[data-promotion-row]'),root=>bindPromotionMarquee(root,this.signal));
this.disposePromotions=()=>dispose.forEach(close=>close());
}
private restartHeroAutoplay(){
this.stopHeroAutoplay();
if(this.signal.aborted||location.pathname!=='/'||document.hidden||activeBanners(this.home).length<2)return;
// Wait for the requested artwork instead of advancing past slides that are still loading.
this.heroTimer=setInterval(()=>{
if(this.feature===this.renderedFeature)return this.selectHero(this.renderedFeature+1);
},3000);
}
async route(){
// Every page navigation goes through the server. Re-entry after a mutation reloads fresh HTML.
if(this.routeKey){window.location.reload();return;}
this.routeKey=location.pathname+location.search;
const page=this.initialPage;this.initialPage=undefined;
const seo=this.initialSeo;this.initialSeo=undefined;
if(seo&&samePageAddress(seo.path,this.routeKey))this.seoContent=seo.content;
const route=parseSitePath(location.pathname,location.search);
const section=route?navigationSection(route):'',topSection=topNavigationSection(route);
document.querySelectorAll<HTMLAnchorElement>('a[data-page],a[data-hg-page],a[data-mobile-route],a[data-info]').forEach(link=>{
const selected=link.dataset.siteNav?navigationIsActive(link.getAttribute('href')||'',this.routeKey,link.dataset.siteNav==='top'?'top':'sidebar'):link.dataset.hgPage?link.dataset.hgPage===topSection:link.dataset.info?route?.group==='info'&&link.dataset.info===route.key:(link.dataset.page||link.dataset.mobileRoute)===section;
link.classList.toggle('active',selected);
if(selected)link.setAttribute('aria-current','page');else link.removeAttribute('aria-current');
});
window.dispatchEvent(new Event(ROUTE_EVENT));
if(!route)return;
this.bindPromotions();
if(!route.group){
bindHeroSwipe($('.home-content'),{enabled:()=>activeBanners(this.home).length>1,onStart:()=>this.stopHeroAutoplay(),onStep:step=>this.selectHero(this.feature+step),onEnd:()=>this.restartHeroAutoplay()},this.signal);
this.restartHeroAutoplay();return;
}
// 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})});
}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);
}
}
}
}
private bindEvents(){
bindPaginationJumps();
document.addEventListener('visibilitychange',()=>this.restartHeroAutoplay());
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;}
if(shouldUseDownloadDialog(event)){event.preventDefault();await this.download();}
return;
}
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;}
if(el.id==='search-picks-refresh'){const picks=await this.api.get<PageData<Drama>>('playlet/operate/list',{pageNum:1,pageSize:3,type:4});this.hot.guessLike=recordList<Drama>(picks.list).slice(0,3);$('#search-picks-grid').innerHTML=searchPicksMarkup(this.hot.guessLike);return;}
})().catch(error=>this.notify(error instanceof Error?error.message:'操作失败'));});
const form=$<HTMLFormElement>('#search'),query=$<HTMLInputElement>('#query');
const panel=$('#search-discovery'),position=()=>positionSearchDiscovery(form,panel);
window.addEventListener('resize',position);
if(typeof ResizeObserver!=='undefined'){const observer=new ResizeObserver(position);observer.observe(panel);this.signal.addEventListener('abort',()=>observer.disconnect(),{once:true});}
form.addEventListener('submit',event=>{event.preventDefault();const text=query.value.trim();if(!text)return;try{const history:string[]=JSON.parse(localStorage.getItem('91-search-history')||'[]');localStorage.setItem('91-search-history',JSON.stringify([text,...history.filter(v=>v!==text)].slice(0,10)));}catch{}this.closeSearchDiscovery();this.navigate(searchPath(text));});
query.addEventListener('focus',()=>{void this.searchDiscovery();});
query.addEventListener('click',()=>{if(panel.hidden)void this.searchDiscovery();});
query.addEventListener('input',()=>{clearTimeout(this.searchTimer);this.searchTimer=setTimeout(()=>{void this.searchDiscovery();},250);});
query.addEventListener('keydown',event=>{if(event.key==='Escape')this.closeSearchDiscovery();});
document.addEventListener('click',event=>{if(!form.contains(event.target as Node))this.closeSearchDiscovery();});
}
private async download(){
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);
}
private async loadDiscovery(){
try{
this.hot=await this.api.get('playlet/hot-like');
$('#popular-search-tags').innerHTML=(this.hot.hotSearch||[]).map((item,i)=>{const text=item.keyword||item.name||'';return `<a class="popular-search-tag popular-search-tag--${i%5}" href="${escape(searchPath(text))}">${escape(text)}</a>`;}).join('');
}catch{}
}
private closeSearchDiscovery(){this.searchSeq++;$('#search-discovery').hidden=true;$('#query').setAttribute('aria-expanded','false');}
private async searchDiscovery(){
const seq=++this.searchSeq;const query=$<HTMLInputElement>('#query').value.trim();const panel=$('#search-discovery');panel.hidden=false;
$('#query').setAttribute('aria-expanded','true');positionSearchDiscovery($('#search'),panel);
if(!Object.keys(this.hot).length)await this.loadDiscovery();
if(seq!==this.searchSeq||query!==$<HTMLInputElement>('#query').value.trim())return;
let history:string[]=[];try{history=JSON.parse(localStorage.getItem('91-search-history')||'[]');}catch{}
$('#search-history-tags').innerHTML=(Array.isArray(history)?history.filter(text=>typeof text==='string').slice(0,10):[]).map(text=>`<a href="${escape(searchPath(text))}">${escape(text)}</a>`).join('')||'<span class="search-discovery-status">暂无搜索记录</span>';
$('#search-picks-grid').innerHTML=searchPicksMarkup(this.hot.guessLike);
positionSearchDiscovery($('#search'),panel);
try{
const suggestions=query?await this.api.get<{list?:unknown[];suggestions?:unknown[]}>('playlet/search/suggest',{keyword:query}):null;
if(seq!==this.searchSeq||query!==$<HTMLInputElement>('#query').value.trim())return;
const list=suggestions?.list||suggestions?.suggestions||this.hot.hotSearch||[];
$('#search-suggestion-title').textContent=query?'搜索联想':'热搜榜';
$('#search-suggestion-list').innerHTML=searchSuggestionsMarkup(list,query);
}catch(error){if(seq===this.searchSeq)$('#search-suggestion-list').innerHTML=`<p class="search-discovery-status">${escape(error instanceof Error?error.message:'搜索建议加载失败')}</p>`;}
finally{if(seq===this.searchSeq)positionSearchDiscovery($('#search'),panel);}
}
private startFloatingTools(){
const tools=$('#page-tools');const visibility=()=>{const visible=window.scrollY>240;tools.classList.toggle('is-visible',visible);tools.inert=!visible;tools.setAttribute('aria-hidden',String(!visible));};
window.addEventListener('scroll',visibility,{passive:true});visibility();
}
private async loadFooter(){
try{
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 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);}}
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.`;
}catch{}
}
}
export function startApplication(home:HomeData,homeLoaded=true,seo?:SeoBootstrap,page?:PageBootstrap,session?:PageBootstrap['session'],shared?:SiteSharedData){
if(window.__dramaApiApp)return;
const app=new Application(home,homeLoaded,seo,page,session,shared);window.__dramaApiApp=app;
void app.init().catch(error=>{app.notify(error instanceof Error?error.message:'页面初始化失败');});
}
+56
View File
@@ -0,0 +1,56 @@
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'});
}
+90
View File
@@ -0,0 +1,90 @@
// Only device and attribution values are persisted here. Credentials stay in the form/request.
let volatileDevice = '';
let firstChannel = '';
let inviteCode = '';
/**
* Client bearer aligned with jha-1048 auth cookie + localStorage:
* - localStorage primary (survives reload)
* - drama_bearer cookie mirror (SSR/BFF also write this so ensureGuestSession can skip guest)
* JSON responses still omit token; the cookie/localStorage is the client source of truth.
*/
const BEARER_KEY = 'drama_bearer';
const BEARER_COOKIE = 'drama_bearer';
const BEARER_MAX_AGE = 7 * 86400;
const BEARER_PATTERN = /^[A-Za-z0-9._~+/=-]+$/;
function stored(key: string) {
try { return localStorage.getItem(key)?.trim() || ''; } catch { return ''; }
}
function remember(key: string, value: string) {
try { localStorage.setItem(key, value); } catch {}
}
function forget(key: string) {
try { localStorage.removeItem(key); } catch {}
}
function cookie(name: string) {
try {
const value = document.cookie.split(';').map(item => item.trim()).find(item => item.startsWith(name + '='));
return value ? decodeURIComponent(value.slice(name.length + 1)) : '';
} catch { return ''; }
}
export function readBearerToken() {
const fromStore = stored(BEARER_KEY);
if (BEARER_PATTERN.test(fromStore)) return fromStore;
const fromCookie = cookie(BEARER_COOKIE);
if (BEARER_PATTERN.test(fromCookie)) {
remember(BEARER_KEY, fromCookie);
return fromCookie;
}
return '';
}
export function writeBearerToken(token: string) {
if (typeof document === 'undefined') return;
const secure = typeof location !== 'undefined' && location.protocol === 'https:' ? '; Secure' : '';
if (!token) {
forget(BEARER_KEY);
document.cookie = `${BEARER_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax${secure}`;
return;
}
remember(BEARER_KEY, token);
document.cookie = `${BEARER_COOKIE}=${encodeURIComponent(token)}; Path=/; Max-Age=${BEARER_MAX_AGE}; SameSite=Lax${secure}`;
}
export function authMetadata() {
const validDevice = (value: string) => /^[a-zA-Z0-9_-]{1,100}$/.test(value) ? value : '';
const devID = validDevice(cookie('drama_device')) || validDevice(stored('uuid')) || volatileDevice ||
(volatileDevice = globalThis.crypto?.randomUUID?.() || Array.from({length:16}, () => Math.floor(Math.random()*16).toString(16)).join('') + Date.now());
remember('uuid', devID);
const params = new URLSearchParams(typeof location === 'undefined' ? '' : location.search);
firstChannel ||= cookie('drama_channel') || stored('channel') ||
(params.get('dc') || params.get('ch') || params.get('pc') || '').trim().slice(0,100);
if (firstChannel) remember('channel', firstChannel);
inviteCode ||= (params.get('inviteCode') || params.get('invite') || params.get('code') || '').trim().slice(0,100);
return {devID, deviceId:devID, channel:firstChannel, ...(inviteCode ? {agentInviteCode:inviteCode} : {})};
}
export function authFormPayload(mode: string, values: Record<string, FormDataEntryValue>): Record<string, string> {
const account = String(values.account || '').trim();
const password = String(values.password || '');
if (mode === 'register') {
if (!/^[A-Za-z0-9]{6,20}$/.test(account)) throw new Error('账号需为 6 到 20 位字母或数字');
if (password.length < 8 || password.length > 64 || !/[A-Za-z]/.test(password) || !/[0-9]/.test(password)) throw new Error('密码需为 8–64 位,且同时包含字母与数字');
if (password !== values.passwordConfirm) throw new Error('两次输入的密码不一致');
if (!values.agree) throw new Error('请先同意用户协议与隐私政策');
return {accountNo:account, password};
}
if (mode === 'resetPassword') {
const email = String(values.email || '').trim();
if (!account || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw new Error('请填写账号和有效的绑定邮箱');
if (password.length < 8 || password.length > 64 || !/[A-Za-z]/.test(password) || !/[0-9]/.test(password)) throw new Error('密码需为 8–64 位,且同时包含字母与数字');
if (password !== values.passwordConfirm) throw new Error('两次输入的密码不一致');
return {accountNo:account, email, newPassword:password};
}
// Accept legacy 4-character passwords and the longer accounts/passwords accepted by binding.
if (!(account.includes('@') ? /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(account) : account.length >= 4 && account.length <= 20)) throw new Error('请输入 4–20 位账号或有效邮箱');
if (password.length < 4 || password.length > 64) throw new Error('密码需为 4–64 位');
return {account, password};
}
+118
View File
@@ -0,0 +1,118 @@
import type { Drama, HomeModule, PageData, Topic, TopicGroup } from '../api/types';
import type { ApiClient } from './api';
import { homeBlocks, recordList } from '../api/protocol';
import { contentPath, contentQuery, contentSegment, getRankPage, matchesContentKey, paginationPath, RANK_PAGES, type SiteRoute } from '../routing';
import { card, empty, escape, pagination, topicCard } from './render';
const labels:Record<string,string>={shorts:'短剧',topics:'专题推荐',theme:'题材短剧',tag:'标签短剧',config:'更多好剧',rank:'热播榜'};
const shortsSortNames=['最新更新','当前热播','独家原创','随机推荐'];
function channelSort(route:SiteRoute,search:string):number {
const value=Number(new URLSearchParams(search).get('sort')||1);
return (route.key==='shorts'?[1,2,3,4]:[1,2,3]).includes(value)?value:1;
}
export function rankNavigation(slug='hot'):string {
return `<nav class="channel-tabs rank-tabs" aria-label="排行榜">${RANK_PAGES.map(item=>`<a href="${item.path}"${item.slug===slug?' aria-current="page"':''}>${item.title}</a>`).join('')}</nav>`;
}
export function channelRequest(route:SiteRoute,search=''):{endpoint:string;query:Record<string,string|number>} {
const sort=channelSort(route,search);
const query:Record<string,string|number>={pageNum:route.page||1,pageSize:24};
let endpoint='';
if(route.key==='shorts'){endpoint='playlet/operate/list';query.type=sort;}
if(route.key==='rank'){endpoint='playlet/hot/rank/list';query.rankType=getRankPage(route.value)!.rankType;}
if(route.key==='topics'){endpoint=route.value?'playlet/topic/detail':'playlet/topic/list';}
if(route.key==='theme'){
endpoint='playlet/all/list';Object.assign(query,contentQuery(route.value!,'themeId','themeSlug'));query.sortType=sort;
const status=new URLSearchParams(search).get('updateStatus');if(status!==null&&/^[012]$/.test(status))query.updateStatus=Number(status);
}
if(endpoint==='playlet/topic/detail'){
Object.assign(query,contentQuery(route.value!,'id','topicSlug'));query.dataType=1;query.sortType=sort;
}
if(route.key==='tag'){endpoint='playlet/tag/list';Object.assign(query,contentQuery(route.value!,'tagId','tagSlug'));query.sortType=sort;}
if(route.key==='config'){endpoint='playlet/home-config/list';Object.assign(query,contentQuery(route.value!));}
if(!endpoint)throw new Error('该页面不存在');
return {endpoint,query};
}
/** Keep server rendering and browser navigation on the same documented contracts. */
export async function loadChannel(api:Pick<ApiClient,'get'>,route:SiteRoute,search='',signal?:AbortSignal):Promise<PageData<unknown>> {
let theme:Topic|undefined;
if(route.key==='theme'){
const catalog=await api.get<PageData<Topic>>('theme/list',{},signal);
if(!Array.isArray(catalog.list))throw new Error('题材目录暂不可用');
theme=recordList<Topic>(catalog.list).find(item=>matchesContentKey(item,route.value!));
if(!theme)return {missing:true};
}
const {endpoint,query}=channelRequest(route,search);
const data=await api.get<PageData<unknown>>(endpoint,query,signal);
if(route.key==='config'){
const module=data.config as HomeModule|undefined;
if(!module||!matchesContentKey(module,route.value!))return {missing:true};
const block=homeBlocks({modules:[module]})[0];if(!block)return {missing:true};
return {...block,...data,id:module.id,slug:module.slug,name:module.title,desc:module.desc,type:block.type,themeList:recordList<Topic>(data.topicList),topicKind:'theme'};
}
if(theme)return {...data,id:theme.id,slug:theme.slug,name:theme.name,desc:theme.desc,seoInfo:theme.seoInfo||data.seoInfo};
if(route.key==='topics'&&route.value){
const identity=data.topicInfo||data;
if(identity.id!==undefined&&!matchesContentKey(identity,route.value!))return {missing:true};
return {...data,id:identity.id??(Number(query.id)||undefined),slug:identity.slug||(typeof query.topicSlug==='string'?query.topicSlug:undefined)};
}
if(route.key==='topics'){
const groups=recordList<TopicGroup>(data.list).filter(item=>Array.isArray(item.list)&&typeof item.totalCount==='number');
if(groups.length)return {...data,topicGroups:groups,list:groups.flatMap(group=>recordList<Topic>(group.list)),totalCount:Math.max(0,...groups.map(group=>group.totalCount))};
if(Array.isArray(data.list)&&!data.list.length)return {...data,totalCount:0};
}
return data;
}
/** Shared by the initial server response and client-side page navigation. */
export function channelView(route:SiteRoute,data:PageData<unknown>,path:string) {
const ranking=route.key==='rank'?getRankPage(route.value):undefined;
const sort=channelSort(route,new URL(path,'https://site.invalid').search);
const title=ranking?.title||(typeof data.name==='string'?data.name:'')||(typeof data.topicInfo?.name==='string'?data.topicInfo.name:'')||labels[route.key]||'内容列表';
let subtitle=typeof data.desc==='string'?data.desc.trim():typeof data.topicInfo?.desc==='string'?data.topicInfo.desc.trim():'';
const total=typeof data.totalCount==='number'&&Number.isFinite(data.totalCount)?Math.max(0,data.totalCount):0;
const playletCount=Number(data.playletCount??data.topicInfo?.playletCount);
if(route.key==='topics'&&route.value){
const count=Number.isFinite(playletCount)&&playletCount>0?playletCount:total;
if(count>0)subtitle=subtitle?`${subtitle} · ${count} 部精选`:`${count} 部精选`;
}
const identity={id:Number(data.topicInfo?.id??data.id??route.value),slug:data.topicInfo?.slug||data.slug};
const pagePath=route.value&&['topics','theme','tag','config'].includes(route.key)&&contentSegment(identity)?contentPath(route.key as 'topics'|'theme'|'tag'|'config',identity)+new URL(path,'https://site.invalid').search:path;
const page=route.page||1,href=(page:number)=>paginationPath(pagePath,page);
let tabs=ranking?rankNavigation(ranking.slug):'';
if(!ranking&&route.key==='shorts'){
tabs=`<div class="channel-tabs" aria-label="页面分类">${shortsSortNames.map((name,index)=>`<button type="button" data-sort="${index+1}" aria-pressed="${sort===index+1}">${name}</button>`).join('')}</div>`;
}
let content:string;
if(route.key==='topics'&&!route.value){
const groups=recordList<TopicGroup>(data.topicGroups).filter(group=>recordList(group.list).length);
if(groups.length){
content=groups.map((group,index)=>{
const desc=String(group.desc||'').trim();
const cards=recordList<Topic>(group.list).map(item=>topicCard(item,false,3)).join('');
return cards?`<section class="topic-section channel-topic-group" aria-labelledby="topic-group-${index}"><div class="section-head"><div class="section-heading-copy"><h2 id="topic-group-${index}">${escape(group.name)}</h2>${desc?`<p class="section-subtitle">${escape(desc)}</p>`:''}</div></div><div class="topic-recommendation-grid channel-topic-grid">${cards}</div></section>`:'';
}).join('');
}else{
const cards=recordList<Topic>(data.list).map(item=>topicCard(item,false,2)).join('');
content=cards?`<div class="topic-recommendation-grid channel-topic-grid">${cards}</div>`:'';
}
if(content&&!subtitle)subtitle='围绕一个主题,发现一组好故事。';
}else if(route.key==='config'&&data.type===3){
content=`<div class="topic-recommendation-grid">${recordList<Topic>(data.themeList).map(item=>topicCard(item,true,2,'theme')).join('')||empty()}</div>`;
}else if(route.key==='topics'&&route.value){
const dramas=recordList<Drama>(data.list);
const works=total||(Number.isFinite(playletCount)&&playletCount>0?playletCount:0);
const summary=works>0?`<div class="channel-summary"><span>${escape(title)}</span><span>共 ${works} 部作品</span></div>`:'';
content=summary+(dramas.length?`<div class="cards">${dramas.map(item=>card(item,false,{headingLevel:2})).join('')}</div>`:'');
}else{
const summary=ranking?`<div class="channel-summary"><span>${ranking.title}</span><span>共 ${total} 部作品</span></div>`:'';
content=tabs+summary+`<div class="cards${ranking?' channel-ranking-cards':''}">${recordList<Drama>(data.list).map((item,index)=>card(item,false,{headingLevel:2,...(ranking?{rank:(page-1)*24+index+1}:{})})).join('')||empty(typeof data.message==='string'?data.message:undefined)}</div>`;
}
const moreTopics=route.key==='topics'&&route.value?recordList<Topic>(data.moreTopics).map(item=>topicCard(item)).join(''):'';
const more=moreTopics
?`<section class="topic-section channel-more-topics" aria-labelledby="more-topics-title"><div class="section-head"><div class="section-heading-copy"><h2 id="more-topics-title">更多专题</h2><p class="section-subtitle">发现更多精选合集</p></div></div><div class="topic-recommendation-grid channel-topic-grid">${moreTopics}</div></section>`
:'';
return {title,subtitle,html:content+pagination(total,page,href,typeof data.pageSize==='number'&&data.pageSize>0?data.pageSize:24)+more};
}
+73
View File
@@ -0,0 +1,73 @@
import { recordList, safeUrl } from "../api/protocol";
import type { Config } from "../api/types";
import { escape } from "./render";
import { downloadUrl } from "../app-download";
import { configureDownloadLinks } from "./download";
export { downloadUrl };
export function configureSite(config: Config) {
const settings = {...config.newAddressInfo,...config.config};
const download = downloadUrl(config);
configureDownloadLinks(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;}
});
}
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 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>`;
const copy=(value:string,label='复制地址')=>`<button type="button" data-road-copy="${escape(value)}">${label}</button>`;
const display=(url:string)=>{const parsed=new URL(url);return parsed.host+(parsed.pathname==='/'?'':parsed.pathname.replace(/\/$/,''));};
const configured=[values.newUrlAddr,values.newUrlAddr1,values.newUrlAddr2].map(value=>safeUrl(value)).filter(Boolean);
const navigation=recordList<{line?:number;url?:unknown}>(config.navPageList).sort((a,b)=>(a.line||0)-(b.line||0)).map(item=>safeUrl(item.url)).filter(Boolean);
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));
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('遇到访问问题?','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>
<div id="road-copy-status" class="return-copy-status" role="status" aria-live="polite"></div>
</div>`;
}
export async function copyRoadAddress(button:HTMLElement){
const value=button.dataset.roadCopy;if(!value)return;
const text=value.startsWith('/')?new URL(value,location.origin).href:value;
let copied=false;
try{await navigator.clipboard.writeText(text);copied=true;}
catch{
const input=document.createElement('textarea');input.value=text;input.style.cssText='position:fixed;left:-9999px;top:0';document.body.append(input);input.select();
try{copied=document.execCommand('copy');}catch{}
input.remove();if(button.isConnected)button.focus({preventScroll:true});
}
const status=document.getElementById('road-copy-status');
if(status)status.textContent=copied?'地址已复制':`请手动复制:${text}`;
if(copied&&button.isConnected&&!button.classList.contains('return-contact-card')){
const label=button.dataset.roadCopyLabel??button.textContent??'';button.dataset.roadCopyLabel=label;button.textContent='已复制';
setTimeout(()=>{if(button.isConnected)button.textContent=label;},1800);
}
}
+16
View File
@@ -0,0 +1,16 @@
import type { ApiClient } from "./api";
import type { Config, HomeData } from "../api/types";
import type { SeoContent } from '../seo';
export interface SiteContext {
api: ApiClient;
home: HomeData;
config: Config;
signal: AbortSignal;
notify(message: string): void;
current(): boolean;
route(): Promise<void>;
navigate(path: string): void;
openAuth(mode?: string): void;
updateSeo?(content:SeoContent):void;
}
+108
View File
@@ -0,0 +1,108 @@
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";
// 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;
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 {
const items: {name: string; href: string}[] = [];
const seen = new Set<string>();
const add = (names: unknown, ids: number[] | undefined, slugs: string[] | undefined, kind: 'tag' | 'theme') => {
if (!Array.isArray(names)) return;
names.forEach((name, index) => {
if (typeof name !== 'string' || !name.trim() || seen.has(name.trim())) return;
const label = name.trim();
seen.add(label);
const identity = {id: ids?.[index], slug: slugs?.[index]};
items.push({name: label, href: contentSegment(identity) ? contentPath(kind, identity) : ''});
});
};
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>`;
}
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>` : '';
}
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>
</div>
</div>`;
}
export function officialLinks(config: Config): string {
const settings = {...config.newAddressInfo,...config.config};
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>
</section>`;
}
export function bindSeriesDescription(root: HTMLElement, signal: AbortSignal): () => void {
const text = root.querySelector<HTMLElement>('#series-description-text');
const toggle = root.querySelector<HTMLButtonElement>('.series-description-toggle');
if (!text || !toggle) return () => {};
const update = () => {
if (signal.aborted || !text.clientWidth) return;
const lineHeight = Number.parseFloat(getComputedStyle(text).lineHeight) || 26.6;
const overflow = text.scrollHeight > lineHeight*3 + 1;
toggle.hidden = !overflow;
if (!overflow) {
text.classList.remove('is-expanded');
toggle.setAttribute('aria-expanded','false');toggle.textContent = '展开';
}
};
toggle.addEventListener('click',() => {
const expanded = toggle.getAttribute('aria-expanded') !== 'true';
text.classList.toggle('is-expanded',expanded);
toggle.setAttribute('aria-expanded',String(expanded));toggle.textContent = expanded ? '收起' : '展开';
},{signal});
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update);
observer?.observe(text);
if (!observer) window.addEventListener('resize',update,{signal});
const dispose = () => observer?.disconnect();
signal.addEventListener('abort',dispose,{once:true});
update();
return dispose;
}
+22
View File
@@ -0,0 +1,22 @@
import type { Config } from '../api/types';
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<HTMLAnchorElement>('a[data-app-download]').forEach(link => {
if (url) {link.href = url;link.rel = 'sponsored nofollow noopener noreferrer';} else link.removeAttribute('href');
});
}
type DownloadClick = Pick<MouseEvent, 'defaultPrevented' | 'button' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>;
export function shouldUseDownloadDialog(event: DownloadClick, userAgent = navigator.userAgent, touchPoints = navigator.maxTouchPoints): boolean {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
return /Android|iPhone|iPad|iPod|Mobile|HarmonyOS|OpenHarmony/i.test(userAgent) || /Macintosh/i.test(userAgent) && touchPoints > 1;
}
export async function createDownloadQr(url: string): Promise<string> {
const {default: QRCode} = await import('qrcode');
return QRCode.toDataURL(url, {width: 192, margin: 2});
}
+282
View File
@@ -0,0 +1,282 @@
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
@@ -0,0 +1,51 @@
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
View File
@@ -0,0 +1,55 @@
interface HeroSwipeOptions {
enabled:()=>boolean;
onStart:()=>void;
onStep:(step:number)=>void;
onEnd:()=>void;
}
/** Delegate to the persistent home container so replacing the hero keeps gestures working. */
export function bindHeroSwipe(root:HTMLElement,options:HeroSwipeOptions,signal:AbortSignal){
const view=root.ownerDocument.defaultView;
if(!view||signal.aborted)return;
let gesture:{id:number;x:number;y:number;horizontal:boolean;hero:Element}|undefined;
let blockClickUntil=0;
const finish=(event?:PointerEvent)=>{
if(!gesture)return;
const current=gesture;gesture=undefined;
current.hero.classList.remove('is-swiping');
if(event){
const dx=event.clientX-current.x,dy=event.clientY-current.y;
const horizontal=Math.abs(dx)>Math.abs(dy)*1.2;
if(current.horizontal||horizontal&&Math.abs(dx)>10)blockClickUntil=Date.now()+500;
if(horizontal&&Math.abs(dx)>=50)options.onStep(dx>0?-1:1);
}
options.onEnd();
};
root.addEventListener('pointerdown',event=>{
const hero=(event.target as Element).closest('#hero-feature-carousel');
if(!hero||!options.enabled())return;
if(gesture){if(event.pointerId!==gesture.id)finish();return;}
if(!event.isPrimary||event.button!==0)return;
blockClickUntil=0;
gesture={id:event.pointerId,x:event.clientX,y:event.clientY,horizontal:false,hero};
options.onStart();
},{signal});
view.addEventListener('pointermove',event=>{
if(!gesture||gesture.id!==event.pointerId)return;
const dx=Math.abs(event.clientX-gesture.x),dy=Math.abs(event.clientY-gesture.y);
if(!gesture.horizontal){
if(Math.max(dx,dy)<10)return;
if(dx<=dy*1.2){finish();return;}
gesture.horizontal=true;gesture.hero.classList.add('is-swiping');
}
if(event.cancelable)event.preventDefault();
},{passive:false,signal});
view.addEventListener('pointerup',event=>{if(gesture?.id===event.pointerId)finish(event);},{signal});
view.addEventListener('pointercancel',event=>{if(gesture?.id===event.pointerId)finish();},{signal});
view.addEventListener('blur',()=>finish(),{signal});
root.ownerDocument.addEventListener('visibilitychange',()=>{if(root.ownerDocument.hidden)finish();},{signal});
root.addEventListener('dragstart',event=>{if(gesture)event.preventDefault();},{signal});
root.addEventListener('click',event=>{
if(event.detail===0||Date.now()>blockClickUntil)return;
blockClickUntil=0;event.preventDefault();event.stopImmediatePropagation();
},{capture:true,signal});
signal.addEventListener('abort',()=>finish(),{once:true});
}
+211
View File
@@ -0,0 +1,211 @@
import { safeUrl } from '../api/protocol';
const PLACEHOLDER = '/placeholder.png';
const KEY = new TextEncoder().encode('2019ysapp7527');
type ImageTarget = 'img' | 'poster' | 'bgImg' | 'coverVar';
function imageMime(bytes: Uint8Array): string {
const starts = (...signature: number[]) => signature.every((value, index) => bytes[index] === value);
if (starts(0xff, 0xd8, 0xff)) return 'image/jpeg';
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return 'image/png';
if (starts(0x47, 0x49, 0x46, 0x38)) return 'image/gif';
const ascii = (start: number, end: number) => String.fromCharCode(...bytes.subarray(start, end));
if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') return 'image/webp';
if (ascii(4, 8) === 'ftyp' && /avif|avis/.test(ascii(8, 32))) return 'image/avif';
const text = new TextDecoder().decode(bytes.subarray(0, 512));
if (/^\s*(?:<\?xml[^>]*>\s*)?<svg[\s>]/i.test(text)) return 'image/svg+xml';
return '';
}
/** Reference imgDecypt.js: XOR only the first 100 bytes, repeating the same key. */
export function decodeImage(buffer: ArrayBuffer): Blob {
const bytes = new Uint8Array(buffer.slice(0));
let mime = imageMime(bytes);
if (!mime) {
for (let index = 0; index < Math.min(100, bytes.length); index++) bytes[index] ^= KEY[index % KEY.length];
mime = imageMime(bytes);
}
if (!mime) throw new Error('图片格式无效');
return new Blob([bytes], {type: mime});
}
export function shouldDecryptImage(source: string): boolean {
return !!safeUrl(source);
}
function imageSource(source: string): string {
if (/^\/(?!\/)|^blob:|^data:image\//i.test(source)) return source;
return safeUrl(source);
}
export class ImageLoader {
private cache = new Map<string, string>();
private inflight = new Map<string, Promise<string>>();
private queue: {run: () => void; reject: (error: Error) => void}[] = [];
private controllers = new Set<AbortController>();
private requests = new WeakMap<Element, symbol>();
private active = 0;
private disposed = false;
private concurrency: number;
private timeoutMs: number;
private request: typeof fetch;
constructor(options: {concurrency?: number; timeoutMs?: number; request?: typeof fetch} = {}) {
this.concurrency = Math.max(1, options.concurrency ?? 4);
this.timeoutMs = options.timeoutMs ?? 12000;
this.request = options.request ?? ((input, init) => fetch(input, init));
}
private pump() {
while (!this.disposed && this.active < this.concurrency && this.queue.length) this.queue.shift()!.run();
}
invalidate(node: HTMLElement) {
this.requests.delete(node);
node.onerror = null;
}
fetchDecrypt(source: string, priority = false): Promise<string> {
if (this.disposed) return Promise.reject(new Error('图片加载已停止'));
if (!shouldDecryptImage(source)) return Promise.reject(new Error('图片地址无效'));
const cached = this.cache.get(source);
if (cached) return Promise.resolve(cached);
const pending = this.inflight.get(source);
if (pending) return pending;
let resolve!: (url: string) => void;
let reject!: (error: Error) => void;
const promise = new Promise<string>((done, fail) => {resolve = done; reject = fail;});
this.inflight.set(source, promise);
promise.then(() => this.inflight.delete(source), () => this.inflight.delete(source));
const job = {reject, run: () => {
this.active++;
const controller = new AbortController();
this.controllers.add(controller);
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
void (async () => {
try {
const response = await this.request(source, {signal: controller.signal, credentials: 'omit'});
if (!response.ok) throw new Error('图片请求失败');
const blob = decodeImage(await response.arrayBuffer());
if (this.disposed || controller.signal.aborted) throw new Error('图片加载已取消');
const url = URL.createObjectURL(blob);
this.cache.set(source, url);
resolve(url);
} catch (error) {reject(error instanceof Error ? error : new Error('图片解密失败'));}
finally {
clearTimeout(timer);
this.controllers.delete(controller);
this.active--;
this.pump();
}
})();
}};
if (priority) this.queue.unshift(job); else this.queue.push(job);
this.pump();
return promise;
}
async processImage(source: string, node: HTMLElement, type: ImageTarget = 'img', fallback = PLACEHOLDER): Promise<string> {
const token = Symbol();
this.requests.set(node, token);
const current = () => !this.disposed && this.requests.get(node) === token;
const apply = (url: string) => {
if (type === 'bgImg') node.style.setProperty('background-image', `url("${url}")`);
else if (type === 'coverVar') node.style.setProperty('--cover', `url("${url}")`);
else node.setAttribute(type === 'poster' ? 'poster' : 'src', url);
};
const failed = () => {
if (!current()) return;
node.onerror = null;
node.setAttribute('data-decrypt-failed', '1');
apply(fallback);
};
node.removeAttribute('data-decrypt-failed');
node.onerror = null;
try {
const url = imageSource(source);
if (!url) throw new Error('图片地址无效');
const priority = type === 'poster' || node.getAttribute('fetchpriority') === 'high' || node.getAttribute('data-lazy-eager') === '1' || !!node.classList?.contains('play-comment-avatar') || !!node.classList?.contains('play-compose-avatar');
const result = shouldDecryptImage(url) ? await this.fetchDecrypt(url, priority) : url;
if (!current()) return '';
if (node.tagName === 'IMG') node.onerror = failed;
apply(result);
node.setAttribute('data-decrypted', '1');
return result;
} catch {failed(); return '';}
}
dispose() {
this.disposed = true;
this.controllers.forEach(controller => controller.abort());
this.queue.splice(0).forEach(job => job.reject(new Error('图片加载已停止')));
this.cache.forEach(url => URL.revokeObjectURL(url));
this.cache.clear();
}
}
declare global {
interface Window { processImage?: (source: string, node: HTMLElement, type?: ImageTarget, fallback?: string) => Promise<string> }
}
let runtime: {loader: ImageLoader; refresh: (root?: ParentNode) => void; dispose: () => void} | undefined;
export function processImage(source: string, node: HTMLElement, type: ImageTarget = 'img', fallback?: string) {
return startImageLoading().loader.processImage(source, node, type, fallback);
}
/** Watch API-generated markup as well as the initial SSR content. */
export function startImageLoading() {
if (runtime) return runtime;
const loader = new ImageLoader({concurrency: window.matchMedia?.('(max-width: 767px)').matches ? 2 : 4});
const selector = 'img[data-src],video[data-poster]';
const sources = new WeakMap<HTMLElement, string>();
const load = (node: HTMLElement) => {
const poster = node.tagName === 'VIDEO';
const source = node.getAttribute(poster ? 'data-poster' : 'data-src');
if (source) void loader.processImage(source, node, poster ? 'poster' : 'img', node.getAttribute('data-fallback') || undefined);
};
const lazy = typeof IntersectionObserver === 'undefined' ? undefined : new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
lazy!.unobserve(entry.target);
load(entry.target as HTMLElement);
});
}, {rootMargin: '180px 0px', threshold: 0.01});
const observe = (node: HTMLElement) => {
const source = node.getAttribute(node.tagName === 'VIDEO' ? 'data-poster' : 'data-src') || '';
if (sources.get(node) === source) return;
sources.set(node, source);
loader.invalidate(node);
lazy?.unobserve(node);
if (node.getAttribute('loading') === 'lazy' && node.getAttribute('fetchpriority') !== 'high' && node.getAttribute('data-lazy-eager') !== '1' && lazy) lazy.observe(node);
else load(node);
};
const visit = (root: ParentNode, callback: (node: HTMLElement) => void) => {
if (root instanceof HTMLElement && root.matches(selector)) callback(root);
root.querySelectorAll<HTMLElement>(selector).forEach(callback);
};
const refresh = (root: ParentNode = document) => visit(root, observe);
const mutations = new MutationObserver(records => records.forEach(record => {
if (record.type === 'attributes') observe(record.target as HTMLElement);
else {
record.removedNodes.forEach(node => {if (node instanceof HTMLElement) visit(node, item => {lazy?.unobserve(item); sources.delete(item); loader.invalidate(item);});});
record.addedNodes.forEach(node => {if (node instanceof HTMLElement) refresh(node);});
}
}));
window.processImage = (source, node, type, fallback) => loader.processImage(source, node, type, fallback);
refresh();
mutations.observe(document.body, {subtree: true, childList: true, attributes: true, attributeFilter: ['data-src', 'data-poster']});
const dispose = () => {
mutations.disconnect();
lazy?.disconnect();
loader.dispose();
delete window.processImage;
window.removeEventListener('pagehide', onPageHide);
runtime = undefined;
};
const onPageHide = (event: PageTransitionEvent) => {if (!event.persisted) dispose();};
window.addEventListener('pagehide', onPageHide);
runtime = {loader, refresh, dispose};
return runtime;
}
+24
View File
@@ -0,0 +1,24 @@
import { internalPagePath, paginationPath } from '../routing';
export const NAVIGATE_EVENT = 'site:navigate';
export const ROUTE_EVENT = 'site:route';
export function navigateTo(path: string) {
const target = internalPagePath(path, window.location.href);
if (target === null) return;
window.location.assign(target);
}
/** Delegate so comment and account pagination inserted after hydration also works. */
export function bindPaginationJumps(root: Document = document, navigate: (path: string) => void = navigateTo) {
root.addEventListener('submit', event => {
const form = event.target;
if (!(form instanceof HTMLFormElement) || !form.matches('.pagination-jump')) return;
event.preventDefault();
const input = form.elements.namedItem('page');
if (!(input instanceof HTMLInputElement) || !form.reportValidity()) return;
const page = input.valueAsNumber;
if (!Number.isSafeInteger(page) || page < 1 || page > Number(input.max) || !form.dataset.pageBase) return;
navigate(paginationPath(form.dataset.pageBase, page));
});
}
+246
View File
@@ -0,0 +1,246 @@
import type Hls from 'hls.js';
import type { Chapter } from '../api/types';
import { safeUrl } from '../api/protocol';
export type PlayMode = 'main' | 'preview';
export type PlaybackAccess = {allowed: boolean; gate: 'coins' | 'vip' | 'unavailable' | ''; source: string};
export type PlayerActions = {toggle: () => Promise<void>; retry: () => Promise<void>};
export function chapterRequiresPayment(chapter: Chapter): boolean {
return !!(chapter.needUnlock || chapter.needVip || chapter.payType === 1 || chapter.payType === 2);
}
export function playbackUrl(value: unknown): string {
if (typeof value !== 'string') return '';
const raw = value.trim();
if (/^https?:\/\//i.test(raw)) return safeUrl(raw);
if (!raw || /^[a-z][a-z\d+.-]*:/i.test(raw) || raw.startsWith('//') || /[\\\s\u0000-\u001f]/.test(raw)) return '';
const path = raw.split(/[?#]/)[0];
try {if (decodeURIComponent(path).split('/').some(part => part === '..' || part === '.')) return '';}
catch {return '';}
return '/h5/m3u8/' + raw.replace(/^\/?h5\/m3u8\//, '').replace(/^\/+/, '');
}
export function chapterAccess(chapter: Chapter | null | undefined): PlaybackAccess {
if (!chapter) return {allowed: false, gate: 'unavailable', source: ''};
const source = playbackUrl(chapter.videoUrl);
// Purchased episodes stay watchable even when the chapter is still marked as a coin episode.
const allowed = chapter.canWatch === true || chapter.isUnlocked === true || (!chapter.needUnlock && !chapter.needVip && (chapter.payType ?? 0) === 0 && !!source);
if (allowed) return {allowed: true, gate: '', source};
const gate = chapter.needUnlock || chapter.payType === 1 ? 'coins' : chapter.needVip || chapter.payType === 2 ? 'vip' : 'unavailable';
return {allowed: false, gate, source: ''};
}
// List entries do not necessarily contain a video URL. Follow the reference
// PlayletChapterData.AllowWatch/NeedCoinGate/NeedVipGate precedence.
export function chapterListGate(chapter: Chapter): 'coins' | 'vip' | '' {
if (chapter.canWatch === true || chapter.isUnlocked === true || !chapter.needUnlock && !chapter.needVip && (chapter.payType ?? 0) === 0) return '';
if (chapter.needUnlock || chapter.payType === 1) return 'coins';
if (chapter.needVip || chapter.payType === 2) return 'vip';
return '';
}
export function initialPlayMode(access: PlaybackAccess, preview: unknown): PlayMode {
return (!access.allowed || !access.source) && !!playbackUrl(preview) ? 'preview' : 'main';
}
export function chapterUnlockCost(chapter: Chapter | null): number {
return chapter?.costCoins && chapter.costCoins > 0 ? chapter.costCoins : chapter?.singleCoins && chapter.singleCoins > 0 ? chapter.singleCoins : 0;
}
const INLINE_ATTRS: Record<string, string> = {
playsinline: 'true', 'webkit-playsinline': 'true', 'x5-playsinline': 'true',
't7-video-player-type': 'inline', 'x5-video-player-type': 'h5',
'x5-video-player-fullscreen': 'true', 'x5-video-orientation': 'portrait',
controlslist: 'nodownload noremoteplayback', disablepictureinpicture: 'true',
'webkit-airplay': 'deny', 'x-webkit-airplay': 'deny',
};
export const INLINE_VIDEO_ATTRIBUTES = Object.entries(INLINE_ATTRS).map(([key,value]) => `${key}="${value}"`).join(' ');
export function applyInlinePlayback(video: HTMLVideoElement) {
for (const [key,value] of Object.entries(INLINE_ATTRS)) if (video.getAttribute(key) !== value) video.setAttribute(key,value);
if (video.hasAttribute('controls')) video.removeAttribute('controls');
video.controls = false;
video.playsInline = true;
video.disablePictureInPicture = true;
video.disableRemotePlayback = true;
}
export function preferNativeHls(video: HTMLVideoElement, navigator: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>): boolean {
const ua = navigator.userAgent;
const custom = /Quark|MQQBrowser|QQ\/|UCBrowser|UCWEB|MicroMessenger|baidubrowser|bdbrowser|BaiduHD|MiuiBrowser|HuaweiBrowser|HONORBrowser|HBPC|SogouMobileBrowser|VivoBrowser|HeyTapBrowser|OPPOBrowser|HeyTap|QihooBrowser|360Browser|360SE|360EE/i.test(ua);
const ios = /iPhone|iPad|iPod/i.test(ua) || /Macintosh/i.test(ua) && navigator.maxTouchPoints > 1;
const safari = /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|FxiOS|EdgiOS|OPiOS|Android/i.test(ua);
return !custom && (ios || safari) && nativeHls(video);
}
function nativeHls(video: HTMLVideoElement): boolean {
return !!(video.canPlayType('application/vnd.apple.mpegurl') || video.canPlayType('application/x-mpegURL'));
}
export function isHlsSource(source: string): boolean {
const path = source.split(/[?#]/)[0];
return /\.m3u8$/i.test(path) || path.includes('/h5/m3u8/') && !/\.(?:mp4|webm|ogg|mov)$/i.test(path);
}
const CODEC_UNSUPPORTED = '当前浏览器或设备不支持此视频编码,请更换支持 H.264/H.265 的浏览器或设备';
export function isCodecFailure(data: {details?: string; reason?: string; error?: Error}): boolean {
if (['manifestIncompatibleCodecsError','bufferAddCodecError','bufferIncompatibleCodecsError'].includes(data.details || '')) return true;
const reason = `${data.reason || ''} ${data.error?.message || ''}`;
return data.error?.name === 'NotSupportedError' || /(?:unsupported|not supported|incompatible).*(?:codec|hevc|h[. ]?265|h[. ]?264)|(?:codec|hevc|h[. ]?265|h[. ]?264).*(?:unsupported|not supported|incompatible)/i.test(reason);
}
/** Own one media element and destroy all resources when switching source or route. */
export class MediaPlayback {
private hls?: Hls;
private generation = 0;
private disposed = false;
private ready = false;
private loading: Promise<void> = Promise.resolve();
private observer?: MutationObserver;
private onAbort = () => this.dispose();
private patch = () => applyInlinePlayback(this.video);
private onCodecError?: (event: Event) => void;
private onMediaError = (event: Event) => this.onCodecError?.(event);
private source = '';
constructor(private video: HTMLVideoElement, private options: {
signal: AbortSignal;
status: (message: string) => void;
error: () => void;
navigator?: Pick<Navigator, 'userAgent' | 'maxTouchPoints'>;
loadHls?: () => Promise<typeof Hls>;
}) {
applyInlinePlayback(video);
['loadedmetadata','play'].forEach(event => video.addEventListener(event,this.patch));
video.addEventListener('error',this.onMediaError);
if (typeof MutationObserver !== 'undefined') {
this.observer = new MutationObserver(this.patch);
this.observer.observe(video,{attributes: true, attributeFilter: ['controls','x5-video-player-type']});
}
options.signal.addEventListener('abort',this.onAbort,{once:true});
if (options.signal.aborted) this.dispose();
}
setSource(source: string): Promise<void> {
this.source = source;
const generation = ++this.generation;
const current = () => !this.disposed && generation === this.generation;
this.onCodecError = undefined;
this.ready = false;
this.hls?.destroy();this.hls = undefined;
this.video.pause();this.video.removeAttribute('src');this.video.load();
if (!current() || !source) return this.loading = Promise.resolve();
this.options.status('正在准备视频…');
let failureShown = false;
const fail = (message: string) => {if (current() && !failureShown) {failureShown = true;this.ready = false;this.options.status(message);this.options.error();}};
let attemptedNative = false;
let attemptedHls = false;
let switching = false;
let engine: 'native' | 'hls' | undefined;
const position = () => Number.isFinite(this.video.currentTime) ? this.video.currentTime : 0;
const resetMedia = () => {
this.hls?.destroy();this.hls = undefined;
this.video.pause();this.video.removeAttribute('src');this.video.load();
applyInlinePlayback(this.video);
};
const useNative = (resumeAt = 0) => {
if (!current() || attemptedNative || !nativeHls(this.video)) return false;
attemptedNative = true;engine = 'native';resetMedia();
if (resumeAt > 0) this.video.addEventListener('loadedmetadata',() => {
if (current() && engine === 'native') {try {this.video.currentTime = resumeAt;} catch { /* The native seek range may not be ready. */ }}
},{once:true,signal:this.options.signal});
this.video.src = source;this.ready = true;this.options.status('点击播放');
return true;
};
const useHls = async (resumeAt = 0) => {
if (!current() || attemptedHls) return false;
attemptedHls = true;
// Keep the full build: hls.js/light excludes HEVC in MPEG-2 TS.
const HlsClass = await (this.options.loadHls ?? (async () => (await import('hls.js')).default))();
if (!current() || !HlsClass.isSupported()) return false;
engine = 'hls';resetMedia();
const hls = this.hls = new HlsClass({
maxBufferLength: 30, maxMaxBufferLength: 60, liveSyncDurationCount: 3,
...(resumeAt > 0 ? {startPosition:resumeAt} : {}),
manifestLoadPolicy: {default: {...HlsClass.DefaultConfig.manifestLoadPolicy.default,maxLoadTimeMs: 10000}},
fragLoadPolicy: {default: {...HlsClass.DefaultConfig.fragLoadPolicy.default,maxLoadTimeMs: 20000}},
});
const active = () => current() && this.hls === hls;
let recoveries = 0;
hls.on(HlsClass.Events.MANIFEST_PARSED,() => {if (active() && this.ready) this.options.status('点击播放');});
hls.on(HlsClass.Events.ERROR,(_event,data) => {
// Let hls.js select another compatible H.264/HEVC rendition before falling back.
if (!active() || !data.fatal || data.levelRetry) return;
const codecError = isCodecFailure(data) || /bufferAppend(?:ing)?Error/.test(data.details) && this.video.error?.code === 3;
if (codecError) {
if (!useNative(position())) {hls.stopLoad();fail(CODEC_UNSUPPORTED);}
return;
}
try {
if (recoveries++ < 3) {
if (data.type === HlsClass.ErrorTypes.NETWORK_ERROR) {hls.startLoad();return;}
if (data.type === HlsClass.ErrorTypes.MEDIA_ERROR) {hls.recoverMediaError();return;}
}
} catch { /* Fall through to the visible retry state. */ }
hls.stopLoad();fail('视频加载失败,请重试');
});
hls.loadSource(source);hls.attachMedia(this.video);this.ready = true;
return true;
};
if (isHlsSource(source)) this.onCodecError = event => {
if (current() && engine === 'native' && this.video.error?.code === 2) {fail('视频加载失败,请重试');return;}
if (!current() || engine !== 'native' || ![3,4].includes(this.video.error?.code || 0)) return;
// Handle native decode/unsupported-source errors before the generic controls show an error.
event.stopImmediatePropagation();
if (switching) return;
switching = true;this.ready = false;
this.options.status('正在尝试兼容播放…');
const resumeAt = position();
this.loading = useHls(resumeAt).then(ok => {if (current() && !ok) fail(CODEC_UNSUPPORTED);}).catch(() => fail('播放器加载失败,请重试')).finally(() => {switching = false;});
};
else this.onCodecError = event => {
if (current() && this.video.error?.code === 2) {fail('视频加载失败,请重试');return;}
if (!current() || ![3,4].includes(this.video.error?.code || 0)) return;
event.stopImmediatePropagation();fail(CODEC_UNSUPPORTED);
};
this.loading = (async () => {
try {
if (isHlsSource(source)) {
if (preferNativeHls(this.video,this.options.navigator ?? window.navigator) && useNative()) return;
try {if (await useHls()) return;}
catch {if (!nativeHls(this.video)) throw new Error('播放器加载失败,请重试');}
if (!current()) return;
if (!useNative()) fail('当前浏览器不支持此视频格式');
return;
}
if (!current()) return;
this.video.src = source;this.ready = true;this.options.status('点击播放');
} catch (error) {fail(error instanceof Error ? error.message : '视频加载失败,请重试');}
})();
return this.loading;
}
async toggle() {
const generation = this.generation;
await this.loading;
if (this.disposed || generation !== this.generation || !this.ready) return;
if (!this.video.paused) {this.video.pause();return;}
try {await this.video.play();}
catch {if (!this.disposed && generation === this.generation) this.options.status('请再次点击播放');}
}
async retry() {await this.setSource(this.source);await this.toggle();}
dispose() {
if (this.disposed) return;
this.disposed = true;this.generation++;this.ready = false;
this.options.signal.removeEventListener('abort',this.onAbort);
this.observer?.disconnect();
['loadedmetadata','play'].forEach(event => this.video.removeEventListener(event,this.patch));
this.video.removeEventListener('error',this.onMediaError);this.onCodecError = undefined;
this.hls?.destroy();this.hls = undefined;
this.video.pause();this.video.removeAttribute('src');this.video.load();
}
}
+87
View File
@@ -0,0 +1,87 @@
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 { chapterAccess, chapterListGate, chapterRequiresPayment, initialPlayMode, INLINE_VIDEO_ATTRIBUTES, playbackUrl } from './playback';
/** Local guest avatar used while encrypted CDN avatars decrypt. */
export const COMMENT_AVATAR_FALLBACK='/assets/8837c3e58159bd48.png';
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;
}
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));
}
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 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 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()}
${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>`:''}
${bannerAds?`<section class="play-banner-ads" aria-label="推广广告">${bannerAds}</section>`:''}
${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);
}
+190
View File
@@ -0,0 +1,190 @@
import { contentPath } from '../routing';
import type { Chapter, Comment, 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 { 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 { 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}
export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,isVideo=false,paging:PlayerPagination={}):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);
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;}),
]);
if(!paging.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);
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[]){
const active=kind==='like'?engagement.isLike===1:engagement.isCollect===1;
buttons.forEach(button=>{
button.setAttribute('aria-pressed',String(active));
const label=button.querySelector('span');if(label)label.textContent=kind==='like'?(active?'已点赞':'点赞'):(active?'已收藏':'收藏');
});
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});
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();
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 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);
box.querySelector('.ui-empty-state')?.remove();
const center=box.querySelector<HTMLButtonElement>('[data-media="toggle"]');if(center)center.hidden=false;
status.hidden=false;
void media!.setSource(mode==='preview'?previewSource:access.allowed?access.source:'');
if(blocked)status.textContent=access.gate==='vip'?'开通会员后可观看本集':access.gate==='coins'?'本集需要金币解锁':'本集暂不可观看';
else if(!(mode==='preview'?previewSource:access.source))status.textContent='该剧集暂未提供视频源';
else if(mode==='preview')status.textContent='免费预告 · 点击播放';
}
page.querySelectorAll<HTMLButtonElement>('button[data-play-mode]').forEach(button=>button.addEventListener('click',()=>{setMode(button.dataset.playMode as PlayMode);if(mode==='preview')void media!.toggle();},{signal}));
setMode(mode);
document.addEventListener('eighteen:closed',()=>{if(mode==='preview'&&video.paused)void media!.toggle();},{once:true,signal});
video.addEventListener('playing',()=>{
if(reported||mode!=='main'||!access.allowed||!chapter)return;
reported=true;
void app.api.post('playlet/chapter/watch/report',{playletId:id,chapterId:chapter.id}).catch(()=>{reported=false;});
},{signal});
}
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.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;
const buttons=Array.from(page.querySelectorAll<HTMLButtonElement>(`[data-engage="${kind}"]`));
if(!buttons.length)buttons.push(element);
const finish=buttons.map(button=>beginButtonRequest(button));
pendingEngagements.add(kind);
try{
const result=await app.api.post<unknown>(kind==='like'?'playlet/like/set':'playlet/collect/set',kind==='like'?{id,isLike:!active}:{id,isCollect:!active});
if(signal.aborted)return;
applyEngagementSuccess(engagement,kind,active,result);syncEngagement(kind,buttons);
}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();}
}
})().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();};
}
+76
View File
@@ -0,0 +1,76 @@
/** Loop one overflow row using native scrolling, so touch scrolling and ad links keep working. */
export function bindPromotionMarquee(root:HTMLElement|null,signal:AbortSignal):()=>void {
const view=root?.ownerDocument.defaultView;
const group=root?.querySelector<HTMLElement>('.promo-group');
const track=root?.querySelector<HTMLElement>('.promo-track');
if(!root||!view||!group||!track||signal.aborted)return ()=>{};
const events=new AbortController(),eventSignal=events.signal;
const doc=root.ownerDocument,reduced=view.matchMedia('(prefers-reduced-motion: reduce)');
let copy:HTMLElement|undefined,frame:number|undefined,timer:number|undefined;
let distance=0,position=0,last:number|undefined,hover=false,pressed=false,manual=false,disposed=false;
function stop(){
if(frame!==undefined)view!.cancelAnimationFrame(frame);
frame=undefined;last=undefined;
}
function paused(){return disposed||!distance||hover||pressed||manual||doc.hidden||root!.contains(doc.activeElement)||reduced.matches;}
function resume(){
if(paused()){stop();return;}
position=((root!.scrollLeft%distance)+distance)%distance;
if(frame===undefined)frame=view!.requestAnimationFrame(tick);
}
function tick(time:number){
frame=undefined;
if(paused()||!root!.isConnected){stop();return;}
if(last!==undefined){
position=(position+Math.min(time-last,100)*0.032)%distance;
root!.scrollLeft=position;
}
last=time;frame=view!.requestAnimationFrame(tick);
}
function update(){
if(disposed)return;
const width=group!.getBoundingClientRect().width-parseFloat(view!.getComputedStyle(group!).paddingRight||'0');
const overflow=root!.clientWidth>0&&width>root!.clientWidth+1;
if(!overflow||reduced.matches){
stop();distance=0;copy?.remove();copy=undefined;root!.removeAttribute('data-looping');return;
}
root!.setAttribute('data-looping','');
distance=group!.getBoundingClientRect().width;
if(!copy){
copy=group!.cloneNode(true) as HTMLElement;
copy.setAttribute('data-promo-copy','');copy.setAttribute('aria-hidden','true');copy.removeAttribute('id');
copy.querySelectorAll('[id]').forEach(node=>node.removeAttribute('id'));
copy.querySelectorAll<HTMLElement>('a,button,input,select,textarea,[tabindex]').forEach(node=>{node.tabIndex=-1;});
track!.append(copy);
}
resume();
}
function settle(){
if(timer!==undefined)view!.clearTimeout(timer);
timer=view!.setTimeout(()=>{timer=undefined;manual=false;resume();},180);
}
function beginManual(){manual=true;stop();if(timer!==undefined)view!.clearTimeout(timer);}
root.addEventListener('pointerenter',event=>{if(event.pointerType==='mouse'){hover=true;stop();}},{signal:eventSignal});
root.addEventListener('pointerleave',event=>{if(event.pointerType==='mouse'){hover=false;resume();}},{signal:eventSignal});
root.addEventListener('pointerdown',()=>{pressed=true;beginManual();},{signal:eventSignal,passive:true});
view.addEventListener('pointerup',()=>{if(pressed){pressed=false;settle();}},{signal:eventSignal});
view.addEventListener('pointercancel',()=>{if(pressed){pressed=false;settle();}},{signal:eventSignal});
root.addEventListener('wheel',()=>{beginManual();settle();},{signal:eventSignal,passive:true});
root.addEventListener('scroll',()=>{if(manual&&!pressed)settle();},{signal:eventSignal,passive:true});
root.addEventListener('focusin',stop,{signal:eventSignal});
root.addEventListener('focusout',event=>{if(!root!.contains(event.relatedTarget as Node|null))settle();},{signal:eventSignal});
doc.addEventListener('visibilitychange',resume,{signal:eventSignal});
view.addEventListener('resize',update,{signal:eventSignal});
reduced.addEventListener('change',update,{signal:eventSignal});
const observer=typeof ResizeObserver==='undefined'?undefined:new ResizeObserver(update);
observer?.observe(root);observer?.observe(group);
function dispose(){
if(disposed)return;disposed=true;stop();observer?.disconnect();events.abort();
signal.removeEventListener('abort',dispose);
if(timer!==undefined)view!.clearTimeout(timer);
copy?.remove();root!.removeAttribute('data-looping');
}
signal.addEventListener('abort',dispose,{once:true});
update();return dispose;
}
+158
View File
@@ -0,0 +1,158 @@
import { contentPath, searchPath } from '../routing';
import type { Advertisement, Drama, HomeBlock, HomeData, Topic } from "../api/types";
import { formatCount, homeBlocks, homeBlockStyle, homePreviewItems, recordList, safeUrl } from "../api/protocol";
export const PLACEHOLDER = "/placeholder.png";
export const escape = (value: unknown): string => String(value ?? "").replace(/[&<>"']/g, char => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[char]!);
export function imageAttributes(value: unknown, fallback = PLACEHOLDER, target: 'src' | 'poster' = 'src'): string {
const source = safeUrl(value);
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);
}
export function empty(message = "暂时没有内容", retry = false): string {
return `<div class="ui-empty-state" role="status"><img class="api-empty-image" src="/assets/${retry?'7c46ae4811eea7e9.png':'2a5a7edaf95ff943.png'}" alt="${escape(message || "暂无内容")}"><p>${escape(message)}</p>${retry?'<button type="button" class="primary" data-api-action="retry">重新加载</button>':""}</div>`;
}
export const closeButton = (id: string): string => `<button type="button" class="close auth-close" data-close="${escape(id)}" 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>`;
export function highlightSearchTitle(text:string,term:string):string {
const normalize=(value:string)=>value.normalize('NFKC').toLowerCase().replace(/\s+/g,'');
const needle=normalize(term);if(!needle)return escape(text);
const positions:{start:number;end:number}[]=[];let normalized='',offset=0;
for(const char of text){const part=normalize(char);for(let i=0;i<part.length;i++)positions.push({start:offset,end:offset+char.length});normalized+=part;offset+=char.length;}
let cursor=0,from=0,result='',index:number;
while((index=normalized.indexOf(needle,from))!==-1){
const start=positions[index].start,end=positions[index+needle.length-1].end;
if(start>=cursor){result+=escape(text.slice(cursor,start))+`<span class="search-keyword">${escape(text.slice(start,end))}</span>`;cursor=end;}
from=index+needle.length;
}
return result+escape(text.slice(cursor));
}
/** Cover corner badge priority: 原创(isRecommend) > 爆剧(isFeatured) > 上新(isNew). */
export function coverCornerBadge(drama:Pick<Drama,'isRecommend'|'isFeatured'|'isNew'>|null|undefined):string {
if(!drama||typeof drama!=='object')return '';
if(drama.isRecommend)return '<span class="cover-corner-badge cover-corner-badge--original">原创</span>';
if(drama.isFeatured)return '<span class="cover-corner-badge cover-corner-badge--hot">爆剧</span>';
if(drama.isNew)return '<span class="cover-corner-badge cover-corner-badge--new">上新</span>';
return '';
}
export function card(drama: Drama, video = false, options: {rank?:number;keyword?:string;headingLevel?:2|3;layout?:'curated'|'ranked';label?:string} = {}): string {
if (!drama || typeof drama !== 'object' || Array.isArray(drama)) return '';
const tags = (Array.isArray(drama.tagNames) ? drama.tagNames : Array.isArray(drama.themeNames) ? drama.themeNames : []).filter(tag => typeof tag === 'string');
const badge = coverCornerBadge(drama);
const poster=`<img class="poster-source" ${imageAttributes(drama.cover || drama.coverImg || drama.horizontalCover)} alt="${escape(title(drama))}封面" loading="lazy" decoding="async">`;
const rank=typeof options.rank==='number'&&Number.isSafeInteger(options.rank)&&options.rank>0?options.rank:undefined;
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 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>`;
if(options.layout==='curated')return `<a class="curated-card drama-data-card" ${attributes} aria-label="查看${escape(text)}"><span class="media portrait">${poster}${badge}${episodes}</span><div class="curated-copy">${options.label?`<span class="pick">${escape(options.label)}</span>`:''}<${headingTag}>${heading}</${headingTag}>${description}${meta}<span class="watch-link">立即观看 <svg class="ui-chevron-right" viewBox="0 0 24 24" aria-hidden="true"><path d="m9 5 7 7-7 7"/></svg></span></div></a>`;
if(options.layout==='ranked'){
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>`;
}
export function hero(value: unknown, selected = 0): string {
const items = recordList<Drama>(value);
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>`;
}
/** The fixed recommendation grid uses real enabled homepage content, independently of the carousel. */
export function homeRecommendations(home:HomeData|null|undefined):Drama[] {
const configured=homeBlocks(home).filter(block=>block.type===1).flatMap(block=>recordList<Drama>(block.list));
const seen=new Set<number>();
return [...configured,...activeBanners(home)].filter(item=>Number.isSafeInteger(item.id)&&item.id>0&&!seen.has(item.id)&&!!seen.add(item.id)).slice(0,6);
}
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>`;
}).join('')}</div>`;
}
export function topicCard(topic: Topic, theme = false, headingLevel:2|3=3,kind:'topics'|'theme'='topics'): string {
if (!topic || typeof topic !== 'object' || Array.isArray(topic)) return '';
const label=String(topic.name||'').trim()||(theme?'题材短剧':'精选专题');
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>`;
}
export function ads(items: unknown = [], className = "promo-app"): string {
const tile=className==='promo-tile';
const iconTile=className==='member-ad-tile';
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>`;
}).join("");
}
/** Use the same square ad row on every page; the runtime loops only overflow. */
export function promotionRow(items:unknown):string {
const content=ads(items,'promo-tile');
return content?`<div class="promo-marquee" data-promotion-row><div class="promo-track"><div class="promo-page promo-group">${content}</div></div></div>`:'';
}
/** Map API style to the prototype section shell from 禁果短剧_2组.html (A romance / B library / C|D topic-section / E topic-recommendations). */
function homeSectionShell(style:ReturnType<typeof homeBlockStyle>):{className:string;id?:string} {
if(style==='A')return {className:'topic-section romance-editorial home-block'};
if(style==='B')return {className:'home-block',id:'library'};
if(style==='E')return {className:'topic-section topic-recommendations home-block'};
return {className:'topic-section home-block'};
}
export function blockSection(block: HomeBlock): string {
if(![1,3].includes(block.type))return '';
const configuredUrl=typeof block.moreUrl==='string'?block.moreUrl.trim():'';
const moreUrl=configuredUrl&&safeUrl(configuredUrl,'https://site.invalid/')?configuredUrl:contentPath('config',block);
const style=homeBlockStyle(block);
const shell=homeSectionShell(style);
const gridClass=style==='C'?'curated-pairs':style==='D'?'ranked-shelf':style==='E'?'topic-recommendation-grid':'cards';
const content = block.type===3 ? `<div class="topic-recommendation-grid">${homePreviewItems<Topic>(block,block.themeList).map(item=>topicCard(item,true,3,block.topicKind)).join("")||empty()}</div>` : `<div class="${gridClass}">${homePreviewItems<Drama>(block,block.list).map((item,index)=>card(item,false,style==='C'?{layout:'curated',label:'最新上架'}:style==='D'?{layout:'ranked',rank:index+1}:{})).join("")||empty()}</div>`;
return `<section${shell.id?` id="${escape(shell.id)}"`:''} class="${shell.className}"${style?` data-home-style="${style}"`:''} aria-labelledby="home-block-${block.id}"><div class="section-head"><div class="section-heading-copy"><h2 id="home-block-${block.id}">${escape(block.name)}</h2>${block.desc?`<p class="section-subtitle">${escape(block.desc)}</p>`:""}</div><a class="topic-more" href="${escape(moreUrl)}">查看更多 <span aria-hidden="true"><svg class="ui-chevron-right" viewBox="0 0 24 24"><path d="m9 5 7 7-7 7"/></svg></span></a></div>${content}</section>`;
}
/** Source homepage inserts one two-column banner row after every second topic section. */
function homeBlocksMarkup(blocks:HomeBlock[],banners:unknown,error?:string):string {
if(error)return empty(error,true);
const sections=blocks.map(blockSection).filter(Boolean);
if(!sections.length)return empty();
const creatives=recordList<Advertisement>(banners).filter(item=>safeUrl(item.images));
return sections.map((section,index)=>{
if((index+1)%2)return section;
const row=ads(creatives.slice(index-1,index+1),'wide-ad');
return row?`${section}<div class="wide-ad-row" aria-label="推广广告">${row}</div>`:section;
}).join('');
}
export function renderHome(home: HomeData | null | undefined, error?: string): string {
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>`;
}
export type PageHref=(page:number)=>string;
export function pagination(total:number,page:number,href:PageHref,size=24):string {
const pages=Math.max(1,Math.ceil((Number.isFinite(total)?Math.max(0,total):0)/size));
if(pages<=1&&page<=1)return '';
const numbers=[...new Set([1,page-1,page,page+1,pages])].filter(value=>value>=1&&value<=pages).sort((a,b)=>a-b);
const link=(value:number,label:string,current=false,rel='')=>`<a href="${escape(href(value))}"${current?' aria-current="page"':''}${rel?` rel="${rel}"`:''}>${label}</a>`;
const previous=page>1?`<a href="${escape(href(page-1))}" rel="prev" aria-label="上一页">‹</a>`:'<span aria-disabled="true" aria-label="上一页">‹</span>';
const next=page<pages?`<a href="${escape(href(page+1))}" rel="next" aria-label="下一页">›</a>`:'<span aria-disabled="true" aria-label="下一页">›</span>';
return `<nav class="channel-pagination" aria-label="分页"><div class="pagination-pages">${previous}${numbers.map((value,index)=>(index&&value-numbers[index-1]>1?'<span class="pagination-gap" aria-hidden="true">…</span>':'')+link(value,String(value),value===page)).join('')}${next}</div><form class="pagination-jump" action="${escape(href(1))}" data-page-base="${escape(href(1))}"><label>前往 <input type="number" name="page" min="1" max="${pages}" step="1" placeholder=" " required aria-label="跳转页码"> 页</label><button type="submit">确定</button></form></nav>`;
}
+27
View File
@@ -0,0 +1,27 @@
export type EngagementKind='like'|'collect';
export interface EngagementState {isLike?:number;isCollect?:number;countLike?:number;countCollect?:number}
const count=(value:unknown):number|undefined=>typeof value==='number'&&Number.isFinite(value)&&value>=0?Math.floor(value):undefined;
/** Documented engagement flags are 1 (active) and 2 (inactive). */
const reportedFlag=(value:unknown):1|2|undefined=>value===1||value===2?value:undefined;
/** Empty success responses still commit the requested state and its local count delta. */
export function applyEngagementSuccess(state:EngagementState,kind:EngagementKind,active:boolean,result:unknown):void {
const flag=kind==='like'?'isLike':'isCollect',field=kind==='like'?'countLike':'countCollect';
const response=result&&typeof result==='object'&&!Array.isArray(result)?result as Record<string,unknown>:{};
state[field]=count(response[field])??Math.max(0,(count(state[field])??0)+(active?-1:1));
state[flag]=reportedFlag(response[flag])??(active?2:1);
}
/** Preserve icons and the original label on failure; keep a new success label when supplied. */
export function beginButtonRequest(button:HTMLButtonElement,message='请求中'):()=>void {
const label=button.querySelector<HTMLElement>('[data-request-label]')||button.querySelector<HTMLElement>('span')||button;
const text=label.textContent,disabled=button.disabled,busy=button.getAttribute('aria-busy');
button.disabled=true;button.setAttribute('aria-busy','true');button.setAttribute('data-request-pending','');
label.textContent=message;
return ()=>{
if(label.textContent===message)label.textContent=text;
button.disabled=disabled;button.removeAttribute('data-request-pending');
if(busy===null)button.removeAttribute('aria-busy');else button.setAttribute('aria-busy',busy);
};
}
+83
View File
@@ -0,0 +1,83 @@
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';
export const SEARCH_PAGE_SIZE=25;
/** Suggestions are dramas; hot searches are keywords. Their destinations differ. */
export function searchSuggestionsMarkup(value:unknown,query:string):string {
return recordList<Drama&{keyword?:string;name?:string}>(value).map((item,index)=>{
const text=String(item.playName||item.keyword||item.title||item.name||'');
if(!text)return '';
const href=query&&Number.isSafeInteger(item.id)&&item.id>0?contentPath('detail',item):searchPath(text);
return `<a class="search-suggestion" href="${escape(href)}"><span class="suggestion-number">${index+1}</span><span class="suggestion-copy"><strong>${escape(text)}</strong></span></a>`;
}).join('')||'<p class="search-discovery-status">暂无搜索建议</p>';
}
/** Dropdown recommendations use the compact HTML cards, not the page's large drama cards. */
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>`;
}).join('');
}
/** Match the reference's 458px desktop panel and fit it below the search field. */
export function positionSearchDiscovery(form:HTMLElement,panel:HTMLElement):void {
if(panel.hidden)return;
if(window.innerWidth<=760){panel.style.removeProperty('width');panel.style.removeProperty('--search-panel-scale');return;}
const rect=form.getBoundingClientRect();
panel.style.width=Math.max(0,Math.min(458,window.innerWidth-rect.left-20))+'px';
const available=Math.max(120,window.innerHeight-rect.bottom-28);
panel.style.setProperty('--search-panel-scale',String(Math.min(1,available/Math.max(1,panel.offsetHeight))));
}
export interface SearchResults {
keyword:string;
list:Drama[];
recommended:Drama[];
total:number;
page:number;
pageSize:number;
}
function searchItems(value:unknown):Drama[] {
return recordList<Drama>(value).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).map(item=>({...item,title:typeof item.playName==='string'&&item.playName.trim()?item.playName.trim():item.title}));
}
export async function loadSearchResults(api:Pick<ApiClient,'get'>,term:string,page:number,signal:AbortSignal):Promise<SearchResults> {
const keyword=term.trim();
const result:SearchResults={keyword,list:[],recommended:[],total:0,page,pageSize:SEARCH_PAGE_SIZE};
signal.throwIfAborted();
if(keyword){
const data=await api.get<PageData<Drama>>('playlet/search',{keyword,pageNum:page,pageSize:SEARCH_PAGE_SIZE},signal);
signal.throwIfAborted();
result.list=searchItems(data?.list);
result.total=typeof data?.totalCount==='number'&&Number.isSafeInteger(data.totalCount)&&data.totalCount>=0?data.totalCount:result.list.length;
if(!result.list.length)result.recommended=searchItems(data?.everyoneWatching).filter((item,index,items)=>items.findIndex(other=>other.id===item.id)===index).slice(0,6);
if(typeof data?.keyword==='string'&&data.keyword.trim())result.keyword=data.keyword.trim();
if(typeof data?.pageSize==='number'&&Number.isSafeInteger(data.pageSize)&&data.pageSize>0&&data.pageSize<=50)result.pageSize=data.pageSize;
}
if(!result.list.length&&!result.recommended.length){
try{
const data=await api.get<PageData<Drama>>('playlet/operate/list',{pageNum:1,pageSize:6,type:2},signal);
result.recommended=searchItems(data?.list).filter((item,index,items)=>items.findIndex(other=>other.id===item.id)===index).slice(0,6);
}catch(error){if(signal.aborted)throw error;}
}
signal.throwIfAborted();
return result;
}
function heading(keyword:string,count?:number):string {
return `<nav class="page-breadcrumb" aria-label="面包屑导航"><a href="/">首页</a><span>/</span><span aria-current="page">搜索结果</span></nav><header class="search-results-heading"><h1 id="search-results-title">${keyword?`为您找到关于“<em>${escape(keyword)}</em>”的短剧`:'搜索你想看的短剧'}</h1>${count===undefined?'':`<p class="search-results-count" role="status">共 <strong>${count}</strong> 个结果</p>`}</header>`;
}
export const searchErrorMarkup=(keyword:string,message:string)=>heading(keyword.trim())+`<div class="ui-empty-state search-empty" role="alert"><img src="/assets/7c46ae4811eea7e9.png" alt="搜索加载失败提示" aria-hidden="true"><h2>搜索暂时不可用</h2><p>${escape(message)}</p><button type="button" class="ui-empty-action" data-api-action="retry">重新加载</button></div>`;
export function searchResultsMarkup(data:SearchResults,pageHref:PageHref):string {
const results=data.list.length?`<div class="cards" aria-label="搜索结果">${data.list.map(item=>card(item,false,{keyword:data.keyword,headingLevel:2})).join('')}</div>`:`<section class="ui-empty-state search-empty" aria-label="没有搜索结果" role="status"><img src="/assets/f2ef01f956fdcd80.png" alt="${data.keyword ? "搜索无结果提示" : "输入关键词搜索短剧"}" aria-hidden="true"><h2>${data.keyword?'搜索无结果':'搜索你想看的短剧'}</h2><p>${data.keyword?'换个关键词试试,也许下一部好剧就在这里。':'输入剧名、演员或标签,发现想看的好剧。'}</p><a class="ui-empty-action" href="/shorts/">查看全部短剧</a></section>`;
const recommendations=data.recommended.length?`<section class="topic-section search-recommendations" aria-labelledby="search-recommendations-title"><div class="section-head"><h2 id="search-recommendations-title">大家都在看</h2><a class="topic-more" href="/shorts/">查看全部 <span aria-hidden="true">›</span></a></div><div class="cards" aria-label="热门推荐">${data.recommended.map(item=>card(item)).join('')}</div></section>`:'';
return heading(data.keyword,data.total)+`<div data-search-results data-search-keyword="${escape(data.keyword)}" data-search-page="${data.page}" data-search-page-size="${data.pageSize}">${results}</div>`+(data.keyword?pagination(data.total,data.page,pageHref,data.pageSize):'')+recommendations;
}
+35
View File
@@ -0,0 +1,35 @@
import type { PageHead } from '../seo';
import { JSON_LD_ATTRIBUTE, serializeStructuredData } from '../structured-data';
/** Update the existing SSR tags and remove stale/duplicate tags after SPA navigation. */
export function applyPageHead(head:PageHead,doc:Document=document):void {
doc.title=head.title;
function tag(selector:string,element:'meta'|'link',attributes:Record<string,string>,value:string|undefined){
const matches=Array.from(doc.head.querySelectorAll(selector));
const node=matches.shift();matches.forEach(item=>item.remove());
if(!value){node?.remove();return;}
const target=node||doc.createElement(element);
for(const [name,content] of Object.entries(attributes))target.setAttribute(name,content);
target.setAttribute(element==='meta'?'content':'href',value);
if(!node)doc.head.appendChild(target);
}
const meta=(name:string,value:string|undefined)=>tag(`meta[name="${name}"]`,'meta',{name},value);
const og=(property:string,value:string|undefined)=>tag(`meta[property="${property}"]`,'meta',{property},value);
const link=(rel:string,value:string|undefined)=>tag(`link[rel="${rel}"]`,'link',{rel},value);
meta('description',head.description);meta('keywords',head.keywords.join(','));meta('application-name',head.siteName);
meta('robots',head.robots);meta('googlebot',head.robots);
link('canonical',head.canonical);link('prev',head.previous);link('next',head.next);
og('og:locale','zh_CN');og('og:type',head.type);og('og:site_name',head.siteName);
og('og:title',head.title);og('og:description',head.description);og('og:url',head.canonical);og('og:image',head.image||undefined);
og('og:image:width',head.image?'1200':undefined);og('og:image:height',head.image?'630':undefined);og('og:image:alt',head.image?head.siteName:undefined);
meta('twitter:card','summary_large_image');meta('twitter:title',head.title);meta('twitter:description',head.description);
meta('twitter:image',head.image||undefined);meta('twitter:image:alt',head.image?head.siteName:undefined);
const selector=`script[${JSON_LD_ATTRIBUTE}]`;
const scripts=[...new Set([...doc.head.querySelectorAll(selector),...doc.querySelectorAll(selector)])];
const script=scripts.shift();scripts.forEach(item=>item.remove());
if(!head.jsonLd){script?.remove();return;}
const target=script||doc.createElement('script');
target.setAttribute('id','site-json-ld');target.setAttribute('type','application/ld+json');target.setAttribute(JSON_LD_ATTRIBUTE,'');
target.textContent=serializeStructuredData(head.jsonLd);
if(!script)doc.head.appendChild(target);
}
+319
View File
@@ -0,0 +1,319 @@
import type { Advertisement } from '../api/types';
import { recordList, safeUrl } from '../api/protocol';
const AGE_KEY = 'hg_age_ok';
const DISMISSED_KEY = 'dialogAdDismissed';
const PROGRESS_KEY = 'dialogAdProgress';
const GRID_DISMISSED_KEY = 'gridAdDismissed';
function advertisements(list: unknown): Advertisement[] {
return recordList<Advertisement>(list).map(item => ({...item,
id: String(item.advertiseCode || '').trim() || String(item.id || '').trim(),
images: safeUrl(item.images), link: safeUrl(item.link), title: String(item.title || '').trim(),
})).filter(item => item.images);
}
type SessionStore = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
export interface StartupView {
showAge(): void;
closeAge(): void;
prepareAdvertisement(): void;
loadAdvertisement(item: Advertisement, signal: AbortSignal): Promise<void>;
showAdvertisement(item: Advertisement): void;
hideAdvertisement(): void;
loadGrid(items: Advertisement[], signal: AbortSignal): Promise<Advertisement[]>;
showGrid(items: Advertisement[]): void;
hideGrid(): void;
}
/** Session keys, signature version and resume behavior for JHA-1050. */
export class StartupDialogs {
private accepted = false;
private items: Advertisement[] = [];
private signature = '';
private index = 0;
private readyIndex = -1;
private visibleIndex = -1;
private displayed = false;
private generation = 0;
private loading?: AbortController;
private advertisementPending = false;
private gridSignature = '';
private gridItems: Advertisement[] = [];
private gridLoading?: AbortController;
private gridVisible = false;
private gridDismissed = false;
constructor(private storage: SessionStore, private view: StartupView, confirmed = false) {
this.accepted = confirmed || this.read(AGE_KEY) === '1';
if (this.accepted) view.closeAge(); else view.showAge();
}
private read(key: string) {try {return this.storage.getItem(key) || '';} catch {return '';}}
private write(key: string, value: string) {try {this.storage.setItem(key, value);} catch {}}
private remove(key: string) {try {this.storage.removeItem(key);} catch {}}
confirmAge() {
this.write(AGE_KEY, '1');
this.accepted = true;
this.view.closeAge();
this.showReady();
this.showGridReady();
}
setAdvertisements(list: Advertisement[], gridList: Advertisement[] = []) {
const items = advertisements(list);
const signature = items.length ? '2|' + items.map((item, index) => item.id || item.images || String(index)).join('|') : '';
const changed = signature !== this.signature;
if (changed) {
this.generation++;
this.loading?.abort();
this.view.hideAdvertisement();
this.view.hideGrid();
this.gridVisible = false;
this.items = items;
this.signature = signature;
this.readyIndex = this.visibleIndex = -1;
this.displayed = false;
this.advertisementPending = !!items.length && this.read(DISMISSED_KEY) !== signature;
}
this.setGridAdvertisements(gridList);
if (changed && this.advertisementPending) void this.showItem(this.readProgress());
this.showGridReady();
}
private setGridAdvertisements(list: Advertisement[]) {
const items = advertisements(list);
const signature = items.length ? JSON.stringify(items.map(item => [item.id, item.images, item.link, item.title])) : '';
if (signature === this.gridSignature) return;
this.gridLoading?.abort();
this.view.hideGrid();
this.gridVisible = false;
this.gridItems = [];
this.gridSignature = signature;
this.gridDismissed = this.read(GRID_DISMISSED_KEY) === signature;
if (!items.length || this.gridDismissed) return;
const controller = this.gridLoading = new AbortController();
void this.view.loadGrid(items, controller.signal).then(loaded => {
if (controller.signal.aborted) return;
this.gridItems = loaded;
this.showGridReady();
}).catch(() => { /* A failed grid must never block the page or the other dialogs. */ });
}
private showGridReady() {
if (!this.accepted || this.advertisementPending || this.gridVisible || this.gridDismissed || !this.gridItems.length) return;
this.gridVisible = true;
this.view.showGrid(this.gridItems);
}
dismissGrid() {
if (!this.gridVisible) return;
this.gridDismissed = true;
this.gridVisible = false;
this.write(GRID_DISMISSED_KEY, this.gridSignature);
this.view.hideGrid();
}
private readProgress() {
try {
const progress = JSON.parse(this.read(PROGRESS_KEY));
const value = Number(progress?.index);
return progress?.signature === this.signature && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
} catch {return 0;}
}
private async showItem(index: number) {
const generation = ++this.generation;
this.loading?.abort();
this.readyIndex = this.visibleIndex = -1;
this.index = index;
if (index >= this.items.length) {this.finish(); return;}
this.write(PROGRESS_KEY, JSON.stringify({signature: this.signature, index}));
this.view.prepareAdvertisement();
const controller = this.loading = new AbortController();
try {
await this.view.loadAdvertisement(this.items[index], controller.signal);
if (controller.signal.aborted || generation !== this.generation) return;
this.readyIndex = index;
this.showReady();
} catch {
if (!controller.signal.aborted && generation === this.generation) void this.showItem(index + 1);
}
}
private showReady() {
if (!this.accepted || this.readyIndex < 0 || this.visibleIndex === this.readyIndex) return;
this.visibleIndex = this.readyIndex;
this.displayed = true;
this.view.showAdvertisement(this.items[this.readyIndex]);
}
nextAdvertisement() {
if (!this.accepted || this.visibleIndex < 0) return;
void this.showItem(this.visibleIndex + 1);
}
private finish() {
if (this.displayed) this.write(DISMISSED_KEY, this.signature); else this.remove(DISMISSED_KEY);
this.remove(PROGRESS_KEY);
this.view.hideAdvertisement();
this.readyIndex = this.visibleIndex = -1;
this.advertisementPending = false;
this.showGridReady();
}
}
export function loadStartupImage(image: HTMLImageElement, source: string, signal: AbortSignal, timeoutMs = 12000): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false;
const timer = setTimeout(() => finish(new Error('广告图片加载超时')), timeoutMs);
function finish(error?: Error) {
if (settled) return;
settled = true;
clearTimeout(timer);
image.removeEventListener('load', loaded);
image.removeEventListener('error', failed);
signal.removeEventListener('abort', aborted);
if (error) reject(error); else resolve();
}
function loaded() {finish(image.naturalWidth > 0 ? undefined : new Error('广告图片无法解码'));}
function failed() {finish(new Error('广告图片加载失败'));}
function aborted() {finish(new DOMException('Aborted', 'AbortError'));}
function check() {if (image.complete) loaded();}
image.addEventListener('load', loaded);
image.addEventListener('error', failed);
signal.addEventListener('abort', aborted, {once: true});
if (signal.aborted) {aborted(); return;}
try {
if (window.processImage) {
Promise.resolve(window.processImage(source, image)).then(result => {
if (!result) finish(new Error('广告图片解码失败')); else check();
}).catch(error => finish(error instanceof Error ? error : new Error('广告图片解码失败')));
} else {image.src = source; check();}
} catch (error) {finish(error instanceof Error ? error : new Error('广告图片加载失败'));}
});
}
export function startStartupDialogs(): StartupDialogs {
const age = document.getElementById('startup-age') as HTMLDialogElement;
const ad = document.getElementById('startup-ad') as HTMLDialogElement;
const grid = document.getElementById('startup-grid') as HTMLDialogElement;
const promotions = grid.querySelector<HTMLElement>('.startup-promotions')!;
let gridLinks = new Map<Advertisement, HTMLAnchorElement>();
let image = ad.querySelector<HTMLImageElement>('.startup-ad-image')!;
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() {
const wasOpen = age.open;
if (wasOpen) age.close();
document.documentElement.classList.add('eighteen-ok');
if (wasOpen) document.dispatchEvent(new CustomEvent('eighteen:closed'));
},
prepareAdvertisement() {media.hidden = true; loading.hidden = false; close.disabled = true; ad.setAttribute('aria-busy', 'true');},
async loadAdvertisement(item, signal) {
const pending = image.cloneNode(false) as HTMLImageElement;
pending.removeAttribute('src');
pending.setAttribute('fetchpriority', 'high');
await loadStartupImage(pending, item.images, signal);
if (signal.aborted) return;
image.replaceWith(pending);
image = pending;
},
showAdvertisement(item) {
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');},
async loadGrid(items, signal) {
const links = new Map<Advertisement, HTMLAnchorElement>();
const loaded = await Promise.all(items.map(async item => {
const link = document.createElement('a');
link.className = 'startup-promotion';
link.target = '_blank';
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 || '推荐应用';
icon.decoding = 'async';
const name = document.createElement('span');
name.textContent = item.title || '推荐应用';
link.append(icon, name);
try {
await loadStartupImage(icon, item.images, signal);
links.set(item, link);
return item;
} catch {return null;}
}));
if (signal.aborted) return [];
gridLinks = links;
return loaded.filter((item): item is Advertisement => item !== null);
},
showGrid(items) {
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();}},
};
const storage: SessionStore = {
getItem: key => window.sessionStorage.getItem(key),
setItem: (key, value) => window.sessionStorage.setItem(key, value),
removeItem: key => window.sessionStorage.removeItem(key),
};
const startup = new StartupDialogs(storage, view, document.documentElement.classList.contains('eighteen-ok'));
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();}
close.addEventListener('click', nextAdvertisement);
ad.addEventListener('cancel', event => {event.preventDefault(); nextAdvertisement();});
grid.querySelector('.startup-close')!.addEventListener('click', () => startup.dismissGrid());
grid.addEventListener('cancel', event => {event.preventDefault(); startup.dismissGrid();});
promotions.addEventListener('click', event => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest('.startup-promotion[href]')) startup.dismissGrid();
});
[age, ad, grid].forEach(dialog => {
dialog.addEventListener('keydown', event => {if (event.key === 'Tab') dialog.classList.add('startup-keyboard-focus');});
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
@@ -0,0 +1,136 @@
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};
}
+71
View File
@@ -0,0 +1,71 @@
import type { FooterInfoResponse, FooterNavItem } from './api/types';
export interface FooterPolicyLink {
id: number;
name: string;
href: string;
external: boolean;
}
/** Bottom navigation: children of each group, or the group itself when it has none. */
export function footerNavItems(data: FooterInfoResponse | null | undefined): FooterNavItem[] {
const items: FooterNavItem[] = [];
for (const category of data?.footerInfo?.navList || []) {
const children = (category?.translationList || []).filter(item => item?.name?.trim());
if (children.length) {
items.push(...children);
continue;
}
if (category?.name?.trim() && category.urlValue?.trim()) items.push(category);
}
return items;
}
function richTextPath(title: string): string {
const value = title.trim();
if (!value || value === '.' || value === '..' || value.length > 200 || /[\\/\u0000-\u001f\u007f]/.test(value)) return '';
return `/richtext/${encodeURIComponent(value)}/`;
}
/** Keep external addresses. `/richetext/` is the same page as `/richtext/`. A bare title becomes that page. */
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;
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);
if (titled) {
let title = titled[1];
try { title = decodeURIComponent(title); } catch { /* The segment is already a title. */ }
return richTextPath(title);
}
if (/^\/richtext\/?$/i.test(path)) return '';
if (!raw.startsWith('/')) return richTextPath(raw);
return path.endsWith('/') ? path : `${path}/`;
}
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 [];
return [{
id: typeof item.id === 'number' && Number.isSafeInteger(item.id) ? item.id : index,
name,
href,
external: /^https?:\/\//i.test(href) || href.startsWith('mailto:'),
}];
});
}
export function footerLinkCurrent(address: string, href: string): boolean {
try {
const current = new URL(address || '/', 'https://site.invalid');
const target = new URL(href, 'https://site.invalid');
return current.origin === target.origin && current.pathname.replace(/\/+$/, '') === target.pathname.replace(/\/+$/, '');
} catch {
return false;
}
}
+300
View File
@@ -0,0 +1,300 @@
import type { Config, FooterContact } from "./api/types";
/** Original HTML copy and markup; the about page's contacts use site configuration. */
export interface InfoPageDefinition {key:string;title:string;html:string}
const EMPTY_CONTACT_SECTION='<section class="about-section" data-site-contacts hidden></section>';
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>
</section>
<section class="about-section">
<h2>产品愿景</h2>
<p>围绕内容发现与观看体验,持续完善搜索、收藏、追剧记录及多端体验,让每一次打开都能更快找到想看的故事。</p>
<p>品牌主张:好故事,值得被看见。</p>
</section>
${EMPTY_CONTACT_SECTION}
`},
"user-agreement": {key:"user-agreement",title:"用户协议",html:`
<section class="about-section">
<h2>使用协议</h2>
<p>使用条款 欢迎您光临【禁果短剧】(以下简称本网站),本网站由「禁果短剧」(以下简称禁果短剧)经营。</p>
<p>本网站重视每一位使用者所享有的服务,特此说明本网站的使用政策,以保障您的权益。</p>
<p>请您仔细阅读本使用条款的内容:</p>
</section>
<section class="about-section">
<h2>关于《使用条款》</h2>
<p>在您决定使用本网站所提供的服务(以下简称本服务)前,请仔细阅读本使用条款。您必须在完全同意以下条款的前提下,才能使用本服务。本使用条款与其他附加条件或其他特殊条款相矛盾时,以附加条件或特殊条款为准。 本网站有权在任何时间修改或变更本使用条款的内容,您应经常查看以了解您当前的权利及义务。若您在本网站对本使用条款进行任何修改或变更后仍继续使用本服务,视为您已同意接受本使用条款的修改及变更。 您确认您在访问本网站的司法管辖区内的年龄至少为18岁,或者确认您已经达到所在地法律要求能够查看成人内容的年龄,并且完全有能力履行及遵守本使用条款。如果您未满18岁,您应立即停止使用本服务。您并同意并表示您访问本服务的管辖区不禁止接收或查看色情内容。</p>
</section>
<section class="about-section">
<h2>服务内容</h2>
<p>本服务的具体内容由本网站根据实际情况提供,并对其所提供之服务拥有最终解释权。</p>
<p>本服务系通过互联网向您提供各项网络信息服务,您必须自行配备所需的一切设备,并承担所需费用。</p>
<p>本网站对用户的联系将以电子邮件、网页的消息揭示及其他本网站判断为适宜的方式进行。通知以本网站发出之时视为用户已收到该通知。</p>
<p>本网站对用户自身在各服务内的账号及密码,用户需自行承担管理责任,并知悉本网站仅提供各项网络信息服务,而不包括各服务。第三方不正当的使用等情况本网站概不负责。</p>
<p>本网站及各服务的用户资格,或基于用户资格使用本服务的权利及义务等,不得通过任何方式使第三方以任何方式使用或进行其他处分。</p>
<p>若您的行为违反本使用条款之禁止规定,本网站得不经事先通知限制或者解除您使用本网站提供服务的资格。因前项措施造成用户损害的情况本网站概不负责,本网站对您违反服务条款造成的一切损害、损失、费用(含诉讼费用及律师费用)均可提出赔偿要求。</p>
<p>我们保留删除任何达到一定期间不活跃的账户的权利。已绑定账号而连续一年内无登录记录的账户视为放弃该账户之权利,我们将进行封存处理,用户可申请账号复原,但我方不保证该账户的完整性或恢复的可能性。未绑定账号(即访客)而连续半年内无登录记录的账户视为放弃该账户之权利,我们将进行封存处理,用户可申请账号复原,但我方不保证该账户的完整性或恢复的可能性。</p>
</section>
<section class="about-section">
<h2>授权</h2>
<p>在本使用条款的约束下,本网站仅此授予您有限、仅供个人使用、非商业用途、非专属且不可转让的使用本服务之权利。</p>
</section>
<section class="about-section">
<h2>使用者的行为</h2>
<p>您在使用本网站服务过程中,必须遵循以下原则:</p>
<p>(a) 遵守本网站适用之有关的法律和法规;</p>
<p>(b) 遵守所有与网络服务有关的网络协议、规定和程序;</p>
<p>(c) 不得为任何非法目的而使用网络服务系统;</p>
<p>(d) 不得使用任何设备、软件或程序,干扰或试图干扰本网站、本网站之正常运作;</p>
<p>(e) 不允许侵入、强行入侵、接通、使用或企图侵入、接通、使用本网站服务器及未经本网站对您发出许可的任何资料区;</p>
<p>(f) 不得限制、制止其他使用者使用并享用本服务;</p>
<p>(g) 如发现任何非法使用账号或任何出现安全漏洞的情况,应立即告知本网站;</p>
<p>(h) 违反公序良俗或法令的行为;</p>
<p>(i) 违反儿童色情、色情、猥亵物等相关法令的行为;</p>
<p>(j) 将虚假情报在本服务登录的行为;</p>
<p>(k) 违反本网站其他或个别条款的行为;</p>
<p>(l) 对本网站或本服务有隐匿、欺诈、延迟给付或不为给付财产等行为;</p>
<p>(m) 其他本网站判断为不适当的行为。</p>
</section>
<section class="about-section">
<h2>知识产权</h2>
<p>本网站提供的服务内容(包括但不限于:文字、照片、图形、图像、图表、声音、FLASH动画、视频、音频、商标等),均由本网站或其他相关权利人依法拥有,并受商业形象、著作权法、商标法、公平交易法以及各类知识产权法之保护。</p>
<p>非经本网站或其他相关权利人之事先书面同意,本网站及其内容的任何部分,不得修改、复制、重制、重印、公开播送、改作、散布、发行、公开发表及为任何非法目的使用。</p>
<p>经本网站或其他相关权利人之事先书面同意,您在修改、复制、重制、重印、公开播送、改作、散布、发行、公开发表本网站及其内容的任何部分时,必须详细标明本网站之名称及网址为其资料来源。</p>
</section>
<section class="about-section">
<h2>广告信息</h2>
<p>本网站上如有传播或刊载之广告,关于其内容所展现的文字、图片、动画、声音、影像或以其他方式对商品或服务的说明,均由广告主或广告代理商提供。对于广告之内容,本网站基于尊重广告主或广告代理商权利,不对广告内容进行审查,您应自行判断该广告的正确性及可信度,本网站不对该等广告作任何担保。</p>
</section>
<section class="about-section">
<h2>链接</h2>
<p>本网站或所有服务相关网页上所提供的所有链接,可能链接到其他个人、公司或组织之网站,提供该等链接之目的,仅为便利使用者自行搜集或取得信息,本网站对于被链接的该等个人、公司或组织之网站上所提供之产品、服务或信息,既不担保其真实性、完整性、即时性或可信度,该等个人、公司或组织亦不因此而当然与本网站有任何雇佣、委任、代理、合伙或其他类似关系。</p>
</section>
<section class="about-section">
<h2>违约赔偿</h2>
<p>您同意保障和维护本网站及任何其他第三方的利益,如因违反有关法律、法规或本使用条款项下的任何条款而致使本网站或任何其他第三方造成损失,您同意承担所造成损害之赔偿责任。</p>
</section>
<section class="about-section">
<h2>拒绝提供担保</h2>
<p>本网站对本服务不提供任何明示或暗示的担保,包含但不限于商业适售性、特定目的之适用性及未侵害他人权利。</p>
<p>本网站不担保以下事项:</p>
<p>(a) 本服务将符合您的需求;</p>
<p>(b) 本服务不受干扰、即时提供、安全可靠或从不出错;</p>
<p>(c) 经由本服务之使用而取得任何资料之合适性、有效性、正确性、完整性、安全性、可靠性、即时性及是否侵害他人权利。</p>
<p>本服务之目的为提供您个人使用,您须自行判断该等资料的即时性及适用性。</p>
<p>若您通过本网站与网络上其他厂商或使用者进行商品或服务之买卖或各种交易行为,此时仅该厂商或使用者与您有契约关系存在,本网站并不介入或参与。如该商品或服务有任何瑕疵或纠纷时,请您径行与该厂商或使用者联络解决,本网站对此不负任何担保责任。</p>
</section>
<section class="about-section">
<h2>免责声明</h2>
<p>在任何情况下,本网站对于您或任何其他第三方因使用或不能使用该等服务所发生的任何直接的、间接的、附带引起的、特别的、惩罚性的或者间接性的损失或损害赔偿,不承担任何责任。即使本网站已知悉可能会有该等损失或损害赔偿,亦是如此。</p>
<p>本网站于下列情形之一时,得暂停或中断本服务之部分或全部服务,且对使用者任何直接或间接之损害,不承担任何责任:</p>
<p>(a) 对于本网站相关系统设备进行迁移、更换或维护时;</p>
<p>(b) 任何不可归责于本网站之事由所造成之服务停止或中断;</p>
<p>(c) 天灾或其他不可抗力造成本网站之服务停止或中断;</p>
<p>(d) 使用者有任何违反政府法令或本使用条款情事。</p>
</section>
<section class="about-section">
<h2>服务变更、中断或终止</h2>
<p>鉴于网络服务的特殊性,您同意本网站有权随时变更、中断或终止部分或全部的网络服务。</p>
</section>
<section class="about-section">
<h2>法律管辖</h2>
<p>本使用条款的订立、执行和解释及争议的解决均应适用于本网站所指定法律,并以本网站所指定法院作为第一审管辖法院。</p>
</section>
<section class="about-section">
<h2>其他规定</h2>
<p>本使用条款构成双方对本服务及其他有关事宜的完整协议,并取代双方先前就本使用条款所为之任何形式的承诺或协议。</p>
<p>如本使用条款中的任何条款无论因何种原因,完全或部分无效或不具有执行力,本使用条款的其余条款仍应有效并且有约束力。</p>
<p>本使用条款的标题仅为方便阅读所设,非对条款的定义、限制、解释或描述其范围或界限。</p>
</section>
<section class="about-section">
<h2>联络方式</h2>
<p>若您对于本使用条款或本网站有任何问题或建议,欢迎来信至我们的服务信箱。</p>
<p>本使用条款之订立、修改、更新及最终解释权均属本网站所有。</p>
</section>
`},
"privacy-policy": {key:"privacy-policy",title:"隐私政策",html:`
<section class="about-section">
<p>禁果短剧(以下简称本公司)非常重视您的隐私权,并依据《个人资料保护法》(以下简称个资法)的规定,向您告知下列有关隐私权保护政策的更多内容。</p>
</section>
<section class="about-section">
<h2>一、本公司使用您个人资料的目的</h2>
<p>(一)会员管理</p>
<p>(二)提供会员服务,例如:发送电子邮件通知、调阅游戏历程</p>
</section>
<section class="about-section">
<h2>二、个人资料收集及利用之期间、对象、地区及方式</h2>
<p>(一)期间:平台提供相关服务及营运期间。</p>
<p>(二)对象:本公司。个人资料的收集及利用不适用于非本公司所有或控制的公司,也不适用于非本公司雇用或管理之人,本公司的关系企业或子公司将使用其原先的隐私权政策。</p>
<p>(三)地区:营运之地区。</p>
<p>(四)方式:本公司在您使用官网服务信箱或进行其他活动时,会收集您所提供的姓名、电子邮件地址、联络方式等。</p>
</section>
<section class="about-section">
<h2>三、依据个资法第三条规定,您就本公司保有个人资料得行使之权利及方式</h2>
<p>(一)得行使之权利: 查询、请求阅览 请求制给复制本 请求补充或更正 请求停止收集、处理或利用 请求删除</p>
<p>(二)行使权利之方式:以书面将申请行使上述权利的资料送至本公司所在地。</p>
</section>
<section class="about-section">
<h2>四、您不同意提供个人资料所致权益之影响</h2>
<p>您可自由选择是否提供个人资料给本公司,但若特定资料栏位属于必填栏位,您若不提供该等资料,则可能造成无法使用本公司相关的产品及服务。建议您应提供完整的个人资料并随时更新。若因您未提供完整且真实的个人资料,造成您及第三人发生损害时,您应负担一切责任。</p>
</section>
<section class="about-section">
<h2>五、资料分享与披露</h2>
<p>除非是为了向您提供您所要求的产品或服务、获得您的授权,或有以下情形,否则本公司不会将您的个人资料披露给第三人或用于收集目的以外的其他用途:</p>
<p>(一)本公司因业务需要,有必要委托本公司相关单位或关系企业提供服务的情形,本公司将严格要求其遵守保密义务,并采取必要检查程序以确定其将确实遵守。</p>
<p>(二)本公司对法院传票、行政命令或法律程序作出回应或行使法律权利,或对诉讼上的请求提出防御的情形。</p>
<p>(三)为了调查和防止非法活动、涉嫌欺诈、对人身安全有潜在威胁的情况、对本公司服务条款的违反,或为了对上述情形采取应对措施,或法律另有规定,而有必要提供您的个人资料时。</p>
<p>(四)如果本公司全部或部分业务被其他公司收购或合并,我们会将您的个人资料移转给该公司。如果发生这种情况,本公司会在您的个人资料被移转且将适用不同的隐私权政策前通知您。</p>
</section>
<section class="about-section">
<h2>六、机密性与安全性</h2>
<p>我们会将有权存取您个人资料的员工,限定于我们合理相信是为了向您提供产品或服务或为了完成工作而需要取得这些资料的员工。</p>
<p>我们将采取符合法规要求的实体、电子和程序防护措施,以保护您个人资料的安全,防止您的个人资料被窃取、篡改、毁损、灭失或泄漏。</p>
</section>
<section class="about-section">
<h2>七、隐私权保护政策修订</h2>
<p>本公司有权不时修订本政策。当我们在个人资料及隐私权政策的处理上有重大变更时,会在我们的网站上张贴告示。请您密切注意以保障个人权益。当您于隐私权政策修改或变更后继续使用本公司所提供之产品及服务,即表示您已阅读、了解并同意接受隐私权政策修改或变更后的内容。如果您不同意本隐私权政策,请您立即停止使用本公司所提供之产品及服务。</p>
</section>
<section class="about-section">
<h2>八、问题和建议</h2>
<p>如果您有任何问题或建议,请通过客户服务信箱与我们联系。</p>
</section>
`},
"help-center": {key:"help-center",title:"常见问题",html:`
<h2>常见问题</h2>
<details open>
<summary>如何搜索和筛选短剧?</summary>
<p>在首页搜索框输入剧名或题材,按回车或点击搜索按钮查看结果。也可以通过题材标签筛选,点击“查看全部”进入“值得一追”专题页。</p>
</details>
<details>
<summary>如何管理我的片单?</summary>
<p>点击剧集打开预览,使用“加入我的片单”收藏;再次点击即可取消。首页顶部的“我的片单”可以查看已收藏内容。</p>
</details>
<details>
<summary>为什么视频无法播放?</summary>
<p>当前版本只展示剧集预览和选集交互,尚未接入真实视频,因此不能播放正片。</p>
</details>
<details>
<summary>更换设备后,收藏为什么没有同步?</summary>
<p>收藏保存在当前浏览器中,没有接入账号同步。更换浏览器、设备或清除网站数据后,原来的收藏可能不可见。</p>
</details>
<details>
<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">
<span class="compliance-number" aria-hidden="true">01</span>
<div>
<h2>年龄声明</h2>
<p>内容中的人物须在制作时达到适用地区的法定成年年龄。平台禁止制作和传播涉及未成年人的违法内容。</p>
</div>
</section>
<section class="compliance-section">
<span class="compliance-number" aria-hidden="true">02</span>
<div>
<h2>资料留存</h2>
<p>依法需要保存的年龄及身份证明,由内容提供方负责管理;合作方须按照适用规定履行资料留存义务。</p>
</div>
</section>
<section class="compliance-section">
<span class="compliance-number" aria-hidden="true">03</span>
<div>
<h2>内容标识</h2>
<p>平台通过分类与标识提示内容性质。访问者应确认自身年龄与所在地法律允许浏览相关内容。</p>
</div>
</section>
<section class="compliance-section">
<span class="compliance-number" aria-hidden="true">04</span>
<div>
<h2>合规说明</h2>
<p>如发现疑似违规内容,请通过<a href="/info/feedback/">意见反馈</a>提供线索,以便核查处理。具体义务以适用法律法规为准。</p>
</div>
</section>
`},
"dmca": {key:"dmca",title:"DMCA 版权声明",html:`
<section class="compliance-section">
<span class="compliance-number" aria-hidden="true">01</span>
<div>
<h2>通知要求</h2>
<p>请说明涉及的作品、涉嫌侵权内容的具体页面位置及有效联系方式,并附上权利归属或授权材料,便于核查。</p>
</div>
</section>
<section class="compliance-section">
<span class="compliance-number" aria-hidden="true">02</span>
<div>
<h2>处理流程</h2>
<p>收到完整通知后,将核实相关情况。确认侵权的内容将被移除或屏蔽;重复侵权的来源可能受到进一步限制。</p>
</div>
</section>
<section class="compliance-section">
<span class="compliance-number" aria-hidden="true">03</span>
<div>
<h2>反通知</h2>
<p>如认为内容被误删,可说明异议理由,并提供合法发布该内容的依据,以供重新评估是否恢复。</p>
</div>
</section>
<section class="compliance-section">
<span class="compliance-number" aria-hidden="true">04</span>
<div>
<h2>联系我们</h2>
<p>版权通知及反通知请通过本站<a href="/info/feedback/">意见反馈</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>`;
}
export function getInfoPage(key:string,config:Config={},contacts:FooterContact[]|null=[]):InfoPageDefinition|undefined {
const page=Object.hasOwn(INFO_PAGES,key)?INFO_PAGES[key]:undefined;
return page&&key==='about-us'?{...page,html:page.html.replace(EMPTY_CONTACT_SECTION,infoContactSection(config,contacts))}:page;
}
+39
View File
@@ -0,0 +1,39 @@
import type { Config } from './api/types';
import { recordList, safeUrl } from './api/protocol';
const object=(value:unknown):Record<string,unknown>=>{
if(typeof value==='string'){try{return object(JSON.parse(value));}catch{return {};}}
return value&&typeof value==='object'&&!Array.isArray(value)?value as 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. */
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 seen=new Set<number>();
let routes=recordList<{line?:unknown;url?:unknown}>(config.navPageList).flatMap(item=>{
const line=Number(item.line),href=safeUrl(item.url);
if(!Number.isSafeInteger(line)||line<1||!href||seen.has(line))return [];
seen.add(line);return [{line,href,label:lineLabel(line)}];
}).sort((left,right)=>left.line-right.line);
if(!routes.length){
routes=[1,2,3].flatMap((line,index)=>{
const href=url('dhNewUrlAddr'+line)||url(['newUrlAddr','newUrlAddr1','newUrlAddr2'][index]);
return href?[{line,href,label:lineLabel(line)}]:[];
});
}
return {
siteName,
description:firstText(navigationSeo.dhDescription,navigationSeo.description,settings.dhDescription)||'全网最新最火短剧,禁果为你呈现',
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'),
};
}
+35
View File
@@ -0,0 +1,35 @@
import type { Chapter, Comment, Config, Drama, DramaDetail, FooterInfoResponse, Package, PageData, 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 PlayerInitialData {
detail:DramaDetail;
chapters:Chapter[];
chapter:Chapter|null;
comments?:PageData<Comment>;
}
/** Only the first route can consume this snapshot; later navigation loads fresh data. */
export interface PageBootstrap {
path:string;
panel:'channel'|'player'|'search'|'info';
title:string;
subtitle?:string;
player?:PlayerInitialData;
search?:{keyword:string;total:number;page:number;pageSize:number};
info?:{key:string;member?:MemberInitialData;wallet?:WalletInitialData;feedback?:boolean};
session?:{user:User|null;state:string};
}
export interface PageView {
panel:PageBootstrap['panel'];
title:string;
subtitle?:string;
html:string;
overview?:boolean;
channel?:string;
bootstrap?:PageBootstrap;
}
+9
View File
@@ -0,0 +1,9 @@
import { notFound } from 'next/navigation';
import { pageResponseStatus, type SeoContent } from './seo';
/** Run before rendering the page shell so errors retain their actual HTTP status. */
export function assertPageContent(pathname:string,content:SeoContent):void {
const status=pageResponseStatus(pathname,content);
if(status===404)notFound();
if(status===500)throw new Error('页面内容服务暂不可用,请稍后重试');
}
+69
View File
@@ -0,0 +1,69 @@
import type { Advertisement, Advertisements, Chapter, Comment, DramaDetail, PageData } from './api/types';
import type { PlayerInitialData } from './page-data';
import { recordList } from './api/protocol';
import { normalizeSeo } from './seo';
import { chapterAccess } from './client/playback';
function fields(value:object,strings:string[],numbers:string[],booleans:string[]=[]):Record<string,unknown> {
const data=value as Record<string,unknown>,result:Record<string,unknown>={};
for(const key of strings)if(typeof data[key]==='string')result[key]=data[key];
for(const key of numbers)if(typeof data[key]==='number'&&Number.isFinite(data[key]))result[key]=data[key];
for(const key of booleans)if(typeof data[key]==='boolean')result[key]=data[key];
return result;
}
function advertisements(data:Advertisements|undefined):Advertisements|undefined {
if(!data||typeof data!=='object'||Array.isArray(data))return;
const result:Advertisements={};
for(const key of ['nineGridList','detailbannerList'])result[key]=recordList<Advertisement>(data[key]).map(item=>({
...fields(item,['advertiseCode','advertiseLocationCode','advertiseLocationName'],['location']),
id:typeof item.id==='string'?item.id:'',title:typeof item.title==='string'?item.title:'',
images:typeof item.images==='string'?item.images:'',link:typeof item.link==='string'?item.link:'',
}));
return result;
}
export function publicDrama(data:DramaDetail):DramaDetail {
const result=fields(data,['mediaId','title','playName','slug','desc','description','cover','horizontalCover','banner','coverImg','onlineAt','mediaCreatedAt','mediaUpdatedAt','createdAt','updatedAt','addedTime','anchor','preview','recommendTraceId','recommendId','recommendTraceInfo'],['id','totalEpisode','countLike','countCollect','countBrowse','heatValue','isCollect','isLike','watchTimes','updateStatus','operateType'],['isNew','isFeatured','showOnBanner','isOriginal','isRecommend','showOriginal']) as unknown as DramaDetail;
for(const key of ['tagNames','themeNames','tagSlugs','themeSlugs'] as const)if(Array.isArray(data[key]))result[key]=data[key].filter(value=>typeof value==='string');
for(const key of ['tagIds','themeIds'] as const)if(Array.isArray(data[key]))result[key]=data[key].filter(Number.isSafeInteger);
if(data.seoInfo)result.seoInfo=normalizeSeo(data.seoInfo);
result.advitise=advertisements(data.advitise);
if(data.video)result.video=fields(data.video,['url','cover'],['videoTime','width','height']);
return result;
}
function publicChapter(data:Chapter):Chapter {
const result=fields(data,['title','mediaId'],['id','playletId','currentEpisode','mediaTime','width','height','mediaSize','countBrowse','countCollect','countComment','countLike','ratio','payType','singleCoins','costCoins','balance','isCollect','isLike'],['canWatch','needUnlock','needVip','isUnlocked','vipValid']) as unknown as Chapter;
if(data.seoInfo)result.seoInfo=normalizeSeo(data.seoInfo);
// Protected sources are serialized only when this identity may actually watch them.
if(chapterAccess(data).allowed&&typeof data.videoUrl==='string')result.videoUrl=data.videoUrl;
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 {
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}}:{}),
};
}
+159
View File
@@ -0,0 +1,159 @@
export interface SiteRoute {
group: string;
key: string;
value?: string;
page?: number;
}
export const RANK_PAGES = [
{slug:'hot',rankType:1,title:'热播榜',path:'/rank/hot/'},
{slug:'new',rankType:2,title:'新剧榜',path:'/rank/new/'},
{slug:'follow',rankType:3,title:'追剧榜',path:'/rank/follow/'},
] as const;
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 id = (value?: string) => value !== undefined && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value)>0;
const richTextTitle = (value?: string) => {
const title = (value || '').trim();
return title && title !== '.' && title !== '..' && title.length <= 200 && !/[\\/\u0000-\u001f\u007f]/.test(title) ? title : '';
};
export interface ContentIdentity { id?:number; slug?:unknown }
const slugKey=(value:unknown):string=>typeof value==='string'&&value.trim()&&!['.','..'].includes(value.trim())&&!/[\\\u0000-\u001f\u007f]/.test(value)?value.trim():'';
const routeSlug=(value:unknown):string=>/^[a-z\d][a-z\d_-]*$/i.test(slugKey(value))?slugKey(value):'';
const contentKey=(value?:string)=>!!value&&(id(value)||!/^\d+$/.test(value)&&slugKey(value)===value);
/** Shared by SSR, cards, playback actions and SEO. Slugs are encoded exactly once. */
export function contentSegment(value:ContentIdentity|string|number):string {
if(typeof value==='object')return routeSlug(value.slug)||(id(String(value.id))?String(value.id):'');
if(/^\d+$/.test(String(value)))return id(String(value))?String(Number(value)):'';
return routeSlug(value);
}
export function contentPath(kind:'detail'|'play'|'video'|'topics'|'theme'|'tag'|'config',value:ContentIdentity|string|number,chapterId?:number):string {
return `/${kind}/${encodeURIComponent(contentSegment(value))}/${kind==='play'?`${chapterId}/`:''}`;
}
export function contentQuery(key:string,idName='id',slugName='slug'):Record<string,string|number> {
return id(key)?{[idName]:Number(key)}:{[slugName]:key};
}
export function matchesContentKey(value:ContentIdentity,key:string):boolean {
return id(key)?value.id===Number(key):!!slugKey(value.slug)&&slugKey(value.slug).toLowerCase()===key.toLowerCase();
}
/** Encode the complete keyword once as a single path segment. */
export function searchPath(keyword:string,page=1):string {
if(!Number.isSafeInteger(page)||page<1)throw new RangeError('页码无效');
const term=keyword.trim();
if(!term&&page>1)throw new Error('空搜索不支持分页');
return `/search/${term?encodeURIComponent(term)+'/':''}${page===1?'':`page/${page}/`}`;
}
/** Accept decoded segments; parseSitePath is the boundary for encoded paths. */
function baseRouteFromSegments(parts: string[]): SiteRoute | null {
if (!parts.length) return {group: '', key: ''};
const [group, key, value] = parts;
if(group==='search'&&parts.length===1)return {group,key:''};
if (channels.has(group) && parts.length === 1) return {group: 'page', key: group};
if (group === 'rank' && parts.length === 2 && getRankPage(key)) return {group: 'page', key: 'rank', value: key};
if (['topics', 'theme', 'tag', 'config'].includes(group) && parts.length === 2 && contentKey(key)) {
return {group: 'page', key: group, value: key};
}
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);
}
export function routeFromSegments(parts:string[]):SiteRoute|null {
if(parts.length>=2&&parts.at(-2)==='page'){
const value=parts.at(-1)!;
if(!/^[1-9]\d*$/.test(value)||!Number.isSafeInteger(Number(value)))return null;
const route=baseRouteFromSegments(parts.slice(0,-2));
return route&&supportsPagination(route)?{...route,page:Number(value)}:null;
}
return baseRouteFromSegments(parts);
}
/** Generate page links directly; the first page uses the original page path. */
export function paginationPath(path:string,page:number,updates:Record<string,string|number|null>={}):string {
if(!Number.isSafeInteger(page)||page<1)throw new RangeError('页码无效');
const url=new URL(path,'https://site.invalid');
const route=parseSitePath(url.pathname,url.search);
if(!route||!supportsPagination(route))throw new Error('该页面不支持分页');
let base=url.pathname.replace(/\/$/,'');
if(route.page!==undefined)base=base.replace(/\/page\/[1-9]\d*$/,'');
if(route.group==='search'){
base=searchPath(route.key).replace(/\/$/,'');
url.searchParams.delete('q');
if(!route.key.trim()&&page>1)throw new Error('空搜索不支持分页');
}
for(const [key,value] of Object.entries(updates)){
if(value===null)url.searchParams.delete(key);else url.searchParams.set(key,String(value));
}
return `${base}/${page===1?'':`page/${page}/`}${url.search}`;
}
export function parseSitePath(pathname: string,search=''): SiteRoute | null {
if (!pathname.startsWith('/') || pathname.startsWith('//')) return null;
try {
const path = pathname.replace(/\/$/, '');
const route=routeFromSegments(path ? path.slice(1).split('/').map(decodeURIComponent) : []);
if(route?.group==='search'&&!route.key)route.key=new URLSearchParams(search).get('q')?.trim()||'';
return route;
} catch {
return null;
}
}
export function samePageAddress(left:string,right:string):boolean {
const normalized=(path:string)=>{
const url=new URL(path,'https://site.invalid');
const route=parseSitePath(url.pathname,url.search);
return route?JSON.stringify([route,[...url.searchParams.entries()].sort()]):null;
};
try{const first=normalized(left);return first!==null&&first===normalized(right);}catch{return false;}
}
export function navigationSection(route: SiteRoute): string {
if (!route.group) return 'home';
if (route.group === 'page') return ['theme', 'tag', 'config'].includes(route.key) ? 'shorts' : route.key;
if (['detail', 'play'].includes(route.group)) return 'shorts';
return '';
}
/** Top-level entries match their own pages and pagination, without a detail-page fallback. */
export function topNavigationSection(route: SiteRoute | null): string {
if (!route) return '';
if (!route.group) return 'home';
if (route.group === 'page' && channels.has(route.key) && (!route.value || route.key === 'rank')) return route.key;
return '';
}
/** Return only recognized same-origin page links, leaving files and external links to the browser. */
export function internalPagePath(href: string, base: string): string | null {
if (href.includes('#')) return null;
try {
const url = new URL(href, base);
if (url.origin !== new URL(base).origin || url.hash || !parseSitePath(url.pathname,url.search)) return null;
return url.pathname + url.search;
} catch {
return null;
}
}
+271
View File
@@ -0,0 +1,271 @@
import { cache } from 'react';
import { cookies, headers } from 'next/headers';
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 { 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 { ApiClient } from './client/api';
import { playerInitialData } from './player-data';
import { loadChannel, channelView } from './client/channel';
import { orderedChapters, playerMarkup } 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>();
const getSeoSnapshots=cache(async ()=>{
const source=await getServerApiBaseUrl();
let store=snapshotStores.get(source);
if(!store){
store=new SeoSnapshotStore(source);
if(snapshotStores.size>=32)snapshotStores.delete(snapshotStores.keys().next().value!);
snapshotStores.set(source,store);
}
return store;
});
/** Request memoization shares config/home between generateMetadata and the page render. */
export const getSeoConfig=cache(async ():Promise<{config:Config;error?:string}>=>{
try{
const result=await upstream<Config>('config/info',{timeoutMs:5000});
if(result.code!==200)throw new Error(result.tip||result.msg||'配置加载失败');
if(!result.data||typeof result.data!=='object'||Array.isArray(result.data))throw new Error('配置数据无效');
return {config:result.data};
}catch(error){return {config:{},error:error instanceof Error?error.message:'配置加载失败'};}
});
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;
}catch{return undefined;}
});
const getFooterInfo=cache(async ():Promise<FooterInfoResponse>=>{
try{const result=await upstream<FooterInfoResponse>('config/footer/info',{timeoutMs:5000});return result.code===200?result.data||{}:{};}catch{return {};}
});
const getSiteSharedData=cache(async ()=>{
const [configuration,footer]=await Promise.all([getSeoConfig(),getFooterInfo()]);
return publicSiteData(configuration.config,footer);
});
const getEnvironment=cache(async (navigation=false)=>{
const seoSnapshots=await getSeoSnapshots();
const [configuration,categories,previous]=await Promise.all([getSeoConfig(),navigation?Promise.resolve(undefined):getCategories(),seoSnapshots.read()]);
const environment=resolveSeoEnvironment(configuration.config,categories,previous);
if(!configuration.error)await seoSnapshots.write(environment);
return environment;
});
/** Metadata routes share the canonical origin and its last valid snapshot, without loading navigation. */
export async function getSeoOrigin():Promise<string> {
return (await getEnvironment(true)).origin;
}
/** A read-only detail/search request may require the same guest identity as the reference site.
* The credential stays on the server and is never serialized into metadata or the client bootstrap.
*/
const getReadIdentity=cache(async ()=>{
const [jar,requestHeaders]=await Promise.all([cookies(),headers()]);
const suppliedDevice=jar.get('drama_device')?.value||'';
const device=/^[a-zA-Z0-9_-]{1,100}$/.test(suppliedDevice)?suppliedDevice:randomUUID();
const userAgent=requestHeaders.get('user-agent')||'';
// Prefer BFF session; fall back to client drama_bearer (jha-1048 cookie sync).
const token=jar.get('drama_session')?.value||jar.get('drama_bearer')?.value||'';
const ip=(requestHeaders.get('x-real-ip')||requestHeaders.get('x-forwarded-for')?.split(',')[0]||'').trim();
return {token,device,userAgent,clientIp:isIP(ip)?ip:undefined,registered:jar.get('drama_registered')?.value==='1',prepared:requestHeaders.has(SSR_SESSION_HEADER),channel:(jar.get('drama_channel')?.value||process.env.API_CHANNEL||'').slice(0,100)};
});
async function contentRequest<T>(endpoint:string,query:Record<string,string|number>,protectedRead=false):Promise<T> {
const path=endpoint+'?'+new URLSearchParams(Object.entries(query).map(([key,value])=>[key,String(value)]));
const identity=protectedRead?await getReadIdentity():{};
let result;
try{result=await upstream<T>(path,{...identity,timeoutMs:5000});}
catch(error){
if(!protectedRead||error===null||typeof error!=='object'||!('code' in error)||!([401,403].includes(Number(error.code))||EXPIRED_CODES.has(Number(error.code)))||('registered' in identity&&identity.registered)||('prepared' in identity&&identity.prepared))throw error;
}
// jha-1048: never mint a guest token during SSR (it would invalidate the client bearer).
// Cold protected reads without a cookie may 1000 — surface as unavailable content, not a guest handshake.
if(result?.code!==200)throw new ApiError('内容暂不可用',result?.code||502);
if(result.data==null&&['playlet/detail','playlet/chapter/detail'].includes(endpoint))throw new ApiError('内容不存在',404);
if(!result.data||typeof result.data!=='object'||Array.isArray(result.data))throw new ApiError('内容返回数据无效',502);
return result.data;
}
const preparedSession=cache(async ()=>{
const incoming=await headers();let user:User|null=null;
try{user=publicUser(JSON.parse(Buffer.from(incoming.get(SSR_USER_HEADER)||'','base64url').toString()));}catch{}
return {user,state:incoming.get(SSR_SESSION_HEADER)||'unavailable'};
});
function infoView(path:string,key:string,title:string,html:string,extras:Partial<PageBootstrap>={}):PageView {
return {panel:'info',title,html,bootstrap:{path,panel:'info',title,info:{key},...extras}};
}
const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent&{view?:PageView}>=>{
const route=parseSitePath(pathname,search);if(!route)return {};
const params=new URLSearchParams(search),address=pathname+search;
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});
if(result.code!==200)throw new Error(result.tip||result.msg||'富文本加载失败');
const item=recordList<{title?:string;desc?:string;content?:string}>(result.data?.list).find(entry=>entry.title?.trim()===route.key);
const heading=item?.title?.trim()||route.key;
const desc=item?.desc?.trim()||'';
const body=typeof item?.content==='string'?item.content.trim():'';
const description=desc||body.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi,'').replace(/<[^>]+>/g,' ').replace(/\s+/g,' ').trim().slice(0,160);
const html=body?`${desc?`<p class="richtext-desc">${escape(desc)}</p>`:''}<div class="richtext-body">${body}</div>`:empty('这篇内容暂时没有发布。');
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||[]);
return info?{...infoSeoContent(info),view:infoView(address,route.key,info.title,info.html)}:{};
}
if(route.group==='search'){
if(!route.key&&(route.page||1)>1)return {missing:true};
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)])),endpoint==='playlet/search')};
const results=await loadSearchResults(api,route.key,route.page||1,new AbortController().signal);
const {keyword,total,page,pageSize}=results;
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.
const resolvedId=playletQuery.playletId?Promise.resolve(Number(playletQuery.playletId)):detailRequest.then(detail=>{
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([
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),
]);
let chapter:Chapter|undefined;
if(route.group==='play'){
chapter=chapterResult;
if(!chapter)throw new ApiError('章节不存在',404);
if(chapter.id!==Number(route.value)||chapter.playletId&&chapter.playletId!==detail.id)return {missing:true};
}
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 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}}};
}
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(configuration?.error)throw new Error('入口配置暂不可用');
const html=route.key==='apps'?renderAppDirectory(await loadAppCategories(api,new AbortController().signal)):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([
loadChannel(api,route,search),
route.key==='topics'&&!route.value?getHome():Promise.resolve(undefined),
// The tag-list API returns a successful empty list even for nonexistent tag IDs.
route.key==='tag'?contentRequest<PageData<{id:number;slug?:string}>>('playlet/tag/options',{}).catch(()=>{throw new Error('标签目录暂不可用');}):Promise.resolve(undefined),
]);
if(route.key==='tag'){
if(!Array.isArray(tags?.list))throw new Error('标签目录暂不可用');
const tag=recordList<{id:number;slug?:string}>(tags.list).find(item=>matchesContentKey(item,route.value!));
if(!tag)return {missing:true};
Object.assign(data,{id:tag.id,slug:tag.slug});
}
const rendered=channelView(route,data,address);
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 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.
if(resource&&error instanceof ApiError&&[404,410,5009].includes(error.code))return {missing:true};
if(route.group==='search')return {failed:true,view:{panel:'search',title:'搜索结果',html:searchErrorMarkup(route.key,'搜索服务暂不可用,请稍后重试')}};
return {failed:true};
}
});
export const getPageSeo=cache(async (pathname:string,search='')=>{
const navigation=pathname.replace(/\/$/,'')==='/navigation';
const [environment,loaded,session,shared]=await Promise.all([getEnvironment(navigation),navigation?Promise.resolve({} as SeoContent&{view?:PageView}):loadContent(pathname,search),navigation?Promise.resolve(undefined):preparedSession(),navigation?Promise.resolve(undefined):getSiteSharedData(),navigation?Promise.resolve(undefined):getHeaderNavigation()]);
const {view,...content}=loaded;
const head=buildPageHead(pathname,search,environment,content);
const bootstrap:SeoBootstrap={environment,content,path:pathname+search};
return {head,bootstrap,view,session,shared};
});
export function metadataFromHead(head:PageHead):Metadata {
return {
title:head.title,description:head.description,keywords:head.keywords,applicationName:head.siteName,
alternates:head.canonical?{canonical:head.canonical}:null,
pagination:{previous:head.previous,next:head.next},
robots:{index:!head.robots.includes('noindex'),follow:!head.robots.includes('nofollow'),...(!head.robots.includes('noindex')?{'max-image-preview':'large' as const,'max-snippet':-1,'max-video-preview':-1}:{}),googleBot:head.robots},
openGraph:{locale:'zh_CN',type:head.type,siteName:head.siteName,title:head.title,description:head.description,url:head.canonical,images:head.image?[{url:head.image,width:1200,height:630,alt:head.siteName}]:[]},
twitter:{card:'summary_large_image',title:head.title,description:head.description,images:head.image?[{url:head.image,alt:head.siteName}]:[]},
};
}
export function queryString(params:Record<string,string|string[]|undefined>):string {
const query=new URLSearchParams();
for(const [key,value] of Object.entries(params))for(const item of Array.isArray(value)?value:value===undefined?[]:[value])query.append(key,item);
return query.size?'?'+query.toString():'';
}
+55
View File
@@ -0,0 +1,55 @@
import { createHash, randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { normalizeSeo, siteOrigin, type SeoEnvironment } from './seo';
import { schemaUrl } from './structured-data';
const record=(value:unknown):Record<string,unknown>=>value&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
/** Only public SEO fields are stored, never the full config, SDK config or credentials. */
function publicEnvironment(value:unknown):SeoEnvironment|undefined {
const data=record(value),origin=siteOrigin(data.origin);
if(!origin)return;
const categories=(Array.isArray(data.categories)?data.categories:[]).map(record).filter(item=>typeof item.urlValue==='string').map(item=>({
urlValue:item.urlValue as string,
...(typeof item.name==='string'?{name:item.name}:{}),
...(typeof item.categoryType==='number'?{categoryType:item.categoryType}:{}),
seoInfo:normalizeSeo(item.seoInfo),
}));
const sameAs=[...new Set((Array.isArray(data.sameAs)?data.sameAs:[]).map(value=>schemaUrl(value,origin)).filter(Boolean))];
return {origin,site:normalizeSeo(data.site),navigation:normalizeSeo(data.navigation),categories,sameAs};
}
/** An atomic disk snapshot survives server restarts; the API origin isolates projects. */
export class SeoSnapshotStore {
private filename:string;
private lastKnown?:SeoEnvironment;
constructor(source:string,directory=join(process.cwd(),process.env.APP_DIST_DIR||'.next','cache','seo')) {
this.filename=join(directory,createHash('sha256').update(source).digest('hex')+'.json');
}
async read():Promise<SeoEnvironment|undefined> {
try {
const saved=record(JSON.parse(await readFile(this.filename,'utf8')));
if(saved.version===1){
const environment=publicEnvironment(saved.environment);
if(environment)this.lastKnown=environment;
}
}catch{/* A read-only cache directory can still use this process's last good value. */}
return this.lastKnown;
}
async write(environment:SeoEnvironment):Promise<void> {
const snapshot=publicEnvironment(environment);
if(!snapshot)return;
this.lastKnown=snapshot;
const temporary=this.filename+'.'+randomUUID()+'.tmp';
try {
await mkdir(dirname(this.filename),{recursive:true});
await writeFile(temporary,JSON.stringify({version:1,environment:snapshot}),{flag:'wx',mode:0o600});
await rename(temporary,this.filename);
}catch{/* Cache persistence must not prevent serving a valid configuration. */}
finally {await unlink(temporary).catch(()=>{});}
}
}
+242
View File
@@ -0,0 +1,242 @@
import type { Chapter, Config, DramaDetail, HomeData, NavigationCategory, PageData } from './api/types';
import type { InfoPageDefinition } from './info-pages';
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';
export interface NormalizedSeo { title:string; description:string; keywords:string[]; siteName:string }
export interface SeoEnvironment { origin:string; site:NormalizedSeo; navigation:NormalizedSeo; categories:NavigationCategory[]; sameAs?:string[] }
export interface SeoContent {
seoInfo?:unknown; title?:string; description?:string; keywords?:string[]; episode?:number;
totalCount?:number; pageSize?:number; failed?:boolean; missing?:boolean; unavailable?:boolean; loginRequired?:boolean;
routeConfig?:boolean; indexable?:boolean; paywalled?:boolean; identity?:ContentIdentity;
items?:SchemaItem[]; entity?:SchemaDrama; faq?:SchemaQuestion[];
}
export interface PageHead {
title:string; description:string; keywords:string[]; siteName:string; robots:string;
canonical?:string; previous?:string; next?:string; image:string; type:'website'|'article'|'video.episode';
jsonLd?:SchemaGraph;
}
export interface SeoBootstrap { environment:SeoEnvironment; content:SeoContent; path:string }
const text=(value:unknown):string=>typeof value==='string'?value.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi,'').replace(/<[^>]+>/g,'').replace(/[\u0000-\u001f\u007f]/g,' ').replace(/\s+/g,' ').trim():'';
const first=(...values:unknown[])=>values.map(text).find(Boolean)||'';
function object(value:unknown):Record<string,unknown> {
if(typeof value==='string'){try{return object(JSON.parse(value));}catch{return {};}}
return value&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
}
function keywordList(...values:unknown[]):string[] {
for(const value of values){
const list=(Array.isArray(value)?value:typeof value==='string'?value.split(/[,,、;;]/):[]).map(text).filter(Boolean);
if(list.length)return [...new Set(list)];
}
return [];
}
export function normalizeSeo(value:unknown):NormalizedSeo {
const data=object(value);
return {title:first(data.title,data.webTitle,data.dhTitle),description:first(data.description,data.webDescription,data.dhDescription),keywords:keywordList(data.keywords,data.keyworld,data.webKeyworld,data.dhKeyworld),siteName:first(data.webSiteName,data.siteName,data.dhSiteName)};
}
export function mergeSeo(primary:NormalizedSeo,fallback:NormalizedSeo):NormalizedSeo {
return {title:primary.title||fallback.title,description:primary.description||fallback.description,keywords:primary.keywords.length?primary.keywords:fallback.keywords,siteName:primary.siteName||fallback.siteName};
}
/** Only the configured permanent site's origin is used; API/CDN addresses are never canonical roots. */
export function siteOrigin(value:unknown):string {
const raw=text(value);if(!raw||/^[^/]*:\/\//.test(raw)&&!/^https?:\/\//i.test(raw))return '';
try{
const url=new URL(raw.startsWith('//')?'https:'+raw:/^https?:\/\//i.test(raw)?raw:'https://'+raw);
if(!['http:','https:'].includes(url.protocol)||url.username||url.password||!url.hostname)return '';
const host=url.hostname.toLowerCase();
if(new Set(['localhost','127.0.0.1','0.0.0.0','[::1]','www.xx.xxx','xx.xxx','example.com']).has(host)||host.endsWith('.example.com')||host.endsWith('.local'))return '';
return url.origin;
}catch{return '';}
}
export function seoEnvironment(config:Config={},categories:NavigationCategory[]=[]):SeoEnvironment {
const origin=siteOrigin(config.config?.foreverUrlAddr)||siteOrigin(config.newAddressInfo?.foreverUrlAddr);
const channels={...config.newAddressInfo,...config.config};
const sameAs=[...new Set([channels.twitterAddr,channels.tgGroup].map(value=>schemaUrl(value,origin)).filter(Boolean))];
return {origin,site:normalizeSeo(config.seoInfo),navigation:normalizeSeo(config.seoNavInfo),categories:Array.isArray(categories)?categories.filter(item=>item&&typeof item==='object'):[],sameAs};
}
/** A partial/failed configuration cannot replace the last valid site's identity. */
export function resolveSeoEnvironment(config:Config,categories:NavigationCategory[]|undefined,previous?:SeoEnvironment):SeoEnvironment {
const current=seoEnvironment(config,categories??previous?.categories??[]);
if(!current.origin&&previous?.origin)return previous;
return {...current,
site:mergeSeo(current.site,previous?.site||normalizeSeo({siteName:'禁果短剧'})),
navigation:mergeSeo(current.navigation,previous?.navigation||normalizeSeo({})),
};
}
/** Keep SSR canonical, publisher and site_name unchanged during hydration/navigation. */
export function clientSeoEnvironment(current:SeoEnvironment,config:Config):SeoEnvironment {
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 descriptions:Record<string,string>={
shorts:'精选短剧与最新更新,都市甜宠、古装仙侠、悬疑逆袭,发现值得追的精彩故事。',
topics:'精选短剧专题与主题合集,按专题发现值得追的好剧。',
rank:'查看热播榜、新剧榜和追剧榜,发现大家正在看的热门短剧。',
'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(/\/?$/,'/');
}
const basePath=(path:string)=>canonicalPath(path).replace(/\/page\/[1-9]\d*\/$/,'/');
function canonicalPagePath(path:string,content:SeoContent={}):string {
const route=parseSitePath(path);
let result=canonicalPath(path).replace(/^\/rank\/(?!hot\/|new\/|follow\/)/,'/rank/hot/');
if(route?.group==='page'&&['topics','theme','tag','config'].includes(route.key)&&route.value){
result=contentPath(route.key as 'topics'|'theme'|'tag'|'config',content.identity||route.value)+(route.page&&route.page>1?`page/${route.page}/`:'');
}else if(route&&['detail','play'].includes(route.group)){
result=contentPath(route.group as 'detail'|'play',content.entity||route.key,Number(route.value))+(route.page&&route.page>1?`page/${route.page}/`:'');
}else if(route?.group==='richtext')result=`/richtext/${encodeURIComponent(route.key)}/`;
return result;
}
function categoryPath(value:unknown):string {
const raw=text(value);if(!raw||raw.startsWith('//')||/^[a-z][a-z\d+.-]*:/i.test(raw))return '';
const path=('/'+raw.replace(/^\//,'')).split(/[?#]/)[0].replace(/\/$/,'')||'/';
// These are API navigation keys, not URL redirects or additional public routes.
const aliases:Record<string,string>={'/home':'/','/playlet':'/shorts','/list':'/shorts','/theme':'/topics','/topic':'/topics','/application':'/apps'};
return canonicalPath(aliases[path]||path);
}
function navigationSeo(environment:SeoEnvironment,path:string,exact=false):NormalizedSeo|undefined {
const match=environment.categories.map(item=>({path:categoryPath(item.urlValue),seo:normalizeSeo(item.seoInfo),bottom:item.categoryType===3}))
.filter(item=>!item.bottom&&item.path&&(item.path===path||!exact&&item.path!=='/'&&path.startsWith(item.path))&&(item.seo.title||item.seo.description||item.seo.keywords.length))
.sort((a,b)=>b.path.length-a.path.length)[0];
return match?.seo;
}
export function homeSeoContent(home:HomeData):SeoContent {return {seoInfo:home.seoInfo,items:homeSchemaItems(home)};}
export function infoSeoContent(page:InfoPageDefinition):SeoContent {return {title:page.title,faq:page.key==='help-center'?faqFromHtml(page.html):undefined};}
export function listSeoContent(data:PageData<unknown>,route:SiteRoute,home?:HomeData):SeoContent {
let seo=normalizeSeo(data.seoInfo);
if(route.key==='topics'&&!route.value&&!Object.hasOwn(home||{},'modules')){
const block=Array.isArray(home?.homeConfigData)?home.homeConfigData.find(item=>item&&item.status&&item.type===3):undefined;
if(block)seo=mergeSeo(normalizeSeo(block.seoInfo),seo);
}
if(route.key==='topics'&&route.value)seo=mergeSeo(normalizeSeo(data.topicInfo?.seoInfo),seo);
const content:SeoContent={seoInfo:seo,title:first(data.name,data.title,data.topicInfo?.name),description:first(data.topicInfo?.desc,data.desc),pageSize:typeof data.pageSize==='number'&&data.pageSize>0?data.pageSize:24,unavailable:data.unavailable===true,missing:data.missing===true,routeConfig:route.key==='config'||route.key==='topics'&&!route.value,items:listSchemaItems(data,route)};
if(typeof data.totalCount==='number'&&Number.isFinite(data.totalCount)&&data.totalCount>=0)content.totalCount=data.totalCount;
if(route.value){
const identity={id:data.topicInfo?.id??data.id,slug:data.topicInfo?.slug||data.slug};
if(Number.isSafeInteger(identity.id)&&Number(identity.id)>0)content.identity={id:Number(identity.id),slug:typeof identity.slug==='string'?identity.slug:undefined};
}
// More-list snapshots need a visible introduction and real items of their own.
if(route.key==='config')content.indexable=!!(content.title&&content.description&&content.items?.length);
return content;
}
export function dramaSeoContent(data:DramaDetail,chapter?:Chapter):SeoContent {
const title=first(data.playName,data.title),description=first(data.desc,data.description),keywords=keywordList(data.tagNames,data.themeNames);
const seo=mergeSeo(normalizeSeo(chapter?.seoInfo),mergeSeo(normalizeSeo(data.seoInfo),{title,description,keywords,siteName:''}));
const entity=dramaSchemaData(data,chapter),access=chapterAccess(chapter);
if(chapter&&entity.video)entity.video.source=access.allowed?access.source:undefined;
// The current locked view repeats the series synopsis; metadata alone is not unique episode content.
const indexable=chapter?!!(title&&Number.isSafeInteger(chapter.currentEpisode)&&chapter.currentEpisode!>0&&access.allowed&&access.source):undefined;
return {seoInfo:seo,title,description,keywords,episode:chapter?.currentEpisode,indexable,paywalled:chapter?chapterRequiresPayment(chapter):undefined,missing:!Number.isSafeInteger(data.id)||data.id<=0,entity};
}
/** Only a successful count can prove a page is beyond the end of a collection. */
export function pageResponseStatus(pathname:string,content:SeoContent={}):200|404|500 {
const route=parseSitePath(pathname);
if(!route&&pathname!=='/navigation/')return 404;
// An outage must not turn a temporarily unavailable resource into a permanent 404.
if(content.failed||content.unavailable)return 500;
if(content.missing)return 404;
const pageSize=Number.isSafeInteger(content.pageSize)&&content.pageSize!>0?content.pageSize!:24;
if(route&&supportsPagination(route)&&Number.isSafeInteger(content.totalCount)&&content.totalCount!>=0&&
(route.page||1)>Math.max(1,Math.ceil(content.totalCount!/pageSize)))return 404;
return 200;
}
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 page=route?.page||1;
const params=new URLSearchParams(search);
const sort=Number(params.get('sort')||1);
let seo=normalizeSeo(content.seoInfo);
if(route&&!route.group)seo=mergeSeo(seo,environment.site);
else if(navigation)seo=mergeSeo(environment.navigation,seo);
else if(!content.routeConfig){
if(seo.title===environment.site.title)seo.title='';
if(seo.description===environment.site.description)seo.description='';
if(seo.keywords.join(',')===environment.site.keywords.join(','))seo.keywords=[];
}
const entity=route?.group==='page'&&(!!route.value&&route.key==='topics'||['theme','tag','config'].includes(route.key));
const nav=route?navigationSeo(environment,basePath(pathname),entity):undefined;
const isContent=!!route&&['detail','play','video'].includes(route.group);
let navApplied=false;
if(nav&&!isContent&&!navigation){seo=mergeSeo(nav,seo);navApplied=true;}
else if(isContent){
const section=navigationSection(route!);
const fallback=navigationSeo(environment,`/${section}/`);
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 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));
if(episode&&!description.includes(episode))description+=` - ${episode}`;
let keywords=seo.keywords.length?seo.keywords:[...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}`;
keywords=[...new Set([...keywords,ranking.title])];
}
if(route?.group==='page'&&([2,3].includes(sort)&&['shorts','tag'].includes(route.key)||sort===4&&route.key==='shorts')){
const sortLabel=sort===4?'随机推荐':sort===2?'当前热播':route.key==='shorts'?'独家原创':'收藏最多';
if(!title.includes(sortLabel))title+=` - ${sortLabel}`;
if(!description.includes(sortLabel))description+=` - ${sortLabel}`;
keywords=[...new Set([...keywords,sortLabel])];
}
if(page>1){
const suffix=`第${page}页`;
if(!new RegExp(`第\\s*${page}\\s*页`).test(title))title+=` - ${suffix}`;
if(!new RegExp(`第\\s*${page}\\s*页`).test(description))description+=` - ${suffix}`;
keywords=[...new Set([...keywords,suffix])];
}
if(route?.group==='play'){
if(!title.endsWith(` | ${siteName}`))title+=` | ${siteName}`;
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);
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;
if(params.has('commentSort')&&params.get('commentSort')!=='2')indexable=false;
if(isContent&&page>1)indexable=false;
if((route?.group==='play'||route?.group==='page'&&route.key==='config')&&content.indexable!==true)indexable=false;
if(content.indexable===false)indexable=false;
const pageSize=Number.isSafeInteger(content.pageSize)&&content.pageSize!>0?content.pageSize!:24;
const totalCount=Number.isSafeInteger(content.totalCount)&&content.totalCount!>=0?content.totalCount:undefined;
const totalPages=totalCount===undefined?undefined:Math.max(1,Math.ceil(totalCount/pageSize));
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 origin=environment.origin;
const absolute=(path:string)=>origin?new URL(path,origin).href:'';
const canonical=indexable?absolute(canonicalPagePath(pathname,content)):'';
let previous='',next='';
if(indexable&&route&&supportsPagination(route)&&totalPages!==undefined&&totalPages>1){
if(page>1)previous=absolute(paginationPath(canonicalPagePath(pathname,content)+search,page-1));
if(page<totalPages)next=absolute(paginationPath(canonicalPagePath(pathname,content)+search,page+1));
}
const head:PageHead={title,description,keywords,siteName,robots,canonical:canonical||undefined,previous:previous||undefined,next:next||undefined,image:absolute('/og-default.jpg'),type:route?.group==='play'||route?.group==='video'?'video.episode':route?.group==='info'&&['user-agreement','privacy-policy','2257','dmca'].includes(route.key)?'article':'website'};
head.jsonLd=buildStructuredData(pathname,head,environment,content);
return head;
}
+64
View File
@@ -0,0 +1,64 @@
import type { HeaderNavigation, NavigationCategory } from './api/types';
import { recordList, safeUrl } from './api/protocol';
import { navigationSection, parseSitePath, topNavigationSection } from './routing';
export interface NavigationEntry { id:string; name:string; href:string; key:string; icon:string; external:boolean; children:NavigationEntry[] }
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/'};
function destination(value:unknown,name:string,origin:string):{href:string;external:boolean}|undefined {
const raw=typeof value==='string'?value.trim():'';
const input=raw||emptyPaths[name];
if(!input||/[\\\u0000-\u001f]/.test(input)||!(/^(?:https?:\/\/|\/)/i.test(input)))return;
const base=origin||'https://site.invalid';
const valid=safeUrl(input,base);if(!valid)return;
const url=new URL(valid);
if(url.origin!==new URL(base).origin)return {href:valid,external:true};
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;
const route=parseSitePath(url.pathname);
const path=route&&url.pathname!=='/'?url.pathname.replace(/\/?$/,'/'):url.pathname;
return {href:path+url.search,external:false};
}
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 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]:[];
});
if(!target&&!children.length)return;
const route=target&&!target.external?parseSitePath(new URL(target.href,'https://site.invalid').pathname):null;
const key=route?topNavigationSection(route)||route.key:'nav-'+id;
const iconValue=typeof item.icon==='string'&&/^(?:https?:\/\/|\/)/i.test(item.icon)?safeUrl(item.icon,origin||'https://site.invalid'):'';
const icon=iconValue&&item.icon?.startsWith('/')&&!item.icon.startsWith('//')?item.icon:iconValue;
return {id,name,href:target?.href||'',external:target?.external||false,key,icon,children};
};
return recordList<NavigationCategory>(data?.categoryList).filter(item=>item.categoryType===1||item.categoryType===2).flatMap((item,index)=>{
const entry=make(item,index);return entry?[entry]:[];
});
}
export function navigationIsActive(href:string,address:string,surface:NavigationSurface='top'):boolean {
if(!href.startsWith('/')||href.startsWith('//'))return false;
const target=new URL(href,'https://site.invalid'),current=new URL(address,'https://site.invalid');
const item=parseSitePath(target.pathname,target.search),route=parseSitePath(current.pathname,current.search);
if(!item||!route)return false;
for(const [key,value] of target.searchParams)if(current.searchParams.get(key)!==value)return false;
if(item.group===''||route.group==='')return item.group===''&&route.group==='';
if(item.group==='page'&&!item.value){
return (surface==='top'?topNavigationSection(route):navigationSection(route))===item.key;
}
if(item.key==='rank'&&item.group==='page'&&item.value==='hot')return route.group==='page'&&route.key==='rank';
return item.group===route.group&&item.key===route.key&&item.value===route.value;
}
export function mobileNavigationEntries(items:NavigationEntry[]):NavigationEntry[] {
return items.flatMap(item=>item.children.length?[...(item.href?[{...item,children:[]}]:[]),...item.children]:[item]);
}
+35
View File
@@ -0,0 +1,35 @@
import type { Config, FooterInfoResponse, FooterNavItem, Advertisement } from './api/types';
import { recordList } from './api/protocol';
import type { SiteSharedData } from './page-data';
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);
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);
}
/** Only fields used by visible site controls enter the client snapshot. SDK config stays server-side. */
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',
].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>={};
for(const name of ['id','title','images','link','advertiseCode','advertiseLocationCode','advertiseLocationName'] as const)if(typeof item[name]==='string'||typeof item[name]==='number')output[name]=item[name];
if(typeof item.location==='number')output.location=item.location;
return output as unknown as Advertisement;
})]));
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(),
};
}
+185
View File
@@ -0,0 +1,185 @@
import type { Chapter, DramaDetail, HomeData, PageData } from './api/types';
import type { PageHead, SeoContent, SeoEnvironment } from './seo';
import { contentPath, getRankPage, matchesContentKey, parseSitePath, type SiteRoute } from './routing';
import { homeBlocks, homePreviewItems } from './api/protocol';
export type SchemaNode=Record<string,unknown>;
export interface SchemaGraph { '@context':'https://schema.org'; '@graph':SchemaNode[] }
export interface SchemaItem { path:string }
export interface SchemaQuestion { question:string; answer:string }
export interface SchemaDrama {
id:number; slug?:string; name:string; description?:string; cover?:string; episodes?:number; genres?:string[];
published?:string; modified?:string; actor?:string;
video?:{source?:string; duration?:number; width?:number; height?:number; size?:number; views?:number};
}
export const JSON_LD_ATTRIBUTE='data-site-json-ld';
const plain=(value:unknown):string=>typeof value==='string'?value.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi,'').replace(/<[^>]*>/g,'').replace(/\s+/g,' ').trim():'';
const first=(...values:unknown[])=>values.map(plain).find(Boolean)||'';
const positive=(value:unknown):value is number=>typeof value==='number'&&Number.isFinite(value)&&value>0;
const id=(value:unknown):value is number=>positive(value)&&Number.isSafeInteger(value);
const records=(value:unknown):Record<string,unknown>[]=>Array.isArray(value)?value.filter(item=>item&&typeof item==='object'&&!Array.isArray(item)):[];
/** Accept real HTTP(S)/root-relative URLs, never placeholders, credentials, blobs or opaque media hashes. */
export function schemaUrl(value:unknown,origin:string):string {
const raw=typeof value==='string'?value.trim():'';
if(!raw||!(raw.startsWith('/')||/^https?:\/\//i.test(raw))||/[\\\u0000-\u001f]/.test(raw))return '';
try{
const url=new URL(raw,origin);
if(!['https:','http:'].includes(url.protocol)||url.username||url.password)return '';
if(url.pathname==='/placeholder.png'||url.pathname==='/assets/poster-placeholder.svg')return '';
return url.href;
}catch{return '';}
}
/** API timestamps without a timezone use UTC+8, matching the reference project. */
export function schemaDate(value:unknown):string {
const raw=typeof value==='string'?value.trim():'';
const match=raw.match(/^(\d{4})[-/](\d{2})[-/](\d{2})(?:[T ](\d{2}):(\d{2}):(\d{2})(\.\d{1,3})?(Z|[+-]\d{2}:\d{2})?)?$/);
if(!match)return '';
const [,year,month,day,hour='00',minute='00',second='00',fraction='',zone='+08:00']=match;
if(Number(year)<1970||raw.startsWith('1970-01-01')||Number(month)<1||Number(month)>12||Number(day)<1||Number(day)>new Date(Date.UTC(Number(year),Number(month),0)).getUTCDate()||Number(hour)>23||Number(minute)>59||Number(second)>59)return '';
const date=`${year}-${month}-${day}T${hour}:${minute}:${second}${fraction}${zone}`;
return Number.isFinite(Date.parse(date))?date:'';
}
function itemPaths(value:unknown,kind:'detail'|'video'|'topics'|'theme'):SchemaItem[] {
return records(value).filter(item=>id(item.id)).map(item=>({path:contentPath(kind,{id:Number(item.id),slug:item.slug})}));
}
export function homeSchemaItems(home:HomeData):SchemaItem[] {
const banners=records(home.homeBannerData);
const active=(banners.some(item=>item.showOnBanner)?banners.filter(item=>item.showOnBanner):banners).slice(0,6);
const items=itemPaths(active,'detail');
for(const block of homeBlocks(home)){
items.push(...(block.type===3?itemPaths(homePreviewItems(block,block.themeList),block.topicKind||'topics'):itemPaths(homePreviewItems(block,block.list),'detail')));
}
return items;
}
export function listSchemaItems(data:PageData<unknown>,route:SiteRoute):SchemaItem[] {
if(route.key==='topics'&&!route.value)return itemPaths(data.list,'topics');
if(route.key==='config'&&data.type===3)return itemPaths(data.themeList,'theme');
return itemPaths(data.list,'detail');
}
export function dramaSchemaData(data:DramaDetail,chapter?:Chapter):SchemaDrama {
const names=Array.isArray(data.tagNames)?data.tagNames:Array.isArray(data.themeNames)?data.themeNames:[];
const genres=[...new Set(names.map(plain).filter(Boolean))];
const published=[data.onlineAt,data.addedTime,data.mediaCreatedAt,data.createdAt].map(schemaDate).find(Boolean)||'';
const source=chapter?.canWatch===true||chapter&&!chapter.needUnlock&&!chapter.needVip&&(chapter.payType??0)===0?chapter.videoUrl:undefined;
const video=chapter?{source,duration:chapter.mediaTime,width:chapter.width,height:chapter.height,size:chapter.mediaSize,views:chapter.countBrowse}:data.video?{source:data.video.url,duration:data.video.videoTime,width:data.video.width,height:data.video.height,views:data.watchTimes}:undefined;
return {id:data.id,slug:typeof data.slug==='string'?data.slug:undefined,name:first(data.title,data.playName),description:first(data.desc,data.description),cover:first(data.cover,data.coverImg,data.video?.cover,data.banner,data.horizontalCover),episodes:data.totalEpisode,genres,published,modified:[data.mediaUpdatedAt,data.updatedAt].map(schemaDate).find(Boolean)||'',actor:plain(data.anchor),video};
}
export function faqFromHtml(html:string):SchemaQuestion[] {
const decode=(value:string)=>plain(value).replace(/&(?:amp|lt|gt|quot|apos|#39|nbsp);/g,entity=>({'&amp;':'&','&lt;':'<','&gt;':'>','&quot;':'"','&apos;':"'",'&#39;':"'",'&nbsp;':' '})[entity]||entity).trim();
return [...html.matchAll(/<details\b[^>]*>\s*<summary\b[^>]*>([\s\S]*?)<\/summary>([\s\S]*?)<\/details>/gi)]
.map(([,question,answer])=>({question:decode(question),answer:decode(answer)})).filter(item=>item.question&&item.answer);
}
const channelNames:Record<string,string>={shorts:'短剧',topics:'专题推荐',theme:'题材短剧',tag:'标签短剧',config:'更多好剧'};
function breadcrumbItems(route:SiteRoute,pageURL:string,origin:string,content:SeoContent):[string,string][] {
const items:[string,string][]=[['首页',origin+'/']];
if(!route.group)return items;
if(['detail','play','video'].includes(route.group)){
items.push([route.group==='video'?'剧集':'短剧',origin+(route.group==='video'?'/series/':'/shorts/')]);
if(route.group==='play')items.push([content.entity?.name||content.title||'剧集详情',origin+contentPath('detail',content.entity||route.key)]);
items.push([route.group==='play'&&positive(content.episode)?`第${content.episode}集`:content.entity?.name||content.title||'剧集详情',pageURL]);
}else{
if(route.group==='page'&&route.key==='topics'&&route.value)items.push(['专题',origin+'/topics/']);
if(route.group==='page'&&route.key==='theme')items.push(['短剧',origin+'/shorts/']);
const label=route.key==='rank'?getRankPage(route.value)?.title:content.title||channelNames[route.key];
items.push([label||'平台信息',pageURL]);
}
return items;
}
/** One graph per indexable canonical page; all entity URLs stay on this site's native routes. */
export function buildStructuredData(pathname:string,head:PageHead,environment:SeoEnvironment,content:SeoContent):SchemaGraph|undefined {
const route=parseSitePath(pathname),pageURL=head.canonical;
if(!route||!pageURL||head.robots.includes('noindex')||content.failed||content.missing)return;
const origin=environment.origin;
const brand=environment.site.siteName||head.siteName;
const ref=(value:string)=>({'@id':value});
const organization=origin+'/#organization',website=origin+'/#website';
const absolute=(path:string)=>new URL(path,origin).href;
const graph:SchemaNode[]=[
{'@type':'Organization','@id':organization,name:brand,url:origin+'/',logo:{'@type':'ImageObject','@id':origin+'/#logo',url:absolute('/apple-touch-icon.png'),contentUrl:absolute('/apple-touch-icon.png'),width:180,height:180},...(environment.sameAs?.length?{sameAs:environment.sameAs}:{})},
{'@type':'WebSite','@id':website,name:brand,url:origin+'/',description:environment.site.description||head.description,inLanguage:'zh-CN',publisher:ref(organization)},
];
const page:SchemaNode={'@type':route.group==='page'?'CollectionPage':route.group==='info'&&route.key==='about-us'?'AboutPage':'WebPage','@id':pageURL+'#webpage',name:head.title,description:head.description,url:pageURL,inLanguage:'zh-CN',isPartOf:ref(website),publisher:ref(organization)};
graph.push(page);
if(route.group==='play'&&content.paywalled){
page.isAccessibleForFree=false;
page.hasPart={'@type':'WebPageElement',isAccessibleForFree:false,cssSelector:'.play-paywalled-content'};
}
if(route.group){
page.breadcrumb=ref(pageURL+'#breadcrumb');
graph.push({'@type':'BreadcrumbList','@id':pageURL+'#breadcrumb',itemListElement:breadcrumbItems(route,pageURL,origin,content).map(([name,item],index)=>({'@type':'ListItem',position:index+1,name,item}))});
}
if(!route.group||route.group==='page'){
const seen=new Set<string>();
const items=(content.items||[]).flatMap(item=>{
const url=schemaUrl(item.path,origin);
if(!url||new URL(url).origin!==origin||!parseSitePath(new URL(url).pathname)||seen.has(url))return [];
seen.add(url);return [url];
});
if(items.length){
const offset=route.group==='page'?((route.page||1)-1)*(content.pageSize||24):0;
page.mainEntity=ref(pageURL+'#itemlist');
graph.push({'@type':'ItemList','@id':pageURL+'#itemlist',numberOfItems:items.length,itemListElement:items.map((url,index)=>({'@type':'ListItem',position:offset+index+1,url}))});
}
}
if(content.faq?.length){
page.hasPart=ref(pageURL+'#faq');
graph.push({'@type':'FAQPage','@id':pageURL+'#faq',url:pageURL,inLanguage:'zh-CN',isPartOf:ref(pageURL+'#webpage'),mainEntity:content.faq.map(item=>({'@type':'Question',name:item.question,acceptedAnswer:{'@type':'Answer',text:item.answer}}))});
}
const entity=content.entity;
if(entity&&entity.name&&id(entity.id)&&matchesContentKey(entity,route.key)&&['detail','play','video'].includes(route.group)){
const detailURL=absolute(contentPath('detail',entity)),seriesID=detailURL+'#tvseries';
const cover=schemaUrl(entity.cover,origin);
const published=schemaDate(entity.published),updated=schemaDate(entity.modified);
const modified=updated&&(!published||Date.parse(updated)>=Date.parse(published))?updated:'';
if(route.group!=='video'){
const series:SchemaNode={'@type':'TVSeries','@id':seriesID,name:entity.name,description:entity.description||head.description,url:detailURL,inLanguage:'zh-CN',publisher:ref(organization)};
if(cover)series.image=[cover];
if(id(entity.episodes))series.numberOfEpisodes=entity.episodes;
if(entity.genres?.length)series.genre=entity.genres;
if(entity.actor)series.actor={'@type':'Person',name:entity.actor};
if(published)series.datePublished=published;
if(modified)series.dateModified=modified;
graph.push(series);page.mainEntity=ref(seriesID);
}
const watchPath=contentPath(route.group==='video'?'video':'play',entity,Number(route.value));
const watchURL=absolute(watchPath),videoID=watchURL+'#video';
let episode:SchemaNode|undefined;
if(route.group==='play'&&id(content.episode)){
episode={'@type':'TVEpisode','@id':watchURL+'#episode',name:`${entity.name} - 第${content.episode}集`,description:head.description,episodeNumber:content.episode,url:watchURL,partOfSeries:ref(seriesID)};
if(cover)episode.image=[cover];
if(content.paywalled)episode.isAccessibleForFree=false;
if(published)episode.datePublished=published;
graph.push(episode);page.mainEntity=ref(watchURL+'#episode');
}
// The overview describes the series; only watch pages describe a video. Never invent required dates or thumbnails.
const media=entity.video,source=schemaUrl(media?.source,origin);
if(route.group!=='detail'&&cover&&published&&source){
const video:SchemaNode={'@type':'VideoObject','@id':videoID,name:episode?.name||entity.name,description:head.description,url:watchURL,mainEntityOfPage:ref(pageURL+'#webpage'),inLanguage:'zh-CN',isFamilyFriendly:false,contentRating:'adult',isPartOf:ref(website),publisher:ref(organization),thumbnailUrl:[cover],image:[cover],uploadDate:published,potentialAction:{'@type':'WatchAction',target:{'@type':'EntryPoint',urlTemplate:watchURL}}};
video.contentUrl=source;
if(content.paywalled)video.isAccessibleForFree=false;
if(modified)video.dateModified=modified;
if(positive(media?.duration)&&!String(media.duration).includes('e'))video.duration=`PT${media.duration}S`;
if(id(media?.width))video.width=media.width;
if(id(media?.height))video.height=media.height;
if(id(media?.size))video.contentSize=`${media.size} bytes`;
if(id(media?.views))video.interactionStatistic={'@type':'InteractionCounter',interactionType:{'@type':'WatchAction'},userInteractionCount:media.views};
if(entity.genres?.length){video.genre=entity.genres;video.keywords=entity.genres.join(',');}
graph.push(video);page.mainEntity=ref(videoID);
if(episode)episode.associatedMedia=ref(videoID);
}
}
return {'@context':'https://schema.org','@graph':graph};
}
/** Safe inside a native script, including API strings containing a closing script tag. */
export function serializeStructuredData(value:SchemaGraph):string {
return JSON.stringify(value).replace(/</g,'\\u003c').replace(/\u2028/g,'\\u2028').replace(/\u2029/g,'\\u2029');
}
+35
View File
@@ -0,0 +1,35 @@
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;
}
+22
View File
@@ -0,0 +1,22 @@
import { NextResponse, type NextRequest } from 'next/server';
import { parseSitePath } from '@/lib/routing';
import { preparePageSession } from '@/lib/api/page-session';
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if(pathname==='/healthz'||pathname==='/healthz/')return NextResponse.next();
if (pathname.endsWith('/') || (pathname !== '/navigation' && !parseSitePath(pathname))) {
const route=parseSitePath(pathname);
// Site pages and /navigation need a guest identity; unknown/404 URLs must not handshake.
const entry=pathname.replace(/\/$/,'')==='/navigation';
return preparePageSession(request,!!route||entry);
}
const destination = new URL(request.url);
destination.pathname = pathname + '/';
return NextResponse.redirect(destination, 308);
}
export const config = {
matcher: '/((?!api/|_next/|h5/|assets/|scripts/|static/|sitemaps/|sitemap\\.xml|robots\\.txt|enkey|newenkey|\\.well-known/).*)',
};