feat(fonts): 添加客户端安全检查和字体数据上下文
refactor(fonts): 重构字体详情和嵌入页面使用FontDataProvider fix(hooks): 添加客户端环境检查防止服务端执行DOM操作 perf(fonts): 优化字体嵌入代码生成逻辑支持多字体选择 chore: 移除不再使用的测试HTML文件 docs: 更新字体操作按钮文本更准确表达功能
This commit is contained in:
parent
1caa9448c0
commit
b1940adc5c
22 changed files with 674 additions and 1113 deletions
|
@ -9,150 +9,138 @@ export const dynamic = 'force-dynamic';
|
|||
/**
|
||||
* Google Fonts 风格的 CSS API - 简化版本(不依赖数据库)
|
||||
* 支持格式:
|
||||
* /api/css?name=FontName:wght@400;700&subset={直接指定变体,优先级大于wght}&lang={是否需要纯中文字符,需要传zh}
|
||||
* /api/css?name=FontName1:wght@{100-900多权重以","或";"分隔}|FontName2:wght@700&subset={直接指定变体,优先级大于wght}&lang=zh
|
||||
* 注意:字体名称大小写严格匹配
|
||||
* /api/css?family=FontName:wght@400;700&subset={直接指定变体,优先级大于wght}&lang={是否需要纯中文字符,需要传zh}
|
||||
* /api/css?family=FontName1:wght@{100-900多权重以","或";"分隔}|FontName2:wght@700&subset={直接指定变体,优先级大于wght}&lang=zh
|
||||
* 注意:字体名称大小写严格匹配,只返回 CSS 格式
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const nameParam = searchParams.get('name');
|
||||
const familyParam = searchParams.get('family');
|
||||
const lang = searchParams.get('lang');
|
||||
const subsetOverride = searchParams.get('subset'); // 直接指定具体的子族名,例如 "Regular"、"BoldItalic"
|
||||
const format = searchParams.get('format') || 'css'; // 默认直接返回 CSS,更适配 <link> / @import
|
||||
// 只支持 CSS 格式返回
|
||||
|
||||
if (!nameParam) {
|
||||
if (format === 'css') {
|
||||
return new NextResponse('/* Missing required parameter: name */', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/css; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Missing required parameter: name',
|
||||
message: '请使用 name 参数提供字体族名称',
|
||||
example: '/api/css?name=FontName:wght@400;700',
|
||||
if (!familyParam) {
|
||||
return new NextResponse('/* 缺少必需参数: family */', {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'text/css; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const families = parseNameParam(nameParam);
|
||||
const { families, hasLangZh } = parseFamilyParam(familyParam);
|
||||
const cssParts: string[] = [];
|
||||
const failedFonts: string[] = [];
|
||||
|
||||
// 如果检测到任何字体包含 :lang@zh 参数,自动设置 lang=zh
|
||||
const effectiveLang = hasLangZh ? 'zh' : lang;
|
||||
|
||||
for (const { name, weights } of families) {
|
||||
if (subsetOverride) {
|
||||
// subset 覆盖权重大于 wght/ital,只输出该子族 CSS
|
||||
try {
|
||||
const css = await generateCSSResponse(name, subsetOverride, lang);
|
||||
const css = await generateCSSResponse(name, subsetOverride, effectiveLang);
|
||||
cssParts.push(css);
|
||||
} catch (e) {
|
||||
Logger.warn(`Failed to generate CSS for ${name}/${subsetOverride}`, e as any);
|
||||
const errorMsg = e instanceof Error ? e.message : '未知错误';
|
||||
failedFonts.push(`${name}/${subsetOverride}: ${errorMsg}`);
|
||||
}
|
||||
} else {
|
||||
// 逐个权重处理,并根据权重推导子族;同时在 CSS 中重写 font-weight
|
||||
const seen = new Set<string>();
|
||||
// 逐个权重处理,并根据权重推导子族
|
||||
|
||||
for (const w of weights) {
|
||||
const sub = inferSubfamilyFromWeightStyle(w);
|
||||
const key = `${name}::${sub}::${w}`;
|
||||
if (seen.has(key)) {
|
||||
// skip duplicate
|
||||
} else {
|
||||
seen.add(key);
|
||||
try {
|
||||
const css = await generateCSSResponse(name, sub, lang, w);
|
||||
cssParts.push(css);
|
||||
} catch (e) {
|
||||
Logger.warn(`Failed to generate CSS for ${name}/${sub} (w=${w})`, e as any);
|
||||
}
|
||||
try {
|
||||
const css = await generateCSSResponse(name, sub, effectiveLang);
|
||||
cssParts.push(css);
|
||||
} catch (e) {
|
||||
const errorMsg = e instanceof Error ? e.message : '未知错误';
|
||||
failedFonts.push(`${name}/${sub} (w=${w}): ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 若未提供任何有效权重,回退到 Regular
|
||||
if (weights.length === 0) {
|
||||
try {
|
||||
const css = await generateCSSResponse(name, 'Regular', lang, '400');
|
||||
const css = await generateCSSResponse(name, 'Regular', effectiveLang);
|
||||
cssParts.push(css);
|
||||
} catch (e) {
|
||||
Logger.warn(`Failed to generate CSS for ${name}/Regular (fallback)`, e as any);
|
||||
const errorMsg = e instanceof Error ? e.message : '未知错误';
|
||||
failedFonts.push(`${name}/Regular (fallback): ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全部失败时,至少返回注释,避免 500
|
||||
// 全部失败时,返回404错误
|
||||
if (cssParts.length === 0) {
|
||||
if (format === 'css') {
|
||||
return new NextResponse('/* No CSS could be generated for specified fonts */', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/css; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ css: '/* No CSS could be generated for specified fonts */' },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
const cssBundle = cssParts.join('\n\n');
|
||||
|
||||
if (format === 'css') {
|
||||
return new NextResponse(cssBundle, {
|
||||
status: 200,
|
||||
return new NextResponse('/* No CSS could be generated for specified fonts */', {
|
||||
status: 404,
|
||||
headers: {
|
||||
'Content-Type': 'text/css; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
// 'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ css: cssBundle }, { status: 200 });
|
||||
} catch (error: any) {
|
||||
Logger.error('Error in CSS API:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'internal_error',
|
||||
message: error?.message || 'Internal server error',
|
||||
const cssBundle = cssParts.join('\n\n');
|
||||
|
||||
return new NextResponse(cssBundle, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/css; charset=utf-8',
|
||||
'Cache-Control': 'no-store',
|
||||
// 'Cache-Control': 'public, max-age=31536000, immutable',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
} catch (error: any) {
|
||||
Logger.error('CSS API 错误:', error);
|
||||
return new NextResponse('/* 内部服务器错误 */', {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'text/css; charset=utf-8',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 name 参数
|
||||
* 解析 family 参数
|
||||
* 支持格式:
|
||||
* - FontName:wght@400;700
|
||||
* - FontName1:wght@400|FontName2:wght@700
|
||||
*/
|
||||
function parseNameParam(nameParam: string) {
|
||||
const families = nameParam.split('|');
|
||||
function parseFamilyParam(familyParam: string) {
|
||||
const families = familyParam.split('|');
|
||||
let hasLangZh = false;
|
||||
|
||||
return families.map((family) => {
|
||||
const parsedFamilies = families.map((family) => {
|
||||
const [name, styleSpec] = family.split(':');
|
||||
const fontName = name.replace(/\+/g, ' '); // 替换 + 为空格
|
||||
|
||||
let weights = [] as string[]; // 不默认填充,交由上层兜底
|
||||
let lang = null;
|
||||
|
||||
if (styleSpec) {
|
||||
// 支持 wght@ 和 ital@,分隔符支持","或";"
|
||||
const specs = styleSpec.split(';');
|
||||
// 支持 wght@ 和 lang@ ,分隔符支持","或";"
|
||||
const specs = styleSpec.split(':');
|
||||
|
||||
for (const spec of specs) {
|
||||
if (spec.startsWith('wght@')) {
|
||||
weights = spec.substring(5).split(/[;,]+/);
|
||||
} else if (spec.startsWith('lang@')) {
|
||||
lang = spec.substring(5);
|
||||
if (lang === 'zh') {
|
||||
hasLangZh = true;
|
||||
}
|
||||
} else if (/^[0-9,;]+$/.test(spec)) {
|
||||
// 旧语法:直接的权重列表,如 "300,400,700" 或 "300;400;700"
|
||||
weights = spec.split(/[;,]+/);
|
||||
|
@ -160,8 +148,10 @@ function parseNameParam(nameParam: string) {
|
|||
}
|
||||
}
|
||||
|
||||
return { name: fontName, weights };
|
||||
return { name: fontName, weights, lang };
|
||||
});
|
||||
|
||||
return { families: parsedFamilies, hasLangZh };
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -171,50 +161,118 @@ function parseNameParam(nameParam: string) {
|
|||
function inferSubfamilyFromWeightStyle(
|
||||
weight: string
|
||||
): string {
|
||||
// 权重映射 - 使用标准的字体子族命名规范
|
||||
// 如果权重参数是纯数字,则进行映射推导
|
||||
if (/^\d+$/.test(weight)) {
|
||||
// 权重映射 - 使用标准的字体子族命名规范
|
||||
const weightMap: Record<string, string> = {
|
||||
100: 'Thin',
|
||||
200: 'ExtraLight',
|
||||
300: 'Light',
|
||||
400: 'Regular',
|
||||
500: 'Medium',
|
||||
600: 'SemiBold',
|
||||
700: 'Bold',
|
||||
800: 'ExtraBold',
|
||||
900: 'Black',
|
||||
};
|
||||
|
||||
// 获取基础权重名称
|
||||
return (weightMap as any)[weight] || 'Regular';
|
||||
}
|
||||
|
||||
// 如果已经是字符串形式的权重名称,直接返回
|
||||
return weight;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为@font-face规则添加font-weight属性
|
||||
*/
|
||||
function addFontWeightToFontFace(cssContent: string, subset: string): string {
|
||||
// 字重名称到数字的映射
|
||||
const weightMap: Record<string, string> = {
|
||||
100: 'Thin',
|
||||
200: 'ExtraLight',
|
||||
300: 'Light',
|
||||
400: 'Regular',
|
||||
500: 'Medium',
|
||||
600: 'SemiBold',
|
||||
700: 'Bold',
|
||||
800: 'ExtraBold',
|
||||
900: 'Black',
|
||||
thin: '100',
|
||||
extralight: '200',
|
||||
light: '300',
|
||||
regular: '400',
|
||||
normal: '400',
|
||||
medium: '500',
|
||||
semibold: '600',
|
||||
bold: '700',
|
||||
extrabold: '800',
|
||||
black: '900',
|
||||
heavy: '900',
|
||||
};
|
||||
|
||||
// 获取基础权重名称
|
||||
const weightName = (weightMap as any)[weight] || 'Regular';
|
||||
// 获取字重数字值
|
||||
const getFontWeight = (subsetName: string): string => {
|
||||
const lowerSubset = subsetName.toLowerCase();
|
||||
|
||||
return weightName;
|
||||
// 如果已经是数字,直接返回
|
||||
if (/^\d+$/.test(lowerSubset)) {
|
||||
return lowerSubset;
|
||||
}
|
||||
|
||||
// 查找匹配的字重名称
|
||||
for (const [name, weight] of Object.entries(weightMap)) {
|
||||
if (lowerSubset.includes(name)) {
|
||||
return weight;
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回400
|
||||
return '400';
|
||||
};
|
||||
|
||||
const fontWeight = getFontWeight(subset);
|
||||
|
||||
// 使用正则表达式匹配@font-face规则并添加font-weight属性
|
||||
return cssContent.replace(
|
||||
/@font-face\s*\{([^}]+)\}/g,
|
||||
(match, content) => {
|
||||
// 检查是否已经包含font-weight属性
|
||||
if (content.includes('font-weight:')) {
|
||||
return match;
|
||||
}
|
||||
|
||||
// 在最后一个属性后添加font-weight
|
||||
const trimmedContent = content.trim();
|
||||
const hasTrailingSemicolon = trimmedContent.endsWith(';');
|
||||
const fontWeightDeclaration = `font-weight: ${fontWeight};`;
|
||||
|
||||
if (hasTrailingSemicolon) {
|
||||
return `@font-face {${trimmedContent} ${fontWeightDeclaration}}`;
|
||||
}
|
||||
return `@font-face {${trimmedContent}; ${fontWeightDeclaration}}`;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 CSS 响应 - 返回包含字体文件引用的 CSS 内容
|
||||
*/
|
||||
async function generateCSSResponse(
|
||||
fontFamily: string,
|
||||
subfamily: string,
|
||||
family: string,
|
||||
subset: string,
|
||||
lang?: string | null,
|
||||
weightOverride?: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
// 将参数转换为小写以匹配OSS路径格式
|
||||
const lowercaseFontFamily = fontFamily.toLowerCase();
|
||||
const lowercaseSubfamily = subfamily.toLowerCase();
|
||||
const lowercasefamily = family.toLowerCase();
|
||||
const lowercaseSubfamily = subset.toLowerCase();
|
||||
|
||||
// 构建字体文件URL(优先使用 CDN 配置)
|
||||
const staticBaseRaw = APP_CONFIG.fontStaticUrl;
|
||||
if (!staticBaseRaw) {
|
||||
Logger.error(
|
||||
'Missing font static base URL. Please set NEXT_PUBLIC_FONT_STATIC_URL or OSS_ENDPOINT in your environment.'
|
||||
'缺少字体静态资源基础 URL。请在环境变量中设置 NEXT_PUBLIC_FONT_STATIC_URL 或 OSS_ENDPOINT。'
|
||||
);
|
||||
throw new Error('Missing font static base URL');
|
||||
throw new Error('缺少字体静态资源基础 URL');
|
||||
}
|
||||
const staticBase = staticBaseRaw.replace(/\/+$/g, '');
|
||||
|
||||
const normalizedFamily = getFontFamilyName(lowercaseFontFamily);
|
||||
// 添加OSS_FONT_PREFIX前缀到字体名,然后在构建OSS路径时移除前缀
|
||||
const fontWithPrefix = `${OSS_CONFIG.fontPrefix}-${lowercasefamily}`;
|
||||
const normalizedFamily = getFontFamilyName(fontWithPrefix);
|
||||
const baseUrl = `${staticBase}/${OSS_CONFIG.folder}/${normalizedFamily}/${lowercaseSubfamily}/${OSS_CONFIG.subFolderWeb}`;
|
||||
let cssUrl = `${baseUrl}/${OSS_CONFIG.cssFilename}`;
|
||||
|
||||
|
@ -223,33 +281,39 @@ async function generateCSSResponse(
|
|||
cssUrl = `${baseUrl}/zh_${OSS_CONFIG.cssFilename}`;
|
||||
}
|
||||
|
||||
// 获取CSS文件内容
|
||||
const response = await fetch(cssUrl);
|
||||
if (!response.ok) {
|
||||
// 如果CSS文件不存在,返回一个基本的@font-face声明作为fallback
|
||||
Logger.warn(`CSS file not found at ${cssUrl}, generating fallback CSS`);
|
||||
const fontUseKey = `${OSS_CONFIG.fontPrefix}-${normalizedFamily}`;
|
||||
// 获取CSS文件内容,单次请求
|
||||
const response = await fetch(cssUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'Windfonts-CSS-API/1.0',
|
||||
Accept: 'text/css,*/*',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
// 设置超时时间
|
||||
signal: AbortSignal.timeout(10000), // 10秒超时
|
||||
});
|
||||
|
||||
let fallback = `/* Fallback CSS for ${normalizedFamily} */\n@font-face {\n font-family: '${fontUseKey}';\n font-style: normal;\n font-weight: 400;\n font-display: swap;\n /* Note: Font files not available at ${cssUrl} */\n}`;
|
||||
if (weightOverride && /^\d{3}$/.test(weightOverride)) {
|
||||
fallback = fallback.replace(/(font-weight:\s*)\d{3}/g, `$1${weightOverride}`);
|
||||
}
|
||||
return fallback;
|
||||
if (!response.ok) {
|
||||
throw new Error(`字体未找到: ${normalizedFamily}/${subset} - HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
let cssContent = await response.text();
|
||||
|
||||
// 将相对路径替换为绝对路径
|
||||
cssContent = cssContent.replace(/url\(["']?\.\/([^"')]+)["']?\)/g, `url("${baseUrl}/$1")`);
|
||||
|
||||
// 对多权重情况下的 CSS,重写 font-weight 属性
|
||||
if (weightOverride && /^\d{3}$/.test(weightOverride)) {
|
||||
cssContent = cssContent.replace(/(font-weight:\s*)\d{3}/g, `$1${weightOverride}`);
|
||||
// 检查响应内容是否是有效的CSS
|
||||
// 如果内容包含错误消息而不是CSS,则抛出错误
|
||||
if (!cssContent.includes('@font-face') && !cssContent.includes('font-family')) {
|
||||
throw new Error(`无效的 CSS 内容: ${cssContent.substring(0, 50)}...`);
|
||||
}
|
||||
|
||||
// 将相对路径替换为绝对路径
|
||||
cssContent = cssContent.replace(/url\(["']?\.\//g, `url("${baseUrl}/`);
|
||||
|
||||
// 为@font-face规则添加font-weight属性
|
||||
cssContent = addFontWeightToFontFace(cssContent, subset);
|
||||
|
||||
return cssContent;
|
||||
} catch (error: any) {
|
||||
Logger.error('Error generating CSS response:', error);
|
||||
Logger.error('生成 CSS 响应时出错:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,167 +1,17 @@
|
|||
'use client';
|
||||
|
||||
import { Card, SegmentedControl, Tabs } from '@mantine/core';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { TypefaceOutput } from '@/types/typing';
|
||||
import { getFontFamilyName, getFontUseName } from '@/utils/fontHelper';
|
||||
import Breadcrumb from '@/components/BreadCrumb';
|
||||
import Empty from '@/components/Empty';
|
||||
import { useFontData } from '@/components/fonts/FontDataProvider';
|
||||
import EmbedClient from '@/components/fonts/EmbedClient';
|
||||
import { getFontUseName } from '@/utils/fontHelper';
|
||||
|
||||
import CodeChip from '@/components/code/CodeChip';
|
||||
|
||||
export default function Embed({ params: { id } }: { params: { id: string } }) {
|
||||
const [tab, setTab] = useState<string>('link');
|
||||
const [subset, setSubset] = useState<string>('');
|
||||
const [previewInfo, setPreviewInfo] = useState<TypefaceOutput | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function loadFont() {
|
||||
try {
|
||||
const fontfamily = getFontFamilyName(id);
|
||||
const response = await fetch('/api/fonts/detail/name', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ fontFamily: fontfamily }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch font data');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.code === 200) {
|
||||
setPreviewInfo(result.data);
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to load font');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
loadFont();
|
||||
}, [id]);
|
||||
|
||||
const fontUseName = useMemo(() => previewInfo ? getFontUseName(previewInfo) : '', [previewInfo]);
|
||||
const embedCode = useMemo(() => {
|
||||
if (previewInfo && typeof window !== 'undefined') {
|
||||
const fontFamily = getFontUseName(previewInfo);
|
||||
const subfamilies = subset ? [subset] : previewInfo.fontSubfamily;
|
||||
const weights = subfamilies.join(',');
|
||||
|
||||
// 生成 link/@import 代码,改用可直接使用的 CSS 原始端点
|
||||
const linkUrl = `${window.location.origin}/api/css?name=${fontFamily}:wght@${weights}&display=swap&format=css`;
|
||||
const linkZhUrl = `${window.location.origin}/api/css?name=${fontFamily}:wght@${weights}&display=swap&lang=zh&format=css`;
|
||||
|
||||
const importUrl = `@import url('${linkUrl}');`;
|
||||
const importZhUrl = `@import url('${linkZhUrl}');`;
|
||||
|
||||
const link = `<link rel="stylesheet" crossorigin="anonymous" href="${linkUrl}">\n<!-- 此中文网页字体由文风字体(Windfonts)免费提供,您可以自由引用,请务必保留此授权许可标注 https://wenfeng.org/license -->`;
|
||||
const linkZh = `<link rel="stylesheet" crossorigin="anonymous" href="${linkZhUrl}">\n<!-- 此中文网页字体由文风字体(Windfonts)免费提供,您可以自由引用,请务必保留此授权许可标注 https://wenfeng.org/license -->`;
|
||||
|
||||
const importUrlCode = `<style>\n${importUrl}\n</style>\n<!-- 此中文网页字体由文风字体(Windfonts)免费提供,您可以自由引用,请务必保留此授权许可标注 https://wenfeng.org/license -->`;
|
||||
const importUrlZh = `<style>\n${importZhUrl}\n</style>\n<!-- 此中文网页字体由文风字体(Windfonts)免费提供,您可以自由引用,请务必保留此授权许可标注 https://wenfeng.org/license -->`;
|
||||
|
||||
return { link, importUrl: importUrlCode, importUrlZh, linkZh };
|
||||
}
|
||||
return {
|
||||
link: '',
|
||||
importUrl: '',
|
||||
linkZh: '',
|
||||
importUrlZh: '',
|
||||
};
|
||||
}, [previewInfo, subset]);
|
||||
const segmentData = useMemo(() => previewInfo ? [{ label: '全部', value: '' }, ...previewInfo.fontSubfamily.map(item => ({ label: item, value: item }))] : [], [previewInfo]);
|
||||
|
||||
const weight = useMemo(() => previewInfo ? (subset || `${previewInfo.fontSubfamily.join('|')}|${[100, 200, 300, 400, 500, 600, 700, 800, 900, 1000].join('|')}`) : '', [subset, previewInfo]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl py-10">
|
||||
<Breadcrumb items={[{ title: '字体库', href: '/fonts' }]} />
|
||||
<div>加载中...</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !previewInfo) {
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl py-10">
|
||||
<Breadcrumb items={[{ title: '字体库', href: '/fonts' }]} />
|
||||
<Empty>字体不存在</Empty>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
export default function EmbedPage() {
|
||||
const { previewInfo } = useFontData();
|
||||
const fontUseName = getFontUseName(previewInfo);
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl py-10">
|
||||
<Breadcrumb items={[{ title: '字体库', href: '/fonts' }, { title: previewInfo.name, href: `/fonts/wenfeng-${previewInfo.fontFamily}` }, { title: '获取嵌入代码' }]} />
|
||||
<h2 className="text-4xl font-semibold my-6">{previewInfo.name}</h2>
|
||||
|
||||
{previewInfo.fontSubfamily.length > 1 && <SegmentedControl
|
||||
data={segmentData}
|
||||
value={subset}
|
||||
onChange={setSubset} />}
|
||||
<Tabs className="mt-6" value={tab} onChange={(val) => val && setTab(val)}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="link">link</Tabs.Tab>
|
||||
<Tabs.Tab value="import">@import</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="link">
|
||||
<Card withBorder radius="sm" shadow="sm" className="mt-6">
|
||||
<Card.Section className="flex-col items-start pl-6 pt-5">
|
||||
<h1 className="mb-2 font-semibold">1.引入web项目</h1>
|
||||
<p className="ml-2">将代码放入你的 {'<head>'} 标签内</p>
|
||||
</Card.Section>
|
||||
<Card.Section className="p-8">
|
||||
<CodeChip content={embedCode.link} />
|
||||
</Card.Section>
|
||||
<h4 className="ml-4 font-medium">如果需要仅包含中文字符字体的样式,请仅引入下面的代码</h4>
|
||||
<Card.Section className="px-8 py-4">
|
||||
<CodeChip content={embedCode.linkZh} />
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="import">
|
||||
<Card withBorder radius="sm" shadow="sm" className="mt-6">
|
||||
<Card.Section className="flex-col items-start pl-6 pt-5">
|
||||
<h1 className="mb-2 font-semibold">1.引入web项目</h1>
|
||||
<p className="ml-2">将代码放入你的 {'<head>'} 标签内</p>
|
||||
</Card.Section>
|
||||
<Card.Section className="p-8">
|
||||
<CodeChip content={embedCode.importUrl} />
|
||||
</Card.Section>
|
||||
<h4 className="ml-4 font-medium">仅使用中文字符的字体样式,请仅引入下面的代码</h4>
|
||||
<Card.Section className="px-8 py-4">
|
||||
<CodeChip content={embedCode.importUrlZh} />
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Card withBorder radius="sm" shadow="sm" className="mt-6 min-h-40">
|
||||
<Card.Section className="flex-col items-start pl-6 pt-5">
|
||||
<h1 className="mb-2 font-semibold">2.使用</h1>
|
||||
<p>代码使用示例</p>
|
||||
</Card.Section>
|
||||
|
||||
<Card.Section className="mt-6 p-8 pt-2">
|
||||
<CodeChip
|
||||
content={`// font-weight: 字体权重值 决定字体粗细,存在多个变体时,使用变体值${previewInfo.fontSubfamily.join(', ')}。
|
||||
// 注:若没有生效可使用 100 - 900 之间的值,若还是没生效说明该变体中没有匹配的样式
|
||||
// font-style: 字体样式 normal | italic | oblique 控制字体风格,可展示倾斜样式
|
||||
.wf-font-example {
|
||||
font-family: '${fontUseName}'; // 固定值,不可修改
|
||||
font-weight: ${weight};
|
||||
font-style: normal | italic | oblique;
|
||||
}`}
|
||||
/>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
<Breadcrumb items={[{ title: '字体库', href: '/fonts' }, { title: previewInfo.name, href: `/fonts/${fontUseName}` }, { title: '嵌入代码' }]} />
|
||||
<EmbedClient previewInfo={previewInfo} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
44
app/fonts/[id]/layout.tsx
Normal file
44
app/fonts/[id]/layout.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { TypefaceService } from '@/lib/services/typeface.service';
|
||||
import { TypefaceOutput } from '@/types/typing';
|
||||
import { getFontFamilyName } from '@/utils/fontHelper';
|
||||
import Breadcrumb from '@/components/BreadCrumb';
|
||||
import Empty from '@/components/Empty';
|
||||
import { FontDataProvider } from '@/components/fonts/FontDataProvider';
|
||||
|
||||
interface FontLayoutProps {
|
||||
children: React.ReactNode;
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export default async function FontLayout({ children, params: { id } }: FontLayoutProps) {
|
||||
try {
|
||||
const fontfamily = getFontFamilyName(id);
|
||||
const typefaceService = new TypefaceService();
|
||||
const previewInfoModel = await typefaceService.findFontName(fontfamily);
|
||||
const previewInfo = previewInfoModel as TypefaceOutput;
|
||||
|
||||
if (!previewInfo) {
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl py-10">
|
||||
<Breadcrumb items={[{ title: '字体库', href: '/fonts' }]} />
|
||||
<Empty>字体不存在</Empty>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FontDataProvider previewInfo={previewInfo}>
|
||||
{children}
|
||||
</FontDataProvider>
|
||||
);
|
||||
} catch (error) {
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl py-10">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-red-600">加载字体信息失败</h1>
|
||||
<p className="mt-2 text-gray-600">请稍后重试</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,3 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import { Button, Divider, Pill } from '@mantine/core';
|
||||
import Link from 'next/link';
|
||||
import { SolarLink } from '@/components/icons/SolarLink';
|
||||
|
@ -5,108 +7,86 @@ import { Download } from '@/components/icons/Download';
|
|||
import { SolarDangerTriangle } from '@/components/icons/SolarDangerTriangle';
|
||||
import ActionArea from '@/components/fonts/ActionArea';
|
||||
import Operation from '@/components/fonts/Operation';
|
||||
import { getFontDownloadUrl, getFontFamilyName } from '@/utils/fontHelper';
|
||||
import { getFontDownloadUrl } from '@/utils/fontHelper';
|
||||
import Breadcrumb from '@/components/BreadCrumb';
|
||||
import Empty from '@/components/Empty';
|
||||
import { TypefaceService } from '@/lib/services/typeface.service';
|
||||
import { TypefaceOutput } from '@/types/typing';
|
||||
import { useFontData } from '@/components/fonts/FontDataProvider';
|
||||
|
||||
export default async function Page({ params: { id } }: { params: { id: string } }) {
|
||||
try {
|
||||
const fontfamily = getFontFamilyName(id);
|
||||
const typefaceService = new TypefaceService();
|
||||
const previewInfoModel = await typefaceService.findFontName(fontfamily);
|
||||
const previewInfo = previewInfoModel as TypefaceOutput;
|
||||
if (!previewInfo) {
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl py-10">
|
||||
<Breadcrumb items={[{ title: '字体库', href: '/fonts' }]} />
|
||||
<Empty>字体不存在</Empty>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const downloadUrl = getFontDownloadUrl(previewInfo);
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl py-10">
|
||||
<Breadcrumb items={[{ title: '字体库', href: '/fonts' }, { title: previewInfo.name }]} />
|
||||
<div className="flex mt-6 justify-between">
|
||||
<div className="pr-32">
|
||||
<h1 className="text-4xl font-semibold">{previewInfo.name}</h1>
|
||||
<p className="text-1 mt-3 font-normal text-[rgba(63,63,70,1)]">
|
||||
由<span className="mx-1 font-semibold text-black">@{previewInfo.designer}</span>
|
||||
export default function Page() {
|
||||
const { previewInfo } = useFontData();
|
||||
const downloadUrl = getFontDownloadUrl(previewInfo);
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl py-10">
|
||||
<Breadcrumb items={[{ title: '字体库', href: '/fonts' }, { title: previewInfo.name }]} />
|
||||
<div className="flex mt-6 justify-between">
|
||||
<div className="pr-32">
|
||||
<h1 className="text-4xl font-semibold">{previewInfo.name}</h1>
|
||||
<p className="text-1 mt-3 font-normal text-[rgba(63,63,70,1)]">
|
||||
由<span className="mx-1 font-semibold text-black">@{ previewInfo.designer || '匿名'}</span>
|
||||
设计
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="transparent" className="text-[#27272A]">
|
||||
<Link href={`${previewInfo.fontFamily}/embed`} className="flex items-center gap-2">
|
||||
<SolarLink width={20} height={20} color="#27272A" />
|
||||
<span>获取嵌入代码</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="transparent" className="text-[#27272A]">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Download height={16} width={16} />
|
||||
<span>下载字体</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Operation fontInfo={previewInfo} />
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="transparent" className="text-[#27272A]">
|
||||
<Link href={`${previewInfo.fontFamily}/embed`} className="flex items-center gap-2">
|
||||
<SolarLink width={20} height={20} color="#27272A" />
|
||||
<span>获取嵌入代码</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="transparent" className="text-[#27272A]">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Download height={16} width={16} />
|
||||
<span>下载字体</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Operation fontInfo={previewInfo} />
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="mt-10"></Divider>
|
||||
<div className="mt-10 flex justify-between">
|
||||
<div className="w-[31rem]">
|
||||
<h2 className="text-lg font-semibold">描述信息</h2>
|
||||
<p className="mt-4">{previewInfo?.description || '暂无'}</p>
|
||||
<h2 className="mt-10 text-lg font-semibold">字体分类</h2>
|
||||
<p className="mt-4">{previewInfo?.category || '暂无'}</p>
|
||||
<h2 className="mt-10 text-lg font-semibold">字体标签</h2>
|
||||
<div className="mt-4 flex gap-3">
|
||||
{previewInfo.tags ? (
|
||||
<Pill className="text-[rgba(0,0,0,0.8)]" size="lg">
|
||||
{previewInfo?.tags}
|
||||
</Pill>
|
||||
) : (
|
||||
'暂无'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Divider className="mt-10"></Divider>
|
||||
<div className="mt-10 flex justify-between">
|
||||
<div className="w-[31rem]">
|
||||
<h2 className="text-lg font-semibold">描述信息</h2>
|
||||
<p className="mt-4">{previewInfo?.description || '暂无'}</p>
|
||||
<h2 className="mt-10 text-lg font-semibold">字体分类</h2>
|
||||
<p className="mt-4">{previewInfo?.category || '暂无'}</p>
|
||||
<h2 className="mt-10 text-lg font-semibold">字体标签</h2>
|
||||
<div className="mt-4 flex gap-3">
|
||||
{previewInfo.tags ? (
|
||||
<Pill className="text-[rgba(0,0,0,0.8)]" size="lg">
|
||||
{previewInfo?.tags}
|
||||
</Pill>
|
||||
) : (
|
||||
'暂无'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[29.5rem]">
|
||||
<ActionArea fontInfo={previewInfo} />
|
||||
</div>
|
||||
<div className="w-[29.5rem]">
|
||||
<ActionArea fontInfo={previewInfo} />
|
||||
</div>
|
||||
<div className="mt-16 rounded-lg bg-[#F4F4F5] p-4 text-[#52525B]">
|
||||
<div className="flex items-center gap-1.5 text-base font-semibold">
|
||||
<SolarDangerTriangle width={19} height={19} color="#52525B" /> <span>使用提示</span>
|
||||
</div>
|
||||
<div className="font-400 mt-2 text-sm">
|
||||
<p>
|
||||
</div>
|
||||
<div className="mt-16 rounded-lg bg-[#F4F4F5] p-4 text-[#52525B]">
|
||||
<div className="flex items-center gap-1.5 text-base font-semibold">
|
||||
<SolarDangerTriangle width={19} height={19} color="#52525B" /> <span>使用提示</span>
|
||||
</div>
|
||||
<div className="font-400 mt-2 text-sm">
|
||||
<p>
|
||||
|
||||
文风字体(Windfonts)所有源文件均收集自网络,大多数由本站智能程序自动整理发布,若有任何疑问请通过
|
||||
<a className="underline" href="https://windfonts.com/forums/">
|
||||
<a className="underline" href="https://windfonts.com/forums/">
|
||||
支持论坛
|
||||
</a>
|
||||
</a>
|
||||
开贴或
|
||||
<a className="underline" href="https://windfonts.com/contact/ ">
|
||||
<a className="underline" href="https://windfonts.com/contact/ ">
|
||||
联系我们
|
||||
</a>
|
||||
</a>
|
||||
。
|
||||
</p>
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
} catch (error) {
|
||||
return (
|
||||
<section className="mx-auto max-w-5xl py-10">
|
||||
<Breadcrumb items={[{ title: '字体库', href: '/fonts' }]} />
|
||||
<Empty>获取字体信息失败</Empty>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>字体加载测试</title>
|
||||
<link rel="stylesheet" href="/api/css?family=kslmt&weight=400">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.font-test {
|
||||
font-family: 'wenfeng-kslmt', serif;
|
||||
font-size: 24px;
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.info {
|
||||
background-color: #e7f3ff;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>字体加载测试页面</h1>
|
||||
|
||||
<div class="info">
|
||||
<strong>测试说明:</strong>此页面使用新的 API 路径 <code>/api/css</code> 加载字体,
|
||||
该 API 返回包含绝对路径的 CSS 内容,解决了相对路径导致的 404 问题。
|
||||
</div>
|
||||
|
||||
<div class="font-test">
|
||||
<h2>中文测试:</h2>
|
||||
<p>这是使用 wenfeng-kslmt 字体的中文文本测试。你好世界!</p>
|
||||
|
||||
<h2>English Test:</h2>
|
||||
<p>This is an English text test using the wenfeng-kslmt font. Hello World!</p>
|
||||
|
||||
<h2>混合文本测试:</h2>
|
||||
<p>Mixed text: 中文 English 数字123 符号!@#$%</p>
|
||||
</div>
|
||||
|
||||
<div class="info">
|
||||
<strong>API 对比:</strong><br>
|
||||
• 旧 API (重定向): <code>/api/css?family=kslmt:400&format=css</code><br>
|
||||
• 新 API (绝对路径): <code>/api/css?family=kslmt&weight=400</code>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
|
@ -1,165 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>测试简化版 CSS API</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.test-section {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.api-url {
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
margin: 10px 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
button {
|
||||
background: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin: 5px 0;
|
||||
}
|
||||
button:hover {
|
||||
background: #005a87;
|
||||
}
|
||||
.result {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 3px;
|
||||
white-space: pre-wrap;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.success {
|
||||
background: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>测试简化版 CSS API</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<p>此页面用于测试修改后的 <code>/api/css</code> API,验证:</p>
|
||||
<ul>
|
||||
<li>✅ 不再查询数据库验证字体是否存在</li>
|
||||
<li>✅ 直接根据固定路径拼接规则返回CSS</li>
|
||||
<li>✅ 支持Google Fonts风格的参数格式</li>
|
||||
<li>✅ 支持通过weight参数推断子族名</li>
|
||||
<li>⚠️ <strong>字体名称大小写严格匹配</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>测试1: 基本字体请求</h2>
|
||||
<div class="api-url">/api/css?family=wenfeng-SourceHanSans:wght@400</div>
|
||||
<button onclick="testAPI('/api/css?family=wenfeng-SourceHanSans:wght@400', 'result1')">测试API</button>
|
||||
<div id="result1" class="result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>测试2: 多权重字体请求</h2>
|
||||
<div class="api-url">/api/css?family=wenfeng-SourceHanSans:wght@400;700</div>
|
||||
<button onclick="testAPI('/api/css?family=wenfeng-SourceHanSans:wght@400;700', 'result2')">测试API</button>
|
||||
<div id="result2" class="result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>测试3: 斜体样式</h2>
|
||||
<div class="api-url">/api/css?family=wenfeng-SourceHanSans:ital,wght@1,400</div>
|
||||
<button onclick="testAPI('/api/css?family=wenfeng-SourceHanSans:ital,wght@1,400', 'result3')">测试API</button>
|
||||
<div id="result3" class="result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>测试4: 多字体家族</h2>
|
||||
<div class="api-url">/api/css?family=wenfeng-SourceHanSans:wght@400|wenfeng-SourceCodePro:wght@400</div>
|
||||
<button onclick="testAPI('/api/css?family=wenfeng-SourceHanSans:wght@400|wenfeng-SourceCodePro:wght@400', 'result4')">测试API</button>
|
||||
<div id="result4" class="result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>测试5: 不存在的字体(应该直接尝试获取CSS)</h2>
|
||||
<div class="api-url">/api/css?family=NonExistentFont:wght@400</div>
|
||||
<button onclick="testAPI('/api/css?family=NonExistentFont:wght@400', 'result5')">测试API</button>
|
||||
<div id="result5" class="result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>验证要点</h2>
|
||||
<p>请查看浏览器开发者工具的Network标签页和服务器终端日志,确认:</p>
|
||||
<ul>
|
||||
<li>❌ 不应该看到任何SQL查询日志</li>
|
||||
<li>✅ 应该看到直接的OSS文件获取请求或重定向</li>
|
||||
<li>✅ API响应时间应该更快(无数据库查询延迟)</li>
|
||||
<li>✅ 返回307重定向到实际的CSS文件URL</li>
|
||||
</ul>
|
||||
|
||||
<div style="background: #fff3cd; border: 1px solid #ffeaa7; padding: 10px; border-radius: 5px; margin-top: 10px;">
|
||||
<strong>⚠️ 重要提醒:</strong><br>
|
||||
字体名称的大小写必须与OSS存储路径完全匹配。API现在直接根据参数构建路径,不再验证字体是否存在。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function testAPI(url, resultId) {
|
||||
const resultDiv = document.getElementById(resultId);
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.textContent = '正在请求...';
|
||||
resultDiv.className = 'result';
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
redirect: 'manual' // 不自动跟随重定向,以便查看重定向响应
|
||||
});
|
||||
const endTime = Date.now();
|
||||
|
||||
let result = `状态码: ${response.status}\n`;
|
||||
result += `响应时间: ${endTime - startTime}ms\n`;
|
||||
result += `响应头:\n`;
|
||||
|
||||
for (let [key, value] of response.headers.entries()) {
|
||||
result += ` ${key}: ${value}\n`;
|
||||
}
|
||||
|
||||
if (response.status === 307 || response.status === 302) {
|
||||
result += `\n重定向到: ${response.headers.get('location')}`;
|
||||
resultDiv.className = 'result success';
|
||||
} else {
|
||||
const text = await response.text();
|
||||
result += `\n响应内容:\n${text.substring(0, 500)}${text.length > 500 ? '...' : ''}`;
|
||||
resultDiv.className = response.ok ? 'result success' : 'result error';
|
||||
}
|
||||
|
||||
resultDiv.textContent = result;
|
||||
} catch (error) {
|
||||
resultDiv.textContent = `错误: ${error.message}`;
|
||||
resultDiv.className = 'result error';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,180 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>测试字体CSS API - 无数据库查询版本</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.test-section {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.api-url {
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
.result {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #e8f5e8;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.error {
|
||||
background: #ffe8e8;
|
||||
}
|
||||
button {
|
||||
background: #007cba;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover {
|
||||
background: #005a87;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>字体CSS API测试 - 无数据库查询版本</h1>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>测试说明</h2>
|
||||
<p>此页面用于测试修改后的 <code>/api/css</code> API,验证:</p>
|
||||
<ul>
|
||||
<li>✅ 不再查询数据库验证字体是否存在</li>
|
||||
<li>✅ 直接根据固定路径拼接规则返回CSS</li>
|
||||
<li>✅ 支持通过subfamily参数直接指定子族名</li>
|
||||
<li>✅ 支持通过weight和style参数推断子族名</li>
|
||||
<li>⚠️ <strong>字体名称和子族名称大小写严格匹配</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>测试1: 直接指定子族名</h2>
|
||||
<div class="api-url">/api/css?family=wenfeng-SourceHanSans&subfamily=Regular</div>
|
||||
<button onclick="testAPI('/api/css?family=wenfeng-SourceHanSans&subfamily=Regular', 'result1')">测试API</button>
|
||||
<div id="result1" class="result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>测试2: 通过weight推断子族名</h2>
|
||||
<div class="api-url">/api/css?family=wenfeng-SourceHanSans&weight=700</div>
|
||||
<button onclick="testAPI('/api/css?family=wenfeng-SourceHanSans&weight=700', 'result2')">测试API</button>
|
||||
<div id="result2" class="result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>测试3: 通过style推断子族名</h2>
|
||||
<div class="api-url">/api/css?family=wenfeng-SourceHanSans&weight=400&style=italic</div>
|
||||
<button onclick="testAPI('/api/css?family=wenfeng-SourceHanSans&weight=400&style=italic', 'result3')">测试API</button>
|
||||
<div id="result3" class="result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>测试4: 大小写敏感测试</h2>
|
||||
<div class="api-url">/api/css?family=wenfeng-SourceHanSans&subfamily=regular(小写,应该失败)</div>
|
||||
<button onclick="testAPI('/api/css?family=wenfeng-SourceHanSans&subfamily=regular', 'result4')">测试API</button>
|
||||
<div id="result4" class="result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>测试5: 不存在的字体(应该直接尝试获取CSS)</h2>
|
||||
<div class="api-url">/api/css?family=NonExistentFont&subfamily=Regular</div>
|
||||
<button onclick="testAPI('/api/css?family=NonExistentFont&subfamily=Regular', 'result5')">测试API</button>
|
||||
<div id="result5" class="result" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>实时日志监控</h2>
|
||||
<p>请查看浏览器开发者工具的Network标签页和服务器终端日志,确认:</p>
|
||||
<ul>
|
||||
<li>❌ 不应该看到任何SQL查询日志</li>
|
||||
<li>✅ 应该看到直接的OSS文件获取请求</li>
|
||||
<li>✅ API响应时间应该更快(无数据库查询延迟)</li>
|
||||
<li>⚠️ <strong>大小写不匹配的请求应该返回404错误</strong></li>
|
||||
</ul>
|
||||
|
||||
<div style="background: #fff3cd; border: 1px solid #ffeaa7; padding: 10px; border-radius: 5px; margin-top: 10px;">
|
||||
<strong>⚠️ 重要提醒:</strong><br>
|
||||
字体名称和子族名称的大小写必须与OSS存储路径完全匹配。例如:
|
||||
<ul style="margin: 5px 0;">
|
||||
<li>✅ 正确:<code>subfamily=Regular</code></li>
|
||||
<li>❌ 错误:<code>subfamily=regular</code></li>
|
||||
<li>✅ 正确:<code>subfamily=Bold</code></li>
|
||||
<li>❌ 错误:<code>subfamily=bold</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function testAPI(url, resultId) {
|
||||
const resultDiv = document.getElementById(resultId);
|
||||
resultDiv.style.display = 'block';
|
||||
resultDiv.innerHTML = '🔄 正在测试...';
|
||||
resultDiv.className = 'result';
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
let content;
|
||||
const contentType = response.headers.get('content-type');
|
||||
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
content = await response.json();
|
||||
content = JSON.stringify(content, null, 2);
|
||||
} else {
|
||||
content = await response.text();
|
||||
}
|
||||
|
||||
resultDiv.innerHTML = `
|
||||
<strong>✅ 响应成功</strong><br>
|
||||
<strong>状态码:</strong> ${response.status}<br>
|
||||
<strong>响应时间:</strong> ${duration}ms<br>
|
||||
<strong>Content-Type:</strong> ${contentType}<br>
|
||||
<strong>响应内容:</strong><br>
|
||||
<pre style="max-height: 200px; overflow-y: auto; background: #f9f9f9; padding: 10px; margin-top: 5px;">${content}</pre>
|
||||
`;
|
||||
|
||||
if (!response.ok) {
|
||||
resultDiv.className = 'result error';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const endTime = Date.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
resultDiv.className = 'result error';
|
||||
resultDiv.innerHTML = `
|
||||
<strong>❌ 请求失败</strong><br>
|
||||
<strong>错误:</strong> ${error.message}<br>
|
||||
<strong>耗时:</strong> ${duration}ms
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载时显示当前时间
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('🚀 字体CSS API测试页面已加载 - ' + new Date().toLocaleString());
|
||||
console.log('📝 请注意观察服务器终端日志,确认没有SQL查询');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,135 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Float Menu Test</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.test-nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
background: rgba(238, 238, 238, 1);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.test-nav.hidden {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
.test-nav.visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.static-nav {
|
||||
height: 180px;
|
||||
background: rgba(238, 238, 238, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.content {
|
||||
height: 2000px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(to bottom, #f0f0f0, #e0e0e0);
|
||||
}
|
||||
.debug {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
right: 20px;
|
||||
background: white;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
z-index: 1001;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="floatNav" class="test-nav hidden">
|
||||
<h3>悬浮导航菜单 (应该在滚动时显示)</h3>
|
||||
</div>
|
||||
|
||||
<div class="static-nav">
|
||||
<h3>静态导航菜单 (向下滚动150px后悬浮菜单应该出现)</h3>
|
||||
</div>
|
||||
|
||||
<div class="debug" id="debug">
|
||||
<div>滚动位置: <span id="scrollY">0</span>px</div>
|
||||
<div>静态导航位置: <span id="navTop">0</span>px</div>
|
||||
<div>悬浮菜单状态: <span id="floatStatus">隐藏</span></div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<h2>测试内容</h2>
|
||||
<p>向下滚动测试悬浮菜单功能...</p>
|
||||
<p>当静态导航菜单滚动到视窗顶部上方150px时,悬浮菜单应该显示。</p>
|
||||
<p>继续滚动以测试功能...</p>
|
||||
|
||||
<div style="margin-top: 500px;">
|
||||
<h3>中间内容</h3>
|
||||
<p>这里是一些中间内容,用于测试滚动效果。</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 500px;">
|
||||
<h3>底部内容</h3>
|
||||
<p>这里是底部内容。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const floatNav = document.getElementById('floatNav');
|
||||
const staticNav = document.querySelector('.static-nav');
|
||||
const scrollYSpan = document.getElementById('scrollY');
|
||||
const navTopSpan = document.getElementById('navTop');
|
||||
const floatStatusSpan = document.getElementById('floatStatus');
|
||||
|
||||
let isFloatVisible = false;
|
||||
|
||||
function updateFloatNav() {
|
||||
const scrollY = window.scrollY;
|
||||
const navRect = staticNav.getBoundingClientRect();
|
||||
const navTop = navRect.top;
|
||||
|
||||
// 更新调试信息
|
||||
scrollYSpan.textContent = scrollY;
|
||||
navTopSpan.textContent = Math.round(navTop);
|
||||
|
||||
// 判断是否显示悬浮菜单
|
||||
const shouldShow = navTop < -150;
|
||||
|
||||
if (shouldShow && !isFloatVisible) {
|
||||
floatNav.classList.remove('hidden');
|
||||
floatNav.classList.add('visible');
|
||||
isFloatVisible = true;
|
||||
floatStatusSpan.textContent = '显示';
|
||||
console.log('显示悬浮菜单', { scrollY, navTop });
|
||||
} else if (!shouldShow && isFloatVisible) {
|
||||
floatNav.classList.remove('visible');
|
||||
floatNav.classList.add('hidden');
|
||||
isFloatVisible = false;
|
||||
floatStatusSpan.textContent = '隐藏';
|
||||
console.log('隐藏悬浮菜单', { scrollY, navTop });
|
||||
}
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
window.addEventListener('scroll', updateFloatNav);
|
||||
|
||||
// 初始化
|
||||
updateFloatNav();
|
||||
|
||||
console.log('悬浮菜单测试页面已加载');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -1,168 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>测试 @import 格式 CSS API</title>
|
||||
<style>
|
||||
/* 测试 @import 格式的字体加载 */
|
||||
@import url('/api/css?family=Noto+Sans+SC:wght@400;700&format=import&subset=chinese&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans SC', sans-serif;
|
||||
margin: 40px;
|
||||
line-height: 1.6;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.font-normal {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>@import 格式 CSS API 测试</h1>
|
||||
|
||||
<div class="status info">
|
||||
<strong>测试说明:</strong>此页面使用 @import 格式加载字体,验证修改后的 API 是否正常工作。
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>字体加载测试</h2>
|
||||
<p class="font-normal">这是 400 字重的文本:你好世界!Hello World! 1234567890</p>
|
||||
<p class="font-bold">这是 700 字重的文本:你好世界!Hello World! 1234567890</p>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>中文字符测试</h2>
|
||||
<p class="font-normal">常用汉字:的一是在不了有和人这中大为上个国我以要他时来用们生到作地于出就分对成会可主发年动同工也能下过子说产种面而方后多定行学法所民得经十三之进着等部度家电力里如水化高自二理起小物现实加量都两体制机当使点从业本去把性好应开它合还因由其些然前外天政四日那社义事平形相全表间样与关各重新线内数正心反你明看原又么利比或但质气第向道命此变条只没结解问意建月公无系军很情者最立代想已通并提直题党程展五果料象员革位入常文总次品式活设及管特件长求老头基资边流路级少图山统接知较将组见计别她手角期根论运农指几九区强放决西被干做必战先回则任取据处队南给色光门即保治北造百规热领七海口东导器压志世金增争济阶油思术极交受联什认六共权收证改清己美再采转更单风切打白教速花带安场身车例真务具万每目至达走积示议声报斗完类八离华名确才科张信马节话米整空元况今集温传土许步群广石记需段研界拉林律叫且究观越织装影算低持音众书布复容儿须际商非验连断深难近矿千周委素技备半办青省列习响约支般史感劳便团往酸历市克何除消构府称太准精值号率族维划选标写存候毛亲快效斯院查江型眼王按格养易置派层片始却专状育厂京识适属圆包火住调满县局照参红细引听该铁价严龙飞"</p>
|
||||
</div>
|
||||
|
||||
<div class="test-section">
|
||||
<h2>API 响应检查</h2>
|
||||
<div id="api-status">正在检查 API 响应...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 检查 API 响应
|
||||
async function checkApiResponse() {
|
||||
const statusDiv = document.getElementById('api-status');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/css?family=Noto+Sans+SC:wght@400;700&format=import&subset=chinese&display=swap');
|
||||
|
||||
if (response.ok) {
|
||||
const content = await response.text();
|
||||
console.log('API 响应内容:', content);
|
||||
|
||||
if (content.includes('@import')) {
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status success">
|
||||
<strong>✓ API 响应正常</strong><br>
|
||||
Content-Type: ${response.headers.get('content-type')}<br>
|
||||
响应长度: ${content.length} 字符<br>
|
||||
包含 @import 语句: ${content.split('@import').length - 1} 个
|
||||
</div>
|
||||
<details>
|
||||
<summary>查看响应内容</summary>
|
||||
<pre style="background: #f8f9fa; padding: 10px; border-radius: 4px; overflow-x: auto;">${content}</pre>
|
||||
</details>
|
||||
`;
|
||||
} else {
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status error">
|
||||
<strong>✗ API 响应格式错误</strong><br>
|
||||
响应内容不包含 @import 语句
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status error">
|
||||
<strong>✗ API 请求失败</strong><br>
|
||||
状态码: ${response.status}<br>
|
||||
状态文本: ${response.statusText}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.innerHTML = `
|
||||
<div class="status error">
|
||||
<strong>✗ 请求异常</strong><br>
|
||||
错误信息: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 页面加载完成后检查 API
|
||||
document.addEventListener('DOMContentLoaded', checkApiResponse);
|
||||
|
||||
// 检查字体是否加载成功
|
||||
document.fonts.ready.then(() => {
|
||||
console.log('所有字体加载完成');
|
||||
const testElement = document.createElement('span');
|
||||
testElement.style.fontFamily = 'Noto Sans SC';
|
||||
testElement.textContent = '测试';
|
||||
document.body.appendChild(testElement);
|
||||
|
||||
const computedStyle = window.getComputedStyle(testElement);
|
||||
console.log('实际使用的字体:', computedStyle.fontFamily);
|
||||
|
||||
document.body.removeChild(testElement);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -7,8 +7,8 @@ import { Copy } from '@/components/icons/Copy';
|
|||
export default function CodeChip({ content }: { content: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const handleClick = () => {
|
||||
if (!navigator.clipboard) {
|
||||
// Clipboard API not supported - fallback handled silently
|
||||
if (typeof window === 'undefined' || !navigator.clipboard) {
|
||||
// Clipboard API not supported or running on server - fallback handled silently
|
||||
return;
|
||||
}
|
||||
setCopied(true);
|
||||
|
|
|
@ -42,12 +42,21 @@ export default function ActionArea({ fontInfo }: { fontInfo: TypefaceOutput }) {
|
|||
useCssLoader(fontInfo, shouldLoadCss ? fontWeight : undefined);
|
||||
|
||||
const saveText = () => {
|
||||
domToPng(document.getElementById('previewArea')!)
|
||||
// 确保只在客户端执行
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const previewElement = document.getElementById('previewArea');
|
||||
if (!previewElement) return;
|
||||
|
||||
domToPng(previewElement)
|
||||
.then((dataUrl) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.setAttribute('download', 'wenfeng-text.png');
|
||||
link.click();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to save screenshot:', error);
|
||||
});
|
||||
};
|
||||
return (
|
||||
|
|
255
src/components/fonts/EmbedClient.tsx
Normal file
255
src/components/fonts/EmbedClient.tsx
Normal file
|
@ -0,0 +1,255 @@
|
|||
'use client';
|
||||
|
||||
import { Card, SegmentedControl, Tabs, Button, Stack, Text } from '@mantine/core';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { TypefaceOutput } from '@/types/typing';
|
||||
import { getFontUseName } from '@/utils/fontHelper';
|
||||
import { useAppSelector } from '@/store/hooks';
|
||||
import CodeChip from '@/components/code/CodeChip';
|
||||
import { APP_CONFIG } from '@/lib/config';
|
||||
|
||||
interface EmbedClientProps {
|
||||
previewInfo: TypefaceOutput;
|
||||
}
|
||||
|
||||
export default function EmbedClient({ previewInfo }: EmbedClientProps) {
|
||||
const [tab, setTab] = useState<string>('link');
|
||||
const [subset, setSubset] = useState<string>('');
|
||||
const [selectedFonts, setSelectedFonts] = useState<string[]>([previewInfo.id]);
|
||||
const [includeAllSubsets, setIncludeAllSubsets] = useState(true);
|
||||
|
||||
// 获取收藏的字体列表
|
||||
const collectedFonts = useAppSelector((state) => state.counter?.selects || []);
|
||||
|
||||
// 合并当前字体和收藏字体
|
||||
const allFonts = useMemo(() => {
|
||||
if (!previewInfo) return collectedFonts;
|
||||
|
||||
// 检查当前字体是否已在收藏列表中
|
||||
const isCurrentFontCollected = collectedFonts.some(font => font.id === previewInfo.id);
|
||||
|
||||
if (isCurrentFontCollected) {
|
||||
return collectedFonts;
|
||||
}
|
||||
return [previewInfo, ...collectedFonts];
|
||||
}, [previewInfo, collectedFonts]);
|
||||
|
||||
// 获取选中的字体数据
|
||||
const selectedFontData = useMemo(
|
||||
() => allFonts.filter(font => selectedFonts.includes(font.id)),
|
||||
[allFonts, selectedFonts]
|
||||
);
|
||||
|
||||
// 生成最终嵌入代码
|
||||
const finalEmbedCode = useMemo(() => {
|
||||
if (selectedFontData.length === 0) {
|
||||
return { link: '', importUrl: '', linkZh: '', importUrlZh: '' };
|
||||
}
|
||||
|
||||
// 生成字体查询参数
|
||||
const fontQueries = selectedFontData.map(font => {
|
||||
const fontFamily = getFontUseName(font);
|
||||
const weights = includeAllSubsets
|
||||
? font.fontSubfamily.join(';')
|
||||
: font.fontSubfamily[0] || '400';
|
||||
return `${fontFamily}:wght@${weights}`;
|
||||
}).join('|');
|
||||
|
||||
// 生成仅中文字体的查询参数
|
||||
const fontQueriesZh = selectedFontData.map(font => {
|
||||
const fontFamily = getFontUseName(font);
|
||||
const weights = includeAllSubsets
|
||||
? font.fontSubfamily.join(';')
|
||||
: font.fontSubfamily[0] || '400';
|
||||
return `${fontFamily}:wght@${weights}:lang@zh`;
|
||||
}).join('|');
|
||||
|
||||
// 使用绝对路径,因为这是要给别人使用的嵌入代码
|
||||
const linkUrl = `${APP_CONFIG.baseUrl}/api/css?family=${fontQueries}`;
|
||||
const linkZhUrl = `${APP_CONFIG.baseUrl}/api/css?family=${fontQueriesZh}`;
|
||||
|
||||
const dnsPreconnect = '<link rel=\'preconnect\' href=\'https://cn.windfonts.com\' crossorigin>';
|
||||
const importUrl = `@import url('${linkUrl}');`;
|
||||
const importZhUrl = `@import url('${linkZhUrl}');`;
|
||||
|
||||
const link = `${dnsPreconnect}\n<link rel="stylesheet" crossorigin="anonymous" href="${linkUrl}">\n<!-- 此中文网页字体由文风字体(Windfonts)免费提供,您可以自由引用,请务必保留此授权许可标注 https://wenfeng.org/license -->`;
|
||||
const linkZh = `${dnsPreconnect}\n<link rel="stylesheet" crossorigin="anonymous" href="${linkZhUrl}">\n<!-- 此中文网页字体由文风字体(Windfonts)免费提供,您可以自由引用,请务必保留此授权许可标注 https://wenfeng.org/license -->`;
|
||||
|
||||
const importUrlCode = `<style>\n${importUrl}\n</style>\n<!-- 此中文网页字体由文风字体(Windfonts)免费提供,您可以自由引用,请务必保留此授权许可标注 https://wenfeng.org/license -->`;
|
||||
const importUrlZh = `<style>\n${importZhUrl}\n</style>\n<!-- 此中文网页字体由文风字体(Windfonts)免费提供,您可以自由引用,请务必保留此授权许可标注 https://wenfeng.org/license -->`;
|
||||
|
||||
return { link, importUrl: importUrlCode, linkZh, importUrlZh };
|
||||
}, [selectedFontData, includeAllSubsets]);
|
||||
|
||||
// 生成最终 CSS 使用示例
|
||||
const finalCssExample = useMemo(() => {
|
||||
if (selectedFontData.length === 0) return '';
|
||||
|
||||
const fontExamples = selectedFontData.map(font => {
|
||||
const fontUseNameLocal = getFontUseName(font);
|
||||
const availableWeights = includeAllSubsets
|
||||
? font.fontSubfamily
|
||||
: [font.fontSubfamily[0] || '400'];
|
||||
|
||||
// 为每个字重生成示例
|
||||
const weightExamples = availableWeights.map(weight =>
|
||||
`.wf-font-${font.fontFamily.replace(/\s+/g, '-').toLowerCase()}-${weight.toLowerCase()} {\n font-family: '${fontUseNameLocal}';\n font-weight: ${weight};\n font-style: normal;\n}`
|
||||
).join('\n\n');
|
||||
|
||||
return `/* ${font.name} */\n${weightExamples}`;
|
||||
}).join('\n\n');
|
||||
|
||||
return `// 字体使用示例\n// font-weight: 字体权重值 决定字体粗细,存在多个变体时,使用变体值 \n// 注:若没有生效可使用 100 - 900 之间的值,若还是没生效说明该变体中没有匹配的样式 \n// font-style: 字体样式 normal | italic | oblique 控制字体风格,可展示倾斜样式\n\n${fontExamples}`;
|
||||
}, [selectedFontData, includeAllSubsets]);
|
||||
|
||||
// 处理字体选择变化
|
||||
const handleFontToggle = (fontId: string) => {
|
||||
// 当前字体不能被取消选择
|
||||
if (fontId === previewInfo.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedFonts(prev =>
|
||||
prev.includes(fontId)
|
||||
? prev.filter(selectedId => selectedId !== fontId)
|
||||
: [...prev, fontId]
|
||||
);
|
||||
};
|
||||
|
||||
// 全选/取消全选
|
||||
const handleSelectAll = () => {
|
||||
if (selectedFonts.length === allFonts.length) {
|
||||
// 取消全选时,只保留当前字体
|
||||
setSelectedFonts([previewInfo.id]);
|
||||
} else {
|
||||
setSelectedFonts(allFonts.map(font => font.id));
|
||||
}
|
||||
};
|
||||
|
||||
const segmentData = useMemo(() =>
|
||||
previewInfo ? [{ label: '全部', value: '' }, ...previewInfo.fontSubfamily.map(item => ({ label: item, value: item }))] : [],
|
||||
[previewInfo]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{previewInfo.fontSubfamily.length > 1 && (
|
||||
<SegmentedControl
|
||||
data={segmentData}
|
||||
value={subset}
|
||||
onChange={setSubset}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 多字体嵌入界面 */}
|
||||
<div className="flex flex-col gap-4 mt-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-md font-medium">选择字体</h3>
|
||||
{
|
||||
collectedFonts.length > 0 && <Text size="sm" c="dimmed"> (已选择 {collectedFonts.length} 个字体) </Text>
|
||||
}
|
||||
</div>
|
||||
{allFonts.length > 1 && (
|
||||
<Button variant="subtle" size="xs" onClick={handleSelectAll}>
|
||||
{selectedFonts.length === allFonts.length ? '取消全选' : '全选'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Stack gap="xs">
|
||||
{allFonts.map((font) => {
|
||||
const isCurrentFont = font.id === previewInfo.id;
|
||||
return (
|
||||
<div key={font.id} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`font-${font.id}`}
|
||||
checked={selectedFonts.includes(font.id)}
|
||||
onChange={() => handleFontToggle(font.id)}
|
||||
disabled={isCurrentFont}
|
||||
className={`w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2 ${isCurrentFont ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`font-${font.id}`}
|
||||
className={`text-sm font-medium ${isCurrentFont
|
||||
? 'text-gray-500 dark:text-gray-500'
|
||||
: 'text-gray-900 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{`${font.name} (${font.fontSubfamily.join(', ')})${isCurrentFont ? ' - 当前字体' : ''}`}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="include-all-subsets"
|
||||
checked={includeAllSubsets}
|
||||
onChange={(event) => setIncludeAllSubsets(event.currentTarget.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 focus:ring-2"
|
||||
/>
|
||||
<label htmlFor="include-all-subsets" className="text-sm font-medium text-gray-900 dark:text-gray-300">
|
||||
包含所有字重变体
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedFonts.length > 0 && (
|
||||
<>
|
||||
<Tabs className="mt-6" value={tab} onChange={(val) => val && setTab(val)}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="link">link</Tabs.Tab>
|
||||
<Tabs.Tab value="import">@import</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="link">
|
||||
<Card withBorder radius="sm" shadow="sm" className="mt-6">
|
||||
<Card.Section className="flex-col items-start pl-6 pt-5">
|
||||
<h1 className="mb-2 font-semibold">1.引入web项目</h1>
|
||||
<p className="ml-2">将代码放入你的 {'<head>'} 标签内</p>
|
||||
</Card.Section>
|
||||
<Card.Section className="p-8">
|
||||
<CodeChip content={finalEmbedCode.link} />
|
||||
</Card.Section>
|
||||
<h4 className="ml-4 font-medium">如果需要仅包含中文字符字体的样式,请仅引入下面的代码</h4>
|
||||
<Card.Section className="px-8 py-4">
|
||||
<CodeChip content={finalEmbedCode.linkZh} />
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="import">
|
||||
<Card withBorder radius="sm" shadow="sm" className="mt-6">
|
||||
<Card.Section className="flex-col items-start pl-6 pt-5">
|
||||
<h1 className="mb-2 font-semibold">1.引入web项目</h1>
|
||||
<p className="ml-2">将代码放入你的 {'<head>'} 标签内</p>
|
||||
</Card.Section>
|
||||
<Card.Section className="p-8">
|
||||
<CodeChip content={finalEmbedCode.importUrl} />
|
||||
</Card.Section>
|
||||
<h4 className="ml-4 font-medium">仅使用中文字符的字体样式,请仅引入下面的代码</h4>
|
||||
<Card.Section className="px-8 py-4">
|
||||
<CodeChip content={finalEmbedCode.importUrlZh} />
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Card withBorder radius="sm" shadow="sm" className="mt-6 min-h-40">
|
||||
<Card.Section className="flex-col items-start pl-6 pt-5">
|
||||
<h1 className="mb-2 font-semibold">2.使用</h1>
|
||||
<p>代码使用示例</p>
|
||||
</Card.Section>
|
||||
<Card.Section className="mt-6 p-8 pt-2">
|
||||
<CodeChip content={finalCssExample} />
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
31
src/components/fonts/FontDataProvider.tsx
Normal file
31
src/components/fonts/FontDataProvider.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
'use client';
|
||||
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { TypefaceOutput } from '@/types/typing';
|
||||
|
||||
interface FontDataContextType {
|
||||
previewInfo: TypefaceOutput;
|
||||
}
|
||||
|
||||
const FontDataContext = createContext<FontDataContextType | null>(null);
|
||||
|
||||
interface FontDataProviderProps {
|
||||
children: React.ReactNode;
|
||||
previewInfo: TypefaceOutput;
|
||||
}
|
||||
|
||||
export function FontDataProvider({ children, previewInfo }: FontDataProviderProps) {
|
||||
return (
|
||||
<FontDataContext.Provider value={{ previewInfo }}>
|
||||
{children}
|
||||
</FontDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useFontData() {
|
||||
const context = useContext(FontDataContext);
|
||||
if (!context) {
|
||||
throw new Error('useFontData must be used within a FontDataProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
|
@ -73,6 +73,9 @@ export default function FontsClientWrapper({ initialFonts }: FontsClientWrapperP
|
|||
|
||||
// 监听滚动位置变化
|
||||
useEffect(() => {
|
||||
// 确保只在客户端执行
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrollPosition(window.scrollY);
|
||||
};
|
||||
|
@ -83,6 +86,9 @@ export default function FontsClientWrapper({ initialFonts }: FontsClientWrapperP
|
|||
|
||||
// 从详情页返回时恢复滚动位置
|
||||
useEffect(() => {
|
||||
// 确保只在客户端执行
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const savedPosition = sessionStorage.getItem('fonts-scroll-position');
|
||||
const savedPage = sessionStorage.getItem('fonts-current-page');
|
||||
|
||||
|
@ -101,6 +107,9 @@ export default function FontsClientWrapper({ initialFonts }: FontsClientWrapperP
|
|||
|
||||
// 保存当前状态的函数
|
||||
const saveCurrentState = () => {
|
||||
// 确保只在客户端执行
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
sessionStorage.setItem('fonts-scroll-position', scrollPosition.toString());
|
||||
sessionStorage.setItem('fonts-current-page', currentPage.toString());
|
||||
};
|
||||
|
|
|
@ -62,26 +62,26 @@ export default function ItemFont({
|
|||
{isHovered && (
|
||||
status === 'operation' ? (
|
||||
<div className="flex gap-4">
|
||||
<Button variant="transparent" className="text-[#27272A]">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
rel="noreferrer"
|
||||
<Button variant="transparent" className="text-[#27272A]">
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Download height={16} width={16} />
|
||||
<span>下载字体</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
className="bg-red-500"
|
||||
radius="xl"
|
||||
onClick={() => dispatch(remove(info.id))}
|
||||
>
|
||||
<Download height={16} width={16} />
|
||||
<span>下载字体</span>
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
className="bg-red-500"
|
||||
radius="xl"
|
||||
onClick={() => dispatch(remove(info.id))}
|
||||
>
|
||||
<Delete width={20} height={20} />
|
||||
<span className="ml-1">移除字体</span>
|
||||
</Button>
|
||||
<Delete width={20} height={20} />
|
||||
<span className="ml-1">移除字体</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
|
@ -93,7 +93,7 @@ export default function ItemFont({
|
|||
onClick={() => dispatch(remove(info.id))}
|
||||
>
|
||||
<Delete width={20} height={20} />
|
||||
<span className="ml-1">移除字体</span>
|
||||
<span className="ml-1">移除选择</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
@ -103,7 +103,7 @@ export default function ItemFont({
|
|||
onClick={() => dispatch(add(info))}
|
||||
>
|
||||
<BookMark width={20} height={20} />
|
||||
<span className="ml-1">收藏字体</span>
|
||||
<span className="ml-1">加入选择</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
@ -120,7 +120,7 @@ export default function ItemFont({
|
|||
}
|
||||
}}
|
||||
>
|
||||
{txt || '文风字体,感受文字之美'}
|
||||
{txt || '文风字体'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -36,7 +36,7 @@ export default function Operation({ fontInfo }: { fontInfo: TypefaceOutput }) {
|
|||
onClick={() => fontInfo && dispatch(add(fontInfo))}
|
||||
>
|
||||
<BookMark width={20} height={20} />
|
||||
<span className="ml-1">收藏字体</span>
|
||||
<span className="ml-1">添加字体</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -17,8 +17,9 @@ export default function LinkTabs({ previewInfo }: { previewInfo: TypefaceOutput
|
|||
const weights = subfamilies.join(',');
|
||||
|
||||
// 生成 link 标签格式的嵌入代码
|
||||
const linkUrl = `${window.location.origin}/api/css?name=${fontFamily}:wght@${weights}&display=swap&format=css`;
|
||||
const linkZhUrl = `${window.location.origin}/api/css?name=${fontFamily}:wght@${weights}&display=swap&lang=zh&format=css`;
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const linkUrl = `${origin}/api/css?family=${fontFamily}:wght@${weights}&display=swap&format=css`;
|
||||
const linkZhUrl = `${origin}/api/css?family=${fontFamily}:wght@${weights}&display=swap&lang=zh&format=css`;
|
||||
|
||||
// 生成 @import 格式的嵌入代码
|
||||
const importUrl = `@import url('${linkUrl}');`;
|
||||
|
|
|
@ -15,21 +15,28 @@ export default function useCssLoader(info: TypefaceOutput, current?: string) {
|
|||
const loadedStyleElements: HTMLStyleElement[] = [];
|
||||
|
||||
const loadFontCSS = async () => {
|
||||
// 确保只在客户端执行
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const base = (APP_CONFIG.baseUrl || '').replace(/\/+$/, '');
|
||||
const path = `/api/css?name=${info.fontFamily}&subset=${curr}`;
|
||||
const path = `/api/css?family=${info.fontFamily}&subset=${curr}`;
|
||||
const url = base ? `${base}${path}` : path;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
if (!data) return;
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = data;
|
||||
styleElement.setAttribute('data-font-family', info.fontFamily);
|
||||
styleElement.setAttribute('data-font-subset', current);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (response.ok) {
|
||||
const data = await response.text();
|
||||
if (!data) return;
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = data;
|
||||
styleElement.setAttribute('data-font-family', info.fontFamily);
|
||||
styleElement.setAttribute('data-font-subset', current);
|
||||
|
||||
document.head.appendChild(styleElement);
|
||||
loadedStyleElements.push(styleElement);
|
||||
document.head.appendChild(styleElement);
|
||||
loadedStyleElements.push(styleElement);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load font CSS:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -36,6 +36,9 @@ export const useHeadingsData = (page: string) => {
|
|||
const [nestedHeadings, setNestedHeadings] = useState<HeadingsData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const headingElements = Array.from(document.querySelectorAll('h2, h3'));
|
||||
|
||||
const newNestedHeadings = getNestedHeadings(headingElements as HTMLHeadElement[]);
|
||||
|
|
|
@ -9,6 +9,9 @@ export const useIntersectionObserver = (
|
|||
const headingElementsRef = useRef<HeadingIntersectionEntry>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
headingElementsRef.current = {}; // Reset ref on page change to update active states
|
||||
const headingElements = Array.from(document.querySelectorAll('h2, h3'));
|
||||
|
||||
|
|
|
@ -16,6 +16,9 @@ export function useViewportBasedPagination(
|
|||
|
||||
useEffect(() => {
|
||||
const calculateItemsPerPage = () => {
|
||||
// 确保只在客户端执行
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// 获取视窗高度
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
export function cssLoader(src: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const style = document.createElement('link');
|
||||
style.setAttribute('href', src);
|
||||
style.rel = 'stylesheet';
|
||||
|
@ -18,6 +21,9 @@ export function cssLoader(src: string) {
|
|||
}
|
||||
|
||||
export function removeCssLoader(src: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const head = document.getElementsByTagName('head')[0] || document.body;
|
||||
head.childNodes.forEach((node) => {
|
||||
// @ts-ignore
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue