[Ai] feat: 花魁短剧 Next.js 站点初始提交

- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-16 11:33:31 +07:00
co-authored by Claude Opus 5.5
commit d2ddb1c321
423 changed files with 52152 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createContext } from '../src/server/context';
import { buildPage, matchPageRoute } from '../src/server/pages';
import { list } from '../src/server/pages/catalog';
import { pagination } from '../src/server/pages/pagination';
import { pageRoutes } from '../src/server/pages/routes';
import { play } from '../src/server/pages/playback';
import { applyLegacyContract } from '../src/server/services/contracts';
import { choose, normalizePayload, playURL, positive, profileImage, videoURL } from '../src/server/services/model';
const ctx = (path: string) => {
const context=createContext(new Request('http://localhost'+path,{headers:{cookie:'token=test-session; isRegister=1'}}));
context.user={userId:7,accountNo:'member',recharge:12345,isRegister:true,type:1};context.state.set('auth:fresh',true);return context;
};
test('legacy route families retain exact dynamic parameters and reject repeated slashes',()=>{
const routes:[string,string,Record<string,string>][]=[
['/','Index',{}],['/list/drama/2/3/page/4/','List',{theme:'drama',updateStatus:'2',sortType:'3',page:'4'}],
['/series/anime/12/2/page/3/','Series',{media:'anime',topic:'12',sortType:'2',page:'3'}],
['/more-list/recommend/1/drama/page/2/','MoreList',{configId:'recommend',timeRange:'1',theme:'drama',page:'2'}],
['/more-series-list/recommend/page/2/','MoreSeriesList',{configId:'recommend',page:'2'}],
['/topic/sort/2/page/3/','Topic',{sortType:'2',page:'3'}],
['/topic-detail/title/sort/3/page/2/','TopicDetail',{slug:'title',sortType:'3',page:'2'}],
['/tags/tag/sort/2/page/4/','Tags',{slug:'tag',sortType:'2',page:'4'}],
['/search/%E7%9F%AD%E5%89%A7/page/2/','Search',{q:'短剧',page:'2'}],
['/play/drama/100/recommendations/page/2/','Play',{slug:'drama',ep:'100',guessPage:'2'}],
['/series-play/video/recommendations/page/2/','SeriesPlay',{slug:'video',guessPage:'2'}],
['/popular-recommend/3/page/2/','PopularRecommend',{rankType:'3',page:'2'}],
['/rank/2/page/3/','Rank',{sortOrder:'2',page:'3'}],['/recently-added/page/3/','RecentlyAdded',{page:'3'}],
];
for(const [path,handler,params]of routes)assert.deepEqual(matchPageRoute(path),{handler,params},path);
for(const path of ['/favorites/chapter/page/3/','/history/page/2/','/purchased/page/2/','/profile/','/setting/','/recharge/','/login/','/register/'])assert.equal(matchPageRoute(path),null,path);
for(const path of ['/list//','//list/','/settings//','/list/theme/extra/','/list/drama/2/3/page/2/extra/','/series/anime/'])assert.equal(matchPageRoute(path),null,path);
});
test('the 58 surviving original public HTML route registrations resolve with unchanged parameter names',()=>{
const removedAccountHandlers=new Set(['Profile','History','Favorites','PurchasedSeries','Setting','Recharge','LoginPage','RegisterPage']);
const legacy=JSON.parse(readFileSync(new URL('./fixtures/legacy-page-routes.json',import.meta.url),'utf8')) as [string,string][];
assert.equal(legacy.length,71);const original=legacy.filter(([,handler])=>!removedAccountHandlers.has(handler));assert.equal(original.length,58);
assert.deepEqual(pageRoutes.filter(([,handler])=>handler!=='HomeRoad'),original);assert.equal(pageRoutes.some(([,handler])=>removedAccountHandlers.has(handler)),false);
assert.deepEqual(pageRoutes.filter(([,handler])=>handler==='HomeRoad'),[['/home-road/','HomeRoad']]);
for(const path of ['/home-road/','/return-home/'])assert.deepEqual(matchPageRoute(path),{handler:'HomeRoad',params:{}},path);
const values:Record<string,string>={q:'短剧',theme:'drama',slug:'drama',configId:'recommend',media:'anime',topic:'12',updateStatus:'2',sortType:'3',sortOrder:'2',rankType:'3',timeRange:'1',page:'2',guessPage:'2',ep:'100',filter:'chapter'};
for(const [pattern,handler]of original){const params:Record<string,string>={};const path=pattern.replace(/:(\w+)/g,(_,name)=>{params[name]=values[name];return encodeURIComponent(values[name]);});assert.deepEqual(matchPageRoute(path),{handler,params},pattern);}
});
test('pagination keeps source zero fields and route numbers use decimal integer parsing',()=>{
const small=pagination(n=>'/page/'+n+'/',2,25,0);assert.equal(small.Page,0);assert.equal(small.TotalPages,0);assert.equal(small.Visible,false);assert.equal(small.Items,null);
const many=pagination(n=>'/page/'+n+'/',50,25,250);assert.equal(many.Page,10);assert.equal(many.HasNext,false);assert.equal(many.PrevURL,'/page/9/');assert.equal(many.Items.at(-1).Active,true);
assert.equal(choose('+2',[2,3],1),2);for(const value of ['2.0','2e0','0x2'])assert.equal(choose(value,[2,3],1),1);assert.equal(positive('+2'),2);
});
test('API model aliases preserve JSON fields, latest mutable values and chapter gates',()=>{
const raw=normalizePayload({id:10,videoUrl:'signed-media.m3u8',seoInfo:'{"webTitle":"页面","webKeyworld":"a,b;a"}',payType:1,needUnlock:true,costCoins:25});
assert.equal(raw.ID,10);assert.equal(raw.VideoURL,'signed-media.m3u8');assert.deepEqual(raw.seoInfo.keywords,['a','b']);assert.equal(raw.NeedCoinGate,true);assert.equal(raw.AllowWatch,false);assert.equal(raw.UnlockCost,25);
raw.seoInfo.title='新标题';assert.equal(raw.seoInfo.Title,'新标题');assert.equal(JSON.stringify(raw).includes('"VideoURL"'),false);
});
test('missing upstream fields retain legacy Go zero values rather than blank attributes',()=>{
const detail=normalizePayload(applyLegacyContract('/api/app/playlet/detail',{id:10,playletChapterInfo:[{id:100}]}));
assert.equal(detail.CountLike,0);assert.equal(detail.IsLike,0);assert.equal(detail.Preview,'');assert.equal(detail.ThemeNames,null);assert.equal(detail.PlayletChapterInfo[0].MediaTime,0);assert.equal(detail.PlayletChapterInfo[0].NeedUnlock,false);assert.equal(detail.PlayletChapterInfo[0].AllowWatch,true);
});
test('list and playback page loaders preserve filters, canonical paths, locked media and tracking',async()=>{
const originalFetch=globalThis.fetch,originalUpstream=process.env.UPSTREAM_API_URL;process.env.UPSTREAM_API_URL='http://page-contract-test';const calls:{url:URL;headers:Headers}[]=[];
globalThis.fetch=async(input,init)=>{
const url=new URL(String(input));calls.push({url,headers:new Headers(init?.headers)});let data:any={list:[],totalCount:0};
if(url.pathname==='/api/app/theme/list')data={list:[{id:12,slug:'romance',name:'爱情',status:true,rank:1}]};
if(url.pathname==='/api/app/playlet/all/list')data={list:[{id:10,title:'剧'}],totalCount:100,pageNum:Number(url.searchParams.get('pageNum')),pageSize:Number(url.searchParams.get('pageSize')),seoInfo:{title:'全部短剧'}};
if(url.pathname==='/api/app/playlet/detail')data={id:10,slug:'drama',playName:'剧名',desc:'剧情介绍',themeIds:[12],themeNames:['爱情'],tagIds:[1],tagNames:['甜宠'],totalEpisode:2,preview:'preview.m3u8',playletChapterInfo:[{id:101,currentEpisode:1,payType:0},{id:102,currentEpisode:2,payType:1,needUnlock:true,costCoins:20}],seoInfo:{keywords:['剧名']}};
if(url.pathname==='/api/app/playlet/chapter/detail')data={id:102,currentEpisode:2,payType:1,needUnlock:true,canWatch:false,videoUrl:'protected.m3u8',costCoins:20,balance:12345,mediaTime:60};
if(url.pathname==='/api/app/recharge/package/list')data={vipList:[],coinList:[]};
return Response.json({code:200,data});
};
try{
const listed=await list(ctx('/list/12/2/3/page/2/'),{theme:'12',updateStatus:'2',sortType:'3',page:'2'});
assert.equal(listed.themeId,12);assert.equal(listed.themeSlug,'romance');assert.equal(listed.canonicalPath,'/list/romance/2/3/page/2/');assert.equal(listed.pagination.NextURL,'/list/romance/2/3/page/3/');assert.equal(listed.pageHeading,'爱情短剧');
const catalogue=calls.find(call=>call.url.pathname==='/api/app/playlet/all/list')!;assert.equal(catalogue.url.searchParams.get('themeSlug'),'romance');assert.equal(catalogue.headers.has('authorization'),false);
const played=await play(ctx('/play/drama/102/'),{slug:'drama',ep:'102'});assert.ok(played);assert.equal(played.chapterID,102);assert.equal(played.episode,2);assert.equal(played.canWatch,false);assert.equal(played.mainURL,'');assert.equal(played.previewURL,'preview.m3u8');assert.equal(played.balanceText,'12,345');assert.equal(played.seoInfo.title,'剧名 - 第二集');assert.equal(played.canonicalPath,'/drama/drama/');assert.equal(played.trackerDrama.VideoID,102);assert.equal(played.trackerDrama.DramaTypeID,'12');assert.equal(played.comments,undefined);assert.ok(!calls.some(call=>call.url.pathname==='/api/app/comment/list'));
const chapter=calls.find(call=>call.url.pathname==='/api/app/playlet/chapter/detail')!;assert.equal(chapter.url.searchParams.get('chapterId'),'102');assert.equal(chapter.headers.get('authorization'),'test-session');
assert.equal(await play(ctx('/play/drama/999/'),{slug:'drama',ep:'999'}),null);
for(const path of ['/search/?q=legacy','/search/?page=2','/play/drama/?guessPage=2','/favorites/?filter=all','/favorites/invalid/','/history/page/10001/','/settings//'])assert.equal((await buildPage(ctx(path))).status,404,path);
}finally{globalThis.fetch=originalFetch;if(originalUpstream===undefined)delete process.env.UPSTREAM_API_URL;else process.env.UPSTREAM_API_URL=originalUpstream;}
});