Compare commits
4 commits
d4541c1a5e
...
5ba6d4c5c3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ba6d4c5c3 | ||
|
|
c3e641ed88 | ||
|
|
30f7c7bd53 | ||
|
|
ab00d7d1a0 |
38 changed files with 2479 additions and 1230 deletions
53
.dockerignore
Normal file
53
.dockerignore
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# 依赖
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
build
|
||||
|
||||
# LICENSE 文件(避免构建错误)
|
||||
**/LICENSE
|
||||
**/LICENSE.txt
|
||||
**/LICENSE.md
|
||||
|
||||
# 测试
|
||||
coverage
|
||||
|
||||
# 环境变量
|
||||
.env*.local
|
||||
.env.development
|
||||
|
||||
# 数据库(保留 prod.db)
|
||||
data/dev.db
|
||||
data/*.db-shm
|
||||
data/*.db-wal
|
||||
|
||||
# 日志
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# 其他
|
||||
.trae
|
||||
*.pem
|
||||
.vercel
|
||||
README.md
|
||||
28
.env.example
28
.env.example
|
|
@ -1,23 +1,17 @@
|
|||
# 环境标识
|
||||
NODE_ENV=development
|
||||
|
||||
# 数据库配置
|
||||
DATABASE_URL=file:./data/dev.db
|
||||
|
||||
# NextAuth.js 认证配置
|
||||
AUTH_SECRET=your-super-secret-key-change-this-in-production
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
|
||||
# 管理员账号配置
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin123
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# OSS 配置
|
||||
OSS_ENDPOINT=https://your-oss-endpoint.aliyuncs.com
|
||||
OSS_REGION=your-region
|
||||
OSS_BUCKET=your-bucket
|
||||
OSS_FONT_PREFIX=fonts
|
||||
OSS_FOLDER=font-packages
|
||||
OSS_ACCESS_KEY_ID=your-access-key
|
||||
OSS_ACCESS_KEY_SECRET=your-secret-key
|
||||
OSS_ENDPOINT=your-endpoint
|
||||
|
||||
# 字体静态 URL (CDN)
|
||||
NEXT_PUBLIC_FONT_STATIC_URL=https://your-cdn.com
|
||||
# NextAuth 配置
|
||||
NEXTAUTH_URL=http://localhost:4000
|
||||
NEXTAUTH_SECRET=your-secret-key-here
|
||||
|
||||
# 管理员配置
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=your-password-here
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -51,3 +51,6 @@ next-env.d.ts
|
|||
# logs
|
||||
/logs
|
||||
*.log
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
|
@ -22,4 +22,4 @@
|
|||
"[json]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
}
|
||||
62
Dockerfile
Normal file
62
Dockerfile
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# 多阶段构建 - 基础镜像
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# 安装依赖阶段
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖文件
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
# 构建阶段
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# 设置环境变量
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# 构建应用
|
||||
RUN npm run build
|
||||
|
||||
# 运行阶段 - 使用 standalone 输出
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV AUTH_TRUST_HOST=true
|
||||
ENV DATABASE_URL=file:./data/prod.db
|
||||
|
||||
# 创建非 root 用户
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# 只复制 standalone 输出
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
# 创建数据和日志目录
|
||||
RUN mkdir -p /app/data /app/logs && chown -R nextjs:nodejs /app/data /app/logs
|
||||
|
||||
# 复制本地初始化好的数据库
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/data/prod.db /app/data/prod.db
|
||||
|
||||
# 复制环境变量文件和启动脚本
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.env.production ./.env.production
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts/start.sh ./start.sh
|
||||
RUN chmod +x ./start.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
ENV PORT=4000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["./start.sh"]
|
||||
|
|
@ -10,16 +10,18 @@ import { notFound } from 'next/navigation';
|
|||
import { FontForm } from '../../font-form';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditFontPage({ params }: PageProps) {
|
||||
await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// 获取字体详情 - 使用 normalizedName
|
||||
const font = await fontService.findByNormalizedName(params.id);
|
||||
const font = await fontService.findByNormalizedName(id);
|
||||
if (!font) {
|
||||
notFound();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,16 +10,18 @@ import Link from 'next/link';
|
|||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface PageProps {
|
||||
params: {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function FontDetailPage({ params }: PageProps) {
|
||||
await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
// 获取字体详情(带关联数据)- 使用 normalizedName
|
||||
const font = await fontService.findByNormalizedNameWithRelations(params.id);
|
||||
const font = await fontService.findByNormalizedNameWithRelations(id);
|
||||
if (!font) {
|
||||
notFound();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,8 +45,7 @@ export default async function FontManagementPage({ searchParams }: PageProps) {
|
|||
const categoryId = params.categoryId || undefined;
|
||||
const brandId = params.brandId || undefined;
|
||||
const sort =
|
||||
(params.sort as 'name' | 'viewCount' | 'downloadCount' | 'createdAt' | 'updatedAt') ||
|
||||
'createdAt';
|
||||
(params.sort as 'name' | 'viewCount' | 'downloadCount' | 'createdAt') || 'createdAt';
|
||||
const order = (params.order as 'asc' | 'desc') || 'desc';
|
||||
|
||||
// 获取字体列表
|
||||
|
|
|
|||
17
app/admin/layout.tsx
Normal file
17
app/admin/layout.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { getSession } from '@/lib/auth/session';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const session = await getSession();
|
||||
const disableAuth = process.env.DISABLE_AUTH === 'true';
|
||||
|
||||
if (!disableAuth && !session?.user) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { handleApiError } from '@/lib/auth/api-guard';
|
||||
import { brandService } from '@/lib/services/brand.service';
|
||||
import { brandUpdateSchema } from '@/lib/services/validation';
|
||||
import { handleApiError } from '@/lib/auth/api-guard';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
/**
|
||||
|
|
@ -9,9 +9,10 @@ import { ZodError } from 'zod';
|
|||
* 获取品牌详情
|
||||
* 公开访问
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const brand = await brandService.findById(params.id);
|
||||
const { id } = await params;
|
||||
const brand = await brandService.findById(id);
|
||||
|
||||
if (!brand) {
|
||||
return NextResponse.json(
|
||||
|
|
@ -39,19 +40,20 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||
* 更新品牌
|
||||
* 需要管理员认证
|
||||
*/
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const { verifyAdmin } = await import('@/lib/auth/api-guard');
|
||||
await verifyAdmin();
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
// 验证数据
|
||||
const validated = brandUpdateSchema.parse(body);
|
||||
|
||||
// 更新品牌
|
||||
const brand = await brandService.update(params.id, validated);
|
||||
const brand = await brandService.update(id, validated);
|
||||
|
||||
return NextResponse.json({
|
||||
code: 200,
|
||||
|
|
@ -107,13 +109,14 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
|
|||
* 删除品牌
|
||||
* 需要管理员认证
|
||||
*/
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const { verifyAdmin } = await import('@/lib/auth/api-guard');
|
||||
await verifyAdmin();
|
||||
|
||||
await brandService.delete(params.id);
|
||||
const { id } = await params;
|
||||
await brandService.delete(id);
|
||||
|
||||
return NextResponse.json({
|
||||
code: 200,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { handleApiError } from '@/lib/auth/api-guard';
|
||||
import { categoryService } from '@/lib/services/category.service';
|
||||
import { categoryUpdateSchema } from '@/lib/services/validation';
|
||||
import { handleApiError } from '@/lib/auth/api-guard';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
/**
|
||||
|
|
@ -9,9 +9,10 @@ import { ZodError } from 'zod';
|
|||
* 获取分类详情
|
||||
* 公开访问
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const category = await categoryService.findById(params.id);
|
||||
const { id } = await params;
|
||||
const category = await categoryService.findById(id);
|
||||
|
||||
if (!category) {
|
||||
return NextResponse.json(
|
||||
|
|
@ -39,19 +40,20 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||
* 更新分类
|
||||
* 需要管理员认证
|
||||
*/
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const { verifyAdmin } = await import('@/lib/auth/api-guard');
|
||||
await verifyAdmin();
|
||||
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
// 验证数据
|
||||
const validated = categoryUpdateSchema.parse(body);
|
||||
|
||||
// 更新分类
|
||||
const category = await categoryService.update(params.id, validated);
|
||||
const category = await categoryService.update(id, validated);
|
||||
|
||||
return NextResponse.json({
|
||||
code: 200,
|
||||
|
|
@ -107,13 +109,14 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
|
|||
* 删除分类
|
||||
* 需要管理员认证
|
||||
*/
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const { verifyAdmin } = await import('@/lib/auth/api-guard');
|
||||
await verifyAdmin();
|
||||
|
||||
await categoryService.delete(params.id);
|
||||
const { id } = await params;
|
||||
await categoryService.delete(id);
|
||||
|
||||
return NextResponse.json({
|
||||
code: 200,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cssService } from '@/lib/services/css.service';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { cssService } from '@/lib/services/css.service';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
/**
|
||||
|
|
@ -41,10 +41,13 @@ export async function GET(request: NextRequest) {
|
|||
}
|
||||
|
||||
// 生成CSS
|
||||
// 将 subset/lang 参数映射到 version
|
||||
const version = (subset || lang || 'full') as 'en' | 'zh' | 'zh-common' | 'full';
|
||||
const weight = searchParams.get('weight') || 'Regular';
|
||||
const { css, etag } = await cssService.generateCSS({
|
||||
family,
|
||||
subset,
|
||||
lang,
|
||||
version,
|
||||
weight,
|
||||
});
|
||||
|
||||
// 检查条件请求(If-None-Match)
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@ import { ZodError } from 'zod';
|
|||
* 公开访问
|
||||
* 支持通过 ID 或 normalizedName 查询
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = params;
|
||||
const { id } = await params;
|
||||
|
||||
// 尝试通过 normalizedName 查询,如果失败则通过 ID 查询
|
||||
let font = await fontService.findByNormalizedName(id);
|
||||
|
|
@ -50,13 +50,13 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||
* 需要管理员认证
|
||||
* 支持通过 ID 或 normalizedName 查询
|
||||
*/
|
||||
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const { verifyAdmin } = await import('@/lib/auth/api-guard');
|
||||
await verifyAdmin();
|
||||
|
||||
const { id } = params;
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
// 检查字体是否存在 - 支持 normalizedName 或 ID
|
||||
|
|
@ -124,13 +124,13 @@ export async function PUT(req: NextRequest, { params }: { params: { id: string }
|
|||
* 需要管理员认证
|
||||
* 支持通过 ID 或 normalizedName 查询
|
||||
*/
|
||||
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const { verifyAdmin } = await import('@/lib/auth/api-guard');
|
||||
await verifyAdmin();
|
||||
|
||||
const { id } = params;
|
||||
const { id } = await params;
|
||||
|
||||
// 检查字体是否存在 - 支持 normalizedName 或 ID
|
||||
let existing = await fontService.findByNormalizedName(id);
|
||||
|
|
|
|||
12
app/api/health/route.ts
Normal file
12
app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
|
|
@ -11,10 +11,7 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||
* - 统一缓存策略
|
||||
* - 记录访问日志
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
ctx: { params: { path: string[] } | Promise<{ path: string[] }> }
|
||||
) {
|
||||
export async function GET(request: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
try {
|
||||
const { path } = await ctx.params;
|
||||
const normalizedPath = path.join('/');
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@ const styleUpdateSchema = z.object({
|
|||
* 获取单个风格
|
||||
* 公开访问
|
||||
*/
|
||||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||
export async function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const style = await styleService.findById(params.id);
|
||||
const { id } = await params;
|
||||
const style = await styleService.findById(id);
|
||||
|
||||
if (!style) {
|
||||
return NextResponse.json(
|
||||
|
|
@ -45,62 +46,65 @@ export async function GET(req: NextRequest, { params }: { params: { id: string }
|
|||
* 更新风格
|
||||
* 需要管理员认证
|
||||
*/
|
||||
export const PUT = withAdmin(async (req: NextRequest, { params }: { params: { id: string } }) => {
|
||||
try {
|
||||
const body = await req.json();
|
||||
export const PUT = withAdmin(
|
||||
async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
|
||||
// 验证数据
|
||||
const validated = styleUpdateSchema.parse(body);
|
||||
// 验证数据
|
||||
const validated = styleUpdateSchema.parse(body);
|
||||
|
||||
// 更新风格
|
||||
const style = await styleService.update(params.id, validated);
|
||||
// 更新风格
|
||||
const style = await styleService.update(id, validated);
|
||||
|
||||
return NextResponse.json({
|
||||
code: 200,
|
||||
data: style,
|
||||
message: '更新风格成功',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 400,
|
||||
message: '数据验证失败',
|
||||
status: 'fail',
|
||||
errors: error.errors.map((e) => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
return NextResponse.json({
|
||||
code: 200,
|
||||
data: style,
|
||||
message: '更新风格成功',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 400,
|
||||
message: '数据验证失败',
|
||||
status: 'fail',
|
||||
errors: error.errors.map((e) => ({
|
||||
field: e.path.join('.'),
|
||||
message: e.message,
|
||||
})),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('不存在')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: error.message,
|
||||
status: 'fail',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('已存在')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 422,
|
||||
message: error.message,
|
||||
status: 'fail',
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
return handleApiError(error);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('不存在')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 404,
|
||||
message: error.message,
|
||||
status: 'fail',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('已存在')) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 422,
|
||||
message: error.message,
|
||||
status: 'fail',
|
||||
},
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
return handleApiError(error);
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/styles/[id]
|
||||
|
|
@ -108,9 +112,10 @@ export const PUT = withAdmin(async (req: NextRequest, { params }: { params: { id
|
|||
* 需要管理员认证
|
||||
*/
|
||||
export const DELETE = withAdmin(
|
||||
async (req: NextRequest, { params }: { params: { id: string } }) => {
|
||||
async (req: NextRequest, { params }: { params: Promise<{ id: string }> }) => {
|
||||
try {
|
||||
await styleService.delete(params.id);
|
||||
const { id } = await params;
|
||||
await styleService.delete(id);
|
||||
|
||||
return NextResponse.json({
|
||||
code: 200,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,48 @@ interface FaqItem {
|
|||
}
|
||||
|
||||
const faqData: FaqItem[] = [
|
||||
{
|
||||
category: '费用与授权',
|
||||
question: '使用文风字体需要付费吗?',
|
||||
answer:
|
||||
'不需要,现阶段所有文风在线字体均可免费使用。未来我们可能会尝试与字体厂商合作推出更多优质字体系列,以保证平台的正常运营和长期发展。',
|
||||
},
|
||||
{
|
||||
category: '费用与授权',
|
||||
question: '我可以在商业产品中使用文风字体吗?',
|
||||
answer:
|
||||
'可以,但请注意部分字体存在设计师及公司的个性主观要求限制,使用时您应仔细查看具体的使用协议和场景。字体授权查询:https://wenfeng.org/license',
|
||||
},
|
||||
{
|
||||
category: '费用与授权',
|
||||
question: '如何知道一个字体是否可以商用?',
|
||||
answer:
|
||||
'在字体详情页面会明确标注授权类型。标记为"免费商用"的字体可以在商业项目中使用。如果标记为"个人免费"或"付费授权",则需要购买商业授权。使用前请务必查看详细的授权信息。',
|
||||
},
|
||||
{
|
||||
category: '费用与授权',
|
||||
question: '免费商用字体有什么限制吗?',
|
||||
answer:
|
||||
'大多数免费商用字体可以自由使用,但通常有以下限制:1) 不得单独出售字体文件;2) 部分字体要求保留版权声明;3) 不得修改字体后重新分发。具体限制请查看每个字体的授权协议。',
|
||||
},
|
||||
{
|
||||
category: '平台使用',
|
||||
question: '文风字体只支持中文字体包吗?',
|
||||
answer:
|
||||
'Windfonts 作为国内首个类 Google Fonts 的字体服务平台,我们的愿景是希望可以更好的为中文用户服务。目前收集的字体包大部分都支持中文,其中也包含支持英文字符的字体包。后续的改进和完善工作将会明显标记出支持中英文的标识、缺失字符等,并完善字体语言的分类。',
|
||||
},
|
||||
{
|
||||
category: '平台使用',
|
||||
question: '我可以在哪些平台使用?',
|
||||
answer:
|
||||
'Web:网页 Web 端可直接引入当前字体的 CSS 文件使用。WordPress:我们针对 WordPress 用户专门开发了插件,请下载文派叶子🍃(WP-China-Yes) 插件安装即用。CMS:任何建站系统均可自行对接 Windfonts API 来实现自定义功能集成。APP 及小程序:请访问文风开源字体(Wenfeng.org)下载字体切片包或源文件以满足您的嵌入要求。',
|
||||
},
|
||||
{
|
||||
category: '平台使用',
|
||||
question: '如何实现使用某款字体包时仅对中文字样起作用?',
|
||||
answer:
|
||||
'当您引入字体代码的 CSS 样式文件时,请参照"开始使用"的示例代码,只需要引入仅包含中文字符的 CSS 样式文件即可。',
|
||||
},
|
||||
{
|
||||
category: '基础使用',
|
||||
question: '如何在我的网站中使用字体?',
|
||||
|
|
@ -27,25 +69,13 @@ const faqData: FaqItem[] = [
|
|||
category: '基础使用',
|
||||
question: '可以同时使用多个字体吗?',
|
||||
answer:
|
||||
'可以。使用 | 符号分隔多个字体名称,例如:/api/css?family=思源黑体|思源宋体。但建议不要同时加载过多字体,以免影响页面加载性能。',
|
||||
'可以。使用 | 符号分隔多个字体名称,例如:/api/css?family=WF-Qtxtt|WF-Hxbsb。但建议不要同时加载过多字体,以免影响页面加载性能。',
|
||||
},
|
||||
{
|
||||
category: '授权相关',
|
||||
question: '如何知道一个字体是否可以商用?',
|
||||
category: '技术问题',
|
||||
question: '什么是可变字体?',
|
||||
answer:
|
||||
'在字体详情页面会明确标注授权类型。标记为"免费商用"的字体可以在商业项目中使用。如果标记为"个人免费"或"付费授权",则需要购买商业授权。使用前请务必查看详细的授权信息。',
|
||||
},
|
||||
{
|
||||
category: '授权相关',
|
||||
question: '免费商用字体有什么限制吗?',
|
||||
answer:
|
||||
'大多数免费商用字体可以自由使用,但通常有以下限制:1) 不得单独出售字体文件;2) 部分字体要求保留版权声明;3) 不得修改字体后重新分发。具体限制请查看每个字体的授权协议。',
|
||||
},
|
||||
{
|
||||
category: '授权相关',
|
||||
question: '如何购买付费字体的授权?',
|
||||
answer:
|
||||
'在字体详情页面会显示购买链接或联系方式。您可以直接访问字体厂商的官方网站购买授权,或联系版权方获取报价。购买后请保留授权凭证。',
|
||||
'可变字体是排版领域的新近发展。所有样式都仅存储在一个或两个字体文件中,而不是每个样式都存储在单独的文件中。文风字体将会在未来版本中提供可变字体支持。',
|
||||
},
|
||||
{
|
||||
category: '技术问题',
|
||||
|
|
@ -58,12 +88,6 @@ const faqData: FaqItem[] = [
|
|||
question: 'CSS API 支持 HTTPS 吗?',
|
||||
answer: '是的,CSS API 完全支持 HTTPS。建议在生产环境中使用 HTTPS 以确保安全性。',
|
||||
},
|
||||
{
|
||||
category: '技术问题',
|
||||
question: '可以下载字体文件到本地使用吗?',
|
||||
answer:
|
||||
'为了保护字体版权和确保授权合规,我们不提供字体文件的直接下载。请使用 CSS API 在线加载字体。如需离线使用,请联系字体版权方获取授权。',
|
||||
},
|
||||
{
|
||||
category: '技术问题',
|
||||
question: 'API 有请求频率限制吗?',
|
||||
|
|
@ -71,34 +95,52 @@ const faqData: FaqItem[] = [
|
|||
'目前 CSS API 没有严格的频率限制,但我们建议合理使用。如果您的项目有大量请求需求,建议利用浏览器缓存机制,避免重复请求相同的资源。',
|
||||
},
|
||||
{
|
||||
category: '账号管理',
|
||||
question: '需要注册账号才能使用字体吗?',
|
||||
category: '字体管理',
|
||||
question: '为什么没有 XXX 免费商用字体?',
|
||||
answer:
|
||||
'不需要。浏览和使用字体不需要注册账号。只有管理员需要登录才能管理字体、品牌和分类等后台功能。',
|
||||
'大部分原因可能在于字体厂商的授权方式不允许提供类似 Web 字体服务,所以文风字体不提供这类字体的引用。免费字体并非开源字体,使用时仍需注意区分。',
|
||||
},
|
||||
{
|
||||
category: '账号管理',
|
||||
question: '如何成为管理员?',
|
||||
answer: '管理员账号由系统管理员通过环境变量配置。如果您需要管理权限,请联系系统管理员。',
|
||||
category: '字体管理',
|
||||
question: '我想要使用的字体没有,如何提交?',
|
||||
answer:
|
||||
'如果您确认字体的授权没有问题,Windfonts.com 乐意收录该中文字体提供服务,请通过支持论坛或联系我们发送相关的文件下载及授权信息地址。审核无误后将会上传至字库系统进行切片处理。',
|
||||
},
|
||||
{
|
||||
category: '其他问题',
|
||||
category: '字体管理',
|
||||
question: '我是字体作者/厂商,发现某款字体有问题?',
|
||||
answer:
|
||||
'请通过文风支持论坛、邮箱、联系表单、任何网站公开的方式联系我们,此问题将会在第一时间得到跟进处理。如果您确信需要对某款字体进行下架,根据《民法典》要求您可通过提交侵权投诉或邮件等书面形式发送您(作者信息)及您作品的权利证明(包括但不限于可访问授权网址、版权证书、授权协议等),工作人员确认无误后我们将会下线此字体。虽然对此表示遗憾,但我们尊重作者本身的权利。注意:您的邮件或权利证明信息将在脱敏后面向用户公示,以做记录存证方便未来公众查询、避免再次因字体授权方式不明确导致的侵权行为。',
|
||||
},
|
||||
{
|
||||
category: '字体管理',
|
||||
question: '我可以在 Windfonts 上传自己的商用授权字体吗?',
|
||||
answer: '现阶段不可以,但您仍然值得期待此功能,我们将会在未来开发计划中考虑这一可能性。',
|
||||
},
|
||||
{
|
||||
category: '字体管理',
|
||||
question: '字体列表多久更新一次?',
|
||||
answer:
|
||||
'字体列表通过 OSS 同步功能更新。管理员可以手动触发同步,或设置定期自动同步。新增的字体会在同步后立即显示在列表中。',
|
||||
},
|
||||
{
|
||||
category: '字体管理',
|
||||
question: '发现字体信息错误怎么办?',
|
||||
answer:
|
||||
'如果发现字体信息有误,请联系管理员。管理员可以在后台编辑字体信息,或重新同步 OSS 数据以更新信息。',
|
||||
},
|
||||
{
|
||||
category: '开源与开发',
|
||||
question: 'Windfonts 字体服务器是开源的吗?',
|
||||
answer:
|
||||
'Windfonts Webfonts Server 的诞生离不开各种开源项目的支持,所以这也将会是一款可以自托管的字体服务器软件。我们计划不仅限于支持中英文字体,对 CJK 中日汉简繁及特殊字符字库均有考量。由于现在还处于开发早期,有很多问题需要处理和完善,具体的源代码版本稳定后将全部开源。具体时间待定,您可以关注文风字体官方新闻通告。',
|
||||
},
|
||||
{
|
||||
category: '其他问题',
|
||||
question: '如何搜索字体?',
|
||||
answer:
|
||||
'在字体列表页面使用搜索框输入关键词,系统会搜索字体名称、品牌名称和标签。您也可以使用筛选器按分类、品牌等条件筛选字体。',
|
||||
},
|
||||
{
|
||||
category: '其他问题',
|
||||
question: '发现字体信息错误怎么办?',
|
||||
answer:
|
||||
'如果发现字体信息有误,请联系管理员。管理员可以在后台编辑字体信息,或重新同步 OSS 数据以更新信息。',
|
||||
},
|
||||
];
|
||||
|
||||
export function FaqSection() {
|
||||
|
|
@ -163,17 +205,41 @@ export function FaqSection() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground mb-4 text-sm">
|
||||
如果您的问题没有在上面列出,或需要更多帮助,请联系我们的技术支持团队。
|
||||
如果您的问题没有在上面列出,或需要更多帮助,请通过以下方式联系我们。
|
||||
</p>
|
||||
<div className="space-y-2 text-sm">
|
||||
<p>
|
||||
<strong>邮箱:</strong>{' '}
|
||||
<a href="mailto:support@example.com" className="text-primary hover:underline">
|
||||
support@example.com
|
||||
<strong>官方网站:</strong>{' '}
|
||||
<a
|
||||
href="https://wenfeng.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://wenfeng.org
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<strong>工作时间:</strong> 周一至周五 9:00 - 18:00
|
||||
<strong>授权查询:</strong>{' '}
|
||||
<a
|
||||
href="https://wenfeng.org/license"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://wenfeng.org/license
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<strong>GitHub:</strong>{' '}
|
||||
<a
|
||||
href="https://feicode.com/Windfonts/fonts-vault"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
https://feicode.com/Windfonts/fonts-vault
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ export function FontDetailContent({ font, relatedFonts, analysis }: FontDetailCo
|
|||
// Get available weights from font data
|
||||
const availableWeights = font.weights
|
||||
? Object.values(font.weights).map((w) => ({
|
||||
name: w.weight_name,
|
||||
value: w.font_weight,
|
||||
}))
|
||||
name: w.weight_name,
|
||||
value: w.font_weight,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const firstWeightName = font.weights ? Object.keys(font.weights)[0] : 'Regular';
|
||||
|
|
@ -279,11 +279,10 @@ export function FontDetailContent({ font, relatedFonts, analysis }: FontDetailCo
|
|||
<button
|
||||
key={weight.value}
|
||||
onClick={() => setSelectedWeight(weight.name)}
|
||||
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
|
||||
selectedWeight === weight.name
|
||||
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${selectedWeight === weight.name
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{weight.name}
|
||||
</button>
|
||||
|
|
@ -811,9 +810,10 @@ const MyComponent = () => (
|
|||
{
|
||||
((localAnalysis || analysis)?.total_char_count ??
|
||||
(localAnalysis || analysis)?.char_count ??
|
||||
(localAnalysis || analysis)?.chars?.length ??
|
||||
(localAnalysis || analysis)?.characters?.length ??
|
||||
'-') as any
|
||||
((localAnalysis || analysis)?.chars as string[] | undefined)?.length ??
|
||||
((localAnalysis || analysis)?.characters as string[] | undefined)
|
||||
?.length ??
|
||||
'-') as string | number
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,31 +6,8 @@ import { notFound } from 'next/navigation';
|
|||
import { Suspense } from 'react';
|
||||
import { FontDetailContent } from './font-detail-content';
|
||||
|
||||
// ISR: 每小时重新生成页面
|
||||
export const revalidate = 3600;
|
||||
|
||||
// 动态路由参数
|
||||
export const dynamicParams = true;
|
||||
|
||||
// 生成静态参数 - 预渲染热门字体
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
// 获取前20个最受欢迎的字体进行预渲染
|
||||
const fonts = await fontService.findAll({
|
||||
page: 1,
|
||||
size: 20,
|
||||
sort: 'viewCount',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
return fonts.dataList.map((font) => ({
|
||||
id: font.normalizedName,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error generating static params:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
// 动态渲染,避免构建时查询数据库
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function FontDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
|
|
@ -53,14 +30,24 @@ export default async function FontDetailPage({ params }: { params: Promise<{ id:
|
|||
});
|
||||
|
||||
// Filter out current font from related fonts
|
||||
const filteredRelatedFonts = relatedFonts.dataList.filter((f) => f.id !== font.id);
|
||||
const filteredRelatedFonts = relatedFonts.dataList
|
||||
.filter((f) => f.id !== font.id)
|
||||
.map((f) => ({
|
||||
...f,
|
||||
brand: f.brand ?? null,
|
||||
category: f.category ?? null,
|
||||
}));
|
||||
|
||||
const analysis = await syncService.fetchFontAnalysis(font.normalizedName);
|
||||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<Suspense fallback={<FontDetailSkeleton />}>
|
||||
<FontDetailContent font={font} relatedFonts={filteredRelatedFonts} analysis={analysis} />
|
||||
<FontDetailContent
|
||||
font={font}
|
||||
relatedFonts={filteredRelatedFonts as Parameters<typeof FontDetailContent>[0]['relatedFonts']}
|
||||
analysis={analysis}
|
||||
/>
|
||||
</Suspense>
|
||||
</PublicLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,19 +5,10 @@ import { categoryService } from '@/lib/services/category.service';
|
|||
import { Suspense } from 'react';
|
||||
import { FontListContent } from './font-list-content';
|
||||
|
||||
// ISR: 每小时重新生成页面
|
||||
export const revalidate = 3600;
|
||||
// 动态渲染,避免构建时查询数据库
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// 生成静态参数
|
||||
export async function generateStaticParams() {
|
||||
return [{ searchParams: {} }];
|
||||
}
|
||||
|
||||
export default async function FontsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: { [key: string]: string | string[] | undefined };
|
||||
}) {
|
||||
export default async function FontsPage() {
|
||||
// 获取分类和品牌数据用于筛选器
|
||||
const [categories, brands] = await Promise.all([
|
||||
categoryService.findAll(),
|
||||
|
|
|
|||
|
|
@ -41,8 +41,8 @@
|
|||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
|
|
|
|||
|
|
@ -2,20 +2,11 @@ import { Toaster } from '@/components/ui/sonner';
|
|||
import { loadFont } from '@windfonts/chinese-fonts';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { SessionProvider } from 'next-auth/react';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import './globals.css';
|
||||
loadFont('Hclcks-Regular', { subset: 'zh-common' });
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || 'https://fonts.wptea.com'),
|
||||
title: {
|
||||
default: '文风字库',
|
||||
template: '%s | 文风字库',
|
||||
|
|
@ -72,9 +63,7 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" className="overscroll-none">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} touch-pan-y overscroll-none antialiased`}
|
||||
>
|
||||
<body className="touch-pan-y overscroll-none antialiased">
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { fontService } from '@/lib/services/font.service';
|
|||
import { ArrowRight, Palette, Search, Shield, Zap } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
// ISR: 每小时重新生成首页
|
||||
export const revalidate = 3600;
|
||||
// 动态渲染,避免构建时查询数据库
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default async function Home() {
|
||||
// 获取推荐字体(热门字体,带关联数据)
|
||||
|
|
@ -19,7 +19,7 @@ export default async function Home() {
|
|||
|
||||
return (
|
||||
<PublicLayout>
|
||||
<div className="container py-8 md:py-16">
|
||||
<div className="container mx-auto py-8 md:py-16">
|
||||
{/* Hero Section - 平台介绍区域 */}
|
||||
<section className="mb-16 text-center">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
|
|
|
|||
123
middleware.ts
123
middleware.ts
|
|
@ -1,123 +0,0 @@
|
|||
import { auth } from '@/lib/auth/config';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Next.js Middleware for authentication and authorization
|
||||
*
|
||||
* Protected routes:
|
||||
* - /admin/* - Requires authentication
|
||||
* - API routes with POST, PUT, DELETE methods - Requires authentication
|
||||
*
|
||||
* Public routes:
|
||||
* - / - Home page
|
||||
* - /fonts/* - Font browsing and details
|
||||
* - /docs/* - Documentation
|
||||
* - /login - Login page
|
||||
* - GET /api/fonts - Font listing
|
||||
* - GET /api/css - CSS generation
|
||||
* - GET /api/brands - Brand listing
|
||||
* - GET /api/categories - Category listing
|
||||
*/
|
||||
|
||||
// Define public paths that don't require authentication
|
||||
const publicPaths = ['/', '/login', '/fonts', '/docs'];
|
||||
|
||||
// Define public API paths (GET only)
|
||||
const publicApiPaths = ['/api/auth', '/api/fonts', '/api/css', '/api/brands', '/api/categories'];
|
||||
|
||||
// Check if a path matches any pattern in the list
|
||||
function matchesPath(pathname: string, patterns: string[]): boolean {
|
||||
return patterns.some((pattern) => {
|
||||
if (pattern.endsWith('*')) {
|
||||
return pathname.startsWith(pattern.slice(0, -1));
|
||||
}
|
||||
return pathname === pattern || pathname.startsWith(`${pattern}/`);
|
||||
});
|
||||
}
|
||||
|
||||
export default auth((req) => {
|
||||
const { pathname } = req.nextUrl;
|
||||
const isLoggedIn = !!req.auth;
|
||||
const disableAuth = process.env.DISABLE_AUTH === 'true';
|
||||
|
||||
// Allow access to static files and Next.js internals
|
||||
if (pathname.startsWith('/_next') || pathname.startsWith('/static') || pathname.includes('.')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Check if this is an admin route
|
||||
const isAdminRoute = pathname.startsWith('/admin');
|
||||
|
||||
// Check if this is an API route
|
||||
const isApiRoute = pathname.startsWith('/api');
|
||||
|
||||
// Handle admin routes - require authentication
|
||||
if (isAdminRoute) {
|
||||
if (disableAuth) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
if (!isLoggedIn) {
|
||||
const loginUrl = new URL('/login', req.url);
|
||||
loginUrl.searchParams.set('callbackUrl', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Handle API routes
|
||||
if (isApiRoute) {
|
||||
const method = req.method;
|
||||
|
||||
// Allow all auth-related API calls
|
||||
if (pathname.startsWith('/api/auth')) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// For public API paths, allow GET requests without authentication
|
||||
if (method === 'GET' && matchesPath(pathname, publicApiPaths)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// All other API routes (POST, PUT, DELETE, or non-public GET) require authentication
|
||||
if (!isLoggedIn && !disableAuth) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: 401,
|
||||
message: '未授权访问,请先登录',
|
||||
status: 'error',
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Handle public routes - allow access without authentication
|
||||
if (matchesPath(pathname, publicPaths)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Redirect to login if trying to access protected route without authentication
|
||||
if (!isLoggedIn) {
|
||||
const loginUrl = new URL('/login', req.url);
|
||||
loginUrl.searchParams.set('callbackUrl', pathname);
|
||||
return NextResponse.redirect(loginUrl);
|
||||
}
|
||||
|
||||
return NextResponse.next();
|
||||
});
|
||||
|
||||
// Configure which routes the middleware should run on
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public files (public folder)
|
||||
*/
|
||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||
],
|
||||
};
|
||||
|
|
@ -1,15 +1,8 @@
|
|||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Webpack 配置
|
||||
webpack: (config) => {
|
||||
// 排除 LICENSE 文件
|
||||
config.module.rules.push({
|
||||
test: /LICENSE$/,
|
||||
type: 'asset/source',
|
||||
});
|
||||
return config;
|
||||
},
|
||||
// Turbopack 配置 (Next.js 16 默认使用 Turbopack)
|
||||
turbopack: {},
|
||||
|
||||
// 图片优化配置
|
||||
images: {
|
||||
|
|
@ -53,6 +46,9 @@ const nextConfig: NextConfig = {
|
|||
poweredByHeader: false,
|
||||
compress: true,
|
||||
|
||||
// Docker 部署配置
|
||||
output: 'standalone',
|
||||
|
||||
// 静态资源缓存
|
||||
async headers() {
|
||||
return [
|
||||
|
|
|
|||
2660
package-lock.json
generated
2660
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -4,7 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
|
|
@ -35,11 +35,11 @@
|
|||
"drizzle-kit": "^0.31.7",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"lucide-react": "^0.554.0",
|
||||
"next": "15.5.6",
|
||||
"next": "^16.0.7",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
|
@ -54,9 +54,10 @@
|
|||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.6",
|
||||
"eslint-config-next": "^16.0.7",
|
||||
"eslint-config-prettier": "^9.1.2",
|
||||
"husky": "^9.1.7",
|
||||
"ignore-loader": "^0.1.2",
|
||||
"lint-staged": "^15.5.2",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
76
scripts/docker-all.sh
Executable file
76
scripts/docker-all.sh
Executable file
|
|
@ -0,0 +1,76 @@
|
|||
#!/bin/bash
|
||||
|
||||
# 一键构建、推送、部署脚本
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
echo -e "${BLUE} 文风字库 Docker 一键部署${NC}"
|
||||
echo -e "${BLUE}========================================${NC}"
|
||||
|
||||
# 检查 REGISTRY 环境变量
|
||||
if [ -z "$DOCKER_REGISTRY" ]; then
|
||||
echo -e "${YELLOW}警告: 未设置 DOCKER_REGISTRY 环境变量${NC}"
|
||||
echo -e "${YELLOW}将只构建本地镜像,不推送到远程仓库${NC}"
|
||||
read -p "是否继续? (y/n) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 步骤 1: 构建镜像
|
||||
echo -e "\n${BLUE}[1/3] 构建 Docker 镜像...${NC}"
|
||||
./scripts/docker-build.sh
|
||||
|
||||
# 步骤 2: 推送镜像(如果设置了 REGISTRY)
|
||||
if [ -n "$DOCKER_REGISTRY" ]; then
|
||||
echo -e "\n${BLUE}[2/3] 推送镜像到仓库...${NC}"
|
||||
./scripts/docker-push.sh
|
||||
else
|
||||
echo -e "\n${YELLOW}[2/3] 跳过推送步骤${NC}"
|
||||
fi
|
||||
|
||||
# 步骤 3: 询问是否部署
|
||||
echo -e "\n${BLUE}[3/3] 部署选项${NC}"
|
||||
echo -e "${YELLOW}选择部署方式:${NC}"
|
||||
echo " 1) 本地部署 (docker-compose)"
|
||||
echo " 2) 远程部署 (需要 SSH 配置)"
|
||||
echo " 3) 跳过部署"
|
||||
read -p "请选择 (1-3): " -n 1 -r
|
||||
echo
|
||||
|
||||
case $REPLY in
|
||||
1)
|
||||
echo -e "${GREEN}启动本地部署...${NC}"
|
||||
docker-compose up -d
|
||||
echo -e "${GREEN}✓ 本地部署完成!${NC}"
|
||||
echo -e "${BLUE}访问地址: http://localhost:3000${NC}"
|
||||
;;
|
||||
2)
|
||||
read -p "请输入服务器地址 (user@host): " SERVER
|
||||
if [ -n "$SERVER" ]; then
|
||||
echo -e "${GREEN}部署到远程服务器: ${SERVER}${NC}"
|
||||
ssh $SERVER "cd ~/windfonts-vault && ./docker-deploy.sh"
|
||||
echo -e "${GREEN}✓ 远程部署完成!${NC}"
|
||||
else
|
||||
echo -e "${RED}未输入服务器地址,跳过部署${NC}"
|
||||
fi
|
||||
;;
|
||||
3)
|
||||
echo -e "${YELLOW}跳过部署步骤${NC}"
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}无效选择,跳过部署${NC}"
|
||||
;;
|
||||
esac
|
||||
|
||||
echo -e "\n${GREEN}========================================${NC}"
|
||||
echo -e "${GREEN} 部署流程完成!${NC}"
|
||||
echo -e "${GREEN}========================================${NC}"
|
||||
28
scripts/docker-build.sh
Executable file
28
scripts/docker-build.sh
Executable file
|
|
@ -0,0 +1,28 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Docker 构建脚本
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}开始构建 Docker 镜像...${NC}"
|
||||
|
||||
# 获取版本号(从 package.json)
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
IMAGE_NAME="windfonts-vault"
|
||||
REGISTRY="${DOCKER_REGISTRY:-xianyu12580}"
|
||||
|
||||
# 构建镜像
|
||||
echo -e "${GREEN}构建镜像: ${REGISTRY}/${IMAGE_NAME}:${VERSION}${NC}"
|
||||
docker build -t ${REGISTRY}/${IMAGE_NAME}:${VERSION} -t ${REGISTRY}/${IMAGE_NAME}:latest .
|
||||
|
||||
# 同时打本地标签(方便本地测试)
|
||||
docker tag ${REGISTRY}/${IMAGE_NAME}:latest ${IMAGE_NAME}:latest
|
||||
|
||||
echo -e "${GREEN}✓ 构建完成!${NC}"
|
||||
echo -e "${BLUE}镜像列表:${NC}"
|
||||
docker images | grep ${IMAGE_NAME}
|
||||
61
scripts/docker-deploy.sh
Executable file
61
scripts/docker-deploy.sh
Executable file
|
|
@ -0,0 +1,61 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Docker 部署脚本(用于云端服务器)
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 配置
|
||||
IMAGE_NAME="windfonts-vault"
|
||||
CONTAINER_NAME="windfonts-vault"
|
||||
REGISTRY="${DOCKER_REGISTRY:-}"
|
||||
VERSION="${VERSION:-latest}"
|
||||
|
||||
echo -e "${BLUE}开始部署 ${IMAGE_NAME}...${NC}"
|
||||
|
||||
# 停止并删除旧容器
|
||||
if [ "$(docker ps -aq -f name=${CONTAINER_NAME})" ]; then
|
||||
echo -e "${GREEN}停止旧容器...${NC}"
|
||||
docker stop ${CONTAINER_NAME} || true
|
||||
docker rm ${CONTAINER_NAME} || true
|
||||
fi
|
||||
|
||||
# 拉取最新镜像
|
||||
if [ -n "$REGISTRY" ]; then
|
||||
echo -e "${GREEN}拉取镜像: ${REGISTRY}/${IMAGE_NAME}:${VERSION}${NC}"
|
||||
docker pull ${REGISTRY}/${IMAGE_NAME}:${VERSION}
|
||||
IMAGE_TAG="${REGISTRY}/${IMAGE_NAME}:${VERSION}"
|
||||
else
|
||||
IMAGE_TAG="${IMAGE_NAME}:${VERSION}"
|
||||
fi
|
||||
|
||||
# 启动新容器
|
||||
echo -e "${GREEN}启动新容器...${NC}"
|
||||
docker run -d \
|
||||
--name ${CONTAINER_NAME} \
|
||||
--restart unless-stopped \
|
||||
-p 3000:3000 \
|
||||
-v $(pwd)/data:/app/data \
|
||||
-v $(pwd)/logs:/app/logs \
|
||||
--env-file .env.production \
|
||||
${IMAGE_TAG}
|
||||
|
||||
# 等待容器启动
|
||||
echo -e "${BLUE}等待容器启动...${NC}"
|
||||
sleep 5
|
||||
|
||||
# 检查容器状态
|
||||
if [ "$(docker ps -q -f name=${CONTAINER_NAME})" ]; then
|
||||
echo -e "${GREEN}✓ 部署成功!${NC}"
|
||||
echo -e "${BLUE}容器状态:${NC}"
|
||||
docker ps -f name=${CONTAINER_NAME}
|
||||
echo -e "${BLUE}查看日志: docker logs -f ${CONTAINER_NAME}${NC}"
|
||||
else
|
||||
echo -e "${RED}✗ 部署失败!${NC}"
|
||||
echo -e "${BLUE}查看日志: docker logs ${CONTAINER_NAME}${NC}"
|
||||
exit 1
|
||||
fi
|
||||
30
scripts/docker-push.sh
Executable file
30
scripts/docker-push.sh
Executable file
|
|
@ -0,0 +1,30 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Docker 推送脚本
|
||||
set -e
|
||||
|
||||
# 颜色输出
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 获取版本号
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
IMAGE_NAME="windfonts-vault"
|
||||
REGISTRY="${DOCKER_REGISTRY:-xianyu12580}"
|
||||
|
||||
echo -e "${BLUE}开始推送镜像到 ${REGISTRY}...${NC}"
|
||||
|
||||
# 推送版本标签
|
||||
echo -e "${GREEN}推送: ${REGISTRY}/${IMAGE_NAME}:${VERSION}${NC}"
|
||||
docker push ${REGISTRY}/${IMAGE_NAME}:${VERSION}
|
||||
|
||||
# 推送 latest 标签
|
||||
echo -e "${GREEN}推送: ${REGISTRY}/${IMAGE_NAME}:latest${NC}"
|
||||
docker push ${REGISTRY}/${IMAGE_NAME}:latest
|
||||
|
||||
echo -e "${GREEN}✓ 推送完成!${NC}"
|
||||
echo -e "${BLUE}镜像地址:${NC}"
|
||||
echo -e " ${REGISTRY}/${IMAGE_NAME}:${VERSION}"
|
||||
echo -e " ${REGISTRY}/${IMAGE_NAME}:latest"
|
||||
15
scripts/start.sh
Normal file
15
scripts/start.sh
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 加载环境变量
|
||||
if [ -f .env.production ]; then
|
||||
echo "Loading .env.production..."
|
||||
export $(grep -v '^#' .env.production | xargs)
|
||||
fi
|
||||
|
||||
echo "Starting server with environment:"
|
||||
echo " NEXTAUTH_URL=$NEXTAUTH_URL"
|
||||
echo " AUTH_TRUST_HOST=$AUTH_TRUST_HOST"
|
||||
echo " DATABASE_URL=$DATABASE_URL"
|
||||
|
||||
exec node server.js
|
||||
|
|
@ -12,8 +12,8 @@ export const EXTRA_LINKS = [
|
|||
path: 'https://admincdn.com',
|
||||
},
|
||||
{
|
||||
label: '方块字库',
|
||||
path: 'https://fontsquare.com',
|
||||
label: '赛博字库',
|
||||
path: 'https://cyberfonts.com',
|
||||
},
|
||||
{
|
||||
label: '文派开源',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { auth } from '@/lib/auth/config';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export async function getSession() {
|
||||
const disableAuth = process.env.DISABLE_AUTH === 'true';
|
||||
|
|
@ -16,11 +17,15 @@ export async function getSession() {
|
|||
return await auth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Require authentication for server components
|
||||
* Redirects to login if not authenticated
|
||||
*/
|
||||
export async function requireAuth() {
|
||||
const session = await getSession();
|
||||
|
||||
if (!session?.user) {
|
||||
throw new Error('未授权访问');
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
return session;
|
||||
|
|
|
|||
|
|
@ -11,14 +11,15 @@ interface CacheEntry {
|
|||
export class CSSService {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private cacheTTL: number = 30 * 60 * 1000; // 30分钟
|
||||
private proxyBaseUrl: string;
|
||||
private cdnBaseUrl: string;
|
||||
|
||||
constructor() {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
|
||||
this.proxyBaseUrl = `${baseUrl}/api/proxy`;
|
||||
private get proxyBaseUrl(): string {
|
||||
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:4000';
|
||||
return `${baseUrl}/api/proxy`;
|
||||
}
|
||||
|
||||
private get cdnBaseUrl(): string {
|
||||
const cdnEnv = process.env.NEXT_PUBLIC_FONT_STATIC_URL;
|
||||
this.cdnBaseUrl = (cdnEnv && cdnEnv.replace(/\/$/, '')) || '';
|
||||
return (cdnEnv && cdnEnv.replace(/\/$/, '')) || '';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
|
@ -11,7 +15,7 @@
|
|||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
|
|
@ -19,13 +23,31 @@
|
|||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/lib/*": ["./src/lib/*"],
|
||||
"@/types/*": ["./src/types/*"],
|
||||
"@/hooks/*": ["./src/hooks/*"]
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@/components/*": [
|
||||
"./src/components/*"
|
||||
],
|
||||
"@/lib/*": [
|
||||
"./src/lib/*"
|
||||
],
|
||||
"@/types/*": [
|
||||
"./src/types/*"
|
||||
],
|
||||
"@/hooks/*": [
|
||||
"./src/hooks/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue