Skip to content

Commit 790a58b

Browse files
feat: 轻量发文 posts 模块(编辑器直发 + /feed 原创 Tab + 个人主页 + 详情页 + 转正 PR)
新增 PostContent UGC Markdown 渲染器(react-markdown + rehype-sanitize,XSS 防护), EditorPageClient 改造为直发 POST /api/posts,/feed 加原创文章默认 Tab, /u/[username]/posts 列表页和 /u/[username]/posts/[slug] 详情页, PromoteToDocsButton 三态按钮支持一键转正 PR,个人主页追加文章入口。
1 parent 66a601e commit 790a58b

17 files changed

Lines changed: 1266 additions & 199 deletions

app/[locale]/editor/EditorPageClient.tsx

Lines changed: 99 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22

33
import { useEditorStore } from "@/lib/editor-store";
44
import { EditorMetadataForm } from "@/app/components/EditorMetadataForm";
5-
import { DocsDestinationForm } from "@/app/components/DocsDestinationForm";
65
import {
76
MarkdownEditor,
87
type MarkdownEditorHandle,
98
} from "@/app/components/MarkdownEditor";
109
import { Button } from "@/app/components/ui/button";
1110
import { useCallback, useRef, useState } from "react";
11+
import { useRouter } from "next/navigation";
1212
import Link from "next/link";
1313
import type { UserView } from "@/lib/use-auth";
14-
import { buildDocsNewUrl } from "@/lib/github";
14+
import type { PostRequest, ApiResponse, PostView } from "@/app/types/post";
1515
import {
1616
FILENAME_PATTERN,
1717
normalizeMarkdownFilename,
@@ -22,7 +22,24 @@ interface EditorPageClientProps {
2222
user: UserView;
2323
}
2424

25-
function buildFrontmatter({
25+
/**
26+
* 从文章标题生成 slug 候选值,和后端生成逻辑保持一致(kebab-case,纯 ASCII)。
27+
* 后端会做唯一性去重,前端只是提前填充 filename input 用,不是最终 slug。
28+
*/
29+
function titleToSlug(title: string): string {
30+
return title
31+
.toLowerCase()
32+
.trim()
33+
.replace(/[\s_]+/g, "-")
34+
.replace(/[^\p{L}\p{N}-]/gu, "")
35+
.replace(/-{2,}/g, "-")
36+
.replace(/^-+|-+$/g, "")
37+
.slice(0, 128);
38+
}
39+
40+
// buildFrontmatter 仅在「收录进知识库」(PromoteToDocsButton)路径使用,
41+
// 这里为 PromoteToDocsButton 单独导出,editor 直发不再拼 frontmatter。
42+
export function buildFrontmatter({
2643
title,
2744
description,
2845
tags,
@@ -58,46 +75,36 @@ function buildFrontmatter({
5875
return lines.join("\n");
5976
}
6077

61-
/**
62-
* 编辑器页面客户端组件
63-
* 包含表单、编辑器和发布按钮
64-
*/
6578
export function EditorPageClient({ user }: EditorPageClientProps) {
79+
const router = useRouter();
6680
const [isPublishing, setIsPublishing] = useState(false);
6781
const [imageCount, setImageCount] = useState(0);
68-
const [destinationPath, setDestinationPath] = useState("");
6982
const editorRef = useRef<MarkdownEditorHandle | null>(null);
7083
const { title, description, tags, filename, markdown, setFilename } =
7184
useEditorStore();
7285
const handleImageCountChange = useCallback((count: number) => {
7386
setImageCount(count);
7487
}, []);
75-
const previewFilename = filename ? normalizeMarkdownFilename(filename) : "";
88+
const previewSlug = filename
89+
? stripMarkdownExtension(normalizeMarkdownFilename(filename))
90+
: "";
7691

77-
/**
78-
* 上传单个图片到 R2
79-
*/
92+
// 上传单个图片到 R2,返回 { blobUrl, publicUrl }
8093
const uploadImage = async (
8194
blobUrl: string,
8295
file: File,
8396
articleSlug: string,
8497
): Promise<{ blobUrl: string; publicUrl: string }> => {
8598
// 规范化 Content-Type:只取主 MIME(分号前)+ trim + 小写。
86-
// 服务端预签名 URL 绑的是这个规范化后的 ContentType,客户端 PUT 时的
87-
// Content-Type header 必须 byte-exact 对得上,否则 R2 返 403 SignatureDoesNotMatch。
88-
// 浏览器 file.type 在极少见情况下可能是 "Image/JPEG" 或 "image/jpeg; foo=bar",
89-
// 不能直接原样透传。
99+
// 服务端预签名 URL 绑的是规范化后的 ContentType,客户端 PUT 时必须 byte-exact 一致,
100+
// 否则 R2 返 403 SignatureDoesNotMatch。
90101
const primaryMime = file.type.split(";")[0]!.trim().toLowerCase();
91102
if (!primaryMime) {
92-
// 浏览器识别不出 MIME(某些冷门类型会给空串)。此时继续走会被服务端 MIME_PATTERN
93-
// 正则直接 400,给个本地报错更清晰,和 editor 里其它 throw -> handlePublish alert 的
94-
// 链路一致。
95103
throw new Error(
96104
`无法识别图片类型:${file.name}(浏览器未给出 MIME),请另存为 PNG/JPG/WebP 后重试`,
97105
);
98106
}
99107

100-
// 1. 获取预签名 URL(带 x-satoken 请求头,供服务端验证身份)
101108
const token = localStorage.getItem("satoken") ?? "";
102109
const response = await fetch("/api/upload", {
103110
method: "POST",
@@ -120,12 +127,10 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
120127

121128
const { uploadUrl, publicUrl } = await response.json();
122129

123-
// 2. 上传文件到 R2 —— Content-Type 必须和签名时服务端绑的 primaryMime byte-exact 一致
130+
// Content-Type 必须和签名时绑的 primaryMime byte-exact 一致,否则 R2 返 403
124131
const uploadResponse = await fetch(uploadUrl, {
125132
method: "PUT",
126-
headers: {
127-
"Content-Type": primaryMime,
128-
},
133+
headers: { "Content-Type": primaryMime },
129134
body: file,
130135
});
131136

@@ -145,117 +150,92 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
145150
return;
146151
}
147152

148-
if (!filename.trim()) {
149-
alert("请输入文件名");
150-
return;
151-
}
152-
153-
if (!destinationPath) {
154-
alert("请选择投稿目录");
155-
return;
156-
}
153+
// filename 字段作为 slug 来源;为空时用 title 自动生成
154+
const rawSlug = filename.trim()
155+
? stripMarkdownExtension(normalizeMarkdownFilename(filename))
156+
: titleToSlug(title);
157157

158-
const normalizedFilename = normalizeMarkdownFilename(filename);
159-
const filenameBase = stripMarkdownExtension(normalizedFilename);
160-
if (!filenameBase || !FILENAME_PATTERN.test(filenameBase)) {
158+
if (rawSlug && !FILENAME_PATTERN.test(rawSlug)) {
161159
alert(
162160
"文件名仅支持字母、数字、连字符或下划线,并需以字母或数字开头(已自动清洗空格和特殊符号)。",
163161
);
164162
return;
165163
}
166164

167-
if (normalizedFilename !== filename) {
168-
setFilename(normalizedFilename);
165+
if (filename.trim()) {
166+
const normalized = normalizeMarkdownFilename(filename);
167+
if (normalized !== filename) setFilename(normalized);
169168
}
170169

171-
let githubDraftWindow: Window | null = null;
172-
try {
173-
githubDraftWindow = window.open("", "_blank");
174-
if (githubDraftWindow) {
175-
githubDraftWindow.document.title = "正在生成稿件…";
176-
githubDraftWindow.document.body.innerHTML =
177-
'<p style="font-family:system-ui;padding:16px;">正在生成 GitHub 草稿,请稍候…</p>';
178-
githubDraftWindow.opener = null;
179-
}
180-
} catch {
181-
githubDraftWindow = null;
182-
}
183-
184-
console.group("发布流程:上传图片并生成 GitHub 草稿");
185-
console.log("文章标题:", title);
186-
console.log("文件名:", normalizedFilename);
187-
console.log("投稿目录:", destinationPath);
188-
console.log("图片数量:", imageCount);
189-
190170
let finalMarkdown = markdown;
191-
const articleSlug = filenameBase;
171+
const articleSlug = rawSlug || "draft";
192172

193-
// 如果有图片,上传到 R2 并替换 URL
194173
const editorHandle = editorRef.current;
195174
if (!editorHandle) {
196175
throw new Error("编辑器尚未就绪,无法上传图片");
197176
}
198177

178+
// 清理编辑器中未被 Markdown 正文引用的孤儿图片
199179
const removedImages = editorHandle.removeUnreferencedImages(markdown);
200180
if (removedImages > 0) {
201-
console.log(`已清理 ${removedImages} 个未在 Markdown 中引用的图片`);
181+
console.log(`已清理 ${removedImages} 个未引用的图片`);
202182
}
203183

204184
const imageEntries = Array.from(editorHandle.getImages().entries());
205185

206186
if (imageEntries.length > 0) {
207-
console.log("开始上传图片...");
208-
209-
// 并发上传所有图片
210187
const uploadPromises = imageEntries.map(([blobUrl, file]) =>
211188
uploadImage(blobUrl, file, articleSlug),
212189
);
213-
214190
const uploadResults = await Promise.all(uploadPromises);
215191

216-
console.log("所有图片上传完成!");
217-
console.group("图片 URL 映射");
218-
uploadResults.forEach(({ blobUrl, publicUrl }) => {
219-
console.log(`${blobUrl} -> ${publicUrl}`);
220-
});
221-
console.groupEnd();
222-
223-
// 替换 Markdown 中的 blob URL 为公开 URL
192+
// 用 R2 公开 URL 替换 blob URL
224193
uploadResults.forEach(({ blobUrl, publicUrl }) => {
225194
finalMarkdown = finalMarkdown.replaceAll(blobUrl, publicUrl);
226195
});
227-
228-
console.log("Markdown 中的 blob URL 已替换为公开 URL");
229196
}
230197

231-
console.group("最终 Markdown 内容");
232-
console.log(finalMarkdown);
233-
console.groupEnd();
198+
// POST /api/posts 直发落库
199+
// TODO(backend-contract): 等后端 #2 完成后确认 BACKEND_URL 路由 rewrite 情况;
200+
// 目前后端路由走 next.config.mjs rewrites 同源代理(参考 /api/community/links),
201+
// 若 posts 同样走代理则直接 fetch "/api/posts",否则需要带 BACKEND_URL。
202+
const token = localStorage.getItem("satoken") ?? "";
203+
const postRequest: PostRequest = {
204+
title: title.trim(),
205+
description: description.trim() || undefined,
206+
tags: tags.filter((t) => t.trim().length > 0),
207+
contentMd: finalMarkdown,
208+
// 有用户填的 slug 就带上,后端会去重;没有则不传,后端从 title 自动生成
209+
...(rawSlug ? { slug: rawSlug } : {}),
210+
};
211+
212+
const res = await fetch("/api/posts", {
213+
method: "POST",
214+
headers: {
215+
"Content-Type": "application/json",
216+
// rewrite 透传:后端 sa-token.token-name=satoken,需用 satoken 而非 x-satoken
217+
satoken: token,
218+
},
219+
body: JSON.stringify(postRequest),
220+
signal: AbortSignal.timeout(30_000),
221+
});
234222

235-
console.groupEnd();
223+
if (!res.ok) {
224+
const body = await res.json().catch(() => ({}));
225+
throw new Error(
226+
(body as { message?: string }).message ??
227+
`发布失败(HTTP ${res.status})`,
228+
);
229+
}
236230

237-
const frontmatter = buildFrontmatter({
238-
title,
239-
description,
240-
tags,
241-
});
242-
const markdownBody = finalMarkdown.trimStart();
243-
const finalContent =
244-
markdownBody.length > 0
245-
? `${frontmatter}\n\n${markdownBody}`
246-
: `${frontmatter}\n`;
247-
248-
const params = new URLSearchParams({
249-
filename: normalizedFilename,
250-
value: finalContent,
251-
});
252-
const githubUrl = buildDocsNewUrl(destinationPath, params);
253-
if (githubDraftWindow) {
254-
githubDraftWindow.location.href = githubUrl;
255-
} else {
256-
window.open(githubUrl, "_blank", "noopener,noreferrer");
231+
const body = (await res.json()) as ApiResponse<PostView>;
232+
if (!body.success || !body.data) {
233+
throw new Error(body.message ?? "发布失败,请重试");
257234
}
258-
alert("图片已上传并生成 GitHub 草稿,请在新标签页完成提交。");
235+
236+
const { slug: finalSlug } = body.data;
237+
// 跳到文章详情页
238+
router.push(`/u/${user.username}/posts/${finalSlug}`);
259239
} catch (error) {
260240
console.error("发布失败:", error);
261241
alert(`发布失败:${error instanceof Error ? error.message : "未知错误"}`);
@@ -264,14 +244,16 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
264244
}
265245
};
266246

247+
const canPublish = title.trim().length > 0 && !isPublishing;
248+
267249
return (
268250
<div className="mx-auto max-w-6xl px-4 py-8">
269251
{/* 头部 */}
270252
<header className="mb-8 flex items-center justify-between">
271253
<div>
272-
<h1 className="text-3xl font-bold">创作新文章</h1>
254+
<h1 className="text-3xl font-bold">写篇文章</h1>
273255
<p className="text-muted-foreground mt-1">
274-
欢迎,{user.displayName || user.username}
256+
写完直接发布,想进知识库再一键投稿。
275257
</p>
276258
</div>
277259
<Link href="/">
@@ -281,9 +263,8 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
281263

282264
{/* 主要内容区域 */}
283265
<div className="space-y-6">
284-
{/* 元数据表单 */}
266+
{/* 元数据表单(标题/描述/标签/文件名) */}
285267
<EditorMetadataForm />
286-
<DocsDestinationForm onChange={setDestinationPath} />
287268

288269
{/* Markdown 编辑器 */}
289270
<div>
@@ -299,24 +280,20 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
299280
/>
300281
</div>
301282

302-
{/* 操作按钮 */}
283+
{/* 操作区 */}
303284
<div className="flex items-center justify-between rounded-lg border border-border bg-card p-4">
304285
<div className="text-sm text-muted-foreground">
305-
{!title.trim() || !filename.trim() ? (
306-
<span className="text-destructive">请填写标题和文件名</span>
307-
) : !destinationPath ? (
308-
<span className="text-destructive">请选择投稿目录</span>
309-
) : (
286+
{!title.trim() ? (
287+
<span className="text-destructive">请填写标题</span>
288+
) : previewSlug ? (
310289
<span>
311-
将在{" "}
290+
将发布到{" "}
312291
<code className="font-mono text-foreground">
313-
{destinationPath}
314-
</code>{" "}
315-
下创建{" "}
316-
<code className="font-mono text-foreground">
317-
{previewFilename}
292+
/u/{user.username}/posts/{previewSlug}
318293
</code>
319294
</span>
295+
) : (
296+
<span>发布后 slug 由标题自动生成</span>
320297
)}
321298
</div>
322299

@@ -333,29 +310,19 @@ export function EditorPageClient({ user }: EditorPageClientProps) {
333310
清空
334311
</Button>
335312

336-
<Button
337-
onClick={handlePublish}
338-
disabled={
339-
!title.trim() ||
340-
!filename.trim() ||
341-
!destinationPath ||
342-
isPublishing
343-
}
344-
>
345-
{isPublishing ? "处理中..." : "发布文章"}
313+
<Button onClick={handlePublish} disabled={!canPublish}>
314+
{isPublishing ? "发布中..." : "发布文章"}
346315
</Button>
347316
</div>
348317
</div>
349318

350-
{/* 提示信息 */}
319+
{/* 流程提示 */}
351320
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm dark:border-green-900 dark:bg-green-950">
352-
<h3 className="font-medium mb-2">发布流程提示</h3>
321+
<h3 className="font-medium mb-2">写完直接发</h3>
353322
<ul className="space-y-1 text-muted-foreground list-disc list-inside">
354-
<li>填写标题、描述、标签与文件名,自动补全 .md 后缀</li>
355-
<li>选择或新建投稿目录,目录结构与现有投稿机制一致</li>
356-
<li>点击“发布文章”将自动上传图片并替换为线上 URL</li>
357-
<li>系统会生成标准 Frontmatter,并打开 GitHub 新建页面</li>
358-
<li>在 GitHub 页面确认内容后提交 PR 即可完成投稿</li>
323+
<li>图片粘贴后自动上传到 CDN,发布时无需额外处理</li>
324+
<li>发布即可见,链接可直接分享,不等 review</li>
325+
<li>想进知识库?发布后点「收录进知识库」一键投稿</li>
359326
</ul>
360327
</div>
361328
</div>

0 commit comments

Comments
 (0)