@@ -12,18 +12,24 @@ import {
1212 DialogTrigger ,
1313} from "@/components/ui/dialog" ;
1414import { Input } from "@/components/ui/input" ;
15- import { ExternalLink , Plus , Sparkles } from "lucide-react" ;
15+ import { ExternalLink , Sparkles } from "lucide-react" ;
1616import styles from "./Contribute.module.css" ;
17+ import { useRouter } from "next/navigation" ;
1718
1819// --- antd
1920import { TreeSelect } from "antd" ;
20- import type { DefaultOptionType } from "antd/es/select" ;
2121import { DataNode } from "antd/es/tree" ;
2222import { buildDocsNewUrl } from "@/lib/github" ;
23-
24- type DirNode = { name : string ; path : string ; children ?: DirNode [ ] } ;
25-
26- const FILENAME_PATTERN = / ^ [ A - Z a - z 0 - 9 ] [ A - Z a - z 0 - 9 _ - ] + $ / ;
23+ import {
24+ FILENAME_PATTERN ,
25+ normalizeFilenameBase ,
26+ type DirNode ,
27+ } from "@/lib/submission" ;
28+ import {
29+ CREATE_SUBDIR_SUFFIX ,
30+ toTreeSelectData ,
31+ } from "@/app/components/contribute/tree-utils" ;
32+ import { sanitizeDocumentSlug } from "@/lib/sanitizer" ;
2733
2834// 统一调用工具函数生成 GitHub 新建链接,路径规则与 Edit 按钮一致
2935function buildGithubNewUrl ( dirPath : string , filename : string , title : string ) {
@@ -44,35 +50,8 @@ Write your content here.
4450 return buildDocsNewUrl ( dirPath , params ) ;
4551}
4652
47- // ✅ 用纯文本 label + 一级节点 selectable:false
48- function toTreeSelectData ( tree : DirNode [ ] ) : DefaultOptionType [ ] {
49- return tree . map ( ( l1 ) => ( {
50- key : l1 . path ,
51- value : l1 . path ,
52- label : l1 . name ,
53- selectable : false , // ✅ 一级不可选
54- children : [
55- ...( l1 . children || [ ] ) . map ( ( l2 ) => ( {
56- key : l2 . path ,
57- value : l2 . path ,
58- label : `${ l1 . name } / ${ l2 . name } ` , // 纯文本,方便搜索
59- isLeaf : true ,
60- } ) ) ,
61- {
62- key : `${ l1 . path } /__create__` ,
63- value : `${ l1 . path } /__create__` ,
64- label : (
65- < span className = "inline-flex items-center" >
66- < Plus className = "mr-1 h-3.5 w-3.5" />
67- 在「{ l1 . name } 」下新建二级子栏目…
68- </ span >
69- ) ,
70- } ,
71- ] ,
72- } ) ) ;
73- }
74-
7553export function Contribute ( ) {
54+ const router = useRouter ( ) ;
7655 const [ open , setOpen ] = useState ( false ) ;
7756 const [ tree , setTree ] = useState < DirNode [ ] > ( [ ] ) ;
7857 const [ loading , setLoading ] = useState ( false ) ;
@@ -85,22 +64,26 @@ export function Contribute() {
8564 const [ articleFile , setArticleFile ] = useState ( "" ) ;
8665 const [ articleFileTouched , setArticleFileTouched ] = useState ( false ) ;
8766
88- const trimmedArticleFile = useMemo ( ( ) => articleFile . trim ( ) , [ articleFile ] ) ;
67+ const normalizedArticleFile = useMemo (
68+ ( ) => normalizeFilenameBase ( articleFile ) ,
69+ [ articleFile ] ,
70+ ) ;
8971 const { isFileNameValid, fileNameError } = useMemo ( ( ) => {
90- if ( ! trimmedArticleFile ) {
72+ if ( ! normalizedArticleFile ) {
9173 return {
9274 isFileNameValid : false ,
9375 fileNameError : "请填写文件名。" ,
9476 } ;
9577 }
96- if ( ! FILENAME_PATTERN . test ( trimmedArticleFile ) ) {
78+ if ( ! FILENAME_PATTERN . test ( normalizedArticleFile ) ) {
9779 return {
9880 isFileNameValid : false ,
99- fileNameError : "文件名仅支持英文、数字、连字符或下划线。" ,
81+ fileNameError :
82+ "文件名仅支持字母、数字、连字符或下划线,并需以字母或数字开头。" ,
10083 } ;
10184 }
10285 return { isFileNameValid : true , fileNameError : "" } ;
103- } , [ trimmedArticleFile ] ) ;
86+ } , [ normalizedArticleFile ] ) ;
10487
10588 useEffect ( ( ) => {
10689 let mounted = true ;
@@ -125,22 +108,31 @@ export function Contribute() {
125108
126109 const options = useMemo ( ( ) => toTreeSelectData ( tree ) , [ tree ] ) ;
127110
111+ const sanitizedSubdir = useMemo (
112+ ( ) => sanitizeDocumentSlug ( newSub , "" ) ,
113+ [ newSub ] ,
114+ ) ;
115+
128116 const finalDirPath = useMemo ( ( ) => {
129117 if ( ! selectedKey ) return "" ;
130- if ( selectedKey . endsWith ( "/__create__" ) ) {
118+ if ( selectedKey . endsWith ( CREATE_SUBDIR_SUFFIX ) ) {
131119 const l1 = selectedKey . split ( "/" ) [ 0 ] ;
132- if ( ! newSub . trim ( ) ) return "" ;
133- return `${ l1 } /${ newSub . trim ( ) . replace ( / \s + / g , "-" ) } ` ;
120+ if ( ! l1 || ! sanitizedSubdir ) return "" ;
121+ return `${ l1 } /${ sanitizedSubdir } ` ;
134122 }
135123 return selectedKey ;
136- } , [ selectedKey , newSub ] ) ;
124+ } , [ selectedKey , sanitizedSubdir ] ) ;
137125
138126 const canProceed = ! ! finalDirPath && isFileNameValid ;
139127
140128 const handleOpenGithub = ( ) => {
141129 if ( ! canProceed ) return ;
142- const filename = trimmedArticleFile . toLowerCase ( ) ;
130+ if ( ! normalizedArticleFile ) return ;
131+ const filename = normalizedArticleFile ;
143132 const title = articleTitle || filename ;
133+ if ( filename !== articleFile ) {
134+ setArticleFile ( filename ) ;
135+ }
144136 window . open (
145137 buildGithubNewUrl ( finalDirPath , filename , title ) ,
146138 "_blank" ,
@@ -173,6 +165,10 @@ export function Contribute() {
173165 bg-gradient-to-r from-sky-300 via-sky-400 to-blue-600
174166 dark:from-indigo-950 dark:via-slate-900 dark:to-black
175167 hover:shadow-[0_25px_60px_-12px] hover:scale-[1.03] transition-all duration-300 ease-out"
168+ onClick = { ( event ) => {
169+ event . preventDefault ( ) ;
170+ router . push ( "/editor" ) ;
171+ } }
176172 >
177173 { /* Day gradient shimmer */ }
178174 < span
@@ -276,7 +272,7 @@ export function Contribute() {
276272 />
277273 </ div >
278274
279- { selectedKey . endsWith ( "/__create__" ) && (
275+ { selectedKey . endsWith ( CREATE_SUBDIR_SUFFIX ) && (
280276 < div className = "space-y-1" >
281277 < label className = "text-sm font-medium" > 新建二级子栏目名称</ label >
282278 < Input
@@ -285,7 +281,8 @@ export function Contribute() {
285281 onChange = { ( e ) => setNewSub ( e . target . value ) }
286282 />
287283 < p className = "text-xs text-muted-foreground" >
288- 将创建路径:{ selectedKey . split ( "/" ) [ 0 ] } / { newSub || "<未填写>" }
284+ 将创建路径:{ selectedKey . split ( "/" ) [ 0 ] } /{ " " }
285+ { sanitizedSubdir || "<未填写>" }
289286 </ p >
290287 </ div >
291288 ) }
0 commit comments