UI修改
This commit is contained in:
@@ -29,7 +29,7 @@ export default async function Page({params,searchParams}:PageProps) {
|
||||
if (!route) notFound();
|
||||
const {bootstrap,view,session,shared}=await getPageSeo(pathname(path),queryString(await searchParams));
|
||||
assertPageContent(pathname(path),bootstrap.content);
|
||||
const title=bootstrap.content.title||({'apps':'福利应用','home-road':'回家的路',center:'个人中心',favorites:'我的收藏',history:'观看历史',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置',deposit:'金币充值',feedback:'意见反馈'} as Record<string,string>)[route.key]||'内容列表';
|
||||
const title=bootstrap.content.title||({'home-road':'回家的路'} as Record<string,string>)[route.key]||'内容列表';
|
||||
const fallback:PageView={panel:'channel',title,html:empty(bootstrap.content.failed?'内容暂不可用,请稍后重试':'暂无内容',true)};
|
||||
return <SiteFrame home={{}} homeLoaded={false} view={view||fallback} seo={bootstrap} session={session} shared={shared} />;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isIP } from "node:net";
|
||||
import { allowedRequest, PUBLIC_PATHS, upstream } from "@/lib/api/server";
|
||||
import { UPSTREAM_HOP_HEADER } from '@/lib/api/config';
|
||||
import { ApiError, EXPIRED_CODES } from "@/lib/api/protocol";
|
||||
import { trackingHeaders } from "@/lib/api/tracking-headers";
|
||||
import { sdkHeaders } from "@/lib/api/sdk-headers";
|
||||
import type { Envelope, User } from "@/lib/api/types";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -14,7 +14,6 @@ const BEARER = "drama_bearer";
|
||||
const DEVICE = "drama_device";
|
||||
const REGISTERED = "drama_registered";
|
||||
const CHANNEL = "drama_channel";
|
||||
const CREDENTIALS = new Set(['auth/login','auth/register','auth/resetPassword']);
|
||||
const text = (value: unknown) => typeof value === 'string' ? value.trim() : '';
|
||||
const deviceId = (value: unknown) => /^[a-zA-Z0-9_-]{1,100}$/.test(text(value)) ? text(value) : '';
|
||||
const expired = (code: number) => EXPIRED_CODES.has(code) || code === 401;
|
||||
@@ -32,11 +31,6 @@ function sessionUser(value: unknown, token = '', registered = false): User {
|
||||
return {...user, token:sessionToken, isRegister:Boolean(user.isRegister) || user.type === 1 || registered};
|
||||
}
|
||||
|
||||
function userIdOf(value: unknown) {
|
||||
const userId = value && typeof value === 'object' ? (value as User).userId : 0;
|
||||
return Number.isSafeInteger(userId) && userId > 0 ? userId : 0;
|
||||
}
|
||||
|
||||
async function handler(request: NextRequest, context: {params: Promise<{path: string[]}>}) {
|
||||
const path = (await context.params).path.join('/');
|
||||
const method = request.method;
|
||||
@@ -61,12 +55,11 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
|
||||
const registered = request.cookies.get(REGISTERED)?.value === '1';
|
||||
let device = deviceId(request.cookies.get(DEVICE)?.value) || randomUUID();
|
||||
let channel = text(request.cookies.get(CHANNEL)?.value).slice(0,100);
|
||||
const establishesDevice = ['auth/guest','auth/login','auth/register'].includes(path);
|
||||
const establishesDevice = path === 'auth/guest';
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const forwardedIp = (request.headers.get('x-real-ip') || request.headers.get('x-forwarded-for')?.split(',')[0] || '').trim();
|
||||
const clientIp = isIP(forwardedIp) ? forwardedIp : undefined;
|
||||
const tracking=trackingHeaders(request.headers,path,method);
|
||||
const identity=trackingHeaders(request.headers,'auth/guest','POST');
|
||||
const identity=sdkHeaders(request.headers);
|
||||
const setContextCookies = (response: NextResponse) => {
|
||||
if (establishesDevice) {
|
||||
response.cookies.set(DEVICE,device,{...cookieOptions,httpOnly:false,maxAge:365*86400});
|
||||
@@ -80,7 +73,7 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
|
||||
};
|
||||
// Normalize HTTP 401 into the same recovery path as an expired business envelope.
|
||||
const call = async (callPath: string, options: Parameters<typeof upstream>[1]): Promise<Envelope<unknown>> => {
|
||||
try { return await upstream(callPath,{...options,request,clientIp,tracking:callPath.split('?')[0]===path?tracking:identity}); }
|
||||
try { return await upstream(callPath,{...options,request,clientIp,tracking:identity}); }
|
||||
catch (error) {
|
||||
if (error instanceof ApiError && error.code === 401) return {code:401,msg:error.message,data:null};
|
||||
throw error;
|
||||
@@ -90,21 +83,16 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
|
||||
let body: unknown;
|
||||
if (method === 'POST') {
|
||||
const size = Number(request.headers.get('content-length') || 0);
|
||||
if (size > 6*1024*1024) throw new ApiError('文件过大,请上传小于 5MB 的图片',413);
|
||||
body = request.headers.get('content-type')?.includes('multipart/form-data') ? await request.formData() : await request.json().catch(()=>({}));
|
||||
if (size > 1024*1024) throw new ApiError('请求内容过大',413);
|
||||
body = await request.json().catch(()=>({}));
|
||||
}
|
||||
const input = body && typeof body === 'object' && !Array.isArray(body) && !(body instanceof FormData) ? body as Record<string,unknown> : {};
|
||||
const input = body && typeof body === 'object' && !Array.isArray(body) ? body as Record<string,unknown> : {};
|
||||
if (establishesDevice) {
|
||||
device = deviceId(request.cookies.get(DEVICE)?.value) || deviceId(input.devID) || device;
|
||||
channel ||= text(input.channel).slice(0,100) || text(process.env.API_CHANNEL).slice(0,100);
|
||||
}
|
||||
let session: User | undefined;
|
||||
let result: Envelope<unknown>;
|
||||
let switching = false;
|
||||
let previousUserId = 0;
|
||||
let switchedDevice = '';
|
||||
let switchAccount = '';
|
||||
let switchPassword = '';
|
||||
if (path === 'auth/guest') {
|
||||
if (token) {
|
||||
const info = await call('auth/getInfo',{token,device,userAgent});
|
||||
@@ -114,71 +102,20 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
|
||||
session ||= await guest();
|
||||
result = {code:200,data:session};
|
||||
} else {
|
||||
if (path === 'auth/login' || path === 'auth/register') {
|
||||
const account = text(input[path === 'auth/login' ? 'account' : 'accountNo']);
|
||||
const password = typeof input.password === 'string' ? input.password : '';
|
||||
if (!account || !password) throw new ApiError('请填写账号和密码',400);
|
||||
// Binding keeps the guest. Switching must not: the upstream login finds
|
||||
// the current visitor by devID and would return that same account.
|
||||
switching = path === 'auth/login' && input.switchAccount === true;
|
||||
if (switching && token) {
|
||||
const info = await call('auth/getInfo',{token,device,userAgent});
|
||||
if (info.code === 200) previousUserId = userIdOf(info.data);
|
||||
}
|
||||
if (switching) switchedDevice = randomUUID();
|
||||
switchAccount = account;
|
||||
switchPassword = password;
|
||||
body = path === 'auth/login'
|
||||
? {account,password,devID:switchedDevice || device,channel}
|
||||
: {accountNo:account,password,email:text(input.email) || account+'@jgdj.local',devID:device,channel,...(text(input.agentInviteCode) ? {agentInviteCode:text(input.agentInviteCode).slice(0,100)} : {})};
|
||||
if (path === 'auth/login' || registered) token = '';
|
||||
} else if (path === 'auth/resetPassword') {
|
||||
const accountNo = text(input.accountNo);
|
||||
const email = text(input.email);
|
||||
const newPassword = typeof input.newPassword === 'string' ? input.newPassword : '';
|
||||
if (!accountNo || !email || !newPassword) throw new ApiError('请填写账号、绑定邮箱和新密码',400);
|
||||
body = {accountNo,email,newPassword};
|
||||
token = '';
|
||||
} else if (path === 'auth/profile') {
|
||||
const next: Record<string, unknown> = {};
|
||||
for (const key of ['nickName','avatar','introduction','oldPassword','newPassword'] as const) {
|
||||
if (typeof input[key] === 'string' && input[key]) next[key] = input[key];
|
||||
}
|
||||
if (typeof input.privacyMode === 'boolean') next.privacyMode = input.privacyMode;
|
||||
if (!Object.keys(next).length) throw new ApiError('请填写要修改的内容',400);
|
||||
body = next;
|
||||
} else if (path === 'feedback/add') {
|
||||
const content = typeof input.content === 'string' ? input.content.trim().slice(0,500) : '';
|
||||
let type = Number(input.type);
|
||||
if (type === 5) type = 4;
|
||||
if (!content) throw new ApiError('请填写问题描述',400);
|
||||
if (![1,2,3,4].includes(type)) throw new ApiError('请选择问题类型',400);
|
||||
const screenshots = Array.isArray(input.screenshots) ? input.screenshots.filter((item): item is string => typeof item === 'string' && !!item.trim()).map(item => item.trim()).slice(0,3) : [];
|
||||
const contact = text(input.contact).slice(0,120);
|
||||
body = {type,content,...(contact ? {contact} : {}),...(screenshots.length ? {screenshots} : {})};
|
||||
}
|
||||
result = await call(path+request.nextUrl.search,{method,body,token,device,userAgent});
|
||||
if (switching && result.code === 200 && previousUserId && userIdOf(result.data) === previousUserId) {
|
||||
body = {account:switchAccount,password:switchPassword,channel};
|
||||
result = await call(path+request.nextUrl.search,{method,body,token:'',device:switchedDevice,userAgent});
|
||||
}
|
||||
if (switching && result.code === 200) {
|
||||
if (previousUserId && userIdOf(result.data) === previousUserId) throw new ApiError('切换失败,请确认账号和密码',400);
|
||||
device = switchedDevice;
|
||||
}
|
||||
// Retry only an explicitly rejected guest request, once; never downgrade registered actions.
|
||||
if (expired(result.code) && !PUBLIC_PATHS.has(path) && !CREDENTIALS.has(path) && path !== 'auth/profile' && path !== 'feedback/add' && path !== 'upload/image' && !registered) {
|
||||
if (expired(result.code) && !PUBLIC_PATHS.has(path) && !registered) {
|
||||
session = await guest();
|
||||
token = session.token!;
|
||||
result = await call(path+request.nextUrl.search,{method,body,token,device,userAgent});
|
||||
}
|
||||
if (result.code === 200 && ['auth/login','auth/register','auth/getInfo'].includes(path)) {
|
||||
session = sessionUser(result.data,path === 'auth/getInfo' ? token : '',registered || CREDENTIALS.has(path));
|
||||
if (result.code === 200 && path === 'auth/getInfo') {
|
||||
session = sessionUser(result.data,token,registered);
|
||||
result = {...result,data:session};
|
||||
}
|
||||
}
|
||||
const data = result.data && typeof result.data === 'object' && 'token' in result.data ? {...result.data,token:undefined} : result.data;
|
||||
const isExpired = expired(result.code) && !CREDENTIALS.has(path);
|
||||
const isExpired = expired(result.code);
|
||||
const response = NextResponse.json({...result,data},{status:isExpired ? 401 : 200,headers});
|
||||
if (isExpired) {
|
||||
// A content or action failure must not erase a cookie that getInfo can still renew.
|
||||
@@ -197,7 +134,7 @@ async function handler(request: NextRequest, context: {params: Promise<{path: st
|
||||
return response;
|
||||
} catch (error) {
|
||||
const code = error instanceof ApiError ? error.code : 502;
|
||||
const isExpired = expired(code) && !CREDENTIALS.has(path);
|
||||
const isExpired = expired(code);
|
||||
const response = NextResponse.json({code,msg:error instanceof Error ? error.message : '请求失败',data:null},{status:isExpired ? 401 : code >= 400 && code <= 599 ? code : 502,headers});
|
||||
if (isExpired) {
|
||||
if (path === 'auth/getInfo' || path === 'auth/guest') clearSession(response);
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
export default function PageError({retry}:{retry:()=>void}) {
|
||||
return <main className="route-not-found">
|
||||
<title>页面暂不可用 - 禁果短剧</title>
|
||||
<title>页面暂不可用 - 黄果短剧</title>
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
<h1>页面暂不可用</h1>
|
||||
<p>内容服务暂时异常,请稍后重试</p>
|
||||
|
||||
@@ -183,11 +183,7 @@
|
||||
@media(max-width:1180px){.channel-shell .cards{grid-template-columns:repeat(3,minmax(0,1fr))}.road-grid{grid-template-columns:1fr}.channel-rank{gap:14px}}
|
||||
@media(max-width:760px){.mobile-pages{display:flex;gap:6px;overflow-x:auto;padding:12px 19px;border-bottom:1px solid var(--line);background:var(--bg)}.mobile-pages a{flex-shrink:0;font-size:12px;padding:8px 11px;border-radius:7px;color:var(--muted)}.mobile-pages a[aria-current=page]{color:var(--accent);background:#ec208314}.channel-shell{padding-top:22px!important}.channel-shell .cards{grid-template-columns:repeat(2,minmax(0,1fr));gap:22px 12px}.channel-topics{grid-template-columns:1fr}.channel-rank{grid-template-columns:24px 60px minmax(0,1fr);gap:12px;padding:16px}.channel-rank .score{grid-column:3;font-size:14px}.channel-rank .score small{display:inline;margin-left:8px}.channel-rank h2{font-size:16px}.channel-rank p.rank-desc{display:none}.channel-rank>.number{font-size:24px}.road-banner{padding:24px}.road-banner h2{font-size:24px}}
|
||||
|
||||
/* benefits-directory-styles */
|
||||
.benefits-directory{max-width:1400px}.benefits-directory>.channel-tabs{position:sticky;top:0;z-index:3;background:var(--bg);padding:14px 0;gap:12px;margin-bottom:16px}.benefits-directory>.channel-tabs button{border:1px solid transparent;padding:12px 20px;font-size:14px}.benefits-directory>.channel-tabs button[aria-pressed=true]{border-color:#ec20832c}.benefits-notice{font-size:12px;color:#80867d;margin:0 0 36px}.benefit-section{scroll-margin-top:96px;padding:0 0 34px;margin:0 0 32px;border-bottom:1px solid var(--line)}.benefit-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:24px}.benefit-heading h2{font-size:24px;letter-spacing:.3px;margin:0 0 8px}.benefit-heading p{font-size:14px;line-height:1.8;color:var(--muted);margin:0}.benefit-heading>span{flex-shrink:0;font-size:12px;color:#93998e;border:1px solid var(--line);padding:5px 10px;border-radius:20px}.benefit-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.benefit-grid>div{min-width:0}.benefit-card{display:flex;align-items:center;gap:16px;text-align:left;width:100%;height:116px;padding:20px;background:#1c1e1d;border:1px solid #ffffff0a;border-radius:14px;transition:background .2s,border-color .2s,transform .2s}.benefit-card:hover{background:#242622;border-color:#ec20834a;transform:translateY(-2px)}.benefit-icon{position:relative;display:grid;place-items:center;width:64px;height:64px;flex-shrink:0;border-radius:17px;background:linear-gradient(140deg,var(--app-color),#1b1e23);color:#fff5e7;overflow:hidden;box-shadow:inset 0 1px 0 #ffffff38,0 5px 14px #0003}.benefit-icon>span{position:relative;z-index:1;font:600 30px "Songti SC",serif}.benefit-icon:after{content:'';position:absolute;right:-10px;bottom:-14px;width:52px;height:52px;border:1px solid #ffffff3a;border-radius:50%}.benefit-icon i{position:absolute;width:40px;height:40px;border:1px solid #ffffff26;border-radius:50%;left:-14px;top:-9px}.benefit-copy{min-width:0;display:grid;gap:7px}.benefit-copy strong{font-size:16px;font-weight:650;color:var(--text)}.benefit-copy small{font-size:12px;color:#989e92;line-height:1.7}.benefit-arrow{margin-left:auto;color:#808878;font-size:18px}.benefit-card:hover .benefit-arrow{color:var(--accent)}.benefit-expand{display:block;margin:22px auto 0;padding:10px 28px;border:1px solid #ffffff18;border-radius:8px;color:#b5bbad;font-size:12px}.benefit-expand:hover{border-color:#ec208366}.benefit-pending{display:flex;align-items:center;gap:18px;border:1px dashed #ffffff20;border-radius:14px;padding:28px;background:#ffffff02}.benefit-pending>span{width:48px;height:48px;display:grid;place-items:center;border:1px solid var(--line);border-radius:12px;font-size:22px;color:#8c9385}.benefit-pending strong{font-size:14px;color:#a8ae9f;font-weight:500}.benefit-pending p{font-size:12px;color:#767d71;margin:7px 0 0}.benefit-detail{padding:44px;text-align:center}.benefit-detail>.benefit-icon{margin:0 auto 22px;width:80px;height:80px;border-radius:22px}.benefit-detail-label{font-size:12px;color:var(--accent);letter-spacing:2px}.benefit-detail h2{font-size:28px;margin:10px 0}.benefit-detail>p:not(.benefit-detail-label){font-size:14px;color:var(--muted)}.benefit-detail-note{padding:18px;background:#ffffff04;border-radius:10px;font-size:12px;line-height:1.9;color:#a1a899;margin:26px 0 20px}.benefit-disabled{background:#ffffff08;color:#747b6c;padding:12px 22px;border-radius:8px;cursor:not-allowed}
|
||||
|
||||
@media(max-width:1180px){.benefit-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.benefit-card{gap:13px;padding:16px}.benefits-directory>.channel-tabs{gap:6px}.benefits-directory>.channel-tabs button{font-size:12px;padding:10px 12px}}
|
||||
@media(max-width:760px){.benefits-directory>.channel-tabs{flex-wrap:nowrap;overflow-x:auto;margin-left:-19px;margin-right:-19px;padding-left:19px;padding-right:19px}.benefits-directory>.channel-tabs button{flex-shrink:0}.benefit-section{scroll-margin-top:82px}.benefit-heading{align-items:flex-start}.benefit-heading h2{font-size:20px}.benefit-heading p{font-size:12px}.benefit-heading>span{display:none}.benefit-grid{gap:12px}.benefit-card{height:158px;flex-direction:column;justify-content:flex-start;gap:10px;text-align:center;padding:16px 10px}.benefit-icon{width:54px;height:54px;border-radius:15px}.benefit-icon>span{font-size:26px}.benefit-copy{gap:4px}.benefit-copy strong{font-size:14px}.benefit-copy small{font-size:10px}.benefit-arrow{display:none}.benefit-detail{padding:36px 22px}.benefits-notice{line-height:1.8;margin-bottom:28px}}
|
||||
|
||||
/* remote-placeholder-covers */
|
||||
/* Placeholder cover source: https://hongguoduanju.com/ */
|
||||
@@ -201,7 +197,6 @@
|
||||
@media(max-width:760px){.hero-art{width:100%;opacity:.65}.dialog-art{background-position:center 24%!important}}
|
||||
|
||||
/* tencent-navigation-icons */
|
||||
.side>nav>a .qq-nav-icon{display:block;width:24px;height:24px;flex:0 0 24px;background-color:currentColor;-webkit-mask-image:var(--qq-icon);mask-image:var(--qq-icon);-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-position:center;mask-position:center;-webkit-mask-size:contain;mask-size:contain}.side>nav>a.active .qq-nav-icon{-webkit-mask-image:var(--qq-icon-selected);mask-image:var(--qq-icon-selected)}.side>nav>a[data-page="rank"] .qq-nav-icon{-webkit-mask-size:32px 32px;mask-size:32px 32px}.side>nav>a[data-page="home"]{--qq-icon:url("/assets/19ed41602046c136.svg");--qq-icon-selected:url("/assets/813abcae48078ac7.svg")}.side>nav>a[data-page="shorts"]{--qq-icon:url("/assets/f3d55d5dd5a321d3.png");--qq-icon-selected:url("/assets/7d05c412943babef.png")}.side>nav>a[data-page="series"]{--qq-icon:url("/assets/8c1c285ac6bd3b66.svg");--qq-icon-selected:url("/assets/49cffe02110af446.svg")}.side>nav>a[data-page="topics"]{--qq-icon:url("/assets/9903882b2da0df62.svg");--qq-icon-selected:url("/assets/bf82634dbd0f4907.svg")}.side>nav>a[data-page="rank"]{--qq-icon:url("/assets/c80f072eec53a3ca.svg");--qq-icon-selected:url("/assets/c80f072eec53a3ca.svg")}.side>nav>a[data-page="home-road"]{--qq-icon:url("/assets/350ab2f3118693a8.svg");--qq-icon-selected:url("/assets/350ab2f3118693a8.svg")}.side>nav>a[data-page="apps"]{--qq-icon:url("/assets/16bd37861fd6246c.svg");--qq-icon-selected:url("/assets/16bd37861fd6246c.svg")}
|
||||
|
||||
/* portrait-cover-ratio */
|
||||
/* All portrait poster frames use 3:4. Landscape modules retain their sizing. */
|
||||
@@ -693,7 +688,6 @@ html:not(.sidebar-collapsed) .side .sidebar-header>.sidebar-logo-toggle .brand-w
|
||||
@media(max-width:760px){#twist-cards{grid-template-columns:repeat(2,minmax(0,1fr));gap:22px 12px}}
|
||||
.side>nav>a[data-page="rank"]{--qq-icon-selected:url("/assets/2699fa102e73d8c8.svg")}
|
||||
.side>nav>a[data-page="home-road"]{--qq-icon-selected:url("/assets/92fc7fda98f7db04.svg")}
|
||||
.side>nav>a[data-page="apps"]{--qq-icon-selected:url("/assets/3482d6856aee853d.svg")}
|
||||
html .side>nav>a[data-page="rank"].active .qq-nav-icon{-webkit-mask-size:contain;mask-size:contain}
|
||||
|
||||
/* martial-two-line-description */
|
||||
@@ -737,7 +731,6 @@ html .side>nav>a[data-page="rank"].active .qq-nav-icon{-webkit-mask-size:contain
|
||||
/* consistent-navigation-icons */
|
||||
.side>nav>a[data-page="rank"].active .qq-nav-icon,
|
||||
.side>nav>a[data-page="home-road"].active .qq-nav-icon,
|
||||
.side>nav>a[data-page="apps"].active .qq-nav-icon{-webkit-mask-image:var(--qq-icon);mask-image:var(--qq-icon)}
|
||||
html .side>nav>a[data-page="rank"].active .qq-nav-icon{-webkit-mask-size:32px 32px;mask-size:32px 32px}
|
||||
html:not(.sidebar-collapsed) .side>nav>a[data-page="rank"].active .qq-nav-icon{-webkit-mask-size:37px 37px;mask-size:37px 37px}
|
||||
|
||||
@@ -949,35 +942,7 @@ html:not(.sidebar-collapsed) .side .sidebar-header>.sidebar-logo-toggle{height:4
|
||||
@media(max-width:1000px){#channel-page[data-channel="rank"] #channel-content .channel-ranking-cards{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||||
@media(max-width:600px){#channel-page[data-channel="rank"] #channel-content .channel-ranking-cards{grid-template-columns:repeat(2,minmax(0,1fr));gap:22px 12px}.rank-home-badge{min-width:38px;height:32px;font-size:20px;border-radius:8px 8px 8px 0}}
|
||||
|
||||
/* benefits-reference-layout */
|
||||
#channel-page[data-channel="apps"] .benefits-directory{max-width:none}
|
||||
#channel-page[data-channel="apps"] .benefits-directory>.channel-tabs{position:static;gap:14px;padding:0;border:0;margin-bottom:34px;background:transparent}
|
||||
#channel-page[data-channel="apps"] .channel-tabs button{border:1px solid #ffffff0c;border-radius:9px;background:#202022;padding:12px 22px;font-size:16px;color:#99999f!important}
|
||||
#channel-page[data-channel="apps"] .channel-tabs button[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:white!important}
|
||||
#channel-page[data-channel="apps"] .benefit-section{padding:0;margin:0 0 42px;border:0;scroll-margin-top:28px}
|
||||
#channel-page[data-channel="apps"] .benefit-heading{margin-bottom:20px}
|
||||
#channel-page[data-channel="apps"] .benefit-heading h2{font-size:22px;margin:0 0 9px}
|
||||
#channel-page[data-channel="apps"] .benefit-heading p{font-size:14px;color:#85858e;margin:0}
|
||||
#channel-page[data-channel="apps"] .benefit-grid{display:grid;grid-template-columns:repeat(10,minmax(0,1fr));gap:22px;max-width:1440px}
|
||||
#channel-page[data-channel="apps"] .benefit-grid>[hidden]{display:none}
|
||||
#channel-page[data-channel="apps"] .benefit-card{display:flex;flex-direction:column;align-items:center;gap:10px;width:100%;height:auto;min-height:0;min-width:0;padding:0;border:0;border-radius:0;background:transparent;box-shadow:none;text-align:center}
|
||||
#channel-page[data-channel="apps"] .benefit-card:hover{background:transparent;transform:none}
|
||||
#channel-page[data-channel="apps"] .benefit-icon{width:100%;height:auto;max-width:110px;aspect-ratio:1;flex:none;border-radius:22%;overflow:hidden;background:transparent;box-shadow:none}
|
||||
.benefit-icon>img{display:block;width:100%;height:100%;object-fit:cover;transition:transform .25s}
|
||||
.benefit-card:hover .benefit-icon>img{transform:scale(1.08)}
|
||||
#channel-page[data-channel="apps"] .benefit-copy{display:block;flex:none;min-width:0;width:100%}
|
||||
#channel-page[data-channel="apps"] .benefit-copy strong{display:block;font-size:14px;font-weight:500;line-height:1.5;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#ddd}
|
||||
#channel-page[data-channel="apps"] .benefit-copy small{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:12px;line-height:1.5;margin-top:4px;color:#818188}
|
||||
#channel-page[data-channel="apps"] .benefit-card:hover strong{color:var(--accent)}
|
||||
#channel-page[data-channel="apps"] .benefit-expand{display:block;position:static;clear:both;margin:28px auto 0;padding:8px 18px;border:0;border-radius:999px;background:transparent;font-size:14px;color:#9898a0!important}
|
||||
#channel-page[data-channel="apps"] .benefit-expand:hover{color:var(--accent)!important;background:#ffffff05}
|
||||
@media(max-width:1100px){#channel-page[data-channel="apps"] .benefit-grid{grid-template-columns:repeat(5,minmax(0,1fr));gap:22px 18px}}
|
||||
@media(max-width:600px){#channel-page[data-channel="apps"] .benefit-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:20px 14px}#channel-page[data-channel="apps"] .channel-tabs button{padding:10px 14px;font-size:14px}#channel-page[data-channel="apps"] .benefit-copy strong{font-size:12px}#channel-page[data-channel="apps"] .benefit-heading h2{font-size:18px}}
|
||||
@media(prefers-reduced-motion:reduce){.benefit-icon>img{transition:none}.benefit-card:hover .benefit-icon>img{transform:none}}
|
||||
|
||||
/* benefits-two-visible-rows */
|
||||
@media(max-width:1100px){#channel-page[data-channel="apps"] .benefit-grid{grid-template-columns:repeat(var(--compact-app-columns),minmax(0,1fr))}}
|
||||
@media(max-width:600px){#channel-page[data-channel="apps"] .benefit-grid{gap:20px 10px}}
|
||||
|
||||
/* home-road-directory-style */
|
||||
.return-home-directory{display:flex;flex-direction:column;gap:36px;padding-bottom:36px}.return-hero{padding:38px 40px;border:1px solid #ec208325;border-radius:22px;background:radial-gradient(ellipse at top left,#ec208320,transparent 70%),#19191d}.return-eyebrow{font-size:14px;color:#f276b0;letter-spacing:2px}.return-hero h2{font-size:30px;line-height:1.4;margin:15px 0 12px}.return-hero p{font-size:14px;color:#9b98a2;margin:0 0 24px}.return-actions,.return-card-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.return-actions a,.return-actions button{display:inline-flex;gap:18px;padding:13px 24px;border-radius:999px;border:1px solid #ffffff18;background:#ffffff05;font-size:14px;color:white}.return-actions a{background:var(--accent);border-color:var(--accent)}.return-actions a:hover{filter:brightness(1.1)}.return-actions button:hover{background:#ffffff10}
|
||||
@@ -1947,22 +1912,7 @@ html{scroll-padding-top:calc(var(--global-header-height) + 20px)}
|
||||
#drama-play-page .play-related .drama-meta{margin:0}
|
||||
}
|
||||
|
||||
/* mobile-benefits-directory-layout */
|
||||
@media(max-width:760px){
|
||||
#channel-page[data-channel="apps"] .benefits-directory{width:100%;min-width:0}
|
||||
#channel-page[data-channel="apps"] .benefits-directory>.channel-tabs{display:flex;flex-wrap:nowrap;width:100%;min-width:0;box-sizing:border-box;margin:0 0 24px;padding:8px 0;gap:8px;overflow-x:auto;scrollbar-width:none;top:var(--global-header-height);background:transparent;backdrop-filter:none}
|
||||
#channel-page[data-channel="apps"] .benefits-directory>.channel-tabs::-webkit-scrollbar{display:none}
|
||||
#channel-page[data-channel="apps"] .benefits-directory>.channel-tabs button{flex:0 0 auto;min-height:36px;padding:8px 12px;font-size:12px;line-height:18px;white-space:nowrap;border-radius:999px}
|
||||
#channel-page[data-channel="apps"] .benefit-heading{margin-bottom:16px}
|
||||
#channel-page[data-channel="apps"] .benefit-heading h2{font-size:18px;margin-bottom:6px}
|
||||
#channel-page[data-channel="apps"] .benefit-heading p{font-size:12px;line-height:1.7}
|
||||
#channel-page[data-channel="apps"] .benefit-section{margin-bottom:28px;scroll-margin-top:calc(var(--global-header-height) + 64px)}
|
||||
#channel-page[data-channel="apps"] .benefit-grid{grid-template-columns:repeat(4,minmax(0,1fr));gap:20px 10px}
|
||||
#channel-page[data-channel="apps"] .benefit-card{box-sizing:border-box;height:100%;padding:0;gap:8px;border:0;border-radius:0;background:transparent;justify-content:flex-start}
|
||||
#channel-page[data-channel="apps"] .benefit-card:hover{background:transparent}
|
||||
#channel-page[data-channel="apps"] .benefit-icon{width:100%;height:auto;max-width:72px;aspect-ratio:1;flex:none}
|
||||
#channel-page[data-channel="apps"] .benefit-copy strong{font-size:12px;line-height:1.5;white-space:normal;overflow-wrap:anywhere;text-overflow:clip}
|
||||
#channel-page[data-channel="apps"] .benefit-copy small{display:block;font-size:12px;line-height:1.6;margin-top:5px;white-space:normal;overflow:visible;-webkit-line-clamp:unset}
|
||||
}
|
||||
|
||||
/* mobile-member-ad-scroll */
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* 黄果短剧 media UI: tall gallery card, cinematic detail hero, film-strip player page.
|
||||
* Loaded after globals.css; selectors are scoped to new class names.
|
||||
*/
|
||||
|
||||
/* ---------- brand logo (PNG 444×108) ---------- */
|
||||
#global-site-header .top>.hg-header-brand .hg-complete-logo{width:164px;height:40px;aspect-ratio:444/108;object-fit:contain}
|
||||
.jg-masthead-brand img{width:222px;height:54px;object-fit:contain}
|
||||
.side-info .side-info-wordmark,#mobile-navigation-drawer .side-info-wordmark{width:110px;height:auto;aspect-ratio:444/108}
|
||||
.entry-brand-logo{aspect-ratio:444/108}
|
||||
.side .sidebar-header .sidebar-brand{object-position:left center}
|
||||
|
||||
/* ---------- card: 9:16 gallery cover ---------- */
|
||||
.hg-card{display:flex;flex-direction:column;min-width:0;color:inherit;text-decoration:none;outline:none;-webkit-tap-highlight-color:transparent}
|
||||
.hg-card-cover{position:relative;display:block;aspect-ratio:9/16;border-radius:16px;overflow:hidden;background:#1b1c1c}
|
||||
.hg-card-cover::after{content:"";position:absolute;inset:0;z-index:3;border-radius:inherit;box-shadow:inset 0 0 0 1px rgba(255,255,255,.09);pointer-events:none;transition:box-shadow .18s ease}
|
||||
.hg-card-cover>.ranking-poster-frame{position:absolute;inset:0;display:block;width:100%;height:100%}
|
||||
.hg-card-cover>.poster-source,.hg-card-cover>.ranking-poster-frame>.poster-source{position:absolute;inset:0;width:100%;height:100%;max-width:none;object-fit:cover;object-position:center top;transform:none!important;transition:none!important}
|
||||
.hg-card:hover .hg-card-cover::after,.hg-card:focus-visible .hg-card-cover::after{box-shadow:inset 0 0 0 3px var(--accent,#ec2083)}
|
||||
.hg-card-counter{position:absolute;top:8px;right:8px;z-index:2;padding:2px 7px;border-radius:6px;background:rgba(12,12,12,.55);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:#fff;font:600 11px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.03em;font-variant-numeric:tabular-nums}
|
||||
.hg-card-badge{position:absolute;left:0;bottom:0;z-index:2;display:block;line-height:0}
|
||||
.hg-card-badge .cover-corner-badge{position:static;inset:auto;display:block;width:auto;height:auto;min-width:0;margin:0;padding:4px 9px;border:0;border-radius:0;box-shadow:none;transform:none;clip-path:none;background:var(--accent,#ec2083);color:#fff;font-size:11px;font-weight:700;line-height:1.4;letter-spacing:.06em}
|
||||
.hg-card-badge .cover-corner-badge::before,.hg-card-badge .cover-corner-badge::after{content:none;display:none}
|
||||
.hg-card-badge .cover-corner-badge--hot{background:#ff5b1f}
|
||||
.hg-card-badge .cover-corner-badge--new{background:#13a77a}
|
||||
.hg-card-title{margin:10px 0 0;color:#ececec;font-size:14px;font-weight:600;line-height:1.45;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;line-clamp:2;overflow:hidden;min-height:2.9em;overflow-wrap:anywhere;transition:color .18s ease}
|
||||
.hg-card:hover .hg-card-title,.hg-card:focus-visible .hg-card-title{color:var(--accent,#ec2083)}
|
||||
.hg-card-title .search-keyword{color:var(--accent,#ec2083)}
|
||||
.hg-card-rule{display:block;height:0;margin:8px 0 7px;border-top:1px dotted rgba(255,255,255,.22)}
|
||||
.hg-card-meta{display:flex;align-items:center;gap:6px;min-width:0;color:var(--muted,#92958f);font-size:12px;line-height:1.4}
|
||||
.hg-card-views{display:inline-flex;align-items:center;gap:4px;flex:0 0 auto;font-variant-numeric:tabular-nums}
|
||||
.hg-card-views svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.8}
|
||||
.hg-card-dot{opacity:.6}
|
||||
.hg-card-theme{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
|
||||
/* ---------- detail: cinematic hero ---------- */
|
||||
#drama-play-page .cine-hero{position:relative;isolation:isolate;display:flex;align-items:stretch;min-height:420px;border-radius:20px;overflow:hidden;background:#0b0c0c;box-shadow:inset 0 0 0 1px rgba(255,255,255,.06)}
|
||||
#drama-play-page .cine-hero-art{position:absolute;top:0;right:0;bottom:0;width:48%;z-index:0}
|
||||
#drama-play-page .cine-hero-image{display:block;width:100%;height:100%;max-width:none;object-fit:cover;object-position:center 22%;-webkit-mask-image:linear-gradient(90deg,transparent 0,rgba(0,0,0,.55) 22%,#000 52%);mask-image:linear-gradient(90deg,transparent 0,rgba(0,0,0,.55) 22%,#000 52%)}
|
||||
#drama-play-page .cine-hero::after{content:"";position:absolute;inset:auto 0 0 0;height:90px;z-index:0;background:linear-gradient(180deg,transparent,rgba(11,12,12,.65));pointer-events:none}
|
||||
#drama-play-page .cine-hero-copy{position:relative;z-index:1;display:flex;flex-direction:column;justify-content:center;width:62%;max-width:720px;padding:44px 48px;box-sizing:border-box}
|
||||
#drama-play-page .cine-kicker{display:block;color:var(--accent,#ec2083);font-size:12px;font-weight:700;letter-spacing:.24em;line-height:1.4}
|
||||
#drama-play-page .cine-hero h1{margin:12px 0 16px;color:#fff;font-size:clamp(34px,4.1vw,58px);font-weight:800;line-height:1.08;letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||
#drama-play-page .cine-facts{display:flex;flex-wrap:wrap;align-items:center;gap:4px 10px;margin:0;color:#cfcfcf;font-size:14px;line-height:1.6}
|
||||
#drama-play-page .cine-facts i{font-style:normal;color:#666}
|
||||
#drama-play-page .cine-tags{display:flex;flex-wrap:wrap;gap:4px 14px;margin:12px 0 0}
|
||||
#drama-play-page .cine-tags a,#drama-play-page .cine-tags span{color:#9fa39f;font-size:13px;text-decoration:none}
|
||||
#drama-play-page .cine-tags a:hover{color:var(--accent,#ec2083)}
|
||||
#drama-play-page .cine-hero .series-description-wrap{max-width:560px;margin:16px 0 24px}
|
||||
#drama-play-page .cine-hero .series-description{color:#a9a9a9}
|
||||
#drama-play-page .cine-actions{display:flex;align-items:center;flex-wrap:wrap;gap:14px 26px}
|
||||
#drama-play-page .cine-play{display:inline-flex;align-items:center;gap:10px;height:48px;padding:0 28px;border-radius:12px;background:var(--accent,#ec2083);color:#fff;font-size:16px;font-weight:700;text-decoration:none;box-shadow:0 10px 26px -12px var(--accent,#ec2083)}
|
||||
#drama-play-page .cine-play svg{width:18px;height:18px;fill:currentColor}
|
||||
#drama-play-page .cine-play:hover{filter:brightness(1.08)}
|
||||
#drama-play-page .cine-download{display:inline-flex;align-items:center;gap:6px;padding-bottom:2px;border-bottom:1px solid rgba(255,255,255,.3);color:#eee;font-size:15px;text-decoration:none;cursor:pointer}
|
||||
#drama-play-page .cine-download[hidden]{display:none}
|
||||
#drama-play-page .cine-download svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:2;transition:transform .18s}
|
||||
#drama-play-page .cine-download:hover{color:var(--accent,#ec2083);border-color:currentColor}
|
||||
#drama-play-page .cine-download:hover svg{transform:translateX(3px)}
|
||||
#drama-play-page .series-unavailable{color:#888;font-size:14px}
|
||||
|
||||
/* small outlined text buttons (like / collect / share) */
|
||||
#drama-play-page .cine-engage.play-detail-actions{display:flex;flex-wrap:wrap;gap:8px;width:auto;margin:20px 0 0}
|
||||
#drama-play-page .cine-engage.play-detail-actions>button{display:inline-flex;flex:0 0 auto;width:auto;min-width:0;max-width:none;height:30px;min-height:0;padding:0 12px;gap:0;border:1px solid rgba(255,255,255,.24);border-radius:6px;background:transparent;color:#cfcfcf;font-size:12px;line-height:1}
|
||||
#drama-play-page .cine-engage.play-detail-actions .play-action-icon{display:none}
|
||||
#drama-play-page .cine-engage.play-detail-actions>button:hover{border-color:var(--accent,#ec2083)}
|
||||
#drama-play-page .cine-engage.play-detail-actions>button[aria-pressed="true"]{background:transparent!important;border-color:var(--accent,#ec2083)!important;color:var(--accent,#ec2083)!important}
|
||||
#drama-play-page .cine-engage.play-detail-actions>button[aria-pressed="true"]>span{color:var(--accent,#ec2083)!important}
|
||||
|
||||
/* ---------- play: player, edge arrows, app gate ---------- */
|
||||
#drama-play-page .play-stage{position:relative}
|
||||
#drama-play-page .play-edge{position:absolute;top:50%;z-index:6;display:grid;place-items:center;width:46px;height:46px;margin-top:-23px;border-radius:50%;background:rgba(10,10,10,.45);border:1px solid rgba(255,255,255,.2);color:#fff;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);opacity:.85;transition:background .18s,opacity .18s}
|
||||
#drama-play-page .play-edge--prev{left:14px}
|
||||
#drama-play-page .play-edge--next{right:14px}
|
||||
#drama-play-page .play-edge svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-linejoin:round}
|
||||
#drama-play-page .play-edge:hover,#drama-play-page .play-edge:focus-visible{background:var(--accent,#ec2083);border-color:transparent;opacity:1}
|
||||
#drama-play-page .play-app-gate{position:absolute;inset:0;z-index:5;display:grid;place-items:center;padding:24px;text-align:center;background:radial-gradient(circle at 50% 42%,rgba(236,32,131,.2),rgba(0,0,0,.88) 68%)}
|
||||
#drama-play-page .play-app-gate[hidden]{display:none}
|
||||
#drama-play-page .play-app-gate-copy[hidden],#drama-play-page .play-app-gate-fallback[hidden]{display:none}
|
||||
#drama-play-page .play-app-gate-copy strong{display:block;color:#fff;font-size:22px;font-weight:800;line-height:1.35}
|
||||
#drama-play-page .play-app-gate-copy p{margin:8px 0 18px;color:#bdbdbd;font-size:14px}
|
||||
#drama-play-page .play-app-gate-btn{display:inline-flex;align-items:center;gap:8px;height:44px;padding:0 24px;border-radius:12px;background:var(--accent,#ec2083);color:#fff;font-size:15px;font-weight:700;text-decoration:none}
|
||||
#drama-play-page .play-app-gate-btn svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2}
|
||||
#drama-play-page .play-app-gate-fallback{margin:0;color:#ddd;font-size:15px}
|
||||
#drama-play-page .play-screen.is-app-gated .play-screen-controls{display:none}
|
||||
|
||||
/* headline under the player: outlined episode number */
|
||||
#drama-play-page .play-headline{display:grid;grid-template-columns:auto minmax(0,1fr);align-items:start;gap:30px;padding:28px 0 26px;border-bottom:1px solid #ffffff12}
|
||||
#drama-play-page .play-headline-mark{padding-top:4px;color:transparent;-webkit-text-stroke:2px var(--accent,#ec2083);font:900 112px/.82 "Arial Black",Impact,"Helvetica Neue",system-ui,sans-serif;letter-spacing:-.04em;font-variant-numeric:tabular-nums;user-select:none}
|
||||
#drama-play-page .play-headline-mark.is-word{font-size:62px;letter-spacing:.02em;padding-top:10px}
|
||||
#drama-play-page .play-headline h1{margin:8px 0 10px;color:#fff;font-size:28px;line-height:1.3}
|
||||
#drama-play-page .play-headline .series-description-wrap{margin:14px 0 0;max-width:820px}
|
||||
|
||||
/* film-strip episode selector */
|
||||
#drama-play-page .play-episodes-section{margin-top:28px}
|
||||
#drama-play-page .film-strip{position:relative;display:flex;gap:10px;overflow-x:auto;overscroll-behavior-x:contain;scroll-snap-type:x mandatory;scroll-padding:0 14px;padding:20px 14px;border-radius:10px;background-color:#0a0a0b;background-image:radial-gradient(circle,#2f3033 2.6px,transparent 3.2px),radial-gradient(circle,#2f3033 2.6px,transparent 3.2px);background-size:18px 10px,18px 10px;background-repeat:repeat-x;background-position:4px 3px,4px calc(100% - 3px);background-attachment:local;scrollbar-width:thin;scrollbar-color:#3a3a3d transparent}
|
||||
#drama-play-page .film-strip::-webkit-scrollbar{height:6px}
|
||||
#drama-play-page .film-strip::-webkit-scrollbar-thumb{background:#3a3a3d;border-radius:3px}
|
||||
#drama-play-page .film-tile{position:relative;flex:0 0 56px;display:grid;place-items:center;width:56px;height:56px;box-sizing:border-box;scroll-snap-align:center;border:1px solid #ffffff12;border-radius:6px;background:#1e1f20;color:#ddd;font:700 16px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;text-decoration:none;transition:border-color .15s,color .15s}
|
||||
#drama-play-page .film-tile:hover,#drama-play-page .film-tile:focus-visible{border-color:var(--accent,#ec2083);color:#fff}
|
||||
#drama-play-page .film-tile.is-current{background:var(--accent,#ec2083);border-color:var(--accent,#ec2083);color:#fff}
|
||||
#drama-play-page .film-tile.is-locked{opacity:.5}
|
||||
#drama-play-page .film-tile.is-locked.is-current{opacity:1}
|
||||
#drama-play-page .film-tile-lock{position:absolute;top:4px;right:4px;width:12px;height:12px;fill:none;stroke:currentColor;stroke-width:2.2}
|
||||
|
||||
/* recommendations */
|
||||
#drama-play-page .play-recommend{margin-top:36px}
|
||||
#drama-play-page .recommend-heading{position:relative;margin-bottom:20px}
|
||||
#drama-play-page .recommend-outline{display:block;overflow:hidden;white-space:nowrap;color:transparent;-webkit-text-stroke:1.5px rgba(255,255,255,.2);font:900 66px/1 "Arial Black",Impact,"Helvetica Neue",system-ui,sans-serif;letter-spacing:.03em;user-select:none}
|
||||
#drama-play-page .recommend-heading h2{position:absolute;left:6px;top:16px;margin:0;color:#fff;font-size:26px;font-weight:800;line-height:1.3}
|
||||
#drama-play-page .recommend-heading p{margin:6px 0 0;color:var(--muted,#92958f);font-size:14px}
|
||||
#drama-play-page .recommend-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:24px 16px}
|
||||
#drama-play-page .play-official{margin-top:32px}
|
||||
|
||||
@media (max-width:1200px){
|
||||
#drama-play-page .recommend-grid{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
}
|
||||
@media (max-width:900px){
|
||||
#drama-play-page .recommend-grid{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
#drama-play-page .cine-hero-copy{width:70%;padding:36px 32px}
|
||||
}
|
||||
@media (max-width:760px){
|
||||
#drama-play-page .cine-hero{display:block;min-height:0;border-radius:0;background:transparent;box-shadow:none;overflow:visible}
|
||||
#drama-play-page .cine-hero::after{content:none}
|
||||
#drama-play-page .cine-hero-art{position:relative;width:auto;aspect-ratio:16/9;margin:0 -12px;overflow:hidden}
|
||||
#drama-play-page .cine-hero-image{object-position:center 18%;-webkit-mask-image:linear-gradient(180deg,#000 45%,transparent 100%);mask-image:linear-gradient(180deg,#000 45%,transparent 100%)}
|
||||
#drama-play-page .cine-hero-copy{width:auto;max-width:none;margin-top:-28px;padding:0}
|
||||
#drama-play-page .cine-hero h1{margin:8px 0 10px;font-size:28px}
|
||||
#drama-play-page .cine-actions{gap:12px 20px}
|
||||
#drama-play-page .cine-play{height:44px;padding:0 22px;font-size:15px}
|
||||
#drama-play-page .play-edge{width:36px;height:36px;margin-top:-18px}
|
||||
#drama-play-page .play-edge--prev{left:8px}
|
||||
#drama-play-page .play-edge--next{right:8px}
|
||||
#drama-play-page .play-app-gate{padding:12px 54px}
|
||||
#drama-play-page .play-app-gate-copy strong{font-size:16px}
|
||||
#drama-play-page .play-app-gate-copy p{margin:6px 0 12px;font-size:12px}
|
||||
#drama-play-page .play-app-gate-btn{height:38px;padding:0 16px;font-size:13px}
|
||||
#drama-play-page .play-headline{gap:16px;padding:20px 0}
|
||||
#drama-play-page .play-headline-mark{font-size:64px;-webkit-text-stroke-width:1.5px}
|
||||
#drama-play-page .play-headline-mark.is-word{font-size:34px}
|
||||
#drama-play-page .play-headline h1{font-size:20px}
|
||||
#drama-play-page .recommend-outline{font-size:44px}
|
||||
#drama-play-page .recommend-heading h2{top:8px;font-size:20px}
|
||||
.hg-card-cover{border-radius:12px}
|
||||
}
|
||||
@media (max-width:560px){
|
||||
#drama-play-page .recommend-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 12px}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
@@ -1,32 +0,0 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_42558_134113)">
|
||||
<path d="M-0.0055542 19.984C-0.0055542 8.93831 8.94875 -0.0159912 19.9944 -0.0159912V-0.0159912C31.0401 -0.0159912 39.9944 8.93831 39.9944 19.984V19.984C39.9944 31.0297 31.0401 39.984 19.9944 39.984V39.984C8.94875 39.984 -0.0055542 31.0297 -0.0055542 19.984V19.984Z" fill="url(#paint0_linear_42558_134113)"/>
|
||||
<path d="M34.9795 20.2336C34.9651 20.0651 34.9542 19.8931 34.9421 19.7241L34.9358 19.682C34.9231 19.3903 34.8054 18.875 34.7423 18.5923L34.7292 18.5587C34.4771 17.3099 33.6857 16.01 32.8351 15.082C32.3404 14.5425 31.7519 14.0335 31.1963 13.5545C28.6032 11.2953 25.6656 9.45862 22.4934 8.11303C20.9669 7.48079 19.8656 7.11202 18.2159 7.00627C17.9724 7.00179 17.7289 7 17.4849 7C16.8459 7.0242 16.1103 7.1053 15.4776 7.24913C13.6755 7.65913 11.9186 8.63281 10.6423 9.9645C10.0998 10.5309 9.58342 11.199 9.1816 11.8684C7.52878 14.621 6.8857 18.1034 7.01603 21.279C7.1662 24.9353 8.31483 28.9425 11.0969 31.4867C12.9806 33.1943 15.4668 34.0945 18.0148 33.9919C19.8372 33.9149 21.2992 33.3825 22.9624 32.6719C26.0962 31.3379 28.9825 29.4919 31.502 27.2102C32.5704 26.2366 33.5341 25.3189 34.1786 23.9961L34.3454 23.6377C34.7653 22.6107 34.8731 22.0161 34.9772 20.9107C34.9849 20.8892 34.9849 20.8829 34.9948 20.8417C34.9975 20.7014 35.0102 20.3568 34.98 20.2336H34.9795ZM27.4253 21.1329C27.1637 21.568 25.6385 22.8831 25.165 23.2062C24.9445 23.3984 24.6067 23.6466 24.3654 23.8169C22.9061 24.843 21.3331 25.6993 19.6775 26.3692C19.2631 26.539 18.6475 26.7823 18.2065 26.8697C17.8764 26.936 17.5368 26.9423 17.2044 26.8885C15.9268 26.6689 15.2612 25.7163 14.8133 24.6006C14.0616 22.7276 13.9349 20.4922 14.2853 18.5184C14.3926 17.7759 14.6889 16.9174 15.0118 16.2376C15.1935 15.9168 15.3505 15.6368 15.589 15.3513C16.2461 14.5681 17.2946 14.2688 18.2872 14.4543C18.7224 14.5354 19.1661 14.7294 19.5765 14.9006C20.5173 15.2881 21.4318 15.7353 22.3148 16.2403C22.4745 16.3304 22.6594 16.4357 22.8104 16.5401C24.1485 17.3404 25.3882 18.3024 26.5553 19.3307C26.8151 19.5597 27.1105 19.7819 27.3242 20.0548C27.4117 20.1664 27.4776 20.2869 27.5127 20.4245C27.5736 20.662 27.5533 20.9192 27.4257 21.1316L27.4253 21.1329Z" fill="white"/>
|
||||
<g filter="url(#filter0_d_42558_134113)">
|
||||
<path d="M20.5 28.7835C25.0621 28.7835 28.7604 25.0852 28.7604 20.5232C28.7604 15.9611 25.0621 12.2628 20.5 12.2628C15.938 12.2628 12.2397 15.9611 12.2397 20.5232C12.2397 25.0852 15.938 28.7835 20.5 28.7835Z" fill="#0F0F0F"/>
|
||||
<path d="M16.6111 24.3571V17.1369L17.0821 17.7465L15.2763 18.6624L14.9065 17.212L17.3334 15.9523H18.2493V24.3542H16.6082L16.6111 24.3571ZM15.126 24.9783V23.4527H19.7777V24.9783H15.126Z" fill="white"/>
|
||||
<path d="M23.5916 25.016C23.0224 25.016 22.5052 24.9004 22.0343 24.6664C21.5633 24.4323 21.1877 24.1203 20.9103 23.7245C20.6301 23.3315 20.4914 22.8924 20.4914 22.4099C20.4914 22.1209 20.5318 21.8551 20.6127 21.6095C20.6936 21.364 20.8121 21.1444 20.9681 20.9479C21.1241 20.7543 21.3148 20.5781 21.5402 20.4192C21.7656 20.2632 22.0198 20.1245 22.3087 20.006L22.2076 20.3614C21.988 20.2689 21.7858 20.1591 21.6038 20.032C21.4217 19.9049 21.2657 19.7575 21.1328 19.5929C20.9999 19.4282 20.9017 19.249 20.8352 19.0583C20.7688 18.8677 20.7341 18.6654 20.7341 18.4545C20.7341 17.998 20.8641 17.5848 21.1212 17.2208C21.3784 16.8567 21.7251 16.5649 22.1585 16.3511C22.5919 16.1344 23.0686 16.0275 23.5944 16.0275C24.1203 16.0275 24.6115 16.1344 25.0448 16.3511C25.4782 16.5678 25.8221 16.8567 26.0821 17.2208C26.3392 17.5848 26.4692 17.9951 26.4692 18.4545C26.4692 18.6654 26.4346 18.8677 26.3681 19.0583C26.3017 19.249 26.2063 19.4224 26.0821 19.5784C25.9578 19.7344 25.8047 19.8789 25.6169 20.0118C25.4291 20.1447 25.2153 20.2603 24.9697 20.3614L24.9062 20.0551C25.1778 20.1476 25.4233 20.2718 25.6429 20.4249C25.8625 20.5781 26.0532 20.7543 26.215 20.9595C26.3768 21.1617 26.501 21.3871 26.5906 21.6327C26.6802 21.8782 26.7235 22.1383 26.7235 22.407C26.7235 22.8981 26.5848 23.3402 26.3046 23.736C26.0243 24.129 25.6487 24.441 25.1806 24.6693C24.7126 24.8975 24.1839 25.0131 23.5973 25.0131L23.5916 25.016ZM23.5916 23.5945C23.8805 23.5945 24.1376 23.5396 24.3659 23.4298C24.5941 23.32 24.7733 23.1726 24.9062 22.9848C25.0362 22.7999 25.1026 22.5832 25.1026 22.3376C25.1026 22.0747 25.0362 21.8436 24.9062 21.6442C24.7733 21.4449 24.5941 21.2888 24.3659 21.1733C24.1376 21.0577 23.8776 21.0028 23.5916 21.0028C23.3055 21.0028 23.0599 21.0606 22.8346 21.1733C22.6092 21.2888 22.4301 21.4449 22.2943 21.6442C22.1585 21.8436 22.092 22.0747 22.092 22.3376C22.092 22.5746 22.1585 22.7884 22.2943 22.9733C22.4301 23.1611 22.6092 23.3113 22.8346 23.424C23.0599 23.5396 23.3113 23.5945 23.5916 23.5945ZM23.5916 19.6015C23.8458 19.6015 24.0712 19.5553 24.2705 19.4628C24.4699 19.3704 24.623 19.2433 24.7357 19.0815C24.8455 18.9197 24.9004 18.7347 24.9004 18.5209C24.9004 18.3071 24.8455 18.1078 24.7357 17.9431C24.6259 17.7784 24.4699 17.6455 24.2705 17.5502C24.0712 17.4519 23.8458 17.4028 23.5916 17.4028C23.3373 17.4028 23.1235 17.4519 22.9241 17.5502C22.7248 17.6484 22.5688 17.7784 22.459 17.9431C22.3492 18.1078 22.2943 18.3014 22.2943 18.5209C22.2943 18.7405 22.3492 18.9197 22.459 19.0815C22.5688 19.2433 22.7248 19.3704 22.9241 19.4628C23.1235 19.5553 23.346 19.6015 23.5916 19.6015Z" fill="white"/>
|
||||
<path d="M20.5 11.5C15.5363 11.5 11.5 15.5363 11.5 20.5C11.5 25.4637 15.5363 29.5 20.5 29.5C25.4637 29.5 29.5 25.4637 29.5 20.5C29.5 15.5363 25.4637 11.5 20.5 11.5ZM28.0583 20.5C28.0583 22.0284 27.5989 23.4528 26.8159 24.6432L16.7266 13.9588C17.839 13.3144 19.1276 12.9417 20.5029 12.9417C24.6692 12.9417 28.0583 16.3308 28.0583 20.4971V20.5ZM20.5 28.0554C16.3337 28.0554 12.9446 24.6663 12.9446 20.5C12.9446 18.2319 13.9501 16.195 15.5392 14.8082L25.9 25.7787C24.5276 27.1828 22.6149 28.0554 20.5029 28.0554H20.5Z" fill="#DB0015"/>
|
||||
</g>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_42558_134113" x="10.4177" y="10.4177" width="20.1647" height="20.1647" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset/>
|
||||
<feGaussianBlur stdDeviation="0.541168"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_42558_134113"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_42558_134113" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_42558_134113" x1="30.5469" y1="9.57031" x2="5.54686" y2="33.75" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#89FFEB"/>
|
||||
<stop offset="0.5" stop-color="#FFA53E"/>
|
||||
<stop offset="1" stop-color="#FD6119"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_42558_134113">
|
||||
<rect width="40" height="40" rx="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.8 KiB |
+4
-2
@@ -1,12 +1,14 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
import "./hg-media.css";
|
||||
import { SITE_NAME } from "@/lib/brand";
|
||||
|
||||
const GTM_ID = "GTM-TXLP67SX";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "禁果短剧 · 精品原创 · 日更爽看",
|
||||
applicationName: "禁果短剧",
|
||||
title: `${SITE_NAME} · 精品原创 · 日更爽看`,
|
||||
applicationName: SITE_NAME,
|
||||
icons: {icon:[{url:'/favicon.png',sizes:'48x48',type:'image/png'}],apple:[{url:'/apple-touch-icon.png',sizes:'180x180',type:'image/png'}]},
|
||||
verification: {google: "QJFFfKYHJsNfa-lGTdGjsCJc4rytPw-4wsTty-BM6iU"},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata:Metadata={title:'页面不存在 - 禁果短剧',robots:{index:false,follow:true},alternates:{canonical:null}};
|
||||
export const metadata:Metadata={title:'页面不存在 - 黄果短剧',robots:{index:false,follow:true},alternates:{canonical:null}};
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
|
||||
@@ -20,16 +20,16 @@ export function AppDownloadDialog({download, qr, status, dialogRef, onClose, onC
|
||||
</svg>
|
||||
</button>
|
||||
<h2 id={"app-download-heading"}>
|
||||
{"下载禁果短剧 App"}
|
||||
{"下载黄果短剧 App"}
|
||||
</h2>
|
||||
<p className={"app-download-intro"}>
|
||||
{"扫码下载,随时继续追剧"}
|
||||
</p>
|
||||
<div className={"app-download-code"}>
|
||||
<img src={qr || undefined} id={"app-platform-qr"} width={"192"} height={"192"} alt={"禁果短剧 App 下载二维码"} />
|
||||
<img src={qr || undefined} id={"app-platform-qr"} width={"192"} height={"192"} alt={"黄果短剧 App 下载二维码"} />
|
||||
</div>
|
||||
<p id={"app-platform-caption"}>
|
||||
{download === undefined ? "" : download ? "扫码下载安装 禁果短剧 App" : "暂未配置 App 下载地址"}
|
||||
{download === undefined ? "" : download ? "扫码下载安装 黄果短剧 App" : "暂未配置 App 下载地址"}
|
||||
</p>
|
||||
<div className={"app-download-link"}>
|
||||
<input id={"app-platform-link"} value={download || ""} readOnly aria-label={"下载链接"} />
|
||||
|
||||
@@ -30,7 +30,7 @@ export function ChannelPages({info,view}: {info?:InfoPageDefinition;view?:PageVi
|
||||
</main>
|
||||
<main id={"info-page"} className={"shell info-shell"} hidden={!info} data-info={info?.key} aria-labelledby={"info-title"}>
|
||||
<nav className={"page-breadcrumb"} id={"info-breadcrumb"} aria-label={"面包屑导航"}>
|
||||
{info?<><a href="/">首页</a><span>/</span>{info.key==='settings'?<><a href="/member/center/">个人中心</a><span>/</span></>:null}<span aria-current="page">{info.title}</span></>:null}
|
||||
{info?<><a href="/">首页</a><span>/</span><span aria-current="page">{info.title}</span></>:null}
|
||||
</nav>
|
||||
<header className={"info-heading"}>
|
||||
<small>
|
||||
@@ -63,9 +63,6 @@ export function ChannelPages({info,view}: {info?:InfoPageDefinition;view?:PageVi
|
||||
<a href={"/info/dmca/"} data-info={"dmca"}>
|
||||
{"DMCA"}
|
||||
</a>
|
||||
<a href={"/info/feedback/"} data-info={"feedback"}>
|
||||
{"意见反馈"}
|
||||
</a>
|
||||
</nav>
|
||||
<article className={"info-card"} id={"info-content"} dangerouslySetInnerHTML={info?{__html:info.html}:undefined} />
|
||||
</div>
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
import type { navigationPageData } from '@/lib/navigation-page';
|
||||
import { BUSINESS_CONTACT } from '@/lib/contact';
|
||||
import { SITE_NAME, brandText } from '@/lib/brand';
|
||||
import { footerLinkCurrent, type FooterPolicyLink } from '@/lib/footer-links';
|
||||
|
||||
export function Footer({title,content,contacts,links=[],address='/'}:{title?:string;content?:string;contacts?:Pick<ReturnType<typeof navigationPageData>,'telegram'|'tgGroup'|'twitter'>;links?:FooterPolicyLink[];address?:string}={}) {
|
||||
const brand=title?.trim()||'';
|
||||
const slogan=content?.trim()||'';
|
||||
export function Footer({title,content,links=[],address='/'}:{title?:string;content?:string;links?:FooterPolicyLink[];address?:string}={}) {
|
||||
const brand=brandText(title)||SITE_NAME;
|
||||
const slogan=brandText(content);
|
||||
const year=new Date().getFullYear();
|
||||
return (
|
||||
<>
|
||||
<footer id="shared-site-footer" aria-label={"网站底部"}>
|
||||
<div className="site-footer-inner">
|
||||
<strong className="site-footer-title">
|
||||
{brand}{brand?<i aria-hidden="true"/>:null}
|
||||
{brand}<i aria-hidden="true"/>
|
||||
</strong>
|
||||
<p className="site-footer-slogan hg-footer-original-slogan">
|
||||
{slogan}
|
||||
</p>
|
||||
{links.length?<nav className="site-footer-links" aria-label={"平台规则与帮助"}>
|
||||
{links.map((link,index)=><a key={`${link.id}-${index}`} href={link.href} aria-current={footerLinkCurrent(address,link.href)?'page':undefined} {...(link.external?{target:link.href.startsWith('mailto:')?undefined:'_blank',rel:'noopener noreferrer'}:{})}>
|
||||
{links.map((link,index)=><a key={`${link.id}-${index}`} href={link.href} aria-current={footerLinkCurrent(address,link.href)?'page':undefined} {...(link.external?{target:'_blank',rel:'noopener noreferrer'}:{})}>
|
||||
{link.name}
|
||||
</a>)}
|
||||
</nav>:null}
|
||||
<small className="site-footer-copyright">
|
||||
{`© ${year}${brand?` ${brand} `:''}All rights reserved.`}
|
||||
{`© ${year} ${SITE_NAME} All rights reserved.`}
|
||||
</small>
|
||||
<div className="site-footer-social" aria-label={"联系我们"}>
|
||||
{contacts?.tgGroup&&<a href={contacts.tgGroup} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"官方社群"} title={"官方社群"}>
|
||||
<img src={"/assets/c9e57990dff7b688.png"} alt={"官方社群图标"} />
|
||||
</a>}
|
||||
{contacts?.telegram&&<a href={contacts.telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"Telegram"} title={"Telegram"}>
|
||||
<a href={BUSINESS_CONTACT.href} target={"_blank"} rel={"nofollow noopener noreferrer"} aria-label={BUSINESS_CONTACT.label+" Telegram "+BUSINESS_CONTACT.handle} title={BUSINESS_CONTACT.label+" "+BUSINESS_CONTACT.handle}>
|
||||
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
|
||||
<path d={"M21.7 3.3 18.4 20c-.2 1.2-.9 1.5-1.9.9l-5-3.7-2.4 2.3c-.3.3-.5.5-1 .5l.4-5.1 9.3-8.4c.4-.4-.1-.6-.6-.3L5.7 13.4.8 11.9c-1-.3-1-1 .2-1.5L20.2 3c.9-.3 1.7.2 1.5.3Z"}>
|
||||
|
||||
</path>
|
||||
</svg>
|
||||
</a>}
|
||||
{contacts?.twitter&&<a href={contacts.twitter} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"X"} title={"X"}>
|
||||
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
|
||||
<path d={"M18.9 2H22l-6.8 7.8L23 22h-6.3l-4.9-7.4L5.3 22H2l8.3-9.5L2 2h6.5l4.4 6.8L18.9 2ZM17.8 20h1.8L7.5 4H5.6l12.2 16Z"}>
|
||||
|
||||
</path>
|
||||
</svg>
|
||||
</a>}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { HomeData, User } from "@/lib/api/types";
|
||||
import type { HomeData } from "@/lib/api/types";
|
||||
import { renderHome } from "@/lib/client/render";
|
||||
|
||||
import type { NavigationEntry } from '@/lib/site-navigation';
|
||||
import { SiteNavigation } from './SiteNavigation';
|
||||
|
||||
export function HomePage({home,error,hideContent=false,navigation=[],address='/',navigationError=false,user=null}: {user?:User|null;home: HomeData; error?: string;hideContent?:boolean;navigation?:NavigationEntry[];address?:string;navigationError?:boolean}) {
|
||||
export function HomePage({home,error,hideContent=false,navigation=[],address='/',navigationError=false}: {home: HomeData; error?: string;hideContent?:boolean;navigation?:NavigationEntry[];address?:string;navigationError?:boolean}) {
|
||||
return <>
|
||||
<div id="global-site-header" className="shell">
|
||||
<header className={"top"}>
|
||||
<a className="hg-header-brand" href="/" aria-label="禁果短剧首页"><img className="hg-complete-logo" src="/assets/40803f26f3c0acbb.svg" width={135} height={40} alt="禁果短剧" /></a>
|
||||
<a className="hg-header-brand" href="/" aria-label="黄果短剧首页"><img className="hg-complete-logo" src="/logo.png" width={164} height={40} alt="黄果短剧" /></a>
|
||||
<SiteNavigation entries={navigation} address={address} surface="top" error={navigationError} />
|
||||
<form className={"search"} id={"search"} role={"search"}>
|
||||
<input id={"query"} aria-controls={"search-discovery"} aria-expanded={"false"} type={"search"} placeholder={"找一部,想看的好剧"} aria-label={"搜索短剧"} autoComplete={"off"} />
|
||||
@@ -71,26 +71,6 @@ export function HomePage({home,error,hideContent=false,navigation=[],address='/'
|
||||
</section>
|
||||
</form>
|
||||
<div className={"header-actions"} aria-label={"用户服务"}>
|
||||
<button className={"header-action coin-entry"} data-account-action={"coins"} aria-label={'金币余额 '+(user?.recharge||0)}>
|
||||
<span className={"coin-symbol"} aria-hidden={"true"}>
|
||||
<img className={"coin-soft-star"} src={"/assets/897547dcbff56113.svg"} alt={"金币图标"} aria-hidden={"true"} />
|
||||
</span>
|
||||
<span className={"coin-caption"}>
|
||||
{"金币 "}
|
||||
<span className={"coin-value"}>
|
||||
{user?.recharge||0}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="jg-history-wrap">
|
||||
<a className="header-action jg-history-entry" href="/member/history/" id="header-history-button" aria-label="观看历史" aria-controls="header-history-panel" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg><span>历史</span>
|
||||
</a>
|
||||
<section className="jg-history-panel" id="header-history-panel" aria-labelledby="header-history-title" hidden>
|
||||
<h2 id="header-history-title">观看历史</h2><div className="jg-history-list" id="header-history-list" aria-live="polite"/>
|
||||
<a className="jg-history-all" href="/member/history/">查看全部 <span aria-hidden="true">›</span></a>
|
||||
</section>
|
||||
</div>
|
||||
<div className={"header-download-wrap"} id={"header-download-wrap"} data-app-download-entry="" hidden>
|
||||
<a className={"header-action download-entry"} id={"header-download-button"} data-app-download="" target="_blank" rel="sponsored nofollow noopener noreferrer" aria-label={"下载 App"}>
|
||||
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
|
||||
@@ -106,34 +86,15 @@ export function HomePage({home,error,hideContent=false,navigation=[],address='/'
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<button className={"header-action account-entry "} id={"account-entry"} aria-controls={"profile-popover"} aria-expanded={"false"}>
|
||||
<span className={"account-avatar"} aria-hidden={"true"}>
|
||||
<img className={"member-avatar-image"} src={"/assets/8837c3e58159bd48.png"} alt={"默认用户头像"} width={"50"} height={"50"} />
|
||||
</span>
|
||||
<span className={"account-copy"}>
|
||||
<strong>
|
||||
{user?.nickName||user?.accountNo||'我的'}
|
||||
</strong>
|
||||
<small id={"account-status"}>
|
||||
{user?(user.isRegister||user.type===1?'已登录':'游客账号'):'未登录'}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<button className={"saved header-saved-proxy"} id={"saved"} hidden>
|
||||
{"♡ 我的片单 "}
|
||||
<span id={"count"}>
|
||||
0
|
||||
</span>
|
||||
</button>
|
||||
</header>
|
||||
<div className="jg-masthead">
|
||||
<a className="jg-masthead-brand" href="/" aria-label="禁果短剧首页"><img src="/assets/40803f26f3c0acbb.svg" alt="禁果短剧" width={182} height={54}/><div className="jg-masthead-copy"><span>精品原创 · 日更爽看</span></div></a>
|
||||
<a className="jg-masthead-brand" href="/" aria-label="黄果短剧首页"><img src="/logo.png" alt="黄果短剧" width={222} height={54}/><div className="jg-masthead-copy"><span>精品原创 · 日更爽看</span></div></a>
|
||||
<span className="jg-masthead-note">每一种心情,都有一个好故事</span>
|
||||
</div>
|
||||
</div>
|
||||
<main className="shell" hidden={hideContent} aria-labelledby={hideContent?undefined:'home-title'}>
|
||||
{!hideContent&&<h1 id="home-title" className="home-page-title">禁果短剧 · 精品原创 · 日更爽看</h1>}
|
||||
{!hideContent&&<h1 id="home-title" className="home-page-title">黄果短剧 · 精品原创 · 日更爽看</h1>}
|
||||
<div className="home-content" hidden={hideContent} dangerouslySetInnerHTML={{__html:hideContent?'':renderHome(home,error)}} />
|
||||
</main>
|
||||
</>;
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
import { startTracker } from "@/lib/client/analytics";
|
||||
import { useRef, useState, type MouseEvent } from "react";
|
||||
import { BUSINESS_CONTACT } from "@/lib/contact";
|
||||
import type { Config } from "@/lib/api/types";
|
||||
import { navigationPageData } from "@/lib/navigation-page";
|
||||
import { AppDownloadDialog } from "./AppDownloadDialog";
|
||||
import { createDownloadQr, shouldUseDownloadDialog } from "@/lib/client/download";
|
||||
|
||||
export function NavigationPage({config, download, error}: {config: Config; download: string; error?: string}) {
|
||||
useEffect(()=>{startTracker(config);},[config]);
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const [status, setStatus] = useState("");
|
||||
const [qr, setQr] = useState<string>();
|
||||
const {siteName,description,routes,permanent,telegram,tgGroup,twitter}=navigationPageData(config);
|
||||
const channelCount=2+Number(!!tgGroup)+Number(!!twitter)+Number(!!download);
|
||||
const {siteName,description,routes,permanent}=navigationPageData(config);
|
||||
const channelCount=2+Number(!!download);
|
||||
const openDownload = async (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!shouldUseDownloadDialog(event)) return;
|
||||
event.preventDefault();setStatus("");dialog.current?.showModal();
|
||||
@@ -27,12 +26,12 @@ export function NavigationPage({config, download, error}: {config: Config; downl
|
||||
return (
|
||||
<>
|
||||
<div id={"entry-navigation"} className={"navigation-page"}>
|
||||
<img className={"entry-background"} src={"/assets/d99080bbba89c515.png"} alt={"禁果短剧官方入口背景"} aria-hidden={"true"} />
|
||||
<img className={"entry-background"} src={"/assets/d99080bbba89c515.png"} alt={"黄果短剧官方入口背景"} aria-hidden={"true"} />
|
||||
<div className={"entry-stage"}>
|
||||
<main className={"entry-content"} aria-labelledby={"entry-page-title"}>
|
||||
<header className={"entry-brand"}>
|
||||
<h1 id="entry-page-title" className="entry-page-title">{siteName+"官方入口"}</h1>
|
||||
<img className={"entry-brand-logo hg-complete-logo"} src={"/assets/40803f26f3c0acbb.svg"} alt={siteName} width={"500"} height={"176"} />
|
||||
<img className={"entry-brand-logo hg-complete-logo"} src={"/logo.png"} alt={siteName} width={"444"} height={"108"} />
|
||||
<p>
|
||||
{description}
|
||||
</p>
|
||||
@@ -68,11 +67,11 @@ export function NavigationPage({config, download, error}: {config: Config; downl
|
||||
{siteName+" · APP 下载"}
|
||||
</span>
|
||||
</a>}
|
||||
{telegram&&<a className={"entry-contact"} href={telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
|
||||
{"官方联系:" + telegram}
|
||||
</a>}
|
||||
<a className={"entry-contact"} href={BUSINESS_CONTACT.href} target={"_blank"} rel={"nofollow noopener noreferrer"}>
|
||||
{BUSINESS_CONTACT.label+":"+BUSINESS_CONTACT.handle}
|
||||
</a>
|
||||
<nav className={"entry-channels"} aria-label={"官方渠道"} style={{gridTemplateColumns: `repeat(${channelCount}, minmax(0, 1fr))`}}>
|
||||
{tgGroup&&<a className={"entry-channel"} href={tgGroup} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
|
||||
<a className={"entry-channel"} href={BUSINESS_CONTACT.href} target={"_blank"} rel={"nofollow noopener noreferrer"}>
|
||||
<span className={"entry-channel-icon"}>
|
||||
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"16"} height={"16"} viewBox={"0 0 16 16"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
|
||||
<g>
|
||||
@@ -83,23 +82,9 @@ export function NavigationPage({config, download, error}: {config: Config; downl
|
||||
</svg>
|
||||
</span>
|
||||
<span>
|
||||
{"官方TG群"}
|
||||
{BUSINESS_CONTACT.label}
|
||||
</span>
|
||||
</a>}
|
||||
{twitter&&<a className={"entry-channel"} href={twitter} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"}>
|
||||
<span className={"entry-channel-icon"}>
|
||||
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"16"} height={"16"} viewBox={"0 0 16 16"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
|
||||
<g>
|
||||
<path d={"M11.9025 1.90609H13.97L9.45311 7.06859L14.7669 14.0936H10.6062L7.34748 9.83296L3.61872 14.0936H1.54998L6.38123 8.57171L1.28373 1.90609H5.54999L8.49561 5.80046L11.9025 1.90609ZM11.1769 12.8561H12.3225L4.92747 3.07859H3.69809L11.1769 12.8561Z"} fill={"currentColor"}>
|
||||
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<span>
|
||||
{"官方推特"}
|
||||
</span>
|
||||
</a>}
|
||||
</a>
|
||||
<a className={"entry-channel"} href={"/home-road/"}>
|
||||
<span className={"entry-channel-icon"}>
|
||||
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"16"} height={"16"} viewBox={"0 0 16 16"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
|
||||
@@ -128,26 +113,6 @@ export function NavigationPage({config, download, error}: {config: Config; downl
|
||||
{"APP"}
|
||||
</span>
|
||||
</a>}
|
||||
<a className={"entry-channel"} href={"/apps/"}>
|
||||
<span className={"entry-channel-icon"}>
|
||||
<svg className={"entry-channel-svg"} aria-hidden={"true"} focusable={"false"} preserveAspectRatio={"none"} overflow={"visible"} style={{"display": "block"}} width={"16"} height={"16"} viewBox={"0 0 16 16"} fill={"none"} xmlns={"http://www.w3.org/2000/svg"}>
|
||||
<g>
|
||||
<path d={"M4.43637 10.3782C4.35322 10.4255 4.25929 10.4505 4.16364 10.4509C4.04345 10.451 3.92659 10.4115 3.8312 10.3384C3.73581 10.2653 3.66725 10.1627 3.63615 10.0466C3.60505 9.93049 3.61317 9.80738 3.65924 9.69638C3.70531 9.58537 3.78675 9.49269 3.89091 9.43273L5.01818 8.78182L4.32364 8.38182C3.91526 8.16333 3.60971 7.79237 3.4735 7.3497C3.33729 6.90702 3.38145 6.42846 3.59636 6.01818C3.71269 5.81966 3.87027 5.64842 4.05848 5.51605C4.24668 5.38368 4.46111 5.29326 4.68727 5.25091C5.00863 5.18723 5.34112 5.20982 5.65091 5.31637V1.81818H2.74182C2.47813 1.81578 2.21657 1.86565 1.97226 1.9649C1.72795 2.06415 1.50572 2.21082 1.31841 2.39644C1.1311 2.58206 0.98242 2.80295 0.880959 3.04635C0.779497 3.28975 0.727263 3.55084 0.727274 3.81455V12.1782C0.726317 12.441 0.777248 12.7013 0.877146 12.9444C0.977044 13.1874 1.12394 13.4084 1.30942 13.5945C1.49489 13.7807 1.71529 13.9284 1.95798 14.0292C2.20066 14.1299 2.46086 14.1818 2.72364 14.1818H5.63273V9.68364L4.43637 10.3782Z"} fill={"currentColor"}>
|
||||
|
||||
</path>
|
||||
<path d={"M4.89091 6.32C4.81646 6.33243 4.74552 6.36059 4.68281 6.4026C4.62011 6.44461 4.56708 6.49951 4.52727 6.56364C4.45835 6.72444 4.45516 6.90584 4.51837 7.06897C4.58159 7.23211 4.70618 7.36399 4.86545 7.43637L5.63273 7.88V6.48364C5.56864 6.4752 5.50685 6.4542 5.45091 6.42182C5.28354 6.31906 5.08374 6.28274 4.89091 6.32ZM13.28 1.81818H6.73455V5.27637C7.00405 5.20499 7.28614 5.19505 7.56 5.24727C7.78608 5.28985 8.00042 5.38036 8.18859 5.51271C8.37676 5.64506 8.5344 5.81618 8.65091 6.01455C8.76587 6.21316 8.83645 6.43432 8.85779 6.66282C8.87914 6.89131 8.85074 7.12171 8.77455 7.33818C8.61873 7.77517 8.30827 8.13996 7.90182 8.36364L7.2 8.78182L8.33091 9.45455C8.43507 9.51451 8.51651 9.60719 8.56258 9.71819C8.60865 9.8292 8.61677 9.95231 8.58567 10.0684C8.55457 10.1845 8.48601 10.2871 8.39062 10.3602C8.29523 10.4333 8.17837 10.4729 8.05818 10.4727C7.96266 10.4714 7.86896 10.4464 7.78545 10.4L6.72727 9.77091V14.1818H13.2727C13.5352 14.1823 13.7952 14.131 14.0378 14.0309C14.2804 13.9308 14.501 13.7838 14.6867 13.5984C14.8725 13.413 15.0199 13.1927 15.1204 12.9503C15.221 12.7078 15.2727 12.4479 15.2727 12.1855V3.82182C15.2737 3.55935 15.2229 3.29926 15.1232 3.05645C15.0235 2.81364 14.877 2.59286 14.6919 2.40676C14.5068 2.22066 14.2868 2.07288 14.0445 1.97189C13.8023 1.87089 13.5425 1.81866 13.28 1.81818Z"} fill={"currentColor"}>
|
||||
|
||||
</path>
|
||||
<path d={"M7.72364 6.99636C7.7495 6.92548 7.75996 6.8499 7.75432 6.77465C7.74868 6.69941 7.72706 6.62623 7.69091 6.56C7.65165 6.4954 7.59878 6.44013 7.53597 6.39805C7.47317 6.35598 7.40195 6.3281 7.32727 6.31636C7.13445 6.27837 6.93438 6.31474 6.76727 6.41818L6.72364 6.43636V7.79273L7.35273 7.42909C7.52486 7.33474 7.65672 7.1809 7.72364 6.99636Z"} fill={"currentColor"}>
|
||||
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
<span>
|
||||
{"福利应用"}
|
||||
</span>
|
||||
</a>
|
||||
</nav>
|
||||
<footer className={"entry-footer"}>
|
||||
{error && <p role="status">{error}</p>}
|
||||
|
||||
@@ -7,16 +7,6 @@ export function PageTools() {
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4m-3 14h2"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<button className={"page-tool"} id={"floating-history"} type={"button"} aria-label={"历史记录"} title={"历史记录"} aria-haspopup={"dialog"} aria-controls={"history-dialog"}>
|
||||
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
|
||||
<circle cx={"12"} cy={"12"} r={"9"} fill={"none"} stroke={"currentColor"} strokeWidth={"1.8"}>
|
||||
|
||||
</circle>
|
||||
<path d={"M12 7v5l3 2"} fill={"none"} stroke={"currentColor"} strokeWidth={"1.8"} strokeLinecap={"round"} strokeLinejoin={"round"}>
|
||||
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
<button className={"page-tool"} id={"back-to-top"} type={"button"} aria-label={"回到顶部"} title={"回到顶部"}>
|
||||
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
|
||||
<path d={"M5 4h14M12 20V8m-6 6 6-6 6 6"}>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { NavigationEntry } from '@/lib/site-navigation';
|
||||
import type { navigationPageData } from '@/lib/navigation-page';
|
||||
import { BUSINESS_CONTACT } from '@/lib/contact';
|
||||
import { SiteNavigation } from './SiteNavigation';
|
||||
|
||||
export function Sidebar({navigation,address,error,contacts}:{navigation:NavigationEntry[];address:string;error?:boolean;contacts?:Pick<ReturnType<typeof navigationPageData>,'telegram'|'tgGroup'|'twitter'>}) {
|
||||
export function Sidebar({navigation,address,error}:{navigation:NavigationEntry[];address:string;error?:boolean}) {
|
||||
return (
|
||||
<>
|
||||
<aside className={"side"} id={"site-sidebar"} aria-label={"网站侧边栏"}>
|
||||
<div className={"sidebar-header"}>
|
||||
<a className={"mark sidebar-logo-toggle"} href={"/"} aria-label={"禁果短剧首页"}>
|
||||
<a className={"mark sidebar-logo-toggle"} href={"/"} aria-label={"黄果短剧首页"}>
|
||||
<span className={"sidebar-brand-window"}>
|
||||
<img className={"sidebar-brand"} src={"/assets/40803f26f3c0acbb.svg"} alt={"禁果短剧"} width={"500"} height={"176"} />
|
||||
<img className={"sidebar-brand"} src={"/logo.png"} alt={"黄果短剧"} width={"444"} height={"108"} />
|
||||
</span>
|
||||
</a>
|
||||
<button className={"sidebar-toggle"} id={"sidebar-toggle"} type={"button"} aria-controls={"site-sidebar"} aria-expanded={"true"} aria-label={"收起侧边栏"} title={"收起侧边栏"}>
|
||||
@@ -26,7 +26,7 @@ export function Sidebar({navigation,address,error,contacts}:{navigation:Navigati
|
||||
<SiteNavigation entries={navigation} address={address} error={error} />
|
||||
<div className={"side-info"}>
|
||||
<strong>
|
||||
<img className={"side-info-wordmark"} src={"/assets/87eeb5bf339a03f0.svg"} alt={"禁果短剧"} width={308} height={73} />
|
||||
<img className={"side-info-wordmark"} src={"/logo.png"} alt={"黄果短剧"} width={300} height={73} />
|
||||
<i className={"side-info-brand-dot"} aria-hidden={"true"}>
|
||||
|
||||
</i>
|
||||
@@ -53,12 +53,9 @@ export function Sidebar({navigation,address,error,contacts}:{navigation:Navigati
|
||||
<a href={"/info/dmca/"} data-info={"dmca"}>
|
||||
{"DMCA"}
|
||||
</a>
|
||||
<a href={"/info/feedback/"} data-info={"feedback"}>
|
||||
{"意见反馈"}
|
||||
</a>
|
||||
</div>
|
||||
<small>
|
||||
{"© 2026 禁果短剧"}
|
||||
{"© 2026 黄果短剧"}
|
||||
<br />
|
||||
{"All rights reserved."}
|
||||
</small>
|
||||
@@ -74,23 +71,13 @@ export function Sidebar({navigation,address,error,contacts}:{navigation:Navigati
|
||||
</svg>
|
||||
</a>
|
||||
<div className={"sidebar-social"} aria-label={"联系我们"}>
|
||||
{contacts?.tgGroup&&<a href={contacts.tgGroup} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"官方社群"} title={"官方社群"}>
|
||||
<img src={"/assets/c9e57990dff7b688.png"} alt={"官方社群图标"} />
|
||||
</a>}
|
||||
{contacts?.telegram&&<a href={contacts.telegram} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"Telegram"} title={"Telegram"}>
|
||||
<a href={BUSINESS_CONTACT.href} target={"_blank"} rel={"nofollow noopener noreferrer"} aria-label={BUSINESS_CONTACT.label+" Telegram "+BUSINESS_CONTACT.handle} title={BUSINESS_CONTACT.label+" "+BUSINESS_CONTACT.handle}>
|
||||
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
|
||||
<path d={"M21.7 3.3 18.4 20c-.2 1.2-.9 1.5-1.9.9l-5-3.7-2.4 2.3c-.3.3-.5.5-1 .5l.4-5.1 9.3-8.4c.4-.4-.1-.6-.6-.3L5.7 13.4.8 11.9c-1-.3-1-1 .2-1.5L20.2 3c.9-.3 1.7.2 1.5.3Z"}>
|
||||
|
||||
</path>
|
||||
</svg>
|
||||
</a>}
|
||||
{contacts?.twitter&&<a href={contacts.twitter} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} aria-label={"X"} title={"X"}>
|
||||
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
|
||||
<path d={"M18.9 2H22l-6.8 7.8L23 22h-6.3l-4.9-7.4L5.3 22H2l8.3-9.5L2 2h6.5l4.4 6.8L18.9 2ZM17.8 20h1.8L7.5 4H5.6l12.2 16Z"}>
|
||||
|
||||
</path>
|
||||
</svg>
|
||||
</a>}
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
<SiteNavigation entries={navigation} address={address} surface="mobile" />
|
||||
|
||||
@@ -9,104 +9,7 @@ export function SiteDialogs() {
|
||||
<div className={"toast hidden"} id={"toast"} role={"status"}>
|
||||
|
||||
</div>
|
||||
<dialog id={"account-dialog"} aria-labelledby={"account-dialog-title"}>
|
||||
|
||||
</dialog>
|
||||
<dialog id={"history-dialog"} className={"history-dialog"} aria-labelledby={"history-dialog-title"}>
|
||||
<button className={"close"} id={"history-close"} type={"button"} aria-label={"关闭历史记录"}>
|
||||
<svg className={"dialog-close-icon"} viewBox={"0 0 24 24"} aria-hidden={"true"} focusable={"false"}>
|
||||
<path d={"m6 6 12 12M18 6 6 18"}>
|
||||
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
<h2 id={"history-dialog-title"}>
|
||||
{"历史记录"}
|
||||
</h2>
|
||||
<p className={"history-note"}>
|
||||
{"最近浏览的剧集"}
|
||||
</p>
|
||||
<div className={"account-history-list"} id={"history-list"}>
|
||||
|
||||
</div>
|
||||
<button type={"button"} className={"history-clear"} id={"history-clear"}>
|
||||
{"清空记录"}
|
||||
</button>
|
||||
</dialog>
|
||||
<AppDownloadDialog />
|
||||
<dialog id={"member-delete-dialog"} aria-labelledby={"member-delete-title"} aria-describedby={"member-delete-description"}>
|
||||
<form method={"dialog"}>
|
||||
<h2 id={"member-delete-title"}>
|
||||
{"确认删除"}
|
||||
</h2>
|
||||
<p id={"member-delete-description"}>
|
||||
|
||||
</p>
|
||||
<div className={"member-delete-actions"}>
|
||||
<button value={"cancel"} autoFocus>
|
||||
{"取消"}
|
||||
</button>
|
||||
<button value={"confirm"}>
|
||||
{"确认删除"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id={"member-auth-dialog"} aria-labelledby={"member-auth-title"}>
|
||||
|
||||
</dialog>
|
||||
<dialog id={"wallet-payment-dialog"} aria-labelledby={"wallet-payment-title"}>
|
||||
|
||||
</dialog>
|
||||
<dialog id={"play-episode-lock"} aria-labelledby={"episode-lock-title"} aria-describedby={"episode-lock-description episode-lock-note"}>
|
||||
<button type={"button"} className={"unlock-close"} aria-label={"关闭金币解锁弹窗"}>
|
||||
<svg viewBox={"0 0 24 24"} aria-hidden={"true"}>
|
||||
<path d={"m6 6 12 12M18 6 6 18"}>
|
||||
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
<div className={"unlock-coin"} aria-hidden={"true"}>
|
||||
<span className={"coin-symbol"} aria-hidden={"true"}>
|
||||
<img className={"coin-soft-star"} src={"/assets/897547dcbff56113.svg"} alt={"金币图标"} aria-hidden={"true"} />
|
||||
</span>
|
||||
</div>
|
||||
<h2 id={"episode-lock-title"}>
|
||||
{"使用金币解锁本集"}
|
||||
</h2>
|
||||
<p id={"episode-lock-description"} className={"episode-lock-copy"}>
|
||||
{"解锁后可永久观看本集内容。"}
|
||||
</p>
|
||||
<div className={"unlock-cost-summary"}>
|
||||
<div>
|
||||
<span>
|
||||
{"当前余额"}
|
||||
</span>
|
||||
<strong id={"episode-lock-balance"}>
|
||||
{"— 金币"}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>
|
||||
{"本次消耗"}
|
||||
</span>
|
||||
<strong className={"unlock-cost"}>
|
||||
{"— 金币"}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p id={"episode-lock-note"} className={"unlock-confirm-note"}>
|
||||
{"后续付费剧集仍需逐集确认"}
|
||||
</p>
|
||||
<p id={"episode-lock-error"} className={"unlock-error"} role={"alert"} hidden></p>
|
||||
<button type={"button"} className={"unlock-pay"}>
|
||||
{"支付 — 金币并播放"}
|
||||
</button>
|
||||
<a className={"unlock-recharge"} href={"/info/deposit/"} data-close-dialog="">
|
||||
{"金币不足?去充值"}
|
||||
</a>
|
||||
</dialog>
|
||||
|
||||
<dialog id={"startup-age"} className={"startup-popup"} aria-labelledby={"startup-age-title"} aria-describedby={"startup-age-description"}>
|
||||
<div className={"startup-age-layout"}>
|
||||
<div className={"startup-age-mark"}>
|
||||
@@ -150,7 +53,7 @@ export function SiteDialogs() {
|
||||
</button>
|
||||
</dialog>
|
||||
<dialog id={"startup-ad"} className={"startup-popup"} aria-label={"广告推荐"}>
|
||||
<a className={"startup-ad-media"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} data-track-ad="external-dialog" data-ad-type="interstitial" hidden>
|
||||
<a className={"startup-ad-media"} target={"_blank"} rel={"sponsored nofollow noopener noreferrer"} hidden>
|
||||
<img className={"startup-ad-image"} width={"400"} height={"602"} alt={"广告"} />
|
||||
</a>
|
||||
<p className={"startup-ad-loading"} role={"status"} hidden>{"正在加载下一条广告…"}</p>
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { HomeData } from '@/lib/api/types';
|
||||
import type { InfoPageDefinition } from '@/lib/info-pages';
|
||||
import { getHeaderNavigation } from '@/lib/api/server';
|
||||
import { navigationEntries } from '@/lib/site-navigation';
|
||||
import { navigationPageData } from '@/lib/navigation-page';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { HomePage } from './HomePage';
|
||||
import { ChannelPages } from './ChannelPages';
|
||||
@@ -20,16 +19,15 @@ export async function SiteFrame({home,error,info,seo,view,homeLoaded=true,sessio
|
||||
const header=await getHeaderNavigation();
|
||||
const navigation=navigationEntries(header.data,seo?.environment.origin);
|
||||
const address=seo?.path||'/';
|
||||
const contacts=navigationPageData(shared?.config||{});
|
||||
return <>
|
||||
<StructuredData seo={seo} />
|
||||
<Sidebar navigation={navigation} address={address} error={header.error} contacts={contacts} />
|
||||
<HomePage user={session?.user||view?.bootstrap?.session?.user} navigation={navigation} address={address} navigationError={header.error} home={home} error={error} hideContent={!!info||!!view} />
|
||||
<Sidebar navigation={navigation} address={address} error={header.error} />
|
||||
<HomePage navigation={navigation} address={address} navigationError={header.error} home={home} error={error} hideContent={!!info||!!view} />
|
||||
<ChannelPages info={info} view={view} />
|
||||
<PlayerPage view={view} />
|
||||
<PageTools />
|
||||
<SiteDialogs />
|
||||
<Footer title={shared?.footer.footerInfo?.brand?.title} content={shared?.footer.footerInfo?.brand?.content} contacts={contacts} links={footerPolicyLinks(shared?.footer)} address={address} />
|
||||
<Footer title={shared?.footer.footerInfo?.brand?.title} content={shared?.footer.footerInfo?.brand?.content} links={footerPolicyLinks(shared?.footer)} address={address} />
|
||||
<SiteRuntime home={home} homeLoaded={homeLoaded&&!info} seo={seo} page={view?.bootstrap} navigation={navigation} session={session} shared={shared} />
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,7 @@ const icons:Record<string,string>={
|
||||
"shorts": "M765 558l-22.93-14.02L725 366h1.44l-1.17-12.3c-24.41-22.2-39.3-35.74-63.71-57.95H265.44c-24.41 22.2-39.3 35.74-63.71 57.95l-37.72 397c25.39 27.92 38.32 42.14 63.71 70.05h471.55c25.39-27.92 38.32-42.14 63.71-70.05l-9.03-95.04 132.04 55.6v-306l-116.13 48.9-4.87 103.85z",
|
||||
"topics": "M473.6 307.44L373.88 234H190.45c-13.24 14.55-19.99 21.97-33.24 36.52l44.5 470.8 52.39 47.67h536.5l52.31-46.92 28.26-259.47-523.33-12.55 20.98-25.18 508.58-19.49 8.82-80.95c-13.27-14.79-19.92-22.21-33.19-37H473.58z",
|
||||
"rank": "M710.83 236.25c-109.55-109.55-287.18-109.55-396.73 0-109.55 109.55-109.55 287.18 0 396.73 41.51 41.51 92.8 67.28 146.46 77.33l-14.22 28.06c-42.51 2.32-87.82-4.48-131.06-38.02l-18.72 181.57 215.04-83.69 215.04 83.69-24.78-240.38c3.03-2.78 6.03-5.62 8.97-8.56 109.55-109.55 109.55-287.18 0-396.73z",
|
||||
"home-road": "M534.27 168v0.36h-46.45l-13.39 461.39h-0.19l-0.99 40.53-1.78 61.34h0.28l-0.17 6.93-24.29-15.19-6.63-91.65c-146.16 11.32-255.42 56.61-255.42 110.77 0 62.68 146.35 113.5 326.88 113.5S839 805.16 839 742.48c0-58.26-126.42-106.25-289.19-112.74l-4.84-166.76h346.86l-80.71-147.5 80.71-147.5H534.27z",
|
||||
"apps": "M178.74 474.5h46.77v0.28l33.03-0.28h305.41l-25.85 16.77-312.59 22.53V812h570.47V474.5h46.76v-102h-664v102zM662 243l-119 61-204.78-75.63L287 340l195.97 23 261.77-25L662 243z"
|
||||
"home-road": "M534.27 168v0.36h-46.45l-13.39 461.39h-0.19l-0.99 40.53-1.78 61.34h0.28l-0.17 6.93-24.29-15.19-6.63-91.65c-146.16 11.32-255.42 56.61-255.42 110.77 0 62.68 146.35 113.5 326.88 113.5S839 805.16 839 742.48c0-58.26-126.42-106.25-289.19-112.74l-4.84-166.76h346.86l-80.71-147.5 80.71-147.5H534.27z"
|
||||
};
|
||||
|
||||
function NavigationIcon({item}:{item:NavigationEntry}) {
|
||||
@@ -17,7 +16,7 @@ function NavigationIcon({item}:{item:NavigationEntry}) {
|
||||
|
||||
function NavigationLink({item,address,surface}:{item:NavigationEntry;address:string;surface:NavigationSurface}) {
|
||||
const active=navigationIsActive(item.href,address,surface);
|
||||
return <a href={item.href} data-site-nav={surface} data-nav-id={item.id} data-page={surface==='top'?undefined:item.key} data-hg-page={surface==='top'?item.key:undefined} className={active?'active':undefined} aria-current={active?'page':undefined} aria-label={item.name} title={item.name} target={item.external?'_blank':undefined} rel={item.external?'sponsored nofollow noopener noreferrer':undefined}>
|
||||
return <a href={item.href} data-site-nav={surface} data-page={surface==='top'?undefined:item.key} data-hg-page={surface==='top'?item.key:undefined} className={active?'active':undefined} aria-current={active?'page':undefined} aria-label={item.name} title={item.name} target={item.external?'_blank':undefined} rel={item.external?'sponsored nofollow noopener noreferrer':undefined}>
|
||||
{surface==='sidebar'||surface==='top'?<NavigationIcon item={item}/>:null}<span className={surface==='sidebar'?'side-nav-label':undefined}>{item.name}</span>
|
||||
</a>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
})};
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
/**
|
||||
* 广告等事件上报前补齐公共字段。
|
||||
* web-sdk 只带 device / user_agent,device_brand、device_model 固定为空,
|
||||
* 且没有 system_name、system_version。口径对齐 jha-1040 tracker.js。
|
||||
*/
|
||||
|
||||
export type ReportDevice = {
|
||||
device_brand: string;
|
||||
device_model: string;
|
||||
system_name: string;
|
||||
system_version: string;
|
||||
};
|
||||
|
||||
type ClientHints = {
|
||||
platform?: string;
|
||||
mobile?: boolean | null;
|
||||
brands?: string;
|
||||
};
|
||||
|
||||
type ClientEnv = {
|
||||
touchPoints?: number;
|
||||
hints?: ClientHints;
|
||||
platform?: string;
|
||||
language?: string;
|
||||
appVersion?: string;
|
||||
};
|
||||
|
||||
const SDK_VERSION_FALLBACK = "1.2.0";
|
||||
const FP_VERSION_FALLBACK = "1.0.0";
|
||||
const APP_VERSION_FALLBACK = "1.0.0";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__trackerReportPatch?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
function liveEnv(): ClientEnv {
|
||||
if (typeof navigator === "undefined") return {};
|
||||
const nav = navigator as Navigator & {
|
||||
userAgentData?: { platform?: string; mobile?: boolean; brands?: Array<{ brand?: string; version?: string }> };
|
||||
deviceMemory?: number;
|
||||
};
|
||||
const hints = nav.userAgentData;
|
||||
return {
|
||||
touchPoints: nav.maxTouchPoints || 0,
|
||||
platform: nav.platform || "",
|
||||
language: nav.language || "",
|
||||
appVersion: nav.appVersion || "",
|
||||
hints: hints
|
||||
? {
|
||||
platform: hints.platform,
|
||||
mobile: typeof hints.mobile === "boolean" ? hints.mobile : null,
|
||||
brands: Array.isArray(hints.brands)
|
||||
? hints.brands.map((item) => `${item.brand || ""}/${item.version || ""}`).join(" ")
|
||||
: "",
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function guessAndroidBrand(model: string, ua: string) {
|
||||
const value = `${model} ${ua}`;
|
||||
const brands: Array<[RegExp, string]> = [
|
||||
[/Build\/HONOR|\bHONOR\b/i, "Honor"],
|
||||
[/Build\/HUAWEI|HUAWEI|HW-|HMA-|ELS-|ANA-|\bJAD-|\bNOH-/i, "Huawei"],
|
||||
[/\b[A-Z]{2,4}-(AL|AN|TL|EM|DL)\d{2}\b/, "Huawei"],
|
||||
[/SM-|samsung|Galaxy/i, "Samsung"],
|
||||
[/Pixel/i, "Google"],
|
||||
[/Redmi|POCO|XiaoMi|\bMi\b|Mi\s?\d/i, "Xiaomi"],
|
||||
[/\b2[0-9]{3,4}[A-Z][A-Z0-9]+\b/, "Xiaomi"],
|
||||
[/OPPO|\bCPH\d{3,4}|\bPB\w{2}|\bP[A-Z]{2}\d{2}\b/, "OPPO"],
|
||||
[/vivo|\bV\d{4}[A-Z]?\b/i, "vivo"],
|
||||
[/OnePlus|ONEPLUS|\bGM19|\bHD19|\bKB20|\bLE21/i, "OnePlus"],
|
||||
[/realme|\bRMX\d{3,4}/i, "realme"],
|
||||
[/Meizu/i, "Meizu"],
|
||||
[/Lenovo/i, "Lenovo"],
|
||||
[/\bZTE\b/i, "ZTE"],
|
||||
[/Nokia/i, "Nokia"],
|
||||
[/Sony|\bSO-|\bXQ-/i, "Sony"],
|
||||
[/Motorola|\bmoto\b/i, "Motorola"],
|
||||
[/asus/i, "ASUS"],
|
||||
];
|
||||
return brands.find(([pattern]) => pattern.test(value))?.[1] || "";
|
||||
}
|
||||
|
||||
function parseBotInfo(ua: string): ReportDevice | null {
|
||||
const known: Array<[RegExp, string, string]> = [
|
||||
[/bingbot/i, "Bing", "bingbot"],
|
||||
[/googlebot/i, "Google", "Googlebot"],
|
||||
[/baiduspider/i, "Baidu", "Baiduspider"],
|
||||
[/yandex(bot|\.com\/bots)/i, "Yandex", "YandexBot"],
|
||||
[/duckduckbot/i, "DuckDuckGo", "DuckDuckBot"],
|
||||
[/bytespider/i, "ByteDance", "Bytespider"],
|
||||
[/petalbot/i, "Huawei", "PetalBot"],
|
||||
[/applebot/i, "Apple", "Applebot"],
|
||||
[/sogou.{0,6}spider/i, "Sogou", "Sogou Spider"],
|
||||
[/360spider/i, "360", "360Spider"],
|
||||
[/semrushbot/i, "Semrush", "SemrushBot"],
|
||||
[/ahrefsbot/i, "Ahrefs", "AhrefsBot"],
|
||||
[/facebookexternalhit/i, "Facebook", "facebookexternalhit"],
|
||||
];
|
||||
const version = ua.match(/(?:bot|spider)[/ ]v?([\d.]+)/i)?.[1] || "";
|
||||
for (const [pattern, brand, model] of known) {
|
||||
if (pattern.test(ua)) {
|
||||
return { device_brand: brand, device_model: model, system_name: "Bot", system_version: version };
|
||||
}
|
||||
}
|
||||
const generic = ua.match(/([A-Za-z][A-Za-z]*bot)\/([\d.]+)/) || ua.match(/([A-Za-z][A-Za-z]*spider)\/?([\d.]*)/i);
|
||||
if (!generic) return null;
|
||||
return { device_brand: "", device_model: generic[1], system_name: "Bot", system_version: generic[2] || "" };
|
||||
}
|
||||
|
||||
function clientHints(env: ClientEnv): ClientHints {
|
||||
return env.hints || { platform: "", mobile: null, brands: "" };
|
||||
}
|
||||
|
||||
export function buildUAFallback(env: ClientEnv = liveEnv()) {
|
||||
if (typeof navigator !== "undefined" && navigator.userAgent) return navigator.userAgent;
|
||||
const hints = clientHints(env);
|
||||
const parts = [hints.platform, hints.brands, env.platform, env.appVersion, env.language].filter(Boolean);
|
||||
return parts.join("; ").replace(/\s+/g, " ").trim() || "unknown";
|
||||
}
|
||||
|
||||
function extractAndroidModel(ua: string) {
|
||||
const code = ua.match(/;\s*([A-Z][A-Z0-9]{1,5}-[A-Z0-9]{2,})/);
|
||||
if (code) return code[1].trim();
|
||||
const matched = ua.match(/;\s*([^;()]+?)\s+Build\//) || ua.match(/;\s*([^;()]+?)\)\s*(?:AppleWebKit|Gecko|$)/);
|
||||
let model = matched?.[1]?.trim() || "";
|
||||
model = model
|
||||
.replace(/\bwv\b/i, "")
|
||||
.replace(/^U\s+/i, "")
|
||||
.replace(/\bzh[-_][A-Za-z-]+\b/gi, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (
|
||||
/^(rv:|Mobile$|Tablet$)/i.test(model) ||
|
||||
/OpenHarmony|HarmonyOS|HMSCore|^Android\b|^Linux\b/i.test(model) ||
|
||||
!/[A-Za-z0-9]/.test(model)
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return model;
|
||||
}
|
||||
|
||||
export function guessDeviceType(ua: string, touchPoints = 0) {
|
||||
if (/Android/i.test(ua)) return "Android";
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return "iOS";
|
||||
if (/Macintosh/.test(ua) && touchPoints > 1) return "iOS";
|
||||
return "PC";
|
||||
}
|
||||
|
||||
export function parseUAClientInfo(ua?: string, env: ClientEnv = liveEnv()): ReportDevice {
|
||||
const source = ua ?? (typeof navigator === "undefined" ? "" : navigator.userAgent || "");
|
||||
const out: ReportDevice = { device_brand: "", device_model: "", system_name: "", system_version: "" };
|
||||
try {
|
||||
const bot = parseBotInfo(source);
|
||||
if (bot) {
|
||||
out.device_brand = bot.device_brand;
|
||||
out.device_model = bot.device_model;
|
||||
out.system_name = bot.system_name;
|
||||
out.system_version = bot.system_version;
|
||||
} else if (/iPhone|iPad|iPod/.test(source)) {
|
||||
out.device_brand = "Apple";
|
||||
out.device_model = /iPad/.test(source) ? "iPad" : /iPod/.test(source) ? "iPod" : "iPhone";
|
||||
out.system_name = "iOS";
|
||||
const version = source.match(/OS (\d+[_.]\d+(?:[_.]\d+)?)/);
|
||||
if (version) out.system_version = version[1].replace(/_/g, ".");
|
||||
} else if (/OpenHarmony|HarmonyOS|ArkWeb/i.test(source)) {
|
||||
out.system_name = "HarmonyOS";
|
||||
const version = source.match(/(?:OpenHarmony|HarmonyOS)\s+([\d.]+)/i) || source.match(/Android\s+([\d.]+)/);
|
||||
if (version) out.system_version = version[1];
|
||||
out.device_model = extractAndroidModel(source) || (/Tablet/i.test(source) ? "Tablet" : "Phone");
|
||||
out.device_brand = guessAndroidBrand(out.device_model, source);
|
||||
} else if (/Android/.test(source)) {
|
||||
out.system_name = "Android";
|
||||
const version = source.match(/Android\s+([\d.]+)/);
|
||||
if (version) out.system_version = version[1];
|
||||
const model = extractAndroidModel(source) || (/Tablet/i.test(source) ? "Tablet" : "Mobile");
|
||||
out.device_model = model;
|
||||
out.device_brand = guessAndroidBrand(model, source);
|
||||
} else if (/Windows NT/.test(source)) {
|
||||
out.system_name = "Windows";
|
||||
const version = source.match(/Windows NT ([\d.]+)/);
|
||||
const names: Record<string, string> = { "10.0": "10", "6.3": "8.1", "6.2": "8", "6.1": "7" };
|
||||
if (version) out.system_version = names[version[1]] || version[1];
|
||||
out.device_model = "PC";
|
||||
} else if (/Windows/i.test(source)) {
|
||||
out.system_name = "Windows";
|
||||
out.device_model = "PC";
|
||||
} else if (/CrOS/.test(source)) {
|
||||
out.system_name = "Chrome OS";
|
||||
out.device_model = "PC";
|
||||
const version = source.match(/CrOS\s+\S+\s+([\d.]+)/);
|
||||
if (version) out.system_version = version[1];
|
||||
} else if (/Macintosh|Mac OS X/.test(source)) {
|
||||
const touchIPad = /Macintosh/.test(source) && (env.touchPoints || 0) > 1;
|
||||
if (touchIPad) {
|
||||
out.system_name = "iOS";
|
||||
out.device_brand = "Apple";
|
||||
out.device_model = "iPad";
|
||||
const version = source.match(/Version\/(\d+[_.]\d+(?:[_.]\d+)?)/);
|
||||
if (version) out.system_version = version[1].replace(/_/g, ".");
|
||||
} else {
|
||||
out.system_name = "macOS";
|
||||
out.device_brand = "Apple";
|
||||
out.device_model = "Mac";
|
||||
const version = source.match(/Mac OS X (\d+[_.]\d+(?:[_.]\d+)?)/);
|
||||
if (version) out.system_version = version[1].replace(/_/g, ".");
|
||||
}
|
||||
} else if (/Linux/.test(source)) {
|
||||
out.system_name = "Linux";
|
||||
out.device_model = "PC";
|
||||
}
|
||||
|
||||
if (!out.system_name || !out.device_model) {
|
||||
const hints = clientHints(env);
|
||||
if (!out.system_name && hints.platform) out.system_name = hints.platform;
|
||||
if (!out.device_model) {
|
||||
if (hints.mobile === false) out.device_model = "PC";
|
||||
else if (hints.mobile === true) out.device_model = "Mobile";
|
||||
}
|
||||
if (!out.device_brand && hints.platform === "macOS") out.device_brand = "Apple";
|
||||
}
|
||||
if (!out.system_name) {
|
||||
const device = guessDeviceType(source, env.touchPoints || 0);
|
||||
out.system_name = device === "Android" ? "Android" : device === "iOS" ? "iOS" : device === "PC" ? "PC" : "Unknown";
|
||||
}
|
||||
if (!out.system_version) out.system_version = "0.0.0";
|
||||
if (!out.device_model) out.device_model = "Unknown";
|
||||
} catch {
|
||||
/* 解析失败时走下面的空值兜底 */
|
||||
}
|
||||
if (!out.system_name) out.system_name = "Unknown";
|
||||
if (!out.system_version) out.system_version = "0.0.0";
|
||||
if (!out.device_model) out.device_model = "Unknown";
|
||||
return out;
|
||||
}
|
||||
|
||||
function fingerprintFallback(ua: string, env: ClientEnv) {
|
||||
try {
|
||||
const screenInfo = typeof screen === "undefined" ? undefined : screen;
|
||||
const parts = [
|
||||
ua,
|
||||
env.platform || "",
|
||||
env.language || "",
|
||||
`${screenInfo?.width || ""}x${screenInfo?.height || ""}`,
|
||||
screenInfo?.colorDepth || "",
|
||||
new Date().getTimezoneOffset(),
|
||||
typeof navigator === "undefined" ? "" : navigator.hardwareConcurrency || "",
|
||||
].join("||");
|
||||
let hash = 2166136261 >>> 0;
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
hash ^= parts.charCodeAt(index);
|
||||
hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
|
||||
}
|
||||
return `fp_fb_${hash.toString(16).padStart(8, "0")}`;
|
||||
} catch {
|
||||
return "fp_fb_unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/** 给批量上报 JSON 补上设备与系统字段。没有改动时返回 null。 */
|
||||
export function augmentReportBody(body: string, options: { ua?: string; env?: ClientEnv; appVersion?: string } = {}) {
|
||||
try {
|
||||
if (!body) return null;
|
||||
const records = JSON.parse(body) as unknown;
|
||||
if (!Array.isArray(records) || !records.length) return null;
|
||||
const env = options.env || liveEnv();
|
||||
const ua = options.ua ?? (typeof navigator === "undefined" ? "" : navigator.userAgent || "");
|
||||
const info = parseUAClientInfo(ua, env);
|
||||
const uaFallback = ua || buildUAFallback(env);
|
||||
const device = guessDeviceType(ua, env.touchPoints || 0);
|
||||
const fingerprint = fingerprintFallback(ua, env);
|
||||
const appVersion = options.appVersion?.trim() || APP_VERSION_FALLBACK;
|
||||
let changed = false;
|
||||
for (const record of records) {
|
||||
if (!record || typeof record !== "object") continue;
|
||||
const item = record as Record<string, unknown>;
|
||||
const fill = (key: string, value: string) => {
|
||||
if (!text(item[key]) && value) {
|
||||
item[key] = value;
|
||||
changed = true;
|
||||
}
|
||||
};
|
||||
fill("device_brand", info.device_brand);
|
||||
fill("device_model", info.device_model);
|
||||
fill("system_name", info.system_name);
|
||||
fill("system_version", info.system_version);
|
||||
fill("device", device);
|
||||
if (!text(item.system_name)) {
|
||||
item.system_name = "Unknown";
|
||||
changed = true;
|
||||
}
|
||||
if (!text(item.system_version)) {
|
||||
item.system_version = "0.0.0";
|
||||
changed = true;
|
||||
}
|
||||
fill("device_fingerprint", fingerprint);
|
||||
fill("fp_version", FP_VERSION_FALLBACK);
|
||||
fill("sdk_version", SDK_VERSION_FALLBACK);
|
||||
fill("app_version", appVersion);
|
||||
fill("user_agent", uaFallback);
|
||||
}
|
||||
return changed ? JSON.stringify(records) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isReportUrl(url: string) {
|
||||
return /eventTracking\/batchReport/i.test(url);
|
||||
}
|
||||
|
||||
/** 在 SDK 发出 batchReport 前补齐公共字段。重复调用只安装一次。 */
|
||||
export function installReportFieldPatch(appVersion = APP_VERSION_FALLBACK) {
|
||||
if (typeof window === "undefined" || window.__trackerReportPatch) return;
|
||||
window.__trackerReportPatch = true;
|
||||
const version = appVersion.trim() || APP_VERSION_FALLBACK;
|
||||
|
||||
if (typeof window.fetch === "function") {
|
||||
const origFetch = window.fetch.bind(window);
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
try {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
if (isReportUrl(url) && init && typeof init.body === "string") {
|
||||
const next = augmentReportBody(init.body, { appVersion: version });
|
||||
if (next != null) init = { ...init, body: next };
|
||||
}
|
||||
} catch {
|
||||
/* 补齐失败时走原始请求 */
|
||||
}
|
||||
return origFetch(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof XMLHttpRequest === "undefined") return;
|
||||
if (typeof XMLHttpRequest === "undefined") return;
|
||||
const proto = XMLHttpRequest.prototype;
|
||||
const origOpen = proto.open;
|
||||
const origSend = proto.send;
|
||||
type ReportXHR = XMLHttpRequest & { __trackerReportUrl?: boolean };
|
||||
proto.open = function (this: ReportXHR, method: string, url: string | URL, async?: boolean, username?: string | null, password?: string | null) {
|
||||
try {
|
||||
this.__trackerReportUrl = isReportUrl(String(url));
|
||||
} catch {
|
||||
this.__trackerReportUrl = false;
|
||||
}
|
||||
return origOpen.call(this, method, url, async ?? true, username, password);
|
||||
};
|
||||
proto.send = function (this: ReportXHR, body?: Document | XMLHttpRequestBodyInit | null) {
|
||||
try {
|
||||
if (this.__trackerReportUrl && typeof body === "string") {
|
||||
const next = augmentReportBody(body, { appVersion: version });
|
||||
if (next != null) return origSend.call(this, next);
|
||||
}
|
||||
} catch {
|
||||
/* 补齐失败时走原始请求 */
|
||||
}
|
||||
return origSend.call(this, body);
|
||||
};
|
||||
|
||||
if (typeof navigator.sendBeacon === "function") {
|
||||
const origBeacon = navigator.sendBeacon.bind(navigator);
|
||||
navigator.sendBeacon = (url: string | URL, data?: BodyInit | null) => {
|
||||
try {
|
||||
if (isReportUrl(String(url)) && typeof data === "string") {
|
||||
const next = augmentReportBody(data, { appVersion: version });
|
||||
if (next != null) data = next;
|
||||
}
|
||||
} catch {
|
||||
/* 补齐失败时走原始请求 */
|
||||
}
|
||||
return origBeacon(url, data);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isIP } from 'node:net';
|
||||
import { buildUserAgent, userAgentSid } from './user-agent';
|
||||
import { trackingHeaders } from './tracking-headers';
|
||||
import { sdkHeaders } from './sdk-headers';
|
||||
|
||||
type HeaderSource=Pick<Headers,'get'>;
|
||||
|
||||
@@ -11,7 +11,7 @@ export function clientIp(source:HeaderSource):string {
|
||||
return isIP(forwarded)?forwarded:'';
|
||||
}
|
||||
|
||||
/** Session device from drama_device; business DevID must not be replaced by the SDK fingerprint. */
|
||||
/** Session device from drama_device; business DevID always uses the visitor device id. */
|
||||
export function businessDeviceId(cookie:string):string {
|
||||
const raw=cookie.split(';').map(part=>part.trim()).find(part=>part.startsWith('drama_device='))?.slice('drama_device='.length)||'';
|
||||
try{
|
||||
@@ -37,6 +37,6 @@ export function clientHeaders(source:HeaderSource|undefined,path='',method='GET'
|
||||
};
|
||||
if(ua)headers['User-Agent']=ua;
|
||||
if(ip)headers['X-Real-IP']=ip;
|
||||
if(source)Object.assign(headers,trackingHeaders(source,path,method));
|
||||
if(source)Object.assign(headers,sdkHeaders(source));
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
const object=(value:unknown):value is Record<string,unknown>=>!!value&&typeof value==='object'&&!Array.isArray(value);
|
||||
const string=(value:unknown,max:number)=>typeof value==='string'&&value.length<=max&&!/[\x00-\x1f\x7f]/.test(value);
|
||||
/** Visitor identity fields the browser may send. Analytics events and contexts are not forwarded. */
|
||||
const SDK_FIELD_LIMITS:Record<string,number>={sid:50,device_id:50,app_version:128};
|
||||
|
||||
/** Forward the visitor device snapshot (X-SDK-Common) only; it carries the guest device id. */
|
||||
export function sdkHeaders(source:Pick<Headers,'get'>):Record<string,string>{
|
||||
const sdkHeader=source.get('x-sdk-common')||'';
|
||||
if(!sdkHeader||sdkHeader.length>4096||!/^[a-z\d+/]+=*$/i.test(sdkHeader))return {};
|
||||
try{
|
||||
const fields:unknown=JSON.parse(Buffer.from(sdkHeader,'base64').toString('utf8'));
|
||||
if(!object(fields))return {};
|
||||
const common=Object.fromEntries(Object.entries(fields).filter(([key,value])=>key in SDK_FIELD_LIMITS&&string(value,SDK_FIELD_LIMITS[key])));
|
||||
if(!common.device_id)return {};
|
||||
return {'X-SDK-Common':Buffer.from(JSON.stringify(common)).toString('base64')};
|
||||
}catch{return {};}
|
||||
}
|
||||
+7
-10
@@ -5,28 +5,25 @@ import { headers } from 'next/headers';
|
||||
import { publicCachePolicy, publicDataCache } from './public-cache';
|
||||
import { getApiBaseUrl, usesCurrentApiOrigin, UPSTREAM_HOP_HEADER, type ApiRequestContext } from './config';
|
||||
import { clientHeaders } from './client-headers';
|
||||
import { withoutRemovedNavigation } from '../site-navigation';
|
||||
|
||||
export { DEFAULT_API_BASE_URL } from './config';
|
||||
|
||||
export const PUBLIC_PATHS = new Set([
|
||||
"playlet/home", "config/info", "config/footer/info", "config/richtext/list",
|
||||
"navigation/header", "theme/list", "playlet/tag/options", "translation/category/list",
|
||||
"recharge/package/list", "applist/list", "playlet/all/list", "playlet/operate/list", "playlet/home-config/list",
|
||||
"playlet/all/list", "playlet/operate/list", "playlet/home-config/list",
|
||||
"playlet/hot/rank/list", "playlet/topic/list", "playlet/topic/detail", "playlet/tag/list",
|
||||
"playlet/guess-like/list", "playlet/hot-like",
|
||||
]);
|
||||
const GET_PATHS = [
|
||||
...PUBLIC_PATHS, "auth/getInfo", "playlet/detail", "playlet/chapter/list", "playlet/chapter/detail",
|
||||
"playlet/search", "playlet/search/suggest", "playlet/collect/list", "playlet/chapter/collect/list",
|
||||
"playlet/history/list", "playlet/like/list", "playlet/bought/list", "user/center/data", "coin/orders", "coin/trans/list",
|
||||
"vip/orders", "comment/list",
|
||||
"playlet/search", "playlet/search/suggest",
|
||||
];
|
||||
const POST_PATHS = [
|
||||
"auth/guest", "auth/login", "auth/register", "auth/resetPassword", "auth/profile",
|
||||
"auth/guest",
|
||||
"playlet/collect/set", "playlet/like/set", "playlet/chapter/collect/set", "playlet/chapter/like/set",
|
||||
"playlet/chapter/unlock", "playlet/chapter/watch/report", "playlet/forward/heat",
|
||||
"playlet/history/clear", "playlet/history/delete", "comment/send", "comment/like/set",
|
||||
"coin/buy", "vip/pay", "feedback/add", "upload/image",
|
||||
"playlet/chapter/watch/report", "playlet/forward/heat",
|
||||
];
|
||||
export function allowedRequest(path: string, method: string): boolean {
|
||||
return (method === "GET" ? GET_PATHS : method === "POST" ? POST_PATHS : []).includes(path);
|
||||
@@ -56,7 +53,7 @@ async function requestUpstream<T>(url:URL,options:Parameters<typeof upstream>[1]
|
||||
[UPSTREAM_HOP_HEADER]: '1',
|
||||
};
|
||||
if (options.token) headers.Authorization = options.token;
|
||||
for(const key of ['X-SDK-Common','X-Tracker-Request-Id','X-Tracker-Event-Name','X-DC-Request-Context'])if(options.tracking?.[key])headers[key]=options.tracking[key];
|
||||
if(options.tracking?.['X-SDK-Common'])headers['X-SDK-Common']=options.tracking['X-SDK-Common'];
|
||||
const multipart = options.body instanceof FormData;
|
||||
if (options.body && !multipart) headers["Content-Type"] = "application/json";
|
||||
let response: Response;
|
||||
@@ -91,6 +88,6 @@ export const getHeaderNavigation=cache(async ():Promise<{data:HeaderNavigation;e
|
||||
try{
|
||||
const response=await upstream<HeaderNavigation>('navigation/header',{timeoutMs:5000});
|
||||
if(response.code!==200||!response.data||typeof response.data!=='object')throw new Error('导航加载失败');
|
||||
return {data:response.data};
|
||||
return {data:withoutRemovedNavigation(response.data)};
|
||||
}catch{return {data:{categoryList:[]},error:true};}
|
||||
});
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { COMMON_KEYS, COMMON_FIELD_LIMITS, SDK_FIELD_KEYS, SERVER_EVENTS, TRACKER_APP_ID } from '../tracking';
|
||||
|
||||
const object=(value:unknown):value is Record<string,unknown>=>!!value&&typeof value==='object'&&!Array.isArray(value);
|
||||
const string=(value:unknown,max:number)=>typeof value==='string'&&value.length<=max&&!/[\x00-\x1f\x7f]/.test(value);
|
||||
const DRAMA_KEYS=['media_id','drama_id','episode','video_id','recommend_trace_id'];
|
||||
|
||||
/** Forward only the SDK snapshot and the event associated with this business route. */
|
||||
export function trackingHeaders(source:Pick<Headers,'get'>,path:string,method:string):Record<string,string>{
|
||||
const headers:Record<string,string>={};
|
||||
const sdkHeader=source.get('x-sdk-common')||'';
|
||||
if(sdkHeader&&sdkHeader.length<=16384&&/^[a-z\d+/]+=*$/i.test(sdkHeader)){
|
||||
try{
|
||||
const fields:unknown=JSON.parse(Buffer.from(sdkHeader,'base64').toString('utf8'));
|
||||
const validField=(key:string,value:unknown)=>key==='client_ts'?Number.isSafeInteger(value)&&Number(value)>0:string(value,COMMON_FIELD_LIMITS[key]);
|
||||
if(object(fields)&&SDK_FIELD_KEYS.every(key=>!Object.hasOwn(fields,key)||validField(key,fields[key]))){
|
||||
const common=Object.fromEntries(Object.entries(fields).filter(([key])=>SDK_FIELD_KEYS.includes(key as typeof SDK_FIELD_KEYS[number])));
|
||||
headers['X-SDK-Common']=Buffer.from(JSON.stringify(common)).toString('base64');
|
||||
}
|
||||
}catch{}
|
||||
}
|
||||
const event=SERVER_EVENTS[path],requestId=source.get('x-tracker-request-id')||'',packed=source.get('x-dc-request-context')||'';
|
||||
if(method!=='POST'||!event||source.get('x-tracker-event-name')!==event||!(/^[a-f\d]{32}$/i.test(requestId))||!packed||packed.length>8192||!/^[a-z\d_-]+$/i.test(packed))return headers;
|
||||
try{
|
||||
const raw=Buffer.from(packed,'base64url');if(raw.length>6144)return headers;
|
||||
const context=JSON.parse(raw.toString('utf8'));
|
||||
if(!object(context)||context.v!==1||context.request_id!==requestId||context.event_name!==event||!object(context.common)||!object(context.drama))return headers;
|
||||
const {common,drama}=context;
|
||||
if(Object.keys(common).length!==COMMON_KEYS.length||!COMMON_KEYS.every(key=>Object.hasOwn(common,key))||Object.keys(drama).length!==DRAMA_KEYS.length||!DRAMA_KEYS.every(key=>Object.hasOwn(drama,key)))return headers;
|
||||
if(!COMMON_KEYS.every(key=>key==='client_ts'?Number.isSafeInteger(common[key])&&Number(common[key])>0:string(common[key],COMMON_FIELD_LIMITS[key])))return headers;
|
||||
if(common.app_id!==TRACKER_APP_ID||!common.device_id||!['','Android','iOS','PC','OTHER'].includes(String(common.device)))return headers;
|
||||
if(common.uid&&!/^[a-z\d_]{4,32}$/i.test(String(common.uid)))return headers;
|
||||
if(!/^[a-z\d_.-]{1,50}$/i.test(String(common.channel)))return headers;
|
||||
if(!DRAMA_KEYS.every(key=>key==='episode'?Number.isSafeInteger(drama[key])&&Number(drama[key])>0:string(drama[key],key==='recommend_trace_id'?2048:512))||!drama.drama_id)return headers;
|
||||
headers['X-Tracker-Request-Id']=requestId;headers['X-Tracker-Event-Name']=event;
|
||||
headers['X-DC-Request-Context']=Buffer.from(JSON.stringify({v:1,request_id:requestId,event_name:event,common,drama})).toString('base64url');
|
||||
}catch{}
|
||||
return headers;
|
||||
}
|
||||
@@ -22,13 +22,12 @@ export interface HomeData { modules?: HomeModule[]; homeBannerData?: Drama[]; ho
|
||||
export interface User { userId: number; accountNo?: string; nickName?: string; avatar?: string; introduction?: string; email?: string; isRegister?: boolean; type?: number; recharge?: number; vipType?: number; currentVipExpire?: string; hasPassword?: boolean; token?: string }
|
||||
export interface Chapter { mediaId?: string; mediaTime?: number; width?: number; height?: number; mediaSize?: number; countBrowse?: number; countLike?: number; countCollect?: number; countComment?: number; ratio?: number; seoInfo?: SeoInfo; id: number; playletId?: number; currentEpisode?: number; title?: string; videoUrl?: string; canWatch?: boolean; needUnlock?: boolean; needVip?: boolean; isUnlocked?: boolean; vipValid?: boolean; payType?: number; singleCoins?: number; costCoins?: number; balance?: number; isCollect?: number; isLike?: number }
|
||||
export interface DramaDetail extends Drama { preview?: string; video?: {url?: string; cover?: string; videoTime?: number; width?: number; height?: number}; relatedRecommendData?: Drama[]; playletChapterInfo?: Chapter[]; advitise?: Advertisements }
|
||||
export interface Comment { id: number; text: string; userName?: string; userAvatar?: string; createdAt?: string; children?: Comment[]; isLike?: number; countLike?: number; parentsId?: number; replyCommentId?: number; replyUserName?: string; commentCount?: number; level?: number }
|
||||
export interface PageData<T> { id?:number; slug?:string; config?:HomeModule; topicList?:Topic[]; topicGroups?:TopicGroup[]; list?: T[]; pageNum?: number; pageSize?: number; totalCount?: number; name?: string; title?: string; topicInfo?: Topic; desc?: string; everyoneWatching?: Drama[]; moreTopics?: Topic[]; themeList?: Topic[]; videoList?: Drama[]; advitise?: Advertisements; seoInfo?: SeoInfo; [key:string]: unknown }
|
||||
export interface AppItem { id: number; categoryId?: number; advitiseId?: string; location?: number; name: string; logo?: string; intro?: string; description?: string; url?: string }
|
||||
export interface AppCategory { id: number; code?: number; title: string; intro?: string; appList?: AppItem[] }
|
||||
export interface AppData { category?: AppCategory; categoryList?: AppCategory[] }
|
||||
export interface Package { id: number; name: string; subTitle?: string; packageType: number; price: number; originalPrice?: number; durationHours?: number; baseCoin?: number; giftCoin?: number; totalCoin?: number; durationDays?: number; payMethods?: {type:string; typeName:string}[] }
|
||||
export interface Config { reportSdkConfig?: string; config?: Record<string, unknown>; newAddressInfo?: Record<string, unknown>; navPageList?: {line:number; url:string}[]; advitise?: Advertisements; seoInfo?: SeoInfo | string; seoNavInfo?: Record<string,unknown> | string }
|
||||
export interface Config { config?: Record<string, unknown>; newAddressInfo?: Record<string, unknown>; navPageList?: {line:number; url:string}[]; advitise?: Advertisements; seoInfo?: SeoInfo | string; seoNavInfo?: Record<string,unknown> | string }
|
||||
export interface FooterContact { name?: string; value?: string }
|
||||
export interface FooterNavItem { id?: number; name?: string; urlValue?: string; translationList?: FooterNavItem[] | null }
|
||||
export interface FooterInfoResponse { footerInfo?: { brand?: {title?: string; content?: string}; contactList?: FooterContact[] | null; navList?: FooterNavItem[] | null } }
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 站点品牌名。后台文案、页面标题与页脚统一使用。 */
|
||||
export const SITE_NAME = "黄果短剧";
|
||||
|
||||
/** 后台文案里残留的旧站名统一换成 SITE_NAME。 */
|
||||
const LEGACY_SITE_NAMES = /禁果短剧|91短剧|黄豆魔改短剧|黄豆短剧/g;
|
||||
|
||||
export function brandText(value?: string | null): string {
|
||||
return (value || "").trim().replace(LEGACY_SITE_NAMES, SITE_NAME);
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import type { MemberInitialData, WalletInitialData } from "../page-data";
|
||||
import type { Advertisement, Drama, Package, PageData, User } from "../api/types";
|
||||
import { recordList, safeUrl } from "../api/protocol";
|
||||
import type { SiteContext } from "./context";
|
||||
import { ads, card, closeButton, empty, escape, imageAttributes, pagination } from "./render";
|
||||
import type { PageHref } from './render';
|
||||
import { trackingPage } from '../tracking';
|
||||
import { authFormPayload } from './auth';
|
||||
|
||||
const passwordEye=(revealed=false)=>`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>${revealed?'<path d="m3 3 18 18"/>':''}</svg>`;
|
||||
|
||||
export function bindAccountUI(app: SiteContext) {
|
||||
const dialog=document.getElementById('member-auth-dialog') as HTMLDialogElement;
|
||||
dialog.addEventListener('click',event=>{
|
||||
const toggle=(event.target as Element).closest<HTMLButtonElement>('[data-auth-password-toggle]');
|
||||
if(!toggle)return;
|
||||
const input=dialog.querySelector<HTMLInputElement>('#'+toggle.dataset.authPasswordToggle)!;
|
||||
const show=input.type==='password';input.type=show?'text':'password';
|
||||
toggle.innerHTML=passwordEye(show);toggle.setAttribute('aria-label',show?'隐藏密码':'显示密码');
|
||||
});
|
||||
dialog.addEventListener('input',()=>{const error=dialog.querySelector('#auth-error');if(error)error.textContent='';});
|
||||
dialog.addEventListener('submit',event=>{
|
||||
event.preventDefault();const form=event.target as HTMLFormElement;
|
||||
if(form.dataset.submitting||!form.reportValidity())return;
|
||||
const mode=form.dataset.mode||'login';const error=form.querySelector('#auth-error')!;
|
||||
const switching=form.dataset.authSwitch==='1';
|
||||
let payload:Record<string,string>;
|
||||
try{payload=authFormPayload(mode,Object.fromEntries(new FormData(form)));}
|
||||
catch(reason){error.textContent=reason instanceof Error?reason.message:'请检查输入';return;}
|
||||
const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;
|
||||
const label=button.textContent;form.dataset.submitting='1';button.disabled=true;
|
||||
button.textContent=mode==='register'?'绑定中…':mode==='resetPassword'?'提交中…':switching?'切换中…':'登录中…';error.textContent='';
|
||||
const previousUserId=app.api.user?.userId;
|
||||
void app.api.post<User>('auth/'+mode,switching?{...payload,switchAccount:true}:payload).then(async user=>{
|
||||
if(switching&&previousUserId&&user?.userId===previousUserId){if(form.isConnected)error.textContent='切换失败,请确认账号和密码';return;}
|
||||
form.querySelectorAll<HTMLInputElement>('[data-auth-password]').forEach(input=>input.value='');
|
||||
if(mode==='resetPassword'){app.notify('密码已更新,请重新登录');if(form.isConnected)app.openAuth();return;}
|
||||
if(form.isConnected)dialog.close();
|
||||
if(mode==='register'){
|
||||
// Bind issues a new JWT and invalidates the guest token. Confirm getInfo with the new
|
||||
// bearer, then stay on the settings page — a full reload would replay the stale
|
||||
// HttpOnly drama_session, hit getInfo 1000, and open the login dialog.
|
||||
await app.api.refreshUser();
|
||||
const state=document.querySelector('[data-bind-state]');if(state)state.textContent='已绑定';
|
||||
app.notify('绑定成功');
|
||||
return;
|
||||
}
|
||||
app.notify(switching?'已切换账号':'登录成功');
|
||||
await app.api.refreshUser().catch(()=>{});
|
||||
await app.route();
|
||||
}).catch(reason=>{if(form.isConnected)error.textContent=reason instanceof Error?reason.message:'请求失败,请重试';})
|
||||
.finally(()=>{delete form.dataset.submitting;button.disabled=false;button.textContent=label;});
|
||||
});
|
||||
dialog.addEventListener('close',()=>{dialog.querySelectorAll<HTMLInputElement>('[data-auth-password]').forEach(input=>input.value='');});
|
||||
}
|
||||
|
||||
export function openAuth(mode='login') {
|
||||
const switching=mode==='switch';
|
||||
if(mode==='bind')mode='register';
|
||||
if(!['login','register','resetPassword'].includes(mode))mode='login';
|
||||
const dialog=document.getElementById('member-auth-dialog') as HTMLDialogElement;
|
||||
dialog.classList.toggle('auth-bind-mode',mode==='register');
|
||||
const menu=document.getElementById('profile-popover');if(menu)menu.hidden=true;
|
||||
const heading=mode==='register'?'绑定账号':mode==='resetPassword'?'找回密码':switching?'切换账号':'账号登录';
|
||||
const passwordField=(id:string,name:string,label:string,confirm=false)=>`<label class="auth-field" for="${id}">${label}</label><div class="auth-input-row auth-password-row${mode==='register'?' auth-icon-row':''}">${mode==='register'?'<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>':''}<input id="${id}" name="${name}" type="password" data-auth-password autocomplete="${mode==='login'?'current-password':'new-password'}" minlength="${mode==='login'?4:8}" maxlength="64" placeholder="请输入${label}" required><button class="auth-password-toggle" type="button" data-auth-password-toggle="${id}" aria-label="显示${confirm?'确认':''}密码">${passwordEye()}</button></div>`;
|
||||
dialog.innerHTML=`${closeButton(dialog.id)}<h2 id="member-auth-title">${heading}</h2><p class="auth-subtitle">${mode==='register'?'绑定后可保留当前游客的收藏与观看记录':mode==='resetPassword'?'通过账号和已绑定邮箱找回密码。':switching?'请输入其他账号和密码完成切换。':'登录后同步收藏、观看记录与已购剧集。'}</p><form data-mode="${mode}"${switching?' data-auth-switch="1"':''}><label class="auth-field" for="auth-account">${mode==='login'?'账号或邮箱':'账号'}</label><div class="auth-input-row${mode==='register'?' auth-icon-row':''}">${mode==='register'?'<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/></svg>':''}<input id="auth-account" name="account" autocomplete="username" required maxlength="${mode==='register'?20:254}" placeholder="${mode==='register'?'6 到 20 位字母或数字':'请输入账号'+(mode==='login'?'或邮箱':'')}"></div>${mode==='resetPassword'?'<label class="auth-field" for="auth-email">绑定邮箱</label><div class="auth-input-row"><input id="auth-email" name="email" type="email" autocomplete="email" required></div>':''}${passwordField('auth-password','password',mode==='resetPassword'?'新密码':'密码')}${mode!=='login'?passwordField('auth-password-confirm','passwordConfirm','确认密码',true):''}${mode==='register'?'<p class="auth-note">密码需为 8–64 位,同时包含字母和数字。</p><label class="auth-bind-agreement"><input name="agree" type="checkbox" required><span>我已年满 18 岁并同意 <a href="/info/user-agreement/">《用户协议》</a> 和 <a href="/info/privacy-policy/">《隐私政策》</a></span></label>':''}<p id="auth-error" class="auth-error" role="alert" aria-live="polite"></p><button class="auth-submit" type="submit">${heading}</button><p class="auth-footer"><button type="button" data-auth-mode="login">登录</button> · <button type="button" data-auth-mode="register">绑定账号</button> · <button type="button" data-auth-mode="resetPassword">找回密码</button></p></form>`;
|
||||
if(!dialog.open)dialog.showModal();
|
||||
dialog.querySelector<HTMLInputElement>('#auth-account')?.focus();
|
||||
}
|
||||
|
||||
const profileIcons={
|
||||
user:'M718.196364 139.636364h-418.909091a209.454545 209.454545 0 0 0-209.454546 209.454545v330.938182a209.454545 209.454545 0 0 0 209.454546 209.454545h418.909091a209.454545 209.454545 0 0 0 209.454545-209.454545V349.090909a209.454545 209.454545 0 0 0-209.454545-209.454545z m-223.650909 465.454545a43.52 43.52 0 0 1-43.752728 43.287273h-119.621818A43.52 43.52 0 0 1 287.418182 605.090909a43.52 43.52 0 0 1 43.752727-43.287273h119.621818A43.52 43.52 0 0 1 494.545455 605.090909z m162.90909-159.418182h-325.818181a43.287273 43.287273 0 1 1 0-86.574545h325.818181a43.287273 43.287273 0 1 1 0 86.574545z',
|
||||
gift:'M508.509091 69.818182L135.912727 176.872727v418.909091a157.789091 157.789091 0 0 0 48.64 114.501818l46.545455 44.683637 7.447273 6.749091 14.196363 13.265454 13.963637 12.8 13.730909 12.101818 13.265454 11.636364 13.032727 11.170909c16.058182 13.498182 32.349091 26.530909 49.105455 39.098182l11.636364 8.378182c57.018182 41.425455 103.563636 60.741818 140.8 60.741818s83.549091-20.247273 139.636363-60.974546l11.636364-8.378181c16.756364-12.567273 33.28-25.6 49.105454-39.098182l13.032728-11.170909 13.498182-11.636364 13.498181-12.101818 13.963637-12.8 14.429091-13.265455 14.429091-13.730909 39.330909-37.701818a159.650909 159.650909 0 0 0 48.872727-115.2V176.872727z m0 659.549091a51.898182 51.898182 0 0 1 0-103.563637 51.898182 51.898182 0 0 1 0 103.563637z m52.363636-139.636364h-105.192727l-9.076364-319.534545h122.414546z',
|
||||
center:'M750.08 741.469091A363.985455 363.985455 0 0 0 884.363636 459.636364 369.338182 369.338182 0 0 0 512 93.090909 369.570909 369.570909 0 0 0 139.636364 459.636364a363.52 363.52 0 0 0 134.516363 281.832727A312.785455 312.785455 0 0 0 214.341818 884.363636 41.658182 41.658182 0 0 0 256 930.909091h513.163636a41.425455 41.425455 0 0 0 40.494546-46.545455 312.552727 312.552727 0 0 0-59.578182-142.894545z m-128.930909-192a126.836364 126.836364 0 0 1-218.763636 4.189091 36.770909 36.770909 0 0 1 13.032727-50.501818 37.701818 37.701818 0 0 1 51.432727 12.8 51.2 51.2 0 0 0 88.436364-1.861819 37.701818 37.701818 0 0 1 50.967272-14.661818 36.538182 36.538182 0 0 1 14.894546 50.036364z',
|
||||
bookmark:'M857.6 327.68l-104.494545-34.210909a178.036364 178.036364 0 0 1-90.763637-66.56l-64-89.367273a104.96 104.96 0 0 0-23.272727-23.272727 106.123636 106.123636 0 0 0-148.48 23.272727l-64.930909 89.367273a179.665455 179.665455 0 0 1-90.763637 66.792727l-104.494545 33.978182a106.821818 106.821818 0 0 0-53.061818 162.909091l93.090909 128.930909a35.84 35.84 0 0 1 6.981818 21.643636v158.952728a109.381818 109.381818 0 0 0 5.12 33.512727 105.658182 105.658182 0 0 0 133.818182 67.956364l128-39.33091a107.985455 107.985455 0 0 1 64.232727 0l127.534546 39.33091a106.123636 106.123636 0 0 0 139.636363-101.469091v-158.254546a35.84 35.84 0 0 1 6.981818-21.643636l93.09091-128.930909a104.494545 104.494545 0 0 0 15.127272-29.556364 106.821818 106.821818 0 0 0-69.352727-134.050909z m-192 83.549091a34.909091 34.909091 0 0 1-48.174545 10.938182l-46.545455-29.090909a34.909091 34.909091 0 0 1 37.003636-59.112728l46.545455 29.090909a34.909091 34.909091 0 0 1 11.170909 48.174546z',
|
||||
like:'M512 896l-58-53C248 656 112 533 112 382c0-123 97-220 220-220 70 0 137 33 180 85 43-52 110-85 180-85 123 0 220 97 220 220 0 151-136 274-342 461z',
|
||||
clock:'M512 46.545455a465.454545 465.454545 0 1 0 465.454545 465.454545A465.454545 465.454545 0 0 0 512 46.545455z m34.909091 479.883636L398.894545 674.909091a35.607273 35.607273 0 0 1-24.901818 10.24A35.141818 35.141818 0 0 1 349.090909 626.501818l128.698182-128.930909V302.545455a34.909091 34.909091 0 0 1 69.818182 0z',
|
||||
coin:'M896 272.756364h-15.592727l-535.272728-175.476364A106.821818 106.821818 0 0 0 209.454545 200.145455v72.610909H153.6a83.781818 83.781818 0 0 0-83.781818 83.781818v490.356363A84.014545 84.014545 0 0 0 153.6 930.909091h716.567273A84.014545 84.014545 0 0 0 954.181818 846.894545V330.938182a58.181818 58.181818 0 0 0-58.181818-58.181818zM279.272727 200.145455a37.934545 37.934545 0 0 1 14.894546-30.021819 34.909091 34.909091 0 0 1 30.72-6.050909l331.869091 108.683637H279.272727z m35.141818 435.2a54.923636 54.923636 0 1 1 55.389091-54.923637 54.923636 54.923636 0 0 1-56.087272 54.923637z',
|
||||
switch:'M861.090909 494.778182a84.712727 84.712727 0 0 0-52.130909-64.698182l-97.745455-40.261818 64.232728-99.374546a84.247273 84.247273 0 0 0 6.749091-79.592727 87.505455 87.505455 0 0 0-65.163637-51.432727L361.425455 94.487273a87.272727 87.272727 0 0 0-100.538182 62.836363l-95.418182 372.363637a81.221818 81.221818 0 0 0 10.007273 63.534545 85.178182 85.178182 0 0 0 55.389091 37.469091l91.22909 18.152727-27.694545 183.621819a84.247273 84.247273 0 0 0 43.52 86.574545 88.669091 88.669091 0 0 0 43.287273 11.403636 87.272727 87.272727 0 0 0 57.716363-21.643636l392.843637-337.454545A83.316364 83.316364 0 0 0 861.090909 494.778182z m-256 53.992727l-59.112727 71.447273a34.443636 34.443636 0 0 1-26.996364 12.8 35.141818 35.141818 0 0 1-26.763636-57.250909l59.112727-71.447273A34.909091 34.909091 0 1 1 605.090909 548.770909z',
|
||||
} as const;
|
||||
const profileChevron='<span class="profile-next" aria-hidden="true"><svg class="ui-chevron-right" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false"><path d="m9 5 7 7-7 7"/></svg></span>';
|
||||
const profileIcon=(name:keyof typeof profileIcons)=>`<svg class="account-collection-icon" viewBox="0 0 1024 1024" aria-hidden="true"><path d="${profileIcons[name]}"/></svg>`;
|
||||
const profileCount=(id:string,value?:number)=>`<span class="profile-count" id="${id}">${typeof value==='number'&&Number.isFinite(value)&&value>=0?Math.floor(value).toLocaleString('zh-CN'):''}</span>`;
|
||||
const profileLink=(href:string,icon:string,label:string,count='')=>`<a class="profile-row" href="${href}">${icon}<span class="profile-row-label">${label}</span>${count}${profileChevron}</a>`;
|
||||
const profileAction=(mode:string,icon:string,label:string,footer=false)=>`<button type="button" data-auth-mode="${mode}"${footer?' class="profile-switch"':''}>${icon}<span class="profile-row-label">${label}</span>${footer?'':profileChevron}</button>`;
|
||||
|
||||
export interface AccountMenuStats { collectCount?: number; likeCount?: number; watchCount?: number; boughtChapterCount?: number }
|
||||
|
||||
export function accountMenuMarkup(user:User|null|undefined,stats?:AccountMenuStats):string {
|
||||
const name=user?.nickName||user?.accountNo||'游客';
|
||||
const coins=typeof user?.recharge==='number'&&Number.isFinite(user.recharge)&&user.recharge>=0?Math.floor(user.recharge):undefined;
|
||||
// Row order matches 禁果短剧_2组.html: login → bind → center → favorites → likes → history → coins → switch.
|
||||
return `<div class="profile-popover-head"><a class="profile-avatar-link" href="/member/center/" aria-label="进入个人中心"><img ${imageAttributes(user?.avatar,'/icon.svg')} width="44" height="44" alt="${escape(name)}的头像"></a><div><strong>${escape(name)}</strong><small>${user?'ID '+user.userId:'登录后同步个人数据'}</small></div></div>${profileAction('login',profileIcon('user'),'账号登录')}${profileAction('bind',profileIcon('gift'),'绑定账号')}${profileLink('/member/center/',profileIcon('center'),'个人中心')}${profileLink('/member/favorites/',profileIcon('bookmark'),'我的收藏',profileCount('profile-saved-count',stats?.collectCount))}${profileLink('/member/likes/',profileIcon('like'),'我的点赞',profileCount('profile-likes-count',stats?.likeCount))}${profileLink('/member/history/',profileIcon('clock'),'观看历史')}${profileLink('/info/deposit/',profileIcon('coin'),'金币充值',profileCount('profile-coin-count',coins))}${profileAction('switch',profileIcon('switch'),'切换账号',true)}`;
|
||||
}
|
||||
|
||||
const settingsChevron='<span aria-hidden="true"><svg class="ui-chevron-right" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" focusable="false"><path d="m9 5 7 7-7 7"/></svg></span>';
|
||||
|
||||
export function settingsMarkup(user:User):string {
|
||||
const bound=user.isRegister===true||user.type===1||user.hasPassword===true;
|
||||
return `<div class="member-settings"><section class="member-settings-card"><h2>个人资料</h2><img class="member-settings-avatar" ${imageAttributes(user.avatar,'/icon.svg')} width="88" height="88" alt="当前头像"><form id="member-nickname-form"><label for="member-nickname-input">昵称</label><div class="member-nickname-editor"><input id="member-nickname-input" name="nickname" type="text" maxlength="20" required autocomplete="nickname" aria-describedby="member-nickname-hint" value="${escape(user.nickName)}"><button type="submit">保存修改</button></div><p id="member-nickname-hint">支持 1–20 个字符</p><p id="member-nickname-status" role="status"></p></form></section><section class="member-settings-card"><h2>账号与安全</h2><button type="button" class="member-settings-action" data-member-settings="bind" data-auth-mode="register"><span><strong>账号绑定</strong><small>设置账号和密码,方便下次登录</small></span><span class="member-settings-trailing"><span data-bind-state>${bound?'已绑定':'未绑定'}</span> ${settingsChevron}</span></button><button type="button" class="member-settings-action" data-member-settings="switch" data-auth-mode="switch"><span><strong>切换账号</strong><small>使用其他账号登录</small></span>${settingsChevron}</button></section></div>`;
|
||||
}
|
||||
|
||||
export function memberMarkup(user:User,tab:string,data:PageData<Drama>={},home:SiteContext['home']={},pageNum=1,pageHref:PageHref=()=>'/member/history/'):string {
|
||||
const active=tab==='center'?'history':tab;
|
||||
const libraryPageHref:PageHref=tab==='center'?page=>page>1?`/member/history/page/${page}/`:'/member/history/':pageHref;
|
||||
const tabs=[['history','观看历史'],['favorites','我的收藏'],['likes','我的点赞'],['purchased','已购剧集']];
|
||||
const iconAds=ads(recordList<Advertisement>(home?.advitise?.nineGridList).filter(item=>safeUrl(item.images)).slice(0,16),'member-ad-tile');
|
||||
const header=`${iconAds?`<section class="member-advertisements" aria-label="广告推荐"><span class="member-ad-label">广告</span><div class="member-ad-grid">${iconAds}</div></section>`:''}<section class="member-summary"><div class="member-identity"><img ${imageAttributes(user.avatar,'/icon.svg')} alt="我的头像"><div><div class="member-name-row"><h2>${escape(user.nickName||user.accountNo||'游客')}</h2><a class="member-settings-link" href="/member/settings/">账号设置 <span aria-hidden="true">›</span></a></div><p>ID:${user.userId}</p></div></div><a class="member-wallet" href="/info/deposit/"><span>金币余额 <small>(金币)</small></span><strong><span class="coin-symbol" aria-hidden="true"><img class="coin-soft-star" src="/assets/897547dcbff56113.svg" alt="金币图标"></span>${(user.recharge||0).toLocaleString('zh-CN')}</strong><span class="member-wallet-link">金币充值 <span aria-hidden="true">↗</span></span></a></section>`;
|
||||
const nav='<nav class="member-library-tabs" aria-label="我的短剧">'+tabs.map(([key,label])=>`<a href="/member/${key}/" ${key===active?'aria-current="page"':''}>${key===active?`<h2 id="member-library-title" class="member-library-title">${label}</h2>`:label}</a>`).join('')+'</nav>';
|
||||
if(tab==='settings')return settingsMarkup(user);
|
||||
const noRecords=active==='likes'?'<div class="member-library-empty" role="status"><p>暂无点赞的短剧</p><a href="/shorts/">去发现好剧</a></div>':empty(active==='history'?'暂无观看历史':active==='purchased'?'暂无已购剧集':'暂无收藏');
|
||||
const content=`<section class="member-library" aria-labelledby="member-library-title">${nav}<div class="member-library-toolbar"><span role="status">共 ${data.totalCount||0} 部短剧</span></div><div class="member-library-cards" id="member-library-panel">${recordList<Drama>(data.list).map(item=>`<div class="member-library-item">${card(item)}</div>`).join('')||noRecords}</div>${pagination(data.totalCount||0,pageNum,libraryPageHref)}</section>`;
|
||||
return `<div class="member-center">${header}${content}</div>`;
|
||||
}
|
||||
|
||||
export async function renderMember(app:SiteContext,tab:string,pageNum:number,pageHref:PageHref,initial?:MemberInitialData,preserveMarkup=false) {
|
||||
const signal=app.signal;
|
||||
const user=initial?.user||await app.api.ensureSession();
|
||||
if(!initial)await app.api.get<Record<string,number>>('user/center/data',{},signal);
|
||||
if(signal.aborted)return;
|
||||
const root=document.getElementById('info-content')!;
|
||||
const active=tab==='center'?'history':tab;
|
||||
let data=initial?.list;
|
||||
if(tab!=='settings'&&!data){
|
||||
const paths:Record<string,string>={favorites:'playlet/collect/list',history:'playlet/history/list',likes:'playlet/like/list',purchased:'playlet/bought/list'};
|
||||
data=await app.api.get<PageData<Drama>>(paths[active],{pageNum,pageSize:24},signal);
|
||||
}
|
||||
if(signal.aborted)return;
|
||||
if(data)app.updateSeo?.({totalCount:data.totalCount,pageSize:24});
|
||||
if(!preserveMarkup)root.innerHTML=memberMarkup(user,tab,data,app.home,pageNum,pageHref);
|
||||
if(tab!=='settings')return;
|
||||
root.querySelector('#member-nickname-form')?.addEventListener('submit',event=>{
|
||||
event.preventDefault();const form=event.target as HTMLFormElement;if(!form.reportValidity()||form.dataset.submitting)return;
|
||||
const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;button.disabled=true;
|
||||
const status=form.querySelector('#member-nickname-status');if(status)status.textContent='';
|
||||
form.dataset.submitting='1';
|
||||
void (async()=>{
|
||||
const data=new FormData(form);const nickName=String(data.get('nickname')??data.get('nickName')??'').trim();
|
||||
const payload:Record<string,unknown>={nickName};if(user.introduction)payload.introduction=user.introduction;
|
||||
if(signal.aborted)return;
|
||||
await app.api.post('auth/profile',payload);await app.api.refreshUser();if(!signal.aborted){app.notify('昵称已保存');void app.route();}
|
||||
})().catch(error=>{if(!signal.aborted&&status)status.textContent=error.message;}).finally(()=>{button.disabled=false;delete form.dataset.submitting;});
|
||||
},{signal});
|
||||
}
|
||||
|
||||
function paymentMethods(item:Package) {
|
||||
return recordList<{type:string;typeName:string}>(item.payMethods).filter((method,index,methods)=>typeof method.type==='string'&&method.type.trim()&&methods.findIndex(other=>other.type===method.type)===index);
|
||||
}
|
||||
|
||||
const count=(value:unknown):value is number=>typeof value==='number'&&Number.isFinite(value)&&value>=0;
|
||||
const packagePrice=(item:Package)=>(item.price/100).toFixed(2).replace(/\.00$/,'');
|
||||
function packageCoins(item:Package) {
|
||||
const gift=count(item.giftCoin)?item.giftCoin:0;
|
||||
const base=count(item.baseCoin)?item.baseCoin:count(item.totalCoin)&&item.totalCoin>=gift?item.totalCoin-gift:undefined;
|
||||
const total=count(item.totalCoin)?item.totalCoin:base===undefined?undefined:base+gift;
|
||||
return {base,gift,total};
|
||||
}
|
||||
|
||||
export function paymentDialogMarkup(item:Package,vip:boolean):string {
|
||||
const methods=paymentMethods(item);
|
||||
const {base,gift,total}=packageCoins(item);
|
||||
const number=(value:number)=>String(value);
|
||||
const logos:Record<string,string>={alipay:'5652fcead17188e8',wechat:'2bead9ea5c435c98',usdt:'7848816d05aa8591'};
|
||||
const logo=(method:{type:string;typeName:string})=>{
|
||||
const type=method.type.toLowerCase();
|
||||
const key=type.includes('alipay')?'alipay':/wechat|wxpay|weixin/.test(type)?'wechat':type.includes('usdt')?'usdt':'';
|
||||
return key?`<img class="wallet-pay-logo" src="/assets/${logos[key]}.svg" width="36" height="36" alt="${escape(({alipay:"支付宝",wechat:"微信",usdt:"USDT"})[key])}支付图标" aria-hidden="true">`:'<span class="wallet-pay-logo wallet-pay-logo--other" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M3 10h18M7 15h3"/></svg></span>';
|
||||
};
|
||||
return `<button type="button" class="wallet-dialog-close" data-close="wallet-payment-dialog" aria-label="关闭支付弹窗"><svg class="dialog-close-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M18 6 6 18"/></svg></button>
|
||||
<div class="wallet-payment-heading"><h2 id="wallet-payment-title" class="wallet-payment-title">确认支付</h2><span>${vip?'会员畅享':'金币充值'}</span></div>
|
||||
<div class="wallet-order"><div class="wallet-order-copy"><strong>${escape(!vip&&base!==undefined?number(base)+' 金币':item.name)}</strong><span>${escape(item.subTitle||(vip?'支付成功后开通会员':'支付成功后即时到账'))}</span>${gift>0?`<small>赠送 ${number(gift)} 金币${total!==undefined?' · 合计 '+number(total)+' 金币':''}</small>`:''}</div><b>¥${packagePrice(item)}</b></div>
|
||||
<form id="payment-form"><h3 class="wallet-method-title">选择支付方式</h3><input type="hidden" name="payMode" value="${escape(methods[0]?.type||'')}">
|
||||
<div class="wallet-methods" role="group" aria-label="支付方式">${methods.map((method,index)=>`<button type="button" data-wallet-method="${escape(method.type)}" aria-pressed="${index===0}">${logo(method)}<span class="wallet-method-name">${escape(method.typeName||method.type)}</span><span class="wallet-method-check" aria-hidden="true">${index===0?'✓':''}</span></button>`).join('')||'<p class="wallet-payment-status" role="status">当前套餐暂无可用支付方式</p>'}</div>
|
||||
<button class="wallet-submit wallet-payment-go" type="submit" ${methods.length?'':'disabled'}>去支付</button><p class="wallet-payment-note">点击支付即表示已确认订单金额与支付方式</p><p class="api-error wallet-payment-status" role="alert" aria-live="polite"></p>
|
||||
</form>`;
|
||||
}
|
||||
|
||||
export function walletMarkup(user:User|null,packages:WalletInitialData['packages'],transactions:WalletInitialData['transactions'],vip:boolean,pageNum:number,pageHref:PageHref):string {
|
||||
const items=recordList<Package>(vip?packages.vipList:packages.coinList).filter(item=>Number.isSafeInteger(item.id)&&item.id>0&&count(item.price));
|
||||
let selected=items[0];
|
||||
const title=(item:Package)=>!vip&&packageCoins(item).base!==undefined?packageCoins(item).base+'金币':item.name;
|
||||
const selectionText=(item:Package)=>`已选择 ${title(item)}${packageCoins(item).gift>0?' + 赠送'+packageCoins(item).gift+'金币':''} · 点击后选择支付方式`;
|
||||
const coin=(className:string)=>`<span class="coin-symbol ${className}" aria-hidden="true"><img class="coin-soft-star" src="/assets/897547dcbff56113.svg" alt="金币图标" aria-hidden="true"></span>`;
|
||||
const balance=count(user?.recharge)?user!.recharge.toLocaleString('zh-CN'):'0';
|
||||
const transactionRows=recordList<Record<string,unknown>>(transactions.list).map(row=>{
|
||||
const numeric=(value:unknown)=>typeof value==='number'&&Number.isFinite(value)?value:undefined;
|
||||
const change=numeric(row.changeAmount);
|
||||
const amount=change!==undefined&&change!==0?change:numeric(row.amount)??numeric(row.coin)??numeric(row.changeCoin)??change??0;
|
||||
const expense=Number(row.flowDir)===-1||amount<0;
|
||||
const name=row.title||row.tranTypeName||row.name||row.remark||row.typeName||'金币变动';
|
||||
const time=String(row.createdAt??row.time??'');
|
||||
const date=new Date(time);
|
||||
const displayTime=time.replace(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}:\d{2}).*$/,'$1/$2/$3 $4');
|
||||
return `<div class="wallet-transaction-row"><div><strong>${escape(name)}</strong>${time?`<time${Number.isFinite(date.getTime())?` datetime="${date.toISOString()}"`:''}>${escape(displayTime)}</time>`:''}</div><span class="wallet-transaction-amount ${expense?'expense':'income'}">${amount===0?'0':(expense?'−':'+')+Math.abs(amount).toLocaleString('zh-CN')}</span></div>`;
|
||||
}).join('');
|
||||
return `<div class="wallet-page">
|
||||
<section class="wallet-balance-card" aria-label="金币余额与用途"><div class="wallet-balance-summary">${coin('wallet-balance-coin')}<div class="wallet-balance-copy"><span>当前余额</span><div class="wallet-balance-amount"><strong>${balance}</strong><b>金币</b></div><p>按剧集原定价按集解锁,解锁后永久有效</p></div></div>
|
||||
<div class="wallet-usage"><h2>金币怎么用</h2>
|
||||
<div class="wallet-usage-item"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V6a4 4 0 0 1 8 0v4"/></svg><div><strong>解锁单集内容</strong><p>按剧集标定价消耗金币</p></div></div>
|
||||
<div class="wallet-usage-item"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m13 2-9 12h7l-1 8 10-13h-8l1-7Z"/></svg><div><strong>奖励金币优先使用</strong><p>解锁剧集先自动优先扣除任务获得的奖励金币</p></div></div>
|
||||
<div class="wallet-usage-item"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="6"/><path d="m8 13-1 9 5-3 5 3-1-9"/></svg><div><strong>已解锁剧集不再扣费</strong><p>已解锁的剧集可随时回看,不会重复消耗金币</p></div></div>
|
||||
</div></section>
|
||||
${vip?'<nav class="channel-tabs" aria-label="充值分类"><a href="/info/deposit/">金币充值</a><a href="/info/deposit/vip/" aria-current="page">会员畅享</a><a href="/member/purchased/">已购剧集</a></nav>':''}
|
||||
<h2 class="wallet-section-title">购买套餐</h2><section class="wallet-pack-panel" aria-label="购买套餐"><div class="wallet-pack-heading"><strong>${vip?'请选择会员套餐':'请选择充值金额'}</strong><span>${vip?'支付成功后开通会员':'按集解锁,自由使用'}</span></div>
|
||||
<div class="wallet-pack-grid" role="group" aria-label="${vip?'会员':'金币'}套餐">${items.map((item,index)=>{
|
||||
const {gift}=packageCoins(item);
|
||||
const bonus=gift>0?`赠送 ${gift}金币`:item.subTitle||(vip&&item.durationDays?`${item.durationDays}天会员`: '');
|
||||
return `<button type="button" class="wallet-package wallet-mobile-package" data-buy-package="${item.id}" aria-pressed="${item===selected}" aria-label="${escape(title(item)+(bonus?','+bonus:'')+',人民币'+packagePrice(item)+'元')}">${coin('wallet-package-coin')}<span class="wallet-mobile-coins">${escape(title(item))}</span><span class="wallet-mobile-bonus">${escape(bonus)}</span><span class="wallet-mobile-price"><small>¥</small><strong>${packagePrice(item)}</strong></span>${index===0?'<span class="wallet-double-badge"><img src="/assets/0f0ef061135a13f0.svg" width="37.0911" height="37.0911" alt="推荐套餐标识" aria-hidden="true"><span>推荐</span></span>':''}<img class="wallet-mobile-selected" src="/assets/2fdedce06a90a6a1.svg" width="24" height="24" alt="套餐选中标识" aria-hidden="true"></button>`;
|
||||
}).join('')||`<div class="wallet-pack-empty">${empty('暂无可购买套餐')}</div>`}</div></section>
|
||||
<div class="wallet-pay-area"><button type="button" class="wallet-submit" data-wallet-action="pay" ${selected?'':'disabled'}>${selected?'立即支付 ¥'+packagePrice(selected):'暂无可购买套餐'}</button><p id="wallet-selection" role="status">${selected?escape(selectionText(selected)):''}</p></div>
|
||||
<section class="wallet-transactions"><h2 class="wallet-section-title">交易明细</h2><div class="wallet-transaction-list" aria-label="金币交易记录">${transactionRows||empty('error' in transactions?String(transactions.error):'暂无交易明细')}</div>${pagination(transactions.totalCount||0,pageNum,pageHref,10)}</section>
|
||||
<p class="wallet-support">如遇充值问题请咨询 <a href="/info/feedback/">在线客服</a></p></div>`;
|
||||
}
|
||||
|
||||
export async function renderWallet(app:SiteContext,vip:boolean,pageNum:number,pageHref:PageHref,initial?:WalletInitialData,preserveMarkup=false) {
|
||||
const signal=app.signal;
|
||||
const user=initial?.user||await app.api.ensureSession();
|
||||
if(signal.aborted)return;
|
||||
const [packages,transactions]=initial?[initial.packages,initial.transactions]:await Promise.all([
|
||||
app.api.get<{coinList?:Package[];vipList?:Package[]}>('recharge/package/list',{},signal),
|
||||
app.api.get<PageData<Record<string,unknown>>>('coin/trans/list',{page:pageNum,pageSize:10,type:0},signal).catch(()=>({list:[],totalCount:0,error:'余额明细暂不可用,请稍后刷新'})),
|
||||
]);
|
||||
if(signal.aborted)return;
|
||||
const items=recordList<Package>(vip?packages.vipList:packages.coinList).filter(item=>Number.isSafeInteger(item.id)&&item.id>0&&count(item.price));
|
||||
app.updateSeo?.({totalCount:transactions.totalCount,pageSize:10});
|
||||
let selected=items[0];
|
||||
const title=(item:Package)=>!vip&&packageCoins(item).base!==undefined?packageCoins(item).base+'金币':item.name;
|
||||
const selectionText=(item:Package)=>`已选择 ${title(item)}${packageCoins(item).gift>0?' + 赠送'+packageCoins(item).gift+'金币':''} · 点击后选择支付方式`;
|
||||
const root=document.getElementById('info-content')!;
|
||||
if(!preserveMarkup)root.innerHTML=walletMarkup(user,packages,transactions,vip,pageNum,pageHref);
|
||||
const openPayment=(item:Package)=>{
|
||||
const dialog=document.getElementById('wallet-payment-dialog') as HTMLDialogElement;
|
||||
const methods=paymentMethods(item);
|
||||
dialog.innerHTML=paymentDialogMarkup(item,vip);
|
||||
if(!dialog.open)dialog.showModal();
|
||||
const paymentForm=dialog.querySelector<HTMLFormElement>('form')!;
|
||||
const choices=Array.from(paymentForm.querySelectorAll<HTMLButtonElement>('[data-wallet-method]'));
|
||||
paymentForm.addEventListener('click',choiceEvent=>{
|
||||
const choice=(choiceEvent.target as Element).closest<HTMLButtonElement>('[data-wallet-method]');
|
||||
if(!choice||choice.disabled||!methods.some(method=>method.type===choice.dataset.walletMethod))return;
|
||||
paymentForm.querySelector<HTMLInputElement>('[name="payMode"]')!.value=choice.dataset.walletMethod!;
|
||||
choices.forEach(button=>{const selected=button===choice;button.setAttribute('aria-pressed',String(selected));button.querySelector('.wallet-method-check')!.textContent=selected?'✓':'';});
|
||||
paymentForm.querySelector('.api-error')!.textContent='';
|
||||
},{signal});
|
||||
paymentForm.addEventListener('submit',submit=>{
|
||||
submit.preventDefault();const form=submit.target as HTMLFormElement;if(!form.reportValidity()||form.dataset.submitting)return;
|
||||
const mode=new FormData(form).get('payMode');if(!methods.some(method=>method.type===mode))return;
|
||||
const confirm=form.querySelector<HTMLButtonElement>('[type="submit"]')!;confirm.disabled=true;form.dataset.submitting='1';choices.forEach(choice=>choice.disabled=true);
|
||||
const page=trackingPage(typeof location==='undefined'?'/info/deposit/':location.pathname);
|
||||
void app.api.post<{payUrl?:string;tradeNo?:string}>(vip?'vip/pay':'coin/buy',{packageId:item.id,payMode:mode,sourcePageKey:page.key,sourcePageName:page.name}).then(result=>{
|
||||
if(signal.aborted||!form.isConnected)return;
|
||||
const url=safeUrl(result.payUrl);if(!url)throw new Error('支付服务未返回有效地址,请稍后重试');
|
||||
form.innerHTML=`<p class="wallet-payment-status" role="status">订单 ${escape(result.tradeNo)} 已创建,请在支付页面完成付款。</p><a class="primary wallet-payment-link" href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer">打开支付页面</a><button type="button" class="wallet-submit wallet-payment-go" data-payment-refresh>我已完成支付,刷新余额</button>`;
|
||||
form.querySelector('[data-payment-refresh]')!.addEventListener('click',()=>{void app.api.refreshUser().then(()=>{dialog.close();app.notify('余额已刷新');void app.route();}).catch(error=>app.notify(error.message));},{signal});
|
||||
}).catch(error=>{if(signal.aborted||!form.isConnected)return;form.querySelector('.api-error')!.textContent=error.message;confirm.disabled=false;delete form.dataset.submitting;choices.forEach(choice=>choice.disabled=false);});
|
||||
},{signal});
|
||||
};
|
||||
root.addEventListener('click',event=>{
|
||||
const button=(event.target as Element).closest<HTMLElement>('[data-buy-package],[data-wallet-action]');if(!button)return;
|
||||
if(button.dataset.buyPackage){
|
||||
const item=items.find(item=>item.id===Number(button.dataset.buyPackage));if(!item)return;
|
||||
selected=item;
|
||||
root.querySelectorAll<HTMLElement>('[data-buy-package]').forEach(card=>card.setAttribute('aria-pressed',String(Number(card.dataset.buyPackage)===item.id)));
|
||||
const pay=root.querySelector<HTMLButtonElement>('[data-wallet-action="pay"]');if(pay)pay.textContent='立即支付 ¥'+packagePrice(item);
|
||||
const status=root.querySelector('#wallet-selection');if(status)status.textContent=selectionText(item);
|
||||
}else if(button.dataset.walletAction==='pay'&&selected){openPayment(selected);}
|
||||
},{signal});
|
||||
}
|
||||
@@ -1,290 +0,0 @@
|
||||
import type { Chapter, Config, DramaDetail, User } from '../api/types';
|
||||
import { base64JSON, COMMON_KEYS, dramaContext, SDK_FIELD_KEYS, SERVER_EVENTS, TRACKER_APP_ID, trackingPage, trackingText, type Fields } from '../tracking';
|
||||
import { authMetadata } from './auth';
|
||||
import { buildSdkUserAgent } from '../api/user-agent';
|
||||
import { installReportFieldPatch, parseUAClientInfo } from '../analytics/report-fields';
|
||||
|
||||
function uaExtras(fields:Fields):Fields {
|
||||
const parsed=parseUAClientInfo();
|
||||
const text=(value:unknown)=>String(value??'').trim();
|
||||
const useful=(value:string)=>value!==''&&value!=='Unknown'&&value!=='0.0.0';
|
||||
const extra:Fields={};
|
||||
if(!text(fields.device_brand)&&useful(parsed.device_brand))extra.device_brand=parsed.device_brand;
|
||||
if(!text(fields.device_model)&&useful(parsed.device_model))extra.device_model=parsed.device_model;
|
||||
if(!text(fields.system_name)&&useful(parsed.system_name)&&parsed.system_name!=='PC')extra.system_name=parsed.system_name;
|
||||
if(!text(fields.system_version)&&useful(parsed.system_version))extra.system_version=parsed.system_version;
|
||||
return extra;
|
||||
}
|
||||
|
||||
interface WebSDK {
|
||||
init(options:Record<string,unknown>):unknown;
|
||||
track:Record<string,(payload:Fields)=>unknown>;
|
||||
getCommonFields(keys?:string[]):Promise<Fields>;
|
||||
setUid(uid:string):unknown;
|
||||
setChannel(channel:string):unknown;
|
||||
notifyPageLoad():void;
|
||||
flush():Promise<unknown>;
|
||||
}
|
||||
declare global {interface Window {WebSDK?:WebSDK;tracker?:SiteTracker}}
|
||||
const number=(value:unknown)=>Math.max(0,Math.round(Number(value)||0));
|
||||
const clamp=(value:number)=>Math.min(100,Math.max(0,Math.round(value)));
|
||||
const channelValue=(value:string)=>/^[a-z\d_.-]{1,50}$/i.test(value)&&!['self','natural','official','channel','0','-','unknown'].includes(value.toLowerCase())?value:'organic';
|
||||
const uidValue=(user:User|null)=>{const uid=trackingText(user?.userId);return /^[a-z\d_]{4,32}$/i.test(uid)?uid:'';};
|
||||
const waitLimit=<T>(promise:Promise<T>,fallback:T,ms=1500)=>new Promise<T>(resolve=>{
|
||||
const timer=setTimeout(()=>resolve(fallback),ms);
|
||||
promise.then(value=>{clearTimeout(timer);resolve(value);},()=>{clearTimeout(timer);resolve(fallback);});
|
||||
});
|
||||
|
||||
/** SDK adapter: named v1.2.0 events only; business requests own server-side mutations. */
|
||||
export class SiteTracker {
|
||||
private sdkReady:Promise<void>;
|
||||
private config:Config={};
|
||||
private initialized=false;
|
||||
private initializing=false;
|
||||
private queue:{method:string;payload:Fields}[]=[];
|
||||
private user:User|null=null;
|
||||
private path='';
|
||||
private page=trackingPage(location.pathname);
|
||||
private referrer={key:'direct',name:'直接访问'};
|
||||
private shown=new Set<string>();
|
||||
private adShown=new Set<string>();
|
||||
private popupClosed=new Set<string>();
|
||||
private observed=new WeakSet<Element>();
|
||||
private observer?:IntersectionObserver;
|
||||
private diagnostics=new Set<string>();
|
||||
private drama?:Fields;
|
||||
private chapters:Chapter[]=[];
|
||||
private isVideo=false;
|
||||
private playback=new WeakMap<HTMLVideoElement,{started:number;elapsed:number;position:number;seekFrom?:number;failed?:boolean}>();
|
||||
|
||||
constructor(){
|
||||
installReportFieldPatch('1.0.0');
|
||||
try{if(document.referrer){const url=new URL(document.referrer);this.referrer=url.origin===location.origin?trackingPage(url.pathname):{key:'external',name:url.host};}}catch{}
|
||||
this.sdkReady=this.loadSDK().catch(()=>{});
|
||||
window.addEventListener('site:route',()=>this.pageView());
|
||||
window.addEventListener('pagehide',()=>{this.reportProgress();void this.flush();});
|
||||
document.addEventListener('visibilitychange',()=>{if(document.hidden){this.reportProgress();void this.flush();}else this.observe();});
|
||||
document.addEventListener('click',event=>this.click(event),true);
|
||||
document.addEventListener('external-ad:track',event=>{const {element,eventType}=(event as CustomEvent).detail||{};this.externalAd(element,eventType);});
|
||||
document.addEventListener('dialog-ad:track',event=>{const {element,eventType}=(event as CustomEvent).detail||{};this.advertising(element,eventType);});
|
||||
document.addEventListener('tracker:refresh',()=>this.observe());
|
||||
document.addEventListener('close',()=>this.observe(),true);
|
||||
for(const event of ['play','playing','pause','waiting','timeupdate','seeking','seeked','ended'])document.addEventListener(event,event=>this.videoEvent(event),true);
|
||||
if(typeof IntersectionObserver!=='undefined')this.observer=new IntersectionObserver(entries=>{
|
||||
for(const entry of entries)if(entry.isIntersecting){
|
||||
this.observer?.unobserve(entry.target);
|
||||
if(this.visible(entry.target))this.impression(entry.target as HTMLElement);else this.observed.delete(entry.target);
|
||||
}
|
||||
},{threshold:0.1});
|
||||
if(typeof MutationObserver!=='undefined')new MutationObserver(()=>this.observe()).observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:['hidden','open','inert','aria-hidden']});
|
||||
this.pageView();this.observe();
|
||||
}
|
||||
|
||||
private loadSDK():Promise<void>{
|
||||
if(window.WebSDK)return Promise.resolve();
|
||||
return new Promise((resolve,reject)=>{
|
||||
const script=document.createElement('script');script.src='/scripts/web-sdk-v1.2.0.js';script.async=true;
|
||||
script.onload=()=>window.WebSDK?resolve():reject(new Error('sdk_unavailable'));
|
||||
script.onerror=()=>{script.remove();reject(new Error('sdk_load_failed'));};
|
||||
document.head.append(script);
|
||||
});
|
||||
}
|
||||
private diagnose(method:string,result:unknown){
|
||||
const value=result&&typeof result==='object'?result as Record<string,unknown>:{};
|
||||
const reason=trackingText(value.reason)||'sdk_exception',key=method+'|'+reason;
|
||||
if(this.diagnostics.has(key))return;
|
||||
this.diagnostics.add(key);
|
||||
const detail={method,reason,...(Array.isArray(value.missingFields)?{missingFields:value.missingFields}:{}),...(Array.isArray(value.typeErrorFields)?{typeErrorFields:value.typeErrorFields}:{})};
|
||||
try{document.dispatchEvent(new CustomEvent('tracker:diagnostic',{detail}));}catch{}
|
||||
}
|
||||
|
||||
configure(config:Config){
|
||||
this.config=config;
|
||||
if(this.initialized||this.initializing)return;
|
||||
const encryptedConfig=trackingText(config.reportSdkConfig)||process.env.NEXT_PUBLIC_TRACKER_ENCRYPTED_CONFIG||'';
|
||||
if(!encryptedConfig)return;
|
||||
this.initializing=true;
|
||||
void this.sdkReady.then(async()=>{
|
||||
if(!window.WebSDK){this.sdkReady=this.loadSDK();await this.sdkReady;}
|
||||
if(!window.WebSDK)throw new Error('sdk_unavailable');
|
||||
await window.WebSDK.init({appId:TRACKER_APP_ID,uid:uidValue(this.user),channel:channelValue(authMetadata().channel),encryptedConfig,appVersion:'1.0.0',disableAutoEvents:['app_page_view','page_click']});
|
||||
this.initialized=true;
|
||||
this.syncUser(this.user);
|
||||
for(const event of this.queue.splice(0))this.invoke(event.method,event.payload);
|
||||
}).catch(()=>this.diagnose('init',{reason:'sdk_initialization_failed'})).finally(()=>{this.initializing=false;});
|
||||
}
|
||||
private invoke(method:string,payload:Fields){
|
||||
if(!this.initialized){
|
||||
if(this.queue.length>=1000){this.diagnose(method,{reason:'pending_queue_full'});return;}
|
||||
this.queue.push({method,payload:{...payload}});return;
|
||||
}
|
||||
try{
|
||||
const track=window.WebSDK?.track[method];
|
||||
if(typeof track!=='function'){this.diagnose(method,{reason:'unknown_event_method'});return;}
|
||||
void Promise.resolve(track(payload)).then(result=>{
|
||||
if(result&&typeof result==='object'&&'ok' in result&&result.ok===false)this.diagnose(method,result);
|
||||
}).catch(()=>this.diagnose(method,{reason:'sdk_exception'}));
|
||||
}catch{this.diagnose(method,{reason:'sdk_exception'});}
|
||||
}
|
||||
syncUser(user:User|null){
|
||||
this.user=user;
|
||||
try{if(this.initialized)window.WebSDK?.setUid(uidValue(user));}catch{}
|
||||
}
|
||||
pageView(){
|
||||
const path=location.pathname+location.search;if(path===this.path)return;
|
||||
if(this.path){this.reportProgress();this.referrer=this.page;try{window.WebSDK?.notifyPageLoad();}catch{}}
|
||||
this.path=path;this.page=trackingPage(location.pathname);this.drama=undefined;this.chapters=[];
|
||||
this.shown.clear();this.observed=new WeakSet();this.observer?.disconnect();
|
||||
this.invoke('appPageView',{...this.pageFields(),user_type:this.user?.vipType?'vip':'normal',referrer_page_key:this.referrer.key,referrer_page_name:this.referrer.name,current_page_key:this.page.key,current_page_name:this.page.name,page_load_time:number(performance.getEntriesByType?.('navigation')[0]?.duration),recommend_trace_id:''});
|
||||
this.observe();
|
||||
}
|
||||
resumePage(){
|
||||
try{window.WebSDK?.notifyPageLoad();}catch{}
|
||||
this.path='';this.pageView();
|
||||
}
|
||||
private pageFields():Fields{return {page_key:this.page.key,page_name:this.page.name};}
|
||||
private visible(node:Element){
|
||||
if(document.hidden||node.closest('[hidden],[inert],[aria-hidden="true"],dialog:not([open])')||!node.getClientRects().length)return false;
|
||||
const modal=document.querySelector('dialog:modal');
|
||||
return !modal||modal.contains(node);
|
||||
}
|
||||
private observe(){
|
||||
document.querySelectorAll<HTMLElement>('[data-track-ad="external"],[data-track-ad="external-dialog"],[data-track-item]').forEach(node=>{
|
||||
if(!this.visible(node)){
|
||||
if(this.observed.has(node)){this.observer?.unobserve(node);this.observed.delete(node);}
|
||||
return;
|
||||
}
|
||||
if(this.observed.has(node))return;
|
||||
this.observed.add(node);if(this.observer)this.observer.observe(node);else this.impression(node);
|
||||
});
|
||||
}
|
||||
private item(node:HTMLElement):Fields {
|
||||
return {...this.pageFields(),item_id:node.dataset.itemId||'',item_type:node.dataset.itemType||'short_drama',item_name:node.dataset.itemName||'',parent_item_id:node.dataset.parentItemId||'0',recommend_trace_id:node.dataset.recommendTraceId||'',recommend_id:node.dataset.recommendId||'',recommend_trace_info:node.dataset.recommendTraceInfo||''};
|
||||
}
|
||||
private impression(node:HTMLElement){
|
||||
if(node.hasAttribute('data-ad-clone'))return;
|
||||
if(node.hasAttribute('data-track-ad')){this.externalAd(node,'show');return;}
|
||||
const key='item|'+node.dataset.itemType+'|'+node.dataset.itemId+'|'+(node.dataset.parentItemId||'');
|
||||
if(!node.dataset.itemId||this.shown.has(key))return;
|
||||
this.shown.add(key);this.invoke('appPageitemExposure',{...this.item(node),referrer_page_key:this.referrer.key,referrer_page_name:this.referrer.name});
|
||||
}
|
||||
externalAd(node:HTMLElement,eventType:string){
|
||||
if(!node||!['show','click'].includes(eventType)||!node.dataset.adId)return;
|
||||
if(eventType==='show'&&!this.visible(node)){this.observed.delete(node);return;}
|
||||
const payload={...this.pageFields(),ad_slot_key:node.dataset.adSlotKey||this.page.key+'_ad',ad_slot_name:node.dataset.adSlotName||this.page.name+'广告位',ad_id:node.dataset.adId,creative_id:node.dataset.creativeId||'',ad_type:node.dataset.adType||'banner'};
|
||||
const key=this.adKey(node);
|
||||
if(!this.adShown.has(key)){this.adShown.add(key);this.invoke('adImpression',payload);this.advertising(node,'show');}
|
||||
if(eventType==='click'&&node.dataset.adClickable==='1'){this.invoke('adClick',payload);this.advertising(node,'click');}
|
||||
}
|
||||
private adKey(node:HTMLElement){return this.path+'|'+(node.dataset.adPlacement||'inline')+'|'+(node.dataset.adSlotKey||this.page.key+'_ad')+'|'+node.dataset.adId;}
|
||||
advertising(node:HTMLElement,eventType:string){
|
||||
if(!node?.dataset.adId||!['startup-ad','startup-grid'].includes(node.dataset.adPlacement||'')||!['show','click','close'].includes(eventType))return;
|
||||
const key=this.adKey(node);
|
||||
if(eventType==='close'){
|
||||
if(!this.adShown.has(key)||this.popupClosed.has(key))return;
|
||||
this.popupClosed.add(key);
|
||||
}else if(eventType==='show')this.popupClosed.delete(key);
|
||||
this.invoke('advertising',{event_type:eventType,advertising_key:node.dataset.adPlacement==='startup-grid'?'grid_ad':'dialog_ad',advertising_name:node.dataset.adSlotName||'弹窗广告',advertising_id:node.dataset.adId});
|
||||
}
|
||||
search(keyword:string,total:number,page:number){if(keyword&&page===1)this.invoke('keywordSearch',{keyword,search_result_count:total,search_content_type:'',search_trace_id:'',search_id:''});}
|
||||
private click(event:MouseEvent){
|
||||
const target=event.target instanceof Element?event.target:null;if(!target)return;
|
||||
if(!target.closest('[data-close],.startup-close'))this.invoke('pageClick',{...this.pageFields(),click_page_x:number(event.clientX),click_page_y:number(event.clientY),click_x_percent:clamp(event.clientX/(innerWidth||1)*100),click_y_percent:clamp(event.clientY/(innerHeight||1)*100),screen_width:number(screen.width),screen_height:number(screen.height),recommend_trace_id:''});
|
||||
const nav=target.closest<HTMLAnchorElement>('a[data-page],a[data-hg-page],a[data-mobile-route],a[data-info],a[data-track-navigation],a[data-app-download],.entry-routes a,.entry-channels a');
|
||||
if(nav&&nav.hasAttribute('href'))this.invoke('navigation',{navigation_key:'nav_'+(nav.dataset.trackNavigation||nav.dataset.page||nav.dataset.hgPage||nav.dataset.mobileRoute||nav.dataset.info||(nav.hasAttribute('data-app-download')?'app_download':trackingPage(new URL(nav.href,location.href).pathname).key)).replace(/-/g,'_'),navigation_name:(nav.getAttribute('aria-label')||nav.textContent||'导航').trim().slice(0,255)});
|
||||
const ad=target.closest<HTMLElement>('[data-track-ad="external"]');if(ad)this.externalAd(ad,'click');
|
||||
const switcher=target.closest<HTMLElement>('[data-drama-switch]');
|
||||
if(switcher)this.dramaStatusChange({id:Number(switcher.dataset.targetVideoId),currentEpisode:Number(switcher.dataset.targetEpisode)});
|
||||
const item=target.closest<HTMLElement>('[data-track-item]');
|
||||
if(item){
|
||||
const items=Array.from(item.closest('.cards,.topic-recommendation-grid,.benefit-grid,.hero')?.querySelectorAll('[data-track-item]')||[]),position=items.indexOf(item)+1;
|
||||
this.invoke('appPageitemClick',{...this.item(item),position:position||1});
|
||||
const results=item.closest<HTMLElement>('[data-search-results]');
|
||||
if(results)this.invoke('keywordClick',{keyword:results.dataset.searchKeyword||'',click_item_id:item.dataset.itemId||'',click_item_type_key:'short_drama',click_item_type_name:'短剧',click_position:(number(results.dataset.searchPage)-1)*number(results.dataset.searchPageSize)+(position||1),search_trace_id:''});
|
||||
}
|
||||
}
|
||||
setDrama(drama:DramaDetail,chapter:Chapter|null|undefined,chapters:Chapter[],isVideo=false){
|
||||
this.drama=dramaContext(drama,chapter);this.chapters=chapters;this.isVideo=isVideo;
|
||||
if(isVideo)this.drama.video_id=String(drama.id);
|
||||
if(this.drama.video_id)this.dramaEvent('ep_view');
|
||||
}
|
||||
private video(){return document.querySelector<HTMLVideoElement>('#drama-play-page:not([hidden]) .play-screen video');}
|
||||
private main(video:HTMLVideoElement){return video===this.video()&&video.closest<HTMLElement>('[data-play-mode]')?.dataset.playMode!=='preview';}
|
||||
private state(video:HTMLVideoElement){let state=this.playback.get(video);if(!state){state={started:0,elapsed:0,position:0};this.playback.set(video,state);}return state;}
|
||||
private settle(video:HTMLVideoElement){const s=this.state(video);if(s.started){s.elapsed+=Math.max(0,performance.now()-s.started);s.started=0;}}
|
||||
dramaEvent(behavior:string,video?:HTMLVideoElement,options:{forceProgress?:number;keepDuration?:boolean}={}){
|
||||
if(!this.drama?.video_id)return;
|
||||
const s=video?this.state(video):undefined;
|
||||
const duration=number(video?.duration&&Number.isFinite(video.duration)?video.duration:this.drama.drama_duration);
|
||||
const elapsed=s?s.elapsed+(s.started?Math.max(0,performance.now()-s.started):0):0;
|
||||
const names:Record<string,string>={ep_view:'展示',ep_play:'播放',ep_pause:'暂停',ep_forward:'快进',ep_rewind:'快退',ep_complete:'播放完成',ep_share:'分享',ep_failed:'播放失败'};
|
||||
const metrics={play_duration:number(elapsed/1000),play_progress:options.forceProgress!==undefined?clamp(options.forceProgress):behavior==='ep_complete'?100:duration?clamp((video?.currentTime||0)/duration*100):0,play_speed:number((video?.playbackRate||1)*100)};
|
||||
if(this.isVideo){const d=this.drama;this.invoke('videoEvent',{...metrics,media_id:d.media_id,video_id:d.video_id,video_title:d.drama_title,video_type_id:d.drama_type_id,video_type_name:d.drama_type_name,video_content_type:'video',recommend_trace_id:d.recommend_trace_id,video_tag_key:d.drama_tag_key,video_tag_name:d.drama_tag_name,video_duration:duration,video_behavior_key:behavior.replace('ep_','video_'),video_behavior_name:behavior==='ep_view'?'视频展示':names[behavior]||behavior});}
|
||||
else this.invoke('dramaEvent',{...this.drama,...metrics,drama_duration:duration,play_behavior_key:behavior,play_behavior_name:names[behavior]||behavior});
|
||||
if(s&&!options.keepDuration){s.elapsed=0;if(s.started)s.started=performance.now();}
|
||||
}
|
||||
private videoEvent(event:Event){
|
||||
const video=event.target;if(!(video instanceof HTMLVideoElement)||!this.main(video))return;
|
||||
const s=this.state(video);
|
||||
if(event.type==='play')this.dramaEvent('ep_play',video);
|
||||
if(event.type==='playing'){s.failed=false;if(!s.started)s.started=performance.now();}
|
||||
if(event.type==='waiting')this.settle(video);
|
||||
if(event.type==='pause'){const played=s.started||s.elapsed;this.settle(video);if(played&&!video.ended&&!video.seeking)this.dramaEvent('ep_pause',video);}
|
||||
if(event.type==='timeupdate'&&!video.seeking)s.position=video.currentTime;
|
||||
if(event.type==='seeking'){this.settle(video);s.seekFrom??=s.position;}
|
||||
if(event.type==='seeked'&&s.seekFrom!==undefined){const from=s.seekFrom;s.seekFrom=undefined;if(Math.abs(video.currentTime-from)>=0.5)this.dramaEvent(video.currentTime>from?'ep_forward':'ep_rewind',video);s.position=video.currentTime;if(!video.paused&&!video.ended)s.started=performance.now();}
|
||||
if(event.type==='ended'){
|
||||
this.settle(video);this.dramaEvent('ep_complete',video,{keepDuration:!this.isVideo});
|
||||
if(!this.isVideo)this.dramaEvent('ep_play',video,{forceProgress:100});
|
||||
}
|
||||
}
|
||||
playbackFailed(video:HTMLVideoElement){
|
||||
if(!this.main(video)||!this.drama?.video_id)return;
|
||||
const state=this.state(video);if(state.failed)return;
|
||||
state.failed=true;this.settle(video);this.dramaEvent('ep_failed',video);
|
||||
}
|
||||
prepareDramaModeChange(){const video=this.video();if(video){this.reportProgress();this.playback.delete(video);}}
|
||||
private reportProgress(){const video=this.video();if(video&&this.main(video)){const s=this.state(video);if(s.started||s.elapsed){this.settle(video);this.dramaEvent('ep_play',video);if(!video.paused&&!video.ended)s.started=performance.now();}}}
|
||||
dramaStatusChange(chapter:Chapter,behavior?:string){
|
||||
if(!this.drama||this.isVideo)return;
|
||||
const episode=Math.max(1,chapter.currentEpisode||1),previous=Number(this.drama.episode);
|
||||
const key=behavior||(episode===previous+1?'ep_next':episode===previous-1?'ep_previous':'ep_switch');
|
||||
const names:Record<string,string>={ep_previous:'上一集',ep_next:'下一集',ep_switch:'切换剧集',ep_unlock:'解锁',ep_auto_play:'自动播放'};
|
||||
if(!names[key]||(!behavior&&String(chapter.id)===this.drama.video_id))return;
|
||||
this.invoke('dramaStatusChange',{media_id:this.drama.media_id,drama_id:this.drama.drama_id,drama_title:this.drama.drama_title,episode,video_id:String(chapter.id),pre_episode:previous,drama_behavior_key:key,drama_behavior_name:names[key]});
|
||||
}
|
||||
async getCommonFields(){await this.sdkReady;try{return await window.WebSDK?.getCommonFields()||{};}catch{return {};}}
|
||||
async requestHeaders(path:string,method:string,body:unknown):Promise<Record<string,string>>{
|
||||
// This request supplies the SDK configuration; it must not wait for that SDK to load first.
|
||||
if(path==='config/info'&&!this.initialized)return {};
|
||||
const input=body&&typeof body==='object'&&!(body instanceof FormData)?body as Record<string,unknown>:{};
|
||||
const snapshot=this.drama?{...this.drama}:undefined,chapters=this.chapters.slice(),isVideo=this.isVideo;
|
||||
return waitLimit((async()=>{
|
||||
const fields=await this.getCommonFields();
|
||||
// 请求头 DevID 继续用登录态设备。X-SDK-Common 保留 SDK 原值,SDK 没有设备号时才用登录态补上。
|
||||
const loginDevice=authMetadata().devID||'';
|
||||
const sdkDevice=trackingText(fields.device_id)||loginDevice;
|
||||
const headers:Record<string,string>={
|
||||
'X-User-Agent':buildSdkUserAgent({...fields,...uaExtras(fields),device_id:loginDevice||sdkDevice}),
|
||||
'X-SDK-Common':base64JSON(Object.fromEntries(SDK_FIELD_KEYS.filter(key=>Object.hasOwn(fields,key)||key==='device_id'&&!!sdkDevice).map(key=>[key,key==='device_id'?fields.device_id||sdkDevice:fields[key]]))),
|
||||
};
|
||||
const event=method==='POST'?SERVER_EVENTS[path]:'';
|
||||
if(!event||fields.app_id!==TRACKER_APP_ID||!sdkDevice||path==='comment/send'&&![10,11].includes(Number(input.objectType)))return headers;
|
||||
const chapterId=Number(input.chapterId||(Number(input.objectType)===11?input.objectId:0));
|
||||
const id=trackingText(input.playletId||input.id||(Number(input.objectType)===10?input.objectId:0)||(!isVideo?snapshot?.drama_id:''));
|
||||
if(!id||id==='0')return headers;
|
||||
const same=id===snapshot?.drama_id&&!isVideo,chapter=same?chapters.find(ch=>ch.id===chapterId):undefined;
|
||||
const drama={media_id:same?snapshot?.media_id||'':'',drama_id:id,episode:chapter?.currentEpisode||(same?number(snapshot?.episode):1)||1,video_id:chapterId?String(chapterId):same?snapshot?.video_id||'':'',recommend_trace_id:same?snapshot?.recommend_trace_id||'':''};
|
||||
const requestId=crypto.randomUUID().replace(/-/g,'');
|
||||
const common=Object.fromEntries(COMMON_KEYS.map(key=>[key,key==='client_ts'?Number(fields[key]):fields[key]??'']));
|
||||
return {...headers,'X-Tracker-Request-Id':requestId,'X-Tracker-Event-Name':event,'X-DC-Request-Context':base64JSON({v:1,request_id:requestId,event_name:event,common,drama},true)};
|
||||
})(),{});
|
||||
}
|
||||
async flush(){try{await Promise.resolve();if(this.initialized)await window.WebSDK?.flush();}catch{}}
|
||||
}
|
||||
|
||||
export function startTracker(config?:Config){
|
||||
if(typeof window==='undefined')return;
|
||||
window.tracker??=new SiteTracker();if(config)window.tracker.configure(config);
|
||||
return window.tracker;
|
||||
}
|
||||
+7
-22
@@ -1,9 +1,10 @@
|
||||
import { ApiError, decodeEnvelope, EXPIRED_CODES } from "../api/protocol";
|
||||
import type { User } from "../api/types";
|
||||
import { authMetadata, readBearerToken, writeBearerToken } from "./auth";
|
||||
import { visitorHeaders } from "./visitor-headers";
|
||||
|
||||
export type Query = Record<string, string | number | boolean | undefined>;
|
||||
const PUBLIC = /^(?:config\/|navigation\/|theme\/|applist\/|recharge\/|translation\/|playlet\/home$|playlet\/tag\/options$)/;
|
||||
const PUBLIC = /^(?:config\/|navigation\/|theme\/|translation\/|playlet\/home$|playlet\/tag\/options$)/;
|
||||
const PUBLIC_CONTENT = new Set(["playlet/all/list","playlet/operate/list","playlet/home-config/list","playlet/hot/rank/list","playlet/topic/list","playlet/topic/detail","playlet/tag/list","playlet/guess-like/list","playlet/hot-like"]);
|
||||
const tokenOf = (user: User | null | undefined) => typeof user?.token === 'string' ? user.token.trim() : '';
|
||||
|
||||
@@ -52,13 +53,13 @@ export class ApiClient extends EventTarget {
|
||||
Object.entries(query).forEach(([key,value])=>{if(value!==undefined && value!=="")params.set(key,String(value));});
|
||||
const multipart = body instanceof FormData;
|
||||
const method=body===undefined?'GET':'POST';
|
||||
const tracking=typeof window==='undefined'?{}:await window.tracker?.requestHeaders(path,method,body)||{};
|
||||
const identity=typeof window==='undefined'?{}:visitorHeaders(authMetadata().devID);
|
||||
const response = await fetch(`/api/app/${path}${params.size?"?"+params:""}`, {
|
||||
method, credentials:"include", cache:"no-store", signal,
|
||||
headers: {
|
||||
Accept:"application/json",
|
||||
...tracking,
|
||||
// Authorization must win over any tracking header collision.
|
||||
...identity,
|
||||
// Authorization must win over any identity header collision.
|
||||
...(this.bearer ? {Authorization: this.bearer} : {}),
|
||||
...(body===undefined||multipart?{}:{"Content-Type":"application/json"}),
|
||||
},
|
||||
@@ -67,7 +68,7 @@ export class ApiClient extends EventTarget {
|
||||
const result = decodeEnvelope<T>(await response.json());
|
||||
if (!response.ok || result.code!==200) {
|
||||
const mayConfirm = options?.confirmSession !== false;
|
||||
if (mayConfirm && (EXPIRED_CODES.has(result.code) || response.status===401) && !/^auth\/(login|register|resetPassword)$/.test(path) && version === this.version) {
|
||||
if (mayConfirm && (EXPIRED_CODES.has(result.code) || response.status===401) && version === this.version) {
|
||||
const registered = response.headers.get('X-Auth-State') === 'login-required' || this.user?.isRegister || this.user?.type === 1;
|
||||
// Only an identity check may sign the header out. A single list or action
|
||||
// failure keeps the visible account until getInfo confirms the cookie is gone.
|
||||
@@ -192,7 +193,6 @@ export class ApiClient extends EventTarget {
|
||||
this.rememberBearer(user);
|
||||
// Never expose bearer on the public user snapshot (matches BFF stripping token from JSON).
|
||||
this.user = user ? {...user, token: undefined} : null;
|
||||
if(typeof window!=='undefined')window.tracker?.syncUser(this.user);
|
||||
if (user) { this.requiresLogin = false; this.prompted = false; }
|
||||
this.dispatchEvent(new Event('userchange'));
|
||||
}
|
||||
@@ -203,7 +203,6 @@ export class ApiClient extends EventTarget {
|
||||
if (!user) {
|
||||
this.version++;
|
||||
this.user = null;
|
||||
if(typeof window!=='undefined')window.tracker?.syncUser(null);
|
||||
this.dispatchEvent(new Event('userchange'));
|
||||
return;
|
||||
}
|
||||
@@ -224,7 +223,6 @@ export class ApiClient extends EventTarget {
|
||||
this.clearBearer();
|
||||
this.version++;
|
||||
this.user = null;
|
||||
if(typeof window!=='undefined')window.tracker?.syncUser(null);
|
||||
this.dispatchEvent(new Event('userchange'));
|
||||
}
|
||||
async get<T>(path: string, query: Query = {}, signal?: AbortSignal): Promise<T> {
|
||||
@@ -238,20 +236,7 @@ export class ApiClient extends EventTarget {
|
||||
catch { return; }
|
||||
}
|
||||
async post<T>(path: string, body: unknown): Promise<T> {
|
||||
if (['auth/login','auth/register'].includes(path)) {
|
||||
if (path === 'auth/register' && !this.user && !this.requiresLogin) await this.ensureSession();
|
||||
// Binding must send the current guest Authorization; without it Go creates a disconnected account.
|
||||
if (path === 'auth/register' && !this.bearer) await this.initializeSession();
|
||||
const payload = {...authMetadata(), ...body as object};
|
||||
return this.authenticate(async () => {
|
||||
const data = await this.raw<User>(path,{},payload);
|
||||
// register issues a new JWT and invalidates the guest token; prefer response token.
|
||||
const nextToken = tokenOf(data) || this.bearer;
|
||||
this.setUser({...data, ...(nextToken ? {token: nextToken} : {})});
|
||||
return data as T;
|
||||
});
|
||||
}
|
||||
if(!/^auth\/(guest|resetPassword)$/.test(path))await this.ensureSession();
|
||||
if(path!=='auth/guest')await this.ensureSession();
|
||||
return this.raw(path,{},body);
|
||||
}
|
||||
async refreshUser(options?: {soft?: boolean}) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { SiteContext } from "./context";
|
||||
import { recordList, safeUrl } from "../api/protocol";
|
||||
import { activeBanners, card, empty, escape, hero } from "./render";
|
||||
import { configureSite, copyRoadAddress, downloadUrl } from "./config";
|
||||
import { accountMenuMarkup, bindAccountUI, openAuth, renderMember, renderWallet, type AccountMenuStats } from "./account";
|
||||
import { renderPlayer } from "./player";
|
||||
import { contentPath, navigationSection, paginationPath, parseSitePath, searchPath, samePageAddress, topNavigationSection } from "../routing";
|
||||
import { bindPaginationJumps, navigateTo, ROUTE_EVENT } from "./navigation";
|
||||
@@ -14,13 +13,11 @@ import { createDownloadQr, shouldUseDownloadDialog } from "./download";
|
||||
import { processImage, startImageLoading } from "./images";
|
||||
import { bindHeroSwipe } from "./hero-swipe";
|
||||
import { bindPromotionMarquee } from './promo-marquee';
|
||||
import { bindHeaderHistory } from './header-history';
|
||||
import { scrollToAppCategory } from "./apps";
|
||||
import { renderFeedbackPage } from "./feedback";
|
||||
import { positionSearchDiscovery, searchPicksMarkup, searchSuggestionsMarkup } from "./search";
|
||||
import { infoContactSection } from "../info-pages";
|
||||
import { buildPageHead, clientSeoEnvironment, resolveSeoEnvironment, type SeoBootstrap, type SeoContent, type SeoEnvironment } from '../seo';
|
||||
import { applyPageHead } from './seo';
|
||||
import { SITE_NAME, brandText } from '../brand';
|
||||
import type { PageBootstrap, SiteSharedData } from '../page-data';
|
||||
|
||||
declare global { interface Window { __dramaApiApp?: Application } }
|
||||
@@ -57,7 +54,6 @@ export class Application implements SiteContext {
|
||||
private syncHead(){applyPageHead(buildPageHead(location.pathname,location.search,this.seoEnvironment,this.seoContent));}
|
||||
current(){return !this.signal.aborted;}
|
||||
notify(message:string){const el=$('#toast');el.textContent=message;el.classList.remove('hidden');clearTimeout(this.toastTimer);this.toastTimer=setTimeout(()=>el.classList.add('hidden'),2600);}
|
||||
openAuth(mode='login'){openAuth(mode);}
|
||||
navigate(path:string){navigateTo(path);}
|
||||
|
||||
async init(){
|
||||
@@ -68,15 +64,12 @@ export class Application implements SiteContext {
|
||||
if(session?.user)this.api.seedSession(session.user);
|
||||
else if(session?.state==='login-required')this.api.seedSession(null);
|
||||
startImageLoading();
|
||||
this.bindEvents();this.startupDialogs=startStartupDialogs();this.createAccountMenu();bindAccountUI(this);
|
||||
bindHeaderHistory(this);
|
||||
this.bindEvents();this.startupDialogs=startStartupDialogs();
|
||||
window.addEventListener('pagehide',event=>this.hidePage(event.persisted));
|
||||
window.addEventListener('pageshow',event=>{if(event.persisted)this.restorePage();});
|
||||
this.api.addEventListener('userchange',()=>{this.updateAccount();void this.refreshAccountStats();});
|
||||
this.api.addEventListener('expired',()=>{this.updateAccount();this.openAuth();});
|
||||
// Match jha-1048 GuestAuthBootstrap: establish guest (+ soft getInfo) immediately on enter,
|
||||
// in parallel with shared config, before client routes touch private APIs.
|
||||
const bootSession=this.api.bootstrapGuestSession().then(()=>this.updateAccount()).catch(error=>this.notify(error.message));
|
||||
const bootSession=this.api.bootstrapGuestSession().catch(error=>this.notify(error.message));
|
||||
await Promise.all([bootSession,this.initializeShared()]);
|
||||
this.startFloatingTools();
|
||||
await this.route();
|
||||
@@ -87,8 +80,6 @@ export class Application implements SiteContext {
|
||||
if(shared){
|
||||
this.footerContacts=recordList<FooterContact>(shared.footer.footerInfo?.contactList);
|
||||
await this.applyConfig(shared.config);
|
||||
// SDK configuration is fetched separately and is never serialized into page HTML.
|
||||
if(shared.trackerConfigured)void this.api.get<Config>('config/info').then(config=>window.tracker?.configure(config)).catch(()=>{});
|
||||
}else{
|
||||
this.api.get<Config>('config/info').then(config=>this.applyConfig(config)).catch(error=>this.notify(error.message));
|
||||
void this.loadFooter();
|
||||
@@ -99,7 +90,6 @@ export class Application implements SiteContext {
|
||||
this.config=config;this.startupDialogs?.setAdvertisements(config.advitise?.dialogList||[],config.advitise?.nineGridList||[]);
|
||||
this.seoEnvironment=clientSeoEnvironment(this.seoEnvironment,config);
|
||||
if(this.routeKey)this.syncHead();
|
||||
window.tracker?.configure(config);
|
||||
this.refreshInfoContacts();await configureSite(config);
|
||||
}
|
||||
private refreshInfoContacts(){
|
||||
@@ -113,14 +103,13 @@ export class Application implements SiteContext {
|
||||
}
|
||||
private hidePage(persisted:boolean){
|
||||
const route=parseSitePath(location.pathname,location.search);
|
||||
const interactive=route?.group==='detail'||route?.group==='play'||route?.group==='member'||route?.group==='info'&&['deposit','settings','user-center','feedback'].includes(route.key);
|
||||
const interactive=route?.group==='detail'||route?.group==='play';
|
||||
if(!persisted||interactive||document.querySelector('dialog[open]')){this.disposePage();return;}
|
||||
this.stopHeroAutoplay();this.heroSequence++;this.feature=this.renderedFeature;this.disposeHeroFade?.();
|
||||
}
|
||||
private restorePage(){
|
||||
if(this.signal.aborted){window.location.reload();return;}
|
||||
this.restartHeroAutoplay();
|
||||
window.tracker?.resumePage();
|
||||
// Restore the document immediately, then refresh the header identity using current cookies.
|
||||
void this.api.revalidateSession().catch(()=>{});
|
||||
}
|
||||
@@ -191,25 +180,10 @@ export class Application implements SiteContext {
|
||||
}
|
||||
// Error HTML stays visible too; missing initial data must never trigger a client page fetch.
|
||||
if(!page||!samePageAddress(page.path,this.routeKey))return;
|
||||
const pageHref=(value:number)=>paginationPath(this.routeKey,value);
|
||||
if(page.panel==='player'&&page.player){
|
||||
this.disposePlayer=await renderPlayer(this,page.player.detail.id,route.group==='play'?Number(route.value):undefined,false,{initial:page.player,preserveMarkup:true,page:route.page||1,commentSort:new URLSearchParams(location.search).get('commentSort')==='1'?1:2,pageHref:(value,sort)=>paginationPath(contentPath(route.group==='play'?'play':'detail',page.player!.detail,route.group==='play'?Number(route.value):undefined)+location.search,value,{commentSort:sort===2?null:sort})});
|
||||
this.disposePlayer=await renderPlayer(this,page.player.detail.id,route.group==='play'?Number(route.value):undefined,false,{initial:page.player,preserveMarkup:true});
|
||||
}else if(page.panel==='search'&&page.search){
|
||||
$('#query').setAttribute('value',page.search.keyword);
|
||||
window.tracker?.search(page.search.keyword,page.search.total,page.search.page);
|
||||
}else if(page.panel==='info'){
|
||||
if(page.info?.member)await renderMember(this,page.info.key,route.page||1,pageHref,page.info.member,true);
|
||||
else if(page.info?.wallet)await renderWallet(this,route.value==='vip',route.page||1,pageHref,page.info.wallet,true);
|
||||
else if(page.info?.feedback)renderFeedbackPage(this,true);
|
||||
else if(page.session?.state!=='login-required'){
|
||||
// Cold SSR only painted "正在准备账号"; guest bootstrap already finished in init().
|
||||
if(route.group==='member'||(route.group==='info'&&['settings','user-center'].includes(route.key))){
|
||||
const tab=route.group==='member'?route.key:route.key==='settings'?'settings':'center';
|
||||
await renderMember(this,tab,route.page||1,pageHref);
|
||||
}else if(route.group==='info'&&route.key==='deposit'){
|
||||
await renderWallet(this,route.value==='vip',route.page||1,pageHref);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,13 +193,9 @@ export class Application implements SiteContext {
|
||||
document.addEventListener('click',event=>{void (async()=>{
|
||||
const el=(event.target as Element).closest<HTMLElement>('button,a');if(!el)return;
|
||||
if(el.dataset.close){(document.getElementById(el.dataset.close) as HTMLDialogElement)?.close();return;}
|
||||
if(el.dataset.authMode){this.openAuth(el.dataset.authMode);return;}
|
||||
if(el.dataset.memberRoute){this.navigate('/member/'+el.dataset.memberRoute+'/');return;}
|
||||
if(el.dataset.sort){const sort=Number(el.dataset.sort);this.navigate(paginationPath(location.pathname+location.search,1,{sort:sort===1?null:sort}));return;}
|
||||
if(el.dataset.appCategory){scrollToAppCategory($('#channel-content'),el.dataset.appCategory);return;}
|
||||
if(el.dataset.feature!==undefined||el.dataset.heroStep){this.selectHero(el.dataset.feature!==undefined?Number(el.dataset.feature):this.feature+Number(el.dataset.heroStep));this.restartHeroAutoplay();return;}
|
||||
if(el.dataset.apiAction==='retry'){window.location.reload();return;}
|
||||
if(el.dataset.accountAction==='coins'){this.navigate('/info/deposit/');return;}
|
||||
if(el.dataset.roadCopy){await copyRoadAddress(el);return;}
|
||||
if(el.hasAttribute('data-app-download')){
|
||||
if(!downloadUrl(this.config)){event.preventDefault();return;}
|
||||
@@ -234,7 +204,6 @@ export class Application implements SiteContext {
|
||||
}
|
||||
if(el.id==='app-copy-link'){const url=downloadUrl(this.config);if(!url)throw new Error('暂未配置下载地址');await navigator.clipboard.writeText(url);$('#app-copy-status').textContent='链接已复制';return;}
|
||||
if(el.id==='app-download-close'){($<HTMLDialogElement>('#app-download-dialog')).close();return;}
|
||||
if(el.id==='floating-history'){this.navigate('/member/history/');return;}
|
||||
if(el.id==='back-to-top'){window.scrollTo({top:0,behavior:'smooth'});return;}
|
||||
if(el.id==='search-panel-close'){this.closeSearchDiscovery();return;}
|
||||
if(el.id==='search-clear'){localStorage.removeItem('91-search-history');await this.searchDiscovery();return;}
|
||||
@@ -257,55 +226,7 @@ export class Application implements SiteContext {
|
||||
const url=downloadUrl(this.config);if(!url)return;
|
||||
const dialog=$<HTMLDialogElement>('#app-download-dialog');if(!dialog.open)dialog.showModal();
|
||||
const qr=await createDownloadQr(url);
|
||||
if(dialog.open&&downloadUrl(this.config)===url){const image=$<HTMLImageElement>('#app-platform-qr');image.src=qr;image.alt='禁果短剧 App 下载二维码';}
|
||||
}
|
||||
private accountStats?:AccountMenuStats;
|
||||
private accountStatsUser?:number;
|
||||
private accountStatsSeq=0;
|
||||
private createAccountMenu(){
|
||||
const panel=document.createElement('div');panel.id='profile-popover';panel.hidden=true;panel.setAttribute('aria-label','账户菜单');document.body.append(panel);
|
||||
const entry=$('#account-entry');
|
||||
const close=()=>{panel.hidden=true;entry.setAttribute('aria-expanded','false');};
|
||||
const position=()=>{const rect=entry.getBoundingClientRect();panel.style.right=Math.max(12,innerWidth-rect.right)+'px';panel.style.top=Math.min(rect.bottom+12,innerHeight-120)+'px';};
|
||||
entry.addEventListener('click',()=>{
|
||||
if(!panel.hidden){close();return;}
|
||||
this.updateAccount();position();panel.hidden=false;entry.setAttribute('aria-expanded','true');
|
||||
void this.refreshAccountStats();
|
||||
});
|
||||
panel.addEventListener('click',event=>{if((event.target as Element).closest('a,button'))close();});
|
||||
document.addEventListener('click',event=>{if(!entry.contains(event.target as Node)&&!panel.contains(event.target as Node))close();});
|
||||
document.addEventListener('keydown',event=>{
|
||||
if(event.key==='Escape'&&!panel.hidden){close();entry.focus();}
|
||||
if(event.key==='ArrowDown'&&document.activeElement===entry&&!panel.hidden){event.preventDefault();panel.querySelector<HTMLElement>('a,button')?.focus();}
|
||||
});
|
||||
document.addEventListener('focusin',event=>{if(!panel.hidden&&!entry.contains(event.target as Node)&&!panel.contains(event.target as Node))close();});
|
||||
window.addEventListener('resize',()=>{if(!panel.hidden)position();});
|
||||
window.addEventListener('scroll',()=>close(),{passive:true});
|
||||
this.updateAccount();
|
||||
}
|
||||
private async refreshAccountStats(){
|
||||
const userId=this.api.user?.userId;if(!userId)return;
|
||||
const seq=++this.accountStatsSeq;
|
||||
// Menu counts are decorative: never confirmSession/getInfo from a failed stats read.
|
||||
const stats=await this.api.tryGet<AccountMenuStats>('user/center/data',{},this.signal);
|
||||
if(!stats||seq!==this.accountStatsSeq||this.signal.aborted||this.api.user?.userId!==userId)return;
|
||||
this.accountStats=stats;this.accountStatsUser=userId;
|
||||
const panel=document.getElementById('profile-popover');if(!panel)return;
|
||||
const write=(id:string,value?:number)=>{const el=panel.querySelector('#'+id);if(el&&typeof value==='number'&&Number.isFinite(value)&&value>=0)el.textContent=Math.floor(value).toLocaleString('zh-CN');};
|
||||
write('profile-saved-count',stats.collectCount);write('profile-likes-count',stats.likeCount);write('profile-coin-count',this.api.user?.recharge);
|
||||
}
|
||||
private updateAccount(){
|
||||
const user=this.api.user;const name=user?.nickName||user?.accountNo||'游客';
|
||||
const entry=$('#account-entry');entry.setAttribute('aria-label',`${name},展开账户菜单`);
|
||||
entry.querySelector('strong')!.textContent=name;$('#account-status').textContent=user?(user.isRegister||user.type===1?'已登录':'游客账号'):'未登录';
|
||||
const avatarUrl=safeUrl(user?.avatar)||'/icon.svg';
|
||||
const avatar=$<HTMLImageElement>('#account-entry img');void processImage(avatarUrl,avatar,'img','/icon.svg');
|
||||
// Keep the comment composer in sync with the signed-in/guest header avatar.
|
||||
document.querySelectorAll<HTMLImageElement>('.play-compose-avatar').forEach(node=>void processImage(avatarUrl,node,'img','/icon.svg'));
|
||||
$('.coin-value').textContent=user?String(user.recharge||0):'—';$('.coin-entry').setAttribute('aria-label',user?`金币余额 ${user.recharge||0}`:'金币余额,登录后查看');
|
||||
const panel=document.getElementById('profile-popover');if(!panel)return;
|
||||
if(user?.userId!==this.accountStatsUser)this.accountStats=undefined;
|
||||
panel.innerHTML=accountMenuMarkup(user,this.accountStats);
|
||||
if(dialog.open&&downloadUrl(this.config)===url){const image=$<HTMLImageElement>('#app-platform-qr');image.src=qr;image.alt=`${SITE_NAME} App 下载二维码`;}
|
||||
}
|
||||
|
||||
private async loadDiscovery(){
|
||||
@@ -343,13 +264,11 @@ export class Application implements SiteContext {
|
||||
const data=await this.api.get<FooterInfoResponse>('config/footer/info');
|
||||
const footer=data?.footerInfo;if(!footer)return;
|
||||
this.footerContacts=recordList<FooterContact>(footer.contactList);this.refreshInfoContacts();
|
||||
const brand=footer.brand?.title?.trim()||'';
|
||||
const brand=brandText(footer.brand?.title)||SITE_NAME;
|
||||
const titleNode=document.querySelector('.site-footer-title');
|
||||
if(titleNode){titleNode.textContent=brand;if(brand){const mark=document.createElement('i');mark.setAttribute('aria-hidden','true');titleNode.append(mark);}}
|
||||
if(titleNode){titleNode.textContent=brand;const mark=document.createElement('i');mark.setAttribute('aria-hidden','true');titleNode.append(mark);}
|
||||
const slogan=document.querySelector('.hg-footer-original-slogan');
|
||||
if(slogan)slogan.textContent=footer.brand?.content?.trim()||'';
|
||||
const copyright=document.querySelector('.site-footer-copyright');
|
||||
if(copyright)copyright.textContent=`© ${new Date().getFullYear()}${brand?` ${brand} `:''}All rights reserved.`;
|
||||
if(slogan)slogan.textContent=brandText(footer.brand?.content);
|
||||
}catch{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import type { AppCategory, AppData, AppItem } from '../api/types';
|
||||
import { recordList, safeUrl } from '../api/protocol';
|
||||
import type { ApiClient } from './api';
|
||||
import { empty, escape, imageAttributes } from './render';
|
||||
|
||||
function appItems(value:unknown):AppItem[]{
|
||||
return recordList<AppItem>(value).filter(item=>typeof item.name==='string'&&item.name.trim());
|
||||
}
|
||||
|
||||
/** The initial response may include only the selected category's applications. */
|
||||
export async function loadAppCategories(api:Pick<ApiClient,'get'>,signal:AbortSignal):Promise<AppCategory[]>{
|
||||
const data=await api.get<AppData>('applist/list',{categoryId:0},signal);
|
||||
if(signal.aborted)return [];
|
||||
const current=recordList<AppCategory>([data?.category])[0];
|
||||
const categories=recordList<AppCategory>(data?.categoryList);
|
||||
if(current&&!categories.some(category=>Number(category.id)===Number(current.id)))categories.push(current);
|
||||
const seen=new Set<number>();
|
||||
const ordered=categories.filter(category=>{
|
||||
const id=Number(category.id);
|
||||
if(!Number.isSafeInteger(id)||id<0||seen.has(id))return false;
|
||||
seen.add(id);return true;
|
||||
});
|
||||
const results=await Promise.allSettled(ordered.map(async category=>{
|
||||
const id=Number(category.id);
|
||||
const isCurrent=current&&Number(current.id)===id&&Array.isArray(current.appList);
|
||||
let detail=isCurrent?current:category;
|
||||
// Empty lists in the category index may be defaults; verify their detail before hiding them.
|
||||
if(!Array.isArray(detail.appList)||!isCurrent&&!appItems(detail.appList).length){
|
||||
const response=await api.get<AppData>('applist/list',{categoryId:id},signal);
|
||||
detail=recordList<AppCategory>([response?.category,...recordList<AppCategory>(response?.categoryList)]).find(item=>Number(item.id)===id&&Array.isArray(item.appList))||category;
|
||||
}
|
||||
return {...category,...detail,id,appList:appItems(detail.appList)};
|
||||
}));
|
||||
if(signal.aborted)return [];
|
||||
const available=results.flatMap(result=>result.status==='fulfilled'&&result.value.appList.length?[result.value]:[]);
|
||||
if(!available.length){const failure=results.find(result=>result.status==='rejected');if(failure?.status==='rejected')throw failure.reason;}
|
||||
return available;
|
||||
}
|
||||
|
||||
export function renderAppDirectory(categories:AppCategory[]):string{
|
||||
const visible=categories.map(category=>({...category,appList:appItems(category.appList)})).filter(category=>category.appList.length);
|
||||
if(!visible.length)return empty('暂无福利应用');
|
||||
return `<div class="benefits-directory"><nav class="channel-tabs" aria-label="福利应用分类">${visible.map((category,index)=>`<button type="button" data-app-category="${category.id}" aria-controls="apps-${category.id}" aria-pressed="${index===0}">${escape(category.title)}</button>`).join('')}</nav>${visible.map(category=>`<section class="benefit-section" id="apps-${category.id}" aria-labelledby="apps-title-${category.id}"><header class="benefit-heading"><div><h2 id="apps-title-${category.id}">${escape(category.title)}</h2>${category.intro?`<p>${escape(category.intro)}</p>`:''}</div></header><div class="benefit-grid" style="--compact-app-columns:${Math.max(1,Math.ceil(Math.min(category.appList.length,20)/2))}">${category.appList.map(app=>`<a class="benefit-card" ${appTracking(app,category.id)} href="${escape(safeUrl(app.url)||'/apps/')}" target="_blank" rel="${safeUrl(app.url)?"sponsored nofollow noopener noreferrer":"noopener noreferrer"}"><span class="benefit-icon"><img ${imageAttributes(app.logo)} alt="${escape(app.name.trim())}应用图标" loading="lazy"></span><span class="benefit-copy"><strong>${escape(app.name)}</strong><small>${escape(app.intro||app.description)}</small></span></a>`).join('')}</div></section>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function appTracking(app:AppItem,categoryId:number):string{
|
||||
if(app.advitiseId)return `data-track-ad="external" data-ad-id="${escape(app.advitiseId)}" data-ad-clickable="${safeUrl(app.url)?1:0}" data-ad-slot-key="${escape(app.location||'apps_'+categoryId)}" data-ad-slot-name="福利应用" data-ad-type="icon"`;
|
||||
return app.id?`data-track-item data-item-id="${escape(app.id)}" data-item-name="${escape(app.name)}" data-item-type="app" data-parent-item-id="${escape(categoryId)}"`:'';
|
||||
}
|
||||
|
||||
export function scrollToAppCategory(root:HTMLElement,id:string){
|
||||
if(!/^\d+$/.test(id))return;
|
||||
const section=root.querySelector<HTMLElement>(`#apps-${id}`);if(!section)return;
|
||||
root.querySelectorAll<HTMLButtonElement>('[data-app-category]').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.appCategory===id)));
|
||||
section.scrollIntoView({behavior:window.matchMedia?.('(prefers-reduced-motion: reduce)').matches?'auto':'smooth',block:'start'});
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Config } from "../api/types";
|
||||
import { escape } from "./render";
|
||||
import { downloadUrl } from "../app-download";
|
||||
import { configureDownloadLinks } from "./download";
|
||||
import { BUSINESS_CONTACT } from "../contact";
|
||||
|
||||
export { downloadUrl };
|
||||
export function configureSite(config: Config) {
|
||||
@@ -12,19 +13,14 @@ export function configureSite(config: Config) {
|
||||
const input = document.querySelector<HTMLInputElement>("#app-platform-link");
|
||||
if(input)input.value=download;
|
||||
document.querySelector(".app-download-demo")?.remove();
|
||||
const caption=document.getElementById("app-platform-caption");if(caption)caption.textContent=download?"扫码下载安装 禁果短剧 App":"暂未配置 App 下载地址";
|
||||
const channels: Record<string,unknown> = {"官方推特":settings.twitterAddr,"官方TG群":settings.tgGroup,"官方联系":settings.telegram,"Telegram":settings.telegram,"官方社群":settings.tgGroup,"X":settings.twitterAddr};
|
||||
document.querySelectorAll<HTMLAnchorElement>('a[target="_blank"]:not([data-track-ad])').forEach(link=>{
|
||||
const label=link.getAttribute('aria-label')||link.textContent||"";
|
||||
for(const [key,url] of Object.entries(channels))if(label.includes(key)&&safeUrl(url)){link.href=safeUrl(url);link.rel='sponsored nofollow noopener noreferrer';if(label.startsWith('官方联系:'))link.textContent='官方联系:'+safeUrl(url);break;}
|
||||
});
|
||||
const caption=document.getElementById("app-platform-caption");if(caption)caption.textContent=download?"扫码下载安装 黄果短剧 App":"暂未配置 App 下载地址";
|
||||
}
|
||||
|
||||
export function homeRoad(config: Config, pageUrl = '/home-road/'): string {
|
||||
const values={...config.newAddressInfo,...config.config};
|
||||
const icons={rocket:'3651a2618990bf95',link:'ba17d56edfb047e2',shield:'d001d1cc6c2f0ce6',help:'d59d2d7390d88f08',devices:'d4ebee2aa88ba9a2',iphone:'a0c6451fadcb7738',android:'8b1153736cc5dca1',desktop:'11bae9823678ebdc',email:'2a8580c42090f36c',tg:'9a5b6e9db9de76b8',contact:'228a0af1662081ac',twitter:'72f702ca215f597d'};
|
||||
type Icon=keyof typeof icons;
|
||||
const iconLabels:Record<Icon,string>={rocket:'备用访问地址',link:'永久访问入口',shield:'官方联系',help:'访问帮助',devices:'App 下载与设备',iphone:'iPhone 设备',android:'Android 设备',desktop:'电脑设备',email:'联系邮箱',tg:'Telegram 官方交流群',contact:'官方联系',twitter:'X 官方社区'};
|
||||
const iconLabels:Record<Icon,string>={rocket:'备用访问地址',link:'永久访问入口',shield:'官方联系',help:'访问帮助',devices:'App 下载与设备',iphone:'iPhone 设备',android:'Android 设备',desktop:'电脑设备',email:'联系邮箱',tg:'Telegram',contact:'官方联系',twitter:'X 官方社区'};
|
||||
const icon=(key:Icon,size=30)=>`<img${size===30?' class="return-section-icon"':''} src="/assets/${icons[key]}.svg" width="${size}" height="${size}" alt="${iconLabels[key]}图标" aria-hidden="true">`;
|
||||
const heading=(title:string,key?:Icon,note='')=>`<div class="return-section-head">${key?icon(key):''}<h2>${title}</h2>${note?`<span>${note}</span>`:''}</div>`;
|
||||
const external=(url:string,label:string)=>`<a href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer">${escape(label)} <span aria-hidden="true">↗</span></a>`;
|
||||
@@ -35,18 +31,13 @@ export function homeRoad(config: Config, pageUrl = '/home-road/'): string {
|
||||
const backups=[...new Set(configured.length?configured:navigation)].slice(0,3);
|
||||
const forever=safeUrl(values.foreverUrlAddr),primary=backups[0]||forever;
|
||||
const permanent=[['永久入口',forever],['主站入口',primary],['备用中转',backups[1]]].filter((row,index,rows)=>row[1]&&rows.findIndex(other=>other[1]===row[1])===index);
|
||||
const contacts:string[]=[];
|
||||
const contact=(key:Icon,title:string,description:string,url:string,app=false)=>`<a class="return-contact-card" href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer"${app?' data-app-download data-app-download-entry':''}><span class="return-contact-icon">${icon(key,28)}</span><span><strong>${title}</strong><small>${description}</small></span><span class="return-contact-arrow" aria-hidden="true">↗</span></a>`;
|
||||
const email=[values.emailAddr,values.email].find(value=>typeof value==='string'&&/^[^\s@<>"']+@[^\s@<>"']+\.[^\s@<>"']+$/.test(value.trim()));
|
||||
if(typeof email==='string')contacts.push(`<button type="button" class="return-contact-card" data-road-copy="${escape(email.trim())}"><span class="return-contact-icon">${icon('email',28)}</span><span><strong>联系邮箱</strong><small>复制邮箱地址</small></span><span class="return-contact-arrow" aria-hidden="true">↗</span></button>`);
|
||||
const channels:[Icon,string,string,unknown][]=[['tg','官方交流群','查看更新与入口通知',values.tgGroup],['contact','官方联系','联系官方团队',values.telegram],['twitter','X 社区','关注公告与动态',values.twitterAddr]];
|
||||
for(const [key,title,description,value] of channels){const url=safeUrl(value);if(url)contacts.push(contact(key,title,description,url));}
|
||||
const app=downloadUrl(config);if(app)contacts.push(contact('devices','App 下载','下载客户端,随时追剧',app,true));
|
||||
// 保持联系只保留唯一的商务合作入口(Telegram @jbd125),不再展示邮箱 / 交流群 / X。
|
||||
const contacts=[`<a class="return-contact-card" href="${escape(BUSINESS_CONTACT.href)}" target="_blank" rel="nofollow noopener noreferrer"><span class="return-contact-icon">${icon('tg',28)}</span><span><strong>${BUSINESS_CONTACT.label}</strong><small>Telegram ${BUSINESS_CONTACT.handle}</small></span><span class="return-contact-arrow" aria-hidden="true">↗</span></a>`];
|
||||
return `<div class="return-home-directory">
|
||||
<section class="return-hero"><div class="return-hero-copy"><span class="return-eyebrow">保存入口 · 随时回来</span><h2>把回家的路,留在身边</h2><p>收藏常用入口,访问不畅时换一条路线。</p></div><div class="return-actions">${primary?external(primary,'进入主站'):''}${copy(safeUrl(pageUrl)||'/home-road/','复制本页地址')}</div></section>
|
||||
<section class="return-section">${heading('备用访问地址','rocket',`${backups.length} 条备用入口`)}${backups.length?`<div class="return-address-grid">${backups.map((url,index)=>`<article class="return-address-card"><span class="return-card-label">备用地址 ${String(index+1).padStart(2,'0')}</span><strong>${escape(display(url))}</strong><div class="return-card-actions">${external(url,'立即访问')}${copy(url)}</div></article>`).join('')}</div>`:'<p class="return-source-note">暂未配置备用访问地址</p>'}</section>
|
||||
<section class="return-section">${heading('永久访问入口','link','建议收藏')}${permanent.length?`<div class="return-permanent">${permanent.map(([label,url])=>`<div class="return-permanent-row"><span>${label}</span>${external(url,display(url))}${copy(url)}</div>`).join('')}</div>`:'<p class="return-source-note">暂未配置永久访问入口</p>'}</section>
|
||||
<section class="return-section">${heading('保持联系','shield')}${contacts.length?`<div class="return-contact-grid">${contacts.join('')}</div>`:'<p class="return-source-note">暂未配置官方联系方式</p>'}</section>
|
||||
<section class="return-section">${heading('保持联系','shield')}${contacts.length?`<div class="return-contact-grid">${contacts.join('')}</div>`:''}</section>
|
||||
<section class="return-section">${heading('遇到访问问题?','help')}<div class="return-help-grid">${[['01','页面打不开','刷新页面或切换网络,再试其他入口。'],['02','跳转不正常','核对域名拼写,从已保存的入口重新打开。'],['03','地址已失效','查看本页或社区发布的地址更新。']].map(([number,title,description])=>`<article><span>${number}</span><h3>${title}</h3><p>${description}</p></article>`).join('')}</div></section>
|
||||
<section class="return-section">${heading('保存到你的设备','devices')}<div class="return-help-grid">${([['iphone','iPhone 用户','在 Safari 中打开,通过分享菜单添加书签。'],['android','Android 用户','在浏览器菜单中选择收藏或添加书签。'],['desktop','电脑 / 桌面端','使用 Ctrl+D;Mac 使用 Command+D 保存书签。']] as [Icon,string,string][]).map(([key,title,description])=>`<article><h3 class="return-device-heading">${icon(key,28)}${title}</h3><p>${description}</p></article>`).join('')}</div></section>
|
||||
<section class="return-section return-faq">${heading('常见问题')}<details><summary>应该保存哪个地址?</summary><p>收藏本页,并额外保留一个备用入口。</p></details><details><summary>复制地址后怎么保存?</summary><p>粘贴到备忘录,或在浏览器中打开后添加书签。</p></details><details><summary>备用地址也打不开怎么办?</summary><p>更换网络后重试,也可通过社区查看新发布的入口。</p></details></section>
|
||||
|
||||
@@ -11,6 +11,5 @@ export interface SiteContext {
|
||||
current(): boolean;
|
||||
route(): Promise<void>;
|
||||
navigate(path: string): void;
|
||||
openAuth(mode?: string): void;
|
||||
updateSeo?(content:SeoContent):void;
|
||||
}
|
||||
|
||||
+55
-30
@@ -2,13 +2,13 @@ import { contentPath, contentSegment } from '../routing';
|
||||
import type { Chapter, Config, DramaDetail } from "../api/types";
|
||||
import { formatCount, recordList, safeUrl } from "../api/protocol";
|
||||
import { escape, imageAttributes, title } from "./render";
|
||||
import { SITE_NAME } from "../brand";
|
||||
|
||||
// Original HTML action icons, kept as SVG paths so active colors follow the stylesheet.
|
||||
const actionPaths = {
|
||||
"save": "M857.6 327.68l-104.494545-34.210909a178.036364 178.036364 0 0 1-90.763637-66.56l-64-89.367273a104.96 104.96 0 0 0-23.272727-23.272727 106.123636 106.123636 0 0 0-148.48 23.272727l-64.930909 89.367273a179.665455 179.665455 0 0 1-90.763637 66.792727l-104.494545 33.978182a106.821818 106.821818 0 0 0-53.061818 162.909091l93.090909 128.930909a35.84 35.84 0 0 1 6.981818 21.643636v158.952728a109.381818 109.381818 0 0 0 5.12 33.512727 105.658182 105.658182 0 0 0 133.818182 67.956364l128-39.33091a107.985455 107.985455 0 0 1 64.232727 0l127.534546 39.33091a106.123636 106.123636 0 0 0 139.636363-101.469091v-158.254546a35.84 35.84 0 0 1 6.981818-21.643636l93.09091-128.930909a104.494545 104.494545 0 0 0 15.127272-29.556364 106.821818 106.821818 0 0 0-69.352727-134.050909z m-192 83.549091a34.909091 34.909091 0 0 1-48.174545 10.938182l-46.545455-29.090909a34.909091 34.909091 0 0 1 37.003636-59.112728l46.545455 29.090909a34.909091 34.909091 0 0 1 11.170909 48.174546z",
|
||||
"like": "M855.738182 198.283636A252.043636 252.043636 0 0 0 670.254545 116.363636a225.28 225.28 0 0 0-160.814545 63.069091 248.32 248.32 0 0 0-152.901818-53.294545A251.578182 251.578182 0 0 0 171.287273 209.454545 287.883636 287.883636 0 0 0 162.909091 588.567273l256 275.083636a122.181818 122.181818 0 0 0 90.065454 39.563636 121.716364 121.716364 0 0 0 89.832728-39.563636l256-272.989091 2.792727-3.723636a290.443636 290.443636 0 0 0-1.861818-388.654546z",
|
||||
"share": "M910.661818 117.992727a114.734545 114.734545 0 0 0-113.803636-16.756363L134.050909 363.752727a138.472727 138.472727 0 0 0-15.592727 250.181818l97.978182 54.225455 1.861818 162.909091a87.272727 87.272727 0 0 0 137.309091 69.818182L482.210909 814.545455l180.363636 99.374545a138.472727 138.472727 0 0 0 203.869091-101.236364L954.181818 224.814545a113.803636 113.803636 0 0 0-43.52-106.821818z",
|
||||
"feedback": "M505.949091 38.4a465.454545 465.454545 0 1 0 465.454545 465.454545 465.454545 465.454545 0 0 0-465.454545-465.454545z m0 721.454545a46.545455 46.545455 0 1 1 46.545454-46.545454 46.545455 46.545455 0 0 1-46.545454 46.545454z m43.287273-188.276363a11.636364 11.636364 0 0 0-8.378182 11.170909v25.832727a34.909091 34.909091 0 0 1-69.818182 0V581.818182a81.454545 81.454545 0 0 1 58.88-78.196364 87.04 87.04 0 0 0 60.741818-102.865454A86.807273 86.807273 0 0 0 433.803636 372.363636a90.298182 90.298182 0 0 0-11.403636 24.436364 34.909091 34.909091 0 0 1-67.025455-19.549091 162.909091 162.909091 0 0 1 20.712728-43.985454 156.625455 156.625455 0 1 1 173.149091 238.312727z",
|
||||
"phone": "M756.363636 69.818182h-488.727272A128.232727 128.232727 0 0 0 139.636364 197.818182v628.363636a128.232727 128.232727 0 0 0 128 128h488.727272a128 128 0 0 0 128-128v-628.363636A128 128 0 0 0 756.363636 69.818182z m-139.636363 698.181818h-209.454546a34.909091 34.909091 0 0 1 0-69.818182h209.454546a34.909091 34.909091 0 0 1 0 69.818182z"
|
||||
} as const;
|
||||
|
||||
@@ -16,7 +16,8 @@ export function detailActionIcon(kind: keyof typeof actionPaths): string {
|
||||
return `<svg class="play-action-icon" viewBox="0 0 1024 1024" aria-hidden="true"><path d="${actionPaths[kind]}"/></svg>`;
|
||||
}
|
||||
|
||||
function genreMarkup(drama: DramaDetail): string {
|
||||
/** "#标签" links: tags first, then themes, without duplicates. */
|
||||
export function hashTags(drama: DramaDetail): string {
|
||||
const items: {name: string; href: string}[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (names: unknown, ids: number[] | undefined, slugs: string[] | undefined, kind: 'tag' | 'theme') => {
|
||||
@@ -32,36 +33,62 @@ function genreMarkup(drama: DramaDetail): string {
|
||||
add(drama.tagNames, drama.tagIds, drama.tagSlugs, 'tag');
|
||||
add(drama.themeNames, drama.themeIds, drama.themeSlugs, 'theme');
|
||||
if (!items.length) return '';
|
||||
return `<p class="series-genres">${items.map((item, index) => `${index ? ' · ' : ''}${item.href ? `<a href="${escape(item.href)}">${escape(item.name)}</a>` : escape(item.name)}`).join('')}</p>`;
|
||||
return `<p class="cine-tags" aria-label="剧集标签">${items.map(item => item.href ? `<a href="${escape(item.href)}">#${escape(item.name)}</a>` : `<span>#${escape(item.name)}</span>`).join('')}</p>`;
|
||||
}
|
||||
|
||||
function seriesFacts(drama: DramaDetail): string {
|
||||
const facts: string[] = [];
|
||||
if (typeof drama.totalEpisode === 'number' && Number.isSafeInteger(drama.totalEpisode) && drama.totalEpisode > 0) facts.push(`全${drama.totalEpisode}集`);
|
||||
if (drama.updateStatus === 1) facts.push('连载中');
|
||||
else if (drama.updateStatus === 2) facts.push('已完结');
|
||||
if (typeof drama.countBrowse === 'number' && Number.isFinite(drama.countBrowse) && drama.countBrowse > 0) facts.push(`${formatCount(drama.countBrowse)}次观看`);
|
||||
const actor = typeof drama.anchor === 'string' ? drama.anchor.trim() : '';
|
||||
if (actor) facts.push(`演员 ${actor}`);
|
||||
return facts.length ? `<p class="series-facts">${facts.map(item => `<span>${escape(item)}</span>`).join('')}</p>` : '';
|
||||
/** Single videos have at most one episode. */
|
||||
export function isSingleVideo(drama: DramaDetail, chapters: Chapter[] = []): boolean {
|
||||
const total = typeof drama.totalEpisode === 'number' && drama.totalEpisode > 0 ? drama.totalEpisode : chapters.length;
|
||||
return total <= 1;
|
||||
}
|
||||
|
||||
export function overviewMarkup(drama: DramaDetail, first: Chapter | undefined, actions: string): string {
|
||||
const badge = `${drama.isFeatured ? '<span class="series-badge series-badge--hot">爆剧</span>' : ''}${drama.isNew ? '<span class="series-badge series-badge--new">新剧</span>' : ''}${drama.showOriginal ? '<span class="series-badge series-badge--original">原创</span>' : ''}${drama.operateType === 2 ? '<span class="series-badge series-badge--comic">漫剧</span>' : ''}`;
|
||||
return `<div class="series-overview">
|
||||
<img class="series-poster" ${imageAttributes(drama.cover || drama.coverImg)} width="640" height="914" alt="${escape(title(drama))}封面">
|
||||
<div class="series-info">
|
||||
<div class="series-title-row"><h1 id="play-detail-title">${escape(title(drama))}</h1><span class="series-heat"><img class="series-heat-icon" src="/assets/9df0afe903e8d542.svg" width="16" height="16" alt="剧集热度图标" aria-hidden="true">${formatCount(drama.heatValue)}热度</span></div>
|
||||
<div class="series-tags" aria-label="剧集标签">${badge}<span class="series-stat" data-engage-count="collect" aria-live="polite">${formatCount(drama.countCollect)}收藏</span><span class="series-stat" data-engage-count="like" aria-live="polite">${formatCount(drama.countLike)}点赞</span></div>
|
||||
${seriesFacts(drama)}
|
||||
${genreMarkup(drama)}
|
||||
<div class="series-description-wrap"><p class="series-description" id="series-description-text">${escape(drama.desc || drama.description)}</p><button class="series-description-toggle" type="button" aria-expanded="false" aria-controls="series-description-text" hidden>展开</button></div>
|
||||
<div class="series-action-bar"><div class="series-actions">
|
||||
${first ? `<a class="primary detail-play" href="${contentPath('play',drama,first.id)}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg><span>立即播放</span></a>` : '<span class="series-unavailable">暂无可用剧集</span>'}
|
||||
<a class="series-download" data-app-download-entry data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer" hidden><svg class="series-download-phone" viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4M11 19h2"/></svg><span>下载 App</span></a>
|
||||
</div>${actions}</div>
|
||||
export function statusLabel(drama: DramaDetail): string {
|
||||
return drama.updateStatus === 1 ? '连载中' : drama.updateStatus === 2 ? '已完结' : '';
|
||||
}
|
||||
|
||||
/** Inline "·" facts row: 集数 (series), 热度, 收藏, 点赞. */
|
||||
export function factsRow(drama: DramaDetail, chapters: Chapter[] = []): string {
|
||||
const total = drama.totalEpisode || chapters.length;
|
||||
const facts = [
|
||||
isSingleVideo(drama, chapters) ? '' : `<span>${drama.updateStatus === 1 ? `更新至${chapters.length || total}集` : `全${total}集`}</span>`,
|
||||
`<span>${formatCount(drama.heatValue)}热度</span>`,
|
||||
`<span data-engage-count="collect" aria-live="polite">${formatCount(drama.countCollect)}收藏</span>`,
|
||||
`<span data-engage-count="like" aria-live="polite">${formatCount(drama.countLike)}点赞</span>`,
|
||||
].filter(Boolean);
|
||||
return `<p class="cine-facts">${facts.join('<i aria-hidden="true">·</i>')}</p>`;
|
||||
}
|
||||
|
||||
/** Like / collect / share as small outlined text buttons. */
|
||||
export function engageButtons(drama: DramaDetail): string {
|
||||
const liked = drama.isLike === 1, collected = drama.isCollect === 1;
|
||||
return `<div class="play-detail-actions cine-engage"><button type="button" data-engage="like" aria-pressed="${liked}">${detailActionIcon('like')}<span>${liked ? '已点赞' : '点赞'}</span></button><button type="button" data-engage="collect" aria-pressed="${collected}">${detailActionIcon('save')}<span>${collected ? '已收藏' : '收藏'}</span></button><button type="button" data-share>${detailActionIcon('share')}<span>分享</span></button></div>`;
|
||||
}
|
||||
|
||||
export function descriptionBlock(drama: DramaDetail): string {
|
||||
return `<div class="series-description-wrap"><p class="series-description" id="series-description-text">${escape(drama.desc || drama.description)}</p><button class="series-description-toggle" type="button" aria-expanded="false" aria-controls="series-description-text" hidden>展开</button></div>`;
|
||||
}
|
||||
|
||||
/** Cinematic hero: sharp poster on the right, faded into the dark stage; copy on the left. */
|
||||
export function overviewMarkup(drama: DramaDetail, first: Chapter | undefined, chapters: Chapter[] = []): string {
|
||||
const single = isSingleVideo(drama, chapters);
|
||||
const status = statusLabel(drama);
|
||||
const kicker = single ? '视频' : `短剧${status ? ` · ${status}` : ''}`;
|
||||
const start = first && (first.currentEpisode || 1) > 1 ? `从第 ${first.currentEpisode} 集开始` : '立即观看';
|
||||
return `<section class="cine-hero" aria-labelledby="play-detail-title">
|
||||
<div class="cine-hero-art"><img class="cine-hero-image" ${imageAttributes(drama.cover || drama.coverImg || drama.horizontalCover)} width="720" height="1280" alt="${escape(title(drama))}封面" fetchpriority="high" decoding="async"></div>
|
||||
<div class="cine-hero-copy">
|
||||
<span class="cine-kicker">${escape(kicker)}</span>
|
||||
<h1 id="play-detail-title">${escape(title(drama))}</h1>
|
||||
${factsRow(drama, chapters)}
|
||||
${hashTags(drama)}
|
||||
${descriptionBlock(drama)}
|
||||
<div class="cine-actions">
|
||||
${first ? `<a class="cine-play detail-play" href="${contentPath('play',drama,first.id)}"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg><span>${start}</span></a>` : '<span class="series-unavailable">暂无可用剧集</span>'}
|
||||
<a class="cine-download" data-app-download-entry data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer" hidden><span>下载 App</span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14m-6-6 6 6-6 6"/></svg></a>
|
||||
</div>
|
||||
${engageButtons(drama)}
|
||||
</div>
|
||||
</div>`;
|
||||
</section>`;
|
||||
}
|
||||
|
||||
export function officialLinks(config: Config): string {
|
||||
@@ -69,13 +96,11 @@ export function officialLinks(config: Config): string {
|
||||
const primary = safeUrl(settings.newUrlAddr) || recordList<{url?:unknown}>(config.navPageList).map(item => safeUrl(item.url)).find(Boolean) || '';
|
||||
const permanent = safeUrl(settings.foreverUrlAddr);
|
||||
const group = safeUrl(settings.tgGroup);
|
||||
const contact = safeUrl(settings.telegram);
|
||||
const external = (url: string, label = url) => `<a href="${escape(url)}" target="_blank" rel="sponsored nofollow noopener noreferrer">${escape(label)}</a>`;
|
||||
return `<section class="play-official" aria-label="官方链接">
|
||||
${primary ? `<div class="play-official-line play-official-inline"><span>官方入口:</span>${external(primary)}</div>` : ''}
|
||||
${permanent ? `<div class="play-official-line play-official-inline"><span>永久地址:</span>${external(permanent)}</div>` : ''}
|
||||
${group ? `<div class="play-official-line">${external(group,'点击加入禁果短剧官方 Telegram 群')}</div>` : ''}
|
||||
<div class="play-official-line play-official-inline"><span>官方客服:</span>${contact ? external(contact,'立即联系') : '<a href="/info/feedback/">立即联系</a>'}</div>
|
||||
${group ? `<div class="play-official-line">${external(group,`点击加入${SITE_NAME}官方 Telegram 群`)}</div>` : ''}
|
||||
</section>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { downloadUrl } from '../app-download';
|
||||
export function configureDownloadLinks(config: Config, root: ParentNode = document) {
|
||||
const url = downloadUrl(config);
|
||||
root.querySelectorAll<HTMLElement>('[data-app-download-entry]').forEach(entry => {entry.hidden = !url;});
|
||||
root.querySelectorAll<HTMLElement>('[data-app-download-fallback]').forEach(entry => {entry.hidden = !!url;});
|
||||
root.querySelectorAll<HTMLAnchorElement>('a[data-app-download]').forEach(link => {
|
||||
if (url) {link.href = url;link.rel = 'sponsored nofollow noopener noreferrer';} else link.removeAttribute('href');
|
||||
});
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
import type { SiteContext } from "./context";
|
||||
import { escape } from "./render";
|
||||
|
||||
export function feedbackPayload(data:FormData,screenshots:string[]=[]) {
|
||||
const value=(key:string)=>String(data.get(key)||'').trim();
|
||||
const description=value('content');
|
||||
if(description.length<10||description.length>500)throw new Error('请填写 10–500 字的问题描述');
|
||||
const labels:Record<string,string>={category:'问题类型',frequency:'发生频率',province:'所在地区',city:'城市',carrier:'运营商',network:'网络类型',device:'设备类型',model:'设备型号',version:'App 版本'};
|
||||
const details=Object.entries(labels).filter(([key])=>value(key)).map(([key,label])=>`${label}:${value(key)}`).join('\n');
|
||||
const category=value('category');
|
||||
// 1 功能建议,2 播放问题,3 账号问题,4 其他。
|
||||
const type=category==='登录异常'?3:category==='其他问题'||category==='闪退 / 崩溃'?4:2;
|
||||
const remaining=500-description.length;
|
||||
const context=remaining>2&&details?'\n\n'+details.slice(0,remaining-2):'';
|
||||
const payload:{type:number;content:string;contact:string;screenshots?:string[]}={type,content:description+context,contact:value('contact')};
|
||||
if(screenshots.length)payload.screenshots=screenshots;
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function renderFeedbackPage(app:SiteContext,preserveMarkup=false) {
|
||||
const root=document.getElementById('info-content')!;if(!preserveMarkup)root.innerHTML=feedbackMarkup();
|
||||
const form=root.querySelector<HTMLFormElement>('#feedback-form')!,signal=app.signal;
|
||||
const input=form.querySelector<HTMLInputElement>('#feedback-files')!,list=form.querySelector('#feedback-file-list')!,error=form.querySelector('#feedback-file-error')!,status=form.querySelector('#feedback-status')!;
|
||||
let files:File[]=[],previews:string[]=[];
|
||||
const uploaded=new Map<File,string>();
|
||||
const clearPreviews=()=>{previews.forEach(url=>URL.revokeObjectURL(url));previews=[];};
|
||||
const renderFiles=()=>{
|
||||
clearPreviews();
|
||||
list.innerHTML=files.map((file,index)=>{const url=URL.createObjectURL(file);previews.push(url);return `<div class="feedback-attachment"><img src="${url}" alt="${escape(file.name || "反馈图片")}预览"><span>${escape(file.name)}</span><button type="button" data-remove-feedback-file="${index}" aria-label="移除${escape(file.name)}">×</button></div>`;}).join('');
|
||||
const box=input.closest?.('.feedback-upload-box') as HTMLElement|null|undefined;
|
||||
if(box)box.hidden=files.length>=3;
|
||||
if(!form.dataset.submitting)input.disabled=files.length>=3;
|
||||
};
|
||||
signal.addEventListener('abort',clearPreviews,{once:true});
|
||||
input.addEventListener('change',()=>{
|
||||
if(form.dataset.submitting)return;
|
||||
const selected=Array.from(input.files||[]);
|
||||
input.value='';
|
||||
if(!selected.length)return;
|
||||
if(selected.some(file=>file.size>5*1024*1024||!['image/jpeg','image/png','image/webp'].includes(file.type))){error.textContent='请上传 5MB 以内的 JPG、PNG 或 WebP 图片';return;}
|
||||
if(files.length+selected.length>3){error.textContent='最多上传 3 张图片';return;}
|
||||
error.textContent='';
|
||||
files=[...files,...selected];renderFiles();
|
||||
},{signal});
|
||||
list.addEventListener('click',event=>{
|
||||
const button=(event.target as Element).closest<HTMLElement>('[data-remove-feedback-file]');if(!button||form.dataset.submitting)return;
|
||||
files.splice(Number(button.dataset.removeFeedbackFile),1);input.value='';renderFiles();
|
||||
},{signal});
|
||||
const content=form.querySelector<HTMLTextAreaElement>('[name="content"]')!;
|
||||
content.addEventListener('input',()=>{form.querySelector('#feedback-count')!.textContent=content.value.length+' / 500';},{signal});
|
||||
form.addEventListener('submit',event=>{
|
||||
event.preventDefault();if(form.dataset.submitting||!form.reportValidity())return;
|
||||
const data=new FormData(form),attachments=[...files];
|
||||
try{feedbackPayload(data);}catch(reason){status.textContent=reason instanceof Error?reason.message:'请检查反馈内容';return;}
|
||||
const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;
|
||||
button.disabled=true;input.disabled=true;form.dataset.submitting='1';status.textContent='提交中…';
|
||||
void (async()=>{
|
||||
const screenshots:string[]=[];
|
||||
for(const file of attachments){
|
||||
if(signal.aborted)return;
|
||||
let path=uploaded.get(file);
|
||||
if(!path){const body=new FormData();body.append('file',file);body.append('watermark','0');const result=await app.api.post<{sourceNameFs?:string}>('upload/image',body);if(!result.sourceNameFs)throw new Error('图片上传失败,请重试');path=result.sourceNameFs;uploaded.set(file,path);}
|
||||
screenshots.push(path);
|
||||
}
|
||||
if(signal.aborted)return;
|
||||
await app.api.post('feedback/add',feedbackPayload(data,screenshots));
|
||||
if(signal.aborted)return;
|
||||
form.reset();files=[];uploaded.clear();renderFiles();error.textContent='';form.querySelector('#feedback-count')!.textContent='0 / 500';status.textContent='反馈已提交,感谢您的反馈!';
|
||||
})().catch(reason=>{if(!signal.aborted)status.textContent=reason instanceof Error?reason.message:'反馈提交失败,请重试';}).finally(()=>{button.disabled=false;input.disabled=files.length>=3;delete form.dataset.submitting;});
|
||||
},{signal});
|
||||
}
|
||||
|
||||
export const feedbackMarkup=()=>`<form id="feedback-form" class="feedback-form feedback-reference">
|
||||
<fieldset class="feedback-part">
|
||||
<legend>问题类型 <em>*</em>
|
||||
</legend>
|
||||
<div class="feedback-options">
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="category" value="播放卡顿 / 缓冲" required>
|
||||
<span>播放卡顿 / 缓冲</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="category" value="无法播放" required>
|
||||
<span>无法播放</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="category" value="画质模糊" required>
|
||||
<span>画质模糊</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="category" value="音画不同步" required>
|
||||
<span>音画不同步</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="category" value="加载失败" required>
|
||||
<span>加载失败</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="category" value="闪退 / 崩溃" required>
|
||||
<span>闪退 / 崩溃</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="category" value="登录异常" required>
|
||||
<span>登录异常</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="category" value="其他问题" required>
|
||||
<span>其他问题</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="feedback-part">
|
||||
<legend>问题发生频率 <em>*</em>
|
||||
</legend>
|
||||
<div class="feedback-options">
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="frequency" value="每次都会" required>
|
||||
<span>每次都会</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="frequency" value="经常出现" required>
|
||||
<span>经常出现</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="frequency" value="偶尔出现" required>
|
||||
<span>偶尔出现</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="frequency" value="仅出现一次" required>
|
||||
<span>仅出现一次</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="feedback-part">
|
||||
<legend>网络环境信息 <em>*</em>
|
||||
</legend>
|
||||
<div class="feedback-panel">
|
||||
<label>所在地址</label>
|
||||
<div class="feedback-address">
|
||||
<select name="province" aria-label="所在省份" required>
|
||||
<option value="">所在省份</option>
|
||||
<option>北京</option>
|
||||
<option>天津</option>
|
||||
<option>河北</option>
|
||||
<option>山西</option>
|
||||
<option>内蒙古</option>
|
||||
<option>辽宁</option>
|
||||
<option>吉林</option>
|
||||
<option>黑龙江</option>
|
||||
<option>上海</option>
|
||||
<option>江苏</option>
|
||||
<option>浙江</option>
|
||||
<option>安徽</option>
|
||||
<option>福建</option>
|
||||
<option>江西</option>
|
||||
<option>山东</option>
|
||||
<option>河南</option>
|
||||
<option>湖北</option>
|
||||
<option>湖南</option>
|
||||
<option>广东</option>
|
||||
<option>广西</option>
|
||||
<option>海南</option>
|
||||
<option>重庆</option>
|
||||
<option>四川</option>
|
||||
<option>贵州</option>
|
||||
<option>云南</option>
|
||||
<option>西藏</option>
|
||||
<option>陕西</option>
|
||||
<option>甘肃</option>
|
||||
<option>青海</option>
|
||||
<option>宁夏</option>
|
||||
<option>新疆</option>
|
||||
<option>香港</option>
|
||||
<option>澳门</option>
|
||||
<option>台湾</option>
|
||||
<option>海外</option>
|
||||
</select>
|
||||
<input name="city" maxlength="100" aria-label="城市(选填)" placeholder="城市(选填)">
|
||||
</div>
|
||||
<label>运营商<select name="carrier" required>
|
||||
<option value="">选择运营商</option>
|
||||
<option>中国移动</option>
|
||||
<option>中国联通</option>
|
||||
<option>中国电信</option>
|
||||
<option>中国广电</option>
|
||||
<option>其他</option>
|
||||
</select>
|
||||
</label>
|
||||
<span class="feedback-field-label">网络类型</span>
|
||||
<div class="feedback-options">
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="network" value="Wi-Fi" required>
|
||||
<span>Wi-Fi</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="network" value="5G" required>
|
||||
<span>5G</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="network" value="4G" required>
|
||||
<span>4G</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="network" value="其他" required>
|
||||
<span>其他</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="feedback-part">
|
||||
<legend>设备信息 <em>*</em>
|
||||
</legend>
|
||||
<div class="feedback-panel">
|
||||
<span class="feedback-field-label">设备类型</span>
|
||||
<div class="feedback-options">
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="device" value="iPhone" required>
|
||||
<span>iPhone</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="device" value="Android" required>
|
||||
<span>Android</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="device" value="iPad" required>
|
||||
<span>iPad</span>
|
||||
</label>
|
||||
<label class="feedback-option">
|
||||
<input type="radio" name="device" value="电脑 / 其他" required>
|
||||
<span>电脑 / 其他</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="feedback-address">
|
||||
<label>设备型号(选填)<input name="model" maxlength="100" placeholder="例如:iPhone 15、小米 14">
|
||||
</label>
|
||||
<label>App 版本号(选填)<input name="version" maxlength="100" placeholder="例如:v1.2.1">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="feedback-part">
|
||||
<legend>问题描述 <em>*</em>
|
||||
</legend>
|
||||
<div class="feedback-description-wrap">
|
||||
<textarea name="content" aria-label="问题描述" minlength="10" maxlength="500" required placeholder="请详细描述您遇到的问题,包括观看的内容、具体表现和发生时间(至少 10 字)">
|
||||
</textarea>
|
||||
<span id="feedback-count">0 / 500</span>
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="feedback-part">
|
||||
<legend>上传图片</legend>
|
||||
<div class="feedback-uploads">
|
||||
<label class="feedback-upload-box">
|
||||
<input type="file" id="feedback-files" accept="image/jpeg,image/png,image/webp" multiple>
|
||||
<span class="feedback-upload-plus" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
</span>
|
||||
<strong>上传图片</strong>
|
||||
<small>支持 JPG / PNG / WebP,最多 3 张</small>
|
||||
</label>
|
||||
<div id="feedback-file-list">
|
||||
</div>
|
||||
</div>
|
||||
<p class="feedback-file-note">每张图片不超过 5MB</p>
|
||||
<p id="feedback-file-error" role="alert">
|
||||
</p>
|
||||
<input type="hidden" name="attachments" id="feedback-attachments">
|
||||
</fieldset>
|
||||
<fieldset class="feedback-part">
|
||||
<legend>联系方式 <em>*</em>
|
||||
</legend>
|
||||
<input name="contact" aria-label="联系方式" required maxlength="120" placeholder="请输入 Email / Telegram / X 联系方式">
|
||||
</fieldset>
|
||||
<div class="feedback-submit-area">
|
||||
<button type="submit" class="feedback-submit-button">提交反馈</button>
|
||||
<p>提交后请留意您填写的联系方式。</p>
|
||||
<span class="feedback-status" id="feedback-status" role="status">
|
||||
</span>
|
||||
</div>
|
||||
</form>`;
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { Drama, PageData } from '../api/types';
|
||||
import { recordList } from '../api/protocol';
|
||||
import { contentPath } from '../routing';
|
||||
import { escape, imageAttributes, title } from './render';
|
||||
import type { SiteContext } from './context';
|
||||
|
||||
export function headerHistoryMarkup(value:unknown):string {
|
||||
const seen=new Set<number>();
|
||||
const items=recordList<Drama>(value).filter(item=>Number.isSafeInteger(item.id)&&item.id>0&&!seen.has(item.id)&&!!seen.add(item.id)).slice(0,9);
|
||||
return items.map(item=>`<a class="jg-history-item" href="${contentPath('detail',item)}" aria-label="继续观看${escape(title(item))}"><span class="jg-history-thumb"><img ${imageAttributes(item.cover||item.coverImg||item.horizontalCover)} alt="${escape(title(item))}封面" loading="lazy" decoding="async"></span><span class="jg-history-item-copy"><strong>${escape(title(item))}</strong><small>${item.totalEpisode?`全 ${escape(item.totalEpisode)} 集`:'最近观看'}</small></span></a>`).join('')||'<div class="jg-history-empty"><strong>暂无观看历史</strong><span>看过的好故事,会留在这里</span></div>';
|
||||
}
|
||||
|
||||
/** Load private history only when the desktop panel opens; never persist account data locally. */
|
||||
export function bindHeaderHistory(app:SiteContext):void {
|
||||
const wrap=document.querySelector<HTMLElement>('.jg-history-wrap');
|
||||
const trigger=document.getElementById('header-history-button'),panel=document.getElementById('header-history-panel'),list=document.getElementById('header-history-list');
|
||||
if(!wrap||!trigger||!panel||!list)return;
|
||||
const {signal}=app;
|
||||
let timer:ReturnType<typeof setTimeout>|undefined,sequence=0;
|
||||
const close=()=>{clearTimeout(timer);sequence++;panel.hidden=true;wrap.classList.remove('is-open');trigger.setAttribute('aria-expanded','false');};
|
||||
const fit=()=>{
|
||||
panel.style.setProperty('--history-panel-scale','1');
|
||||
const available=Math.max(160,window.innerHeight-panel.getBoundingClientRect().top-16);
|
||||
panel.style.setProperty('--history-panel-scale',String(Math.min(1,available/Math.max(1,panel.offsetHeight))));
|
||||
};
|
||||
const open=async()=>{
|
||||
clearTimeout(timer);
|
||||
if(!window.matchMedia('(min-width:761px)').matches||!panel.hidden)return;
|
||||
const current=++sequence;panel.hidden=false;wrap.classList.add('is-open');trigger.setAttribute('aria-expanded','true');
|
||||
list.innerHTML='<div class="jg-history-empty" role="status">正在加载观看历史…</div>';fit();
|
||||
try{
|
||||
await app.api.ensureSession();
|
||||
const data=await app.api.get<PageData<Drama>>('playlet/history/list',{pageNum:1,pageSize:9},signal);
|
||||
if(signal.aborted||sequence!==current)return;
|
||||
list.innerHTML=headerHistoryMarkup(data.list);fit();
|
||||
}catch{
|
||||
if(signal.aborted||sequence!==current)return;
|
||||
list.innerHTML='<div class="jg-history-empty" role="status"><strong>观看历史暂不可用</strong><span>请前往观看历史页面重试</span></div>';fit();
|
||||
}
|
||||
};
|
||||
const scheduleClose=()=>{clearTimeout(timer);timer=setTimeout(()=>{if(!wrap.contains(document.activeElement))close();},160);};
|
||||
wrap.addEventListener('pointerenter',()=>{void open();},{signal});
|
||||
wrap.addEventListener('pointerleave',scheduleClose,{signal});
|
||||
wrap.addEventListener('focusin',()=>{void open();},{signal});
|
||||
wrap.addEventListener('focusout',event=>{if(!wrap.contains(event.relatedTarget as Node|null))scheduleClose();},{signal});
|
||||
wrap.addEventListener('keydown',event=>{if(event.key==='Escape'){close();event.stopPropagation();}},{signal});
|
||||
document.addEventListener('pointerdown',event=>{if(!wrap.contains(event.target as Node))close();},{signal});
|
||||
window.addEventListener('resize',()=>{if(window.innerWidth<=760)close();else if(!panel.hidden)fit();},{signal});
|
||||
app.api.addEventListener('userchange',close,{signal});
|
||||
signal.addEventListener('abort',close,{once:true});
|
||||
}
|
||||
@@ -1,87 +1,77 @@
|
||||
import { contentPath, contentSegment } from '../routing';
|
||||
import type { Chapter, Comment, Config, Drama, DramaDetail, HomeData, PageData, User } from '../api/types';
|
||||
import { formatCount, recordList } from '../api/protocol';
|
||||
import { ads, card, empty, escape, imageAttributes, pagination, promotionRow, title } from './render';
|
||||
import { detailActionIcon, officialLinks, overviewMarkup } from './detail';
|
||||
import { contentPath } from '../routing';
|
||||
import type { Chapter, Config, Drama, DramaDetail, HomeData } from '../api/types';
|
||||
import { recordList } from '../api/protocol';
|
||||
import { ads, card, empty, escape, imageAttributes, promotionRow, title } from './render';
|
||||
import { descriptionBlock, engageButtons, factsRow, hashTags, isSingleVideo, officialLinks, overviewMarkup, statusLabel } from './detail';
|
||||
import { chapterAccess, chapterListGate, chapterRequiresPayment, initialPlayMode, INLINE_VIDEO_ATTRIBUTES, playbackUrl } from './playback';
|
||||
import { downloadUrl } from '../app-download';
|
||||
import { SITE_NAME } from '../brand';
|
||||
|
||||
/** Local guest avatar used while encrypted CDN avatars decrypt. */
|
||||
export const COMMENT_AVATAR_FALLBACK='/assets/8837c3e58159bd48.png';
|
||||
/** Recommendation shelf size (guess-like list, current item excluded). */
|
||||
export const RECOMMEND_PAGE_SIZE=12;
|
||||
|
||||
export interface PlayerViewOptions {
|
||||
chapter?:Chapter|null; isVideo?:boolean; page?:number; commentSort?:number;
|
||||
comments?:PageData<Comment>; config?:Config; home?:HomeData; user?:User|null;
|
||||
pageHref?:(page:number,sort:number)=>string;
|
||||
chapter?:Chapter|null; isVideo?:boolean; config?:Config; home?:HomeData;
|
||||
}
|
||||
|
||||
export function orderedChapters(value:unknown):Chapter[] {
|
||||
return recordList<Chapter>(value).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).sort((a,b)=>(a.currentEpisode||0)-(b.currentEpisode||0));
|
||||
}
|
||||
|
||||
const lockIcon='<svg class="film-tile-lock" viewBox="0 0 24 24" aria-hidden="true"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>';
|
||||
const arrow=(direction:'prev'|'next')=>`<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${direction==='prev'?'m15 5-7 7 7 7':'m9 5 7 7-7 7'}"/></svg>`;
|
||||
|
||||
/** Locked episodes are watched in the App: overlay inside the player with a download button. */
|
||||
export function appGateMarkup(drama:Drama,config:Config={}):string {
|
||||
const url=downloadUrl(config);
|
||||
return `<div class="play-app-gate" hidden><div class="play-app-gate-copy" data-app-download-entry${url?'':' hidden'}><strong>本集请在 ${escape(SITE_NAME)} App 观看</strong><p>下载 App 即可继续观看《${escape(title(drama))}》及全部剧集</p><a class="primary play-app-gate-btn" data-app-download${url?` href="${escape(url)}"`:''} target="_blank" rel="sponsored nofollow noopener noreferrer"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6" y="2" width="12" height="20" rx="3"/><path d="M10 5h4M11 19h2"/></svg><span>下载 App 继续观看</span></a></div><p class="play-app-gate-fallback" data-app-download-fallback${url?' hidden':''}>本集视频暂未开放,请稍后再试</p></div>`;
|
||||
}
|
||||
|
||||
/** "推荐视频" shelf: guess-like items, current drama excluded. */
|
||||
export function recommendationsMarkup(raw:DramaDetail):string {
|
||||
const items=recordList<Drama>(raw.relatedRecommendData).filter(item=>item.id!==raw.id).slice(0,RECOMMEND_PAGE_SIZE);
|
||||
return `<section class="play-recommend" aria-labelledby="play-recommend-title"><div class="recommend-heading"><span class="recommend-outline" aria-hidden="true">RECOMMEND</span><h2 id="play-recommend-title">推荐视频</h2><p>看了《${escape(title(raw))}》的人还在看</p></div>${items.length?`<div class="recommend-grid">${items.map(item=>card(item)).join('')}</div>`:empty('暂无推荐视频')}</section>`;
|
||||
}
|
||||
|
||||
/** One horizontal film strip with every episode; locked tiles still link to their play page. */
|
||||
export function episodeItems(id:number,ordered:Chapter[],chapter:Chapter|null,slug?:string):string {
|
||||
return ordered.map((item,index)=>{
|
||||
const number=item.currentEpisode||index+1;
|
||||
const locked=!!chapterListGate(item),current=item.id===chapter?.id;
|
||||
return `<a class="film-tile${current?' is-current':''}${locked?' is-locked':''}" href="${contentPath('play',{id,slug},item.id)}" data-drama-switch data-target-episode="${number}" data-target-video-id="${item.id}"${current?' aria-current="true"':''} aria-label="第 ${number} 集${locked?',请在 App 观看':''}"><span class="film-tile-no">${String(number).padStart(2,'0')}</span>${locked?lockIcon:''}</a>`;
|
||||
}).join('')||empty('暂无剧集');
|
||||
}
|
||||
|
||||
export function episodeStrip(raw:DramaDetail,ordered:Chapter[],chapter:Chapter|null):string {
|
||||
const status=statusLabel(raw);
|
||||
return `<section class="play-episodes-section" aria-labelledby="play-episodes-title"><div class="play-section-heading"><h2 id="play-episodes-title">选集</h2><span>全${raw.totalEpisode||ordered.length}集${status?` · ${status}`:''}</span></div><div class="film-strip" id="play-episode-list" aria-label="剧集列表">${episodeItems(raw.id,ordered,chapter,raw.slug)}</div></section>`;
|
||||
}
|
||||
|
||||
export function playerMarkup(raw:DramaDetail,ordered:Chapter[],options:PlayerViewOptions={}):string {
|
||||
const {chapter=null,isVideo=false,comments,config={},home={},user}=options;
|
||||
const id=raw.id,overview=!chapter&&!isVideo,first=ordered.find(item=>item.currentEpisode===1)||ordered[0];
|
||||
const displayTitle=title(raw),heading=chapter?`${displayTitle} - 第${chapter.currentEpisode||1}集`:displayTitle,liked=raw.isLike===1,collected=raw.isCollect===1;
|
||||
const commentPage=options.page||1,commentSort=options.commentSort===1?1:2;
|
||||
const basePath=contentPath(isVideo?'video':chapter?'play':'detail',raw,chapter?.id);
|
||||
const pageHref=options.pageHref||((page:number,sort:number)=>`${basePath}${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`);
|
||||
const {chapter=null,isVideo=false,config={},home={}}=options;
|
||||
const overview=!chapter&&!isVideo,first=ordered.find(item=>item.currentEpisode===1)||ordered[0];
|
||||
const single=isVideo||isSingleVideo(raw,ordered);
|
||||
const displayTitle=title(raw),heading=chapter&&!single?`${displayTitle} - 第${chapter.currentEpisode||1}集`:displayTitle;
|
||||
const access=isVideo?{allowed:!!playbackUrl(raw.video?.url),gate:playbackUrl(raw.video?.url)?'':'unavailable',source:playbackUrl(raw.video?.url)} as const:chapterAccess(chapter);
|
||||
const previewSource=isVideo?'':playbackUrl(raw.preview),mode=initialPlayMode(access,raw.preview);
|
||||
const modeTabs=previewSource?`<div class="channel-tabs play-mode-tabs" role="group" aria-label="播放内容"><button type="button" data-play-mode="main" aria-pressed="${mode==='main'}">正片</button><button type="button" data-play-mode="preview" aria-pressed="${mode==='preview'}">免费预告</button></div>`:'';
|
||||
const initialEpisodePage=chapter?Math.floor(Math.max(0,ordered.findIndex(item=>item.id===chapter.id))/45):0;
|
||||
const actions=()=>`<div class="play-detail-actions"><button type="button" data-engage="like" aria-pressed="${liked}">${detailActionIcon('like')}<span>${liked?'已点赞':'点赞'}</span></button><button type="button" data-engage="collect" aria-pressed="${collected}">${detailActionIcon('save')}<span>${collected?'已收藏':'收藏'}</span></button><button type="button" data-share>${detailActionIcon('share')}<span>分享</span></button><a href="/info/feedback/">${detailActionIcon('feedback')}<span>反馈</span></a></div>`;
|
||||
const genres=(Array.isArray(raw.tagNames)?raw.tagNames:Array.isArray(raw.themeNames)?raw.themeNames:[]).filter(tag=>typeof tag==='string');
|
||||
const useTags=Array.isArray(raw.tagNames);
|
||||
const tags=genres.map((tag,i)=>{
|
||||
const identity={id:(useTags?raw.tagIds:raw.themeIds)?.[i],slug:(useTags?raw.tagSlugs:raw.themeSlugs)?.[i]};
|
||||
return contentSegment(identity)?`<a href="${contentPath(useTags?'tag':'topics',identity)}">${escape(tag)}</a>`:`<span>${escape(tag)}</span>`;
|
||||
}).join('');
|
||||
const related=recordList<Drama>(raw.relatedRecommendData).filter(item=>item.id!==id).slice(0,6);
|
||||
const screen=()=>`${modeTabs}<div class="play-screen${chapter&&chapterRequiresPayment(chapter)?' play-paywalled-content':''}" data-play-mode="${mode}"><video ${INLINE_VIDEO_ATTRIBUTES} preload="metadata" ${imageAttributes(raw.cover||raw.coverImg,undefined,'poster')} aria-label="${escape(displayTitle)}"></video><div class="play-screen-overlay"><button type="button" aria-label="播放">▶</button><p class="play-source-status" role="status">正在准备视频…</p></div><div class="play-screen-controls"><button data-play-action="next" ${!chapter||ordered.at(-1)?.id===chapter.id?'disabled':''}>下一集</button></div></div>`;
|
||||
const position=chapter?ordered.findIndex(item=>item.id===chapter.id):-1;
|
||||
const previous=!single&&position>0?ordered[position-1]:undefined,next=!single&&position>=0?ordered[position+1]:undefined;
|
||||
const edge=(item:Chapter|undefined,direction:'prev'|'next')=>item?`<a class="play-edge play-edge--${direction}" href="${contentPath('play',raw,item.id)}" data-drama-switch aria-label="${direction==='prev'?'上一集':'下一集'}:第 ${item.currentEpisode||''} 集">${arrow(direction)}</a>`:'';
|
||||
const screen=()=>`${modeTabs}<div class="play-stage"><div class="play-screen${chapter&&chapterRequiresPayment(chapter)?' play-paywalled-content':''}" data-play-mode="${mode}"><video ${INLINE_VIDEO_ATTRIBUTES} preload="metadata" ${imageAttributes(raw.cover||raw.coverImg,undefined,'poster')} aria-label="${escape(displayTitle)}"></video><div class="play-screen-overlay"><button type="button" aria-label="播放">▶</button><p class="play-source-status" role="status">正在准备视频…</p></div>${appGateMarkup(raw,config)}<div class="play-screen-controls"><button data-play-action="next" ${!next?'disabled':''}>下一集</button></div></div>${edge(previous,'prev')}${edge(next,'next')}</div>`;
|
||||
const mark=single?'VIDEO':String(chapter?.currentEpisode||1).padStart(2,'0');
|
||||
const headline=()=>`<section class="play-detail-info play-headline"><div class="play-headline-mark${single?' is-word':''}" aria-hidden="true">${mark}</div><div class="play-headline-copy"><span class="cine-kicker">${single?'视频':`第 ${chapter?.currentEpisode||1} 集 / 共 ${raw.totalEpisode||ordered.length} 集`}</span><h1 id="play-detail-title">${escape(heading)}</h1>${factsRow(raw,ordered)}${hashTags(raw)}${descriptionBlock(raw)}${engageButtons(raw)}</div></section>`;
|
||||
const applicationAds=promotionRow(raw.advitise?.nineGridList||home.advitise?.nineGridList);
|
||||
const bannerAds=ads(raw.advitise?.detailbannerList,'play-banner-ad');
|
||||
return `
|
||||
<nav class="page-breadcrumb" aria-label="面包屑导航"><a href="/">首页</a><span>/</span><a href="/${isVideo?'series':'shorts'}/">${isVideo?'剧集':'短剧'}</a><span>/</span>${overview||isVideo?`<span aria-current="page">${escape(displayTitle)}</span>`:`<a href="${contentPath('detail',raw)}">${escape(displayTitle)}</a><span>/</span><span aria-current="page">第${chapter?.currentEpisode||1}集</span>`}</nav>
|
||||
<div class="play-detail-layout${related.length?'':' play-detail-layout--single'}"><div class="play-detail-main">
|
||||
${overview?overviewMarkup(raw,first,actions()):screen()}
|
||||
<div class="play-detail-layout play-detail-layout--single"><div class="play-detail-main">
|
||||
${overview?overviewMarkup(raw,first,ordered):screen()}
|
||||
${overview?'':headline()}
|
||||
${applicationAds?`<section class="play-advertisements" aria-label="推荐应用广告">${applicationAds}</section>`:''}
|
||||
${overview?'':`<section class="play-detail-info">${chapter?`<span class="play-episode-caption">正在观看 第 ${chapter.currentEpisode||1} 集</span>`:''}<h1 id="play-detail-title">${escape(heading)}</h1>${tags?`<div class="play-detail-tags"><span>分类标签:</span>${tags}</div>`:''}<div class="play-detail-meta">${!isVideo?`<span>集数:全${raw.totalEpisode||ordered.length}集</span>`:''}<span>${formatCount(raw.heatValue)}热度</span><span data-engage-count="collect" aria-live="polite">${formatCount(raw.countCollect)}收藏</span></div><p class="play-detail-description">简介:${escape(raw.desc||raw.description||'')}</p>${actions()}</section>`}
|
||||
${!isVideo?`<section class="play-episodes-section"><div class="play-section-heading"><h2>剧集</h2><span>全${raw.totalEpisode||ordered.length}集</span></div><div class="play-episode-pages" role="group" aria-label="集数分段">${episodeTabs(ordered,45,initialEpisodePage)}</div><div class="play-episode-grid" id="play-episode-list">${episodeItems(id,ordered,chapter,45,initialEpisodePage,raw.slug)}</div></section>`:''}
|
||||
${!single?episodeStrip(raw,ordered,chapter):''}
|
||||
${bannerAds?`<section class="play-banner-ads" aria-label="推广广告">${bannerAds}</section>`:''}
|
||||
${recommendationsMarkup(raw)}
|
||||
${officialLinks(config)}
|
||||
<section class="play-comments"><div class="play-section-heading"><h2 id="play-comments-title">评论${comments?` (${comments.totalCount||0})`:""}</h2><div class="play-sort" role="group" aria-label="评论排序"><button type="button" data-comment-sort="1" aria-pressed="${commentSort===1}">热门</button><button type="button" data-comment-sort="2" aria-pressed="${commentSort===2}">最新</button></div></div>
|
||||
<form class="play-comment-form"><img class="play-compose-avatar" ${imageAttributes(user?.avatar,'/icon.svg')} alt="我的头像" width="38" height="38" decoding="async" data-lazy-eager="1"><div class="play-compose-body"><div id="reply-context"></div><textarea name="comment" aria-label="评论内容" maxlength="500" placeholder="说说你对这部剧的看法…" required></textarea><div class="play-comment-footer"><span><span id="play-comment-count">0</span> / 500</span><button type="submit">发表评论</button></div><p class="api-error" id="comment-error" role="alert"></p></div></form><div class="play-comment-list">${comments?commentListMarkup(comments,commentPage,commentSort,pageHref):empty('暂无评论')}</div>
|
||||
</section>
|
||||
</div>${related.length?`<aside class="play-related"><h2>热门短剧</h2><div class="play-related-list">${related.map(item=>card(item)).join('')}</div></aside>`:''}</div>`;
|
||||
}
|
||||
|
||||
export function episodeTabs(ordered:Chapter[],size:number,episodePage:number):string {
|
||||
const pages=Math.ceil(ordered.length/size);
|
||||
return pages?`<div class="episode-page-track">${Array.from({length:pages},(_,i)=>`<button type="button" data-episode-page="${i}" aria-pressed="${episodePage===i}" aria-controls="play-episode-list">${i*size+1}–${Math.min((i+1)*size,ordered.length)}</button>`).join('')}</div>`:'';
|
||||
}
|
||||
|
||||
export function episodeItems(id:number,ordered:Chapter[],chapter:Chapter|null,size:number,episodePage:number,slug?:string):string {
|
||||
return ordered.slice(episodePage*size,(episodePage+1)*size).map((item,index)=>{
|
||||
const number=item.currentEpisode||episodePage*size+index+1;
|
||||
const gate=chapterListGate(item),locked=!!gate;
|
||||
const content=`<span>${number}</span>${gate==='coins'?'<span class="episode-lock-corner" aria-hidden="true"><img class="coin-soft-star" src="/assets/episode-coin.svg" width="16" height="16" alt=""></span>':''}${item.id===chapter?.id?'<span class="episode-playing" aria-hidden="true"><i></i><i></i><i></i></span>':''}`;
|
||||
if(locked)return `<button type="button" class="play-episode-locked" data-locked-chapter="${item.id}" aria-label="第 ${number} 集,${gate==='coins'?'金币解锁':'会员观看'}">${content}</button>`;
|
||||
return `<a href="${contentPath('play',{id,slug},item.id)}" data-drama-switch data-target-episode="${number}" data-target-video-id="${item.id}" ${item.id===chapter?.id?'aria-current="true"':''} aria-label="播放第 ${number} 集">${content}</a>`;
|
||||
}).join('')||empty('暂无剧集');
|
||||
}
|
||||
|
||||
export function commentMarkup(item: Comment, child=false): string {
|
||||
const children=recordList<Comment>(item.children);
|
||||
const date=item.createdAt?new Date(item.createdAt):null;
|
||||
const timestamp=date&&Number.isFinite(date.getTime())?`<time datetime="${date.toISOString()}">${escape(date.toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'}))}</time>`:'';
|
||||
const replyName=typeof item.replyUserName==='string'?item.replyUserName.trim():'';
|
||||
const reply=replyName?`<span class="play-reply-target">回复 @${escape(replyName)}</span>`:'';
|
||||
const liked=item.isLike===1;
|
||||
const count=typeof item.countLike==='number'&&Number.isFinite(item.countLike)?Math.max(0,Math.floor(item.countLike)):0;
|
||||
const parent=typeof item.parentsId==='number'&&item.parentsId>0?item.parentsId:item.id;
|
||||
return `<article class="play-comment-item${child?' play-comment-child':''}"><img class="play-comment-avatar" ${imageAttributes(item.userAvatar,COMMENT_AVATAR_FALLBACK)} alt="${escape(item.userName||'用户')}的头像" width="${child?30:40}" height="${child?30:40}" loading="lazy" decoding="async" data-lazy-eager="1"><div class="play-comment-body"><strong>${escape(item.userName||'用户')}</strong>${reply}<p>${escape(item.text)}</p><div class="play-comment-meta">${timestamp}<button type="button" data-comment-like="${item.id}" data-comment-count="${count}" aria-pressed="${liked}" aria-label="点赞 ${escape(item.userName||'用户')} 的评论"><span data-request-label>${liked?'♥':'♡'} ${formatCount(count)}</span></button><button type="button" data-reply="${item.id}" data-parent="${parent}" data-reply-name="${escape(item.userName)}">回复</button></div>${children.length?`<div class="play-comment-replies" aria-label="评论回复">${children.map(comment=>commentMarkup(comment,true)).join('')}</div>`:''}</div></article>`;
|
||||
}
|
||||
|
||||
export function commentListMarkup(data:PageData<Comment>,page:number,sort:number,href:(page:number,sort:number)=>string):string {
|
||||
return (typeof data.error==='string'?empty(data.error,true):recordList<Comment>(data.list).map(item=>commentMarkup(item)).join('')||empty('暂无评论,来说说你的看法吧'))+pagination(data.totalCount||0,page,value=>href(value,sort),10);
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
+36
-99
@@ -1,56 +1,55 @@
|
||||
import { contentPath } from '../routing';
|
||||
import type { Chapter, Comment, Drama, DramaDetail, PageData } from "../api/types";
|
||||
import type { Chapter, Drama, DramaDetail, PageData } from "../api/types";
|
||||
import type { SiteContext } from "./context";
|
||||
import { empty, escape } from "./render";
|
||||
import { configureDownloadLinks } from "./download";
|
||||
import { bindSeriesDescription } from "./detail";
|
||||
import { bindChapterUnlock } from "./unlock";
|
||||
import { chapterAccess, chapterUnlockCost, initialPlayMode, MediaPlayback, playbackUrl, type PlayMode, type PlayerActions } from './playback';
|
||||
import { chapterAccess, initialPlayMode, MediaPlayback, playbackUrl, type PlayMode, type PlayerActions } from './playback';
|
||||
import { dramaSeoContent } from '../seo';
|
||||
import type { PlayerInitialData } from '../page-data';
|
||||
import { commentListMarkup, episodeItems, episodeTabs, orderedChapters, playerMarkup } from './player-markup';
|
||||
import { publicComment } from '../player-data';
|
||||
import { formatCount, recordList } from '../api/protocol';
|
||||
import { orderedChapters, playerMarkup, RECOMMEND_PAGE_SIZE } from './player-markup';
|
||||
import { formatCount } from '../api/protocol';
|
||||
import { applyEngagementSuccess, beginButtonRequest, type EngagementKind } from './request-feedback';
|
||||
|
||||
declare global { interface Window { installPlayerControls?: (screen: HTMLElement, episode?: number, actions?: PlayerActions)=>void } }
|
||||
|
||||
interface PlayerPagination {page?:number;commentSort?:number;pageHref?:(page:number,sort:number)=>string;initial?:PlayerInitialData;preserveMarkup?:boolean}
|
||||
interface PlayerOptions {initial?:PlayerInitialData;preserveMarkup?:boolean}
|
||||
|
||||
export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,isVideo=false,paging:PlayerPagination={}):Promise<()=>void> {
|
||||
/** Keep the current film-strip tile visible without scrolling the page itself. */
|
||||
function revealCurrentEpisode(page:HTMLElement){
|
||||
const strip=page.querySelector<HTMLElement>('.film-strip'),current=strip?.querySelector<HTMLElement>('.film-tile.is-current');
|
||||
if(!strip||!current)return;
|
||||
const left=current.offsetLeft-(strip.clientWidth-current.offsetWidth)/2;
|
||||
strip.scrollLeft=Math.max(0,left);
|
||||
}
|
||||
|
||||
export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,isVideo=false,options:PlayerOptions={}):Promise<()=>void> {
|
||||
if(isVideo)throw new Error('当前接口暂未提供剧集内容');
|
||||
const signal=app.signal;
|
||||
const page=document.getElementById('drama-play-page')!;
|
||||
const raw = paging.initial?.detail || await app.api.get<DramaDetail>('playlet/detail',{id},signal);
|
||||
const raw = options.initial?.detail || await app.api.get<DramaDetail>('playlet/detail',{id},signal);
|
||||
if(signal.aborted)return ()=>{};
|
||||
if(!raw?.id||raw.id!==id)throw new Error('该剧集不存在或已下架');
|
||||
const [chapters,related]=await Promise.all([
|
||||
paging.initial?Promise.resolve(paging.initial.chapters):app.api.get<{list?:Chapter[]}>('playlet/chapter/list',{playletId:id},signal).then(data=>data?.list).catch(error=>{if(signal.aborted)throw error;return raw.playletChapterInfo;}),
|
||||
paging.initial?Promise.resolve(undefined):app.api.get<PageData<Drama>>('playlet/guess-like/list',{playletId:id,pageNum:1,pageSize:6},signal).catch(error=>{if(signal.aborted)throw error;return undefined;}),
|
||||
options.initial?Promise.resolve(options.initial.chapters):app.api.get<{list?:Chapter[]}>('playlet/chapter/list',{playletId:id},signal).then(data=>data?.list).catch(error=>{if(signal.aborted)throw error;return raw.playletChapterInfo;}),
|
||||
options.initial?Promise.resolve(undefined):app.api.get<PageData<Drama>>('playlet/guess-like/list',{playletId:id,pageNum:1,pageSize:RECOMMEND_PAGE_SIZE},signal).catch(error=>{if(signal.aborted)throw error;return undefined;}),
|
||||
]);
|
||||
if(!paging.initial)raw.relatedRecommendData=related?.list||[];
|
||||
if(!options.initial)raw.relatedRecommendData=related?.list||[];
|
||||
if(signal.aborted)return ()=>{};
|
||||
const ordered=orderedChapters(chapters ?? raw.playletChapterInfo);
|
||||
const first=ordered.find(item=>item.currentEpisode===1)||ordered[0];
|
||||
let chapter: Chapter | null = null;
|
||||
if(chapterId){
|
||||
if(ordered.length&&!ordered.some(item=>item.id===chapterId))throw new Error('该章节不存在或已下架');
|
||||
chapter=paging.initial?.chapter || await app.api.get<Chapter>('playlet/chapter/detail',{playletId:id,chapterId},signal);
|
||||
chapter=options.initial?.chapter || await app.api.get<Chapter>('playlet/chapter/detail',{playletId:id,chapterId},signal);
|
||||
if(!signal.aborted&&(!chapter||chapter.id!==chapterId||chapter.playletId&&chapter.playletId!==id))throw new Error('章节信息不匹配,请重新选择');
|
||||
}
|
||||
if(signal.aborted)return ()=>{};
|
||||
const overview=!chapterId&&!isVideo;
|
||||
const unlockCost=chapterUnlockCost(chapter);
|
||||
const source=isVideo?playbackUrl(raw.video?.url):'';
|
||||
const access=isVideo?{allowed:!!source,gate:source?'':'unavailable',source} as const:chapterAccess(chapter);
|
||||
const previewSource=isVideo?'':playbackUrl(raw.preview);
|
||||
let mode:PlayMode=initialPlayMode(access,raw.preview);
|
||||
page.classList.toggle('is-overview',overview);
|
||||
const commentPage=paging.page||1,commentSort=paging.commentSort===1?1:2;
|
||||
const basePath=contentPath(isVideo?'video':chapterId?'play':'detail',raw,chapterId);
|
||||
const pageHref=paging.pageHref||((page:number,sort:number)=>`${basePath}${page>1?`page/${page}/`:''}${sort===1?'?commentSort=1':''}`);
|
||||
app.updateSeo?.({...dramaSeoContent(raw,chapter||undefined),failed:false,unavailable:isVideo&&!source});
|
||||
const objectId=id, objectType=10;
|
||||
const engagement=raw;
|
||||
const pendingEngagements=new Set<EngagementKind>();
|
||||
function syncEngagement(kind:EngagementKind,buttons:HTMLButtonElement[]){
|
||||
@@ -62,74 +61,37 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
|
||||
const value=kind==='like'?engagement.countLike:engagement.countCollect;
|
||||
page.querySelectorAll<HTMLElement>(`[data-engage-count="${kind}"]`).forEach(node=>{node.textContent=`${formatCount(value)}${kind==='like'?'点赞':'收藏'}`;});
|
||||
}
|
||||
if(!paging.preserveMarkup)page.innerHTML=playerMarkup(raw,ordered,{chapter,isVideo,page:commentPage,commentSort,pageHref,config:app.config,home:app.home,user:app.api.user,comments:paging.initial?.comments});
|
||||
if(!options.preserveMarkup)page.innerHTML=playerMarkup(raw,ordered,{chapter,isVideo,config:app.config,home:app.home});
|
||||
configureDownloadLinks(app.config, page);
|
||||
window.tracker?.setDrama({...raw,playletChapterInfo:ordered},chapter||first,ordered,isVideo);
|
||||
const disposeDescription=bindSeriesDescription(page,signal);
|
||||
let episodeSize=matchMedia('(max-width:760px)').matches?15:45;
|
||||
let episodePage=chapter?Math.floor(Math.max(0,ordered.findIndex(item=>item.id===chapter!.id))/episodeSize):0;
|
||||
function renderEpisodes(){
|
||||
const size=matchMedia('(max-width:760px)').matches?15:45;
|
||||
if(size!==episodeSize){episodePage=Math.floor(episodePage*episodeSize/size);episodeSize=size;}
|
||||
const pages=Math.ceil(ordered.length/size);
|
||||
episodePage=Math.min(Math.max(0,episodePage),Math.max(0,pages-1));
|
||||
const segment=page.querySelector('.play-episode-pages'),grid=page.querySelector('.play-episode-grid');
|
||||
if(!grid||!segment)return;
|
||||
segment.innerHTML=episodeTabs(ordered,size,episodePage);
|
||||
const track=segment.querySelector<HTMLElement>('.episode-page-track'),selected=segment.querySelector<HTMLElement>('[aria-pressed="true"]');
|
||||
if(track&&selected){
|
||||
const bounds=track.getBoundingClientRect(),tab=selected.getBoundingClientRect();
|
||||
if(tab.left<bounds.left)track.scrollLeft+=tab.left-bounds.left;
|
||||
else if(tab.right>bounds.right)track.scrollLeft+=tab.right-bounds.right;
|
||||
}
|
||||
grid.innerHTML=episodeItems(id,ordered,chapter,size,episodePage,raw.slug);
|
||||
}
|
||||
renderEpisodes();
|
||||
window.addEventListener('resize',renderEpisodes,{signal});
|
||||
let commentSeq=0,replyParent=0,replyId=0;
|
||||
let initialComments=paging.initial?.comments;
|
||||
async function comments(){
|
||||
const seq=++commentSeq;
|
||||
const list=page.querySelector<HTMLElement>('.play-comment-list')!;
|
||||
try{
|
||||
const existing=initialComments;initialComments=undefined;
|
||||
const raw=existing || await app.api.get<PageData<Comment>>('comment/list',{objectId,objectType,pageNum:commentPage,pageSize:10,sortOrder:commentSort},signal);
|
||||
if(signal.aborted||seq!==commentSeq)return;
|
||||
const data=existing?raw:{...raw,list:recordList<Comment>(raw.list).map(item=>publicComment(item))};
|
||||
app.updateSeo?.({totalCount:data.totalCount,pageSize:10});
|
||||
page.querySelector('#play-comments-title')!.textContent=`评论 (${data.totalCount||0})`;
|
||||
if(!(existing&&paging.preserveMarkup))list.innerHTML=commentListMarkup(data,commentPage,commentSort,pageHref);
|
||||
}catch(error){if(!signal.aborted&&seq===commentSeq)list.innerHTML=empty(error instanceof Error?error.message:'评论加载失败');}
|
||||
}
|
||||
void comments();
|
||||
revealCurrentEpisode(page);
|
||||
const video=page.querySelector<HTMLVideoElement>('video');
|
||||
const chapterUnlock=bindChapterUnlock(app,id,chapter,raw.slug);
|
||||
let media:MediaPlayback|undefined;
|
||||
let reported=false;
|
||||
if(video){
|
||||
const box=page.querySelector<HTMLElement>('.play-screen')!;
|
||||
const status=page.querySelector<HTMLElement>('.play-source-status')!;
|
||||
media=new MediaPlayback(video,{signal,status:message=>{status.textContent=message;},error:()=>{window.tracker?.playbackFailed(video);box.dispatchEvent(new Event('playback:error'));}});
|
||||
const toggle=async()=>{if(mode==='main'&&!access.allowed){chapterUnlock.openCurrent();return;}await media?.toggle();};
|
||||
window.installPlayerControls?.(box,chapter?.currentEpisode||1,{toggle,retry:async()=>{if(mode==='main'&&!access.allowed){chapterUnlock.openCurrent();return;}window.tracker?.prepareDramaModeChange();await media?.retry();}});
|
||||
const gate=box.querySelector<HTMLElement>('.play-app-gate');
|
||||
const appOnly=['coins','vip'].includes(access.gate);
|
||||
media=new MediaPlayback(video,{signal,status:message=>{status.textContent=message;},error:()=>{box.dispatchEvent(new Event('playback:error'));}});
|
||||
const blocked=()=>mode==='main'&&!access.allowed;
|
||||
const toggle=async()=>{if(blocked())return;await media?.toggle();};
|
||||
window.installPlayerControls?.(box,chapter?.currentEpisode||1,{toggle,retry:async()=>{if(blocked())return;await media?.retry();}});
|
||||
const position=ordered.findIndex(item=>item.id===chapter?.id);
|
||||
const previous=box.querySelector<HTMLButtonElement>('[data-play-action="previous"]');
|
||||
const next=box.querySelector<HTMLButtonElement>('[data-play-action="next"]');
|
||||
if(previous)previous.disabled=position<=0;if(next)next.disabled=position<0||position>=ordered.length-1;
|
||||
const unlock=document.createElement('button');unlock.type='button';unlock.className='primary';unlock.dataset.showUnlock='';
|
||||
unlock.textContent=access.gate==='vip'?'查看会员套餐':`使用 ${unlockCost} 金币解锁`;
|
||||
box.querySelector('.play-screen-overlay')!.append(unlock);
|
||||
function setMode(nextMode:PlayMode){
|
||||
window.tracker?.prepareDramaModeChange();
|
||||
mode=nextMode;box.dataset.playMode=mode;
|
||||
page.querySelectorAll<HTMLButtonElement>('[data-play-mode]').forEach(button=>{if(button.tagName==='BUTTON')button.setAttribute('aria-pressed',String(button.dataset.playMode===mode));});
|
||||
const blocked=mode==='main'&&!access.allowed;
|
||||
unlock.hidden=!blocked||!['coins','vip'].includes(access.gate);
|
||||
const locked=blocked()&&appOnly;
|
||||
if(gate)gate.hidden=!locked;
|
||||
box.classList.toggle('is-app-gated',locked);
|
||||
box.querySelector('.ui-empty-state')?.remove();
|
||||
const center=box.querySelector<HTMLButtonElement>('[data-media="toggle"]');if(center)center.hidden=false;
|
||||
status.hidden=false;
|
||||
const center=box.querySelector<HTMLButtonElement>('[data-media="toggle"]');if(center)center.hidden=locked;
|
||||
status.hidden=locked;
|
||||
void media!.setSource(mode==='preview'?previewSource:access.allowed?access.source:'');
|
||||
if(blocked)status.textContent=access.gate==='vip'?'开通会员后可观看本集':access.gate==='coins'?'本集需要金币解锁':'本集暂不可观看';
|
||||
if(blocked())status.textContent=appOnly?'请在 App 观看':'本集暂不可观看';
|
||||
else if(!(mode==='preview'?previewSource:access.source))status.textContent='该剧集暂未提供视频源';
|
||||
else if(mode==='preview')status.textContent='免费预告 · 点击播放';
|
||||
}
|
||||
@@ -144,11 +106,7 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
|
||||
}
|
||||
page.addEventListener('click',event=>{void (async()=>{
|
||||
const element=(event.target as Element).closest<HTMLButtonElement>('button');if(!element||element.disabled)return;
|
||||
if(element.dataset.lockedChapter){const selected=ordered.find(item=>item.id===Number(element.dataset.lockedChapter));if(selected)await chapterUnlock.openChapter(selected);return;}
|
||||
if(element.dataset.episodePage){episodePage=Number(element.dataset.episodePage);renderEpisodes();}
|
||||
if(element.dataset.episodePage==='0'){episodePage=0;renderEpisodes();}
|
||||
if(element.hasAttribute('data-show-unlock'))chapterUnlock.openCurrent();
|
||||
if(element.dataset.playAction){const index=ordered.findIndex(item=>item.id===chapter?.id);const next=ordered[index+(element.dataset.playAction==='next'?1:-1)];if(next){window.tracker?.dramaStatusChange(next);app.navigate(contentPath('play',raw,next.id));}}
|
||||
if(element.dataset.playAction){const index=ordered.findIndex(item=>item.id===chapter?.id);const next=ordered[index+(element.dataset.playAction==='next'?1:-1)];if(next)app.navigate(contentPath('play',raw,next.id));}
|
||||
if(element.dataset.engage){
|
||||
const kind=element.dataset.engage;if(kind!=='like'&&kind!=='collect'||pendingEngagements.has(kind))return;
|
||||
const active=kind==='like'?engagement.isLike===1:engagement.isCollect===1;
|
||||
@@ -163,28 +121,7 @@ export async function renderPlayer(app:SiteContext,id:number,chapterId?:number,i
|
||||
}finally{pendingEngagements.delete(kind);finish.forEach(done=>done());}
|
||||
return;
|
||||
}
|
||||
if(element.hasAttribute('data-share')){const finish=beginButtonRequest(element,'复制中');try{await navigator.clipboard.writeText(location.href);if(!signal.aborted){window.tracker?.dramaEvent('ep_share');app.notify('页面链接已复制');}}finally{finish();}return;}
|
||||
if(element.dataset.commentSort){const sort=Number(element.dataset.commentSort);if(sort===1||sort===2)app.navigate(pageHref(1,sort));return;}
|
||||
if(element.dataset.reply){replyId=Number(element.dataset.reply);replyParent=Number(element.dataset.parent);page.querySelector('#reply-context')!.innerHTML=`<div class="play-reply-context"><span>回复 @${escape(element.dataset.replyName)}</span><button type="button" data-cancel-reply>取消回复</button></div>`;page.querySelector<HTMLTextAreaElement>('textarea')!.focus();}
|
||||
if(element.hasAttribute('data-cancel-reply')){replyId=replyParent=0;page.querySelector('#reply-context')!.textContent='';}
|
||||
if(element.dataset.commentLike){
|
||||
const active=element.getAttribute('aria-pressed')==='true',state={isLike:active?1:0,countLike:Number(element.dataset.commentCount)||0};
|
||||
const finish=beginButtonRequest(element);
|
||||
try{
|
||||
const result=await app.api.post<unknown>('comment/like/set',{commentId:Number(element.dataset.commentLike),isLike:!active});
|
||||
if(signal.aborted)return;
|
||||
applyEngagementSuccess(state,'like',active,result);
|
||||
element.setAttribute('aria-pressed',String(state.isLike===1));element.dataset.commentCount=String(state.countLike);
|
||||
const label=element.querySelector('[data-request-label]')||element;label.textContent=`${state.isLike===1?'♥':'♡'} ${formatCount(state.countLike)}`;
|
||||
}finally{finish();}
|
||||
}
|
||||
if(element.hasAttribute('data-share')){const finish=beginButtonRequest(element,'复制中');try{await navigator.clipboard.writeText(location.href);if(!signal.aborted)app.notify('页面链接已复制');}finally{finish();}return;}
|
||||
})().catch(error=>app.notify(error instanceof Error?error.message:'操作失败'));},{signal});
|
||||
page.querySelector('textarea[name="comment"]')?.addEventListener('input',event=>{const counter=page.querySelector('#play-comment-count');if(counter)counter.textContent=String((event.target as HTMLTextAreaElement).value.length);},{signal});
|
||||
page.querySelector('.play-comment-form')!.addEventListener('submit',event=>{
|
||||
event.preventDefault();const form=event.target as HTMLFormElement;const input=form.elements.namedItem('comment') as HTMLTextAreaElement;const button=form.querySelector<HTMLButtonElement>('[type="submit"]')!;
|
||||
if(button.disabled||!input.value.trim())return;
|
||||
const finish=beginButtonRequest(button,'发送中');
|
||||
void app.api.post('comment/send',{objectId,objectType,text:input.value.trim(),parentsId:replyParent,replyCommentId:replyId}).then(async()=>{if(signal.aborted)return;input.value='';const counter=page.querySelector('#play-comment-count');if(counter)counter.textContent='0';replyId=replyParent=0;page.querySelector('#reply-context')!.textContent='';page.querySelector('#comment-error')!.textContent='';app.notify('评论已发表');if(commentPage>1){app.navigate(pageHref(1,commentSort));return;}await comments();}).catch(error=>{if(!signal.aborted)page.querySelector('#comment-error')!.textContent=error.message;}).finally(finish);
|
||||
},{signal});
|
||||
return ()=>{disposeDescription();chapterUnlock.dispose();media?.dispose();};
|
||||
return ()=>{disposeDescription();media?.dispose();};
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export function imageAttributes(value: unknown, fallback = PLACEHOLDER, target:
|
||||
return `${target}="${escape(fallback)}"${source ? ` data-${target}="${escape(source)}" data-fallback="${escape(fallback)}"` : ''}`;
|
||||
}
|
||||
export const title = (drama: Drama): string => drama.title || drama.playName || "未命名剧集";
|
||||
export const itemAttributes=(item:Drama,video=false)=>`data-track-item data-item-id="${escape(item.id)}" data-item-type="${video?'video':'short_drama'}" data-item-name="${escape(title(item))}" data-recommend-trace-id="${escape(item.recommendTraceId)}" data-recommend-id="${escape(item.recommendId)}" data-recommend-trace-info="${escape(item.recommendTraceInfo)}"`;
|
||||
export function activeBanners(home: HomeData | null | undefined): Drama[] {
|
||||
const items=recordList<Drama>(home?.homeBannerData);
|
||||
return (items.some(item=>item.showOnBanner)?items.filter(item=>item.showOnBanner):items).slice(0,6);
|
||||
@@ -51,7 +50,7 @@ export function card(drama: Drama, video = false, options: {rank?:number;keyword
|
||||
const text=title(drama),keyword=options.keyword?.trim();
|
||||
const heading=keyword?highlightSearchTitle(text,keyword):escape(text);
|
||||
const headingTag=options.headingLevel===2?'h2':'h3';
|
||||
const attributes=`${itemAttributes(drama,video)} href="${contentPath(video?"video":"detail",drama)}"`;
|
||||
const attributes=`href="${contentPath(video?"video":"detail",drama)}"`;
|
||||
const episodes=!video&&drama.totalEpisode?`<span class="drama-episodes">${escape(drama.totalEpisode)}集</span>`:'';
|
||||
const description=`<span class="drama-description">${escape(drama.desc || drama.description || "")}</span>`;
|
||||
const meta=`<span class="drama-meta"><span class="drama-tags">${tags.slice(0,2).map(tag=>`<span>${escape(tag)}</span>`).join("")}</span><span class="drama-heat"><span class="drama-heat-icon" aria-hidden="true"></span>${formatCount(drama.countLike ?? drama.heatValue ?? 0)}</span></span>`;
|
||||
@@ -60,7 +59,12 @@ export function card(drama: Drama, video = false, options: {rank?:number;keyword
|
||||
const number=rank?String(rank).padStart(2,'0'):'';
|
||||
return `<a class="ranked-card drama-data-card" ${attributes} aria-label="${rank?`第 ${rank} 名,`:''}查看${escape(text)}"><span class="media portrait">${poster}${badge}${episodes}<span class="mobile-cover-rank" aria-hidden="true">${number}</span></span><span class="rank-caption"><strong aria-hidden="true">${number}</strong><span><${headingTag}>${heading}</${headingTag}></span></span>${description}${meta}</a>`;
|
||||
}
|
||||
return `<a class="card drama-data-card" ${attributes} aria-label="查看${escape(text)}"><span class="cover">${rank?`<span class="ranking-poster-frame">${poster}</span>`:poster}${badge}${episodes}${rank?`<span class="rank-home-badge" data-rank="${rank}" aria-label="第 ${rank} 名">${String(rank).padStart(2,'0')}</span>`:''}</span><${headingTag}>${heading}</${headingTag}>${description}${meta}</a>`;
|
||||
const total=typeof drama.totalEpisode==='number'&&Number.isFinite(drama.totalEpisode)?Math.max(0,Math.floor(drama.totalEpisode)):0;
|
||||
const counter=video||total<=1?'单集':`1/${total}${drama.updateStatus===1?'+':''}`;
|
||||
const counterLabel=video||total<=1?'单集视频':`共 ${total} 集${drama.updateStatus===1?',更新中':''}`;
|
||||
const theme=(Array.isArray(drama.themeNames)?drama.themeNames:[]).find(name=>typeof name==='string'&&name.trim())||tags[0]||'';
|
||||
const views=`<span class="hg-card-views"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>${formatCount(drama.heatValue ?? drama.countBrowse ?? drama.countLike ?? 0)}</span>`;
|
||||
return `<a class="hg-card" ${attributes} aria-label="查看${escape(text)}"><span class="hg-card-cover">${rank?`<span class="ranking-poster-frame">${poster}</span>`:poster}<span class="hg-card-counter" aria-label="${counterLabel}">${escape(counter)}</span>${badge?`<span class="hg-card-badge">${badge}</span>`:''}${rank?`<span class="rank-home-badge" data-rank="${rank}" aria-label="第 ${rank} 名">${String(rank).padStart(2,'0')}</span>`:''}</span><${headingTag} class="hg-card-title">${heading}</${headingTag}><span class="hg-card-rule" aria-hidden="true"></span><span class="hg-card-meta">${views}${theme?`<span class="hg-card-dot" aria-hidden="true">·</span><span class="hg-card-theme">${escape(theme)}</span>`:''}</span></a>`;
|
||||
}
|
||||
|
||||
export function hero(value: unknown, selected = 0): string {
|
||||
@@ -68,7 +72,7 @@ export function hero(value: unknown, selected = 0): string {
|
||||
if (!items.length) return empty("暂无推荐剧集");
|
||||
const index=items[selected]?selected:0,current=items[index];
|
||||
const href=contentPath('detail',current);
|
||||
return `<a class="hero-feature-art" ${itemAttributes(current)} href="${href}" aria-label="查看${escape(title(current))}"><img class="hero-art" ${imageAttributes(current.banner || current.horizontalCover || current.cover)} alt="${escape(title(current))}推荐海报" fetchpriority="high" decoding="async"></a><div class="hero-feature-bottom"><div class="hero-feature-title-row"><h2 class="hero-feature-title"><a ${itemAttributes(current)} href="${href}">${escape(title(current))}</a></h2><div class="hero-feature-controls"><button type="button" data-hero-step="-1" aria-label="上一张推荐"${items.length<2?' disabled':''}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14 5-7 7 7 7"/></svg></button><button type="button" data-hero-step="1" aria-label="下一张推荐"${items.length<2?' disabled':''}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m10 5 7 7-7 7"/></svg></button></div></div><div class="hero-feature-dots" role="group" aria-label="选择轮播推荐">${items.map((item,i)=>`<button type="button" data-feature="${i}" aria-label="切换到第 ${i+1} 张轮播:${escape(title(item))}" aria-pressed="${i===index}"><span></span></button>`).join('')}</div></div>`;
|
||||
return `<a class="hero-feature-art" href="${href}" aria-label="查看${escape(title(current))}"><img class="hero-art" ${imageAttributes(current.banner || current.horizontalCover || current.cover)} alt="${escape(title(current))}推荐海报" fetchpriority="high" decoding="async"></a><div class="hero-feature-bottom"><div class="hero-feature-title-row"><h2 class="hero-feature-title"><a href="${href}">${escape(title(current))}</a></h2><div class="hero-feature-controls"><button type="button" data-hero-step="-1" aria-label="上一张推荐"${items.length<2?' disabled':''}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m14 5-7 7 7 7"/></svg></button><button type="button" data-hero-step="1" aria-label="下一张推荐"${items.length<2?' disabled':''}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m10 5 7 7-7 7"/></svg></button></div></div><div class="hero-feature-dots" role="group" aria-label="选择轮播推荐">${items.map((item,i)=>`<button type="button" data-feature="${i}" aria-label="切换到第 ${i+1} 张轮播:${escape(title(item))}" aria-pressed="${i===index}"><span></span></button>`).join('')}</div></div>`;
|
||||
}
|
||||
|
||||
/** The fixed recommendation grid uses real enabled homepage content, independently of the carousel. */
|
||||
@@ -82,7 +86,7 @@ export function heroFeed(value:unknown):string {
|
||||
const items=recordList<Drama>(value);if(!items.length)return '';
|
||||
return `<div class="hero-feed-grid" aria-label="精选横版作品">${items.map(item=>{
|
||||
const tags=(Array.isArray(item.tagNames)?item.tagNames:Array.isArray(item.themeNames)?item.themeNames:[]).filter(tag=>typeof tag==='string');
|
||||
return `<a class="hero-feed-card drama-data-card" ${itemAttributes(item)} href="${contentPath('detail',item)}" aria-label="查看${escape(title(item))}"><span class="hero-feed-cover"><img class="poster-source" ${imageAttributes(item.horizontalCover||item.banner||item.cover||item.coverImg)} alt="${escape(title(item))}封面" loading="lazy" decoding="async">${item.totalEpisode?`<span class="drama-episodes">${escape(item.totalEpisode)}集</span>`:''}</span><strong title="${escape(title(item))}">${escape(title(item))}</strong><span class="drama-meta"><span class="drama-tags">${tags.slice(0,2).map(tag=>`<span>${escape(tag)}</span>`).join('')}</span></span></a>`;
|
||||
return `<a class="hero-feed-card drama-data-card" href="${contentPath('detail',item)}" aria-label="查看${escape(title(item))}"><span class="hero-feed-cover"><img class="poster-source" ${imageAttributes(item.horizontalCover||item.banner||item.cover||item.coverImg)} alt="${escape(title(item))}封面" loading="lazy" decoding="async">${item.totalEpisode?`<span class="drama-episodes">${escape(item.totalEpisode)}集</span>`:''}</span><strong title="${escape(title(item))}">${escape(title(item))}</strong><span class="drama-meta"><span class="drama-tags">${tags.slice(0,2).map(tag=>`<span>${escape(tag)}</span>`).join('')}</span></span></a>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
@@ -92,7 +96,7 @@ export function topicCard(topic: Topic, theme = false, headingLevel:2|3=3,kind:'
|
||||
const count=Number(topic.playletCount);
|
||||
const hasCount=Number.isFinite(count)&&count>0;
|
||||
const headingTag=headingLevel===2?'h2':'h3';
|
||||
return `<a class="topic-recommendation-card" data-track-item data-item-id="${escape(topic.id)}" data-item-type="topic" data-item-name="${escape(topic.name||'精选专题')}" href="${contentPath(kind,topic)}" aria-label="${hasCount?`查看${escape(label)},${count}部短剧`:`查看${escape(label)}`}"><img ${imageAttributes(topic.icon || topic.covers?.[0] || topic.list?.[0]?.cover)} alt="${escape(label)}封面" loading="lazy" decoding="async"><span class="topic-recommendation-copy">${hasCount?`<small>${count}部短剧</small>`:''}<${headingTag}>${escape(topic.name)}</${headingTag}></span></a>`;
|
||||
return `<a class="topic-recommendation-card" href="${contentPath(kind,topic)}" aria-label="${hasCount?`查看${escape(label)},${count}部短剧`:`查看${escape(label)}`}"><img ${imageAttributes(topic.icon || topic.covers?.[0] || topic.list?.[0]?.cover)} alt="${escape(label)}封面" loading="lazy" decoding="async"><span class="topic-recommendation-copy">${hasCount?`<small>${count}部短剧</small>`:''}<${headingTag}>${escape(topic.name)}</${headingTag}></span></a>`;
|
||||
}
|
||||
export function ads(items: unknown = [], className = "promo-app"): string {
|
||||
const tile=className==='promo-tile';
|
||||
@@ -100,7 +104,8 @@ export function ads(items: unknown = [], className = "promo-app"): string {
|
||||
return recordList<Advertisement>(items).filter(item=>safeUrl(item.images)).map(item=>{
|
||||
const label=escape(String(item.title || "").trim() || "推广广告");
|
||||
const image=`<img ${imageAttributes(item.images)} alt="${label}" loading="lazy"${tile||iconTile?` width="${tile?132:76}" height="${tile?132:76}" decoding="async"`:''}>`;
|
||||
return `<a class="${escape(className)}" data-track-ad="external" data-ad-id="${escape(item.advertiseCode||item.id)}" data-ad-clickable="${safeUrl(item.link)?1:0}" data-ad-slot-key="${escape(item.advertiseLocationCode||String(item.location||(tile?'promo-app':className)))}" data-ad-slot-name="${escape(item.advertiseLocationName||"推广广告")}" data-ad-type="${className==="promo-app"||tile||iconTile?"icon":"banner"}" data-creative-id="" href="${escape(safeUrl(item.link)||"/apps/")}" target="_blank" rel="sponsored nofollow noopener noreferrer">${tile?`<span class="promo-tile-image">${image}</span><strong>${escape(item.title)}</strong>`:`${image}<span>${escape(item.title)}</span>`}</a>`;
|
||||
const link=safeUrl(item.link);
|
||||
return `<a class="${escape(className)}"${link?` href="${escape(link)}" target="_blank" rel="sponsored nofollow noopener noreferrer"`:''}>${tile?`<span class="promo-tile-image">${image}</span><strong>${escape(item.title)}</strong>`:`${image}<span>${escape(item.title)}</span>`}</a>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
@@ -143,7 +148,7 @@ export function renderHome(home: HomeData | null | undefined, error?: string): s
|
||||
const banners = activeBanners(home);
|
||||
const blocks = homeBlocks(home);
|
||||
const announcement = recordList<{content?: string; title?: string}>(home?.announcements).map(item=>item.content||item.title).filter(Boolean).join(" · ");
|
||||
return `<section class="hero-grid"><div class="hero${homeRecommendations(home).length?'':' hero--single'}" id="hero"><div class="hero-feature-carousel" id="hero-feature-carousel" aria-roledescription="轮播" aria-label="精选推荐">${hero(banners)}</div>${heroFeed(homeRecommendations(home))}</div></section><section class="home-promotions" aria-label="公告与推荐应用">${announcement?`<div class="announcement-bar"><span class="announcement-label" aria-hidden="true"><svg class="announcement-solid-icon" viewBox="0 0 1024 1024" aria-hidden="true" fill="currentColor"><path d="M407.505455 311.621818a113.570909 113.570909 0 0 0-189.207273 40.261818L52.363636 807.796364a90.763636 90.763636 0 0 0 113.338182 118.225454l456.145455-152.901818a116.363636 116.363636 0 0 0 46.545454-189.672727zM519.912727 325.818182h6.050909a37.701818 37.701818 0 0 0 36.770909-31.650909c23.272727-143.592727 6.516364-215.04 5.818182-218.065455A37.003636 37.003636 0 0 0 523.869091 46.545455a38.167273 38.167273 0 0 0-27.927273 46.545454s13.265455 62.370909-6.749091 188.741818A37.701818 37.701818 0 0 0 519.912727 325.818182zM658.850909 347.694545a37.003636 37.003636 0 0 0 50.967273-13.730909 335.592727 335.592727 0 0 0 31.883636-126.603636 37.701818 37.701818 0 0 0-35.374545-39.563636 37.236364 37.236364 0 0 0-39.098182 35.607272 268.334545 268.334545 0 0 1-21.876364 93.090909 37.701818 37.701818 0 0 0 13.498182 51.2zM721.454545 454.283636a37.701818 37.701818 0 0 0 23.272728 71.912728 85.876364 85.876364 0 0 1 69.818182 11.636363 37.003636 37.003636 0 0 0 52.363636-6.749091 37.934545 37.934545 0 0 0-5.818182-52.829091 159.650909 159.650909 0 0 0-139.636364-23.970909z"/><path d="M837.818182 246.690909a70.749091 69.818182 90 1 0 139.636363 0 70.749091 69.818182 90 1 0-139.636363 0Z"/></svg></span><div class="announcement-viewport" tabindex="0" aria-label="${escape(announcement)}"><div class="announcement-track" aria-hidden="true"><span>${escape(announcement)} · </span><span>${escape(announcement)} · </span></div></div></div>`:""}${promotionRow(home?.advitise?.nineGridList)}</section><div id="home-blocks">${homeBlocksMarkup(blocks,home?.advitise?.detailbannerList,error)}</div><section class="topic-section popular-searches home-trending-topic"><div class="section-head"><h2>大家都在搜</h2></div><nav class="rank-search-tags" id="popular-search-tags" aria-label="大家都在搜关键词">${recordList<{keyword:string}>(home?.hotSearch).map(item=>`<a class="popular-search-tag" href="${escape(searchPath(item.keyword||''))}">${escape(item.keyword)}</a>`).join('')}</nav></section><section class="bottom-app-prompt" data-app-download-entry hidden><div class="bottom-app-copy"><div class="bottom-app-eyebrow">禁果短剧 App</div><h2>在 App 上继续追剧,离线缓存随时看</h2><p>竖屏沉浸播放 · 离线缓存 · 弹幕互动</p></div><a class="bottom-app-download" data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer">点击下载</a></section>`;
|
||||
return `<section class="hero-grid"><div class="hero${homeRecommendations(home).length?'':' hero--single'}" id="hero"><div class="hero-feature-carousel" id="hero-feature-carousel" aria-roledescription="轮播" aria-label="精选推荐">${hero(banners)}</div>${heroFeed(homeRecommendations(home))}</div></section><section class="home-promotions" aria-label="公告与推荐应用">${announcement?`<div class="announcement-bar"><span class="announcement-label" aria-hidden="true"><svg class="announcement-solid-icon" viewBox="0 0 1024 1024" aria-hidden="true" fill="currentColor"><path d="M407.505455 311.621818a113.570909 113.570909 0 0 0-189.207273 40.261818L52.363636 807.796364a90.763636 90.763636 0 0 0 113.338182 118.225454l456.145455-152.901818a116.363636 116.363636 0 0 0 46.545454-189.672727zM519.912727 325.818182h6.050909a37.701818 37.701818 0 0 0 36.770909-31.650909c23.272727-143.592727 6.516364-215.04 5.818182-218.065455A37.003636 37.003636 0 0 0 523.869091 46.545455a38.167273 38.167273 0 0 0-27.927273 46.545454s13.265455 62.370909-6.749091 188.741818A37.701818 37.701818 0 0 0 519.912727 325.818182zM658.850909 347.694545a37.003636 37.003636 0 0 0 50.967273-13.730909 335.592727 335.592727 0 0 0 31.883636-126.603636 37.701818 37.701818 0 0 0-35.374545-39.563636 37.236364 37.236364 0 0 0-39.098182 35.607272 268.334545 268.334545 0 0 1-21.876364 93.090909 37.701818 37.701818 0 0 0 13.498182 51.2zM721.454545 454.283636a37.701818 37.701818 0 0 0 23.272728 71.912728 85.876364 85.876364 0 0 1 69.818182 11.636363 37.003636 37.003636 0 0 0 52.363636-6.749091 37.934545 37.934545 0 0 0-5.818182-52.829091 159.650909 159.650909 0 0 0-139.636364-23.970909z"/><path d="M837.818182 246.690909a70.749091 69.818182 90 1 0 139.636363 0 70.749091 69.818182 90 1 0-139.636363 0Z"/></svg></span><div class="announcement-viewport" tabindex="0" aria-label="${escape(announcement)}"><div class="announcement-track" aria-hidden="true"><span>${escape(announcement)} · </span><span>${escape(announcement)} · </span></div></div></div>`:""}${promotionRow(home?.advitise?.nineGridList)}</section><div id="home-blocks">${homeBlocksMarkup(blocks,home?.advitise?.detailbannerList,error)}</div><section class="topic-section popular-searches home-trending-topic"><div class="section-head"><h2>大家都在搜</h2></div><nav class="rank-search-tags" id="popular-search-tags" aria-label="大家都在搜关键词">${recordList<{keyword:string}>(home?.hotSearch).map(item=>`<a class="popular-search-tag" href="${escape(searchPath(item.keyword||''))}">${escape(item.keyword)}</a>`).join('')}</nav></section><section class="bottom-app-prompt" data-app-download-entry hidden><div class="bottom-app-copy"><div class="bottom-app-eyebrow">黄果短剧 App</div><h2>在 App 上继续追剧,离线缓存随时看</h2><p>竖屏沉浸播放 · 离线缓存 · 弹幕互动</p></div><a class="bottom-app-download" data-app-download target="_blank" rel="sponsored nofollow noopener noreferrer">点击下载</a></section>`;
|
||||
}
|
||||
|
||||
export type PageHref=(page:number)=>string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { contentPath, searchPath } from '../routing';
|
||||
import type { Drama, PageData } from '../api/types';
|
||||
import { recordList } from '../api/protocol';
|
||||
import type { ApiClient } from './api';
|
||||
import { card, coverCornerBadge, escape, imageAttributes, itemAttributes, pagination, title, type PageHref } from './render';
|
||||
import { card, coverCornerBadge, escape, imageAttributes, pagination, title, type PageHref } from './render';
|
||||
|
||||
export const SEARCH_PAGE_SIZE=25;
|
||||
|
||||
@@ -21,7 +21,7 @@ export function searchPicksMarkup(value:unknown):string {
|
||||
return recordList<Drama>(value).slice(0,3).map(item=>{
|
||||
const name=title(item);
|
||||
const badge=coverCornerBadge(item);
|
||||
return `<a class="search-pick" ${itemAttributes(item)} href="${contentPath('detail',item)}" aria-label="查看${escape(name)}"><span class="search-pick-cover"><img class="poster-source" ${imageAttributes(item.cover||item.coverImg||item.horizontalCover)} alt="${escape(name)}封面" width="160" height="240" loading="lazy" decoding="async">${badge}${item.totalEpisode?`<span class="drama-episodes">${escape(item.totalEpisode)}集</span>`:''}</span><span class="search-pick-body"><strong>${escape(name)}</strong><small>${escape(item.desc||item.description||'')}</small></span></a>`;
|
||||
return `<a class="search-pick" href="${contentPath('detail',item)}" aria-label="查看${escape(name)}"><span class="search-pick-cover"><img class="poster-source" ${imageAttributes(item.cover||item.coverImg||item.horizontalCover)} alt="${escape(name)}封面" width="160" height="240" loading="lazy" decoding="async">${badge}${item.totalEpisode?`<span class="drama-episodes">${escape(item.totalEpisode)}集</span>`:''}</span><span class="search-pick-body"><strong>${escape(name)}</strong><small>${escape(item.desc||item.description||'')}</small></span></a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
import { contentPath } from '../routing';
|
||||
import type { Chapter } from '../api/types';
|
||||
import { ApiError, EXPIRED_CODES } from '../api/protocol';
|
||||
import type { SiteContext } from './context';
|
||||
import { chapterAccess, chapterUnlockCost } from './playback';
|
||||
|
||||
export function bindChapterUnlock(app: SiteContext, playletId: number, currentChapter: Chapter | null, slug?:string) {
|
||||
const signal = app.signal;
|
||||
const dialog = document.getElementById('play-episode-lock') as HTMLDialogElement;
|
||||
const pay = dialog.querySelector<HTMLButtonElement>('.unlock-pay')!;
|
||||
const close = dialog.querySelector<HTMLButtonElement>('.unlock-close')!;
|
||||
const error = dialog.querySelector<HTMLElement>('#episode-lock-error')!;
|
||||
const description = dialog.querySelector<HTMLElement>('#episode-lock-description')!;
|
||||
let selected: Chapter | null = null;
|
||||
let lookup = 0;
|
||||
let unlocking = false;
|
||||
|
||||
const showError = (message = '') => { error.textContent = message; error.hidden = !message; };
|
||||
const play = (chapter: Chapter) => app.navigate(contentPath('play',{id:playletId,slug},chapter.id));
|
||||
const recharge = () => { dialog.close(); app.navigate('/info/deposit/'); };
|
||||
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||
const balance = (chapter: Chapter) => {
|
||||
if (finite(chapter.balance) && chapter.balance > 0) return chapter.balance;
|
||||
if (finite(app.api.user?.recharge)) return app.api.user.recharge;
|
||||
return finite(chapter.balance) ? chapter.balance : 0;
|
||||
};
|
||||
const syncWallet = async (chapter: Chapter) => {
|
||||
if (finite(chapter.balance) && chapter.balance > 0) return;
|
||||
await app.api.refreshUser?.().catch(() => {});
|
||||
};
|
||||
const failed = (reason: unknown) => {
|
||||
if (reason instanceof ApiError && (reason.code === 401 || EXPIRED_CODES.has(reason.code))) {
|
||||
dialog.close(); app.openAuth(); return;
|
||||
}
|
||||
showError(reason instanceof Error ? reason.message : '读取章节失败,请重试');
|
||||
};
|
||||
|
||||
function present(chapter: Chapter) {
|
||||
if (signal.aborted) return;
|
||||
const access = chapterAccess(chapter);
|
||||
if (access.allowed) { dialog.close(); play(chapter); return; }
|
||||
if (access.gate === 'vip') { dialog.close(); app.navigate('/info/deposit/vip/'); return; }
|
||||
if (access.gate !== 'coins') { selected = null; pay.disabled = true; showError('本集暂不可解锁,请稍后重试'); return; }
|
||||
selected = chapter;
|
||||
const cost = chapterUnlockCost(chapter), coins = balance(chapter);
|
||||
description.textContent = `第 ${chapter.currentEpisode || 1} 集 · 解锁后可永久观看本集内容。`;
|
||||
dialog.querySelector('#episode-lock-balance')!.textContent = `${coins} 金币`;
|
||||
dialog.querySelector('.unlock-cost')!.textContent = `${cost} 金币`;
|
||||
pay.textContent = coins < cost ? '金币不足,去充值' : cost > 0 ? `支付 ${cost} 金币并播放` : '解锁并播放';
|
||||
pay.setAttribute('aria-label',coins < cost ? `金币不足,充值后解锁第 ${chapter.currentEpisode || 1} 集` : `确认解锁第 ${chapter.currentEpisode || 1} 集,消耗 ${cost} 金币`);
|
||||
pay.disabled = unlocking;
|
||||
close.disabled = unlocking;
|
||||
showError();
|
||||
if (!dialog.open) dialog.showModal();
|
||||
}
|
||||
|
||||
async function openChapter(summary: Chapter) {
|
||||
if (signal.aborted || unlocking) return;
|
||||
if (dialog.open && dialog.dataset.chapterId === String(summary.id)) return;
|
||||
const sequence = ++lookup;
|
||||
selected = null;
|
||||
dialog.dataset.chapterId = String(summary.id);
|
||||
description.textContent = `正在读取第 ${summary.currentEpisode || 1} 集解锁信息…`;
|
||||
dialog.querySelector('#episode-lock-balance')!.textContent = '— 金币';
|
||||
dialog.querySelector('.unlock-cost')!.textContent = '— 金币';
|
||||
pay.textContent = '读取中…'; pay.disabled = true; close.disabled = false;
|
||||
showError();
|
||||
if (!dialog.open) dialog.showModal();
|
||||
try {
|
||||
const chapter = await app.api.get<Chapter>('playlet/chapter/detail',{playletId,chapterId:summary.id},signal);
|
||||
if (signal.aborted || sequence !== lookup || !dialog.open) return;
|
||||
if (!chapter || chapter.id !== summary.id || chapter.playletId && chapter.playletId !== playletId) throw new Error('章节信息不匹配,请重新选择');
|
||||
await syncWallet(chapter);
|
||||
if (signal.aborted || sequence !== lookup || !dialog.open) return;
|
||||
present(chapter);
|
||||
} catch (reason) { if (!signal.aborted && sequence === lookup && dialog.open) failed(reason); }
|
||||
}
|
||||
|
||||
function openCurrent() {
|
||||
if (!currentChapter || signal.aborted || unlocking || chapterAccess(currentChapter).allowed) return;
|
||||
const chapter = currentChapter;
|
||||
const sequence = ++lookup;
|
||||
dialog.dataset.chapterId = String(chapter.id);
|
||||
void syncWallet(chapter).then(() => {
|
||||
if (!signal.aborted && sequence === lookup) present(chapter);
|
||||
});
|
||||
}
|
||||
|
||||
close.addEventListener('click',() => { if (!unlocking) dialog.close(); },{signal});
|
||||
dialog.addEventListener('cancel',event => { if (unlocking) event.preventDefault(); },{signal});
|
||||
dialog.addEventListener('close',() => { lookup++; selected = null; },{signal});
|
||||
dialog.addEventListener('click',event => {
|
||||
if (event.target !== dialog || unlocking) return;
|
||||
const bounds = dialog.getBoundingClientRect();
|
||||
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) dialog.close();
|
||||
},{signal});
|
||||
dialog.querySelector('.unlock-recharge')?.addEventListener('click',event => { if (unlocking) event.preventDefault(); else dialog.close(); },{signal});
|
||||
pay.addEventListener('click',() => { void (async () => {
|
||||
if (signal.aborted || unlocking || pay.disabled || !dialog.open || !selected) return;
|
||||
const chapter = selected;
|
||||
if (balance(chapter) < chapterUnlockCost(chapter)) { recharge(); return; }
|
||||
const confirmText = pay.textContent;
|
||||
unlocking = true; pay.disabled = true; close.disabled = true; pay.textContent = '解锁中…';
|
||||
dialog.setAttribute('aria-busy','true'); showError();
|
||||
try {
|
||||
const result = await app.api.post<{balance?:number}>('playlet/chapter/unlock',{chapterId:chapter.id,playletId});
|
||||
if (signal.aborted) return;
|
||||
if (app.api.user && typeof result?.balance === 'number' && Number.isFinite(result.balance) && result.balance >= 0) app.api.setUser({...app.api.user,recharge:result.balance});
|
||||
await app.api.refreshUser().catch(() => {});
|
||||
if (signal.aborted) return;
|
||||
dialog.close(); app.notify('解锁成功');
|
||||
window.tracker?.dramaStatusChange(chapter,'ep_unlock');
|
||||
if (chapter.id === currentChapter?.id) await app.route();
|
||||
else play(chapter);
|
||||
} catch (reason) {
|
||||
if (signal.aborted) return;
|
||||
if ((reason instanceof ApiError && reason.code === 400) || (reason instanceof Error && /余额.*不足|金币.*不足/.test(reason.message))) { recharge(); return; }
|
||||
failed(reason);
|
||||
} finally {
|
||||
unlocking = false;
|
||||
if (!signal.aborted) {
|
||||
dialog.removeAttribute('aria-busy'); close.disabled = false;
|
||||
pay.disabled = !selected || !dialog.open;
|
||||
if (selected && dialog.open) pay.textContent = confirmText;
|
||||
}
|
||||
}
|
||||
})(); },{signal});
|
||||
|
||||
const dispose = () => {
|
||||
lookup++; selected = null;
|
||||
dialog.removeAttribute('aria-busy'); close.disabled = false; pay.disabled = true;
|
||||
if (dialog.open) dialog.close();
|
||||
};
|
||||
signal.addEventListener('abort',dispose,{once:true});
|
||||
return {openChapter,openCurrent,dispose};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { buildSdkUserAgent, buildUserAgent } from '../api/user-agent';
|
||||
|
||||
/** Encode JSON as base64 (UTF-8 safe) for header transport. */
|
||||
function base64JSON(value: unknown): string {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(value));
|
||||
return btoa(Array.from(bytes, byte => String.fromCharCode(byte)).join(''));
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务请求头沿用原 SDK 字段的格式,但设备号只使用访客设备号(drama_device / 游客 devID)。
|
||||
* 不再加载统计 SDK,也不再附带任何埋点事件上下文。
|
||||
*/
|
||||
export function visitorHeaders(devID = '', userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent): Record<string, string> {
|
||||
const parsed = Object.fromEntries(buildUserAgent(userAgent).split(';').map(part => {
|
||||
const index = part.indexOf('=');
|
||||
return index < 0 ? [part, ''] : [part.slice(0, index), part.slice(index + 1)];
|
||||
}));
|
||||
const system = parsed.system_name || '';
|
||||
const device = system === 'Android' ? 'Android' : system === 'iOS' ? 'iOS' : ['Windows', 'macOS', 'Linux'].includes(system) ? 'PC' : 'OTHER';
|
||||
const headers: Record<string, string> = {
|
||||
'X-User-Agent': buildSdkUserAgent({device_id: devID, device, device_brand: parsed.device_brand, device_model: parsed.device_model, system_name: system, system_version: parsed.system_version, app_version: '1.0.0', sid: ''}),
|
||||
};
|
||||
if (devID) headers['X-SDK-Common'] = base64JSON({sid: '', device_id: devID, app_version: '1.0.0'});
|
||||
return headers;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/** 全站唯一对外联系方式:商务合作(Telegram @jbd125)。不再读取后台的邮箱 / QQ / X / 交流群配置。 */
|
||||
export const BUSINESS_CONTACT = {
|
||||
label: "商务合作",
|
||||
handle: "@jbd125",
|
||||
href: "https://t.me/jbd125",
|
||||
} as const;
|
||||
+13
-3
@@ -32,7 +32,8 @@ export function footerLinkHref(urlValue: string | undefined): string {
|
||||
const raw = (urlValue || '').trim();
|
||||
if (!raw) return '';
|
||||
if (/^https?:\/\//i.test(raw)) return raw;
|
||||
if (raw.startsWith('mailto:')) return raw;
|
||||
// Email / customer-service channels are no longer offered.
|
||||
if (raw.startsWith('mailto:')) return '';
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(raw)) return '';
|
||||
const path = (raw.startsWith('/') ? raw : `/${raw}`).replace(/^\/richetext(?=\/|$)/i, '/richtext');
|
||||
const titled = path.match(/^\/richtext\/([^?#]+?)\/?$/i);
|
||||
@@ -46,16 +47,25 @@ export function footerLinkHref(urlValue: string | undefined): string {
|
||||
return path.endsWith('/') ? path : `${path}/`;
|
||||
}
|
||||
|
||||
/** Removed site features: user center, wallet/recharge, customer-service feedback and the welfare app directory. */
|
||||
const REMOVED_PAGE = /^\/(?:member|apps|application|info\/(?:feedback|user-center|settings|deposit))(?:\/|$)/i;
|
||||
const REMOVED_NAME = /客服|反馈|个人中心|充值|账号设置|福利应用/;
|
||||
|
||||
/** True for configured links that point at pages this site no longer provides. */
|
||||
export function removedFooterLink(name: string, href: string): boolean {
|
||||
return REMOVED_NAME.test(name) || REMOVED_PAGE.test(href);
|
||||
}
|
||||
|
||||
export function footerPolicyLinks(data: FooterInfoResponse | null | undefined): FooterPolicyLink[] {
|
||||
return footerNavItems(data).flatMap((item, index) => {
|
||||
const name = item.name?.trim() || '';
|
||||
const href = footerLinkHref(item.urlValue);
|
||||
if (!name || !href) return [];
|
||||
if (!name || !href || removedFooterLink(name, href)) return [];
|
||||
return [{
|
||||
id: typeof item.id === 'number' && Number.isSafeInteger(item.id) ? item.id : index,
|
||||
name,
|
||||
href,
|
||||
external: /^https?:\/\//i.test(href) || href.startsWith('mailto:'),
|
||||
external: /^https?:\/\//i.test(href),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
+12
-48
@@ -1,6 +1,8 @@
|
||||
import type { Config, FooterContact } from "./api/types";
|
||||
|
||||
/** Original HTML copy and markup; the about page's contacts use site configuration. */
|
||||
import { BUSINESS_CONTACT } from './contact';
|
||||
|
||||
/** Original HTML copy and markup; the about page shows the single business contact. */
|
||||
export interface InfoPageDefinition {key:string;title:string;html:string}
|
||||
|
||||
const EMPTY_CONTACT_SECTION='<section class="about-section" data-site-contacts hidden></section>';
|
||||
@@ -9,7 +11,7 @@ export const INFO_PAGES:Readonly<Record<string,InfoPageDefinition>>={
|
||||
"about-us": {key:"about-us",title:"关于我们",html:`
|
||||
<section class="about-section">
|
||||
<h2>品牌介绍</h2>
|
||||
<p>禁果短剧为喜爱短剧的观众汇集多种题材与精选作品,让找剧、追剧和观看变得轻松便捷。</p>
|
||||
<p>黄果短剧为喜爱短剧的观众汇集多种题材与精选作品,让找剧、追剧和观看变得轻松便捷。</p>
|
||||
<p>我们相信故事能够带来共鸣,也希望让更多创作者的作品与喜欢它们的观众相遇。</p>
|
||||
</section>
|
||||
<section class="about-section">
|
||||
@@ -22,7 +24,7 @@ ${EMPTY_CONTACT_SECTION}
|
||||
"user-agreement": {key:"user-agreement",title:"用户协议",html:`
|
||||
<section class="about-section">
|
||||
<h2>使用协议</h2>
|
||||
<p>使用条款 欢迎您光临【禁果短剧】(以下简称本网站),本网站由「禁果短剧」(以下简称禁果短剧)经营。</p>
|
||||
<p>使用条款 欢迎您光临【黄果短剧】(以下简称本网站),本网站由「黄果短剧」(以下简称黄果短剧)经营。</p>
|
||||
<p>本网站重视每一位使用者所享有的服务,特此说明本网站的使用政策,以保障您的权益。</p>
|
||||
<p>请您仔细阅读本使用条款的内容:</p>
|
||||
</section>
|
||||
@@ -120,7 +122,7 @@ ${EMPTY_CONTACT_SECTION}
|
||||
`},
|
||||
"privacy-policy": {key:"privacy-policy",title:"隐私政策",html:`
|
||||
<section class="about-section">
|
||||
<p>禁果短剧(以下简称本公司)非常重视您的隐私权,并依据《个人资料保护法》(以下简称个资法)的规定,向您告知下列有关隐私权保护政策的更多内容。</p>
|
||||
<p>黄果短剧(以下简称本公司)非常重视您的隐私权,并依据《个人资料保护法》(以下简称个资法)的规定,向您告知下列有关隐私权保护政策的更多内容。</p>
|
||||
</section>
|
||||
<section class="about-section">
|
||||
<h2>一、本公司使用您个人资料的目的</h2>
|
||||
@@ -187,10 +189,6 @@ ${EMPTY_CONTACT_SECTION}
|
||||
<summary>VIP 和金币是否可以购买?</summary>
|
||||
<p>这些标识用于展示剧集类型。目前没有充值、订阅或购买功能。</p>
|
||||
</details>
|
||||
<details>
|
||||
<summary>如何反馈问题?</summary>
|
||||
<p>前往<a href="/info/feedback/">意见反馈</a>,填写问题类型、设备和详细描述,再将反馈保存到本地。当前版本没有线上提交功能。</p>
|
||||
</details>
|
||||
`},
|
||||
"2257": {key:"2257",title:"2257 合规声明",html:`
|
||||
<section class="compliance-section">
|
||||
@@ -218,7 +216,7 @@ ${EMPTY_CONTACT_SECTION}
|
||||
<span class="compliance-number" aria-hidden="true">04</span>
|
||||
<div>
|
||||
<h2>合规说明</h2>
|
||||
<p>如发现疑似违规内容,请通过<a href="/info/feedback/">意见反馈</a>提供线索,以便核查处理。具体义务以适用法律法规为准。</p>
|
||||
<p>如发现疑似违规内容,请通过<a href="https://t.me/jbd125" target="_blank" rel="nofollow noopener noreferrer">商务合作 Telegram @jbd125</a>提供线索,以便核查处理。具体义务以适用法律法规为准。</p>
|
||||
</div>
|
||||
</section>
|
||||
`},
|
||||
@@ -248,50 +246,16 @@ ${EMPTY_CONTACT_SECTION}
|
||||
<span class="compliance-number" aria-hidden="true">04</span>
|
||||
<div>
|
||||
<h2>联系我们</h2>
|
||||
<p>版权通知及反通知请通过本站<a href="/info/feedback/">意见反馈</a>提交,并一并提供核查所需的资料。</p>
|
||||
<p>版权通知及反通知请通过<a href="https://t.me/jbd125" target="_blank" rel="nofollow noopener noreferrer">商务合作 Telegram @jbd125</a>提交,并一并提供核查所需的资料。</p>
|
||||
</div>
|
||||
</section>
|
||||
`},
|
||||
};
|
||||
|
||||
const contactText=(value:unknown)=>typeof value==='string'?value.trim().replace(/立即(?:联系|加入)/g,'').replace(/^[\s::]+|[\s::]+$/g,''):'';
|
||||
const escapeContact=(value:string)=>value.replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]!));
|
||||
|
||||
function contactValue(value:unknown):{text:string;href:string}|undefined {
|
||||
const text=contactText(value);
|
||||
if(!text||/[\x00-\x1f\x7f]/.test(text)||/mailto:|[^/\s@]+@[^/\s@]+\./i.test(text))return;
|
||||
if(/^tel:\+?[\d() .-]+$/i.test(text))return {text:text.slice(4),href:text};
|
||||
const url=text.startsWith('//')?'https:'+text:/^[\w.-]+\.[a-z]{2,}(?:[/?#]|$)/i.test(text)?'https://'+text:text;
|
||||
try{
|
||||
const parsed=new URL(url);
|
||||
if(['https:','http:'].includes(parsed.protocol)&&!parsed.username&&!parsed.password)return {text,href:parsed.href};
|
||||
return;
|
||||
}catch{
|
||||
if(/^[\w-]+:/i.test(text))return;
|
||||
return {text,href:''};
|
||||
}
|
||||
}
|
||||
|
||||
export function infoContactSection(config:Config={},contacts:FooterContact[]|null=[]):string {
|
||||
const settings=config.config,addresses=config.newAddressInfo;
|
||||
const telegram=(...values:unknown[])=>values.map(value=>{
|
||||
const text=contactText(value);
|
||||
return contactValue(/^@[a-z\d_]{4,}$/i.test(text)?'https://t.me/'+text.slice(1):text);
|
||||
}).find(value=>value?.href&&/^https?:/.test(value.href));
|
||||
const business=telegram(settings?.telegram,settings?.telegramAdd,addresses?.telegram,addresses?.telegramAdd);
|
||||
const group=telegram(settings?.tgGroup,addresses?.tgGroup);
|
||||
const rows:{name:string;text:string;href:string}[]=[];
|
||||
if(business)rows.push({name:'商务合作',...business});
|
||||
if(group)rows.push({name:'用户反馈',...group});
|
||||
for(const contact of Array.isArray(contacts)?contacts:[]){
|
||||
if(!contact||typeof contact!=='object')continue;
|
||||
const name=contactText(contact.name);if(!name)continue;
|
||||
if(/商务|商户|合作|反馈|客服|support|官方群|telegram|tg群|邮箱|邮件|e-?mail/i.test(name))continue;
|
||||
const value=contactValue(contact.value);
|
||||
if(value)rows.push({name,...value});
|
||||
}
|
||||
if(!rows.length)return EMPTY_CONTACT_SECTION;
|
||||
return `<section class="about-section" data-site-contacts><h2>联系我们</h2>${rows.map(({name,text,href})=>`<p>${escapeContact(name)}:${href?`<a href="${escapeContact(href)}"${/^https?:/i.test(href)?' target="_blank" rel="sponsored nofollow noopener noreferrer"':''}>${escapeContact(text)}</a>`:escapeContact(text)}</p>`).join('')}</section>`;
|
||||
/** 关于我们页只展示唯一的商务合作联系方式;后台配置的邮箱 / QQ / 交流群等不再展示。 */
|
||||
export function infoContactSection(_config:Config={},_contacts:FooterContact[]|null=[]):string {
|
||||
const {label,handle,href}=BUSINESS_CONTACT;
|
||||
return `<section class="about-section" data-site-contacts><h2>联系我们</h2><p>${label}:<a href="${href}" target="_blank" rel="nofollow noopener noreferrer">Telegram ${handle}</a></p></section>`;
|
||||
}
|
||||
|
||||
export function getInfoPage(key:string,config:Config={},contacts:FooterContact[]|null=[]):InfoPageDefinition|undefined {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Config } from './api/types';
|
||||
import { recordList, safeUrl } from './api/protocol';
|
||||
import { SITE_NAME, brandText } from './brand';
|
||||
|
||||
const object=(value:unknown):Record<string,unknown>=>{
|
||||
if(typeof value==='string'){try{return object(JSON.parse(value));}catch{return {};}}
|
||||
@@ -7,7 +8,6 @@ const object=(value:unknown):Record<string,unknown>=>{
|
||||
};
|
||||
const text=(value:unknown)=>typeof value==='string'?value.replace(/<[^>]*>/g,'').replace(/[\u0000-\u001f\u007f]/g,' ').replace(/\s+/g,' ').trim():'';
|
||||
const firstText=(...values:unknown[])=>values.map(text).find(Boolean)||'';
|
||||
const telegramUrl=(value:unknown)=>safeUrl(typeof value==='string'&&/^@[a-z\d_]{4,}$/i.test(value.trim())?'https://t.me/'+value.trim().slice(1):value);
|
||||
const lineLabel=(line:number)=>'线路'+(['','一','二','三','四','五','六','七','八','九','十'][line]||line);
|
||||
|
||||
/** Navigation uses only configured destinations; missing values never link to a demo site. */
|
||||
@@ -15,7 +15,8 @@ export function navigationPageData(config:Config) {
|
||||
const settings=object(config.config),addresses=object(config.newAddressInfo);
|
||||
const seo=object(config.seoInfo),navigationSeo=object(config.seoNavInfo);
|
||||
const url=(key:string)=>[settings[key],addresses[key]].map(value=>safeUrl(value)).find(Boolean)||'';
|
||||
const siteName=firstText(navigationSeo.dhSiteName,navigationSeo.siteName,settings.dhSiteName,seo.webSiteName,settings.webSiteName)||'禁果短剧';
|
||||
const configuredName=firstText(navigationSeo.dhSiteName,navigationSeo.siteName,settings.dhSiteName,seo.webSiteName,settings.webSiteName);
|
||||
const siteName=brandText(configuredName)||SITE_NAME;
|
||||
const seen=new Set<number>();
|
||||
let routes=recordList<{line?:unknown;url?:unknown}>(config.navPageList).flatMap(item=>{
|
||||
const line=Number(item.line),href=safeUrl(item.url);
|
||||
@@ -30,10 +31,7 @@ export function navigationPageData(config:Config) {
|
||||
}
|
||||
return {
|
||||
siteName,
|
||||
description:firstText(navigationSeo.dhDescription,navigationSeo.description,settings.dhDescription)||'全网最新最火短剧,禁果为你呈现',
|
||||
description:brandText(firstText(navigationSeo.dhDescription,navigationSeo.description,settings.dhDescription))||`全网最新最火短剧,${SITE_NAME}为你呈现`,
|
||||
routes,permanent:url('foreverUrlAddr'),
|
||||
telegram:[settings.telegram,settings.telegramAdd,addresses.telegram,addresses.telegramAdd].map(telegramUrl).find(Boolean)||'',
|
||||
tgGroup:[settings.tgGroup,addresses.tgGroup].map(telegramUrl).find(Boolean)||'',
|
||||
twitter:url('twitterAddr'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { Chapter, Comment, Config, Drama, DramaDetail, FooterInfoResponse, Package, PageData, User } from './api/types';
|
||||
import type { Chapter, Config, DramaDetail, FooterInfoResponse, User } from './api/types';
|
||||
|
||||
export interface SiteSharedData {config:Config;footer:FooterInfoResponse;trackerConfigured:boolean}
|
||||
|
||||
export interface MemberInitialData {user:User;list?:PageData<Drama>}
|
||||
export interface WalletInitialData {user:User;packages:{coinList?:Package[];vipList?:Package[]};transactions:PageData<Record<string,unknown>>}
|
||||
export interface SiteSharedData {config:Config;footer:FooterInfoResponse}
|
||||
|
||||
export interface PlayerInitialData {
|
||||
detail:DramaDetail;
|
||||
chapters:Chapter[];
|
||||
chapter:Chapter|null;
|
||||
comments?:PageData<Comment>;
|
||||
}
|
||||
|
||||
/** Only the first route can consume this snapshot; later navigation loads fresh data. */
|
||||
@@ -20,7 +16,7 @@ export interface PageBootstrap {
|
||||
subtitle?:string;
|
||||
player?:PlayerInitialData;
|
||||
search?:{keyword:string;total:number;page:number;pageSize:number};
|
||||
info?:{key:string;member?:MemberInitialData;wallet?:WalletInitialData;feedback?:boolean};
|
||||
info?:{key:string};
|
||||
session?:{user:User|null;state:string};
|
||||
}
|
||||
|
||||
|
||||
+4
-24
@@ -1,4 +1,4 @@
|
||||
import type { Advertisement, Advertisements, Chapter, Comment, DramaDetail, PageData } from './api/types';
|
||||
import type { Advertisement, Advertisements, Chapter, DramaDetail } from './api/types';
|
||||
import type { PlayerInitialData } from './page-data';
|
||||
import { recordList } from './api/protocol';
|
||||
import { normalizeSeo } from './seo';
|
||||
@@ -41,29 +41,9 @@ function publicChapter(data:Chapter):Chapter {
|
||||
return result;
|
||||
}
|
||||
|
||||
function commentAvatar(data:Record<string,unknown>):string {
|
||||
for(const key of ['userAvatar','avatar']){
|
||||
const value=data[key];
|
||||
if(typeof value==='string'&&value.trim())return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Strip tokens and keep avatar URLs for client decrypt via data-src. */
|
||||
export function publicComment(data:Comment,depth=0):Comment {
|
||||
const source=data as unknown as Record<string,unknown>;
|
||||
const result=fields(data,['text','userName','userAvatar','createdAt','replyUserName'],['id','isLike','countLike','parentsId','replyCommentId','commentCount','level']) as unknown as Comment;
|
||||
const avatar=commentAvatar(source);
|
||||
if(avatar)result.userAvatar=avatar;
|
||||
if(depth<4)result.children=recordList<Comment>(data.children).map(item=>publicComment(item,depth+1));
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Explicit DTO: no auth tokens, SDK configuration, or arbitrary upstream properties. */
|
||||
export function playerInitialData(raw:DramaDetail,chapters:Chapter[],chapter:Chapter|null,comments?:PageData<Comment>):PlayerInitialData {
|
||||
export function playerInitialData(raw:DramaDetail,chapters:Chapter[],chapter:Chapter|null):PlayerInitialData {
|
||||
const detail=publicDrama(raw);
|
||||
detail.relatedRecommendData=recordList<DramaDetail>(raw.relatedRecommendData).filter(item=>Number.isSafeInteger(item.id)&&item.id>0).slice(0,6).map(publicDrama);
|
||||
return {detail,chapters:chapters.map(publicChapter),chapter:chapter?publicChapter(chapter):null,
|
||||
...(comments?{comments:{list:recordList<Comment>(comments.list).map(item=>publicComment(item)),...(typeof comments.error==='string'?{error:comments.error.slice(0,300)}:{}),totalCount:typeof comments.totalCount==='number'?comments.totalCount:0}}:{}),
|
||||
};
|
||||
detail.relatedRecommendData=recordList<DramaDetail>(raw.relatedRecommendData).filter(item=>Number.isSafeInteger(item.id)&&item.id>0&&item.id!==raw.id).slice(0,12).map(publicDrama);
|
||||
return {detail,chapters:chapters.map(publicChapter),chapter:chapter?publicChapter(chapter):null};
|
||||
}
|
||||
|
||||
+3
-9
@@ -15,9 +15,8 @@ export function getRankPage(slug='hot') {
|
||||
return RANK_PAGES.find(page=>page.slug===slug);
|
||||
}
|
||||
|
||||
const channels = new Set(['shorts', 'topics', 'rank', 'apps', 'home-road']);
|
||||
const members = new Set(['center', 'favorites', 'history', 'likes', 'purchased', 'settings']);
|
||||
const information = new Set(['about-us', 'user-agreement', 'privacy-policy', 'help-center', '2257', 'dmca', 'feedback', 'user-center', 'settings', 'deposit']);
|
||||
const channels = new Set(['shorts', 'topics', 'rank', 'home-road']);
|
||||
const information = new Set(['about-us', 'user-agreement', 'privacy-policy', 'help-center', '2257', 'dmca']);
|
||||
const id = (value?: string) => value !== undefined && /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value)>0;
|
||||
const richTextTitle = (value?: string) => {
|
||||
const title = (value || '').trim();
|
||||
@@ -66,19 +65,14 @@ function baseRouteFromSegments(parts: string[]): SiteRoute | null {
|
||||
if (group === 'detail' && parts.length === 2 && contentKey(key)) return {group, key};
|
||||
if (group === 'play' && parts.length === 3 && contentKey(key) && id(value)) return {group, key, value};
|
||||
if (group === 'search' && parts.length === 2 && key.trim()) return {group, key};
|
||||
if (group === 'member' && parts.length === 2 && members.has(key)) return {group, key};
|
||||
if (group === 'info' && parts.length === 2 && information.has(key)) return {group, key};
|
||||
if (group === 'info' && key === 'deposit' && value === 'vip' && parts.length === 3) return {group, key, value};
|
||||
if ((group === 'richtext' || group === 'richetext') && parts.length === 2 && richTextTitle(key)) return {group: 'richtext', key: richTextTitle(key)};
|
||||
return null;
|
||||
}
|
||||
|
||||
export function supportsPagination(route:SiteRoute):boolean {
|
||||
if(route.group==='page')return ['shorts','topics','rank','theme','tag','config'].includes(route.key);
|
||||
if(route.group==='member')return ['favorites','history','likes','purchased'].includes(route.key);
|
||||
if(route.group==='info')return route.key==='deposit';
|
||||
if(route.group==='search')return true;
|
||||
return ['detail','play'].includes(route.group);
|
||||
return route.group==='search';
|
||||
}
|
||||
|
||||
export function routeFromSegments(parts:string[]):SiteRoute|null {
|
||||
|
||||
+15
-53
@@ -4,27 +4,24 @@ import type { Metadata } from 'next';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isIP } from 'node:net';
|
||||
import { getHeaderNavigation, getHome, getServerApiBaseUrl, upstream } from './api/server';
|
||||
import type { Config, DramaDetail, Chapter, Comment, Drama, FooterInfoResponse, NavigationCategory, PageData, RichTextListResponse, User } from './api/types';
|
||||
import { withoutRemovedNavigation } from './site-navigation';
|
||||
import type { Config, DramaDetail, Chapter, Drama, FooterInfoResponse, NavigationCategory, PageData, RichTextListResponse, User } from './api/types';
|
||||
import { ApiError, EXPIRED_CODES, recordList } from './api/protocol';
|
||||
import { contentPath, contentQuery, matchesContentKey, parseSitePath } from './routing';
|
||||
import { getInfoPage } from './info-pages';
|
||||
import { buildPageHead, dramaSeoContent, homeSeoContent, infoSeoContent, listSeoContent, resolveSeoEnvironment, type PageHead, type SeoBootstrap, type SeoContent } from './seo';
|
||||
import { SeoSnapshotStore } from './seo-snapshot.server';
|
||||
import type { PageBootstrap, PageView, WalletInitialData } from './page-data';
|
||||
import type { PageBootstrap, PageView } from './page-data';
|
||||
import type { ApiClient } from './client/api';
|
||||
import { playerInitialData } from './player-data';
|
||||
import { loadChannel, channelView } from './client/channel';
|
||||
import { orderedChapters, playerMarkup } from './client/player-markup';
|
||||
import { orderedChapters, playerMarkup, RECOMMEND_PAGE_SIZE } from './client/player-markup';
|
||||
import { loadSearchResults, searchErrorMarkup, searchResultsMarkup } from './client/search';
|
||||
import { paginationPath } from './routing';
|
||||
import { empty, escape } from './client/render';
|
||||
import { loadAppCategories, renderAppDirectory } from './client/apps';
|
||||
import { homeRoad } from './client/config';
|
||||
import { feedbackMarkup } from './client/feedback';
|
||||
import { memberMarkup, walletMarkup } from './client/account';
|
||||
import { publicUser } from './api/public-user';
|
||||
import { SSR_SESSION_HEADER, SSR_USER_HEADER } from './api/page-session';
|
||||
import { accountList, accountPackages, accountTransactions } from './account-data';
|
||||
import { publicSiteData } from './site-shared-data';
|
||||
|
||||
const snapshotStores=new Map<string,SeoSnapshotStore>();
|
||||
@@ -51,7 +48,7 @@ export const getSeoConfig=cache(async ():Promise<{config:Config;error?:string}>=
|
||||
const getCategories=cache(async ():Promise<NavigationCategory[]|undefined>=>{
|
||||
try{
|
||||
const result=await upstream<{categoryList?:NavigationCategory[]}>('translation/category/list',{timeoutMs:5000});
|
||||
return result.code===200&&Array.isArray(result.data?.categoryList)?result.data.categoryList:undefined;
|
||||
return result.code===200&&Array.isArray(result.data?.categoryList)?withoutRemovedNavigation({categoryList:result.data.categoryList}).categoryList!:undefined;
|
||||
}catch{return undefined;}
|
||||
});
|
||||
const getFooterInfo=cache(async ():Promise<FooterInfoResponse>=>{
|
||||
@@ -118,34 +115,6 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
|
||||
const query:Record<string,string|number>={pageNum:route.page||1,pageSize:24};
|
||||
try{
|
||||
if(!route.group){const home=await getHome();return {...homeSeoContent(home.data),failed:!!home.error};}
|
||||
if(route.group==='member'||route.group==='info'&&['deposit','user-center','settings'].includes(route.key)){
|
||||
const wallet=route.key==='deposit',tab=route.group==='member'?route.key:route.key==='settings'?'settings':'center';
|
||||
const title=wallet?(route.value==='vip'?'会员畅享':'金币充值'):({center:'个人中心',history:'观看历史',favorites:'我的收藏',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置'} as Record<string,string>)[tab];
|
||||
const session=await preparedSession(),pageHref=(page:number)=>paginationPath(address,page);
|
||||
if(!session.user){
|
||||
// jha-1048: cold visits have no SSR guest. login-required stays signed-out;
|
||||
// unavailable waits for client GuestAuthBootstrap instead of a hard 500.
|
||||
const html=session.state==='login-required'
|
||||
? empty('请先登录账号')+'<button type="button" class="primary" data-auth-mode="login">登录账号</button>'
|
||||
: empty('正在准备账号,请稍候…');
|
||||
return {view:infoView(address,route.key,title,html,{session})};
|
||||
}
|
||||
try{
|
||||
if(wallet){
|
||||
const [packages,transactions]=await Promise.all([
|
||||
contentRequest<WalletInitialData['packages']>('recharge/package/list',{}),
|
||||
contentRequest<WalletInitialData['transactions']>('coin/trans/list',{page:route.page||1,pageSize:10,type:0},true).catch(()=>({list:[],totalCount:0,error:true})),
|
||||
]);
|
||||
const initial:WalletInitialData={user:session.user,packages:accountPackages(packages),transactions:accountTransactions(transactions)};
|
||||
return {...(transactions.error?{failed:(route.page||1)>1}:{totalCount:initial.transactions.totalCount}),pageSize:10,view:infoView(address,'deposit',title,walletMarkup(session.user,initial.packages,initial.transactions,route.value==='vip',route.page||1,pageHref),{session,info:{key:'deposit',wallet:initial}})};
|
||||
}
|
||||
const active=tab==='center'?'history':tab,paths:Record<string,string>={history:'playlet/history/list',favorites:'playlet/collect/list',likes:'playlet/like/list',purchased:'playlet/bought/list'};
|
||||
const list=tab==='settings'?undefined:accountList(await contentRequest<PageData<Drama>>(paths[active],query,true));
|
||||
const initial={user:session.user,list};
|
||||
const shared=await getSiteSharedData();
|
||||
return {totalCount:list?.totalCount,pageSize:24,view:infoView(address,tab,title,memberMarkup(session.user,tab,list,{advitise:shared.config.advitise},route.page||1,pageHref),{session,info:{key:tab,member:initial}})};
|
||||
}catch{return {failed:true,view:infoView(address,route.key,title,empty('页面暂不可用,请稍后重试',true),{session})};}
|
||||
}
|
||||
if(route.group==='richtext'){
|
||||
const query=new URLSearchParams({titles:route.key});
|
||||
const result=await upstream<RichTextListResponse>(`config/richtext/list?${query}`,{timeoutMs:5000});
|
||||
@@ -159,7 +128,6 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
|
||||
return {title:heading,description:description||undefined,indexable:!!body,view:infoView(address,'richtext',heading,html)};
|
||||
}
|
||||
if(route.group==='info'){
|
||||
if(route.key==='feedback')return {view:infoView(address,'feedback','意见反馈',feedbackMarkup(),{info:{key:'feedback',feedback:true}})};
|
||||
const configuration=await getSeoConfig();
|
||||
const footer=route.key==='about-us'?await getFooterInfo():undefined;
|
||||
const info=getInfoPage(route.key,configuration.config,footer?.footerInfo?.contactList||[]);
|
||||
@@ -173,7 +141,6 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
|
||||
return {totalCount:total,pageSize,view:{panel:'search',title:'搜索结果',html:searchResultsMarkup(results,value=>paginationPath(address,value)),bootstrap:{path:address,panel:'search',title:'搜索结果',search:{keyword,total,page,pageSize}}}};
|
||||
}
|
||||
if(['detail','play'].includes(route.group)){
|
||||
const commentSort=params.get('commentSort')==='1'?1:2;
|
||||
const detailRequest=contentRequest<DramaDetail>('playlet/detail',contentQuery(route.key),true);
|
||||
const playletQuery=contentQuery(route.key,'playletId','playletSlug');
|
||||
// Slug-aware chapter reads stay parallel. Only ID-only endpoints wait for slug resolution.
|
||||
@@ -181,13 +148,12 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
|
||||
if(!matchesContentKey(detail,route.key)||!Number.isSafeInteger(detail.id)||detail.id<=0)throw new ApiError('剧集不存在',404);
|
||||
return detail.id;
|
||||
});
|
||||
const [detail,chapterResult,chapters,comments,configuration,recommendations]=await Promise.all([
|
||||
const [detail,chapterResult,chapters,configuration,recommendations]=await Promise.all([
|
||||
detailRequest,
|
||||
route.group==='play'?contentRequest<Chapter>('playlet/chapter/detail',{...playletQuery,chapterId:route.value!},true):Promise.resolve(undefined),
|
||||
contentRequest<{list?:Chapter[]}>('playlet/chapter/list',playletQuery,true).then(data=>data.list).catch(()=>undefined),
|
||||
resolvedId.then(objectId=>contentRequest<PageData<Comment>>('comment/list',{objectId,objectType:10,pageNum:route.page||1,pageSize:10,sortOrder:commentSort},true)).catch(()=>({list:[],totalCount:0,error:'评论暂不可用,请稍后重试'})),
|
||||
getSeoConfig(),
|
||||
resolvedId.then(playletId=>contentRequest<PageData<Drama>>('playlet/guess-like/list',{playletId,pageNum:1,pageSize:6})).catch(()=>undefined),
|
||||
resolvedId.then(playletId=>contentRequest<PageData<Drama>>('playlet/guess-like/list',{playletId,pageNum:1,pageSize:RECOMMEND_PAGE_SIZE})).catch(()=>undefined),
|
||||
]);
|
||||
let chapter:Chapter|undefined;
|
||||
if(route.group==='play'){
|
||||
@@ -198,23 +164,19 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
|
||||
const content=dramaSeoContent(detail,chapter);
|
||||
if(!matchesContentKey(detail,route.key))return {...content,missing:true};
|
||||
if(!content.title)return {failed:true};
|
||||
if(comments&&!comments.error){content.totalCount=comments.totalCount;content.pageSize=10;}
|
||||
if(comments?.error&&(route.page||1)>1)content.failed=true;
|
||||
const ordered=orderedChapters(chapters??detail.playletChapterInfo);
|
||||
detail.relatedRecommendData=recommendations?.list||[];
|
||||
const initial=playerInitialData(detail,ordered,chapter||null,comments);
|
||||
const initial=playerInitialData(detail,ordered,chapter||null);
|
||||
const title=content.title||'剧集详情',overview=route.group==='detail';
|
||||
const pageHref=(page:number,sort:number)=>paginationPath(contentPath(overview?'detail':'play',detail,chapter?.id)+search,page,{commentSort:sort===2?null:sort});
|
||||
const session=await preparedSession();
|
||||
return {...content,view:{panel:'player',title,overview,html:playerMarkup(initial.detail,initial.chapters,{chapter:initial.chapter,isVideo:false,page:route.page||1,commentSort,comments:initial.comments,config:configuration.config,user:session.user,pageHref}),bootstrap:{path:address,panel:'player',title,player:initial}}};
|
||||
return {...content,view:{panel:'player',title,overview,html:playerMarkup(initial.detail,initial.chapters,{chapter:initial.chapter,isVideo:false,config:configuration.config}),bootstrap:{path:address,panel:'player',title,player:initial}}};
|
||||
}
|
||||
if(route.group!=='page')return {};
|
||||
const api:Pick<ApiClient,'get'>={get:(endpoint,values={})=>contentRequest(endpoint,Object.fromEntries(Object.entries(values).filter(([,value])=>value!==undefined).map(([key,value])=>[key,String(value)])))};
|
||||
if(['apps','home-road'].includes(route.key)){
|
||||
const title=route.key==='apps'?'福利应用':'回家的路';
|
||||
const configuration=route.key==='home-road'?await getSeoConfig():undefined;
|
||||
if(route.key==='home-road'){
|
||||
const title='回家的路';
|
||||
const configuration=await getSeoConfig();
|
||||
if(configuration?.error)throw new Error('入口配置暂不可用');
|
||||
const html=route.key==='apps'?renderAppDirectory(await loadAppCategories(api,new AbortController().signal)):homeRoad(configuration!.config);
|
||||
const html=homeRoad(configuration.config);
|
||||
return {view:{panel:'channel',channel:route.key,title,html,bootstrap:{path:address,panel:'channel',title}}};
|
||||
}
|
||||
const [data,home,tags]=await Promise.all([
|
||||
@@ -233,8 +195,8 @@ const loadContent=cache(async (pathname:string,search:string):Promise<SeoContent
|
||||
return {...listSeoContent(data,route,home?.data),view:{panel:'channel',channel:route.key,...rendered,bootstrap:{path:address,panel:'channel',title:rendered.title,subtitle:rendered.subtitle}}};
|
||||
}catch(error){
|
||||
if(error instanceof ApiError&&(error.code===401||EXPIRED_CODES.has(error.code))&&(await preparedSession()).state==='login-required'){
|
||||
const title='登录后继续访问';
|
||||
return {loginRequired:true,view:{panel:'channel',title,html:empty('请先登录账号')+'<button type="button" class="primary" data-auth-mode="login">登录账号</button>'}};
|
||||
const title='内容暂不可用';
|
||||
return {loginRequired:true,view:{panel:'channel',title,html:empty('内容暂不可用,请稍后重试',true)}};
|
||||
}
|
||||
const resource=route.group==='detail'||route.group==='play'||route.group==='page'&&['theme','tag','config'].includes(route.key)||route.group==='page'&&route.key==='topics'&&!!route.value;
|
||||
// The current service uses 5009 ("无数据") for missing short dramas, episodes and topics.
|
||||
|
||||
+16
-18
@@ -1,5 +1,6 @@
|
||||
import type { Chapter, Config, DramaDetail, HomeData, NavigationCategory, PageData } from './api/types';
|
||||
import type { InfoPageDefinition } from './info-pages';
|
||||
import { SITE_NAME, brandText } from './brand';
|
||||
import { chapterAccess, chapterRequiresPayment } from './client/playback';
|
||||
import { contentPath, getRankPage, navigationSection, paginationPath, parseSitePath, supportsPagination, type ContentIdentity, type SiteRoute } from './routing';
|
||||
import { buildStructuredData, dramaSchemaData, faqFromHtml, homeSchemaItems, listSchemaItems, schemaUrl, type SchemaGraph, type SchemaDrama, type SchemaItem, type SchemaQuestion } from './structured-data';
|
||||
@@ -63,7 +64,7 @@ export function resolveSeoEnvironment(config:Config,categories:NavigationCategor
|
||||
const current=seoEnvironment(config,categories??previous?.categories??[]);
|
||||
if(!current.origin&&previous?.origin)return previous;
|
||||
return {...current,
|
||||
site:mergeSeo(current.site,previous?.site||normalizeSeo({siteName:'禁果短剧'})),
|
||||
site:mergeSeo(current.site,previous?.site||normalizeSeo({siteName:SITE_NAME})),
|
||||
navigation:mergeSeo(current.navigation,previous?.navigation||normalizeSeo({})),
|
||||
};
|
||||
}
|
||||
@@ -73,21 +74,18 @@ export function clientSeoEnvironment(current:SeoEnvironment,config:Config):SeoEn
|
||||
return current.origin?current:resolveSeoEnvironment(config,current.categories,current);
|
||||
}
|
||||
|
||||
const infoTitles:Record<string,string>={'about-us':'关于我们','user-agreement':'用户协议','privacy-policy':'隐私政策','help-center':'常见问题','2257':'2257',dmca:'DMCA',feedback:'意见反馈','user-center':'个人中心',settings:'账号设置',deposit:'金币充值'};
|
||||
const labels:Record<string,string>={shorts:'短剧',topics:'专题推荐',rank:'热播榜',apps:'福利应用','home-road':'回家的路',theme:'题材短剧',tag:'标签短剧',config:'更多好剧',center:'个人中心',favorites:'我的收藏',history:'观看历史',likes:'我的点赞',purchased:'已购剧集',settings:'账号设置'};
|
||||
const infoTitles:Record<string,string>={'about-us':'关于我们','user-agreement':'用户协议','privacy-policy':'隐私政策','help-center':'常见问题','2257':'2257',dmca:'DMCA'};
|
||||
const labels:Record<string,string>={shorts:'短剧',topics:'专题推荐',rank:'热播榜','home-road':'回家的路',theme:'题材短剧',tag:'标签短剧',config:'更多好剧'};
|
||||
const descriptions:Record<string,string>={
|
||||
shorts:'精选短剧与最新更新,都市甜宠、古装仙侠、悬疑逆袭,发现值得追的精彩故事。',
|
||||
topics:'精选短剧专题与主题合集,按专题发现值得追的好剧。',
|
||||
rank:'查看热播榜、新剧榜和追剧榜,发现大家正在看的热门短剧。',
|
||||
'about-us':'了解禁果短剧的品牌介绍、平台服务与联系方式。',
|
||||
'user-agreement':'了解禁果短剧的用户协议、使用条款与服务规则。',
|
||||
'privacy-policy':'了解禁果短剧如何收集、使用及保护个人信息。',
|
||||
'help-center':'查阅禁果短剧的观看、账户与服务常见问题。',
|
||||
'2257':'禁果短剧的2257合规说明与相关信息。',dmca:'禁果短剧的版权保护政策与DMCA通知说明。',
|
||||
'about-us':'了解黄果短剧的品牌介绍、平台服务与商务合作方式。',
|
||||
'user-agreement':'了解黄果短剧的用户协议、使用条款与服务规则。',
|
||||
'privacy-policy':'了解黄果短剧如何收集、使用及保护个人信息。',
|
||||
'help-center':'查阅黄果短剧的观看、账户与服务常见问题。',
|
||||
'2257':'黄果短剧的2257合规说明与相关信息。',dmca:'黄果短剧的版权保护政策与DMCA通知说明。',
|
||||
};
|
||||
function privateRoute(route:SiteRoute):boolean {
|
||||
return route.group==='member'||route.group==='info'&&['user-center','settings','deposit','feedback'].includes(route.key);
|
||||
}
|
||||
function canonicalPath(path:string):string {
|
||||
if(path==='/')return '/';
|
||||
return path.replace(/\/page\/1\/?$/,'/').replace(/\/?$/,'/');
|
||||
@@ -161,7 +159,7 @@ export function pageResponseStatus(pathname:string,content:SeoContent={}):200|40
|
||||
export function buildPageHead(pathname:string,search:string,environment:SeoEnvironment,content:SeoContent={}):PageHead {
|
||||
const navigation=pathname.replace(/\/$/,'')==='/navigation';
|
||||
const route=navigation?{group:'navigation',key:''}:parseSitePath(pathname,search);
|
||||
const siteName=first(environment.site.siteName,navigation?environment.navigation.siteName:'',normalizeSeo(content.seoInfo).siteName,'禁果短剧');
|
||||
const siteName=brandText(first(environment.site.siteName,navigation?environment.navigation.siteName:'',normalizeSeo(content.seoInfo).siteName))||SITE_NAME;
|
||||
const page=route?.page||1;
|
||||
const params=new URLSearchParams(search);
|
||||
const sort=Number(params.get('sort')||1);
|
||||
@@ -184,13 +182,13 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
|
||||
if(fallback)seo=mergeSeo(seo,fallback);
|
||||
}
|
||||
const ranking=route?.group==='page'&&route.key==='rank'?getRankPage(route.value):undefined;
|
||||
const pageTitle=!route?'页面不存在':!route.group?`${siteName} · 精品原创 · 日更爽看`:navigation?'官方入口':route.group==='search'?(route.key.trim()?`${route.key.trim()} · 搜索结果`:'搜索短剧'):route.group==='info'?(route.key==='deposit'&&route.value==='vip'?'会员畅享':content.title||infoTitles[route.key]):isContent?(content.title||'剧集详情'):ranking?.title||content.title||labels[route.key]||'内容列表';
|
||||
let title=seo.title||pageTitle||siteName;
|
||||
const pageTitle=!route?'页面不存在':!route.group?`${siteName} · 精品原创 · 日更爽看`:navigation?'官方入口':route.group==='search'?(route.key.trim()?`${route.key.trim()} · 搜索结果`:'搜索短剧'):route.group==='info'?(content.title||infoTitles[route.key]):isContent?(content.title||'剧集详情'):ranking?.title||content.title||labels[route.key]||'内容列表';
|
||||
let title=brandText(seo.title)||pageTitle||siteName;
|
||||
const episode=route?.group==='play'&&content.episode?`第${content.episode}集`:'';
|
||||
if(episode&&!title.includes(episode))title+=` - ${episode}`;
|
||||
let description=seo.description||content.description||(!route?.group?`${siteName},发现值得一看的短剧。都市甜宠、古装仙侠、悬疑逆袭,精彩故事随心看。`:isContent?`在${siteName}查看《${content.title||'精选剧集'}》的剧集介绍、更新与观看信息。`:(descriptions[route.key]||`${pageTitle},${siteName}为你提供最新内容与相关服务。`).replaceAll('禁果短剧',siteName));
|
||||
let description=brandText(seo.description)||content.description||(!route?.group?`${siteName},发现值得一看的短剧。都市甜宠、古装仙侠、悬疑逆袭,精彩故事随心看。`:isContent?`在${siteName}查看《${content.title||'精选剧集'}》的剧集介绍、更新与观看信息。`:(descriptions[route.key]||`${pageTitle},${siteName}为你提供最新内容与相关服务。`).replaceAll('黄果短剧',siteName));
|
||||
if(episode&&!description.includes(episode))description+=` - ${episode}`;
|
||||
let keywords=seo.keywords.length?seo.keywords:[...new Set([content.title||pageTitle,...(content.keywords||[]),'在线短剧',siteName].filter(Boolean))];
|
||||
let keywords=seo.keywords.length?seo.keywords.map(brandText):[...new Set([content.title||pageTitle,...(content.keywords||[]),'在线短剧',siteName].filter(Boolean))];
|
||||
if(ranking){
|
||||
if(!title.includes(ranking.title))title+=` - ${ranking.title}`;
|
||||
if(!description.includes(ranking.title))description+=` - ${ranking.title}`;
|
||||
@@ -213,7 +211,7 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
|
||||
if(!description.endsWith(` | ${siteName}`))description+=` | ${siteName}`;
|
||||
}else if(!navApplied&&!title.includes(siteName))title+=` - ${siteName}`;
|
||||
|
||||
let indexable=!!route&&(!route.group||route.group==='page'&&!['apps','home-road'].includes(route.key)||isContent||route.group==='info'&&!!descriptions[route.key]||route.group==='richtext'&&content.indexable===true);
|
||||
let indexable=!!route&&(!route.group||route.group==='page'&&route.key!=='home-road'||isContent||route.group==='info'&&!!descriptions[route.key]||route.group==='richtext'&&content.indexable===true);
|
||||
if(route?.group==='page'&&!(route.key==='shorts'?[1,2,3,4]:[1,2,3]).includes(sort))indexable=false;
|
||||
if(route?.group==='page'&&sort!==1&&route.key!=='rank'&&route.key!=='config')indexable=false;
|
||||
if(route?.group==='page'&&route.key==='theme'&¶ms.has('updateStatus')&¶ms.get('updateStatus')!=='0')indexable=false;
|
||||
@@ -227,7 +225,7 @@ export function buildPageHead(pathname:string,search:string,environment:SeoEnvir
|
||||
const outOfRange=totalPages!==undefined&&page>totalPages;
|
||||
if(route?.group==='page'&&totalCount===0)indexable=false;
|
||||
if(content.failed||content.missing||content.unavailable||content.loginRequired||outOfRange)indexable=false;
|
||||
const robots=indexable?'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1':route&&privateRoute(route)?'noindex,nofollow':'noindex,follow';
|
||||
const robots=indexable?'index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1':'noindex,follow';
|
||||
const origin=environment.origin;
|
||||
const absolute=(path:string)=>origin?new URL(path,origin).href:'';
|
||||
const canonical=indexable?absolute(canonicalPagePath(pathname,content)):'';
|
||||
|
||||
@@ -6,8 +6,11 @@ export interface NavigationEntry { id:string; name:string; href:string; key:stri
|
||||
export type NavigationSurface='top'|'sidebar'|'mobile';
|
||||
|
||||
// Resolve API navigation values directly to native routes; these are link rules, not redirects.
|
||||
const aliases:Record<string,string>={'/hot':'/rank/hot/','/home':'/','/playlet':'/shorts/','/list':'/shorts/','/topic':'/topics/','/theme':'/topics/','/application':'/apps/'};
|
||||
const emptyPaths:Record<string,string>={'首页':'/','回家的路':'/home-road/','福利应用':'/apps/'};
|
||||
const aliases:Record<string,string>={'/hot':'/rank/hot/','/home':'/','/playlet':'/shorts/','/list':'/shorts/','/topic':'/topics/','/theme':'/topics/'};
|
||||
const emptyPaths:Record<string,string>={'首页':'/','回家的路':'/home-road/'};
|
||||
/** Removed features (welfare app directory) stay absent even if backend navigation still links to them. */
|
||||
const REMOVED_NAV_PATH=/^\/(?:apps|application)(?:\/|$)/i;
|
||||
const REMOVED_NAV_NAME=/福利应用/;
|
||||
|
||||
function destination(value:unknown,name:string,origin:string):{href:string;external:boolean}|undefined {
|
||||
const raw=typeof value==='string'?value.trim():'';
|
||||
@@ -20,15 +23,28 @@ function destination(value:unknown,name:string,origin:string):{href:string;exter
|
||||
if(url.hash)return;
|
||||
url.pathname=aliases[url.pathname.replace(/\/$/,'')]||url.pathname;
|
||||
// Removed standalone video pages stay absent even if stale configuration still contains them.
|
||||
if(/^\/(?:series|video)(?:\/|$)/.test(url.pathname))return;
|
||||
if(/^\/(?:series|video)(?:\/|$)/.test(url.pathname)||REMOVED_NAV_PATH.test(url.pathname))return;
|
||||
const route=parseSitePath(url.pathname);
|
||||
const path=route&&url.pathname!=='/'?url.pathname.replace(/\/?$/,'/'):url.pathname;
|
||||
return {href:path+url.search,external:false};
|
||||
}
|
||||
|
||||
function removedNavigationItem(item:NavigationCategory):boolean {
|
||||
const name=typeof item?.name==='string'?item.name.trim():'';
|
||||
let path=typeof item?.urlValue==='string'?item.urlValue.trim():'';
|
||||
try{path=new URL(path,'https://site.invalid').pathname;}catch{/* keep the raw value */}
|
||||
return REMOVED_NAV_NAME.test(name)||REMOVED_NAV_PATH.test(path);
|
||||
}
|
||||
|
||||
/** Drop removed-feature entries from the backend payload before it is rendered or serialized into the page. */
|
||||
export function withoutRemovedNavigation(data:HeaderNavigation):HeaderNavigation {
|
||||
const clean=(list:unknown):NavigationCategory[]=>recordList<NavigationCategory>(list).filter(item=>!removedNavigationItem(item)).map(item=>Array.isArray(item.translationList)?{...item,translationList:clean(item.translationList)}:item);
|
||||
return {...data,categoryList:clean(data?.categoryList)};
|
||||
}
|
||||
|
||||
export function navigationEntries(data:HeaderNavigation,origin=''):NavigationEntry[] {
|
||||
const make=(item:NavigationCategory,index:number,parent=''):NavigationEntry|undefined=>{
|
||||
const name=typeof item.name==='string'?item.name.trim():'';if(!name)return;
|
||||
const name=typeof item.name==='string'?item.name.trim():'';if(!name||REMOVED_NAV_NAME.test(name))return;
|
||||
const id=parent+String(item.id??index),target=destination(item.urlValue,name,origin);
|
||||
const children=recordList<NavigationCategory>(item.translationList).flatMap((child,index)=>{
|
||||
const entry=make({...child,translationList:undefined},index,id+'-');return entry?[entry]:[];
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import type { Config, FooterInfoResponse, FooterNavItem, Advertisement } from './api/types';
|
||||
import { recordList } from './api/protocol';
|
||||
import type { SiteSharedData } from './page-data';
|
||||
import { footerLinkHref, removedFooterLink } from './footer-links';
|
||||
|
||||
function footerNavItem(item: FooterNavItem): FooterNavItem {
|
||||
const output: FooterNavItem = {};
|
||||
if (typeof item.id === 'number' && Number.isSafeInteger(item.id)) output.id = item.id;
|
||||
if (typeof item.name === 'string') output.name = item.name.slice(0, 200);
|
||||
if (typeof item.urlValue === 'string') output.urlValue = item.urlValue.slice(0, 2048);
|
||||
const children = recordList<FooterNavItem>(item.translationList).map(footerNavItem).filter(child => child.name || child.urlValue);
|
||||
const children = recordList<FooterNavItem>(item.translationList).filter(keptFooterItem).map(footerNavItem).filter(child => child.name || child.urlValue);
|
||||
if (children.length) output.translationList = children;
|
||||
return output;
|
||||
}
|
||||
|
||||
function footerNavList(value: unknown): FooterNavItem[] {
|
||||
return recordList<FooterNavItem>(value).map(footerNavItem).filter(item => item.name || item.urlValue || item.translationList?.length);
|
||||
/** Links to removed pages (user center, recharge, feedback) and email links never reach the page. */
|
||||
function keptFooterItem(item: FooterNavItem): boolean {
|
||||
const name = item.name?.trim() || '', raw = item.urlValue?.trim() || '';
|
||||
if (/^mailto:/i.test(raw)) return false;
|
||||
return !removedFooterLink(name, raw ? footerLinkHref(raw) : '');
|
||||
}
|
||||
|
||||
/** Only fields used by visible site controls enter the client snapshot. SDK config stays server-side. */
|
||||
function footerNavList(value: unknown): FooterNavItem[] {
|
||||
return recordList<FooterNavItem>(value).filter(keptFooterItem).map(footerNavItem).filter(item => item.name || item.urlValue || item.translationList?.length);
|
||||
}
|
||||
|
||||
/** Only fields used by visible site controls enter the client snapshot. Contacts and SDK settings never enter the page. */
|
||||
export function publicSiteData(source:Config,footer:FooterInfoResponse={}):SiteSharedData {
|
||||
const section=(value:Record<string,unknown>|undefined)=>Object.fromEntries([
|
||||
'foreverUrlAddr','newUrlAddr','newUrlAddr1','newUrlAddr2','tgGroup','telegram','telegramAdd','twitterAddr','appAddr','officialAppAddr','customerServiceHost','emailAddr','email',
|
||||
'foreverUrlAddr','newUrlAddr','newUrlAddr1','newUrlAddr2','tgGroup','appAddr','officialAppAddr',
|
||||
].filter(key=>typeof value?.[key]==='string').map(key=>[key,String(value![key]).slice(0,4096)]));
|
||||
const advitise=Object.fromEntries(['dialogList','nineGridList','detailTopList','detailbannerList','bottomBanner'].map(key=>[key,recordList<Advertisement>(source.advitise?.[key]).map(item=>{
|
||||
const output:Record<string,unknown>={};
|
||||
@@ -29,7 +37,6 @@ export function publicSiteData(source:Config,footer:FooterInfoResponse={}):SiteS
|
||||
})]));
|
||||
return {
|
||||
config:{config:section(source.config),newAddressInfo:section(source.newAddressInfo),advitise,navPageList:recordList<{line:number;url:string}>(source.navPageList).map(item=>({line:Number(item.line)||0,url:typeof item.url==='string'?item.url:''}))},
|
||||
footer:{footerInfo:{brand:{title:typeof footer.footerInfo?.brand?.title==='string'?footer.footerInfo.brand.title.slice(0,200):'',content:typeof footer.footerInfo?.brand?.content==='string'?footer.footerInfo.brand.content:''},contactList:recordList<{name:string;value:string}>(footer.footerInfo?.contactList).map(item=>({name:typeof item.name==='string'?item.name:'',value:typeof item.value==='string'?item.value:''})),navList:footerNavList(footer.footerInfo?.navList)}},
|
||||
trackerConfigured:typeof source.reportSdkConfig==='string'&&!!source.reportSdkConfig.trim(),
|
||||
footer:{footerInfo:{brand:{title:typeof footer.footerInfo?.brand?.title==='string'?footer.footerInfo.brand.title.slice(0,200):'',content:typeof footer.footerInfo?.brand?.content==='string'?footer.footerInfo.brand.content:''},contactList:[],navList:footerNavList(footer.footerInfo?.navList)}},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { Chapter, DramaDetail } from './api/types';
|
||||
|
||||
export const TRACKER_APP_ID=process.env.NEXT_PUBLIC_TRACKER_APP_ID||'1050';
|
||||
export const SDK_COMMON_KEYS=['sid','device_id','device','device_fingerprint','fp_version','app_version'] as const;
|
||||
export const COMMON_KEYS=['app_id','uid','channel','sid','client_ts','device','device_id','device_brand','device_model','device_fingerprint','fp_version','user_agent','sdk_version','app_version'] as const;
|
||||
export const SDK_FIELD_KEYS=[...COMMON_KEYS,'system_name','system_version'] as const;
|
||||
export const COMMON_FIELD_LIMITS:Record<string,number>={app_id:50,uid:32,channel:50,sid:50,device:30,device_id:50,device_brand:100,device_model:100,device_fingerprint:128,fp_version:128,user_agent:4096,sdk_version:128,app_version:128,system_name:100,system_version:100};
|
||||
export const SERVER_EVENTS:Record<string,string>={
|
||||
'playlet/like/set':'drama_like','playlet/chapter/like/set':'drama_like',
|
||||
'playlet/collect/set':'drama_collect','playlet/chapter/collect/set':'drama_collect',
|
||||
'comment/send':'drama_comment','playlet/chapter/unlock':'drama_purchase',
|
||||
};
|
||||
export type Fields=Record<string,string|number>;
|
||||
export interface TrackingPage {key:string;name:string}
|
||||
export const trackingText=(value:unknown)=>typeof value==='string'||typeof value==='number'?String(value).trim():'';
|
||||
|
||||
export function trackingPage(path:string):TrackingPage {
|
||||
const parts=path.split('/').filter(Boolean),key=parts[0]||'home';
|
||||
if(key==='rank')return {key:'rank_'+(parts[1]||'hot'),name:({'hot':'热播榜',new:'新剧榜',follow:'追剧榜'} as Record<string,string>)[parts[1]||'hot']||'排行榜'};
|
||||
if(key==='topics'&&parts[1]&&parts[1]!=='page')return {key:'topic_detail',name:'专题详情'};
|
||||
if(key==='richtext'||key==='richetext')return {key:'rich_text',name:'富文本页'};
|
||||
const slug=['info','member'].includes(key)?parts[1]:key;
|
||||
const names:Record<string,string>={home:'首页',shorts:'短剧列表',series:'剧集列表',topics:'专题推荐',theme:'题材短剧',tag:'标签短剧',config:'更多好剧',detail:'短剧详情',play:'短剧播放页',video:'剧集播放页',search:'搜索结果页',navigation:'官方入口',apps:'福利应用','home-road':'回家的路',center:'个人中心','user-center':'个人中心',settings:'账号设置',favorites:'我的收藏',history:'观看历史',likes:'我的点赞',purchased:'已购剧集',deposit:'充值中心','about-us':'关于我们','privacy-policy':'隐私政策','user-agreement':'用户协议','help-center':'常见问题',feedback:'意见反馈','2257':'2257合规声明',dmca:'DMCA版权声明'};
|
||||
return {key:names[slug]?slug.replace(/-/g,'_'):'internal_other',name:names[slug]||'站内其他页面'};
|
||||
}
|
||||
|
||||
export function dramaContext(drama:DramaDetail,chapter?:Chapter|null):Fields {
|
||||
return {media_id:trackingText(chapter?.mediaId||drama.mediaId),drama_id:String(drama.id),drama_title:drama.playName||drama.title||'未命名短剧',episode:Math.max(1,chapter?.currentEpisode||1),total_episode:Math.max(1,drama.totalEpisode||drama.playletChapterInfo?.length||1),video_id:chapter?String(chapter.id):'',drama_type_id:trackingText(drama.themeIds?.[0])||'default',drama_type_name:drama.themeNames?.[0]||'默认分类',drama_content_type:'short_drama',recommend_trace_id:trackingText(drama.recommendTraceId),drama_tag_key:drama.tagIds?.join(',')||'default',drama_tag_name:drama.tagNames?.join(',')||'默认标签',drama_duration:Math.max(0,chapter?.mediaTime||0)};
|
||||
}
|
||||
|
||||
export function base64JSON(value:unknown,url=false):string {
|
||||
const bytes=new TextEncoder().encode(JSON.stringify(value));
|
||||
const encoded=btoa(Array.from(bytes,byte=>String.fromCharCode(byte)).join(''));
|
||||
return url?encoded.replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,''):encoded;
|
||||
}
|
||||
Reference in New Issue
Block a user