22
33import { useEditorStore } from "@/lib/editor-store" ;
44import { EditorMetadataForm } from "@/app/components/EditorMetadataForm" ;
5- import { DocsDestinationForm } from "@/app/components/DocsDestinationForm" ;
65import {
76 MarkdownEditor ,
87 type MarkdownEditorHandle ,
98} from "@/app/components/MarkdownEditor" ;
109import { Button } from "@/app/components/ui/button" ;
1110import { useCallback , useRef , useState } from "react" ;
11+ import { useRouter } from "next/navigation" ;
1212import Link from "next/link" ;
1313import type { UserView } from "@/lib/use-auth" ;
14- import { buildDocsNewUrl } from "@/lib/github " ;
14+ import type { PostRequest , ApiResponse , PostView } from "@/app/types/post " ;
1515import {
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- */
6578export 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