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 && (
+
+ )}
+
+
+ {/* ─── 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 (
+
+ );
+}
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)
+
+
+
+
+
+ {/* ── Authors & Tags Card ─────────────────────────── */}
+
+
+ 作者与标签
+
+
+ {/* Authors */}
+
+
+
+ {authors?.map((author: any) => (
+
+ ))}
+ {(!authors || authors.length === 0) && (
+
+ 暂无作者,请先由管理员创建作者。
+
+ )}
+
+
+
+ {/* Tags */}
+
+
+
+ {user.isAdmin && (
+
+ )}
+
+
+ {tags?.map((tag: any) => (
+
+ ))}
+ {(!tags || tags.length === 0) && (
+
+ 暂无标签,请先由管理员创建标签。
+
+ )}
+
+
+
+
+
+ {/* ── Status Info (edit mode only) ────────────────── */}
+ {isEditing && (
+
+
+
+ 状态:
+
+ {status === "draft" && "草稿"}
+ {status === "submitted" && "已提交审核"}
+ {status === "rejected" && "已驳回"}
+ {status === "published" && "已发布"}
+
+
+
+
+ )}
+
+ {/* ── Action Buttons ─────────────────────────────── */}
+
+ {canEdit() && (
+
+ )}
+ {canSubmit() && (
+
+ )}
+ {canReview() && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/template/app/src/cms/pages/PostsListPage.tsx b/template/app/src/cms/pages/PostsListPage.tsx
new file mode 100644
index 0000000..1471c4b
--- /dev/null
+++ b/template/app/src/cms/pages/PostsListPage.tsx
@@ -0,0 +1,280 @@
+import { useState } from "react";
+import { type AuthUser } from "wasp/auth";
+import { Link, routes } from "wasp/client/router";
+import {
+ getAllPosts,
+ deletePost,
+ submitPost,
+ approvePost,
+ rejectPost,
+ useQuery,
+} from "wasp/client/operations";
+import { Button } from "../../client/components/ui/button";
+import { Input } from "../../client/components/ui/input";
+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";
+
+const STATUS_LABELS: Record = {
+ draft: "草稿",
+ submitted: "已提交",
+ rejected: "已驳回",
+ published: "已发布",
+};
+
+const STATUS_COLORS: Record = {
+ draft: "bg-gray-200 text-gray-700",
+ submitted: "bg-blue-200 text-blue-700",
+ rejected: "bg-red-200 text-red-700",
+ published: "bg-green-200 text-green-700",
+};
+
+export function PostsListPage({ user }: { user: AuthUser }) {
+ const [currentPage, setCurrentPage] = useState(1);
+ const [statusFilter, setStatusFilter] = useState(undefined);
+ const [deletingId, setDeletingId] = useState(null);
+
+ const { data, isLoading } = useQuery(getAllPosts, {
+ status: statusFilter as "draft" | "submitted" | "rejected" | "published" | undefined,
+ skipPages: currentPage - 1,
+ });
+
+ const handleDelete = async (id: string) => {
+ try {
+ await deletePost({ id });
+ toast({ title: "文章已删除" });
+ setDeletingId(null);
+ } catch (err: any) {
+ toast({ title: "删除失败", description: err?.message || "未知错误" });
+ }
+ };
+
+ const handleSubmit = async (id: string) => {
+ try {
+ await submitPost({ id });
+ toast({ title: "文章已提交审核" });
+ } catch (err: any) {
+ toast({ title: "提交失败", description: err?.message || "未知错误" });
+ }
+ };
+
+ const handleApprove = async (id: string) => {
+ try {
+ await approvePost({ id });
+ toast({ title: "文章已审核通过并发布" });
+ } catch (err: any) {
+ toast({ title: "审核失败", description: err?.message || "未知错误" });
+ }
+ };
+
+ const handleReject = async (id: string) => {
+ const reason = prompt("请输入驳回原因(可选):");
+ try {
+ await rejectPost({ id, reason: reason || undefined });
+ toast({ title: "文章已驳回" });
+ } catch (err: any) {
+ toast({ title: "驳回失败", description: err?.message || "未知错误" });
+ }
+ };
+
+ const canEdit = (post: any) => {
+ if (user.isAdmin) return true;
+ return (
+ post.userId === user.id &&
+ (post.status === "draft" || post.status === "rejected")
+ );
+ };
+
+ const canDelete = (post: any) => {
+ if (user.isAdmin) return true;
+ return post.userId === user.id;
+ };
+
+ const canSubmit = (post: any) => {
+ return (
+ (post.userId === user.id || user.isAdmin) &&
+ (post.status === "draft" || post.status === "rejected")
+ );
+ };
+
+ const canReview = (post: any) => {
+ return user.isAdmin && post.status === "submitted";
+ };
+
+ return (
+
+
+
+
+ {/* ─── Toolbar ─────────────────────────────────────── */}
+
+
+
+
+
+
+ {data?.totalPages && (
+
+ 第
+ {
+ const v = parseInt(e.currentTarget.value);
+ if (data.totalPages && v > 0 && v <= data.totalPages) {
+ setCurrentPage(v);
+ }
+ }}
+ className="w-16"
+ />
+ /{data.totalPages} 页
+
+ )}
+
+
+
+
+
+
+ {/* ─── Table ───────────────────────────────────────── */}
+
+ {/* Header */}
+
+
+ {isLoading &&
}
+
+ {data?.posts?.length === 0 && !isLoading && (
+
+ 暂无文章
+
+ )}
+
+ {data?.posts?.map((post: any) => (
+
+
+
{post.title}
+
/{post.slug}
+
+
+
+ {STATUS_LABELS[post.status] || post.status}
+
+
+
+
+ {post.authors?.map((pa: any) => pa.author.name).join(", ") || "—"}
+
+
+
+
+ {post.user?.username || post.user?.email || "—"}
+
+
+
+
+ {post.publishedAt
+ ? new Date(post.publishedAt).toLocaleDateString("zh-CN")
+ : post.createdAt
+ ? new Date(post.createdAt).toLocaleDateString("zh-CN")
+ : "—"}
+
+
+
+ {canEdit(post) && (
+
+
+
+ )}
+ {canSubmit(post) && (
+
+ )}
+ {canReview(post) && (
+ <>
+
+
+ >
+ )}
+ {canDelete(post) && (
+
+ )}
+ {deletingId === post.id && (
+
+
+
+
+ )}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/template/app/src/cms/pages/TagsListPage.tsx b/template/app/src/cms/pages/TagsListPage.tsx
new file mode 100644
index 0000000..5ae2c57
--- /dev/null
+++ b/template/app/src/cms/pages/TagsListPage.tsx
@@ -0,0 +1,241 @@
+import { useState } from "react";
+import { type AuthUser } from "wasp/auth";
+import {
+ getAllTags,
+ createTag,
+ updateTag,
+ deleteTag,
+ 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 TagsListPage({ user }: { user: AuthUser }) {
+ const { data: tags, isLoading } = useQuery(getAllTags);
+
+ const [dialogOpen, setDialogOpen] = useState(false);
+ const [editingId, setEditingId] = useState(null);
+ const [deletingId, setDeletingId] = useState(null);
+
+ // Form state
+ const [name, setName] = useState("");
+ const [slug, setSlug] = useState("");
+
+ const resetForm = () => {
+ setName("");
+ setSlug("");
+ setEditingId(null);
+ };
+
+ const openCreate = () => {
+ resetForm();
+ setDialogOpen(true);
+ };
+
+ const openEdit = (tag: any) => {
+ setName(tag.name);
+ setSlug(tag.slug);
+ setEditingId(tag.id);
+ setDialogOpen(true);
+ };
+
+ const handleSave = async () => {
+ if (!name.trim()) {
+ toast({ title: "请输入标签名称" });
+ return;
+ }
+ if (!slug.trim()) {
+ toast({ title: "请输入标签别名" });
+ return;
+ }
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
+ toast({ title: "别名格式不正确", description: "只能包含小写字母、数字和连字符,且不能以连字符开头或结尾" });
+ return;
+ }
+ try {
+ if (editingId) {
+ await updateTag({
+ id: editingId,
+ name,
+ slug,
+ });
+ toast({ title: "标签已更新" });
+ } else {
+ await createTag({ name, slug });
+ toast({ title: "标签已创建" });
+ }
+ setDialogOpen(false);
+ resetForm();
+ } catch (err: any) {
+ toast({ title: "保存失败", description: err?.message || "未知错误" });
+ }
+ };
+
+ const handleDelete = async (id: string) => {
+ try {
+ await deleteTag({ id });
+ toast({ title: "标签已删除" });
+ setDeletingId(null);
+ } catch (err: any) {
+ toast({ title: "删除失败", description: err?.message || "未知错误" });
+ }
+ };
+
+ return (
+
+
+
+
+
+ {user.isAdmin && (
+
+ )}
+
+
+ {/* ─── Table ───────────────────────────────────────── */}
+
+
+
+ {isLoading &&
}
+
+ {tags?.length === 0 && !isLoading && (
+
暂无标签
+ )}
+
+ {tags?.map((tag: any) => (
+
+
+
{tag.name}
+
/{tag.slug}
+
+
+
{tag._count?.posts ?? 0}
+
+
+
+ {new Date(tag.createdAt).toLocaleDateString("zh-CN")}
+
+
+
+ {user.isAdmin && (
+ <>
+
+ {deletingId === tag.id ? (
+
+
+
+
+ ) : (
+
+ )}
+ >
+ )}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/template/app/src/env.ts b/template/app/src/env.ts
index 47aa6db..266ea03 100644
--- a/template/app/src/env.ts
+++ b/template/app/src/env.ts
@@ -3,6 +3,7 @@ import { defineEnvValidationSchema } from "wasp/env";
import * as z from "zod";
import { googleAnalyticsEnvSchema, plausibleEnvSchema } from "./analytics/env";
import { authEnvSchema } from "./auth/env";
+import { cmsEnvSchema } from "./cms/env";
import { demoAiAppEnvSchema } from "./demo-ai-app/env";
import { fileUploadEnvSchema } from "./file-upload/env";
import { lemonSqueezyEnvSchema } from "./payment/lemonSqueezy/env";
@@ -26,5 +27,6 @@ export const serverEnvValidationSchema = defineEnvValidationSchema(
...fileUploadEnvSchema.shape,
...plausibleEnvSchema.shape,
...googleAnalyticsEnvSchema.shape,
+ ...cmsEnvSchema.shape,
}),
);
diff --git a/template/app/src/server/scripts/dbSeeds.ts b/template/app/src/server/scripts/dbSeeds.ts
index eb87517..19f01bd 100644
--- a/template/app/src/server/scripts/dbSeeds.ts
+++ b/template/app/src/server/scripts/dbSeeds.ts
@@ -57,3 +57,111 @@ function generateMockUserData(): MockUserData {
: null,
};
}
+
+/**
+ * Seeds the database with sample CMS data (authors, tags, and posts)
+ * for development and testing.
+ */
+export async function seedCmsData(prismaClient: PrismaClient) {
+ // Pick the first user as the post creator (fallback seeding)
+ const firstUser = await prismaClient.user.findFirst();
+ if (!firstUser) {
+ console.log("[CMS Seed] 数据库中没有用户,跳过 CMS 种子数据");
+ return;
+ }
+
+ // ── Create Authors ──────────────────────────────────────────
+ const author1 = await prismaClient.author.create({
+ data: {
+ name: "张三",
+ title: "创始人 & CEO",
+ url: "https://example.com/zhangsan",
+ avatarUrl: "/CRAIG_ROCK.png",
+ },
+ });
+
+ const author2 = await prismaClient.author.create({
+ data: {
+ name: "李四",
+ title: "技术总监",
+ url: "https://example.com/lisi",
+ },
+ });
+
+ // ── Create Tags ────────────────────────────────────────────
+ const tagData = [
+ { name: "SaaS", slug: "saas" },
+ { name: "创业", slug: "startup" },
+ { name: "技术", slug: "tech" },
+ { name: "产品", slug: "product" },
+ { name: "AI", slug: "ai" },
+ ];
+
+ const tags = await Promise.all(
+ tagData.map((t) =>
+ prismaClient.tag.create({ data: { name: t.name, slug: t.slug } }),
+ ),
+ );
+
+ // ── Create Posts ───────────────────────────────────────────
+ const draftPost = await prismaClient.post.create({
+ data: {
+ title: "如何从零开始构建一个 SaaS 产品",
+ slug: "how-to-build-saas-from-scratch",
+ subtitle: "一份给独立开发者的实战指南",
+ content: `## 引言\n\n构建一个 SaaS 产品并不像想象中那么困难。在这篇文章中,我将分享我的经验...\n\n\n\n## 第一步:验证想法\n\n在写任何代码之前,先确认你的想法有市场需求...\n\n## 第二步:构建 MVP\n\n专注于核心功能,其他一切都可以等待...`,
+ status: "draft",
+ hideBannerImage: false,
+ userId: firstUser.id,
+ },
+ });
+
+ const submittedPost = await prismaClient.post.create({
+ data: {
+ title: "2024 年最值得关注的 5 个技术趋势",
+ slug: "top-5-tech-trends-2024",
+ subtitle: "AI、边缘计算与更多",
+ content: `## 概述\n\n2024 年是技术飞速发展的一年...\n\n\n\n## 1. AI Agent 全面爆发\n\nAI Agent 正在改变我们与软件交互的方式...\n\n## 2. 边缘计算成为主流\n\n随着 IoT 设备的普及...`,
+ status: "submitted",
+ hideBannerImage: false,
+ userId: firstUser.id,
+ },
+ });
+
+ const publishedPost = await prismaClient.post.create({
+ data: {
+ title: "Open SaaS 模板使用指南",
+ slug: "open-saas-starter-guide",
+ subtitle: "快速启动你的 SaaS 项目",
+ content: `## 欢迎使用 Open SaaS\n\nOpen SaaS 是一个功能齐全的 SaaS 启动模板...\n\n\n\n## 快速开始\n\n1. 克隆仓库\n2. 安装依赖\n3. 配置环境变量\n4. 启动开发服务器`,
+ status: "published",
+ publishedAt: new Date(),
+ hideBannerImage: false,
+ userId: firstUser.id,
+ },
+ });
+
+ // ── Link Authors to Posts ──────────────────────────────────
+ await prismaClient.postAuthor.createMany({
+ data: [
+ { postId: draftPost.id, authorId: author1.id },
+ { postId: submittedPost.id, authorId: author1.id },
+ { postId: submittedPost.id, authorId: author2.id },
+ { postId: publishedPost.id, authorId: author2.id },
+ ],
+ });
+
+ // ── Link Tags to Posts ─────────────────────────────────────
+ await prismaClient.postTag.createMany({
+ data: [
+ { postId: draftPost.id, tagId: tags[0].id },
+ { postId: draftPost.id, tagId: tags[1].id },
+ { postId: submittedPost.id, tagId: tags[2].id },
+ { postId: submittedPost.id, tagId: tags[4].id },
+ { postId: publishedPost.id, tagId: tags[0].id },
+ { postId: publishedPost.id, tagId: tags[2].id },
+ ],
+ });
+
+ console.log("[CMS Seed] 种子数据已创建:2 位作者、5 个标签、3 篇文章");
+}
diff --git a/template/blog/astro.config.mjs b/template/blog/astro.config.mjs
index d811f34..7bf4d9e 100644
--- a/template/blog/astro.config.mjs
+++ b/template/blog/astro.config.mjs
@@ -1,6 +1,7 @@
import starlight from "@astrojs/starlight";
import { defineConfig } from "astro/config";
import starlightBlog from "starlight-blog";
+import sitemap from "@astrojs/sitemap";
import tailwind from "@astrojs/tailwind";
@@ -86,5 +87,6 @@ export default defineConfig({
],
}),
tailwind({ applyBaseStyles: false }),
+ sitemap(),
],
});
diff --git a/template/blog/package.json b/template/blog/package.json
index 39b29e5..ec6197d 100644
--- a/template/blog/package.json
+++ b/template/blog/package.json
@@ -17,6 +17,7 @@
"astro": "^4.16.15",
"sharp": "^0.32.5",
"starlight-blog": "^0.15.0",
+ "@astrojs/sitemap": "^3.2.0",
"typescript": "^5.4.5"
}
}