整体UI修改

This commit is contained in:
谢宇宁
2026-10-27 17:39:27 +07:00
parent 8cdbfee08f
commit e289658a28
99 changed files with 1227 additions and 4118 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ export default async function Page({params,searchParams}:PageProps) {
if (!route) notFound();
const {bootstrap,view,session,shared}=await getPageSeo(pathname(path),queryString(await searchParams));
assertPageContent(pathname(path),bootstrap.content);
const title=bootstrap.content.title||({'apps':'福利应用','home-road':'回家的路',center:'个人中心',favorites:'我的收藏',history:'观看历史',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置',deposit:'金币充值',feedback:'意见反馈'} as Record<string,string>)[route.key]||'内容列表';
const title=bootstrap.content.title||({'apps':'福利应用','home-road':'回家的路'} as Record<string,string>)[route.key]||'内容列表';
const fallback:PageView={panel:'channel',title,html:empty(bootstrap.content.failed?'内容暂不可用,请稍后重试':'暂无内容',true)};
return <SiteFrame home={{}} homeLoaded={false} view={view||fallback} seo={bootstrap} session={session} shared={shared} />;
}
+18 -63
View File
@@ -4,27 +4,23 @@ 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 {
function sessionUser(value: unknown, token = ''): 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)};
return {...user, token:sessionToken};
}
async function handler(request: NextRequest, context: {params: Promise<{path: string[]}>}) {
@@ -32,30 +28,17 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
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});
if (!allowedRequest(path,method)) return NextResponse.json({code:404,msg:'接口不存在',data:null},{status:404,headers});
const secure = request.nextUrl.protocol === 'https:';
const cookieOptions = {httpOnly:true,sameSite:'lax' as const,secure,path:'/',maxAge:7*86400};
const clearSession = (response: NextResponse) => {
response.cookies.set(AUTH,'',{...cookieOptions,maxAge:0});
response.cookies.set(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;
}
const clearSession = (response: NextResponse) => response.cookies.set(AUTH,'',{...cookieOptions,maxAge:0});
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 establishesDevice = path === 'auth/guest';
const userAgent = request.headers.get('user-agent') || '';
const forwardedIp = (request.headers.get('x-real-ip') || request.headers.get('x-forwarded-for')?.split(',')[0] || '').trim();
const clientIp = isIP(forwardedIp) ? forwardedIp : undefined;
const tracking=trackingHeaders(request.headers,path,method);
const identity=trackingHeaders(request.headers,'auth/guest','POST');
const setContextCookies = (response: NextResponse) => {
if (establishesDevice) {
response.cookies.set(DEVICE,device,{...cookieOptions,httpOnly:false,maxAge:365*86400});
@@ -63,13 +46,13 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
}
};
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});
const result = await upstream<User>('auth/guest',{request,method:'POST',body:{devID:device,deviceId:device,channel,mac:''},device,userAgent,clientIp});
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}); }
try { return await upstream(callPath,{...options,request,clientIp}); }
catch (error) {
if (error instanceof ApiError && error.code === 401) return {code:401,msg:error.message,data:null};
throw error;
@@ -90,66 +73,38 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
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);
if (info.code === 200) session = sessionUser(info.data,token);
else if (!expired(info.code)) 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) {
// Retry an explicitly rejected session once with a fresh guest.
if (expired(result.code) && !PUBLIC_PATHS.has(path)) {
session = await guest();
token = session.token!;
result = await call(path+request.nextUrl.search,{method,body,token,device,userAgent});
}
if (result.code === 200 && ['auth/login','auth/register','auth/getInfo'].includes(path)) {
session = sessionUser(result.data,path === 'auth/getInfo' ? token : '',registered || CREDENTIALS.has(path));
if (result.code === 200 && path === 'auth/getInfo') {
session = sessionUser(result.data,token);
result = {...result,data:session};
}
}
const data = result.data && typeof result.data === 'object' && 'token' in result.data ? {...result.data,token:undefined} : result.data;
const isExpired = expired(result.code) && !CREDENTIALS.has(path);
const isExpired = expired(result.code);
const response = NextResponse.json({...result,data},{status:isExpired ? 401 : 200,headers});
if (isExpired) {
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});
}
if (isExpired) clearSession(response);
else if (session?.token) response.cookies.set(AUTH,session.token,cookieOptions);
setContextCookies(response);
return response;
} catch (error) {
const code = error instanceof ApiError ? error.code : 502;
const isExpired = expired(code) && !CREDENTIALS.has(path);
const isExpired = expired(code);
const response = NextResponse.json({code,msg:error instanceof Error ? error.message : '请求失败',data:null},{status:isExpired ? 401 : code >= 400 && code <= 599 ? code : 502,headers});
if (isExpired) {
clearSession(response);
if (registered) response.headers.set('X-Auth-State','login-required');
}
if (isExpired) clearSession(response);
setContextCookies(response);
return response;
}
+1 -1
View File
@@ -2,7 +2,7 @@
export default function PageError({retry}:{retry:()=>void}) {
return <main className="route-not-found">
<title>页面暂不可用 - 91短剧</title>
<title>页面暂不可用 - 黄果吃瓜</title>
<meta name="robots" content="noindex,follow" />
<h1>页面暂不可用</h1>
<p>内容服务暂时异常,请稍后重试</p>
+499 -515
View File
File diff suppressed because one or more lines are too long
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

-32
View File
@@ -1,32 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 13 KiB

+2 -2
View File
@@ -2,8 +2,8 @@ import type { Metadata, Viewport } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "91短剧 · 百万大神原创短剧",
applicationName: "91短剧",
title: "黄果吃瓜 · 百万大神原创短剧",
applicationName: "黄果吃瓜",
icons: {icon:[{url:'/favicon.png',sizes:'48x48',type:'image/png'}],apple:[{url:'/apple-touch-icon.png',sizes:'180x180',type:'image/png'}]},
};
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Metadata } from 'next';
export const metadata:Metadata={title:'页面不存在 - 91短剧',robots:{index:false,follow:true},alternates:{canonical:null}};
export const metadata:Metadata={title:'页面不存在 - 黄果吃瓜',robots:{index:false,follow:true},alternates:{canonical:null}};
export default function NotFound() {
return (
+3 -3
View File
@@ -20,16 +20,16 @@ export function AppDownloadDialog({download, qr, status, dialogRef, onClose, onC
</svg>
</button>
<h2 id={"app-download-heading"}>
{"下载91短剧 App"}
{"下载黄果吃瓜 App"}
</h2>
<p className={"app-download-intro"}>
{"扫码下载,随时继续追剧"}
</p>
<div className={"app-download-code"}>
<img src={qr || undefined} id={"app-platform-qr"} width={"192"} height={"192"} alt={"91短剧 App 下载二维码"} />
<img src={qr || undefined} id={"app-platform-qr"} width={"192"} height={"192"} alt={"黄果吃瓜 App 下载二维码"} />
</div>
<p id={"app-platform-caption"}>
{download === undefined ? "" : download ? "扫码下载安装 91短剧 App" : "暂未配置 App 下载地址"}
{download === undefined ? "" : download ? "扫码下载安装 黄果吃瓜 App" : "暂未配置 App 下载地址"}
</p>
<div className={"app-download-link"}>
<input id={"app-platform-link"} value={download || ""} readOnly aria-label={"下载链接"} />
-3
View File
@@ -63,9 +63,6 @@ export function ChannelPages({info,view}: {info?:InfoPageDefinition;view?:PageVi
<a href={"/info/dmca/"} data-info={"dmca"}>
{"DMCA"}
</a>
<a href={"/info/feedback/"} data-info={"feedback"}>
{"意见反馈"}
</a>
</nav>
<article className={"info-card"} id={"info-content"} dangerouslySetInnerHTML={info?{__html:info.html}:undefined} />
</div>
+4 -16
View File
@@ -1,10 +1,11 @@
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL } from "@/lib/site";
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>
<span className="footer-brand-text">黄果吃瓜</span>
</strong>
<p className={"hg-footer-original-slogan"}>
{content||"百万大神原创短剧"}
@@ -28,28 +29,15 @@ export function Footer({content}:{content?:string}={}) {
<a href={"/info/dmca/"}>
{"DMCA"}
</a>
<a href={"/info/feedback/"}>
{"意见反馈"}
</a>
</nav>
<small className={"hg-footer-original-copyright"}>
{"© 2026 91短剧 All rights reserved."}
{"© 2026 黄果吃瓜 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"}>
<a href={OFFICIAL_TG_URL} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"官方TG "+OFFICIAL_TG_HANDLE} title={"官方TG "+OFFICIAL_TG_HANDLE}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M21.7 3.3 18.4 20c-.2 1.2-.9 1.5-1.9.9l-5-3.7-2.4 2.3c-.3.3-.5.5-1 .5l.4-5.1 9.3-8.4c.4-.4-.1-.6-.6-.3L5.7 13.4.8 11.9c-1-.3-1-1 .2-1.5L20.2 3c.9-.3 1.7.2 1.5.3Z"}>
</path>
</svg>
</a>
<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>
+4 -28
View File
@@ -1,14 +1,14 @@
import type { HomeData, User } from "@/lib/api/types";
import type { HomeData } from "@/lib/api/types";
import { renderHome } from "@/lib/client/render";
import type { NavigationEntry } from '@/lib/site-navigation';
import { SiteNavigation } from './SiteNavigation';
export function HomePage({home,error,hideContent=false,navigation=[],address='/',navigationError=false,user=null}: {user?:User|null;home: HomeData; error?: string;hideContent?:boolean;navigation?:NavigationEntry[];address?:string;navigationError?:boolean}) {
export function HomePage({home,error,hideContent=false,navigation=[],address='/',navigationError=false}: {home: HomeData; error?: string;hideContent?:boolean;navigation?:NavigationEntry[];address?:string;navigationError?:boolean}) {
return <>
<div id="global-site-header" className="shell">
<header className={"top"}>
<a className="hg-header-brand" href="/" aria-label="91短剧首页"><img className="hg-complete-logo" src="/assets/5178e628105b82b4.svg" width={125} height={36} alt="91短剧" /></a>
<a className="hg-header-brand" href="/" aria-label="黄果吃瓜首页"><img className="hg-complete-logo" src="/assets/hgcg-logo.png" width={125} height={44} alt="黄果吃瓜" /></a>
<SiteNavigation entries={navigation} address={address} surface="top" error={navigationError} />
<form className={"search"} id={"search"} role={"search"}>
<input id={"query"} aria-controls={"search-discovery"} aria-expanded={"false"} type={"search"} placeholder={"找一部,想看的好剧"} aria-label={"搜索短剧"} autoComplete={"off"} />
@@ -71,17 +71,6 @@ export function HomePage({home,error,hideContent=false,navigation=[],address='/'
</section>
</form>
<div className={"header-actions"} aria-label={"用户服务"}>
<button className={"header-action coin-entry"} data-account-action={"coins"} aria-label={'金币余额 '+(user?.recharge||0)}>
<span className={"coin-symbol"} aria-hidden={"true"}>
<img className={"coin-soft-star"} src={"/assets/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"}>
@@ -97,19 +86,6 @@ export function HomePage({home,error,hideContent=false,navigation=[],address='/'
</span>
</a>
</div>
<button className={"header-action account-entry "} id={"account-entry"} aria-controls={"profile-popover"} aria-expanded={"false"}>
<span className={"account-avatar"} aria-hidden={"true"}>
<img className={"member-avatar-image"} src={"/assets/8837c3e58159bd48.png"} alt={"默认用户头像"} width={"50"} height={"50"} />
</span>
<span className={"account-copy"}>
<strong>
{user?.nickName||user?.accountNo||'我的'}
</strong>
<small id={"account-status"}>
{user?(user.isRegister||user.type===1?'已登录':'游客账号'):'未登录'}
</small>
</span>
</button>
</div>
<button className={"saved header-saved-proxy"} id={"saved"} hidden>
{"♡ 我的片单 "}
@@ -120,7 +96,7 @@ export function HomePage({home,error,hideContent=false,navigation=[],address='/'
</header>
</div>
<main className="shell" hidden={hideContent} aria-labelledby={hideContent?undefined:'home-title'}>
{!hideContent&&<h1 id="home-title" className="home-page-title">91短剧 · 百万大神原创短剧</h1>}
{!hideContent&&<h1 id="home-title" className="home-page-title">黄果吃瓜 · 百万大神原创短剧</h1>}
<div className="home-content" hidden={hideContent} dangerouslySetInnerHTML={{__html:hideContent?'':renderHome(home,error)}} />
</main>
</>;
+11 -27
View File
@@ -1,19 +1,17 @@
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { startTracker } from "@/lib/client/analytics";
import { useRef, useState, type MouseEvent } from "react";
import type { Config } from "@/lib/api/types";
import { navigationPageData } from "@/lib/navigation-page";
import { AppDownloadDialog } from "./AppDownloadDialog";
import { createDownloadQr, shouldUseDownloadDialog } from "@/lib/client/download";
export function NavigationPage({config, download, error}: {config: Config; download: string; error?: string}) {
useEffect(()=>{startTracker(config);},[config]);
const dialog = useRef<HTMLDialogElement>(null);
const [status, setStatus] = useState("");
const [qr, setQr] = useState<string>();
const {siteName,description,routes,permanent,telegram,tgGroup,twitter}=navigationPageData(config);
const channelCount=2+Number(!!tgGroup)+Number(!!twitter)+Number(!!download);
const {siteName,description,routes,permanent,telegram,telegramHandle}=navigationPageData(config);
const channelCount=3+Number(!!download);
const openDownload = async (event: MouseEvent<HTMLAnchorElement>) => {
if (!shouldUseDownloadDialog(event)) return;
event.preventDefault();setStatus("");dialog.current?.showModal();
@@ -27,12 +25,12 @@ export function NavigationPage({config, download, error}: {config: Config; downl
return (
<>
<div id={"entry-navigation"} className={"navigation-page"}>
<img className={"entry-background"} src={"/assets/7f137587edc271d0.png"} alt={"91短剧官方入口背景"} aria-hidden={"true"} />
<img className={"entry-background"} src={"/assets/7f137587edc271d0.png"} alt={"黄果吃瓜官方入口背景"} aria-hidden={"true"} />
<div className={"entry-stage"}>
<main className={"entry-content"} aria-labelledby={"entry-page-title"}>
<header className={"entry-brand"}>
<h1 id="entry-page-title" className="entry-page-title">{siteName+"官方入口"}</h1>
<img className={"entry-brand-logo hg-complete-logo"} src={"/assets/5178e628105b82b4.svg"} alt={siteName} width={"500"} height={"176"} />
<img className={"entry-brand-logo hg-complete-logo"} src={"/assets/hgcg-logo.png"} alt={siteName} width={"500"} height={"176"} />
<p>
{description}
</p>
@@ -68,11 +66,11 @@ export function NavigationPage({config, download, error}: {config: Config; downl
{siteName+" · APP 下载"}
</span>
</a>}
{telegram&&<a className={"entry-contact"} href={telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
{"官方联系:" + telegram}
</a>}
<a className={"entry-contact"} href={telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
{"官方TG:" + telegramHandle}
</a>
<nav className={"entry-channels"} aria-label={"官方渠道"} style={{gridTemplateColumns: `repeat(${channelCount}, minmax(0, 1fr))`}}>
{tgGroup&&<a className={"entry-channel"} href={tgGroup} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
<a className={"entry-channel"} href={telegram} 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>
@@ -83,23 +81,9 @@ export function NavigationPage({config, download, error}: {config: Config; downl
</svg>
</span>
<span>
{"官方TG群"}
{"官方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>
<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"}>
+1 -11
View File
@@ -11,19 +11,9 @@ export function PageTools() {
</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"} />
<img className={"floating-app-icon"} src={"/assets/hgcg-app-icon.png"} alt={"黄果吃瓜 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"}>
+6 -18
View File
@@ -1,3 +1,4 @@
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL } from "@/lib/site";
import type { NavigationEntry } from '@/lib/site-navigation';
import { SiteNavigation } from './SiteNavigation';
@@ -6,9 +7,9 @@ export function Sidebar({navigation,address,error}:{navigation:NavigationEntry[]
<>
<aside className={"side"} id={"site-sidebar"} aria-label={"网站侧边栏"}>
<div className={"sidebar-header"}>
<a className={"mark sidebar-logo-toggle"} href={"/"} aria-label={"91短剧首页"}>
<a className={"mark sidebar-logo-toggle"} href={"/"} aria-label={"黄果吃瓜首页"}>
<span className={"sidebar-brand-window"}>
<img className={"sidebar-brand"} src={"/assets/5178e628105b82b4.svg"} alt={"91短剧"} width={"500"} height={"176"} />
<img className={"sidebar-brand"} src={"/assets/hgcg-logo.png"} alt={"黄果吃瓜"} width={"500"} height={"176"} />
</span>
</a>
<button className={"sidebar-toggle"} id={"sidebar-toggle"} type={"button"} aria-controls={"site-sidebar"} aria-expanded={"true"} aria-label={"收起侧边栏"} title={"收起侧边栏"}>
@@ -25,7 +26,7 @@ export function Sidebar({navigation,address,error}:{navigation:NavigationEntry[]
<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"} />
<img className={"side-info-wordmark"} src={"/assets/hgcg-logo.png"} alt={"黄果吃瓜"} width={"500"} height={"176"} />
<i className={"side-info-brand-dot"} aria-hidden={"true"}>
</i>
@@ -52,12 +53,9 @@ export function Sidebar({navigation,address,error}:{navigation:NavigationEntry[]
<a href={"/info/dmca/"} data-info={"dmca"}>
{"DMCA"}
</a>
<a href={"/info/feedback/"} data-info={"feedback"}>
{"意见反馈"}
</a>
</div>
<small>
{"© 2026 91短剧"}
{"© 2026 黄果吃瓜"}
<br />
{"All rights reserved."}
</small>
@@ -73,20 +71,10 @@ export function Sidebar({navigation,address,error}:{navigation:NavigationEntry[]
</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"}>
<a href={OFFICIAL_TG_URL} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"官方TG "+OFFICIAL_TG_HANDLE} title={"官方TG "+OFFICIAL_TG_HANDLE}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"M21.7 3.3 18.4 20c-.2 1.2-.9 1.5-1.9.9l-5-3.7-2.4 2.3c-.3.3-.5.5-1 .5l.4-5.1 9.3-8.4c.4-.4-.1-.6-.6-.3L5.7 13.4.8 11.9c-1-.3-1-1 .2-1.5L20.2 3c.9-.3 1.7.2 1.5.3Z"}>
</path>
</svg>
</a>
<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>
+1 -97
View File
@@ -9,103 +9,7 @@ export function SiteDialogs() {
<div className={"toast hidden"} id={"toast"} role={"status"}>
</div>
<dialog id={"account-dialog"} aria-labelledby={"account-dialog-title"}>
</dialog>
<dialog id={"history-dialog"} className={"history-dialog"} aria-labelledby={"history-dialog-title"}>
<button className={"close"} id={"history-close"} type={"button"} aria-label={"关闭历史记录"}>
<svg className={"dialog-close-icon"} viewBox={"0 0 24 24"} aria-hidden={"true"} focusable={"false"}>
<path d={"m6 6 12 12M18 6 6 18"}>
</path>
</svg>
</button>
<h2 id={"history-dialog-title"}>
{"历史记录"}
</h2>
<p className={"history-note"}>
{"最近浏览的剧集"}
</p>
<div className={"account-history-list"} id={"history-list"}>
</div>
<button type={"button"} className={"history-clear"} id={"history-clear"}>
{"清空记录"}
</button>
</dialog>
<AppDownloadDialog />
<dialog id={"member-delete-dialog"} aria-labelledby={"member-delete-title"} aria-describedby={"member-delete-description"}>
<form method={"dialog"}>
<h2 id={"member-delete-title"}>
{"确认删除"}
</h2>
<p id={"member-delete-description"}>
</p>
<div className={"member-delete-actions"}>
<button value={"cancel"} autoFocus>
{"取消"}
</button>
<button value={"confirm"}>
{"确认删除"}
</button>
</div>
</form>
</dialog>
<dialog id={"member-auth-dialog"} aria-labelledby={"member-auth-title"}>
</dialog>
<dialog id={"wallet-payment-dialog"} aria-labelledby={"wallet-payment-title"}>
</dialog>
<dialog id={"play-episode-lock"} aria-labelledby={"episode-lock-title"} aria-describedby={"episode-lock-description episode-lock-note"}>
<button type={"button"} className={"unlock-close"} aria-label={"关闭金币解锁弹窗"}>
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
<path d={"m6 6 12 12M18 6 6 18"}>
</path>
</svg>
</button>
<div className={"unlock-coin"} aria-hidden={"true"}>
<span className={"coin-symbol"} aria-hidden={"true"}>
<img className={"coin-soft-star"} src={"/assets/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"}>
@@ -150,7 +54,7 @@ export function SiteDialogs() {
</button>
</dialog>
<dialog id={"startup-ad"} className={"startup-popup"} aria-label={"广告推荐"}>
<a className={"startup-ad-media"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} data-track-ad="external-dialog" data-ad-type="interstitial" hidden>
<a className={"startup-ad-media"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} data-ad hidden>
<img className={"startup-ad-image"} width={"400"} height={"602"} alt={"广告"} />
</a>
<p className={"startup-ad-loading"} role={"status"} hidden>{"正在加载下一条广告…"}</p>
+1 -1
View File
@@ -21,7 +21,7 @@ export async function SiteFrame({home,error,info,seo,view,homeLoaded=true,sessio
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} />
<HomePage navigation={navigation} address={address} navigationError={header.error} home={home} error={error} hideContent={!!info||!!view} />
<ChannelPages info={info} view={view} />
<PlayerPage view={view} />
<PageTools />
+1 -2
View File
@@ -3,7 +3,6 @@
import Script from "next/script";
import type { HomeData } from "@/lib/api/types";
import { startApplication } from "@/lib/client/application";
import { startTracker } from "@/lib/client/analytics";
import type { SeoBootstrap } from '@/lib/seo';
import type { PageBootstrap, SiteSharedData } from '@/lib/page-data';
import type { NavigationEntry } from '@/lib/site-navigation';
@@ -11,6 +10,6 @@ import type { NavigationEntry } from '@/lib/site-navigation';
/** Initialize the reference interactions once, after React finishes hydration. */
export function SiteRuntime({home,homeLoaded=true,seo,page,session,shared}: {home: HomeData;homeLoaded?:boolean;seo?:SeoBootstrap;page?:PageBootstrap;navigation?:NavigationEntry[];session?:PageBootstrap['session'];shared?:SiteSharedData}) {
return <Script src="/scripts/site-runtime.js" strategy="afterInteractive" onReady={()=>{
startTracker();startApplication(home,homeLoaded,seo,page,session,shared);
startApplication(home,homeLoaded,seo,page,session,shared);
}} />;
}
-8
View File
@@ -123,10 +123,6 @@
"type": "image/svg+xml",
"bytes": 12572
},
"5178e628105b82b4.svg": {
"type": "image/svg+xml",
"bytes": 12572
},
"c9e57990dff7b688.png": {
"type": "image/png",
"bytes": 538
@@ -415,10 +411,6 @@
"type": "image/svg+xml",
"bytes": 474
},
"6dd2c76631816aa2.svg": {
"type": "image/svg+xml",
"bytes": 13124
},
"388ac2896091bdb8.png": {
"type": "image/png",
"bytes": 173198
-26
View File
@@ -1,26 +0,0 @@
import type { Drama, Package, PageData } from './api/types';
import { recordList } from './api/protocol';
import { publicDrama } from './player-data';
import type { WalletInitialData } from './page-data';
export function accountList(data:PageData<Drama>):PageData<Drama> {
return {list:recordList<Drama>(data.list).map(publicDrama),totalCount:typeof data.totalCount==='number'?data.totalCount:0};
}
export function accountPackages(data:WalletInitialData['packages']):WalletInitialData['packages'] {
const items=(value:unknown)=>recordList<Package>(value).map(item=>{
const result:Record<string,unknown>={};
for(const key of ['id','packageType','price','originalPrice','baseCoin','giftCoin','totalCoin','durationDays','durationHours'] as const)if(typeof item[key]==='number'&&Number.isFinite(item[key]))result[key]=item[key];
for(const key of ['name','subTitle'] as const)if(typeof item[key]==='string')result[key]=item[key];
result.payMethods=recordList<{type:string;typeName:string}>(item.payMethods).map(method=>({type:String(method.type||''),typeName:String(method.typeName||'')}));
return result as unknown as Package;
});
return {coinList:items(data.coinList),vipList:items(data.vipList)};
}
export function accountTransactions(data:WalletInitialData['transactions']):WalletInitialData['transactions'] {
return {totalCount:typeof data.totalCount==='number'?data.totalCount:0,...(data.error?{error:'余额明细暂不可用,请稍后刷新'}:{}),list:recordList<Record<string,unknown>>(data.list).map(item=>{
const result:Record<string,unknown>={};
for(const key of ['changeAmount','amount','coin','changeCoin','flowDir'])if(typeof item[key]==='number'&&Number.isFinite(item[key]))result[key]=item[key];
for(const key of ['title','tranTypeName','name','remark','typeName','createdAt','time'])if(typeof item[key]==='string')result[key]=item[key];
return result;
})};
}
+2 -4
View File
@@ -1,4 +1,4 @@
export const DEFAULT_API_BASE_URL = 'http://172.104.181.99:21022';
export const DEFAULT_API_BASE_URL = 'https://webhgcgapi.mfcg.me';
export const UPSTREAM_HOP_HEADER = 'x-91dj-upstream';
export interface ApiRequestContext {
@@ -7,9 +7,7 @@ export interface ApiRequestContext {
}
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();
return (process.env.UPSTREAM_API_URL ?? process.env.API_BASE_URL ?? DEFAULT_API_BASE_URL).trim();
}
export function usesCurrentApiOrigin(): boolean {
+6 -9
View File
@@ -21,7 +21,7 @@ export async function preparePageSession(request:NextRequest,required:boolean) {
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';
let token=cookies.get('drama_session')||'',user:User|null=null,state='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);
@@ -31,20 +31,17 @@ export async function preparePageSession(request:NextRequest,required:boolean) {
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 if(expired(result.code)){set('drama_session','');token='';}
else throw new Error('账号服务暂不可用');
}
if(!user&&!registered&&!cookies.has('drama_skip_auto')){
if(!user){
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. */}
set('drama_session',token);set('drama_device',device);if(channel)set('drama_channel',channel);
headers.set(SSR_USER_HEADER,Buffer.from(JSON.stringify(user)).toString('base64url'));state='ready';
}catch{/* Render a retry state when the guest session is unavailable. */}
headers.set(SSR_SESSION_HEADER,state);
headers.set('cookie',[...cookies].map(([key,value])=>`${key}=${encodeURIComponent(value)}`).join('; '));
const response=NextResponse.next({request:{headers}});
+6 -11
View File
@@ -10,22 +10,18 @@ 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",
"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",
"playlet/search", "playlet/search/suggest",
];
const POST_PATHS = [
"auth/guest", "auth/login", "auth/register", "auth/resetPassword", "auth/profile",
"auth/guest",
"playlet/collect/set", "playlet/like/set", "playlet/chapter/collect/set", "playlet/chapter/like/set",
"playlet/chapter/unlock", "playlet/chapter/watch/report", "playlet/forward/heat",
"playlet/history/clear", "playlet/history/delete", "comment/send", "comment/like/set",
"coin/buy", "vip/pay", "feedback/add", "upload/image",
"playlet/chapter/watch/report", "playlet/forward/heat",
];
export function allowedRequest(path: string, method: string): boolean {
return (method === "GET" ? GET_PATHS : method === "POST" ? POST_PATHS : []).includes(path);
@@ -35,10 +31,10 @@ export async function getServerApiBaseUrl(request?: ApiRequestContext): Promise<
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>> {
export async function upstream<T>(path: string, options: {method?: string; body?: unknown; token?: string; device?: string; userAgent?: string; clientIp?: 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){
if(policy&&(!options.method||options.method==='GET')&&!options.token&&options.body===undefined){
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)});
}
@@ -56,7 +52,6 @@ async function requestUpstream<T>(url:URL,options:Parameters<typeof upstream>[1]
};
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;
-40
View File
@@ -1,40 +0,0 @@
import { COMMON_KEYS, COMMON_FIELD_LIMITS, SDK_FIELD_KEYS, SERVER_EVENTS, TRACKER_APP_ID } from '../tracking';
const object=(value:unknown):value is Record<string,unknown>=>!!value&&typeof value==='object'&&!Array.isArray(value);
const string=(value:unknown,max:number)=>typeof value==='string'&&value.length<=max&&!/[\x00-\x1f\x7f]/.test(value);
const DRAMA_KEYS=['media_id','drama_id','episode','video_id','recommend_trace_id'];
/** Forward only the SDK snapshot and the event associated with this business route. */
export function trackingHeaders(source: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;
}
+1 -1
View File
@@ -25,7 +25,7 @@ export interface AppItem { id: number; categoryId?: number; advitiseId?: string;
export interface AppCategory { id: number; code?: number; title: string; intro?: string; appList?: AppItem[] }
export interface AppData { category?: AppCategory; categoryList?: AppCategory[] }
export interface Package { id: number; name: string; subTitle?: string; packageType: number; price: number; originalPrice?: number; durationHours?: number; baseCoin?: number; giftCoin?: number; totalCoin?: number; durationDays?: number; payMethods?: {type:string; typeName:string}[] }
export interface Config { reportSdkConfig?: string; config?: Record<string, unknown>; newAddressInfo?: Record<string, unknown>; navPageList?: {line:number; url:string}[]; advitise?: Advertisements; seoInfo?: SeoInfo | string; seoNavInfo?: Record<string,unknown> | string }
export interface Config { config?: Record<string, unknown>; newAddressInfo?: Record<string, unknown>; navPageList?: {line:number; url:string}[]; advitise?: Advertisements; seoInfo?: SeoInfo | string; seoNavInfo?: Record<string,unknown> | string }
export interface FooterContact { name?: string; value?: string }
export interface FooterInfoResponse { footerInfo?: { brand?: {content?: string}; contactList?: FooterContact[] | null } }
export interface Envelope<T> { code: number; data: T; msg?: string; tip?: string }
-273
View File
@@ -1,273 +0,0 @@
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});
}
-270
View File
@@ -1,270 +0,0 @@
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;
}
+13 -48
View File
@@ -3,62 +3,46 @@ 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 = /^(?:config\/|navigation\/|theme\/|applist\/|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"]);
/** Every visitor uses a guest session; an expired session is replaced by a new guest on the next request. */
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.
// Serialize responses which set session cookies so a slow guest response cannot replace a newer one.
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"})},
headers: 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();
}
if ((EXPIRED_CODES.has(result.code) || response.status===401) && version === this.version) this.setUser(null);
throw new ApiError(result.tip || result.msg || "请求失败,请稍后重试",result.code);
}
return result.data;
}
private restoreSession(automatic: boolean): Promise<User | null> {
private restoreSession(): 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});
const user = await this.raw<User | null>('auth/guest',{}, authMetadata());
this.setUser(user);
if (!user) this.requiresLogin = true;
return user;
});
this.session = job;
@@ -66,51 +50,32 @@ export class ApiClient extends EventTarget {
}
return this.session;
}
initializeSession() { return this.restoreSession(true); }
initializeSession() { return this.restoreSession(); }
async ensureSession(): Promise<User> {
const user = await this.restoreSession(false);
if (!user) { this.promptLogin(); throw new ApiError('请先登录账号',401); }
const user = await this.restoreSession();
if (!user) 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);}
seedSession(user:User|null){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});
const user=await this.raw<User|null>('auth/guest',{}, authMetadata());
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();
if(path!=='auth/guest')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);
}
}
+13 -50
View File
@@ -1,11 +1,10 @@
import { navigationIsActive } from '../site-navigation';
import { ApiClient } from "./api";
import type { Config, Drama, FooterContact, FooterInfoResponse, HomeData, PageData } from "../api/types";
import type { Config, Drama, 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";
@@ -15,9 +14,7 @@ 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';
@@ -45,7 +42,6 @@ export class Application implements SiteContext {
private searchSeq=0;
private initialized=false;
private startupDialogs?:StartupDialogs;
private footerContacts:FooterContact[]=[];
private seoEnvironment:SeoEnvironment;
private seoContent:SeoContent={};
private initialSeo?:SeoBootstrap;
@@ -56,20 +52,17 @@ export class Application implements SiteContext {
private syncHead(){applyPageHead(buildPageHead(location.pathname,location.search,this.seoEnvironment,this.seoContent));}
current(){return !this.signal.aborted;}
notify(message:string){const el=$('#toast');el.textContent=message;el.classList.remove('hidden');clearTimeout(this.toastTimer);this.toastTimer=setTimeout(()=>el.classList.add('hidden'),2600);}
openAuth(mode='login'){openAuth(mode);}
navigate(path:string){navigateTo(path);}
async init(){
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);
this.bindEvents();this.startupDialogs=startStartupDialogs();
window.addEventListener('pagehide',event=>this.hidePage(event.persisted));
window.addEventListener('pageshow',event=>{if(event.persisted)this.restorePage();});
this.api.addEventListener('userchange',()=>this.updateAccount());
this.api.addEventListener('expired',()=>{this.updateAccount();this.openAuth();});
await this.initializeShared();
void this.api.initializeSession().then(()=>this.updateAccount()).catch(error=>this.notify(error.message));
void this.api.initializeSession().catch(()=>{});
this.startFloatingTools();
await this.route();
}
@@ -77,10 +70,7 @@ export class Application implements SiteContext {
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();
@@ -91,12 +81,7 @@ export class Application implements SiteContext {
this.config=config;this.startupDialogs?.setAdvertisements(config.advitise?.dialogList||[],config.advitise?.nineGridList||[]);
this.seoEnvironment=clientSeoEnvironment(this.seoEnvironment,config);
if(this.routeKey)this.syncHead();
window.tracker?.configure(config);
this.refreshInfoContacts();await configureSite(config);
}
private refreshInfoContacts(){
const target=document.querySelector<HTMLElement>('#info-content [data-site-contacts]');
if(target)target.outerHTML=infoContactSection(this.config,this.footerContacts);
await configureSite(config);
}
private disposePage(){
@@ -105,14 +90,13 @@ export class Application implements SiteContext {
}
private hidePage(persisted:boolean){
const route=parseSitePath(location.pathname,location.search);
const interactive=route?.group==='detail'||route?.group==='play'||route?.group==='member'||route?.group==='info'&&['deposit','settings','user-center','feedback'].includes(route.key);
const interactive=route?.group==='detail'||route?.group==='play';
if(!persisted||interactive||document.querySelector('dialog[open]')){this.disposePage();return;}
this.stopHeroAutoplay();this.heroSequence++;this.feature=this.renderedFeature;this.disposeHeroFade?.();
}
private restorePage(){
if(this.signal.aborted){window.location.reload();return;}
this.restartHeroAutoplay();
window.tracker?.resumePage();
// Restore the document immediately, then refresh the header identity using current cookies.
void this.api.revalidateSession().catch(()=>{});
}
@@ -181,14 +165,9 @@ export class Application implements SiteContext {
if(!page||!samePageAddress(page.path,this.routeKey))return;
const pageHref=(value:number)=>paginationPath(this.routeKey,value);
if(page.panel==='player'&&page.player){
this.disposePlayer=await renderPlayer(this,page.player.detail.id,route.group==='play'?Number(route.value):undefined,false,{initial:page.player,preserveMarkup:true,page:route.page||1,commentSort:new URLSearchParams(location.search).get('commentSort')==='1'?1:2,pageHref:(value,sort)=>paginationPath(contentPath(route.group==='play'?'play':'detail',page.player!.detail,route.group==='play'?Number(route.value):undefined)+location.search,value,{commentSort:sort===2?null:sort})});
this.disposePlayer=await renderPlayer(this,page.player.detail.id,route.group==='play'?Number(route.value):undefined,false,{initial:page.player,preserveMarkup:true});
}else if(page.panel==='search'&&page.search){
$('#query').setAttribute('value',page.search.keyword);
window.tracker?.search(page.search.keyword,page.search.total,page.search.page);
}else if(page.panel==='info'){
if(page.info?.member)await renderMember(this,page.info.key,route.page||1,pageHref,page.info.member,true);
else if(page.info?.wallet)await renderWallet(this,route.value==='vip',route.page||1,pageHref,page.info.wallet,true);
else if(page.info?.feedback)renderFeedbackPage(this,true);
}
}
@@ -198,14 +177,15 @@ export class Application implements SiteContext {
document.addEventListener('click',event=>{void (async()=>{
const el=(event.target as Element).closest<HTMLElement>('button,a');if(!el)return;
if(el.dataset.close){(document.getElementById(el.dataset.close) as HTMLDialogElement)?.close();return;}
if(el.dataset.authMode){this.openAuth(el.dataset.authMode);return;}
if(el.dataset.memberRoute){this.navigate('/member/'+el.dataset.memberRoute+'/');return;}
if(el.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.feature!==undefined||el.dataset.heroStep){
const index=el.dataset.feature!==undefined?Number(el.dataset.feature):this.feature+Number(el.dataset.heroStep);
// Banner list links are real detail links; the first click only brings that title to the stage.
if(el.tagName==='A'&&index===this.feature)return;
event.preventDefault();this.selectHero(index);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;}
@@ -214,7 +194,6 @@ export class Application implements SiteContext {
}
if(el.id==='app-copy-link'){const url=downloadUrl(this.config);if(!url)throw new Error('暂未配置下载地址');await navigator.clipboard.writeText(url);$('#app-copy-status').textContent='链接已复制';return;}
if(el.id==='app-download-close'){($<HTMLDialogElement>('#app-download-dialog')).close();return;}
if(el.id==='floating-history'){this.navigate('/member/history/');return;}
if(el.id==='back-to-top'){window.scrollTo({top:0,behavior:'smooth'});return;}
if(el.id==='floating-app-close'){$('#page-tool-download').style.display='none';return;}
if(el.id==='search-panel-close'){this.closeSearchDiscovery();return;}
@@ -238,22 +217,7 @@ export class Application implements SiteContext {
const url=downloadUrl(this.config);if(!url)return;
const dialog=$<HTMLDialogElement>('#app-download-dialog');if(!dialog.open)dialog.showModal();
const qr=await createDownloadQr(url);
if(dialog.open&&downloadUrl(this.config)===url){const image=$<HTMLImageElement>('#app-platform-qr');image.src=qr;image.alt='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);
if(dialog.open&&downloadUrl(this.config)===url){const image=$<HTMLImageElement>('#app-platform-qr');image.src=qr;image.alt='黄果吃瓜 App 下载二维码';}
}
private async loadDiscovery(){
@@ -290,7 +254,6 @@ export class Application implements SiteContext {
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{}
}
+1 -5
View File
@@ -40,13 +40,9 @@ export async function loadAppCategories(api:Pick<ApiClient,'get'>,signal:AbortSi
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>`;
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"${app.advitiseId?' data-ad':''} 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;
-22
View File
@@ -29,25 +29,3 @@ export function authMetadata() {
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};
}
+7 -17
View File
@@ -1,27 +1,21 @@
import { recordList, safeUrl } from "../api/protocol";
import { safeUrl } from "../api/protocol";
import type { Config } from "../api/types";
import { escape } from "./render";
import { downloadUrl } from "../app-download";
import { configureDownloadLinks } from "./download";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL, SITE_URL } from "../site";
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;}
});
const caption=document.getElementById("app-platform-caption");if(caption)caption.textContent=download?"扫码下载安装 黄果吃瓜 App":"暂未配置 App 下载地址";
}
export function homeRoad(config: Config, pageUrl = '/home-road/'): string {
const values={...config.newAddressInfo,...config.config};
const icons={rocket:'3651a2618990bf95',link:'ba17d56edfb047e2',shield:'d001d1cc6c2f0ce6',help:'d59d2d7390d88f08',devices:'d4ebee2aa88ba9a2',iphone:'a0c6451fadcb7738',android:'8b1153736cc5dca1',desktop:'11bae9823678ebdc',email:'2a8580c42090f36c',tg:'9a5b6e9db9de76b8',contact:'228a0af1662081ac',twitter:'72f702ca215f597d'};
type Icon=keyof typeof icons;
const iconLabels:Record<Icon,string>={rocket:'备用访问地址',link:'永久访问入口',shield:'官方联系',help:'访问帮助',devices:'App 下载与设备',iphone:'iPhone 设备',android:'Android 设备',desktop:'电脑设备',email:'联系邮箱',tg:'Telegram 官方交流群',contact:'官方联系',twitter:'X 官方社区'};
@@ -30,17 +24,13 @@ export function homeRoad(config: Config, pageUrl = '/home-road/'): string {
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;
// Displayed site addresses are always this site's own domain, never configured mirrors.
const backups=[SITE_URL];
const forever=SITE_URL,primary=SITE_URL;
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));}
contacts.push(contact('tg','官方TG',OFFICIAL_TG_HANDLE,OFFICIAL_TG_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>
-1
View File
@@ -11,6 +11,5 @@ export interface SiteContext {
current(): boolean;
route(): Promise<void>;
navigate(path: string): void;
openAuth(mode?: string): void;
updateSeo?(content:SeoContent):void;
}
+47 -30
View File
@@ -1,14 +1,14 @@
import { contentPath } from '../routing';
import type { Chapter, Config, DramaDetail } from "../api/types";
import { formatCount, recordList, safeUrl } from "../api/protocol";
import { contentPath, contentSegment } from '../routing';
import type { Chapter, DramaDetail } from "../api/types";
import { formatCount } from "../api/protocol";
import { escape, imageAttributes, title } from "./render";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL, SITE_ORIGIN, SITE_URL } from '../site';
// Original HTML action icons, kept as SVG paths so active colors follow the stylesheet.
const actionPaths = {
"save": "M857.6 327.68l-104.494545-34.210909a178.036364 178.036364 0 0 1-90.763637-66.56l-64-89.367273a104.96 104.96 0 0 0-23.272727-23.272727 106.123636 106.123636 0 0 0-148.48 23.272727l-64.930909 89.367273a179.665455 179.665455 0 0 1-90.763637 66.792727l-104.494545 33.978182a106.821818 106.821818 0 0 0-53.061818 162.909091l93.090909 128.930909a35.84 35.84 0 0 1 6.981818 21.643636v158.952728a109.381818 109.381818 0 0 0 5.12 33.512727 105.658182 105.658182 0 0 0 133.818182 67.956364l128-39.33091a107.985455 107.985455 0 0 1 64.232727 0l127.534546 39.33091a106.123636 106.123636 0 0 0 139.636363-101.469091v-158.254546a35.84 35.84 0 0 1 6.981818-21.643636l93.09091-128.930909a104.494545 104.494545 0 0 0 15.127272-29.556364 106.821818 106.821818 0 0 0-69.352727-134.050909z m-192 83.549091a34.909091 34.909091 0 0 1-48.174545 10.938182l-46.545455-29.090909a34.909091 34.909091 0 0 1 37.003636-59.112728l46.545455 29.090909a34.909091 34.909091 0 0 1 11.170909 48.174546z",
"like": "M855.738182 198.283636A252.043636 252.043636 0 0 0 670.254545 116.363636a225.28 225.28 0 0 0-160.814545 63.069091 248.32 248.32 0 0 0-152.901818-53.294545A251.578182 251.578182 0 0 0 171.287273 209.454545 287.883636 287.883636 0 0 0 162.909091 588.567273l256 275.083636a122.181818 122.181818 0 0 0 90.065454 39.563636 121.716364 121.716364 0 0 0 89.832728-39.563636l256-272.989091 2.792727-3.723636a290.443636 290.443636 0 0 0-1.861818-388.654546z",
"share": "M910.661818 117.992727a114.734545 114.734545 0 0 0-113.803636-16.756363L134.050909 363.752727a138.472727 138.472727 0 0 0-15.592727 250.181818l97.978182 54.225455 1.861818 162.909091a87.272727 87.272727 0 0 0 137.309091 69.818182L482.210909 814.545455l180.363636 99.374545a138.472727 138.472727 0 0 0 203.869091-101.236364L954.181818 224.814545a113.803636 113.803636 0 0 0-43.52-106.821818z",
"feedback": "M505.949091 38.4a465.454545 465.454545 0 1 0 465.454545 465.454545 465.454545 465.454545 0 0 0-465.454545-465.454545z m0 721.454545a46.545455 46.545455 0 1 1 46.545454-46.545454 46.545455 46.545455 0 0 1-46.545454 46.545454z m43.287273-188.276363a11.636364 11.636364 0 0 0-8.378182 11.170909v25.832727a34.909091 34.909091 0 0 1-69.818182 0V581.818182a81.454545 81.454545 0 0 1 58.88-78.196364 87.04 87.04 0 0 0 60.741818-102.865454A86.807273 86.807273 0 0 0 433.803636 372.363636a90.298182 90.298182 0 0 0-11.403636 24.436364 34.909091 34.909091 0 0 1-67.025455-19.549091 162.909091 162.909091 0 0 1 20.712728-43.985454 156.625455 156.625455 0 1 1 173.149091 238.312727z",
"phone": "M756.363636 69.818182h-488.727272A128.232727 128.232727 0 0 0 139.636364 197.818182v628.363636a128.232727 128.232727 0 0 0 128 128h488.727272a128 128 0 0 0 128-128v-628.363636A128 128 0 0 0 756.363636 69.818182z m-139.636363 698.181818h-209.454546a34.909091 34.909091 0 0 1 0-69.818182h209.454546a34.909091 34.909091 0 0 1 0 69.818182z"
} as const;
@@ -16,36 +16,53 @@ 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>`;
/** Genre and tag names as internal links when the API supplies their identities. */
export function genreLinks(drama: DramaDetail): string {
const useTags = Array.isArray(drama.tagNames);
const names = (useTags ? drama.tagNames : Array.isArray(drama.themeNames) ? drama.themeNames : []) || [];
return names.map((name, i) => {
if (typeof name !== 'string' || !name.trim()) return '';
const identity = {id:(useTags ? drama.tagIds : drama.themeIds)?.[i], slug:(useTags ? drama.tagSlugs : drama.themeSlugs)?.[i]};
return contentSegment(identity) ? `<a href="${contentPath(useTags ? 'tag' : 'topics', identity)}">${escape(name)}</a>` : `<span>${escape(name)}</span>`;
}).filter(Boolean).join('');
}
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);
/** Labelled facts in a description list so the episode count, genres and engagement are explicit page content. */
export function detailFacts(drama: DramaDetail, episodes: number, counts: {countCollect?: number; countLike?: number} = drama): string {
const genres = genreLinks(drama);
return `<dl class="hg-facts">${genres ? `<div><dt>类型</dt><dd class="hg-facts-links">${genres}</dd></div>` : ''}<div><dt>集数</dt><dd>全${episodes}集</dd></div><div><dt>热度</dt><dd>${formatCount(drama.heatValue)}</dd></div><div><dt>收藏</dt><dd data-engage-count="collect" aria-live="polite">${formatCount(counts.countCollect)}收藏</dd></div><div><dt>点赞</dt><dd data-engage-count="like" aria-live="polite">${formatCount(counts.countLike)}点赞</dd></div></dl>`;
}
export function synopsisMarkup(drama: DramaDetail, heading = '剧情简介'): string {
const text = drama.desc || drama.description;
return `<section class="hg-synopsis" aria-labelledby="hg-synopsis-title"><h2 id="hg-synopsis-title">${escape(heading)}</h2><div class="series-description-wrap"><p class="series-description" id="series-description-text">${text ? escape(text) : '暂无简介'}</p><button class="series-description-toggle" type="button" aria-expanded="false" aria-controls="series-description-text" hidden>展开</button></div></section>`;
}
export function overviewMarkup(drama: DramaDetail, first: Chapter | undefined, actions: string, episodes = drama.totalEpisode || 0): string {
const name = title(drama);
const badge = drama.isFeatured ? '<span class="hg-detail-badge hg-detail-badge--hot">爆剧</span>' : drama.isNew ? '<span class="hg-detail-badge hg-detail-badge--new">新剧</span>' : '';
return `<div class="hg-detail">
<div class="hg-detail-poster"><img class="series-poster" ${imageAttributes(drama.cover || drama.coverImg)} width="640" height="914" alt="《${escape(name)}》海报">${badge}</div>
<div class="hg-detail-head">
<h1 id="play-detail-title">${escape(name)}</h1>
<p class="hg-detail-lead">《${escape(name)}》全${episodes}集在线观看</p>
${detailFacts(drama, episodes)}
<div class="hg-detail-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>播放第1集</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>
${actions}
</div>
</div>
</div>
${synopsisMarkup(drama, `《${name}》剧情简介`)}`;
}
/** Fixed site address and contact; configured mirrors and social links are not shown. */
export function officialLinks(): string {
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>
<div class="play-official-line play-official-inline"><span>官方地址:</span>${external(SITE_URL,SITE_ORIGIN)}</div>
<div class="play-official-line play-official-inline"><span>官方TG:</span>${external(OFFICIAL_TG_URL,OFFICIAL_TG_HANDLE)}</div>
</section>`;
}
-274
View File
@@ -1,274 +0,0 @@
import type { SiteContext } from "./context";
import { escape } from "./render";
export function feedbackPayload(data:FormData,screenshots:string[]=[]) {
const value=(key:string)=>String(data.get(key)||'').trim();
const description=value('content');
if(description.length<10||description.length>500)throw new Error('请填写 10–500 字的问题描述');
const labels:Record<string,string>={category:'问题类型',frequency:'发生频率',province:'所在地区',city:'城市',carrier:'运营商',network:'网络类型',device:'设备类型',model:'设备型号',version:'App 版本'};
const details=Object.entries(labels).filter(([key])=>value(key)).map(([key,label])=>`${label}:${value(key)}`).join('\n');
const category=value('category');
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>`;
+1 -1
View File
@@ -9,7 +9,7 @@ export function navigateTo(path: string) {
window.location.assign(target);
}
/** Delegate so comment and account pagination inserted after hydration also works. */
/** Delegate so 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;
+4 -24
View File
@@ -3,13 +3,9 @@ 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 PlaybackAccess = {allowed: boolean; gate: '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();
@@ -21,32 +17,16 @@ export function playbackUrl(value: unknown): string {
return '/h5/m3u8/' + raw.replace(/^\/?h5\/m3u8\//, '').replace(/^\/+/, '');
}
/** Every episode is free: any chapter the API supplies a playable source for may be watched; payment flags are ignored. */
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 '';
const source = playbackUrl(chapter?.videoUrl);
return source ? {allowed: true, gate: '', source} : {allowed: false, gate: 'unavailable', source: ''};
}
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',
+29 -49
View File
@@ -1,55 +1,48 @@
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';
import { contentPath } from '../routing';
import type { Chapter, Drama, DramaDetail, HomeData } from '../api/types';
import { recordList } from '../api/protocol';
import { ads, empty, escape, imageAttributes, promotionRow, title, videoItem } from './render';
import { detailActionIcon, detailFacts, officialLinks, overviewMarkup, synopsisMarkup } from './detail';
import { chapterAccess, 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;
chapter?:Chapter|null; isVideo?:boolean; home?:HomeData;
}
export function orderedChapters(value:unknown):Chapter[] {
return recordList<Chapter>(value).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).sort((a,b)=>(a.currentEpisode||0)-(b.currentEpisode||0));
}
/** Recommended titles replace comments: crawlable internal links with full context for each title. */
export function recommendationsMarkup(raw:DramaDetail):string {
const related=recordList<Drama>(raw.relatedRecommendData).filter(item=>item.id!==raw.id).slice(0,12);
return `<section class="hg-recommend" aria-labelledby="hg-recommend-title"><div class="play-section-heading"><h2 id="hg-recommend-title">推荐视频</h2><span>看了《${escape(title(raw))}》的人还在看</span></div>${related.length?`<div class="hg-recommend-list">${related.map(item=>videoItem(item)).join('')}</div>`:empty('暂无推荐视频')}</section>`;
}
export function playerMarkup(raw:DramaDetail,ordered:Chapter[],options:PlayerViewOptions={}):string {
const {chapter=null,isVideo=false,comments,config={},home={},user}=options;
const {chapter=null,isVideo=false,home={}}=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 displayTitle=title(raw),episodes=raw.totalEpisode||ordered.length,actionState=chapter||raw;
const heading=chapter?`${displayTitle} 第${chapter.currentEpisode||1}集`:displayTitle;
const access=isVideo?{allowed:!!playbackUrl(raw.video?.url),gate:playbackUrl(raw.video?.url)?'':'unavailable',source:playbackUrl(raw.video?.url)} as const:chapterAccess(chapter);
const previewSource=isVideo?'':playbackUrl(raw.preview),mode=initialPlayMode(access,raw.preview);
const modeTabs=previewSource?`<div class="channel-tabs play-mode-tabs" role="group" aria-label="播放内容"><button type="button" data-play-mode="main" aria-pressed="${mode==='main'}">正片</button><button type="button" data-play-mode="preview" aria-pressed="${mode==='preview'}">免费预告</button></div>`:'';
const initialEpisodePage=chapter?Math.floor(Math.max(0,ordered.findIndex(item=>item.id===chapter.id))/45):0;
const actions=()=>`<div class="play-detail-actions"><button type="button" data-engage="like" aria-pressed="${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 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></div>`;
const screen=()=>`${modeTabs}<div class="play-screen" data-play-mode="${mode}"><video ${INLINE_VIDEO_ATTRIBUTES} preload="metadata" ${imageAttributes(raw.cover||raw.coverImg,undefined,'poster')} aria-label="${escape(heading)}"></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');
const watchInfo=()=>`<section class="hg-watch-info">${chapter?`<span class="play-episode-caption">正在观看 第 ${chapter.currentEpisode||1} 集 / 共 ${episodes} 集</span>`:''}<h1 id="play-detail-title">${escape(heading)}</h1>${detailFacts(raw,episodes,actionState)}${actions()}</section>${synopsisMarkup(raw,`《${displayTitle}》剧情简介`)}`;
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()}
<nav class="page-breadcrumb" aria-label="面包屑导航"><a href="/">首页</a><span>/</span><a href="/shorts/">短剧</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 play-detail-layout--single hg-watch${overview?' hg-watch--overview':''}"><div class="play-detail-main">
${overview?overviewMarkup(raw,first,actions(),episodes):screen()+watchInfo()}
${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>`:''}
${!isVideo?`<section class="play-episodes-section" aria-labelledby="hg-episodes-title"><div class="play-section-heading"><h2 id="hg-episodes-title">《${escape(displayTitle)}》全集列表</h2><span>全${episodes}集</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,displayTitle)}</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>`;
${recommendationsMarkup(raw)}
${officialLinks()}
</div></div>`;
}
export function episodeTabs(ordered:Chapter[],size:number,episodePage:number):string {
@@ -57,23 +50,10 @@ export function episodeTabs(ordered:Chapter[],size:number,episodePage:number):st
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 {
export function episodeItems(id:number,ordered:Chapter[],chapter:Chapter|null,size:number,episodePage:number,slug?:string,name=''):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>`;
const content=`<span>${number}</span>${item.id===chapter?.id?'<span class="episode-playing" aria-hidden="true"><i></i><i></i><i></i></span>':''}`;
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} 集"${name?` title="${escape(`《${name}》第${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);
}
+15 -66
View File
@@ -1,20 +1,18 @@
import { contentPath } from '../routing';
import type { Chapter, Comment, Drama, DramaDetail, PageData } from "../api/types";
import type { Chapter, Drama, DramaDetail, PageData } from "../api/types";
import type { SiteContext } from "./context";
import { empty, escape } from "./render";
import { configureDownloadLinks } from "./download";
import { bindSeriesDescription } from "./detail";
import { bindChapterUnlock } from "./unlock";
import { chapterAccess, chapterUnlockCost, initialPlayMode, MediaPlayback, playbackUrl, type PlayMode, type PlayerActions } from './playback';
import { chapterAccess, initialPlayMode, MediaPlayback, playbackUrl, type PlayMode, type PlayerActions } from './playback';
import { dramaSeoContent } from '../seo';
import type { PlayerInitialData } from '../page-data';
import { commentListMarkup, episodeItems, episodeTabs, orderedChapters, playerMarkup } from './player-markup';
import { 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}
interface PlayerPagination {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('当前接口暂未提供剧集内容');
@@ -25,7 +23,7 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
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;}),
paging.initial?Promise.resolve(undefined):app.api.get<PageData<Drama>>('playlet/guess-like/list',{playletId:id,pageNum:1,pageSize:12},signal).catch(error=>{if(signal.aborted)throw error;return undefined;}),
]);
if(!paging.initial)raw.relatedRecommendData=related?.list||[];
if(signal.aborted)return ()=>{};
@@ -39,17 +37,13 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
}
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 objectId=isVideo?id:chapter?.id||id;
const actionPrefix=isVideo?'video':chapter?'playlet/chapter':'playlet';
const actionState=chapter||raw;
const pendingEngagements=new Set<EngagementKind>();
@@ -62,9 +56,8 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
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});
if(!paging.preserveMarkup)page.innerHTML=playerMarkup(raw,ordered,{chapter,isVideo,home:app.home});
configureDownloadLinks(app.config, page);
window.tracker?.setDrama({...raw,playletChapterInfo:ordered},chapter||first,ordered,isVideo);
const disposeDescription=bindSeriesDescription(page,signal);
let episodeSize=matchMedia('(max-width:760px)').matches?15:45;
let episodePage=chapter?Math.floor(Math.max(0,ordered.findIndex(item=>item.id===chapter!.id))/episodeSize):0;
@@ -82,53 +75,32 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
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);
grid.innerHTML=episodeItems(id,ordered,chapter,size,episodePage,raw.slug,raw.title||raw.playName||'');
}
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();}});
media=new MediaPlayback(video,{signal,status:message=>{status.textContent=message;},error:()=>{box.dispatchEvent(new Event('playback:error'));}});
const toggle=async()=>{if(mode==='main'&&!access.allowed)return;await media?.toggle();};
window.installPlayerControls?.(box,chapter?.currentEpisode||1,{toggle,retry:async()=>{if(mode==='main'&&!access.allowed)return;await media?.retry();}});
const position=ordered.findIndex(item=>item.id===chapter?.id);
const previous=box.querySelector<HTMLButtonElement>('[data-play-action="previous"]');
const next=box.querySelector<HTMLButtonElement>('[data-play-action="next"]');
if(previous)previous.disabled=position<=0;if(next)next.disabled=position<0||position>=ordered.length-1;
const unlock=document.createElement('button');unlock.type='button';unlock.className='primary';unlock.dataset.showUnlock='';
unlock.textContent=access.gate==='vip'?'查看会员套餐':`使用 ${unlockCost} 金币解锁`;
box.querySelector('.play-screen-overlay')!.append(unlock);
function setMode(nextMode:PlayMode){
window.tracker?.prepareDramaModeChange();
mode=nextMode;box.dataset.playMode=mode;
page.querySelectorAll<HTMLButtonElement>('[data-play-mode]').forEach(button=>{if(button.tagName==='BUTTON')button.setAttribute('aria-pressed',String(button.dataset.playMode===mode));});
const blocked=mode==='main'&&!access.allowed;
unlock.hidden=!blocked||!['coins','vip'].includes(access.gate);
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'?'本集需要金币解锁':'本集暂不可观看';
if(blocked)status.textContent='本集视频暂未开放,请稍后再试';
else if(!(mode==='preview'?previewSource:access.source))status.textContent='该剧集暂未提供视频源';
else if(mode==='preview')status.textContent='免费预告 · 点击播放';
}
@@ -143,11 +115,9 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
}
page.addEventListener('click',event=>{void (async()=>{
const element=(event.target as Element).closest<HTMLButtonElement>('button');if(!element||element.disabled)return;
if(element.dataset.lockedChapter){const selected=ordered.find(item=>item.id===Number(element.dataset.lockedChapter));if(selected)await chapterUnlock.openChapter(selected);return;}
if(element.dataset.episodePage){episodePage=Number(element.dataset.episodePage);renderEpisodes();}
if(element.dataset.episodePage==='0'){episodePage=0;renderEpisodes();}
if(element.hasAttribute('data-show-unlock'))chapterUnlock.openCurrent();
if(element.dataset.playAction){const index=ordered.findIndex(item=>item.id===chapter?.id);const next=ordered[index+(element.dataset.playAction==='next'?1:-1)];if(next){window.tracker?.dramaStatusChange(next);app.navigate(contentPath('play',raw,next.id));}}
if(element.dataset.playAction){const index=ordered.findIndex(item=>item.id===chapter?.id);const next=ordered[index+(element.dataset.playAction==='next'?1:-1)];if(next){app.navigate(contentPath('play',raw,next.id));}}
if(element.dataset.engage){
const kind=element.dataset.engage;if(kind!=='like'&&kind!=='collect'||pendingEngagements.has(kind))return;
const active=kind==='like'?actionState.isLike===1:actionState.isCollect===1;
@@ -162,28 +132,7 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
}finally{pendingEngagements.delete(kind);finish.forEach(done=>done());}
return;
}
if(element.hasAttribute('data-share')){const finish=beginButtonRequest(element,'复制中');try{await navigator.clipboard.writeText(location.href);if(!signal.aborted){window.tracker?.dramaEvent('ep_share');app.notify('页面链接已复制');}}finally{finish();}return;}
if(element.dataset.commentSort){const sort=Number(element.dataset.commentSort);if(sort===1||sort===2)app.navigate(pageHref(1,sort));return;}
if(element.dataset.reply){replyId=Number(element.dataset.reply);replyParent=Number(element.dataset.parent);page.querySelector('#reply-context')!.innerHTML=`<div class="play-reply-context"><span>回复 @${escape(element.dataset.replyName)}</span><button type="button" data-cancel-reply>取消回复</button></div>`;page.querySelector<HTMLTextAreaElement>('textarea')!.focus();}
if(element.hasAttribute('data-cancel-reply')){replyId=replyParent=0;page.querySelector('#reply-context')!.textContent='';}
if(element.dataset.commentLike){
const active=element.getAttribute('aria-pressed')==='true',state={isLike:active?1:0,countLike:Number(element.dataset.commentCount)||0};
const finish=beginButtonRequest(element);
try{
const result=await app.api.post<unknown>('comment/like/set',{commentId:Number(element.dataset.commentLike),isLike:!active});
if(signal.aborted)return;
applyEngagementSuccess(state,'like',active,result);
element.setAttribute('aria-pressed',String(state.isLike===1));element.dataset.commentCount=String(state.countLike);
const label=element.querySelector('[data-request-label]')||element;label.textContent=`${state.isLike===1?'♥':'♡'} ${formatCount(state.countLike)}`;
}finally{finish();}
}
if(element.hasAttribute('data-share')){const finish=beginButtonRequest(element,'复制中');try{await navigator.clipboard.writeText(location.href);if(!signal.aborted)app.notify('页面链接已复制');}finally{finish();}return;}
})().catch(error=>app.notify(error instanceof Error?error.message:'操作失败'));},{signal});
page.querySelector('textarea[name="comment"]')?.addEventListener('input',event=>{const counter=page.querySelector('#play-comment-count');if(counter)counter.textContent=String((event.target as HTMLTextAreaElement).value.length);},{signal});
page.querySelector('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();};
return ()=>{disposeDescription();media?.dispose();};
}
+23 -10
View File
@@ -9,7 +9,6 @@ export function imageAttributes(value: unknown, fallback = PLACEHOLDER, target:
return `${target}="${escape(fallback)}"${source ? ` data-${target}="${escape(source)}" data-fallback="${escape(fallback)}"` : ''}`;
}
export const title = (drama: Drama): string => drama.title || drama.playName || "未命名剧集";
export const itemAttributes=(item:Drama,video=false)=>`data-track-item data-item-id="${escape(item.id)}" data-item-type="${video?'video':'short_drama'}" data-item-name="${escape(title(item))}" data-recommend-trace-id="${escape(item.recommendTraceId)}" data-recommend-id="${escape(item.recommendId)}" data-recommend-trace-info="${escape(item.recommendTraceInfo)}"`;
export function activeBanners(home: HomeData | null | undefined): Drama[] {
const items=recordList<Drama>(home?.homeBannerData);
return (items.some(item=>item.showOnBanner)?items.filter(item=>item.showOnBanner):items).slice(0,6);
@@ -33,36 +32,50 @@ export function highlightSearchTitle(text:string,term:string):string {
return result+escape(text.slice(cursor));
}
const genreNames=(drama:Drama):string[]=>(Array.isArray(drama.tagNames)?drama.tagNames:Array.isArray(drama.themeNames)?drama.themeNames:[]).filter((tag):tag is string=>typeof tag==='string'&&!!tag.trim());
const episodeLabel=(drama:Drama):string=>drama.totalEpisode?`全${drama.totalEpisode}集`:'';
const badgeMarkup=(drama:Drama,prefix:string):string=>drama.isFeatured?`<span class="${prefix}-badge ${prefix}-badge--hot">爆剧</span>`:drama.isNew?`<span class="${prefix}-badge ${prefix}-badge--new">新剧</span>`:'';
/** Poster card: the whole tile is one descriptive link, with visible title, genres, synopsis and heat for crawlers. */
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 tags=genreNames(drama),episodes=video?'':episodeLabel(drama);
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>`;
const description=drama.desc||drama.description||'';
return `<article class="hg-card${rank?' hg-card--ranked':''}"><a class="hg-card-link" href="${contentPath(video?"video":"detail",drama)}" title="${escape(`《${text}》${episodes?episodes+' ':''}在线观看`)}"><span class="hg-card-cover"><img class="poster-source" ${imageAttributes(drama.cover || drama.coverImg || drama.horizontalCover)} alt="《${escape(text)}》海报" loading="lazy" decoding="async">${badgeMarkup(drama,'hg-card')}${episodes?`<span class="hg-card-eps">${episodes}</span>`:''}${rank?`<span class="hg-card-rank" data-rank="${rank}" aria-label="第 ${rank} 名">${String(rank).padStart(2,'0')}</span>`:''}</span><span class="hg-card-body"><${headingTag} class="hg-card-title">${heading}</${headingTag}>${tags.length?`<span class="hg-card-tags">${tags.slice(0,2).map(tag=>`<span>${escape(tag)}</span>`).join('')}</span>`:''}${description?`<span class="hg-card-desc">${escape(description)}</span>`:''}<span class="hg-card-heat"><svg 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></article>`;
}
/** Horizontal recommendation row: thumbnail plus a linked heading, episode count, genres and synopsis. */
export function videoItem(drama: Drama, headingLevel:2|3=3): string {
if (!drama || typeof drama !== 'object' || Array.isArray(drama)) return '';
const text=title(drama),href=contentPath('detail',drama),tags=genreNames(drama),episodes=episodeLabel(drama),headingTag=headingLevel===2?'h2':'h3';
const description=drama.desc||drama.description||'';
return `<article class="hg-vitem"><a class="hg-vitem-thumb" href="${href}" tabindex="-1" aria-hidden="true"><img ${imageAttributes(drama.cover || drama.coverImg || drama.horizontalCover)} alt="《${escape(text)}》剧照" loading="lazy" decoding="async">${episodes?`<span>${episodes}</span>`:''}</a><div class="hg-vitem-body"><${headingTag} class="hg-vitem-title"><a href="${href}" title="${escape(`《${text}》在线观看`)}">${escape(text)}</a></${headingTag}><p class="hg-vitem-meta">${[...tags.slice(0,2).map(escape),episodes,`${formatCount(drama.heatValue ?? drama.countLike ?? 0)}热度`].filter(Boolean).map(value=>`<span>${value}</span>`).join('')}</p>${description?`<p class="hg-vitem-desc">${escape(description)}</p>`:''}</div></article>`;
}
/** Featured stage plus a numbered, crawlable list of every banner; list links switch slides before navigating. */
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>`;
const index=items[selected]?selected:0,current=items[index],text=title(current),href=contentPath('detail',current);
const meta=[genreNames(current)[0],episodeLabel(current),`${formatCount(current.heatValue || current.countBrowse)}热度`].filter(Boolean);
return `<div class="hg-hero-stage"><img class="hero-art fade" ${imageAttributes(current.banner || current.horizontalCover || current.cover)} alt="《${escape(text)}》推荐海报" fetchpriority="high"><div class="hg-hero-copy fade"><span class="hg-hero-label">今日推荐 · NO.${String(index+1).padStart(2,'0')}</span><h2 class="hg-hero-title"><a href="${href}">${escape(text)}</a></h2><p class="hg-hero-meta">${meta.map(item=>`<span>${escape(item)}</span>`).join('')}</p>${current.desc||current.description?`<p class="hg-hero-desc">${escape(current.desc || current.description)}</p>`:''}<div class="hg-hero-actions"><a class="primary" href="${href}"><svg 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><ol class="hg-hero-list" aria-label="今日推荐剧集">${items.map((item,i)=>`<li><a href="${contentPath('detail',item)}" data-feature="${i}"${i===index?' aria-current="true"':''}><span class="hg-hero-num">${String(i+1).padStart(2,'0')}</span><img ${imageAttributes(item.cover || item.horizontalCover)} alt="《${escape(title(item))}》缩略图" loading="lazy" decoding="async"><span class="hg-hero-item"><strong>${escape(title(item))}</strong>${episodeLabel(item)?`<small>${episodeLabel(item)}</small>`:''}</span></a></li>`).join("")}</ol>`;
}
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>`;
return `<a class="topic-recommendation-card" 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>`;
return `<a class="${escape(className)}" data-ad 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("");
}
@@ -82,7 +95,7 @@ export function renderHome(home: HomeData | null | undefined, error?: string): s
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>`;
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">黄果吃瓜 App</div><h2>在 App 上继续追剧,离线缓存随时看</h2><p>竖屏沉浸播放 · 离线缓存 · 弹幕互动</p></div><a class="bottom-app-download" data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer">点击下载</a></section>`;
}
export type PageHref=(page:number)=>string;
+2 -2
View File
@@ -2,7 +2,7 @@ import { contentPath, searchPath } from '../routing';
import type { Drama, PageData } from '../api/types';
import { recordList } from '../api/protocol';
import type { ApiClient } from './api';
import { card, escape, imageAttributes, itemAttributes, pagination, title, type PageHref } from './render';
import { card, escape, imageAttributes, pagination, title, type PageHref } from './render';
export const SEARCH_PAGE_SIZE=25;
@@ -21,7 +21,7 @@ export function searchPicksMarkup(value:unknown):string {
return recordList<Drama>(value).slice(0,3).map(item=>{
const name=title(item);
const badge=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>`;
return `<a class="search-pick" href="${contentPath('detail',item)}" aria-label="查看${escape(name)}"><span class="search-pick-cover"><img class="poster-source" ${imageAttributes(item.cover||item.coverImg||item.horizontalCover)} alt="${escape(name)}封面" width="160" height="240" loading="lazy" decoding="async">${badge}${item.totalEpisode?`<span class="drama-episodes">${escape(item.totalEpisode)}集</span>`:''}</span><span class="search-pick-body"><strong>${escape(name)}</strong><small>${escape(item.desc||item.description||'')}</small></span></a>`;
}).join('');
}
+3 -29
View File
@@ -205,15 +205,6 @@ export function startStartupDialogs(): StartupDialogs {
const media = ad.querySelector<HTMLAnchorElement>('.startup-ad-media')!;
const loading = ad.querySelector<HTMLElement>('.startup-ad-loading')!;
const close = ad.querySelector<HTMLButtonElement>('.startup-close')!;
function track(eventType: string, element: HTMLElement = media) {
try {
if (eventType === 'close') {
if (window.tracker?.advertising) window.tracker.advertising(element, eventType);
else document.dispatchEvent(new CustomEvent('dialog-ad:track', {detail: {element, eventType}}));
} else if (window.tracker?.externalAd) window.tracker.externalAd(element, eventType);
else document.dispatchEvent(new CustomEvent('external-ad:track', {detail: {element, eventType}}));
} catch { /* Tracking does not interrupt display or dismissal. */ }
}
const view: StartupView = {
showAge() {if (!age.open) age.showModal();},
closeAge() {
@@ -236,19 +227,12 @@ export function startStartupDialogs(): StartupDialogs {
image.alt = image.title = item.title?.trim() || '推荐广告';
media.setAttribute('aria-label', item.title || '查看广告');
if (item.link) media.href = item.link; else media.removeAttribute('href');
media.dataset.adId = item.advertiseCode || item.id || item.images;
media.dataset.adClickable = item.link ? '1' : '0';
media.dataset.adLocation = String(item.location || '');
media.dataset.adSlotKey = item.advertiseLocationCode || String(item.location || '');
media.dataset.adSlotName = item.advertiseLocationName || '弹窗广告位';
media.dataset.adPlacement = 'startup-ad';
media.hidden = false; loading.hidden = true; close.disabled = false;
ad.removeAttribute('aria-busy');
ad.setAttribute('aria-label', item.title || '广告推荐');
if (!ad.open) ad.showModal();
track('show');
},
hideAdvertisement() {if (ad.open) {if (!media.hidden) track('close'); ad.close();} media.hidden = true; loading.hidden = true; ad.removeAttribute('aria-busy');},
hideAdvertisement() {if (ad.open) ad.close(); media.hidden = true; loading.hidden = true; ad.removeAttribute('aria-busy');},
async loadGrid(items, signal) {
const links = new Map<Advertisement, HTMLAnchorElement>();
const loaded = await Promise.all(items.map(async item => {
@@ -258,14 +242,6 @@ export function startStartupDialogs(): StartupDialogs {
link.rel = 'sponsored nofollow noopener noreferrer';
link.setAttribute('aria-label', item.title || '查看推荐应用');
if (item.link) link.href = item.link;
link.dataset.trackAd = 'external';
link.dataset.adPlacement = 'startup-grid';
link.dataset.adType = 'icon';
link.dataset.adId = item.advertiseCode || item.id || item.images;
link.dataset.adClickable = item.link ? '1' : '0';
link.dataset.adLocation = String(item.location || '');
link.dataset.adSlotKey = item.advertiseLocationCode || String(item.location || 'nineGridList');
link.dataset.adSlotName = item.advertiseLocationName || '宫格弹窗广告位';
const icon = document.createElement('img');
icon.width = icon.height = 80;
icon.alt = item.title || '推荐应用';
@@ -287,9 +263,8 @@ export function startStartupDialogs(): StartupDialogs {
promotions.replaceChildren(...items.map(item => gridLinks.get(item)!));
promotions.scrollTop = 0;
if (!grid.open) grid.showModal();
document.dispatchEvent(new Event('tracker:refresh'));
},
hideGrid() {if (grid.open) {promotions.querySelectorAll<HTMLElement>('.startup-promotion').forEach(link => track('close', link)); grid.close();}},
hideGrid() {if (grid.open) grid.close();},
};
const storage: SessionStore = {
getItem: key => window.sessionStorage.getItem(key),
@@ -300,7 +275,7 @@ export function startStartupDialogs(): StartupDialogs {
age.addEventListener('cancel', event => event.preventDefault());
age.querySelector('.startup-age-confirm')!.addEventListener('click', () => startup.confirmAge());
age.querySelectorAll('.startup-age-leave,.startup-close').forEach(button => button.addEventListener('click', () => location.assign('https://www.google.com')));
function nextAdvertisement() {if (ad.open && !media.hidden) track('close'); startup.nextAdvertisement();}
function nextAdvertisement() {startup.nextAdvertisement();}
close.addEventListener('click', nextAdvertisement);
ad.addEventListener('cancel', event => {event.preventDefault(); nextAdvertisement();});
grid.querySelector('.startup-close')!.addEventListener('click', () => startup.dismissGrid());
@@ -314,6 +289,5 @@ export function startStartupDialogs(): StartupDialogs {
dialog.addEventListener('pointerdown', () => dialog.classList.remove('startup-keyboard-focus'));
dialog.addEventListener('close', () => dialog.classList.remove('startup-keyboard-focus'));
});
media.addEventListener('click', () => {if (ad.open && !media.hidden && media.hasAttribute('href')) track('click');});
return startup;
}
-122
View File
@@ -1,122 +0,0 @@
import { contentPath } from '../routing';
import type { Chapter } from '../api/types';
import { ApiError, EXPIRED_CODES } from '../api/protocol';
import type { SiteContext } from './context';
import { chapterAccess, chapterUnlockCost } from './playback';
export function bindChapterUnlock(app: SiteContext, playletId: number, currentChapter: Chapter | null, slug?:string) {
const signal = app.signal;
const dialog = document.getElementById('play-episode-lock') as HTMLDialogElement;
const pay = dialog.querySelector<HTMLButtonElement>('.unlock-pay')!;
const close = dialog.querySelector<HTMLButtonElement>('.unlock-close')!;
const error = dialog.querySelector<HTMLElement>('#episode-lock-error')!;
const description = dialog.querySelector<HTMLElement>('#episode-lock-description')!;
let selected: Chapter | null = null;
let lookup = 0;
let unlocking = false;
const showError = (message = '') => { error.textContent = message; error.hidden = !message; };
const play = (chapter: Chapter) => app.navigate(contentPath('play',{id:playletId,slug},chapter.id));
const recharge = () => { dialog.close(); app.navigate('/info/deposit/'); };
const 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};
}
+16 -57
View File
@@ -1,15 +1,13 @@
import type { Config, FooterContact } from "./api/types";
import { OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL } from "./site";
/** Original HTML copy and markup; the about page's contacts use site configuration. */
/** Original HTML copy and markup; the only contact is the official Telegram account. */
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>
<p>我们相信故事能够带来共鸣,也希望让更多创作者的作品与喜欢它们的观众相遇。</p>
</section>
<section class="about-section">
@@ -17,12 +15,12 @@ export const INFO_PAGES:Readonly<Record<string,InfoPageDefinition>>={
<p>围绕内容发现与观看体验,持续完善搜索、收藏、追剧记录及多端体验,让每一次打开都能更快找到想看的故事。</p>
<p>品牌主张:好故事,值得被看见。</p>
</section>
${EMPTY_CONTACT_SECTION}
${infoContactSection()}
`},
"user-agreement": {key:"user-agreement",title:"用户协议",html:`
<section class="about-section">
<h2>使用协议</h2>
<p>使用条款 欢迎您光临【91短剧】(以下简称本网站),本网站由「91短剧」(以下简称91短剧)经营。</p>
<p>使用条款 欢迎您光临【黄果吃瓜】(以下简称本网站),本网站由「黄果吃瓜」(以下简称黄果吃瓜)经营。</p>
<p>本网站重视每一位使用者所享有的服务,特此说明本网站的使用政策,以保障您的权益。</p>
<p>请您仔细阅读本使用条款的内容:</p>
</section>
@@ -120,7 +118,7 @@ ${EMPTY_CONTACT_SECTION}
`},
"privacy-policy": {key:"privacy-policy",title:"隐私政策",html:`
<section class="about-section">
<p>91短剧(以下简称本公司)非常重视您的隐私权,并依据《个人资料保护法》(以下简称个资法)的规定,向您告知下列有关隐私权保护政策的更多内容。</p>
<p>黄果吃瓜(以下简称本公司)非常重视您的隐私权,并依据《个人资料保护法》(以下简称个资法)的规定,向您告知下列有关隐私权保护政策的更多内容。</p>
</section>
<section class="about-section">
<h2>一、本公司使用您个人资料的目的</h2>
@@ -184,12 +182,8 @@ ${EMPTY_CONTACT_SECTION}
<p>收藏保存在当前浏览器中,没有接入账号同步。更换浏览器、设备或清除网站数据后,原来的收藏可能不可见。</p>
</details>
<details>
<summary>VIP 和金币是否可以购买?</summary>
<p>这些标识用于展示剧集类型。目前没有充值、订阅或购买功能。</p>
</details>
<details>
<summary>如何反馈问题?</summary>
<p>前往<a href="/info/feedback/">意见反馈</a>,填写问题类型、设备和详细描述,再将反馈保存到本地。当前版本没有线上提交功能。</p>
<summary>观看短剧需要付费吗?</summary>
<p>不需要。本站全部短剧和视频均可免费观看,无需充值、购买或开通会员。</p>
</details>
`},
"2257": {key:"2257",title:"2257 合规声明",html:`
@@ -218,7 +212,7 @@ ${EMPTY_CONTACT_SECTION}
<span class="compliance-number" aria-hidden="true">04</span>
<div>
<h2>合规说明</h2>
<p>如发现疑似违规内容,请通过<a href="/info/feedback/">意见反馈</a>提供线索,以便核查处理。具体义务以适用法律法规为准。</p>
<p>如发现疑似违规内容,请通过官方TG ${OFFICIAL_TG_HANDLE} 提供线索,以便核查处理。具体义务以适用法律法规为准。</p>
</div>
</section>
`},
@@ -247,54 +241,19 @@ ${EMPTY_CONTACT_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>
<h2>联系方式</h2>
<p>版权通知及反通知请通过官方TG <a href="${OFFICIAL_TG_URL}" target="_blank" rel="sponsored nofollow noopener noreferrer">${OFFICIAL_TG_HANDLE}</a> 提交,并一并提供核查所需的资料。</p>
</div>
</section>
`},
};
const contactText=(value:unknown)=>typeof value==='string'?value.trim().replace(/立即(?:联系|加入)/g,'').replace(/^[\s::]+|[\s::]+$/g,''):'';
const escapeContact=(value:string)=>value.replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]!));
function contactValue(value:unknown):{text:string;href:string}|undefined {
const text=contactText(value);
if(!text||/[\x00-\x1f\x7f]/.test(text)||/mailto:|[^/\s@]+@[^/\s@]+\./i.test(text))return;
if(/^tel:\+?[\d() .-]+$/i.test(text))return {text:text.slice(4),href:text};
const url=text.startsWith('//')?'https:'+text:/^[\w.-]+\.[a-z]{2,}(?:[/?#]|$)/i.test(text)?'https://'+text:text;
try{
const parsed=new URL(url);
if(['https:','http:'].includes(parsed.protocol)&&!parsed.username&&!parsed.password)return {text,href:parsed.href};
return;
}catch{
if(/^[\w-]+:/i.test(text))return;
return {text,href:''};
}
/** The only contact shown, matching the other 黄果 sites. */
export function infoContactSection():string {
return `<section class="about-section" data-site-contacts><h2>联系我们</h2><p>官方TG:<a href="${OFFICIAL_TG_URL}" target="_blank" rel="sponsored nofollow noopener noreferrer">${OFFICIAL_TG_HANDLE}</a></p></section>`;
}
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 {
export function getInfoPage(key:string):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;
return page;
}
+9 -25
View File
@@ -1,5 +1,5 @@
import type { Config } from './api/types';
import { recordList, safeUrl } from './api/protocol';
import { NAVIGATION_SITE_NAME, OFFICIAL_TG_HANDLE, OFFICIAL_TG_URL, SITE_URL } from './site';
const object=(value:unknown):Record<string,unknown>=>{
if(typeof value==='string'){try{return object(JSON.parse(value));}catch{return {};}}
@@ -7,33 +7,17 @@ const object=(value:unknown):Record<string,unknown>=>{
};
const text=(value:unknown)=>typeof value==='string'?value.replace(/<[^>]*>/g,'').replace(/[\u0000-\u001f\u007f]/g,' ').replace(/\s+/g,' ').trim():'';
const firstText=(...values:unknown[])=>values.map(text).find(Boolean)||'';
const telegramUrl=(value:unknown)=>safeUrl(typeof value==='string'&&/^@[a-z\d_]{4,}$/i.test(value.trim())?'https://t.me/'+value.trim().slice(1):value);
const lineLabel=(line:number)=>'线路'+(['','一','二','三','四','五','六','七','八','九','十'][line]||line);
/** Navigation uses only configured destinations; missing values never link to a demo site. */
/** The entry, permanent address and contact channel are fixed; only the description comes from configuration. */
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)}]:[];
});
}
const settings=object(config.config);
const navigationSeo=object(config.seoNavInfo);
const siteName=NAVIGATION_SITE_NAME;
const routes=[{line:1,href:SITE_URL,label:'线路一'}];
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'),
description:firstText(navigationSeo.dhDescription,navigationSeo.description,settings.dhDescription)||'全网最新最火短剧,黄果吃瓜为你呈现',
routes,permanent:SITE_URL,
telegram:OFFICIAL_TG_URL,telegramHandle:OFFICIAL_TG_HANDLE,
};
}
+3 -7
View File
@@ -1,15 +1,11 @@
import type { Chapter, Comment, Config, Drama, DramaDetail, FooterInfoResponse, Package, PageData, User } from './api/types';
import type { Chapter, Config, DramaDetail, FooterInfoResponse, User } from './api/types';
export interface SiteSharedData {config:Config;footer:FooterInfoResponse;trackerConfigured:boolean}
export interface MemberInitialData {user:User;list?:PageData<Drama>}
export interface WalletInitialData {user:User;packages:{coinList?:Package[];vipList?:Package[]};transactions:PageData<Record<string,unknown>>}
export interface SiteSharedData {config:Config;footer:FooterInfoResponse}
export interface PlayerInitialData {
detail:DramaDetail;
chapters:Chapter[];
chapter:Chapter|null;
comments?:PageData<Comment>;
}
/** Only the first route can consume this snapshot; later navigation loads fresh data. */
@@ -20,7 +16,7 @@ export interface PageBootstrap {
subtitle?:string;
player?:PlayerInitialData;
search?:{keyword:string;total:number;page:number;pageSize:number};
info?:{key:string;member?:MemberInitialData;wallet?:WalletInitialData;feedback?:boolean};
info?:{key:string};
session?:{user:User|null;state:string};
}
+4 -12
View File
@@ -1,4 +1,4 @@
import type { Advertisement, Advertisements, Chapter, Comment, DramaDetail, PageData } from './api/types';
import type { Advertisement, Advertisements, Chapter, DramaDetail } from './api/types';
import type { PlayerInitialData } from './page-data';
import { recordList } from './api/protocol';
import { normalizeSeo } from './seo';
@@ -41,17 +41,9 @@ function publicChapter(data:Chapter):Chapter {
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 {
export function playerInitialData(raw:DramaDetail,chapters:Chapter[],chapter:Chapter|null):PlayerInitialData {
const detail=publicDrama(raw);
detail.relatedRecommendData=recordList<DramaDetail>(raw.relatedRecommendData).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).slice(0,6).map(publicDrama);
return {detail,chapters:chapters.map(publicChapter),chapter:chapter?publicChapter(chapter):null,
...(comments?{comments:{list:recordList<Comment>(comments.list).map(item=>publicComment(item)),...(typeof comments.error==='string'?{error:comments.error.slice(0,300)}:{}),totalCount:typeof comments.totalCount==='number'?comments.totalCount:0}}:{}),
};
detail.relatedRecommendData=recordList<DramaDetail>(raw.relatedRecommendData).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).slice(0,12).map(publicDrama);
return {detail,chapters:chapters.map(publicChapter),chapter:chapter?publicChapter(chapter):null};
}
+2 -8
View File
@@ -16,8 +16,7 @@ export function getRankPage(slug='hot') {
}
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 information = new Set(['about-us', 'user-agreement', 'privacy-policy', 'help-center', '2257', 'dmca']);
const id = (value?: string) => value !== undefined && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value)>0;
export interface ContentIdentity { id?:number; slug?:unknown }
@@ -62,18 +61,13 @@ function baseRouteFromSegments(parts: string[]): SiteRoute | null {
if (group === 'detail' && parts.length === 2 && contentKey(key)) return {group, key};
if (group === 'play' && parts.length === 3 && contentKey(key) && id(value)) return {group, key, value};
if (group === 'search' && parts.length === 2 && key.trim()) return {group, key};
if (group === 'member' && parts.length === 2 && members.has(key)) return {group, key};
if (group === 'info' && parts.length === 2 && information.has(key)) return {group, key};
if (group === 'info' && key === 'deposit' && value === 'vip' && parts.length === 3) return {group, key, value};
return null;
}
export function supportsPagination(route:SiteRoute):boolean {
if(route.group==='page')return ['shorts','topics','rank','theme','tag','config'].includes(route.key);
if(route.group==='member')return ['favorites','history','likes','purchased'].includes(route.key);
if(route.group==='info')return route.key==='deposit';
if(route.group==='search')return true;
return ['detail','play'].includes(route.group);
return route.group==='search';
}
export function routeFromSegments(parts:string[]):SiteRoute|null {
+10 -50
View File
@@ -4,27 +4,23 @@ 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 type { Config, DramaDetail, Chapter, 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 { PageBootstrap, PageView } from './page-data';
import type { ApiClient } from './client/api';
import { playerInitialData } from './player-data';
import { loadChannel, channelView } from './client/channel';
import { orderedChapters, playerMarkup } from './client/player-markup';
import { 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>();
@@ -84,7 +80,7 @@ const getReadIdentity=cache(async ()=>{
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)};
return {token,device,userAgent,clientIp:isIP(ip)?ip:undefined,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();
@@ -98,9 +94,9 @@ async function contentRequest<T>(endpoint:string,query:Record<string,string|numb
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||error===null||typeof error!=='object'||!('code' in error)||!([401,403].includes(Number(error.code))||EXPIRED_CODES.has(Number(error.code)))||('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)){
if(protectedRead&&(!result||([401,403].includes(result.code)||EXPIRED_CODES.has(result.code)))&&!('prepared' in identity&&identity.prepared)){
result=await upstream<T>(path,{...await getReadGuest(),timeoutMs:5000});
}
if(result?.code!==200)throw new ApiError('内容暂不可用',result?.code||502);
@@ -124,34 +120,8 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
const query:Record<string,string|number>={pageNum:route.page||1,pageSize:24};
try{
if(!route.group){const home=await getHome();return {...homeSeoContent(home.data),failed:!!home.error};}
if(route.group==='member'||route.group==='info'&&['deposit','user-center','settings'].includes(route.key)){
const wallet=route.key==='deposit',tab=route.group==='member'?route.key:route.key==='settings'?'settings':'center';
const title=wallet?(route.value==='vip'?'会员畅享':'金币充值'):({center:'个人中心',history:'观看历史',favorites:'我的收藏',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置'} as Record<string,string>)[tab];
const session=await preparedSession(),pageHref=(page:number)=>paginationPath(address,page);
if(!session.user){
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||[]);
const info=getInfoPage(route.key);
return info?{...infoSeoContent(info),view:infoView(address,route.key,info.title,info.html)}:{};
}
if(route.group==='search'){
@@ -162,7 +132,6 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
return {totalCount:total,pageSize,view:{panel:'search',title:'搜索结果',html:searchResultsMarkup(results,value=>paginationPath(address,value)),bootstrap:{path:address,panel:'search',title:'搜索结果',search:{keyword,total,page,pageSize}}}};
}
if(['detail','play'].includes(route.group)){
const commentSort=params.get('commentSort')==='1'?1:2;
const detailRequest=contentRequest<DramaDetail>('playlet/detail',contentQuery(route.key),true);
const playletQuery=contentQuery(route.key,'playletId','playletSlug');
// Slug-aware chapter reads stay parallel. Only ID-only endpoints wait for slug resolution.
@@ -170,13 +139,11 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
if(!matchesContentKey(detail,route.key)||!Number.isSafeInteger(detail.id)||detail.id<=0)throw new ApiError('剧集不存在',404);
return detail.id;
});
const [detail,chapterResult,chapters,comments,configuration,recommendations]=await Promise.all([
const [detail,chapterResult,chapters,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),
resolvedId.then(playletId=>contentRequest<PageData<Drama>>('playlet/guess-like/list',{playletId,pageNum:1,pageSize:12})).catch(()=>undefined),
]);
let chapter:Chapter|undefined;
if(route.group==='play'){
@@ -187,14 +154,11 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
const content=dramaSeoContent(detail,chapter);
if(!matchesContentKey(detail,route.key))return {...content,missing:true};
if(!content.title)return {failed:true};
if(comments&&!comments.error){content.totalCount=comments.totalCount;content.pageSize=10;}
if(comments?.error&&(route.page||1)>1)content.failed=true;
const ordered=orderedChapters(chapters??detail.playletChapterInfo);
detail.relatedRecommendData=recommendations?.list||[];
const initial=playerInitialData(detail,ordered,chapter||null,comments);
const initial=playerInitialData(detail,ordered,chapter||null);
const title=content.title||'剧集详情',overview=route.group==='detail';
const pageHref=(page:number,sort:number)=>paginationPath(contentPath(overview?'detail':'play',detail,chapter?.id)+search,page,{commentSort:sort===2?null:sort});
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}}};
return {...content,view:{panel:'player',title,overview,html:playerMarkup(initial.detail,initial.chapters,{chapter:initial.chapter,isVideo:false}),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)])))};
@@ -220,10 +184,6 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
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};
+22 -26
View File
@@ -1,15 +1,16 @@
import { NAVIGATION_SITE_NAME, OFFICIAL_TG_URL, SITE_ORIGIN } from './site';
import type { Chapter, Config, DramaDetail, HomeData, NavigationCategory, PageData } from './api/types';
import type { InfoPageDefinition } from './info-pages';
import { chapterAccess, chapterRequiresPayment } from './client/playback';
import { chapterAccess } 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';
import { buildStructuredData, dramaSchemaData, faqFromHtml, homeSchemaItems, listSchemaItems, 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;
totalCount?:number; pageSize?:number; failed?:boolean; missing?:boolean; unavailable?:boolean;
routeConfig?:boolean; indexable?:boolean; identity?:ContentIdentity;
items?:SchemaItem[]; entity?:SchemaDrama; faq?:SchemaQuestion[];
}
export interface PageHead {
@@ -51,19 +52,18 @@ export function siteOrigin(value:unknown):string {
return url.origin;
}catch{return '';}
}
/** Canonical URLs always use the site's own domain, never configured mirror addresses. */
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))];
const origin=SITE_ORIGIN;
const sameAs=[OFFICIAL_TG_URL];
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短剧'})),
site:mergeSeo(current.site,previous?.site||normalizeSeo({siteName:'黄果吃瓜'})),
navigation:mergeSeo(current.navigation,previous?.navigation||normalizeSeo({})),
};
}
@@ -73,21 +73,18 @@ export function clientSeoEnvironment(current:SeoEnvironment,config:Config):SeoEn
return current.origin?current:resolveSeoEnvironment(config,current.categories,current);
}
const infoTitles:Record<string,string>={'about-us':'关于我们','user-agreement':'用户协议','privacy-policy':'隐私政策','help-center':'常见问题','2257':'2257',dmca:'DMCA',feedback:'意见反馈','user-center':'个人中心',settings:'账号设置',deposit:'金币充值'};
const labels:Record<string,string>={shorts:'短剧',topics:'专题推荐',rank:'热播榜',apps:'福利应用','home-road':'回家的路',theme:'题材短剧',tag:'标签短剧',config:'更多好剧',center:'个人中心',favorites:'我的收藏',history:'观看历史',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置'};
const infoTitles:Record<string,string>={'about-us':'关于我们','user-agreement':'用户协议','privacy-policy':'隐私政策','help-center':'常见问题','2257':'2257',dmca:'DMCA'};
const labels:Record<string,string>={shorts:'短剧',topics:'专题推荐',rank:'热播榜',apps:'福利应用','home-road':'回家的路',theme:'题材短剧',tag:'标签短剧',config:'更多好剧'};
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通知说明。',
'about-us':'了解黄果吃瓜的品牌介绍、平台服务与联系方式。',
'user-agreement':'了解黄果吃瓜的用户协议、使用条款与服务规则。',
'privacy-policy':'了解黄果吃瓜如何收集、使用及保护个人信息。',
'help-center':'查阅黄果吃瓜的观看、账户与服务常见问题。',
'2257':'黄果吃瓜的2257合规说明与相关信息。',dmca:'黄果吃瓜的版权保护政策与DMCA通知说明。',
};
function privateRoute(route:SiteRoute):boolean {
return route.group==='member'||route.group==='info'&&['user-center','settings','deposit','feedback'].includes(route.key);
}
function canonicalPath(path:string):string {
if(path==='/')return '/';
return path.replace(/\/page\/1\/?$/,'/').replace(/\/?$/,'/');
@@ -144,7 +141,7 @@ export function dramaSeoContent(data:DramaDetail,chapter?:Chapter):SeoContent {
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};
return {seoInfo:seo,title,description,keywords,episode:chapter?.currentEpisode,indexable,missing:!Number.isSafeInteger(data.id)||data.id<=0,entity};
}
/** Only a successful count can prove a page is beyond the end of a collection. */
@@ -163,7 +160,7 @@ export function pageResponseStatus(pathname:string,content:SeoContent={}):200|40
export function buildPageHead(pathname:string,search:string,environment:SeoEnvironment,content:SeoContent={}):PageHead {
const navigation=pathname.replace(/\/$/,'')==='/navigation';
const route=navigation?{group:'navigation',key:''}:parseSitePath(pathname,search);
const siteName=first(environment.site.siteName,navigation?environment.navigation.siteName:'',normalizeSeo(content.seoInfo).siteName,'91短剧');
const siteName=navigation?NAVIGATION_SITE_NAME:first(environment.site.siteName,normalizeSeo(content.seoInfo).siteName,'黄果吃瓜');
const page=route?.page||1;
const params=new URLSearchParams(search);
const sort=Number(params.get('sort')||1);
@@ -186,11 +183,11 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
if(fallback)seo=mergeSeo(seo,fallback);
}
const ranking=route?.group==='page'&&route.key==='rank'?getRankPage(route.value):undefined;
const pageTitle=!route?'页面不存在':!route.group?`${siteName} · 百万大神原创短剧`:navigation?'官方入口':route.group==='search'?(route.key.trim()?`${route.key.trim()} · 搜索结果`:'搜索短剧'):route.group==='info'?(route.key==='deposit'&&route.value==='vip'?'会员畅享':content.title||infoTitles[route.key]):isContent?(content.title||'剧集详情'):ranking?.title||content.title||labels[route.key]||'内容列表';
const pageTitle=!route?'页面不存在':!route.group?`${siteName} · 百万大神原创短剧`:navigation?'官方入口':route.group==='search'?(route.key.trim()?`${route.key.trim()} · 搜索结果`:'搜索短剧'):route.group==='info'?(content.title||infoTitles[route.key]):isContent?(content.title||'剧集详情'):ranking?.title||content.title||labels[route.key]||'内容列表';
let title=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));
let description=seo.description||content.description||(!route?.group?`${siteName},发现值得一看的短剧。都市甜宠、古装仙侠、悬疑逆袭,精彩故事随心看。`:isContent?`在${siteName}查看《${content.title||'精选剧集'}》的剧集介绍、更新与观看信息。`:(descriptions[route.key]||`${pageTitle},${siteName}为你提供最新内容与相关服务。`).replaceAll('黄果吃瓜',siteName));
if(episode&&!description.includes(episode))description+=` - ${episode}`;
let keywords=seo.keywords.length?seo.keywords:[...new Set([content.title||pageTitle,...(content.keywords||[]),'在线短剧',siteName].filter(Boolean))];
if(ranking){
@@ -218,7 +215,6 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
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')&&params.get('commentSort')!=='2')indexable=false;
if(isContent&&page>1)indexable=false;
if((route?.group==='play'||route?.group==='page'&&route.key==='config')&&content.indexable!==true)indexable=false;
if(content.indexable===false)indexable=false;
@@ -227,8 +223,8 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
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';
if(content.failed||content.missing||content.unavailable||outOfRange)indexable=false;
const robots=indexable?'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1':'noindex,follow';
const origin=environment.origin;
const absolute=(path:string)=>origin?new URL(path,origin).href:'';
const canonical=indexable?absolute(canonicalPagePath(pathname,content)):'';
+4 -5
View File
@@ -2,10 +2,10 @@ 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. */
/** Only fields used by visible site controls enter the client snapshot. */
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',
'appAddr','officialAppAddr',
].filter(key=>typeof value?.[key]==='string').map(key=>[key,String(value![key]).slice(0,4096)]));
const advitise=Object.fromEntries(['dialogList','nineGridList','detailTopList','detailbannerList','bottomBanner'].map(key=>[key,recordList<Advertisement>(source.advitise?.[key]).map(item=>{
const output:Record<string,unknown>={};
@@ -14,8 +14,7 @@ export function publicSiteData(source:Config,footer:FooterInfoResponse={}):SiteS
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(),
config:{config:section(source.config),newAddressInfo:section(source.newAddressInfo),advitise},
footer:{footerInfo:{brand:{content:typeof footer.footerInfo?.brand?.content==='string'?footer.footerInfo.brand.content:''}}}
};
}
+8
View File
@@ -0,0 +1,8 @@
/** The site's only public address. Pages never display or link to configured mirror addresses. */
export const SITE_ORIGIN='https://huangguocg.com';
export const SITE_URL=SITE_ORIGIN+'/';
/** Name shown on the standalone navigation page. */
export const NAVIGATION_SITE_NAME='黄果吃瓜';
/** The only contact channel shown anywhere on the site, matching the other 黄果 sites. */
export const OFFICIAL_TG_HANDLE='@jbd125';
export const OFFICIAL_TG_URL='https://t.me/jbd125';
+2 -6
View File
@@ -105,10 +105,6 @@ export function buildStructuredData(pathname:string,head:PageHead,environment:Se
];
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}))});
@@ -153,7 +149,7 @@ export function buildStructuredData(pathname:string,head:PageHead,environment:Se
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;
episode.isAccessibleForFree=true;
if(published)episode.datePublished=published;
graph.push(episode);page.mainEntity=ref(watchURL+'#episode');
}
@@ -162,7 +158,7 @@ export function buildStructuredData(pathname:string,head:PageHead,environment:Se
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;
video.isAccessibleForFree=true;
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;
-34
View File
@@ -1,34 +0,0 @@
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;
}