nsbp-cli 0.2.34 → 0.2.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -147,7 +147,7 @@ node ./bin/nsbp.js --help # Test CLI locally
147
147
 
148
148
  - **Package Name**: `nsbp-cli`
149
149
  - **Bin Command**: `nsbp` (install globally and run `nsbp --help`)
150
- - **Version**: `0.2.34`
150
+ - **Version**: `0.2.35`
151
151
  - **Dependencies**: chalk, commander, fs-extra, inquirer
152
152
  - **Package Manager**: Uses pnpm (also compatible with npm)
153
153
  - **Node Version**: >=16.0.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nsbp-cli",
3
- "version": "0.2.34",
3
+ "version": "0.2.35",
4
4
  "description": "CLI tool for creating NSBP (Node React SSR by Webpack) projects",
5
5
  "main": "index.js",
6
6
  "homepage": "https://nsbp.erishen.cn/",
@@ -144,14 +144,24 @@ docker-compose exec app env | grep NODE_ENV
144
144
 
145
145
  ### 本地访问
146
146
 
147
- 客户端渲染
147
+ **服务端渲染**(默认,对 SEO 友好)
148
+ ```
148
149
  http://localhost:3001/
150
+ ```
149
151
 
150
- 服务端渲染
151
- http://localhost:3001/?seo=1
152
+ **客户端渲染**(禁用 SSR)
153
+ ```
154
+ http://localhost:3001/?nsbp=0
155
+ ```
156
+
157
+ **服务端渲染回退**(如果 SSR 失败,自动回退到客户端渲染)
158
+ ```
159
+ http://localhost:3001/?nsbp=1&from=link
160
+ ```
152
161
 
153
- 服务端渲染不成功,改为客户端渲染
154
- http://localhost:3001/?seo=1&from=link
162
+ > **参数说明**:`nsbp` 参数控制渲染模式
163
+ > - `nsbp=1` 或省略:服务端渲染(SSR,默认)
164
+ > - `nsbp=0`:客户端渲染(CSR)
155
165
 
156
166
  ## Docker 部署
157
167
 
@@ -25,10 +25,15 @@ pnpm run dev:build:start # 启动服务器
25
25
 
26
26
  ### 3. 访问应用
27
27
 
28
- - **客户端渲染**: http://localhost:3001/
29
- - **服务端渲染**: http://localhost:3001/?seo=1
28
+ - **服务端渲染**(默认,SEO 友好): http://localhost:3001/
29
+ - **客户端渲染**(禁用 SSR): http://localhost:3001/?nsbp=0
30
+ - **服务端渲染回退**(SSR 失败时回退到 CSR): http://localhost:3001/?nsbp=1&from=link
30
31
  - **BrowserSync**: http://localhost:3000/
31
32
 
33
+ > **参数说明**:`nsbp` 参数控制渲染模式
34
+ > - `nsbp=1` 或省略:服务端渲染(SSR,默认)
35
+ > - `nsbp=0`:客户端渲染(CSR)
36
+
32
37
  ## 📝 开发工作流
33
38
 
34
39
  ### 提交代码
@@ -1,5 +1,6 @@
1
1
  import React from 'react'
2
2
  import { loadData as homeLoadData } from '@services/home'
3
+ import { loadData as photoLoadData } from '@services/photo'
3
4
  import loadable from '@loadable/component'
4
5
 
5
6
  const Loading = () => {
@@ -33,7 +34,7 @@ export default [
33
34
  {
34
35
  path: '/photo',
35
36
  element: <Photo />,
36
- loadData: homeLoadData, // 使用相同的 loadData 来预取图片菜单
37
+ loadData: photoLoadData, // 使用 photo 的 loadData 来预取图片数据
37
38
  key: 'photo'
38
39
  }
39
40
  ]
@@ -9,13 +9,13 @@ import Theme from '@components/Theme'
9
9
  import { loadableReady } from '@loadable/component'
10
10
 
11
11
  const App = () => {
12
- const [store, setStore] = useState(getStore())
13
-
14
- useEffect(() => {
15
- if (isSEO()) {
16
- setStore(getStore(window?.context?.state))
12
+ // 优先使用服务端预取的状态初始化 store
13
+ const [store, setStore] = useState(() => {
14
+ if (isSEO() && window?.context?.state) {
15
+ return getStore(window.context.state)
17
16
  }
18
- }, [])
17
+ return getStore()
18
+ })
19
19
 
20
20
  return (
21
21
  <Theme>
@@ -4,17 +4,17 @@ import Loading from './Loading'
4
4
 
5
5
  interface LayoutProps {
6
6
  children: React.ReactNode
7
- query?: { seo?: string | number }
7
+ query?: { nsbp?: string | number }
8
8
  }
9
9
 
10
10
  const Layout = ({ children, query }: LayoutProps) => {
11
- let seo: string | number | undefined
11
+ let nsbp: string | number | undefined
12
12
  if (query !== undefined && query !== null) {
13
- seo = query.seo
13
+ nsbp = query.nsbp
14
14
  }
15
15
 
16
16
  const [pageLoad, setPageLoad] = useState(
17
- seo !== undefined ? parseInt(String(seo), 10) : 0
17
+ nsbp !== undefined ? parseInt(String(nsbp), 10) : 0
18
18
  )
19
19
 
20
20
  useEffect(() => {
@@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'
2
2
  import { Link } from 'react-router-dom'
3
3
  import Layout from '@components/Layout'
4
4
  import { Helmet } from 'react-helmet'
5
+ import { isSEO, usePreserveNSBP } from '@/utils'
5
6
  import {
6
7
  GlobalStyle,
7
8
  PageWrapper,
@@ -66,20 +67,23 @@ interface PhotoMenuItem {
66
67
  const Home: React.FC = () => {
67
68
  const [menu, setMenu] = useState<PhotoMenuItem[]>([])
68
69
  const [loading, setLoading] = useState(true)
70
+ const { withNSBP } = usePreserveNSBP()
69
71
 
70
72
  useEffect(() => {
71
73
  if (typeof window === 'undefined') return
72
74
 
73
75
  setLoading(true)
76
+
77
+ // 检查是否为客户端渲染模式
78
+ const isClientMode = isSEO() === 0
79
+
74
80
  // 先检查服务端是否已预取了图片菜单数据
75
81
  const serverMenu = window?.context?.state?.photo?.menu || {}
76
82
  const serverMenuArray = Array.isArray(serverMenu) ? serverMenu : []
77
83
 
78
- if (serverMenuArray.length > 0) {
79
- setMenu(serverMenuArray)
80
- setLoading(false)
81
- } else {
82
- // 如果服务端没有预取,则在客户端获取
84
+ // 客户端渲染模式下,始终发起新请求;服务端渲染模式下,如果有预取数据则使用预取数据
85
+ if (isClientMode || serverMenuArray.length === 0) {
86
+ // 客户端获取数据
83
87
  fetch('/getPhotoMenu')
84
88
  .then((res) => {
85
89
  if (!res.ok) throw new Error(`Status ${res.status}`)
@@ -93,6 +97,10 @@ const Home: React.FC = () => {
93
97
  setMenu([])
94
98
  })
95
99
  .finally(() => setLoading(false))
100
+ } else {
101
+ // 使用服务端预取的数据
102
+ setMenu(serverMenuArray)
103
+ setLoading(false)
96
104
  }
97
105
  }, [])
98
106
 
@@ -467,7 +475,10 @@ export const getPhotoMenu = (req: any, res: any) => {
467
475
  ) : menu.length > 0 ? (
468
476
  <PhotoGrid>
469
477
  {menu.map((item) => (
470
- <Link key={item.name} to={`/photo?dic=${item.name}`}>
478
+ <Link
479
+ key={item.name}
480
+ to={withNSBP(`/photo?dic=${item.name}`)}
481
+ >
471
482
  <PhotoCard>
472
483
  <PhotoImageWrapper>
473
484
  <PhotoImage
@@ -1,4 +1,4 @@
1
- import React, { Fragment, useState, useEffect } from 'react'
1
+ import React, { Fragment, useState, useEffect, useRef } from 'react'
2
2
  import { connect } from 'react-redux'
3
3
  import { Link, useLocation } from 'react-router-dom'
4
4
  import Header from '@components/Header'
@@ -6,10 +6,10 @@ import Layout from '@components/Layout'
6
6
  import { Helmet } from 'react-helmet'
7
7
  import { Container, Row } from '@styled/photo'
8
8
  import { motion } from 'framer-motion'
9
- import { isSEO, getLocationParams } from '@/utils'
9
+ import { isSEO, getLocationParams, usePreserveNSBP } from '@/utils'
10
10
  import { useCurrentFlag } from '@utils/clientConfig'
11
11
  import _ from 'lodash'
12
- import { loadData } from '@services/photo'
12
+ import { loadDataForContainer } from '@services/photo'
13
13
 
14
14
  const springSettings = { type: 'spring' as const, stiffness: 170, damping: 26 }
15
15
  const NEXT = 'show-next'
@@ -17,8 +17,11 @@ const NEXT = 'show-next'
17
17
  const Photo = ({ query, data, menu, getPhotoMenu }: any) => {
18
18
  const location = useLocation()
19
19
  let { from } = query
20
+ const { withNSBP } = usePreserveNSBP()
20
21
  const photos = Array.isArray(data) ? data : []
21
22
  const [currPhoto, setCurrPhoto] = useState(0)
23
+ // 使用 ref 来跟踪初始的 dic 值,用于区分首次加载和分类切换
24
+ const initialDicRef = useRef<string | null>(null)
22
25
 
23
26
  const [currPhotoData, setCurrPhotoData] = useState(photos[0] || [0, 0, ''])
24
27
 
@@ -70,23 +73,35 @@ const Photo = ({ query, data, menu, getPhotoMenu }: any) => {
70
73
  }
71
74
 
72
75
  useEffect(() => {
73
- const currentDic = getLocationParams('dic')
76
+ const currentDic = getLocationParams('dic') || ''
77
+
78
+ // 初始化时记录初始 dic 值
79
+ if (initialDicRef.current === null) {
80
+ initialDicRef.current = currentDic
81
+ }
74
82
 
75
83
  const doGetPhotoMenu = () => {
76
84
  getPhotoMenu(currentDic)
77
85
  }
78
86
 
79
- if (!isSEO()) {
87
+ // 判断是否需要加载数据:
88
+ // 1. 客户端渲染模式(isSEO() === 0)- 总是加载
89
+ // 2. 服务端渲染模式:
90
+ // - 如果没有数据(hasNoData)- 需要加载
91
+ // - 如果分类切换(isCategoryChanged)- 需要加载
92
+ const isClientMode = isSEO() === 0
93
+ const hasNoData = !data || data.length === 0
94
+ const isCategoryChanged = currentDic !== initialDicRef.current
95
+
96
+ // 客户端渲染模式:总是需要加载数据
97
+ // 服务端渲染模式:只有在没有数据或分类切换时才加载
98
+ if (isClientMode || hasNoData || isCategoryChanged) {
80
99
  doGetPhotoMenu()
81
- } else {
82
- if (from === 'link') {
83
- doGetPhotoMenu()
84
- }
85
100
  }
86
101
 
87
102
  // 重置到第一张
88
103
  setCurrPhoto(0)
89
- }, [location?.search])
104
+ }, [location?.search, from])
90
105
 
91
106
  return (
92
107
  <Fragment>
@@ -101,7 +116,10 @@ const Photo = ({ query, data, menu, getPhotoMenu }: any) => {
101
116
  <Row>
102
117
  {_.map(menu, (item: any, index: number) => {
103
118
  return (
104
- <Link key={`menu${index}`} to={`/photo?dic=${item.name}`}>
119
+ <Link
120
+ key={`menu${index}`}
121
+ to={withNSBP(`/photo?dic=${item.name}`)}
122
+ >
105
123
  {item.name}
106
124
  </Link>
107
125
  )
@@ -131,9 +149,9 @@ const Photo = ({ query, data, menu, getPhotoMenu }: any) => {
131
149
  className="demo4-photo"
132
150
  src={
133
151
  photos[i][2]
134
- ? useCurrentFlag
152
+ ? isSEO() === 1
135
153
  ? `/images/${photos[i][2]}`
136
- : photos[i][2]
154
+ : `/images/${photos[i][2]}`
137
155
  : 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
138
156
  }
139
157
  initial={false}
@@ -167,7 +185,7 @@ const mapStateToProps = (state: any) => {
167
185
 
168
186
  const mapDispatchToProps = (dispatch: any) => ({
169
187
  getPhotoMenu: (dic: any) => {
170
- dispatch(loadData(null, dic))
188
+ dispatch(loadDataForContainer(null, dic))
171
189
  }
172
190
  })
173
191
 
@@ -8,7 +8,7 @@ interface PhotoState {
8
8
  }
9
9
 
10
10
  export const photoReducer = (
11
- state: PhotoState = { data: [[0, 0, '']], menu: {} },
11
+ state: PhotoState = { data: [], menu: {} },
12
12
  action: any
13
13
  ) => {
14
14
  const { type, data, menu } = action
@@ -41,7 +41,6 @@ if (process.env.ENABLE_RATE_LIMIT === '1') {
41
41
  legacyHeaders: false // Disable the `X-RateLimit-*` headers
42
42
  })
43
43
  app.use('/api', limiter)
44
- console.log('🛡️ Rate limiting enabled for /api routes')
45
44
  }
46
45
 
47
46
  // 4. Static file serving (disable dotfiles access)
@@ -79,15 +78,12 @@ app.get('/getPhotoMenu', (req, res) => {
79
78
 
80
79
  // Catch-all middleware for SSR
81
80
  app.use((req, res) => {
82
- // console.log('req.url', req.url, req.headers)
83
81
  render(req, res)
84
82
  })
85
83
 
86
84
  const PORT = process.env.PORT || 3001
87
85
  app.listen(PORT, () => {
88
- console.log(`Server listening on port ${PORT}`)
89
- console.log(`🔒 Security headers enabled`)
90
86
  if (process.env.ENABLE_RATE_LIMIT === '1') {
91
- console.log(`🚦 Rate limiting active`)
87
+ // Rate limiting active
92
88
  }
93
89
  })
@@ -85,6 +85,13 @@ export const getPhotoWH = (req: any, res: any) => {
85
85
  return res.status(403).json({ error: 'Access denied' })
86
86
  }
87
87
 
88
+ // 检查目录是否存在
89
+ if (!fs.existsSync(photoPath)) {
90
+ return res
91
+ .status(404)
92
+ .json({ error: 'Directory not found', details: photoPath })
93
+ }
94
+
88
95
  const getFileList = (dir: string, list: string[]) => {
89
96
  const arr = fs.readdirSync(dir)
90
97
  arr.forEach((item) => {
@@ -19,10 +19,10 @@ export const render = (req: any, res: any) => {
19
19
  const matchRoutes: any = []
20
20
  const promises = []
21
21
 
22
- let { seo } = query
22
+ let { nsbp } = query
23
23
 
24
- if (seo !== undefined && seo !== '') {
25
- seo = parseInt(seo, 10)
24
+ if (nsbp !== undefined && nsbp !== '') {
25
+ nsbp = parseInt(nsbp, 10)
26
26
  }
27
27
 
28
28
  routers.some((route) => {
@@ -33,7 +33,8 @@ export const render = (req: any, res: any) => {
33
33
  if (item?.loadData) {
34
34
  const promise = new Promise((resolve, reject) => {
35
35
  try {
36
- store.dispatch(item?.loadData(resolve))
36
+ // 将 query 参数传递给 loadData,确保能正确预取数据
37
+ store.dispatch(item?.loadData(resolve, query))
37
38
  } catch (e) {
38
39
  reject()
39
40
  }
@@ -57,6 +57,14 @@ const getData = (callback: any, dic: any) => {
57
57
  }
58
58
  }
59
59
 
60
- export const loadData = (resolve: any = null, dic = '') => {
60
+ // 用于路由预取数据的 loadData 函数
61
+ export const loadData = (resolve: any = null, query: any = {}) => {
62
+ // 从 URL 查询参数中获取 dic
63
+ const { dic } = query
64
+ return getData(resolve, dic || '')
65
+ }
66
+
67
+ // 用于容器内部调用的 loadData 函数(保持向后兼容)
68
+ export const loadDataForContainer = (resolve: any = null, dic = '') => {
61
69
  return getData(resolve, dic)
62
70
  }
@@ -5,8 +5,7 @@ const combineReducer = combineReducers({ ...reducers })
5
5
 
6
6
  const getStore = (stateParam = {}) => {
7
7
  return configureStore({
8
- reducer: (state: any, action: any) =>
9
- combineReducer(state || stateParam, action),
8
+ reducer: combineReducer,
10
9
  preloadedState: stateParam || {},
11
10
  middleware: (getDefaultMiddleware) => getDefaultMiddleware()
12
11
  })
@@ -13,15 +13,73 @@ export const getLocationParams = (param: string) => {
13
13
 
14
14
  export const isSEO = () => {
15
15
  if (typeof window !== 'undefined') {
16
- const seo = getLocationParams('seo')
17
- if (seo !== '') {
18
- return parseInt(seo, 10)
16
+ const nsbp = getLocationParams('nsbp')
17
+ if (nsbp !== '') {
18
+ return parseInt(nsbp, 10)
19
19
  }
20
- return 0
20
+ return 1
21
21
  }
22
- return 0
22
+ return 1
23
23
  }
24
24
 
25
+ /**
26
+ * 获取当前的 nsbp 参数值
27
+ * @returns nsbp 参数值,如果没有则返回空字符串
28
+ */
29
+ export const getNSBPParam = () => {
30
+ return getLocationParams('nsbp')
31
+ }
32
+
33
+ /**
34
+ * 在 URL 中保留/添加 nsbp 参数
35
+ * @param url - 原始 URL
36
+ * @param nsbpValue - 可选的 nsbp 值,如果不提供则使用当前 URL 中的值
37
+ * @returns 保留了 nsbp 参数的 URL
38
+ *
39
+ * @example
40
+ * appendNSBPParam('/photo?dic=test')
41
+ * // 如果当前 URL 是 /?nsbp=0,返回 '/photo?dic=test&nsbp=0'
42
+ *
43
+ * @example
44
+ * appendNSBPParam('/photo?dic=test', '0')
45
+ * // 返回 '/photo?dic=test&nsbp=0'
46
+ */
47
+ export const appendNSBPParam = (url: string, nsbpValue?: string): string => {
48
+ // 如果指定了 nsbpValue 则使用它,否则使用当前 URL 中的值
49
+ const nsbp = nsbpValue !== undefined ? nsbpValue : getNSBPParam()
50
+
51
+ // 如果没有 nsbp 值,直接返回原 URL
52
+ if (!nsbp) {
53
+ return url
54
+ }
55
+
56
+ // 判断 URL 中是否已有查询参数
57
+ const separator = url.includes('?') ? '&' : '?'
58
+ return `${url}${separator}nsbp=${nsbp}`
59
+ }
60
+
61
+ /**
62
+ * 从 URL 中移除 nsbp 参数
63
+ * @param url - 原始 URL
64
+ * @returns 移除了 nsbp 参数的 URL
65
+ *
66
+ * @example
67
+ * removeNSBPParam('/photo?dic=test&nsbp=0')
68
+ * // 返回 '/photo?dic=test'
69
+ */
70
+ export const removeNSBPParam = (url: string): string => {
71
+ const urlObj = new URL(
72
+ url,
73
+ typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
74
+ )
75
+ urlObj.searchParams.delete('nsbp')
76
+ return urlObj.pathname + urlObj.search
77
+ }
78
+
79
+ /**
80
+ * 保留 handleLink 作为向后兼容的函数
81
+ * @deprecated 使用 appendNSBPParam 替代
82
+ */
25
83
  export const handleLink = (link: string) => {
26
84
  let result = link
27
85
 
@@ -31,7 +89,9 @@ export const handleLink = (link: string) => {
31
89
  } else {
32
90
  result += '?'
33
91
  }
34
- result += 'seo=1'
92
+ result += 'nsbp=1'
35
93
  }
36
94
  return result
37
95
  }
96
+
97
+ export { usePreserveNSBP } from './usePreserveNSBP'
@@ -0,0 +1,58 @@
1
+ import { useMemo } from 'react'
2
+ import { appendNSBPParam } from './index'
3
+
4
+ /**
5
+ * React Hook 用于在路由跳转时保留 nsbp 参数
6
+ * @returns 包含三个方法的对象
7
+ *
8
+ * @example
9
+ * const { withNSBP, withNSBPValue, withoutNSBP } = usePreserveNSBP()
10
+ *
11
+ * // 保留当前的 nsbp 参数
12
+ * <Link to={withNSBP('/photo?dic=test')}>
13
+ *
14
+ * // 指定 nsbp 值
15
+ * <Link to={withNSBPValue('/photo?dic=test', '0')}>
16
+ *
17
+ * // 移除 nsbp 参数
18
+ * <Link to={withoutNSBP('/photo?dic=test&nsbp=0')}>
19
+ */
20
+ export const usePreserveNSBP = () => {
21
+ return useMemo(
22
+ () => ({
23
+ /**
24
+ * 保留当前 URL 中的 nsbp 参数
25
+ * @param url - 原始 URL
26
+ * @returns 保留了 nsbp 参数的 URL
27
+ */
28
+ withNSBP: (url: string) => appendNSBPParam(url),
29
+
30
+ /**
31
+ * 指定 nsbp 参数值
32
+ * @param url - 原始 URL
33
+ * @param value - nsbp 参数值
34
+ * @returns 添加了指定 nsbp 值的 URL
35
+ */
36
+ withNSBPValue: (url: string, value: string) =>
37
+ appendNSBPParam(url, value),
38
+
39
+ /**
40
+ * 移除 URL 中的 nsbp 参数
41
+ * @param url - 原始 URL
42
+ * @returns 移除了 nsbp 参数的 URL
43
+ */
44
+ withoutNSBP: (url: string) => {
45
+ if (typeof window === 'undefined') return url
46
+
47
+ try {
48
+ const urlObj = new URL(url, window.location.origin)
49
+ urlObj.searchParams.delete('nsbp')
50
+ return urlObj.pathname + urlObj.search
51
+ } catch {
52
+ return url
53
+ }
54
+ }
55
+ }),
56
+ []
57
+ )
58
+ }