Files
谢宇宁andClaude Opus 5.5 d2ddb1c321 [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>
2026-10-16 11:33:31 +07:00

94 lines
5.8 KiB
TypeScript

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { dirname, resolve } from 'node:path';
import { brandAssets } from '../src/lib/brand-assets';
// User-provided design. Credentials are read only from the local environment.
const fileKey = '1vUq4wf5xOs8ZOlmt26Q3O';
const token = process.env.FIGMA_TOKEN;
const assets = [
{ id: '1:414', name: 'header', file: 'public/static/brand/header.svg', width: 104, height: 36 },
{ id: '1:706', name: 'footer', file: 'public/static/brand/footer.svg', width: 124, height: 44 },
{ id: '3:20211', name: 'mobile', file: 'public/static/brand/mobile.svg', width: 88, height: 28 },
{ id: '3:15722', name: 'symbol', file: 'public/static/favicon.svg', width: 34, height: 34 },
] as const;
const manifestFile = resolve('public/static/brand/manifest.json');
const pngAssets = [
{ id: '1:414', name: 'header-png', file: 'public/static/logo.png', width: 208, height: 72 },
{ id: '1:706', name: 'footer-png', file: 'public/static/logo2.png', width: 248, height: 88 },
{ id: '3:21847', name: 'login-symbol', file: 'public/static/logo_icon.png', width: 128, height: 128 },
{ id: '3:15722', name: 'favicon-png', file: 'public/static/favicon.png', width: 68, height: 68 },
{ id: '3:15722', name: 'favicon-ico', file: 'public/static/favicon.ico', width: 68, height: 68 },
];
const alias = (asset: typeof pngAssets[number], url: string) => ({
...asset, name: `${asset.name}-versioned`, source: asset.file, file: `public${url}`,
});
const versionedAssets = [
alias(pngAssets[0]!, brandAssets.headerPng),
alias(pngAssets[1]!, brandAssets.navigationLogo),
alias(pngAssets[2]!, brandAssets.authLogo),
alias(assets[3], brandAssets.iconSvg),
alias(pngAssets[3]!, brandAssets.iconPng),
alias(pngAssets[4]!, brandAssets.faviconIco),
];
const sha256 = (bytes: Buffer): string => createHash('sha256').update(bytes).digest('hex');
if (process.argv.includes('--check')) {
const manifest = JSON.parse(await readFile(manifestFile, 'utf8')) as { assets: { file: string; sha256: string; width: number; height: number }[] };
for (const asset of manifest.assets) {
const bytes = await readFile(resolve(asset.file));
if (sha256(bytes) !== asset.sha256) throw new Error(`Brand asset checksum mismatch: ${asset.file}`);
if (asset.file.endsWith('.png') && (bytes.readUInt32BE(16) !== asset.width || bytes.readUInt32BE(20) !== asset.height)) throw new Error(`PNG dimensions mismatch: ${asset.file}`);
}
for (const asset of versionedAssets) {
if (!manifest.assets.some(entry => entry.file === asset.file)) throw new Error(`Versioned brand asset missing from manifest: ${asset.file}`);
if (!(await readFile(resolve(asset.file))).equals(await readFile(resolve(asset.source)))) throw new Error(`Versioned brand asset differs from source: ${asset.file}`);
}
const ico = await readFile(resolve('public/static/favicon.ico'));
const png = await readFile(resolve('public/static/favicon.png'));
if (!ico.subarray(22).equals(png)) throw new Error('ICO payload differs from exported PNG');
console.log(`Checked ${manifest.assets.length} Figma brand assets offline.`);
process.exit(0);
}
if (!token) throw new Error('FIGMA_TOKEN is required');
async function render(ids: readonly string[], format: 'svg' | 'png', scale = 1): Promise<Record<string, string>> {
const query = new URLSearchParams({ ids: ids.join(','), format, scale: String(scale), svg_outline_text: 'true' });
const response = await fetch(`https://api.figma.com/v1/images/${fileKey}?${query}`, { headers: { 'X-Figma-Token': token! } });
if (!response.ok) throw new Error(`Figma render returned HTTP ${response.status}`);
const result = await response.json() as { images: Record<string, string | null> };
for (const id of ids) if (!result.images[id]) throw new Error(`Figma returned no image for ${id}`);
return result.images as Record<string, string>;
}
async function download(url: string, file: string): Promise<Buffer> {
const response = await fetch(url);
if (!response.ok) throw new Error(`Asset download returned HTTP ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
if (!bytes.length) throw new Error('Empty Figma asset');
await mkdir(dirname(resolve(file)), { recursive: true });
await writeFile(resolve(file), bytes);
return bytes;
}
const svg = await render(assets.map(asset => asset.id), 'svg');
for (const asset of assets) await download(svg[asset.id], asset.file);
const png = await render(['1:414', '1:706', '3:21847', '3:15722'], 'png', 2);
await download(png['1:414'], 'public/static/logo.png');
await download(png['1:706'], 'public/static/logo2.png');
await download(png['3:21847'], 'public/static/logo_icon.png');
const icon = await download(png['3:15722'], 'public/static/favicon.png');
// ICO supports an unmodified PNG payload; no redraw, recoloring or raster resampling.
const icoHeader = Buffer.alloc(22);
icoHeader.writeUInt16LE(1, 2); icoHeader.writeUInt16LE(1, 4);
icoHeader[6] = 68; icoHeader[7] = 68;
icoHeader.writeUInt16LE(1, 10); icoHeader.writeUInt16LE(32, 12);
icoHeader.writeUInt32LE(icon.length, 14); icoHeader.writeUInt32LE(22, 18);
await writeFile(resolve('public/static/favicon.ico'), Buffer.concat([icoHeader, icon]));
for (const asset of versionedAssets) {
await mkdir(dirname(resolve(asset.file)), { recursive: true });
await writeFile(resolve(asset.file), await readFile(resolve(asset.source)));
}
const manifestAssets = await Promise.all([...assets, ...pngAssets, ...versionedAssets].map(async asset => ({ ...asset, sha256: sha256(await readFile(resolve(asset.file))) })));
await writeFile(manifestFile, JSON.stringify({ fileKey, source: `https://www.figma.com/design/${fileKey}/?node-id=0-1`, assets: manifestAssets }, null, 2) + '\n');
console.log(`Downloaded ${assets.length} SVG brand assets and matching PNG/ICO fallbacks from Figma; generated ${versionedAssets.length} versioned aliases.`);