This commit is contained in:
z.ai committed 2026-10-08 11:09:37 +08:00
1 parent c1541b80df
commit b4c7908029
97 files changed
+12403 -154

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
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));
}