diff --git a/.gitignore b/.gitignore index cec5060..78d84ca 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ node_modules/ # We also don't want to ship these ignore rules to the users. template/app/migrations template/app/package-lock.json +.idea diff --git a/template/app/.env.server.example b/template/app/.env.server.example index c14b072..b71662b 100644 --- a/template/app/.env.server.example +++ b/template/app/.env.server.example @@ -61,3 +61,7 @@ AWS_S3_IAM_ACCESS_KEY=ACK... AWS_S3_IAM_SECRET_KEY=t+33a... AWS_S3_FILES_BUCKET=your-bucket-name AWS_S3_REGION=your-region + +# (OPTIONAL) Blog CMS — path to the blog Astro module for Markdown/sitemap sync on publish. +BLOG_MODULE_PATH=../blog +SITE_URL=https://your-site.com diff --git a/template/app/main.wasp.ts b/template/app/main.wasp.ts index 96acac8..407fb01 100644 --- a/template/app/main.wasp.ts +++ b/template/app/main.wasp.ts @@ -4,7 +4,7 @@ import { App } from "./src/client/App" with { type: "ref" }; import { NotFoundPage } from "./src/client/components/NotFoundPage" with { type: "ref" }; import { serverEnvValidationSchema } from "./src/env" with { type: "ref" }; import { LandingPage } from "./src/landing-page/LandingPage" with { type: "ref" }; -import { seedMockUsers } from "./src/server/scripts/dbSeeds" with { type: "ref" }; +import { seedMockUsers, seedCmsData } from "./src/server/scripts/dbSeeds" with { type: "ref" }; import { adminSpec } from "./src/admin/admin.wasp"; import { analyticsSpec } from "./src/analytics/analytics.wasp"; @@ -15,6 +15,7 @@ import { fileUploadSpec } from "./src/file-upload/file-upload.wasp"; import { paymentSpec } from "./src/payment/payment.wasp"; import { emailSender } from "./src/server/emailSender.wasp"; import { userSpec } from "./src/user/user.wasp"; +import { cmsSpec } from "./src/cms/cms.wasp"; export default app({ name: "OpenSaaS", @@ -27,6 +28,8 @@ export default app({ seeds: [ // Populates the database with a bunch of fake users to work with during development. seedMockUsers, + // Populates the database with sample CMS data (authors, tags, posts). + seedCmsData, ], }, client: { @@ -48,5 +51,6 @@ export default app({ fileUploadSpec, analyticsSpec, adminSpec, + cmsSpec, ], }); diff --git a/template/app/schema.prisma b/template/app/schema.prisma index 3c4ff6e..96d093c 100644 --- a/template/app/schema.prisma +++ b/template/app/schema.prisma @@ -1,6 +1,6 @@ datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "sqlite" + url = "file:./dev.db" } generator client { @@ -109,3 +109,59 @@ model ContactFormMessage { isRead Boolean @default(false) repliedAt DateTime? } + +// ─── Blog CMS Models ────────────────────────────────────────────── + +model Author { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + name String + title String? + url String? + avatarUrl String? + posts PostAuthor[] +} + +model Tag { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + name String @unique + slug String @unique + posts PostTag[] +} + +model Post { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + title String + slug String @unique + subtitle String? + content String + status String @default("draft") // "draft" | "submitted" | "rejected" | "published" + publishedAt DateTime? + hideBannerImage Boolean @default(false) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + userId String + authors PostAuthor[] + tags PostTag[] +} + +model PostAuthor { + postId String + authorId String + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + author Author @relation(fields: [authorId], references: [id], onDelete: Cascade) + + @@id([postId, authorId]) +} + +model PostTag { + postId String + tagId String + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@id([postId, tagId]) +} diff --git a/template/app/src/admin/layout/Sidebar.tsx b/template/app/src/admin/layout/Sidebar.tsx index 42038fa..6dc8716 100644 --- a/template/app/src/admin/layout/Sidebar.tsx +++ b/template/app/src/admin/layout/Sidebar.tsx @@ -2,10 +2,13 @@ import { Calendar, ChevronDown, ChevronUp, + FileText, LayoutDashboard, LayoutTemplate, Settings, Sheet, + Tag, + Users, X, } from "lucide-react"; import React, { useEffect, useRef, useState } from "react"; @@ -145,6 +148,97 @@ export function Sidebar({ sidebarOpen, setSidebarOpen }: SidebarProps) { {/* */} + {/* */} + + {(handleClick, open) => { + return ( + + { + e.preventDefault(); + if (sidebarExpanded) { + handleClick(); + } else { + setSidebarExpanded(true); + } + }} + > + + 博客 CMS + {open ? : } + + {/* */} +
+
    +
  • + + cn( + "text-muted-foreground hover:text-accent group relative flex items-center gap-2.5 rounded-md px-4 font-medium duration-300 ease-in-out", + { "text-accent!": isActive }, + ) + } + > + 文章管理 + +
  • +
  • + + cn( + "text-muted-foreground hover:text-accent group relative flex items-center gap-2.5 rounded-md px-4 font-medium duration-300 ease-in-out", + { "text-accent!": isActive }, + ) + } + > + + 作者管理 + +
  • +
  • + + cn( + "text-muted-foreground hover:text-accent group relative flex items-center gap-2.5 rounded-md px-4 font-medium duration-300 ease-in-out", + { "text-accent!": isActive }, + ) + } + > + + 标签管理 + +
  • +
+
+ {/* */} +
+ ); + }} +
+ {/* */} + {/* */}
  • '%@`]/.test(value) || value.includes("\n")) { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; + } + return value; +} + +function buildFrontmatter(post: { + title: string; + publishedAt: Date | null; + createdAt: Date; + subtitle: string | null; + hideBannerImage: boolean; +}, authors: { name: string; title?: string | null; url?: string | null }[], tags: { name: string }[]): string { + const lines: string[] = ["---"]; + + lines.push(`title: ${escapeYamlValue(post.title)}`); + + const date = post.publishedAt || post.createdAt; + const dateStr = date.toISOString().split("T")[0]; // YYYY-MM-DD + lines.push(`date: ${dateStr}`); + + if (tags.length > 0) { + const tagNames = tags.map((t) => t.name); + lines.push(`tags: [${tagNames.join(", ")}]`); + } + + if (authors.length > 0) { + lines.push("authors:"); + for (const author of authors) { + lines.push(` - name: ${escapeYamlValue(author.name)}`); + if (author.title) { + lines.push(` title: ${escapeYamlValue(author.title)}`); + } + if (author.url) { + lines.push(` url: ${author.url}`); + } + } + } + + if (post.subtitle) { + lines.push(`subtitle: ${escapeYamlValue(post.subtitle)}`); + } + + if (post.hideBannerImage) { + lines.push(`hideBannerImage: true`); + } + + // Add canonical URL based on site config + const siteUrl = getSiteUrl(); + lines.push(`canonicalURL: ${siteUrl}/blog/${post.title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}/`); + + lines.push("---"); + lines.push(""); + + return lines.join("\n"); +} + +function ensureDir(dirPath: string): void { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } +} + +// ─── Public API ─────────────────────────────────────────────────── + +export async function syncPostToBlog( + post: { + id: string; + slug: string; + title: string; + subtitle: string | null; + content: string; + publishedAt: Date | null; + createdAt: Date; + hideBannerImage: boolean; + }, + authors: { name: string; title?: string | null; url?: string | null }[], + tags: { name: string }[], +): Promise { + const blogPath = getBlogModulePath(); + const contentDir = path.join(blogPath, "src", "content", "docs", "blog"); + ensureDir(contentDir); + + const frontmatter = buildFrontmatter(post, authors, tags); + const fileContent = frontmatter + post.content; + + const filePath = path.join(contentDir, `${post.slug}.md`); + fs.writeFileSync(filePath, fileContent, "utf-8"); + + console.log(`[CMS] 博客文章已同步: ${filePath}`); +} + +export async function removePostFromBlog(slug: string): Promise { + const blogPath = getBlogModulePath(); + const contentDir = path.join(blogPath, "src", "content", "docs", "blog"); + const filePath = path.join(contentDir, `${slug}.md`); + + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + console.log(`[CMS] 博客文章已删除: ${filePath}`); + } +} + +export async function generateSitemap(): Promise { + const blogPath = getBlogModulePath(); + const contentDir = path.join(blogPath, "src", "content", "docs", "blog"); + const publicDir = path.join(blogPath, "public"); + ensureDir(publicDir); + + const siteUrl = getSiteUrl(); + const urls: string[] = []; + + // Add the blog index page + urls.push(` + ${siteUrl}/blog/ + daily + 0.9 + `); + + // Scan for existing .md files in the blog content directory + if (fs.existsSync(contentDir)) { + const files = fs.readdirSync(contentDir).filter((f) => f.endsWith(".md")); + + for (const file of files) { + const slug = file.replace(/\.md$/, ""); + const filePath = path.join(contentDir, file); + const stat = fs.statSync(filePath); + const lastmod = stat.mtime.toISOString().split("T")[0]; + + urls.push(` + ${siteUrl}/blog/${slug}/ + ${lastmod} + weekly + 0.8 + `); + } + } + + const sitemap = ` + +${urls.join("\n")} + +`; + + const sitemapPath = path.join(publicDir, "sitemap.xml"); + fs.writeFileSync(sitemapPath, sitemap, "utf-8"); + + console.log(`[CMS] Sitemap 已更新: ${sitemapPath}`); +} + +export async function syncAuthors( + authors: { name: string; title?: string | null; url?: string | null; avatarUrl?: string | null }[], +): Promise { + const blogPath = getBlogModulePath(); + const contentDir = path.join(blogPath, "src", "content"); + ensureDir(contentDir); + + const authorsObj: Record = {}; + + for (const author of authors) { + const key = author.name.toLowerCase().replace(/\s+/g, "_"); + authorsObj[key] = { + name: author.name, + ...(author.title && { title: author.title }), + ...(author.url && { url: author.url }), + ...(author.avatarUrl && { picture: author.avatarUrl }), + }; + } + + const filePath = path.join(contentDir, "authors.json"); + fs.writeFileSync(filePath, JSON.stringify(authorsObj, null, 2), "utf-8"); + + console.log(`[CMS] 作者数据已同步: ${filePath}`); +} diff --git a/template/app/src/cms/cms.wasp.ts b/template/app/src/cms/cms.wasp.ts new file mode 100644 index 0000000..cd604d3 --- /dev/null +++ b/template/app/src/cms/cms.wasp.ts @@ -0,0 +1,74 @@ +import { action, page, query, route, type Spec } from "@wasp.sh/spec"; + +import { PostsListPage } from "./pages/PostsListPage" with { type: "ref" }; +import { PostEditorPage } from "./pages/PostEditorPage" with { type: "ref" }; +import { AuthorsListPage } from "./pages/AuthorsListPage" with { type: "ref" }; +import { TagsListPage } from "./pages/TagsListPage" with { type: "ref" }; + +import { + getAllPosts, + getPostById, + getPostBySlug, + getAllAuthors, + getAuthorById, + getAllTags, + getTagById, + createPost, + updatePost, + deletePost, + submitPost, + approvePost, + rejectPost, + createAuthor, + updateAuthor, + deleteAuthor, + createTag, + updateTag, + deleteTag, +} from "./operations" with { type: "ref" }; + +export const cmsSpec: Spec = [ + // ── Post Routes ────────────────────────────────────────────── + route("AdminPostsRoute", "/admin/cms/posts", page(PostsListPage, { authRequired: true })), + route("AdminNewPostRoute", "/admin/cms/posts/new", page(PostEditorPage, { authRequired: true })), + route("AdminPostEditorRoute", "/admin/cms/posts/:id", page(PostEditorPage, { authRequired: true })), + + // ── Author Routes ──────────────────────────────────────────── + route("AdminAuthorsRoute", "/admin/cms/authors", page(AuthorsListPage, { authRequired: true })), + route("AdminNewAuthorRoute", "/admin/cms/authors/new", page(AuthorsListPage, { authRequired: true })), + route("AdminAuthorEditorRoute", "/admin/cms/authors/:id", page(AuthorsListPage, { authRequired: true })), + + // ── Tag Routes ─────────────────────────────────────────────── + route("AdminTagsRoute", "/admin/cms/tags", page(TagsListPage, { authRequired: true })), + + // ── Post Queries ───────────────────────────────────────────── + query(getAllPosts, { entities: ["Post", "PostAuthor", "PostTag", "Author", "Tag"] }), + query(getPostById, { entities: ["Post", "PostAuthor", "PostTag", "Author", "Tag"] }), + query(getPostBySlug, { entities: ["Post"] }), + + // ── Author Queries ─────────────────────────────────────────── + query(getAllAuthors, { entities: ["Author", "PostAuthor"] }), + query(getAuthorById, { entities: ["Author", "PostAuthor"] }), + + // ── Tag Queries ────────────────────────────────────────────── + query(getAllTags, { entities: ["Tag", "PostTag"] }), + query(getTagById, { entities: ["Tag", "PostTag"] }), + + // ── Post Actions ───────────────────────────────────────────── + action(createPost, { entities: ["Post", "PostAuthor", "PostTag"] }), + action(updatePost, { entities: ["Post", "PostAuthor", "PostTag"] }), + action(deletePost, { entities: ["Post", "PostAuthor", "PostTag"] }), + action(submitPost, { entities: ["Post"] }), + action(approvePost, { entities: ["Post", "Author", "Tag"] }), + action(rejectPost, { entities: ["Post"] }), + + // ── Author Actions ─────────────────────────────────────────── + action(createAuthor, { entities: ["Author"] }), + action(updateAuthor, { entities: ["Author"] }), + action(deleteAuthor, { entities: ["Author", "PostAuthor"] }), + + // ── Tag Actions ────────────────────────────────────────────── + action(createTag, { entities: ["Tag"] }), + action(updateTag, { entities: ["Tag"] }), + action(deleteTag, { entities: ["Tag", "PostTag"] }), +]; diff --git a/template/app/src/cms/env.ts b/template/app/src/cms/env.ts new file mode 100644 index 0000000..3e4a1e7 --- /dev/null +++ b/template/app/src/cms/env.ts @@ -0,0 +1,6 @@ +import * as z from "zod"; + +export const cmsEnvSchema = z.object({ + BLOG_MODULE_PATH: z.string().optional(), + SITE_URL: z.string().url().optional(), +}); diff --git a/template/app/src/cms/operations.ts b/template/app/src/cms/operations.ts new file mode 100644 index 0000000..2738bfc --- /dev/null +++ b/template/app/src/cms/operations.ts @@ -0,0 +1,739 @@ +import { type Author, type Post, type Tag } from "wasp/entities"; +import { HttpError, prisma } from "wasp/server"; +import { + type GetAllPosts, + type GetPostById, + type GetPostBySlug, + type GetAllAuthors, + type GetAuthorById, + type GetAllTags, + type GetTagById, + type CreatePost, + type UpdatePost, + type DeletePost, + type SubmitPost, + type ApprovePost, + type RejectPost, + type CreateAuthor, + type UpdateAuthor, + type DeleteAuthor, + type CreateTag, + type UpdateTag, + type DeleteTag, +} from "wasp/server/operations"; +import * as z from "zod"; +import { ensureArgsSchemaOrThrowHttpError } from "../server/validation"; + +// ─── Helpers ────────────────────────────────────────────────────── + +function ensureAuthenticated(context: { user?: { id: string; isAdmin: boolean } | null }): void { + if (!context.user) { + throw new HttpError(401, "请先登录后再执行此操作"); + } +} + +function ensureAdmin(context: { user?: { id: string; isAdmin: boolean } | null }): void { + ensureAuthenticated(context); + if (!context.user!.isAdmin) { + throw new HttpError(403, "仅管理员可执行此操作"); + } +} + +async function ensureCreatorOrAdmin( + context: { user?: { id: string; isAdmin: boolean } | null }, + postId: string, + entities: { Post: { findUnique: (args: any) => Promise } }, +): Promise<{ id: string; userId: string; status: string }> { + ensureAuthenticated(context); + const post = await entities.Post.findUnique({ where: { id: postId } }); + if (!post) { + throw new HttpError(404, "文章不存在"); + } + if (post.userId !== context.user!.id && !context.user!.isAdmin) { + throw new HttpError(403, "您不是此文章的创建人,无权执行此操作"); + } + return post; +} + +function baseSlug(input: string): string { + return ( + input + .toLowerCase() + .replace(/[^a-z0-9一-鿿]+/g, "-") + .replace(/^-+|-+$/g, "") + || "untitled" + ); +} + +async function generateUniqueSlug( + input: string, + excludeId?: string, +): Promise { + const base = baseSlug(input); + let slug = base; + let counter = 1; + while (true) { + const existing = await prisma.post.findUnique({ where: { slug } }); + if (!existing || (excludeId && existing.id === excludeId)) { + return slug; + } + slug = `${base}-${counter}`; + counter++; + } +} + +// Lazy import to avoid circular dependency issues at module load time. +async function syncBlogOnPublish(postId: string): Promise { + try { + const { syncPostToBlog, generateSitemap } = await import("./blog-bridge"); + const post = await prisma.post.findUnique({ + where: { id: postId }, + include: { authors: { include: { author: true } }, tags: { include: { tag: true } } }, + }); + if (post && post.status === "published") { + const authors = post.authors.map((pa: any) => pa.author); + const tags = post.tags.map((pt: any) => pt.tag); + await syncPostToBlog(post, authors, tags); + await generateSitemap(); + } + } catch (err) { + console.error("博客同步失败:", err); + } +} + +async function removeFromBlog(slug: string): Promise { + try { + const { removePostFromBlog, generateSitemap } = await import("./blog-bridge"); + await removePostFromBlog(slug); + await generateSitemap(); + } catch (err) { + console.error("博客文件删除失败:", err); + } +} + +// ─── Zod Schemas ────────────────────────────────────────────────── + +const postInputSchema = z.object({ + title: z.string().min(1, "标题不能为空"), + slug: z.string().min(1, "别名不能为空"), + subtitle: z.string().optional(), + content: z.string().default(""), + hideBannerImage: z.boolean().optional(), + authorIds: z.array(z.string()).min(1, "至少选择一位作者"), + tagIds: z.array(z.string()).optional(), +}); + +const updatePostSchema = z.object({ + id: z.string(), + title: z.string().min(1).optional(), + slug: z.string().min(1).optional(), + subtitle: z.string().optional(), + content: z.string().optional(), + hideBannerImage: z.boolean().optional(), + authorIds: z.array(z.string()).optional(), + tagIds: z.array(z.string()).optional(), +}); + +const postByIdSchema = z.object({ id: z.string() }); + +const postSlugSchema = z.object({ slug: z.string() }); + +const postsListSchema = z.object({ + status: z.enum(["draft", "submitted", "rejected", "published"]).optional(), + skipPages: z.number().int().min(0).default(0), +}); + +const deletePostSchema = z.object({ id: z.string() }); + +const submitPostSchema = z.object({ id: z.string() }); + +const approvePostSchema = z.object({ id: z.string() }); + +const rejectPostSchema = z.object({ + id: z.string(), + reason: z.string().optional(), +}); + +const authorInputSchema = z.object({ + name: z.string().min(1, "作者名称不能为空"), + title: z.string().optional(), + url: z.string().url("请输入有效的 URL").optional().or(z.literal("")), + avatarUrl: z.string().optional(), +}); + +const updateAuthorSchema = z.object({ + id: z.string(), + name: z.string().min(1).optional(), + title: z.string().optional(), + url: z.string().url("请输入有效的 URL").optional().or(z.literal("")).optional(), + avatarUrl: z.string().optional(), +}); + +const tagInputSchema = z.object({ + name: z.string().min(1, "标签名称不能为空"), + slug: z + .string() + .min(1, "别名不能为空") + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "别名只能包含小写字母、数字和连字符,且不能以连字符开头或结尾"), +}); + +const updateTagSchema = z.object({ + id: z.string(), + name: z.string().min(1).optional(), + slug: z + .string() + .min(1) + .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, "别名只能包含小写字母、数字和连字符,且不能以连字符开头或结尾") + .optional(), +}); + +// ─── Queries: Posts ─────────────────────────────────────────────── + +type GetAllPostsInput = z.infer; +type GetAllPostsOutput = { + posts: (Post & { user: { id: string; email: string; username: string }; authors: { author: Author }[]; tags: { tag: Tag }[] })[]; + totalPages: number; +}; + +export const getAllPosts: GetAllPosts = async ( + rawArgs, + context, +) => { + ensureAuthenticated(context); + + const { status, skipPages } = ensureArgsSchemaOrThrowHttpError(postsListSchema, rawArgs); + const pageSize = 10; + + const where: Record = {}; + + // 普通用户只能看自己创建的文章,管理员可以看全部 + if (!context.user!.isAdmin) { + where.userId = context.user!.id; + } + + if (status) { + where.status = status; + } + + const [posts, totalCount] = await Promise.all([ + context.entities.Post.findMany({ + where, + skip: skipPages * pageSize, + take: pageSize, + orderBy: { createdAt: "desc" }, + include: { + user: { select: { id: true, email: true, username: true } }, + authors: { include: { author: true } }, + tags: { include: { tag: true } }, + }, + }), + context.entities.Post.count({ where }), + ]); + + return { + posts, + totalPages: Math.ceil(totalCount / pageSize), + }; +}; + +type GetPostByIdInput = z.infer; +type GetPostByIdOutput = Post & { + user: { id: string; email: string; username: string }; + authors: { author: Author }[]; + tags: { tag: Tag }[]; +} | null; + +export const getPostById: GetPostById = async ( + rawArgs, + context, +) => { + ensureAuthenticated(context); + + const { id } = ensureArgsSchemaOrThrowHttpError(postByIdSchema, rawArgs); + + const post = await context.entities.Post.findUnique({ + where: { id }, + include: { + user: { select: { id: true, email: true, username: true } }, + authors: { include: { author: true } }, + tags: { include: { tag: true } }, + }, + }); + + if (!post) return null; + + // 普通用户只能看自己的文章 + if (!context.user!.isAdmin && post.userId !== context.user!.id) { + throw new HttpError(403, "您无权查看此文章"); + } + + return post; +}; + +type GetPostBySlugInput = z.infer; +type GetPostBySlugOutput = { id: string; slug: string; title: string } | null; + +export const getPostBySlug: GetPostBySlug = async ( + rawArgs, + context, +) => { + const { slug } = ensureArgsSchemaOrThrowHttpError(postSlugSchema, rawArgs); + + const post = await context.entities.Post.findUnique({ + where: { slug }, + select: { id: true, slug: true, title: true }, + }); + + return post; +}; + +// ─── Queries: Authors ───────────────────────────────────────────── + +export const getAllAuthors: GetAllAuthors = async ( + _args, + context, +) => { + ensureAuthenticated(context); + + return context.entities.Author.findMany({ + orderBy: { name: "asc" }, + include: { _count: { select: { posts: true } } }, + }); +}; + +type GetAuthorByIdInput = z.infer; + +export const getAuthorById: GetAuthorById = async ( + rawArgs, + context, +) => { + ensureAuthenticated(context); + + const { id } = ensureArgsSchemaOrThrowHttpError(postByIdSchema, rawArgs); + + return context.entities.Author.findUnique({ + where: { id }, + include: { _count: { select: { posts: true } } }, + }); +}; + +// ─── Queries: Tags ──────────────────────────────────────────────── + +export const getAllTags: GetAllTags = async ( + _args, + context, +) => { + ensureAuthenticated(context); + + return context.entities.Tag.findMany({ + orderBy: { name: "asc" }, + include: { _count: { select: { posts: true } } }, + }); +}; + +type GetTagByIdInput = z.infer; + +export const getTagById: GetTagById = async ( + rawArgs, + context, +) => { + ensureAuthenticated(context); + + const { id } = ensureArgsSchemaOrThrowHttpError(postByIdSchema, rawArgs); + + return context.entities.Tag.findUnique({ + where: { id }, + include: { _count: { select: { posts: true } } }, + }); +}; + +// ─── Actions: Posts ─────────────────────────────────────────────── + +type CreatePostInput = z.infer; +type CreatePostOutput = Post; + +export const createPost: CreatePost = async ( + rawArgs, + context, +) => { + ensureAuthenticated(context); + + const { title, slug: inputSlug, subtitle, content, hideBannerImage, authorIds, tagIds } = + ensureArgsSchemaOrThrowHttpError(postInputSchema, rawArgs); + + // 自动去重 slug + const slug = await generateUniqueSlug(inputSlug || title); + + const post = await context.entities.Post.create({ + data: { + title, + slug, + subtitle, + content, + hideBannerImage: hideBannerImage ?? false, + status: "draft", + user: { connect: { id: context.user!.id } }, + authors: { + create: authorIds.map((authorId: string) => ({ + author: { connect: { id: authorId } }, + })), + }, + tags: tagIds?.length + ? { + create: tagIds.map((tagId: string) => ({ + tag: { connect: { id: tagId } }, + })), + } + : undefined, + }, + include: { + authors: { include: { author: true } }, + tags: { include: { tag: true } }, + }, + }); + + return post; +}; + +type UpdatePostInput = z.infer; +type UpdatePostOutput = Post; + +export const updatePost: UpdatePost = async ( + rawArgs, + context, +) => { + const { id, title, slug: inputSlug, subtitle, content, hideBannerImage, authorIds, tagIds } = + ensureArgsSchemaOrThrowHttpError(updatePostSchema, rawArgs); + + const existing = await ensureCreatorOrAdmin(context, id, context.entities); + + // 只有草稿或驳回状态才能编辑 + if (existing.status !== "draft" && existing.status !== "rejected") { + throw new HttpError(400, "只有草稿或驳回状态的文章才能编辑"); + } + + // 如果修改了 slug,需要去重 + let slug = inputSlug; + if (inputSlug) { + slug = await generateUniqueSlug(inputSlug, id); + } + + const post = await context.entities.Post.update({ + where: { id }, + data: { + ...(title !== undefined && { title }), + ...(slug !== undefined && { slug }), + ...(subtitle !== undefined && { subtitle }), + ...(content !== undefined && { content }), + ...(hideBannerImage !== undefined && { hideBannerImage }), + }, + include: { + authors: { include: { author: true } }, + tags: { include: { tag: true } }, + }, + }); + + // 更新关联 + if (authorIds) { + await context.entities.PostAuthor.deleteMany({ where: { postId: id } }); + if (authorIds.length > 0) { + await context.entities.PostAuthor.createMany({ + data: authorIds.map((authorId: string) => ({ postId: id, authorId })), + }); + } + } + + if (tagIds) { + await context.entities.PostTag.deleteMany({ where: { postId: id } }); + if (tagIds.length > 0) { + await context.entities.PostTag.createMany({ + data: tagIds.map((tagId: string) => ({ postId: id, tagId })), + }); + } + } + + return post; +}; + +type DeletePostInput = z.infer; +type DeletePostOutput = { success: boolean }; + +export const deletePost: DeletePost = async ( + rawArgs, + context, +) => { + const { id } = ensureArgsSchemaOrThrowHttpError(deletePostSchema, rawArgs); + + const post = await ensureCreatorOrAdmin(context, id, context.entities); + + // 删除前先清理博客中的 MD 文件 + await removeFromBlog(post.slug); + + // 先删关联表 + await context.entities.PostAuthor.deleteMany({ where: { postId: id } }); + await context.entities.PostTag.deleteMany({ where: { postId: id } }); + await context.entities.Post.delete({ where: { id } }); + + return { success: true }; +}; + +// ─── Actions: Submission / Review ───────────────────────────────── + +type SubmitPostInput = z.infer; +type SubmitPostOutput = Post; + +export const submitPost: SubmitPost = async ( + rawArgs, + context, +) => { + const { id } = ensureArgsSchemaOrThrowHttpError(submitPostSchema, rawArgs); + + const post = await ensureCreatorOrAdmin(context, id, context.entities); + + if (post.status !== "draft" && post.status !== "rejected") { + throw new HttpError(400, "只有草稿或驳回状态的文章才能提交审核"); + } + + return context.entities.Post.update({ + where: { id }, + data: { status: "submitted" }, + include: { + authors: { include: { author: true } }, + tags: { include: { tag: true } }, + }, + }); +}; + +type ApprovePostInput = z.infer; +type ApprovePostOutput = Post; + +export const approvePost: ApprovePost = async ( + rawArgs, + context, +) => { + ensureAdmin(context); + + const { id } = ensureArgsSchemaOrThrowHttpError(approvePostSchema, rawArgs); + + const existing = await context.entities.Post.findUnique({ where: { id } }); + if (!existing) { + throw new HttpError(404, "文章不存在"); + } + if (existing.status !== "submitted") { + throw new HttpError(400, "只有已提交审核状态的文章才能审核通过"); + } + + const post = await context.entities.Post.update({ + where: { id }, + data: { + status: "published", + publishedAt: new Date(), + }, + include: { + authors: { include: { author: true } }, + tags: { include: { tag: true } }, + }, + }); + + // 触发博客同步 + await syncBlogOnPublish(id); + + return post; +}; + +type RejectPostInput = z.infer; +type RejectPostOutput = Post; + +export const rejectPost: RejectPost = async ( + rawArgs, + context, +) => { + ensureAdmin(context); + + const { id, reason } = ensureArgsSchemaOrThrowHttpError(rejectPostSchema, rawArgs); + + const existing = await context.entities.Post.findUnique({ where: { id } }); + if (!existing) { + throw new HttpError(404, "文章不存在"); + } + if (existing.status !== "submitted") { + throw new HttpError(400, "只有已提交审核状态的文章才能驳回"); + } + + const post = await context.entities.Post.update({ + where: { id }, + data: { + status: "rejected", + }, + include: { + authors: { include: { author: true } }, + tags: { include: { tag: true } }, + }, + }); + + // 驳回时清理可能已存在的博客文件 + if (existing.status === "published") { + await removeFromBlog(existing.slug); + } + + // 记录驳回原因到控制台(后续可扩展为存到数据库字段) + if (reason) { + console.log(`文章 ${id} 被驳回,原因: ${reason}`); + } + + return post; +}; + +// ─── Actions: Authors ───────────────────────────────────────────── + +type CreateAuthorInput = z.infer; +type CreateAuthorOutput = Author; + +export const createAuthor: CreateAuthor = async ( + rawArgs, + context, +) => { + ensureAdmin(context); + + const { name, title, url, avatarUrl } = + ensureArgsSchemaOrThrowHttpError(authorInputSchema, rawArgs); + + return context.entities.Author.create({ + data: { + name, + title: title || null, + url: url || null, + avatarUrl: avatarUrl || null, + }, + }); +}; + +type UpdateAuthorInput = z.infer; +type UpdateAuthorOutput = Author; + +export const updateAuthor: UpdateAuthor = async ( + rawArgs, + context, +) => { + ensureAdmin(context); + + const { id, name, title, url, avatarUrl } = + ensureArgsSchemaOrThrowHttpError(updateAuthorSchema, rawArgs); + + const existing = await context.entities.Author.findUnique({ where: { id } }); + if (!existing) { + throw new HttpError(404, "作者不存在"); + } + + return context.entities.Author.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(title !== undefined && { title: title || null }), + ...(url !== undefined && { url: url || null }), + ...(avatarUrl !== undefined && { avatarUrl: avatarUrl || null }), + }, + }); +}; + +type DeleteAuthorInput = z.infer; +type DeleteAuthorOutput = { success: boolean }; + +export const deleteAuthor: DeleteAuthor = async ( + rawArgs, + context, +) => { + ensureAdmin(context); + + const { id } = ensureArgsSchemaOrThrowHttpError(deletePostSchema, rawArgs); + + const existing = await context.entities.Author.findUnique({ where: { id } }); + if (!existing) { + throw new HttpError(404, "作者不存在"); + } + + // 先删关联 + await context.entities.PostAuthor.deleteMany({ where: { authorId: id } }); + await context.entities.Author.delete({ where: { id } }); + + return { success: true }; +}; + +// ─── Actions: Tags ──────────────────────────────────────────────── + +type CreateTagInput = z.infer; +type CreateTagOutput = Tag; + +export const createTag: CreateTag = async ( + rawArgs, + context, +) => { + ensureAdmin(context); + + const { name, slug } = ensureArgsSchemaOrThrowHttpError(tagInputSchema, rawArgs); + + // 检查 slug 唯一性 + const existing = await context.entities.Tag.findUnique({ where: { slug } }); + if (existing) { + throw new HttpError(400, `别名 "${slug}" 已被标签 "${existing.name}" 使用`); + } + + return context.entities.Tag.create({ + data: { name, slug }, + }); +}; + +type UpdateTagInput = z.infer; +type UpdateTagOutput = Tag; + +export const updateTag: UpdateTag = async ( + rawArgs, + context, +) => { + ensureAdmin(context); + + const { id, name, slug } = ensureArgsSchemaOrThrowHttpError(updateTagSchema, rawArgs); + + const existing = await context.entities.Tag.findUnique({ where: { id } }); + if (!existing) { + throw new HttpError(404, "标签不存在"); + } + + // 如果修改了 slug,检查唯一性 + if (slug && slug !== existing.slug) { + const slugExists = await context.entities.Tag.findUnique({ where: { slug } }); + if (slugExists) { + throw new HttpError(400, `别名 "${slug}" 已被使用`); + } + } + + return context.entities.Tag.update({ + where: { id }, + data: { + ...(name !== undefined && { name }), + ...(slug !== undefined && { slug }), + }, + }); +}; + +type DeleteTagInput = z.infer; +type DeleteTagOutput = { success: boolean }; + +export const deleteTag: DeleteTag = async ( + rawArgs, + context, +) => { + ensureAdmin(context); + + const { id } = ensureArgsSchemaOrThrowHttpError(deletePostSchema, rawArgs); + + const existing = await context.entities.Tag.findUnique({ where: { id } }); + if (!existing) { + throw new HttpError(404, "标签不存在"); + } + + await context.entities.PostTag.deleteMany({ where: { tagId: id } }); + await context.entities.Tag.delete({ where: { id } }); + + return { success: true }; +}; diff --git a/template/app/src/cms/pages/AuthorsListPage.tsx b/template/app/src/cms/pages/AuthorsListPage.tsx new file mode 100644 index 0000000..5c6bfbb --- /dev/null +++ b/template/app/src/cms/pages/AuthorsListPage.tsx @@ -0,0 +1,269 @@ +import { useState } from "react"; +import { type AuthUser } from "wasp/auth"; +import { + getAllAuthors, + createAuthor, + updateAuthor, + deleteAuthor, + useQuery, +} from "wasp/client/operations"; +import { Button } from "../../client/components/ui/button"; +import { Input } from "../../client/components/ui/input"; +import { Label } from "../../client/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../client/components/ui/dialog"; +import { Breadcrumb } from "../../admin/layout/Breadcrumb"; +import { LoadingSpinner } from "../../admin/layout/LoadingSpinner"; +import { CmsLayout } from "./CmsLayout"; +import { toast } from "../../client/hooks/use-toast"; + +export function AuthorsListPage({ user }: { user: AuthUser }) { + const { data: authors, isLoading } = useQuery(getAllAuthors); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); + + // Form state + const [name, setName] = useState(""); + const [title, setTitle] = useState(""); + const [url, setUrl] = useState(""); + const [avatarUrl, setAvatarUrl] = useState(""); + + const resetForm = () => { + setName(""); + setTitle(""); + setUrl(""); + setAvatarUrl(""); + setEditingId(null); + }; + + const openCreate = () => { + resetForm(); + setDialogOpen(true); + }; + + const openEdit = (author: any) => { + setName(author.name); + setTitle(author.title || ""); + setUrl(author.url || ""); + setAvatarUrl(author.avatarUrl || ""); + setEditingId(author.id); + setDialogOpen(true); + }; + + const handleSave = async () => { + if (!name.trim()) { + toast({ title: "请输入作者名称" }); + return; + } + try { + if (editingId) { + await updateAuthor({ + id: editingId, + name, + title: title || undefined, + url: url || undefined, + avatarUrl: avatarUrl || undefined, + }); + toast({ title: "作者已更新" }); + } else { + await createAuthor({ + name, + title: title || undefined, + url: url || undefined, + avatarUrl: avatarUrl || undefined, + }); + toast({ title: "作者已创建" }); + } + setDialogOpen(false); + resetForm(); + } catch (err: any) { + toast({ title: "保存失败", description: err?.message || "未知错误" }); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteAuthor({ id }); + toast({ title: "作者已删除" }); + setDeletingId(null); + } catch (err: any) { + toast({ title: "删除失败", description: err?.message || "未知错误" }); + } + }; + + return ( + + + +
    +
    + {user.isAdmin && ( + + + + + + + {editingId ? "编辑作者" : "新建作者"} + +
    +
    + + setName(e.target.value)} + placeholder="作者名称" + /> +
    +
    + + setTitle(e.target.value)} + placeholder="如:创始人 & CEO" + /> +
    +
    + + setUrl(e.target.value)} + placeholder="https://..." + /> +
    +
    + + setAvatarUrl(e.target.value)} + placeholder="/avatar.png 或 https://..." + /> +
    +
    + + +
    +
    +
    +
    + )} +
    + + {/* ─── Table ───────────────────────────────────────── */} +
    +
    +
    +

    名称 / 头衔

    +
    +
    +

    个人链接

    +
    +
    +

    文章数

    +
    +
    +

    创建时间

    +
    +
    +

    操作

    +
    +
    + + {isLoading && } + + {authors?.length === 0 && !isLoading && ( +
    暂无作者
    + )} + + {authors?.map((author: any) => ( +
    +
    +

    {author.name}

    + {author.title && ( +

    {author.title}

    + )} +
    +
    +

    + {author.url || "—"} +

    +
    +
    +

    {author._count?.posts ?? 0}

    +
    +
    +

    + {new Date(author.createdAt).toLocaleDateString("zh-CN")} +

    +
    +
    + {user.isAdmin && ( + <> + + {deletingId === author.id ? ( +
    + + +
    + ) : ( + + )} + + )} +
    +
    + ))} +
    +
    +
    + ); +} diff --git a/template/app/src/cms/pages/CmsLayout.tsx b/template/app/src/cms/pages/CmsLayout.tsx new file mode 100644 index 0000000..ffe1719 --- /dev/null +++ b/template/app/src/cms/pages/CmsLayout.tsx @@ -0,0 +1,37 @@ +import { ReactNode, useState } from "react"; +import { type AuthUser } from "wasp/auth"; +import { Header } from "../../admin/layout/Header"; +import { Sidebar } from "../../admin/layout/Sidebar"; + +interface Props { + user: AuthUser; + children?: ReactNode; +} + +/** + * CMS 页面专用布局——与 DefaultLayout 不同的是,它不检查 isAdmin, + * 允许所有登录用户访问博客 CMS 功能(创建/编辑自己的文章)。 + */ +export function CmsLayout({ children, user }: Props) { + const [sidebarOpen, setSidebarOpen] = useState(false); + + return ( +
    +
    + +
    +
    +
    +
    + {children} +
    +
    +
    +
    +
    + ); +} diff --git a/template/app/src/cms/pages/PostEditorPage.tsx b/template/app/src/cms/pages/PostEditorPage.tsx new file mode 100644 index 0000000..8cbbf80 --- /dev/null +++ b/template/app/src/cms/pages/PostEditorPage.tsx @@ -0,0 +1,453 @@ +import { useCallback, useEffect, useState } from "react"; +import { type AuthUser } from "wasp/auth"; +import { Link, routes, useParams } from "wasp/client/router"; +import { + createPost, + updatePost, + submitPost, + approvePost, + rejectPost, + getAllAuthors, + getAllTags, + createTag, + getPostById, + getPostBySlug, + useQuery, +} from "wasp/client/operations"; +import { Button } from "../../client/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../../client/components/ui/card"; +import { Checkbox } from "../../client/components/ui/checkbox"; +import { Input } from "../../client/components/ui/input"; +import { Label } from "../../client/components/ui/label"; +import { Textarea } from "../../client/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../client/components/ui/select"; +import { Breadcrumb } from "../../admin/layout/Breadcrumb"; +import { LoadingSpinner } from "../../admin/layout/LoadingSpinner"; +import { CmsLayout } from "./CmsLayout"; +import { toast } from "../../client/hooks/use-toast"; + +function generateSlug(title: string): string { + return ( + title + .toLowerCase() + .replace(/[^a-z0-9一-鿿]+/g, "-") + .replace(/^-+|-+$/g, "") + || "untitled" + ); +} + +export function PostEditorPage({ user }: { user: AuthUser }) { + const params = useParams<{ id?: string }>(); + const postId = params.id; // undefined for new, string for edit + const isEditing = !!postId; + + // ── State ────────────────────────────────────────────────── + const [title, setTitle] = useState(""); + const [slug, setSlug] = useState(""); + const [subtitle, setSubtitle] = useState(""); + const [content, setContent] = useState(""); + const [hideBannerImage, setHideBannerImage] = useState(false); + const [authorIds, setAuthorIds] = useState([]); + const [tagIds, setTagIds] = useState([]); + const [status, setStatus] = useState("draft"); + const [saving, setSaving] = useState(false); + const [slugManuallyEdited, setSlugManuallyEdited] = useState(false); + const [slugCheckResult, setSlugCheckResult] = useState<{ exists: boolean; title: string } | null>(null); + + // ── Data ─────────────────────────────────────────────────── + const { data: postData, isLoading: postLoading } = useQuery( + getPostById, + { id: postId! }, + { enabled: isEditing }, + ); + + const { data: authors } = useQuery(getAllAuthors); + const { data: tags } = useQuery(getAllTags); + + // ── Load existing post ───────────────────────────────────── + useEffect(() => { + if (postData) { + setTitle(postData.title); + setSlug(postData.slug); + setSubtitle(postData.subtitle || ""); + setContent(postData.content || ""); + setHideBannerImage(postData.hideBannerImage); + setAuthorIds(postData.authors?.map((pa: any) => pa.author.id) || []); + setTagIds(postData.tags?.map((pt: any) => pt.tag.id) || []); + setStatus(postData.status); + setSlugManuallyEdited(true); + } + }, [postData]); + + // ── Slug auto-generate ───────────────────────────────────── + useEffect(() => { + if (!slugManuallyEdited && !isEditing) { + setSlug(generateSlug(title)); + } + }, [title, slugManuallyEdited, isEditing]); + + // ── Slug uniqueness check ────────────────────────────────── + const { data: slugCheck } = useQuery( + getPostBySlug, + { slug }, + { enabled: slug.length > 0 }, + ); + + useEffect(() => { + if (slugCheck && slugCheck.id !== postId) { + setSlugCheckResult({ exists: true, title: slugCheck.title }); + } else { + setSlugCheckResult(null); + } + }, [slugCheck, postId]); + + // ── Handlers ─────────────────────────────────────────────── + const canEdit = useCallback((): boolean => { + if (!isEditing) return true; + if (user.isAdmin) return true; + return status === "draft" || status === "rejected"; + }, [isEditing, user.isAdmin, status]); + + const canSubmit = useCallback((): boolean => { + if (!isEditing) return false; + if (user.isAdmin) return true; + return ( + postData?.userId === user.id && + (status === "draft" || status === "rejected") + ); + }, [isEditing, user.isAdmin, postData, user.id, status]); + + const canReview = useCallback((): boolean => { + return isEditing && user.isAdmin && status === "submitted"; + }, [isEditing, user.isAdmin, status]); + + const handleSave = async () => { + if (!title.trim()) { + toast({ title: "请输入文章标题" }); + return; + } + if (!slug.trim()) { + toast({ title: "请输入文章别名" }); + return; + } + if (authorIds.length === 0) { + toast({ title: "请至少选择一位作者" }); + return; + } + + setSaving(true); + try { + if (isEditing) { + await updatePost({ + id: postId!, + title, + slug, + subtitle: subtitle || undefined, + content, + hideBannerImage, + authorIds, + tagIds, + }); + toast({ title: "文章已保存" }); + } else { + await createPost({ + title, + slug, + subtitle: subtitle || undefined, + content, + hideBannerImage, + authorIds, + tagIds, + }); + toast({ title: "文章已创建" }); + // Navigate to posts list + window.location.href = routes.AdminPostsRoute.to; + } + } catch (err: any) { + toast({ title: "保存失败", description: err?.message || "未知错误" }); + } finally { + setSaving(false); + } + }; + + const handleSubmit = async () => { + if (!isEditing) return; + try { + await submitPost({ id: postId! }); + setStatus("submitted"); + toast({ title: "文章已提交审核" }); + } catch (err: any) { + toast({ title: "提交失败", description: err?.message || "未知错误" }); + } + }; + + const handleApprove = async () => { + if (!isEditing) return; + try { + await approvePost({ id: postId! }); + setStatus("published"); + toast({ title: "文章已审核通过并发布" }); + } catch (err: any) { + toast({ title: "审核失败", description: err?.message || "未知错误" }); + } + }; + + const handleReject = async () => { + if (!isEditing) return; + const reason = prompt("请输入驳回原因(可选):"); + try { + await rejectPost({ id: postId!, reason: reason || undefined }); + setStatus("rejected"); + toast({ title: "文章已驳回" }); + } catch (err: any) { + toast({ title: "驳回失败", description: err?.message || "未知错误" }); + } + }; + + const handleCreateTag = async () => { + const name = prompt("请输入新标签名称:"); + if (!name) return; + const tagSlug = prompt("请输入标签别名(小写字母、数字和连字符):", generateSlug(name)); + if (!tagSlug) return; + try { + await createTag({ name, slug: tagSlug }); + toast({ title: "标签已创建" }); + } catch (err: any) { + toast({ title: "创建标签失败", description: err?.message || "未知错误" }); + } + }; + + // ── Loading state ────────────────────────────────────────── + if (isEditing && postLoading) { + return ( + + + + + ); + } + + return ( + + + +
    + {/* ── Basic Info Card ────────────────────────────── */} + + + 基本信息 + + + {/* Title */} +
    + + setTitle(e.target.value)} + placeholder="输入文章标题" + disabled={!canEdit()} + /> +
    + + {/* Slug */} +
    + + { + setSlug(e.target.value); + setSlugManuallyEdited(true); + }} + placeholder="article-slug" + disabled={!canEdit()} + /> +
    + + {/* Subtitle */} +
    + + setSubtitle(e.target.value)} + placeholder="可选副标题" + disabled={!canEdit()} + /> +
    + + {/* Hide Banner Image */} +
    + setHideBannerImage(!!checked)} + disabled={!canEdit()} + /> + +
    +
    +
    + + {/* ── Content Card ───────────────────────────────── */} + + + 正文 (Markdown) + + +