- 原生 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>
94 lines
5.8 KiB
TypeScript
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.`);
|