@@ -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} />;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// The browser BFF stays separate from /api/app/*, which Nginx sends directly to Go.
|
||||
export { GET, POST } from '../app/[...path]/route';
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -0,0 +1,158 @@
|
||||
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 DEVICE = "drama_device";
|
||||
const REGISTERED = "drama_registered";
|
||||
const CHANNEL = "drama_channel";
|
||||
const SKIP_AUTO = "drama_skip_auto";
|
||||
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;
|
||||
|
||||
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:user.isRegister || user.type === 1 || (user.isRegister === undefined && user.type === undefined && registered)};
|
||||
}
|
||||
|
||||
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) && path !== 'auth/logout') 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(REGISTERED,'',{...cookieOptions,maxAge:0});
|
||||
};
|
||||
if (path === 'auth/logout') {
|
||||
if (method !== 'POST') return NextResponse.json({code:405,data:null},{status:405,headers});
|
||||
const response = NextResponse.json({code:200,data:null},{headers});
|
||||
clearSession(response);
|
||||
response.cookies.set(SKIP_AUTO,'1',{httpOnly:true,sameSite:'lax',secure,path:'/'});
|
||||
return response;
|
||||
}
|
||||
let token = PUBLIC_PATHS.has(path) ? '' : request.cookies.get(AUTH)?.value || '';
|
||||
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>;
|
||||
if (path === 'auth/guest') {
|
||||
if (!token && input.automatic === true && request.cookies.get(SKIP_AUTO)?.value === '1') {
|
||||
const response = NextResponse.json({code:200,data:null},{headers});
|
||||
response.cookies.set(SKIP_AUTO,'',{...cookieOptions,maxAge:0});
|
||||
setContextCookies(response);
|
||||
return response;
|
||||
}
|
||||
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);
|
||||
body = path === 'auth/login'
|
||||
? {account,password,devID:device,channel}
|
||||
: {accountNo:account,password,email:text(input.email) || account+'@91dj.local',devID:device,channel,...(text(input.agentInviteCode) ? {agentInviteCode:text(input.agentInviteCode).slice(0,100)} : {})};
|
||||
// Only guest binding inherits the old identity; account switching does not.
|
||||
if (path === 'auth/login' || registered) token = '';
|
||||
} else if (path === 'auth/resetPassword') {
|
||||
body = {accountNo:text(input.accountNo),email:text(input.email),newPassword:input.newPassword};
|
||||
token = '';
|
||||
}
|
||||
result = await call(path+request.nextUrl.search,{method,body,token,device,userAgent});
|
||||
// Retry only an explicitly rejected guest request, once; never downgrade registered actions.
|
||||
if (expired(result.code) && !PUBLIC_PATHS.has(path) && !CREDENTIALS.has(path) && !registered) {
|
||||
session = await guest();
|
||||
token = session.token!;
|
||||
result = await call(path+request.nextUrl.search,{method,body,token,device,userAgent});
|
||||
}
|
||||
if (result.code === 200 && ['auth/login','auth/register','auth/getInfo'].includes(path)) {
|
||||
session = sessionUser(result.data,path === 'auth/getInfo' ? token : '',registered || CREDENTIALS.has(path));
|
||||
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) {
|
||||
clearSession(response);
|
||||
if (registered) response.headers.set('X-Auth-State','login-required');
|
||||
} else if (session?.token) {
|
||||
response.cookies.set(AUTH,session.token,cookieOptions);
|
||||
response.cookies.set(REGISTERED,session.isRegister || session.type === 1 ? '1' : '0',cookieOptions);
|
||||
response.cookies.set(SKIP_AUTO,'',{...cookieOptions,maxAge:0});
|
||||
}
|
||||
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) {
|
||||
clearSession(response);
|
||||
if (registered) response.headers.set('X-Auth-State','login-required');
|
||||
}
|
||||
setContextCookies(response);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
@@ -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);}
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
export default function PageError({retry}:{retry:()=>void}) {
|
||||
return <main className="route-not-found">
|
||||
<title>页面暂不可用 - 91短剧</title>
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
<h1>页面暂不可用</h1>
|
||||
<p>内容服务暂时异常,请稍后重试</p>
|
||||
<button type="button" className="primary" onClick={retry}>重新加载</button>
|
||||
<a href="/">返回首页</a>
|
||||
</main>;
|
||||
}
|
||||
+3160
File diff suppressed because one or more lines are too long
@@ -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('/'));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const dynamic='force-dynamic';
|
||||
|
||||
export function GET() {
|
||||
return Response.json({status:'ok',environment:process.env.APP_ENV||'production',application:'91-drama-next'},
|
||||
{headers:{'Cache-Control':'no-store','X-Robots-Tag':'noindex, nofollow'}});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_46678_103162)">
|
||||
<rect x="-108" y="-67" width="700" height="748" fill="#FEDC5A"/>
|
||||
<path d="M98.0592 271.012C90.5364 265.446 83.1513 258.664 75.7852 252.703C71.5678 249.293 63.6888 245.266 63.6603 239.149C63.6223 231.446 71.7198 227.394 78.2785 230.738C82.2774 232.775 89.4346 243.917 92.2984 248.063C95.5944 252.827 105.392 266.362 106.94 270.955C107.563 272.812 107.016 274.992 104.775 274.973C104.267 274.973 98.8429 271.586 98.064 271.012H98.0592Z" fill="black"/>
|
||||
<path d="M60.0058 275.091C60.0248 275.039 60.6422 275.005 60.799 274.64C62.7414 270.199 64.7931 267.962 70.1218 268.337C75.403 268.708 93.0607 281.369 97.8433 285.254C99.4818 286.589 102.441 288.313 101.619 290.83C100.702 293.632 93.3362 290.683 91.4222 290.246C83.7237 288.493 73.2705 286.788 66.0754 284.247C63.4111 283.307 61.7536 281.773 60.8085 279.066C60.6185 278.52 59.9298 275.271 60.0058 275.086V275.091Z" fill="black"/>
|
||||
<path d="M284.689 167.545C291.803 182.97 286.128 213.28 280.267 229.66C261.437 282.287 215.07 322.171 157.983 324.893C149.843 325.282 141.817 324.536 133.738 323.929C130.77 323.705 128.481 324.584 125.75 322.893C123.57 321.544 122.502 319.099 122.544 316.562C122.663 309.932 134.294 289.363 139.452 284.989C145.678 279.708 149.344 281.712 156.559 281.199C169.614 280.268 182.399 275.619 192.829 268.077C195.702 266.002 198.366 263.432 200.936 260.991C184.147 266.059 167.838 266.923 151.187 260.887C132.674 254.176 120.626 240.55 114.86 221.7C111.925 212.116 112.481 206.859 112.134 196.886C111.246 171.282 131.326 140.645 152.83 126.44C196.101 97.8585 265.141 107.965 283.298 161.071C283.886 162.791 284.176 166.424 284.694 167.545H284.689ZM169.595 121.135C168.93 120.793 168.327 121.434 168.65 121.6C169.315 121.942 169.918 121.301 169.595 121.135ZM168.17 121.605L162.011 124.697C163.754 124.155 165.33 123.267 167.054 122.597C167.734 122.336 168.436 123.053 168.175 121.605H168.17ZM161.417 124.711L158.064 126.302L158.496 127.209L161.849 125.618L161.417 124.711ZM157.247 127.304C156.411 127.266 155.58 127.242 154.754 127.418C151.776 128.04 140.9 137.824 138.488 140.592C138.051 141.096 135.814 143.808 135.885 144.154C136.388 146.605 139.68 141.718 140.131 141.295C145.426 136.328 150.598 132.78 156.354 128.525C156.886 128.13 157.537 128.529 157.252 127.304H157.247ZM174.349 170.997C171.861 186.181 170.441 201.549 168.223 216.776C169.168 225.628 175.589 228.772 183.696 225.699C196.324 220.912 214.381 210.706 226.601 204.014C232.618 200.718 238.678 198.453 238.004 190.213C237.681 186.266 235.449 184.727 232.879 182.391C218.94 175.438 205.452 164.415 191.461 157.918C189.186 156.863 186.26 156.617 183.843 156.749C181.25 156.892 177.731 159.699 176.41 161.902C174.653 164.847 174.876 167.73 174.345 170.997H174.349Z" fill="black"/>
|
||||
<path d="M183.847 156.748C186.264 156.615 189.195 156.862 191.465 157.916C205.456 164.413 218.944 175.436 232.883 182.389C235.452 184.73 237.685 186.264 238.008 190.211C238.682 198.456 232.622 200.721 226.605 204.012C214.385 210.704 196.328 220.91 183.7 225.698C175.593 228.77 169.172 225.626 168.227 216.774C170.444 201.548 171.864 186.179 174.353 170.996L176.248 168.621C176.547 171.888 175.483 175.142 175.279 178.333C175.246 178.884 174.937 180.964 175.768 180.964L177.701 166.987C178.912 161.696 182.859 159.279 188.121 159.118C188.406 156.662 183.101 159.906 183.847 156.748Z" fill="#FCD02B"/>
|
||||
<path d="M183.845 156.75C183.104 159.908 188.405 156.665 188.12 159.12C182.857 159.281 178.911 161.699 177.7 166.989L175.767 180.967C174.936 180.967 175.244 178.886 175.278 178.335C175.482 175.139 176.546 171.886 176.247 168.623L174.352 170.998C174.888 167.735 174.66 164.848 176.417 161.903C177.733 159.699 181.257 156.888 183.85 156.75H183.845Z" fill="#FDFDFD"/>
|
||||
<path d="M157.244 127.302C157.529 128.532 156.883 128.129 156.346 128.523C150.585 132.778 145.418 136.326 140.123 141.294C139.671 141.716 136.38 146.603 135.877 144.153C135.805 143.806 138.042 141.094 138.479 140.591C140.897 137.817 151.768 128.039 154.746 127.416C155.577 127.241 156.403 127.264 157.239 127.302H157.244Z" fill="#FCD02B"/>
|
||||
<path d="M161.417 124.697L158.062 126.291L158.495 127.2L161.849 125.607L161.417 124.697Z" fill="#FCD02B"/>
|
||||
<path d="M168.168 121.604C168.434 123.052 167.727 122.33 167.048 122.596C165.328 123.266 163.752 124.154 162.004 124.695L168.164 121.604H168.168Z" fill="#FCD02B"/>
|
||||
<path d="M169.597 121.132C169.92 121.298 169.317 121.939 168.652 121.598C168.329 121.431 168.932 120.79 169.597 121.132Z" fill="#FCD02B"/>
|
||||
<path d="M361.793 136.527C362.843 136.769 364.885 137.401 366.419 137.79C370.746 138.892 383.697 141.713 386.808 143.523C389.876 145.309 390.816 148.923 390.455 152.257L359.808 305.877C358.26 311.139 353.653 314.378 348.358 315.323L303.99 315.152C300.604 313.879 298.922 310.654 298.813 307.135L321.586 192.63C321.595 192.265 320 192.075 319.62 192.151C315.07 193.091 308.345 197.983 303.719 198.51C299.193 199.023 295.579 196.344 295.921 191.676C298.044 182.885 298.096 172.037 300.518 163.484C301.126 161.337 302.627 159.514 304.631 158.573C318.855 152.076 332.951 143.1 347.37 137.197C353.586 134.651 355.405 135.06 361.788 136.527H361.793Z" fill="black"/>
|
||||
<path d="M391.967 78.004C391.407 78.9539 392.513 82.5538 392.846 83.8171C393.924 87.93 395.634 95.4433 397.097 99.0385C397.282 99.4945 397.253 100.017 397.861 100.202C399.063 100.568 415.049 94.5505 417.713 94.0186C418.867 93.1589 418.112 91.1642 419.641 89.2978C425.336 82.3259 436.492 90.0102 430.816 97.6233C428.779 100.354 427.226 99.504 424.96 100.316C423.004 101.019 408.585 133.067 405.731 137.128C405.218 138.006 404.562 139.004 403.475 139.179C401.395 139.517 393.045 136.064 390.329 135.247C374.661 130.54 359.577 126.485 343.53 123.155C340.732 122.576 336.078 123.426 336.334 119.061L342.285 79.5428C338.856 76.9687 338.448 71.2031 341.758 68.3963C347.215 63.7657 354.819 68.8189 352.715 75.5534C352.42 76.4938 350.967 77.1966 351.722 78.365L364.089 92.1378C367.599 88.6994 371.147 85.2514 374.77 81.9317C376.029 80.7776 381.315 76.9544 381.429 75.7529C381.695 72.8653 379.724 71.7492 382.379 68.1683C388.344 60.123 400.013 69.0042 393.563 76.9877C393.136 77.5196 392.1 77.7856 391.977 77.9993L391.967 78.004ZM415.609 102.13C414.759 101.299 395.648 109.572 393.748 109.055C392.528 108.722 392.252 107.677 391.849 106.656C389.113 99.7272 388.287 90.7986 385.684 83.8504C385.508 83.3802 385.504 82.967 384.981 82.6631C381.362 84.596 365.875 101.499 363.576 101.646C362.774 101.698 362.109 101.408 361.496 100.91C359.482 99.2617 355.93 93.8048 353.859 91.4302C353.498 91.017 352.871 90.8983 352.591 90.5848C352.368 90.3331 349.105 84.862 348.174 85.978L344.37 115.894C363.619 118.772 382.056 125.046 400.597 130.654L415.609 102.126V102.13Z" fill="black"/>
|
||||
<path d="M415.606 102.132L400.594 130.661C382.053 125.052 363.621 118.779 344.367 115.901L348.171 85.9849C349.107 84.8688 352.365 90.34 352.588 90.5917C352.868 90.9052 353.495 91.0239 353.856 91.4371C355.927 93.8117 359.479 99.2686 361.493 100.917C362.106 101.415 362.766 101.705 363.573 101.653C365.872 101.506 381.359 84.6029 384.978 82.6699C385.501 82.9739 385.505 83.3871 385.681 83.8572C388.284 90.8102 389.11 99.734 391.846 106.663C392.249 107.684 392.53 108.729 393.745 109.062C395.645 109.579 414.756 101.301 415.606 102.137V102.132Z" fill="#FCD02B"/>
|
||||
<path d="M384.145 264.584C383.824 263.367 384.081 262.224 384.476 261.065C386.014 256.589 391.478 250.094 394.122 246.168C398.929 239.033 408.217 223.992 414.498 218.987C425.218 210.447 438.464 221.829 429.998 232.885C416.934 242.189 404.484 253.571 391.179 262.432C388.984 263.895 386.853 266.181 384.145 264.59V264.584Z" fill="black"/>
|
||||
<path d="M407.763 278.755C407.629 278.787 407.602 279.444 407.18 279.583C406.465 279.813 399.682 280.165 398.875 280.037C392.028 278.953 404.104 271.454 405.589 270.418C411.197 266.514 429.709 254.09 435.616 253.935C444.734 253.695 448.579 265.568 440.872 270.45C439.398 271.385 437.854 271.721 436.22 272.207C426.948 274.952 417.152 276.437 407.768 278.755H407.763Z" fill="black"/>
|
||||
<ellipse cx="255.5" cy="688" rx="408.5" ry="353" fill="#0E0E0F"/>
|
||||
<path d="M355.492 368.003H369.882C370.975 368.003 369.827 370.887 369.759 371.404C368.966 377.378 367.668 383.172 366.596 389.093C363.327 407.181 360.775 425.91 357.013 443.828C355.809 449.564 354.044 454.413 348.082 456.63C344.369 458.01 334.501 458.375 330.513 457.884C329.744 457.79 329.468 457.743 329.485 456.856C329.796 453.164 329.377 448.686 329.788 445.088C330.073 442.595 331.332 443.713 332.88 443.769C336.358 443.899 342.026 444.304 342.875 439.966C345.985 421.27 349.433 402.556 352.622 383.927C353.415 379.292 353.859 374.25 354.848 369.706C354.978 369.11 354.983 368.423 355.492 368V368.003Z" fill="#F9C027"/>
|
||||
<path d="M349.912 376.521C350.008 376.548 350.431 376.98 350.496 377.115C350.854 377.867 349.618 384.266 349.386 385.688C346.596 402.707 343.201 419.659 340.29 436.646C340.22 436.734 339.697 437.022 339.635 437.022H327.888C327.785 437.022 327.538 436.693 327.488 436.534C330.569 416.672 334.264 396.827 337.703 377.003C337.788 376.883 338.657 376.521 338.757 376.521H349.917H349.912Z" fill="#F9C027"/>
|
||||
<path d="M333.459 369.187L281.177 369.166C278.645 369.046 278.962 371.73 278.657 373.401C275.852 388.77 273.596 404.362 270.269 419.64C267.778 431.076 263.705 442.057 259 452.73L259.687 453.535C262.081 454.454 264.48 455.382 266.85 456.357C267.661 456.692 270.049 458.269 270.727 457.84C271.129 457.585 274.442 450.336 274.927 449.264C279.182 439.857 282.058 430.462 284.199 420.356C284.404 419.378 283.843 418.204 285.006 417.933C285.25 417.878 286.128 417.745 286.031 418.224L284.857 418.521H297.779L293.964 417.936L298.366 417.64L297.153 424.353C296.543 424.747 287.115 424.13 285.285 424.38C284.739 424.453 284.434 424.465 284.137 424.994L278.16 456.624C278.055 456.859 278.436 457.285 278.545 457.285H318.193C319.392 457.285 319.847 455.279 320.129 454.577C320.232 454.322 320.652 454.342 320.693 454.145C320.825 453.523 320.417 452.886 320.475 452.228C321.218 444.013 324.173 434.903 325.072 426.568C325.201 426.066 324.458 424.395 324.067 424.395H310.704L311.585 418.518C316.185 418.207 321.309 418.935 325.844 418.533C326.364 418.486 327.066 418.48 327.448 418.08C328.549 416.935 328.893 408.195 329.501 406.183H313.935C314.07 405.425 315.374 400.309 314.375 400.309H301.012L300.131 406.183H286.915C286.974 405.105 287.473 399.683 288.072 399.263L313.512 399.114C314.449 398.952 314.273 399.98 314.951 400.039L328.605 399.854C329.794 399.246 330.285 398.456 330.658 397.208C332.085 392.45 333.004 385.172 333.815 380.1C334.17 377.883 335.166 373.654 335.107 371.674C335.075 370.573 334.584 369.527 333.457 369.187H333.459ZM310.12 436.142C309.321 438.465 309.53 442.997 308.654 445.035C308.578 445.211 307.735 445.54 307.623 445.54H293.526C293.438 445.54 293.001 445.103 293.13 444.865L294.848 436.142H310.12ZM317.609 387.683H290.736C291.283 385.78 291.241 383.501 292.058 381.809H318.931C318.713 382.585 318.22 387.683 317.609 387.683Z" fill="#F9C027"/>
|
||||
<path d="M180.477 376.517H201.329L198.706 390.194L188.436 390.35L185.763 405.592H196.042L193.652 419.061L182.544 419.114C182.732 420.321 181.41 421.889 181.792 422.9C181.963 423.346 182.67 424.086 182.976 424.676C185.886 430.283 188.438 436.092 191.622 441.564L183.258 456.988C178.153 452.598 175.619 445.57 173.422 439.367C172.876 439.241 172.852 439.552 172.591 439.854C169.771 443.117 167.363 447.684 164.312 451.253C163.454 452.257 157.686 458.554 157.066 458.718C156.447 458.883 148.344 451.652 147.019 450.921C146.679 449.79 150.958 446.263 151.842 445.244C152.981 443.928 154.426 442.075 155.501 440.68C160.558 434.116 164.876 426.856 167.845 419.102H154.922L157.287 405.607C157.757 405.152 168.485 405.839 170.356 405.607C170.905 405.54 171.352 405.466 171.534 404.873L174.309 390.323L173.413 390.517L167.842 399.886L156.097 398.544L169.052 368.473L169.754 368L183.134 368.72C183.531 368.99 183.428 369.436 183.358 369.839C183.008 371.874 180.756 374.317 180.477 376.52V376.517Z" fill="white"/>
|
||||
<path d="M201.916 443.478C201.44 439.589 200.618 435.739 199.831 431.906C199.67 431.113 198.709 429.768 199.402 428.776C199.793 428.215 211.309 427.278 212.933 426.711C213.182 426.729 213.482 426.72 213.684 426.867C214.331 427.334 216.151 440.708 216.269 442.561L214.251 443.478H221.887L230.833 426.717L244.795 428.502L236.865 443.481H247.585C248.037 443.481 248.005 444.365 247.967 444.72C247.667 447.44 246.375 451.161 245.764 454.001C245.641 454.573 245.656 456.697 244.945 456.697H188.703L191.493 443.481H201.919L201.916 443.478Z" fill="white"/>
|
||||
<path d="M256.503 383.524L203.847 383.864C203.069 383.744 205.592 371.603 205.924 370.616C207.058 370.02 208.223 370.639 209.392 370.654C225.548 370.866 241.722 370.478 257.881 370.628C258.189 370.634 258.577 370.648 258.771 370.918C259.173 371.476 256.571 381.885 256.503 383.524Z" fill="white"/>
|
||||
<path d="M252.872 388.558H205.001L197.814 424.573C197.685 424.884 198.02 425.563 198.246 425.563H245.236C245.445 425.563 246.546 424.67 246.69 424.373C249.037 412.808 251.087 401.154 253.295 389.539C253.436 389.227 253.095 388.555 252.869 388.555L252.872 388.558ZM234.329 412.37C227.809 412.905 221.095 412.696 214.548 412.344L216.363 401.973L216.748 401.477H236.279L234.329 412.37Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_46678_103162">
|
||||
<rect width="512" height="512" rx="256" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,23 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "91短剧 · 百万大神原创短剧",
|
||||
applicationName: "91短剧",
|
||||
icons: {icon:[{url:'/favicon.png',sizes:'48x48',type:'image/png'}],apple:[{url:'/apple-touch-icon.png',sizes:'180x180',type:'image/png'}]},
|
||||
};
|
||||
|
||||
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>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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);}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata:Metadata={title:'页面不存在 - 91短剧',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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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}:{}),
|
||||
};
|
||||
}
|
||||
@@ -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"}>
|
||||
{"下载91短剧 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={"91短剧 App 下载二维码"} />
|
||||
</div>
|
||||
<p id={"app-platform-caption"}>
|
||||
{download === undefined ? "" : download ? "扫码下载安装 91短剧 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>
|
||||
);
|
||||
}
|
||||
@@ -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><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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export function Footer({content}:{content?:string}={}) {
|
||||
return (
|
||||
<>
|
||||
<footer className={"hg-footer hg-footer-original"} aria-label={"网站底部"}>
|
||||
<div className={"hg-footer-original-info"}>
|
||||
<strong className={"hg-footer-original-title"}>
|
||||
<span className="footer-brand-text">91短剧</span>
|
||||
</strong>
|
||||
<p className={"hg-footer-original-slogan"}>
|
||||
{content||"百万大神原创短剧"}
|
||||
</p>
|
||||
<nav className={"hg-footer-links"} aria-label={"平台规则与帮助"}>
|
||||
<a href={"/info/about-us/"}>
|
||||
{"关于我们"}
|
||||
</a>
|
||||
<a href={"/info/user-agreement/"}>
|
||||
{"用户协议"}
|
||||
</a>
|
||||
<a href={"/info/privacy-policy/"}>
|
||||
{"隐私政策"}
|
||||
</a>
|
||||
<a href={"/info/help-center/"}>
|
||||
{"常见问题"}
|
||||
</a>
|
||||
<a href={"/info/2257/"}>
|
||||
{"2257"}
|
||||
</a>
|
||||
<a href={"/info/dmca/"}>
|
||||
{"DMCA"}
|
||||
</a>
|
||||
<a href={"/info/feedback/"}>
|
||||
{"意见反馈"}
|
||||
</a>
|
||||
</nav>
|
||||
<small className={"hg-footer-original-copyright"}>
|
||||
{"© 2026 91短剧 All rights reserved."}
|
||||
</small>
|
||||
<div className={"hg-footer-social"} aria-label={"联系我们"}>
|
||||
<a href={"https://t.me/huangguoAI_2026"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"官方社群"} title={"官方社群"}>
|
||||
<img src={"/assets/c9e57990dff7b688.png"} alt={"官方社群图标"} />
|
||||
</a>
|
||||
<a href={"https://t.me/huangguoAI_2026"} 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>
|
||||
<a href={"https://x.com/huangguoAITV"} 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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="91短剧首页"><img className="hg-complete-logo" src="/assets/5178e628105b82b4.svg" width={125} height={36} alt="91短剧" /></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/4532d28d6a61c426.svg"} alt={"金币图标"} aria-hidden={"true"} />
|
||||
</span>
|
||||
<span className={"coin-caption"}>
|
||||
{"金币 "}
|
||||
<span className={"coin-value"}>
|
||||
{user?.recharge||0}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<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>
|
||||
<main className="shell" hidden={hideContent} aria-labelledby={hideContent?undefined:'home-title'}>
|
||||
{!hideContent&&<h1 id="home-title" className="home-page-title">91短剧 · 百万大神原创短剧</h1>}
|
||||
<div className="home-content" hidden={hideContent} dangerouslySetInnerHTML={{__html:hideContent?'':renderHome(home,error)}} />
|
||||
</main>
|
||||
</>;
|
||||
}
|
||||
@@ -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/7f137587edc271d0.png"} alt={"91短剧官方入口背景"} 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/5178e628105b82b4.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/fcbe47a58d6fd9e7.svg"} width={"5"} height={"3.5"} alt={"进入"+route.label} aria-hidden={"true"} />
|
||||
</span>
|
||||
<img className={"entry-route-glow"} src={"/assets/6fa3b8cccc07ed57.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();}} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
<button id={"floating-app-close"} type={"button"} aria-label={"关闭悬浮 App 下载图标"}>
|
||||
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
|
||||
<path d={"m7 7 10 10M17 7 7 17"}>
|
||||
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
<a className={"page-tool"} id={"floating-download"} data-app-download="" target="_blank" rel="sponsored nofollow noopener noreferrer" aria-label={"下载 App"}>
|
||||
<img className={"floating-app-icon"} src={"/assets/6dd2c76631816aa2.svg"} alt={"91短剧 App"} width={"88"} height={"88"} />
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { NavigationEntry } from '@/lib/site-navigation';
|
||||
import { SiteNavigation } from './SiteNavigation';
|
||||
|
||||
export function Sidebar({navigation,address,error}:{navigation:NavigationEntry[];address:string;error?:boolean}) {
|
||||
return (
|
||||
<>
|
||||
<aside className={"side"} id={"site-sidebar"} aria-label={"网站侧边栏"}>
|
||||
<div className={"sidebar-header"}>
|
||||
<a className={"mark sidebar-logo-toggle"} href={"/"} aria-label={"91短剧首页"}>
|
||||
<span className={"sidebar-brand-window"}>
|
||||
<img className={"sidebar-brand"} src={"/assets/5178e628105b82b4.svg"} alt={"91短剧"} 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/5178e628105b82b4.svg"} alt={"91短剧"} width={"500"} height={"176"} />
|
||||
<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 91短剧"}
|
||||
<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={"联系我们"}>
|
||||
<a href={"https://t.me/huangguoAI_2026"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"QQ群(参考站链接)"} title={"QQ群(参考站链接)"}>
|
||||
<img src={"/assets/c9e57990dff7b688.png"} alt={"官方社群图标"} />
|
||||
</a>
|
||||
<a href={"https://t.me/huangguoAI_2026"} 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>
|
||||
<a href={"https://x.com/huangguoAITV"} 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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/4532d28d6a61c426.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/18da69acb7776e92.svg"} width={"96"} height={"96"} alt={"18岁以上"} />
|
||||
<img className={"startup-age-mobile-icon"} src={"/assets/29b976d622f7c5d1.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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 { 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 { 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||'/';
|
||||
return <>
|
||||
<StructuredData seo={seo} />
|
||||
<Sidebar navigation={navigation} address={address} error={header.error} />
|
||||
<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 content={shared?.footer.footerInfo?.brand?.content} />
|
||||
<SiteRuntime home={home} homeLoaded={homeLoaded&&!info} seo={seo} page={view?.bootstrap} navigation={navigation} session={session} shared={shared} />
|
||||
</>;
|
||||
}
|
||||
@@ -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="/assets/poster-placeholder.svg" 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'?<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'?'hg-desktop-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>;
|
||||
}
|
||||
@@ -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);
|
||||
}} />;
|
||||
}
|
||||
@@ -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)}} />;
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
{
|
||||
"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": 12572
|
||||
},
|
||||
"5178e628105b82b4.svg": {
|
||||
"type": "image/svg+xml",
|
||||
"bytes": 12572
|
||||
},
|
||||
"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": 13124
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
})};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const DEFAULT_API_BASE_URL = 'http://172.104.181.99:21022';
|
||||
export const UPSTREAM_HOP_HEADER = 'x-91dj-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://172.104.56.28:26125' : 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) 接口地址'); }
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { isIP } from 'node:net';
|
||||
import { getApiBaseUrl, UPSTREAM_HOP_HEADER } from './config';
|
||||
|
||||
const CACHE = {'Cache-Control': 'no-store'};
|
||||
|
||||
/** Same visitor IP used when the play ticket was issued. Prefer the proxy header, then the first forwarded address. */
|
||||
function clientIp(request: Request): string {
|
||||
const real = request.headers.get('x-real-ip')?.trim() || '';
|
||||
if (isIP(real)) return real;
|
||||
const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || '';
|
||||
return isIP(forwarded) ? forwarded : '';
|
||||
}
|
||||
|
||||
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: '*/*',[UPSTREAM_HOP_HEADER]:'1'});
|
||||
const range = request.headers.get('range');
|
||||
if (range && !key) headers.set('Range', range);
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
if (userAgent) headers.set('User-Agent', userAgent);
|
||||
// Match chapter detail: send only X-Real-IP. An extra X-Forwarded-For is appended by nginx
|
||||
// and becomes the address the play ticket is checked against.
|
||||
const ip = clientIp(request);
|
||||
if (ip) headers.set('X-Real-IP', ip);
|
||||
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});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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);
|
||||
|
||||
/** Prepare one identity before RSC reads private data; forward its cookies in this same request. */
|
||||
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};
|
||||
let token=cookies.get('drama_session')||'',registered=cookies.get('drama_registered')==='1',user:User|null=null,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_registered','');token='';if(registered)state='login-required';}
|
||||
else throw new Error('账号服务暂不可用');
|
||||
}
|
||||
if(!user&&!registered&&!cookies.has('drama_skip_auto')){
|
||||
const result=await upstream<User>('auth/guest',{...identity,method:'POST',body:{devID:device,deviceId:device,channel,mac:''}});
|
||||
user=result.code===200?publicUser(result.data):null;token=result.data?.token||'';
|
||||
if(!user||!token)throw new Error('游客会话暂不可用');
|
||||
}
|
||||
if(user){
|
||||
registered=user.isRegister===true||user.type===1||registered;
|
||||
set('drama_session',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';
|
||||
}else if(cookies.has('drama_skip_auto')){set('drama_skip_auto','');state='login-required';}
|
||||
}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)response.cookies.set(key,value,{...options,...(key==='drama_device'||key==='drama_channel'?{httpOnly:false,maxAge:365*86400}:{}),...(!value?{maxAge:0}:{})});
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Envelope } 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)) : [];
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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},
|
||||
'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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
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/hot/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>> {
|
||||
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 device = (options.device || "server-public").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 100);
|
||||
const rawUA = options.userAgent || "91-drama-web/1.0";
|
||||
const sys = /Android/i.test(rawUA) ? "h5_android" : /iPhone|iPad|iPod/i.test(rawUA) ? "h5_ios" : "h5_pc";
|
||||
const headers: Record<string,string> = {
|
||||
Accept: "application/json", "User-Agent": rawUA,
|
||||
[UPSTREAM_HOP_HEADER]: '1',
|
||||
"X-User-Agent": `DevID=${device};DevType=OTHER;SysType=${sys};Ver=1.0.0;BuildID=web`,
|
||||
};
|
||||
if (options.token) headers.Authorization = options.token;
|
||||
if (options.clientIp) headers['X-Real-IP'] = options.clientIp;
|
||||
for(const key of ['X-SDK-Common','X-User-Agent','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};}
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
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:Headers,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');
|
||||
const clean=(value:unknown)=>String(value??'').replace(/[^\x20-\x7e]|[;=]/g,' ').trim();
|
||||
if(common.device_id||common.sid)headers['X-User-Agent']=[`DevID=${clean(common.device_id)}`,`DevType=${clean(common.device)||'OTHER'}`,`SysType=${common.device==='Android'?'h5_android':common.device==='iOS'?'h5_ios':'h5_pc'}`,`Ver=${clean(common.app_version)||'1.0.0'}`,'BuildID=web',`device_brand=${clean(common.device_brand)}`,`device_model=${clean(common.device_model)}`,`sid=${clean(common.sid)}`].join(';');
|
||||
}
|
||||
}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;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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; 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 interface HomeBlock { id: number; name: string; slug?: string; desc?: string; moreUrl?: string; moreCategoryId?: number; rank?: number; type: number; status: boolean; sortType?: number; list?: Drama[]; videoList?: Drama[]; themeList?: Topic[]; seoInfo?: SeoInfo }
|
||||
export interface HomeData { 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 }
|
||||
export interface PageData<T> { 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 FooterInfoResponse { footerInfo?: { brand?: {content?: string}; contactList?: FooterContact[] | null } }
|
||||
export interface Envelope<T> { code: number; data: T; msg?: string; tip?: string }
|
||||
@@ -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 |
@@ -0,0 +1,273 @@
|
||||
import type { MemberInitialData, WalletInitialData } from "../page-data";
|
||||
import type { Drama, Package, PageData, User } from "../api/types";
|
||||
import { recordList, safeUrl } from "../api/protocol";
|
||||
import type { SiteContext } from "./context";
|
||||
import { promotionRow, card, closeButton, empty, escape, imageAttributes, pagination } from "./render";
|
||||
import type { PageHref } from './render';
|
||||
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')!;
|
||||
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'?'提交中…':'登录中…';error.textContent='';
|
||||
void app.api.post<User>('auth/'+mode,payload).then(async()=>{
|
||||
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();
|
||||
app.notify(mode==='register'?'绑定成功':'登录成功');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}"><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();
|
||||
}
|
||||
|
||||
export function accountMenuMarkup(user:User|null|undefined):string {
|
||||
const name=user?.nickName||user?.accountNo||'游客';
|
||||
return `<div class="profile-popover-head"><img ${imageAttributes(user?.avatar,'/icon.svg')} width="46" height="46" alt="${escape(name)}的头像"><div><strong>${escape(name)}</strong><small>${user?'ID '+user.userId:'登录后同步个人数据'}</small></div></div><button data-auth-mode="switch">切换账号</button><button data-auth-mode="register">绑定账号</button><a href="/member/center/">个人中心 ›</a><a href="/member/favorites/">我的收藏 ›</a><a href="/member/likes/">我的点赞 ›</a><a href="/member/history/">观看历史 ›</a><a href="/member/purchased/">已购剧集 ›</a><a href="/info/deposit/">金币 ›</a><a href="/member/settings/">账号设置 ›</a>${user?'<button data-logout>退出登录</button>':''}`;
|
||||
}
|
||||
|
||||
export function settingsMarkup(user:User):string {
|
||||
const bound=user.isRegister||user.type===1;
|
||||
return `<div class="member-settings"><a class="member-settings-back" href="/member/center/">‹ 返回个人中心</a>
|
||||
<section class="member-settings-card"><h2>个人资料</h2><form id="profile-form">
|
||||
<div class="member-avatar-editor"><button type="button" class="member-avatar-preview" data-avatar-choose aria-label="更换头像"><img id="member-avatar-preview-image" ${imageAttributes(user.avatar,'/icon.svg')} alt="当前头像"><span>编辑头像</span></button><div class="member-avatar-controls"><button type="button" data-avatar-choose>更换头像</button><p>支持 JPG、PNG、WebP,最大 5MB</p><p id="member-avatar-status" role="status"></p></div><input type="file" name="avatarFile" id="member-avatar-file" accept="image/png,image/jpeg,image/webp" hidden></div>
|
||||
<label for="member-nickname-input">昵称</label><div class="member-nickname-editor"><input id="member-nickname-input" name="nickName" value="${escape(user.nickName)}" maxlength="30" required autocomplete="nickname" aria-describedby="member-nickname-hint"><button type="submit">保存修改</button></div><p id="member-nickname-hint">支持 1–30 个字符</p>
|
||||
<label class="member-introduction-label" for="member-introduction">个人简介</label><textarea id="member-introduction" name="introduction" maxlength="200">${escape(user.introduction)}</textarea><p class="api-error" role="alert"></p>
|
||||
</form></section>
|
||||
<section class="member-settings-card"><h2>账号与安全</h2>
|
||||
<button type="button" class="member-settings-action" data-auth-mode="register"><span><strong>账号绑定</strong><small>设置账号和密码,方便下次登录</small></span><span class="member-settings-trailing">${bound?'已绑定':'未绑定'} <span aria-hidden="true">›</span></span></button>
|
||||
<button type="button" class="member-settings-action" data-auth-mode="switch"><span><strong>切换账号</strong><small>使用其他账号登录</small></span><span aria-hidden="true">›</span></button>
|
||||
<details class="member-security-password"><summary class="member-settings-action"><span><strong>修改密码</strong><small>通过当前密码设置新密码</small></span><span aria-hidden="true">›</span></summary><form id="security-form"><label>旧密码<input name="oldPassword" type="password" autocomplete="current-password" required></label><label>新密码<input name="newPassword" type="password" autocomplete="new-password" minlength="6" maxlength="64" required></label><p class="api-error" role="alert"></p><button class="primary" type="submit">保存密码</button></form></details>
|
||||
<button type="button" class="member-settings-action" data-logout><span><strong>退出登录</strong><small>退出当前账号</small></span><span aria-hidden="true">›</span></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 promotions=promotionRow(home?.advitise?.nineGridList);
|
||||
const header=`${promotions?`<section class="member-advertisements" aria-label="广告推荐"><span class="member-ad-label">广告</span>${promotions}</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/4532d28d6a61c426.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;
|
||||
let previewUrl='';
|
||||
const clearPreview=()=>{if(previewUrl)URL.revokeObjectURL(previewUrl);previewUrl='';};
|
||||
signal.addEventListener('abort',clearPreview,{once:true});
|
||||
const input=root.querySelector<HTMLInputElement>('#member-avatar-file');
|
||||
const originalAvatar=root.querySelector('#member-avatar-preview-image')?.cloneNode(true);
|
||||
const restoreAvatar=()=>{if(originalAvatar)root.querySelector('#member-avatar-preview-image')?.replaceWith(originalAvatar.cloneNode(true));};
|
||||
root.addEventListener('click',event=>{if((event.target as Element).closest('[data-avatar-choose]'))input?.click();},{signal});
|
||||
input?.addEventListener('change',()=>{
|
||||
clearPreview();const file=input.files?.[0],status=root.querySelector('#member-avatar-status');
|
||||
if(status)status.textContent='';
|
||||
if(!file){restoreAvatar();return;}
|
||||
if(file.size>5*1024*1024||!['image/png','image/jpeg','image/webp'].includes(file.type)){input.value='';restoreAvatar();if(status)status.textContent='请上传 5MB 以内的 PNG、JPEG 或 WebP 图片';return;}
|
||||
previewUrl=URL.createObjectURL(file);const preview=root.querySelector<HTMLImageElement>('#member-avatar-preview-image');if(preview){const replacement=document.createElement('img');replacement.id=preview.id;replacement.alt='新头像预览';replacement.src=previewUrl;preview.replaceWith(replacement);}
|
||||
if(status)status.textContent='点击“保存修改”保存新头像';
|
||||
},{signal});
|
||||
const bindForm=(selector:string,password=false)=>root.querySelector(selector)?.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;
|
||||
form.dataset.submitting='1';form.querySelector('.api-error')!.textContent='';
|
||||
void (async()=>{
|
||||
const data=new FormData(form);const payload:Record<string,unknown>=password?{oldPassword:data.get('oldPassword'),newPassword:data.get('newPassword')}:{nickName:data.get('nickName'),introduction:data.get('introduction')};
|
||||
const file=data.get('avatarFile');
|
||||
if(file instanceof File&&file.size){
|
||||
if(file.size>5*1024*1024||!['image/png','image/jpeg','image/webp'].includes(file.type))throw new Error('请上传 5MB 以内的 PNG、JPEG 或 WebP 图片');
|
||||
const upload=new FormData();upload.append('file',file);upload.append('watermark','0');
|
||||
const result=await app.api.post<{sourceNameFs:string}>('upload/image',upload);if(!result.sourceNameFs)throw new Error('头像上传失败,请重试');payload.avatar=result.sourceNameFs;
|
||||
}
|
||||
if(signal.aborted)return;
|
||||
await app.api.post('auth/profile',payload);await app.api.refreshUser();if(!signal.aborted){app.notify(password?'密码已更新':'资料已保存');clearPreview();if(!password)void app.route();}
|
||||
form.querySelectorAll<HTMLInputElement>('input[type="password"]').forEach(input=>input.value='');
|
||||
})().catch(error=>{if(!signal.aborted)form.querySelector('.api-error')!.textContent=error.message;}).finally(()=>{button.disabled=false;delete form.dataset.submitting;});
|
||||
},{signal});
|
||||
bindForm('#profile-form');bindForm('#security-form',true);
|
||||
}
|
||||
|
||||
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/4532d28d6a61c426.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 [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(app.api.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);
|
||||
// Reserve a tab during the user gesture so an async order response is not popup-blocked.
|
||||
const paymentWindow=window.open('about:blank','_blank');
|
||||
if(paymentWindow){
|
||||
paymentWindow.opener=null;
|
||||
paymentWindow.document.title='正在创建支付订单';
|
||||
paymentWindow.document.body.textContent='正在创建支付订单,请稍候…';
|
||||
}
|
||||
const closePending=()=>{if(paymentWindow&&!paymentWindow.closed)paymentWindow.close();};
|
||||
signal.addEventListener('abort',closePending,{once:true});
|
||||
void app.api.post<{payUrl?:string;tradeNo?:string}>(vip?'vip/pay':'coin/buy',{packageId:item.id,payMode:mode}).then(result=>{
|
||||
if(signal.aborted||!form.isConnected){closePending();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});
|
||||
signal.removeEventListener('abort',closePending);
|
||||
if(paymentWindow&&!paymentWindow.closed)paymentWindow.location.replace(url);
|
||||
else window.location.assign(url);
|
||||
}).catch(error=>{closePending();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);})
|
||||
.finally(()=>signal.removeEventListener('abort',closePending));
|
||||
},{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});
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { Chapter, Config, DramaDetail, User } from '../api/types';
|
||||
import { base64JSON, COMMON_KEYS, dramaContext, SDK_COMMON_KEYS, SDK_FIELD_KEYS, SERVER_EVENTS, TRACKER_APP_ID, trackingPage, trackingText, type Fields } from '../tracking';
|
||||
import { authMetadata } from './auth';
|
||||
|
||||
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(){
|
||||
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(),headers:Record<string,string>={};
|
||||
if(!SDK_COMMON_KEYS.some(key=>trackingText(fields[key])))return headers;
|
||||
headers['X-SDK-Common']=base64JSON(Object.fromEntries(SDK_FIELD_KEYS.filter(key=>Object.hasOwn(fields,key)).map(key=>[key,fields[key]])));
|
||||
const event=method==='POST'?SERVER_EVENTS[path]:'';
|
||||
if(!event||fields.app_id!==TRACKER_APP_ID||!fields.device_id||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;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { ApiError, decodeEnvelope, EXPIRED_CODES } from "../api/protocol";
|
||||
import type { User } from "../api/types";
|
||||
import { authMetadata } 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/hot/list","playlet/hot/rank/list","playlet/topic/list","playlet/topic/detail","playlet/tag/list","playlet/guess-like/list","playlet/hot-like"]);
|
||||
|
||||
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;
|
||||
|
||||
// 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')); }
|
||||
}
|
||||
async raw<T>(path: string, query: Query = {}, body?: unknown, signal?: AbortSignal): 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/${path}${params.size?"?"+params:""}`, {
|
||||
method, credentials:"same-origin", cache:"no-store", signal,
|
||||
headers: {...tracking,...(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) {
|
||||
if ((EXPIRED_CODES.has(result.code) || response.status===401) && !/^auth\/(login|register|resetPassword|logout)$/.test(path) && version === this.version) {
|
||||
const registered = response.headers.get('X-Auth-State') === 'login-required' || this.user?.isRegister || this.user?.type === 1;
|
||||
this.setUser(null);
|
||||
this.requiresLogin = !!registered;
|
||||
if (registered) this.promptLogin();
|
||||
}
|
||||
throw new ApiError(result.tip || result.msg || "请求失败,请稍后重试",result.code);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
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); }
|
||||
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.user = user;
|
||||
if(typeof window!=='undefined')window.tracker?.syncUser(user);
|
||||
if (user) { this.requiresLogin = false; this.prompted = false; }
|
||||
this.dispatchEvent(new Event('userchange'));
|
||||
}
|
||||
seedSession(user:User|null){this.requiresLogin=!user;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.setUser(null); }
|
||||
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);
|
||||
}
|
||||
async post<T>(path: string, body: unknown): Promise<T> {
|
||||
if (['auth/login','auth/register','auth/logout'].includes(path)) {
|
||||
if (path === 'auth/register' && !this.user && !this.requiresLogin) await this.ensureSession();
|
||||
const payload = path === 'auth/logout' ? {} : {...authMetadata(), ...body as object};
|
||||
return this.authenticate(async () => {
|
||||
const data = await this.raw<T>(path,{},payload);
|
||||
if (path === 'auth/logout') this.reset();
|
||||
else this.setUser(data as User);
|
||||
return data;
|
||||
});
|
||||
}
|
||||
if(!/^auth\/(guest|resetPassword)$/.test(path))await this.ensureSession();
|
||||
return this.raw(path,{},body);
|
||||
}
|
||||
async refreshUser() {
|
||||
await this.ensureSession();
|
||||
const version = this.version;
|
||||
const user = await this.raw<User>('auth/getInfo');
|
||||
if (version === this.version) this.setUser(user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
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 } 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 { 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;if(session)this.api.seedSession(session.user);
|
||||
startImageLoading();
|
||||
this.bindEvents();this.startupDialogs=startStartupDialogs();this.createAccountMenu();bindAccountUI(this);
|
||||
window.addEventListener('pagehide',event=>this.hidePage(event.persisted));
|
||||
window.addEventListener('pageshow',event=>{if(event.persisted)this.restorePage();});
|
||||
this.api.addEventListener('userchange',()=>this.updateAccount());
|
||||
this.api.addEventListener('expired',()=>{this.updateAccount();this.openAuth();});
|
||||
await this.initializeShared();
|
||||
void this.api.initializeSession().then(()=>this.updateAccount()).catch(error=>this.notify(error.message));
|
||||
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');
|
||||
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;
|
||||
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;
|
||||
this.heroTimer=setInterval(()=>this.selectHero(this.feature+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);
|
||||
}
|
||||
}
|
||||
|
||||
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.hasAttribute('data-logout')){await this.api.post('auth/logout',{});this.navigate('/');this.notify('已退出登录');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==='floating-app-close'){$('#page-tool-download').style.display='none';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/all/list',{pageNum:1,pageSize:3,sortType: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='91短剧 App 下载二维码';}
|
||||
}
|
||||
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');entry.addEventListener('click',()=>{this.updateAccount();const rect=entry.getBoundingClientRect();panel.style.right=Math.max(12,innerWidth-rect.right)+'px';panel.style.top=rect.bottom+12+'px';panel.hidden=!panel.hidden;entry.setAttribute('aria-expanded',String(!panel.hidden));});
|
||||
document.addEventListener('click',event=>{if(!entry.contains(event.target as Node)&&!panel.contains(event.target as Node)){panel.hidden=true;entry.setAttribute('aria-expanded','false');}});
|
||||
window.addEventListener('scroll',()=>{panel.hidden=true;},{passive:true});
|
||||
this.updateAccount();
|
||||
}
|
||||
private updateAccount(){
|
||||
const user=this.api.user;const name=user?.nickName||user?.accountNo||'游客';
|
||||
$('#account-entry strong').textContent=name;$('#account-status').textContent=user?(user.isRegister||user.type===1?'已登录':'游客账号'):'未登录';
|
||||
const avatar=$<HTMLImageElement>('#account-entry img');void processImage(safeUrl(user?.avatar)||'/icon.svg',avatar,'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;
|
||||
panel.innerHTML=accountMenuMarkup(user);
|
||||
}
|
||||
|
||||
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();
|
||||
if(footer.brand?.content)$('.hg-footer-original-slogan').textContent=footer.brand.content;
|
||||
}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:'页面初始化失败');});
|
||||
}
|
||||
@@ -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'});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Only device and attribution values are persisted here. Credentials stay in the form/request.
|
||||
let volatileDevice = '';
|
||||
let firstChannel = '';
|
||||
let inviteCode = '';
|
||||
|
||||
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 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 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};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Drama, HomeBlock, HomeData, PageData, Topic } from '../api/types';
|
||||
import type { ApiClient } from './api';
|
||||
import { 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/all/list';query.sortType=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';}
|
||||
// The service exposes the same theme IDs through its paginated topic detail endpoint.
|
||||
if(route.key==='theme')endpoint='playlet/topic/detail';
|
||||
if(endpoint==='playlet/topic/detail'){
|
||||
const identity=contentQuery(route.value!);
|
||||
if(!identity.id)throw new Error('专题 slug 需要先解析为 ID');
|
||||
query.id=identity.id;
|
||||
}
|
||||
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';return {endpoint,query:{}};}
|
||||
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 topic:Topic|undefined;
|
||||
if((route.key==='theme'||route.key==='topics'&&route.value)&&contentQuery(route.value!).slug){
|
||||
// topic/detail accepts only an ID. Resolve its slug through the complete topic directory.
|
||||
const seen=new Set<number>();
|
||||
for(let page=1;!topic;page++){
|
||||
const data=await api.get<PageData<Topic>>('playlet/topic/list',{pageNum:page,pageSize:50},signal);
|
||||
if(!Array.isArray(data.list))throw new Error('专题目录暂不可用');
|
||||
const list=recordList<Topic>(data.list);
|
||||
topic=list.find(item=>Number.isSafeInteger(item.id)&&item.id>0&&matchesContentKey(item,route.value!));
|
||||
if(topic)break;
|
||||
const before=seen.size;for(const item of list)if(Number.isSafeInteger(item.id)&&item.id>0)seen.add(item.id);
|
||||
if(!list.length||Number.isSafeInteger(data.totalCount)&&data.totalCount!>=0&&seen.size>=data.totalCount!)return {missing:true};
|
||||
if(seen.size===before)throw new Error('专题目录分页异常');
|
||||
}
|
||||
}
|
||||
const {endpoint,query}=channelRequest(topic?{...route,value:String(topic.id)}:route,search);
|
||||
if(route.key!=='config'){
|
||||
const data=await api.get<PageData<unknown>>(endpoint,query,signal);
|
||||
if(route.key==='theme'||route.key==='topics'&&route.value){
|
||||
const expected=topic?.id??Number(route.value);
|
||||
if(data.id!==undefined&&data.id!==expected||data.topicInfo?.id!==undefined&&data.topicInfo.id!==expected)return {missing:true};
|
||||
return {...data,id:expected,slug:data.topicInfo?.slug||data.slug||topic?.slug};
|
||||
}
|
||||
return data;
|
||||
}
|
||||
// No paginated home-config endpoint exists in this API. Render only this block's real items.
|
||||
const home=await api.get<HomeData>(endpoint,query,signal);
|
||||
const block=recordList<HomeBlock>(home?.homeConfigData).find(item=>matchesContentKey(item,route.value!)&&item.status&&[1,3].includes(item.type));
|
||||
if(!block)return {list:[],totalCount:0,missing:true};
|
||||
const count=recordList(block.type===3?block.themeList:block.list).length;
|
||||
return {...block,list:(route.page||1)>1?[]:recordList(block.list),themeList:(route.page||1)>1?[]:recordList(block.themeList),totalCount:count,pageNum:route.page||1,pageSize:24};
|
||||
}
|
||||
|
||||
/** 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]||'内容列表';
|
||||
const subtitle=typeof data.desc==='string'?data.desc:typeof data.topicInfo?.desc==='string'?data.topicInfo.desc:'';
|
||||
const total=typeof data.totalCount==='number'&&Number.isFinite(data.totalCount)?Math.max(0,data.totalCount):0;
|
||||
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==='theme'?'topics':route.key as 'topics'|'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){
|
||||
content=`<div class="topic-recommendation-grid channel-topic-grid">${recordList<Topic>(data.list).map(item=>topicCard(item,false,2)).join('')||empty('暂无专题')}</div>`;
|
||||
}else if(route.key==='config'&&data.type===3){
|
||||
content=`<div class="topic-recommendation-grid">${recordList<Topic>(data.themeList).map(item=>topicCard(item,true,2)).join('')||empty()}</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>`;
|
||||
}
|
||||
return {title,subtitle,html:content+pagination(total,page,href,route.key==='config'?Math.max(24,total):24)};
|
||||
}
|
||||
@@ -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?"扫码下载安装 91短剧 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { contentPath } 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>`;
|
||||
}
|
||||
|
||||
export function overviewMarkup(drama: DramaDetail, first: Chapter | undefined, actions: string): string {
|
||||
const genres = (Array.isArray(drama.tagNames) ? drama.tagNames : Array.isArray(drama.themeNames) ? drama.themeNames : []).filter(tag => typeof tag === 'string');
|
||||
const badge = drama.isFeatured ? '<span class="series-badge series-badge--hot">爆剧</span>' : drama.isNew ? '<span class="series-badge series-badge--new">新剧</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>
|
||||
${genres.length ? `<p class="series-genres">${escape(genres.join(' · '))}</p>` : ''}
|
||||
<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,'点击加入91短剧官方 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;
|
||||
}
|
||||
@@ -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});
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
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');
|
||||
const type=category==='登录异常'?4:category==='其他问题'?5:2;
|
||||
const remaining=500-description.length;
|
||||
const context=remaining>2&&details?'\n\n'+details.slice(0,remaining-2):'';
|
||||
return {type,content:description+context,contact:value('contact'),screenshots};
|
||||
}
|
||||
|
||||
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('');
|
||||
};
|
||||
signal.addEventListener('abort',clearPreviews,{once:true});
|
||||
input.addEventListener('change',()=>{
|
||||
if(form.dataset.submitting)return;
|
||||
const selected=Array.from(input.files||[]);
|
||||
const invalid=selected.length>3?'最多上传 3 张图片':selected.some(file=>file.size>5*1024*1024||!['image/jpeg','image/png','image/webp'].includes(file.type))?'请上传 5MB 以内的 JPG、PNG 或 WebP 图片':'';
|
||||
error.textContent=invalid;
|
||||
if(invalid){input.value='';return;}
|
||||
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=false;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>`;
|
||||
@@ -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');
|
||||
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});
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { safeUrl } from '../api/protocol';
|
||||
|
||||
const PLACEHOLDER = '/assets/poster-placeholder.svg';
|
||||
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';
|
||||
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;
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
// Same precedence as PlayletChapterInfoResp.AllowWatch in the reference service.
|
||||
const allowed = chapter.canWatch === 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.needUnlock && !chapter.needVip && (chapter.isUnlocked || (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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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';
|
||||
|
||||
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,actionState=chapter||raw;
|
||||
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="${actionState.isLike===1}">${detailActionIcon('like')}<span>${actionState.isLike===1?'已点赞':'点赞'}</span></button><button type="button" data-engage="collect" aria-pressed="${actionState.isCollect===1}">${detailActionIcon('save')}<span>${actionState.isCollect===1?'已收藏':'收藏'}</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(actionState.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="我的头像"><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>${locked?'<span class="episode-lock-corner" aria-hidden="true">'+(gate==='coins'?'<span class="coin-symbol"><img class="coin-soft-star" src="/assets/4532d28d6a61c426.svg" alt="金币解锁图标"></span>':'<span class="episode-vip-mark">VIP</span>')+'</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>`:'';
|
||||
return `<article class="play-comment-item${child?' play-comment-child':''}"><img ${imageAttributes(item.userAvatar,'/icon.svg')} alt="${escape(item.userName||'用户')}的头像" loading="lazy"><div class="play-comment-body"><strong>${escape(item.userName||'用户')}</strong><p>${escape(item.text)}</p><div class="play-comment-meta">${timestamp}<button type="button" data-comment-like="${item.id}" data-comment-count="${typeof item.countLike==='number'&&Number.isFinite(item.countLike)?Math.max(0,item.countLike):0}" aria-pressed="${item.isLike===1}" aria-label="点赞 ${escape(item.userName||'用户')} 的评论"><span data-request-label>${item.isLike===1?'♥':'♡'} ${formatCount(item.countLike)}</span></button><button type="button" data-reply="${item.id}" data-parent="${item.parentsId||item.id}" 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);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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 { formatCount } from '../api/protocol';
|
||||
import { applyEngagementSuccess, beginButtonRequest, type EngagementKind } from './request-feedback';
|
||||
|
||||
declare global { interface Window { installPlayerControls?: (screen: HTMLElement, episode?: number, actions?: PlayerActions)=>void } }
|
||||
|
||||
interface PlayerPagination {page?:number;commentSort?:number;pageHref?:(page:number,sort:number)=>string;initial?:PlayerInitialData;preserveMarkup?:boolean}
|
||||
|
||||
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=isVideo?id:chapter?.id||id, objectType=isVideo?20:chapter?11:10;
|
||||
const actionPrefix=isVideo?'video':chapter?'playlet/chapter':'playlet';
|
||||
const actionState=chapter||raw;
|
||||
const pendingEngagements=new Set<EngagementKind>();
|
||||
function syncEngagement(kind:EngagementKind,buttons:HTMLButtonElement[]){
|
||||
const active=kind==='like'?actionState.isLike===1:actionState.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'?actionState.countLike:actionState.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 data=existing || await app.api.get<PageData<Comment>>('comment/list',{objectId,objectType,pageNum:commentPage,pageSize:10,sortOrder:commentSort},signal);
|
||||
if(signal.aborted||seq!==commentSeq)return;
|
||||
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'?actionState.isLike===1:actionState.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>(`${actionPrefix}/${kind}/set`,{[chapter?'chapterId':'id']:objectId,[kind==='like'?'isLike':'isCollect']:!active});
|
||||
if(signal.aborted)return;
|
||||
applyEngagementSuccess(actionState,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('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='';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();};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { contentPath, searchPath } from '../routing';
|
||||
import type { Advertisement, Drama, HomeBlock, HomeData, Topic } from "../api/types";
|
||||
import { formatCount, recordList, safeUrl } from "../api/protocol";
|
||||
|
||||
export const PLACEHOLDER = "/assets/poster-placeholder.svg";
|
||||
export const escape = (value: unknown): string => String(value ?? "").replace(/[&<>"']/g, char => ({"&":"&","<":"<",">":">",'"':""","'":"'"})[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="${PLACEHOLDER}" 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));
|
||||
}
|
||||
|
||||
export function card(drama: Drama, video = false, options: {rank?:number;keyword?:string;headingLevel?:2|3} = {}): 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 = drama.isFeatured ? '<span class="cover-corner-badge cover-corner-badge--hot">爆剧</span>' : drama.isNew ? '<span class="cover-corner-badge cover-corner-badge--new">新剧</span>' : "";
|
||||
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';
|
||||
return `<a class="card drama-data-card" ${itemAttributes(drama,video)} href="${contentPath(video?"video":"detail",drama)}" aria-label="查看${escape(text)}"><span class="cover">${rank?`<span class="ranking-poster-frame">${poster}</span>`:poster}${badge}${!video&&drama.totalEpisode?`<span class="drama-episodes">${drama.totalEpisode}集</span>`:""}${rank?`<span class="rank-home-badge" data-rank="${rank}" aria-label="第 ${rank} 名">${String(rank).padStart(2,'0')}</span>`:''}</span><${headingTag}>${heading}</${headingTag}><span class="drama-description">${escape(drama.desc || drama.description || "")}</span><span class="drama-meta"><span class="drama-tags">${tags.slice(0,2).map(tag=>`<span>${escape(tag)}</span>`).join("")}</span><span class="drama-heat"><svg class="drama-heart-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-9-5.7-9-12a5 5 0 0 1 9-3 5 5 0 0 1 9 3c0 6.3-9 12-9 12Z"/></svg>${formatCount(drama.countLike ?? drama.heatValue ?? 0)}</span></span></a>`;
|
||||
}
|
||||
|
||||
export function hero(value: unknown, selected = 0): string {
|
||||
const items = recordList<Drama>(value);
|
||||
if (!items.length) return empty("暂无推荐剧集");
|
||||
const current = items[selected] || items[0];
|
||||
return `<img class="hero-art fade" ${imageAttributes(current.banner || current.horizontalCover || current.cover)} alt="${escape(title(current))}推荐海报" fetchpriority="high"><div class="hero-content fade"><div class="eyebrow"><b>本周主推</b> THE SPOTLIGHT</div><h2>${escape(title(current))}</h2><div class="english"></div><p>${escape(current.desc || current.description)}</p><div class="meta hero-rating"><span>${formatCount(current.heatValue || current.countBrowse)}热度</span><span>${escape(current.themeNames?.[0] || current.tagNames?.[0])}</span><span>全${current.totalEpisode || 0}集</span></div><div class="actions"><a class="primary" ${itemAttributes(current)} href="${contentPath('detail',current)}"><svg class="hero-rounded-play" viewBox="0 0 20 20" width="16" height="16" aria-hidden="true"><path d="M6 3.8C4.9 3.15 3.5 3.95 3.5 5.25v9.5c0 1.3 1.4 2.1 2.5 1.45l8.1-4.75c1.1-.65 1.1-2.25 0-2.9L6 3.8Z"/></svg><span>立即观看</span></a></div></div><div class="hero-previews"><button class="hero-preview-arrow prev" data-hero-step="-1" aria-label="上一部">‹</button>${items.map((item,index)=>`<button class="hero-preview ${index===selected?"active":""}" data-feature="${index}" aria-label="主推${escape(title(item))}" aria-pressed="${index===selected}"><img ${imageAttributes(item.cover || item.horizontalCover)} alt="${escape(title(item))}轮播缩略图" decoding="async"></button>`).join("")}<button class="hero-preview-arrow next" data-hero-step="1" aria-label="下一部">›</button></div><div class="hero-mobile-pagination" role="group" aria-label="轮播切换">${items.map((_,i)=>`<button data-feature="${i}" aria-label="切换到第 ${i+1} 张轮播" aria-pressed="${i===selected}"><span></span></button>`).join("")}<span class="hero-mobile-count">${String(selected+1).padStart(2,"0")} / ${String(items.length).padStart(2,"0")}</span></div><div class="hero-bottom"><div class="dots">${items.map((_,i)=>`<i class="${i===selected?"on":""}"></i>`).join("")}</div><span>${String(selected+1).padStart(2,"0")} / ${String(items.length).padStart(2,"0")}</span></div>`;
|
||||
}
|
||||
|
||||
export function topicCard(topic: Topic, theme = false, headingLevel:2|3=3): string {
|
||||
if (!topic || typeof topic !== 'object' || Array.isArray(topic)) return '';
|
||||
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('topics',topic)}"><img ${imageAttributes(topic.icon || topic.covers?.[0] || topic.list?.[0]?.cover)} alt="${escape(String(topic.name || "").trim() || (theme ? "题材短剧" : "精选专题"))}封面" loading="lazy"><div class="topic-recommendation-copy"><small>${topic.playletCount || 0}部短剧</small><${headingTag}>${escape(topic.name)}</${headingTag}></div></a>`;
|
||||
}
|
||||
export function ads(items: unknown = [], className = "promo-app"): string {
|
||||
const tile=className==='promo-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?' width="132" height="132" 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?"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>`:'';
|
||||
}
|
||||
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 content = block.type===3 ? `<div class="topic-recommendation-grid">${recordList<Topic>(block.themeList).map(item=>topicCard(item,true)).join("")||empty()}</div>` : `<div class="cards">${recordList<Drama>(block.list).map(item=>card(item)).join("")||empty()}</div>`;
|
||||
return `<section class="topic-section home-block" 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>${escape(block.desc)}</p>`:""}</div><a class="topic-more" href="${escape(moreUrl)}">查看更多 <span aria-hidden="true">›</span></a></div>${content}</section>`;
|
||||
}
|
||||
export function renderHome(home: HomeData | null | undefined, error?: string): string {
|
||||
const banners = activeBanners(home);
|
||||
const blocks = recordList<HomeBlock>(home?.homeConfigData).filter(block=>block.status&&[1,3].includes(block.type));
|
||||
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" id="hero">${hero(banners)}</div></section><section class="home-promotions" aria-label="公告与推荐应用">${announcement?`<div class="announcement-bar"><span class="announcement-label" aria-hidden="true">◈</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">${error?empty(error,true):blocks.map(blockSection).join("")||empty()}</div><section class="topic-section popular-searches"><div class="section-head"><h2>大家都在搜</h2></div><nav class="popular-search-tags" id="popular-search-tags" aria-label="大家都在搜关键词">${recordList<{keyword:string}>(home?.hotSearch).map((item,index)=>`<a class="popular-search-tag popular-search-tag--${index%5}" 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">91短剧 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>`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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;
|
||||
|
||||
/** 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]=active?0: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);
|
||||
};
|
||||
}
|
||||
@@ -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, 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=item.isFeatured?'<span class="cover-corner-badge cover-corner-badge--hot">爆剧</span>':item.isNew?'<span class="cover-corner-badge cover-corner-badge--new">新剧</span>':'';
|
||||
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/hot/list',{pageNum:1,pageSize:6},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/2d8f1898b668e45e.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/c568d83ba6e559ac.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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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-1047. */
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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 balance = (chapter: Chapter) => chapter.balance ?? app.api.user?.recharge ?? 0;
|
||||
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('章节信息不匹配,请重新选择');
|
||||
present(chapter);
|
||||
} catch (reason) { if (!signal.aborted && sequence === lookup && dialog.open) failed(reason); }
|
||||
}
|
||||
|
||||
function openCurrent() {
|
||||
if (!currentChapter || signal.aborted || unlocking || chapterAccess(currentChapter).allowed) return;
|
||||
lookup++;
|
||||
dialog.dataset.chapterId = String(currentChapter.id);
|
||||
present(currentChapter);
|
||||
}
|
||||
|
||||
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};
|
||||
}
|
||||
@@ -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>91短剧为喜爱短剧的观众汇集多种题材与精选作品,让找剧、追剧和观看变得轻松便捷。</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>使用条款 欢迎您光临【91短剧】(以下简称本网站),本网站由「91短剧」(以下简称91短剧)经营。</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>91短剧(以下简称本公司)非常重视您的隐私权,并依据《个人资料保护法》(以下简称个资法)的规定,向您告知下列有关隐私权保护政策的更多内容。</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="mailto:huangguoaiai@gmail.com" rel="sponsored nofollow noopener noreferrer">huangguoaiai@gmail.com</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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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;
|
||||
}
|
||||
@@ -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)||'91短剧';
|
||||
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)||'全网最新最火短剧,91为你呈现',
|
||||
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'),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('页面内容服务暂不可用,请稍后重试');
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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']) 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 publicComment(data:Comment,depth=0):Comment {
|
||||
const result=fields(data,['text','userName','userAvatar','createdAt'],['id','isLike','countLike','parentsId']) as unknown as Comment;
|
||||
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}}:{}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
|
||||
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};
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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, 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 } 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')||'91-drama-web/1.0';
|
||||
const token=jar.get('drama_session')?.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)};
|
||||
});
|
||||
const getReadGuest=cache(async ()=>{
|
||||
const identity=await getReadIdentity();
|
||||
const result=await upstream<User>('auth/guest',{method:'POST',body:{devID:identity.device,deviceId:identity.device,channel:identity.channel,mac:''},device:identity.device,userAgent:identity.userAgent,clientIp:identity.clientIp,timeoutMs:5000});
|
||||
if(result.code!==200||!result.data?.token)throw new Error('内容暂不可用');
|
||||
return {...identity,token:result.data.token};
|
||||
});
|
||||
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;
|
||||
}
|
||||
if(protectedRead&&(!result||([401,403].includes(result.code)||EXPIRED_CODES.has(result.code)))&&(!('registered' in identity)||!identity.registered)&&!('prepared' in identity&&identity.prepared)){
|
||||
result=await upstream<T>(path,{...await getReadGuest(),timeoutMs:5000});
|
||||
}
|
||||
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){
|
||||
const html=session.state==='login-required'?empty('请先登录账号')+'<button type="button" class="primary" data-auth-mode="login">登录账号</button>':empty('账号服务暂不可用,请稍后重试',true);
|
||||
return {view:infoView(address,route.key,title,html,{session}),failed:session.state!=='login-required'};
|
||||
}
|
||||
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};
|
||||
return {totalCount:list?.totalCount,pageSize:24,view:infoView(address,tab,title,memberMarkup(session.user,tab,list,{},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==='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),
|
||||
(route.group==='play'?Promise.resolve(Number(route.value)):resolvedId).then(objectId=>contentRequest<PageData<Comment>>('comment/list',{objectId,objectType:route.group==='play'?11: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});
|
||||
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,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():'';
|
||||
}
|
||||
@@ -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(()=>{});}
|
||||
}
|
||||
}
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
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:'91短剧'})),
|
||||
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':'了解91短剧的品牌介绍、平台服务与联系方式。',
|
||||
'user-agreement':'了解91短剧的用户协议、使用条款与服务规则。',
|
||||
'privacy-policy':'了解91短剧如何收集、使用及保护个人信息。',
|
||||
'help-center':'查阅91短剧的观看、账户与服务常见问题。',
|
||||
'2257':'91短剧的2257合规说明与相关信息。',dmca:'91短剧的版权保护政策与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/');
|
||||
// Topic and theme routes currently render the same topic/detail resource.
|
||||
if(route?.group==='page'&&['topics','theme','tag','config'].includes(route.key)&&route.value){
|
||||
result=contentPath(route.key==='theme'?'topics':route.key as 'topics'|'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}/`:'');
|
||||
}
|
||||
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){
|
||||
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:route.key==='config'?Math.max(24,Number(data.totalCount)||0):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);
|
||||
// Catalogs use 24 items; a home-block fallback is a single finite snapshot.
|
||||
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,'91短剧');
|
||||
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('91短剧',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]);
|
||||
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(params.has('commentSort')&¶ms.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.png'),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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Config, FooterInfoResponse, Advertisement } from './api/types';
|
||||
import { recordList } from './api/protocol';
|
||||
import type { SiteSharedData } from './page-data';
|
||||
|
||||
/** 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:{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:''}))}},
|
||||
trackerConfigured:typeof source.reportSdkConfig==='string'&&!!source.reportSdkConfig.trim(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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';
|
||||
|
||||
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==='/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 records(home.homeConfigData).filter(item=>item.status&&(item.type===1||item.type===3))){
|
||||
items.push(...(block.type===3?itemPaths(block.themeList,'topics'):itemPaths(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,'topics');
|
||||
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=>({'&':'&','<':'<','>':'>','"':'"',''':"'",''':"'",' ':' '})[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==='theme'||route.key==='topics'&&route.value))items.push(['专题',origin+'/topics/']);
|
||||
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');
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Chapter, DramaDetail } from './api/types';
|
||||
|
||||
export const TRACKER_APP_ID=process.env.NEXT_PUBLIC_TRACKER_APP_ID||'1047';
|
||||
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:'专题详情'};
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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);
|
||||
return preparePageSession(request,!!route);
|
||||
}
|
||||
|
||||
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/).*)',
|
||||
};
|
||||
Reference in New Issue
Block a user