Files
2026-10-08 11:09:37 +08:00

140 lines
4.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { z } from "zod";
/**
* 文章的输入校验契约(单一事实来源)。
*
* API 路由、seed 脚本、未来的后台表单都复用这套 schema,
* 避免"接口校验一套、脚本又一套"导致的不一致。
*/
/** slug:小写字母/数字,用连字符分隔,与数据库 CHECK 约束保持一致 */
export const slugSchema = z
.string()
.trim()
.min(1, "slug 不能为空")
.max(200, "slug 最长 200 个字符")
.regex(
/^[a-z0-9]+(-[a-z0-9]+)*$/,
"slug 只能包含小写字母、数字和连字符,且不能以连字符开头或结尾",
);
/** 状态枚举,与数据库 CHECK 约束保持一致 */
export const postStatusSchema = z.enum(["draft", "published"]);
/** 标签:去空白、去重、限制数量与长度 */
export const tagsSchema = z
.array(z.string().trim().min(1).max(40, "单个标签最长 40 个字符"))
.max(10, "最多 10 个标签")
.default([])
.transform((tags) => [...new Set(tags)]);
/** 日期:接受 YYYY-MM-DD 或完整 ISO 字符串,统一转为 Date */
export const dateInputSchema = z
.string()
.trim()
.min(1)
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
message: "不是合法的日期",
})
.transform((value) => new Date(value));
/** 创建文章 */
export const createPostSchema = z.object({
slug: slugSchema,
title: z.string().trim().min(1, "标题不能为空").max(200, "标题最长 200 个字符"),
summary: z
.string()
.trim()
.min(1, "摘要不能为空")
.max(500, "摘要最长 500 个字符"),
content: z.string().min(1, "正文不能为空"),
tags: tagsSchema,
status: postStatusSchema.default("draft"),
/** 仅当 status=published 时有意义;不传则用当前时间 */
publishedAt: dateInputSchema.optional(),
cover: z.string().trim().min(1).nullish(),
description: z.string().trim().max(500).nullish(),
});
/**
* 更新文章:所有字段可选,但至少要传一个。
*
* ⚠️ `.partial()` 的陷阱:它只把字段变成可选,**不会移除 `.default()`**。
* `createPostSchema` 里 `tags` 有 `.default([])`、`status` 有 `.default("draft")`,
* 于是 `PATCH {}` 会被解析成 `{ tags: [], status: "draft" }`,
* 既绕过了"至少传一个字段"的校验,又会在用户只想改标题时
* 意外把文章状态重置为草稿。
*
* 因此这里基于 **字段定义** 重建 schema:所有字段都显式声明为 `.optional()`,
* 不带任何默认值。未传 = 不修改。
*/
export const updatePostSchema = z
.object({
slug: slugSchema.optional(),
title: z
.string()
.trim()
.min(1, "标题不能为空")
.max(200, "标题最长 200 个字符")
.optional(),
summary: z
.string()
.trim()
.min(1, "摘要不能为空")
.max(500, "摘要最长 500 个字符")
.optional(),
content: z.string().min(1, "正文不能为空").optional(),
tags: z
.array(z.string().trim().min(1).max(40, "单个标签最长 40 个字符"))
.max(10, "最多 10 个标签")
.transform((tags) => [...new Set(tags)])
.optional(),
status: postStatusSchema.optional(),
publishedAt: dateInputSchema.optional(),
cover: z.string().trim().min(1).nullish(),
description: z.string().trim().max(500).nullish(),
})
// 至少要有一个"真正传了值"的键(nullish 字段传 null 也算显式修改)
.refine((data) => Object.keys(data).length > 0, {
message: "至少需要提供一个要更新的字段",
});
/** 列表查询参数 */
export const listPostsQuerySchema = z.object({
/** 返回数量,1–100 */
limit: z.coerce.number().int().min(1).max(100).default(20),
/** 偏移量,用于分页 */
offset: z.coerce.number().int().min(0).default(0),
/** 按标签过滤 */
tag: z.string().trim().min(1).optional(),
/** 按状态过滤;不传则默认只返回已发布 */
status: postStatusSchema.optional(),
/** 是否包含草稿(仅用于内部/管理场景) */
includeDrafts: z
.enum(["true", "false"])
.default("false")
.transform((value) => value === "true"),
});
export type CreatePostInput = z.infer<typeof createPostSchema>;
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
export type ListPostsQuery = z.infer<typeof listPostsQuerySchema>;
/**
* 估算阅读时长(分钟)。
* 中文按字符计(约 350 字/分钟),英文按单词计(约 200 词/分钟)。
*
* 写入时计算并缓存到 reading_minutes 列,避免列表页反复解析正文。
* 与 `lib/posts/reading-time.ts` 共用同一实现,保证行为一致。
*/
export function estimateReadingMinutes(content: string): number {
const chineseCharacters = content.match(/[\u4e00-\u9fa5]/g)?.length ?? 0;
const latinWords =
content
.replace(/[\u4e00-\u9fa5]/g, " ")
.match(/[A-Za-z0-9]+/g)?.length ?? 0;
const minutes = chineseCharacters / 350 + latinWords / 200;
return Math.max(1, Math.round(minutes));
}