gemercheung 7 miesięcy temu
rodzic
commit
457f5e166d

+ 4 - 2
packages/backend/.env

@@ -6,7 +6,7 @@ DB_PORT=3306
 DB_USER=root
 DB_PWD='4Dage@4Dage#@168'
 DB_DATABASE=4dkankan_motion
-DB_SYNC=true  # 是否开启同步,生产环境请设置成 false
+DB_SYNC=true # 是否开启同步,生产环境请设置成 false
 
 # Redis
 REDIS_URL=redis://192.168.0.47:6379
@@ -14,7 +14,9 @@ REDIS_URL=redis://192.168.0.47:6379
 # JWT
 JWT_SECRET="d0!doc15415B0*4G0`"
 # 是否预览环境
-IS_PREVIEW=false  
+IS_PREVIEW=false
 
 OSS_DOMAIN=https://ossxiaoan.4dage.com
 OSS_FOLDER=/helperCenter
+LANGS=zh,en
+DEFAULT_LANG=zh

+ 3 - 2
packages/backend/src/modules/web/web.service.ts

@@ -41,8 +41,9 @@ export class WebService {
   }
 
   async findArticleDetail(id: number, locale?: string) {
+    const lang = this.sharedService.handleValidLang(locale);
     const article = await this.articleRepo.findOne({
-      where: { id, translations: { locale: locale } },
+      where: { id },
       relations: { user: true, translations: true, category: true },
       select: {
         user: {
@@ -55,6 +56,6 @@ export class WebService {
         },
       },
     });
-    return article.translate(locale);
+    return article.translate(lang);
   }
 }

+ 15 - 0
packages/backend/src/shared/shared.service.ts

@@ -7,9 +7,12 @@
  **********************************/
 
 import { Injectable } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
 
 @Injectable()
 export class SharedService {
+  constructor(private readonly configService: ConfigService) {}
+
   /**
    * 构造树型结构数据
    */
@@ -54,6 +57,18 @@ export class SharedService {
         }
       }
     }
+
     return tree;
   }
+
+  public handleValidLang(locale: string) {
+    const langString = this.configService.get('LANGS');
+    const defaultlang = this.configService.get('DEFAULT_LANG');
+    const langs = langString.split(',');
+    if (langs.includes(locale)) {
+      return locale;
+    } else {
+      return defaultlang;
+    }
+  }
 }

+ 1 - 0
packages/web/package.json

@@ -57,6 +57,7 @@
     "unplugin-vue-components": "^28.0.0",
     "unplugin-vue-router": "^0.10.9",
     "vite": "^6.0.7",
+    "vite-plugin-pages": "^0.32.4",
     "vite-plugin-vue-layouts": "^0.11.0",
     "vitest": "^2.1.8",
     "vue-tsc": "^2.2.0"

+ 6 - 0
packages/web/src/api/article.ts

@@ -5,7 +5,13 @@ export type ArticleDetailType = {
   id: number
   title: string
   content: string
+}
 
+export type ArticleDetailMenuType = {
+  level: number
+  text: string
+  children: ArticleDetailMenuType[]
 }
+
 export const getArticleDetail = (id: number): Promise<ResultData<ArticleDetailType>> =>
   request.get(`web/article/${id}`)

+ 8 - 5
packages/web/src/pages/index.vue

@@ -114,11 +114,14 @@
   </div>
 </template>
 
-<route lang="yaml">
-meta:
-  title: About
+<route>
+{
+name: "index",
+meta: {
+layout: "default"
+}
+}
 </route>
-
 <script setup lang="ts">
 import { NH1, NGrid, NGi } from 'naive-ui'
 import { getMenuList } from '@/api'
@@ -148,7 +151,7 @@ const handleToDoc = (child: never) => {
   const { articleId, categoryId } = child
   console.log(articleId, categoryId)
   if (articleId) {
-    router.push({ path: '/showdoc', query: { id: articleId } })
+    router.push({ path: `/showdoc/${articleId}` })
   } else {
     router.push({ path: '/showdoc', query: { cid: categoryId } })
   }

+ 88 - 0
packages/web/src/pages/showdoc/[id].vue

@@ -0,0 +1,88 @@
+<template>
+  <div class="max-w-screen-xl content my-0 mx-auto text-size-base">
+    <div v-if="detail" class="mt-[100px]">
+      <div class="flex flex-row flex-nowrap">
+        <div class="flex-1 flex-basis-[240px] flex-grow-0">
+          <n-collapse accordion class="">
+            <n-collapse-item title="青铜" name="1">
+              <div class="px-4">可以</div>
+            </n-collapse-item>
+            <n-collapse-item title="白银" name="2">
+              <div class="px-4">很好</div>
+            </n-collapse-item>
+            <n-collapse-item title="黄金" name="3">
+              <div class="px-4">真棒</div>
+            </n-collapse-item>
+          </n-collapse>
+        </div>
+        <div class="flex-1 px-[40px] mb-[120px]">
+          <n-h1 class="font-700"> {{ detail.title }}</n-h1>
+          <div class="w-full content-html" v-html="detail.content"></div>
+        </div>
+
+        <div class="flex-1 flex-basis-[240px] flex-grow-0">
+          <div class="min-h-[200px] bg-[#F5F9FF] b-r-[8px] p-[20px]">
+            <n-h4 class="font-600 ext-size-base">主要内容</n-h4>
+            <n-anchor :show-rail="false">
+              <n-anchor-link
+                class="text-size-base"
+                v-for="(item, index) in mainContents"
+                :key="index"
+                :title="item.text"
+                :href="`#my-section-${index + 1}`"
+              >
+              </n-anchor-link>
+            </n-anchor>
+
+            <!--{{ mainContents }}-->
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<route>
+{
+name: "showdoc",
+meta: {
+layout: "base"
+}
+}
+</route>
+
+<script setup lang="ts">
+import { type ArticleDetailType, type ArticleDetailMenuType, getArticleDetail } from '@/api'
+import { htmlToTree, createAnchorNames } from '@/utils'
+import { NH1, NH4, NCollapse, NCollapseItem, NAnchor, NAnchorLink } from 'naive-ui'
+
+const route = useRoute()
+const id = route.params?.id
+
+console.log('route', route)
+const detail = ref<ArticleDetailType | undefined>()
+const mainContents = ref<ArticleDetailMenuType[]>([])
+
+onMounted(() => {
+  setTimeout(() => {
+    const html = document.querySelector('.content-html')
+    if (html) {
+      createAnchorNames(html)
+    }
+  }, 1000)
+})
+
+watchEffect(() => {
+  if (id) {
+    getArticleDetail(+id).then((data) => {
+      if (data.data) {
+        detail.value = data.data
+        document.title = detail.value.title
+        mainContents.value = htmlToTree(detail.value.content)
+      }
+    })
+  }
+})
+</script>
+
+<style lang="scss" scoped></style>

+ 10 - 7
packages/web/src/pages/showdoc.vue

@@ -2,8 +2,8 @@
   <div class="max-w-screen-xl content my-0 mx-auto text-size-base">
     <div v-if="detail" class="mt-[100px]">
       <div class="flex flex-row flex-nowrap">
-        <div class="flex-1 flex-basis-[160px] flex-grow-0">
-          <n-collapse accordion>
+        <div class="flex-1 flex-basis-[240px] flex-grow-0">
+          <n-collapse accordion class="">
             <n-collapse-item title="青铜" name="1">
               <div class="px-4">可以</div>
             </n-collapse-item>
@@ -16,7 +16,7 @@
           </n-collapse>
         </div>
         <div class="flex-1 px-[40px] mb-[120px]">
-          <n-h1> {{ detail.title }}</n-h1>
+          <n-h1 class="font-700"> {{ detail.title }}</n-h1>
           <div class="w-full" v-html="detail.content"></div>
         </div>
 
@@ -30,10 +30,13 @@
   </div>
 </template>
 
-<route lang="yaml">
-meta:
-title: showdoc
-layout: base
+<route>
+{
+name: "showdoc",
+  meta: {
+  layout: "base"
+  }
+}
 </route>
 
 <script setup lang="ts">

+ 2 - 1
packages/web/src/plugins/router.ts

@@ -1,5 +1,6 @@
 import { createRouter, createWebHistory } from 'vue-router'
-import { routes } from 'vue-router/auto-routes'
+// import { routes } from 'vue-router/auto-routes'
+import routes from '~pages'
 import { setupLayouts } from 'virtual:generated-layouts'
 
 const router = createRouter({

+ 62 - 0
packages/web/src/utils/html.ts

@@ -0,0 +1,62 @@
+interface TreeNode {
+  level: number // 标题的层级(1-6)
+  text: string // 标题的文本内容
+  children: TreeNode[] // 子节点
+}
+
+export function htmlToTree(html: string): TreeNode[] {
+  // 创建一个虚拟的 DOM 元素来解析 HTML
+  const parser = new DOMParser()
+  const doc = parser.parseFromString(html, 'text/html')
+
+  // 获取所有的 h1 到 h6 标签
+  const headers = Array.from(doc.querySelectorAll('h1, h2, h3, h4, h5, h6'))
+
+  // 初始化树结构和栈
+  const root: TreeNode = { level: 0, text: '', children: [] }
+  const stack: TreeNode[] = [root]
+
+  // 遍历所有标题标签
+  headers.forEach((header) => {
+    const level = parseInt(header.tagName[1]) // 获取标题的层级(1-6)
+    const text = header.textContent?.trim() || '' // 获取标题的文本内容
+
+    // 创建当前节点
+    const currentNode: TreeNode = {
+      level,
+      text,
+      children: [],
+    }
+
+    // 找到当前节点的父节点
+    while (stack.length > 0 && stack[stack.length - 1].level >= level) {
+      stack.pop() // 弹出栈顶元素,直到找到合适的父节点
+    }
+
+    // 将当前节点添加到父节点的 children 中
+    const parent = stack[stack.length - 1]
+    parent.children.push(currentNode)
+
+    // 将当前节点压入栈
+    stack.push(currentNode)
+  })
+
+  return root.children // 返回树的根节点的子节点
+}
+
+export function createAnchorNames(element: Element): void {
+  // 获取所有的 h1 到 h6 标签
+
+  const headers = element.querySelectorAll('h1, h2, h3, h4, h5, h6')
+  // debugger
+  // 遍历每个标题标签
+  headers.forEach((header, index) => {
+    // 获取标题的文本内容
+
+    // 生成唯一的 anchor name(移除特殊字符并用连字符连接)
+    const anchorName = `my-section-${index + 1}`
+
+    // 将生成的 anchor name 设置为 id 属性
+    header.id = anchorName
+  })
+}

+ 1 - 1
packages/web/src/utils/index.ts

@@ -1,2 +1,2 @@
 export * from './http'
-
+export * from './html'

+ 3 - 2
packages/web/typed-router.d.ts

@@ -18,8 +18,9 @@ declare module 'vue-router/auto-routes' {
    * Route name map generated by unplugin-vue-router
    */
   export interface RouteNamedMap {
-    '/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
+    'index': RouteRecordInfo<'index', '/', Record<never, never>, Record<never, never>>,
     '/about': RouteRecordInfo<'/about', '/about', Record<never, never>, Record<never, never>>,
-    '/showdoc': RouteRecordInfo<'/showdoc', '/showdoc', Record<never, never>, Record<never, never>>,
+    'showdoc': RouteRecordInfo<'showdoc', '/showdoc/:id', { id: ParamValue<true> }, { id: ParamValue<false> }>,
+    'showdoc': RouteRecordInfo<'showdoc', '/showdoc111', Record<never, never>, Record<never, never>>,
   }
 }

+ 5 - 0
packages/web/vite.config.ts

@@ -9,6 +9,7 @@ import autoImports from 'unplugin-auto-import/vite'
 import Unocss from 'unocss/vite'
 import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
 import Components from 'unplugin-vue-components/vite'
+import Pages from 'vite-plugin-pages'
 
 export default defineConfig(({ mode }) => {
 
@@ -21,6 +22,10 @@ export default defineConfig(({ mode }) => {
       vueRouter(),
       vue(),
       Unocss(),
+      Pages({
+        dirs: 'src/pages',  // 需要生成路由的文件目录
+        exclude: ['**/components/*.vue']  // 排除在外的目录,即不将所有 components 目录下的 .vue 文件生成路由
+      }),
       vueLayouts(),
       vueComponents(),
       autoImports({

+ 65 - 3
pnpm-lock.yaml

@@ -623,6 +623,9 @@ importers:
       vite:
         specifier: ^6.0.7
         version: 6.0.7(@types/node@22.10.6)(jiti@2.4.2)(sass@1.83.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)
+      vite-plugin-pages:
+        specifier: ^0.32.4
+        version: 0.32.4(@vue/compiler-sfc@3.5.13)(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.2)(sass@1.83.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))
       vite-plugin-vue-layouts:
         specifier: ^0.11.0
         version: 0.11.0(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.2)(sass@1.83.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3)))(vue@3.5.13(typescript@5.7.3))
@@ -4843,6 +4846,10 @@ packages:
     resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
     engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
 
+  esprima-extract-comments@1.1.0:
+    resolution: {integrity: sha512-sBQUnvJwpeE9QnPrxh7dpI/dp67erYG4WXEAreAMoelPRpMR7NWb4YtwRPn9b+H1uLQKl/qS8WYmyaljTpjIsw==}
+    engines: {node: '>=4'}
+
   esprima@4.0.1:
     resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==}
     engines: {node: '>=4'}
@@ -4949,6 +4956,10 @@ packages:
     resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
     engines: {node: '>=4'}
 
+  extract-comments@1.1.0:
+    resolution: {integrity: sha512-dzbZV2AdSSVW/4E7Ti5hZdHWbA+Z80RJsJhr5uiL10oyjl/gy7/o+HI1HwK4/WSZhlq4SNKU3oUzXlM13Qx02Q==}
+    engines: {node: '>=6'}
+
   extract-zip@2.0.1:
     resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
     engines: {node: '>= 10.17.0'}
@@ -6672,6 +6683,10 @@ packages:
     resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
     engines: {node: '>=6'}
 
+  parse-code-context@1.0.0:
+    resolution: {integrity: sha512-OZQaqKaQnR21iqhlnPfVisFjBWjhnMl5J9MgbP8xC+EwoVqbXrq78lp+9Zb3ahmLzrIX5Us/qbvBnaS3hkH6OA==}
+    engines: {node: '>=6'}
+
   parse-gitignore@2.0.0:
     resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==}
     engines: {node: '>=14'}
@@ -8305,6 +8320,24 @@ packages:
       '@nuxt/kit':
         optional: true
 
+  vite-plugin-pages@0.32.4:
+    resolution: {integrity: sha512-OM8CNb8mAzyYR8ASRC0+2LXVB8ecR/5JHc5RpxbWtF+CmhjhmIELs0iV5y8qvU48soZbk+NsFOYlhoIcjw3+ew==}
+    peerDependencies:
+      '@solidjs/router': '*'
+      '@vue/compiler-sfc': ^2.7.0 || ^3.0.0
+      react-router: '*'
+      vite: ^2.0.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0 || ^6.0.0
+      vue-router: '*'
+    peerDependenciesMeta:
+      '@solidjs/router':
+        optional: true
+      '@vue/compiler-sfc':
+        optional: true
+      react-router:
+        optional: true
+      vue-router:
+        optional: true
+
   vite-plugin-router-warn@1.0.0:
     resolution: {integrity: sha512-jnr7faHJPkKxukBXVpg7Ui1UDqhmxD7xU6JGidq8ivSHTsNAPqzSpPpwW8O1PBP/0+Owq4bLfNNk11drOkz4xA==}
 
@@ -11114,7 +11147,7 @@ snapshots:
       '@typescript-eslint/types': 8.19.0
       '@typescript-eslint/visitor-keys': 8.19.0
       debug: 4.4.0(supports-color@8.1.1)
-      fast-glob: 3.3.2
+      fast-glob: 3.3.3
       is-glob: 4.0.3
       minimatch: 9.0.5
       semver: 7.6.3
@@ -13637,6 +13670,10 @@ snapshots:
       acorn-jsx: 5.3.2(acorn@8.14.0)
       eslint-visitor-keys: 3.4.3
 
+  esprima-extract-comments@1.1.0:
+    dependencies:
+      esprima: 4.0.1
+
   esprima@4.0.1: {}
 
   esquery@1.6.0:
@@ -13809,6 +13846,11 @@ snapshots:
       iconv-lite: 0.4.24
       tmp: 0.0.33
 
+  extract-comments@1.1.0:
+    dependencies:
+      esprima-extract-comments: 1.1.0
+      parse-code-context: 1.0.0
+
   extract-zip@2.0.1(supports-color@8.1.1):
     dependencies:
       debug: 4.4.0(supports-color@8.1.1)
@@ -14163,7 +14205,7 @@ snapshots:
     dependencies:
       array-union: 2.1.0
       dir-glob: 3.0.1
-      fast-glob: 3.3.2
+      fast-glob: 3.3.3
       ignore: 5.3.2
       merge2: 1.4.1
       slash: 3.0.0
@@ -14171,7 +14213,7 @@ snapshots:
   globby@14.0.2:
     dependencies:
       '@sindresorhus/merge-streams': 2.3.0
-      fast-glob: 3.3.2
+      fast-glob: 3.3.3
       ignore: 5.3.2
       path-type: 5.0.0
       slash: 5.1.0
@@ -15976,6 +16018,8 @@ snapshots:
     dependencies:
       callsites: 3.1.0
 
+  parse-code-context@1.0.0: {}
+
   parse-gitignore@2.0.0: {}
 
   parse-imports@2.2.1:
@@ -17732,6 +17776,24 @@ snapshots:
       - rollup
       - supports-color
 
+  vite-plugin-pages@0.32.4(@vue/compiler-sfc@3.5.13)(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.2)(sass@1.83.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.7.3))):
+    dependencies:
+      '@types/debug': 4.1.12
+      debug: 4.4.0(supports-color@8.1.1)
+      dequal: 2.0.3
+      extract-comments: 1.1.0
+      fast-glob: 3.3.3
+      json5: 2.2.3
+      local-pkg: 0.5.1
+      picocolors: 1.1.1
+      vite: 6.0.7(@types/node@22.10.6)(jiti@2.4.2)(sass@1.83.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)
+      yaml: 2.7.0
+    optionalDependencies:
+      '@vue/compiler-sfc': 3.5.13
+      vue-router: 4.5.0(vue@3.5.13(typescript@5.7.3))
+    transitivePeerDependencies:
+      - supports-color
+
   vite-plugin-router-warn@1.0.0: {}
 
   vite-plugin-vue-devtools@7.6.8(@nuxt/kit@3.15.1(magicast@0.3.5)(rollup@4.29.1))(rollup@4.29.1)(vite@6.0.7(@types/node@22.10.6)(jiti@2.4.2)(sass@1.83.1)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))(vue@3.5.13(typescript@5.7.3)):