Browse Source

Merge branch 'feat/random'

Carson 11 tháng trước cách đây
mục cha
commit
60cca31eb2
100 tập tin đã thay đổi với 1596 bổ sung153 xóa
  1. 5 0
      .env.example
  2. 2 1
      .gitignore
  3. 207 48
      .vitepress/config.mts
  4. 6 0
      .vitepress/theme/index.ts
  5. 7 1
      README.md
  6. BIN
      assets/images/card1.png
  7. BIN
      assets/images/card2.png
  8. BIN
      assets/images/card3.png
  9. BIN
      assets/images/card4.png
  10. BIN
      assets/images/card5.png
  11. 14 4
      components/CustomLayout.vue
  12. 15 2
      components/CustomNavBar.vue
  13. 90 0
      components/Edit/AppendModal.vue
  14. 54 0
      components/Edit/Node.vue
  15. 6 0
      components/Edit/Tree.d.ts
  16. 445 0
      components/Edit/index.vue
  17. 94 0
      components/HomeCard/HomeCard.vue
  18. 5 0
      components/HomeCard/HomeCardContent.vue
  19. 30 0
      components/HomeCard/HomeCardTitle.vue
  20. 9 0
      components/HomeCard/index.ts
  21. 328 56
      components/HomeContent.vue
  22. 36 0
      components/HomeSection/HomeSection.vue
  23. 5 0
      components/HomeSection/HomeSectionContent.vue
  24. 8 0
      components/HomeSection/index.ts
  25. 230 41
      components/Search/index.vue
  26. BIN
      media/AI模块/AI屏幕helloworld.jpeg
  27. BIN
      media/AI模块/AI摄像头.jpg
  28. BIN
      media/AI模块/AI模块1.png
  29. BIN
      media/AI模块/AI模块2.png
  30. BIN
      media/AI模块/AI模块3.png
  31. BIN
      media/AI模块/AI模块和屏幕模块和摄像头模块连接.jpg
  32. BIN
      media/AI模块/AI模块和屏幕模块和摄像头模块连接2.jpg
  33. BIN
      media/AI模块/AI模块和屏幕模块连接.jpg
  34. BIN
      media/AI模块/AI模块基础编程.png
  35. BIN
      media/AI模块/AI模块连接.jpeg
  36. BIN
      media/AI模块/AI模板基础1.gif
  37. BIN
      media/AI模块/IOT连接屏幕模块.jpg
  38. BIN
      media/AI模块/LCD屏幕初始化.png
  39. BIN
      media/AI模块/LCD屏幕填充颜色为.png
  40. BIN
      media/AI模块/LCD屏幕方向旋转.png
  41. BIN
      media/AI模块/LCD屏幕模块1.png
  42. BIN
      media/AI模块/LCD屏幕模块2.png
  43. BIN
      media/AI模块/LCD屏幕模块3.png
  44. BIN
      media/AI模块/LCD屏幕进行镜面翻转.png
  45. BIN
      media/AI模块/LED1.png
  46. BIN
      media/AI模块/LED2.png
  47. BIN
      media/AI模块/LED3.png
  48. BIN
      media/AI模块/X Y.png
  49. BIN
      media/AI模块/image6.png
  50. BIN
      media/AI模块/创建图像画布.png
  51. BIN
      media/AI模块/创建空白画布尺寸.png
  52. BIN
      media/AI模块/否则如果.gif
  53. BIN
      media/AI模块/启用AI摄像头.png
  54. BIN
      media/AI模块/在屏幕显示hello world.png
  55. BIN
      media/AI模块/在屏幕显示hello world(1).png
  56. BIN
      media/AI模块/在屏幕显示hello world(2).png
  57. BIN
      media/AI模块/在画布上绘制二维码.png
  58. BIN
      media/AI模块/在画布上绘制十字标.png
  59. BIN
      media/AI模块/在画布上绘制图片.png
  60. BIN
      media/AI模块/在画布上绘制实心圆形.png
  61. BIN
      media/AI模块/在画布上绘制实心矩形.png
  62. BIN
      media/AI模块/在画布上绘制文本.png
  63. BIN
      media/AI模块/在画布上绘制直线.png
  64. BIN
      media/AI模块/在画布上绘制箭头.png
  65. BIN
      media/AI模块/宽 高.png
  66. BIN
      media/AI模块/将LCD屏幕填充颜色为.png
  67. BIN
      media/AI模块/将LCD屏幕填充颜色为2.png
  68. BIN
      media/AI模块/将屏幕颜色填充为.png
  69. BIN
      media/AI模块/将画布铺满颜色.png
  70. BIN
      media/AI模块/屏幕连接AI模块.jpg
  71. BIN
      media/AI模块/按钮1.png
  72. BIN
      media/AI模块/按钮2.png
  73. BIN
      media/AI模块/摄像头10.png
  74. BIN
      media/AI模块/摄像头2.png
  75. BIN
      media/AI模块/摄像头3.png
  76. BIN
      media/AI模块/摄像头4.png
  77. BIN
      media/AI模块/摄像头5.png
  78. BIN
      media/AI模块/摄像头6.png
  79. BIN
      media/AI模块/摄像头7.png
  80. BIN
      media/AI模块/摄像头8.png
  81. BIN
      media/AI模块/摄像头9.png
  82. BIN
      media/AI模块/摄像头模块1.png
  83. BIN
      media/AI模块/摄像头模块2.png
  84. BIN
      media/AI模块/数据线连接AI模块.png
  85. BIN
      media/AI模块/数据线连接电脑.png
  86. BIN
      media/AI模块/显示画布.png
  87. BIN
      media/AI模块/清除屏幕.png
  88. BIN
      media/AI模块/清除画布.png
  89. BIN
      media/AI模块/画点.png
  90. BIN
      media/AI模块/相机初始化.png
  91. BIN
      media/AI模块/系统1.png
  92. BIN
      media/AI模块/系统2.png
  93. BIN
      media/AI模块/系统3.png
  94. BIN
      media/AI模块/系统4.png
  95. BIN
      media/AI模块/系统5.png
  96. BIN
      media/AI模块/系统6.png
  97. BIN
      media/AI模块/红 绿 蓝.png
  98. BIN
      media/AI模块/红绿蓝IOT.png
  99. BIN
      media/AI模块/绘制实心矩形.png
  100. BIN
      media/AI模块/绘制文本.png

+ 5 - 0
.env.example

@@ -0,0 +1,5 @@
+VITE_AWS_S3_ACCESS_KEY_ID=
+VITE_AWS_S3_SECRET_ACCESS_KEY=
+VITE_AWS_S3_REGION=
+VITE_DOCS_LIST_BUCKET=
+VITE_DOCS_MEDIA_BUCKET=

+ 2 - 1
.gitignore

@@ -15,4 +15,5 @@ examples-temp
 node_modules
 pnpm-global
 TODOs.md
-*.timestamp-*.mjs
+*.timestamp-*.mjs
+.env

+ 207 - 48
.vitepress/config.mts

@@ -1,39 +1,166 @@
+import "dotenv/config";
 import { fileURLToPath, URL } from "node:url";
 import { defineConfig } from "vitepress";
 import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite";
 import Icons from "unplugin-icons/vite";
-// import Components from 'unplugin-vue-components/vite'
 import { FileSystemIconLoader } from "unplugin-icons/loaders";
+import { SVG, cleanupSVG, parseColors, runSVGO } from "@iconify/tools";
+
 import {
-  SVG,
-  cleanupSVG,
-  parseColors,
-  runSVGO,
-  deOptimisePaths,
-  importDirectory,
-} from "@iconify/tools";
-import { compareColors, stringToColor } from "@iconify/utils/lib/colors";
+  S3Client,
+  ListObjectsCommand,
+  GetObjectCommand,
+} from "@aws-sdk/client-s3";
+import fs from "node:fs";
+import path from "node:path";
+import { exec } from "child_process";
+import { buildSideBar } from "../utils/sideBar";
+
+
+const DOC_BASE_PATH = "pages";
+
+if (process.env.NODE_ENV === "production") {
+  // 执行清理pages文件夹命令
+  await new Promise((resolve, reject) =>
+    exec(
+      `git checkout -- ${DOC_BASE_PATH} & git clean -df ${DOC_BASE_PATH}`,
+      (error, stdout, stderr) => {
+        if (error) {
+          console.error(`清理pages时出错: ${error.message}`);
+          reject(stderr);
+        }
+        console.log(`清理pages结果: ${stdout}`);
+        resolve(stdout);
+      }
+    )
+  );
+}
+
+// 从S3构建pages里面的markdown文件
+const s3 = new S3Client({
+  credentials: {
+    accessKeyId: process.env.VITE_AWS_S3_ACCESS_KEY_ID!,
+    secretAccessKey: process.env.VITE_AWS_S3_SECRET_ACCESS_KEY!,
+  },
+  region: process.env.VITE_AWS_S3_REGION!,
+});
+const command = new ListObjectsCommand({
+  Bucket: process.env.VITE_DOCS_LIST_BUCKET,
+});
+const { Contents: contents } = await s3.send(command);
 
+await Promise.all(
+  contents!.map((content) => {
+    return new Promise(async (resolve, reject) => {
+      try {
+        const command = new GetObjectCommand({
+          Bucket: process.env.VITE_DOCS_LIST_BUCKET,
+          Key: content.Key,
+          ResponseCacheControl: "no-cache",
+        });
+        const file = await s3.send(command);
+        const writePath = path.join(DOC_BASE_PATH, content.Key!);
+        const directory = path.dirname(writePath);
+        fs.mkdirSync(directory, { recursive: true });
+        fs.writeFileSync(writePath, await file.Body!.transformToString());
+        resolve(content.Key);
+      } catch (e) {
+        reject(e);
+      }
+      fs.writeFile;
+    });
+  })
+);
+
+// 构建sideBar数据
+function readJsonFile(filePath) {
+  try {
+      // 同步读取文件内容,如果文件不存在会抛出异常
+      const data = fs.readFileSync(filePath, 'utf8');
+      return JSON.parse(data); // 解析 JSON 数据
+  } catch (err) {
+      if (err.code === 'ENOENT') {
+          console.error('File not found:', filePath);
+          // 可以返回默认值或者进行其他处理
+          return {}; // 返回 null 或者其他默认值
+      } else {
+          throw err; // 抛出其他异常
+      }
+  }
+}
+const sideBarSortMap = {
+  'zh-CN': readJsonFile('pages/zh-CN::SIDEBAR_SORTED_MAP.json'),
+  'zh-HK': readJsonFile('pages/zh-HK::SIDEBAR_SORTED_MAP.json'),
+  'en-US': readJsonFile('pages/en-US::SIDEBAR_SORTED_MAP.json'),
+}
+let { rootSideBar, zhHKSideBar, enUSSideBar } = buildSideBar(contents, sideBarSortMap);
+import util from "util";
+console.log(
+  util.inspect(sideBarSortMap, {showHidden: false, depth: null, colors: true}),
+  util.inspect(rootSideBar, { showHidden: false, depth: null, colors: true }),
+);
 
 // https://vitepress.dev/reference/site-config
 export default defineConfig({
   title: "CocoBlockly帮助文档",
   description: "Cococlass help documents",
-  srcDir: "pages",
-  // themeConfig: {
-  //   search: {
-  //     provider: "local",
-  //   },
-  // },
+  srcDir: DOC_BASE_PATH,
+  ignoreDeadLinks: true,
+  lastUpdated: true,
+  themeConfig: {
+    search: {
+      provider: "local",
+      options: {
+        locales: {
+          root: {
+            translations: {
+              button: {
+                buttonText: '搜索文档',
+                buttonAriaLabel: '搜索文档'
+              },
+              modal: {
+                noResultsText: '无法找到相关结果',
+                resetButtonTitle: '清除查询条件',
+                footer: {
+                  selectText: '选择',
+                  navigateText: '切换',
+                  closeText: '关闭',
+                }
+              }
+            }
+          },
+          'zh-HK': {
+            translations: {
+              button: {
+                buttonText: '搜索文档hk',
+                buttonAriaLabel: '搜索文档hk'
+              },
+              modal: {
+                noResultsText: '无法找到相关结果',
+                resetButtonTitle: '清除查询条件',
+                footer: {
+                  selectText: '选择',
+                  navigateText: '切换',
+                  closeText: '关闭',
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+  },
+  appearance: false,
 
   vite: {
     publicDir: "../public",
+    envDir: "../",
+    ssr: {
+      // SSG Vue-i18n workaround
+      noExternal: [/vue-i18n/, 'element-plus'],
+    },
     plugins: [
-      VueI18nPlugin({
-        /* options */
-        // locale messages resource pre-compile option
-        // include: resolve(dirname(fileURLToPath(import.meta.url)), './path/to/src/locales/**'),
-      }),
+      VueI18nPlugin({}),
       Icons({
         compiler: "vue3",
         customCollections: {
@@ -43,7 +170,6 @@ export default defineConfig({
               const svg = new SVG(svgStr);
               cleanupSVG(svg);
 
-
               parseColors(svg, {
                 defaultColor: "currentColor",
                 callback: (attr, colorStr, color) => {
@@ -81,7 +207,6 @@ export default defineConfig({
               const svg = new SVG(svgStr);
               cleanupSVG(svg);
 
-
               // Optimise, but do not change shapes because they are animated
               runSVGO(svg, {
                 keepShapes: true,
@@ -119,19 +244,22 @@ export default defineConfig({
         //   { text: 'Home', link: '/' },
         //   { text: 'Examples', link: '/markdown-examples' }
         // ],
-        sidebar: [
-          { text: "关于CocoBlockly X", link: "/docs" },
-          { text: "常见问题解答", link: "/docs/faq" },
-          {
-            text: "开始使用CocoBlockly X",
-            link: "/docs/start-using-cocoblockly-x",
-          },
-          {
-            text: "电子模块基本教学",
-            collapsed: true,
-            items: [{ text: "todo", link: "/docs" }],
-          },
-        ],
+        // sidebar: [
+        //   { text: "关于CocoBlockly X", link: "/docs" },
+        //   { text: "常见问题解答", link: "/docs/常见问题解答" },
+        //   {
+        //     text: "开始使用CocoBlockly X",
+        //     link: "/docs/start-using-cocoblockly-x",
+        //     collapsed: true,
+        //     items: [],
+        //   },
+        //   {
+        //     text: "电子模块基本教学",
+        //     collapsed: true,
+        //     items: [{ text: "todo", link: "/docs" }],
+        //   },
+        // ],
+        sidebar: rootSideBar,
         // socialLinks: [
         //   { icon: 'github', link: 'https://github.com/vuejs/vitepress' }
         // ]
@@ -148,19 +276,50 @@ export default defineConfig({
         //   { text: 'Home', link: '/' },
         //   { text: 'Examples', link: '/markdown-examples' }
         // ],
-        sidebar: [
-          { text: "什麽是CocoBlockly X", link: "/zh-HK/docs" },
-          { text: "常見問題解答", link: "/zh-HK/docs/faq" },
-          {
-            text: "開始使用CocoBlockly X",
-            link: "/zh-HK/docs/start-using-cocoblockly-x",
-          },
-          {
-            text: "電子模組基本教學",
-            collapsed: true,
-            items: [{ text: "todo", link: "/zh-HK/docs" }],
-          },
-        ],
+        // sidebar: [
+        //   { text: "什麽是CocoBlockly X", link: "/zh-HK/docs" },
+        //   { text: "常見問題解答", link: "/zh-HK/docs/faq" },
+        //   {
+        //     text: "開始使用CocoBlockly X",
+        //     link: "/zh-HK/docs/start-using-cocoblockly-x",
+        //   },
+        //   {
+        //     text: "電子模組基本教學",
+        //     collapsed: true,
+        //     items: [{ text: "todo", link: "/zh-HK/docs" }],
+        //   },
+        // ],
+        sidebar: zhHKSideBar,
+        // socialLinks: [
+        //   { icon: 'github', link: 'https://github.com/vuejs/vitepress' }
+        // ]
+      },
+    },
+    "en-US": {
+      label: "English",
+      lang: "en-US",
+      themeConfig: {
+        logo: "/logo.png",
+        siteTitle: false,
+        // https://vitepress.dev/reference/default-theme-config
+        // nav: [
+        //   { text: 'Home', link: '/' },
+        //   { text: 'Examples', link: '/markdown-examples' }
+        // ],
+        // sidebar: [
+        //   { text: "什麽是CocoBlockly X", link: "/zh-HK/docs" },
+        //   { text: "常見問題解答", link: "/zh-HK/docs/faq" },
+        //   {
+        //     text: "開始使用CocoBlockly X",
+        //     link: "/zh-HK/docs/start-using-cocoblockly-x",
+        //   },
+        //   {
+        //     text: "電子模組基本教學",
+        //     collapsed: true,
+        //     items: [{ text: "todo", link: "/zh-HK/docs" }],
+        //   },
+        // ],
+        sidebar: enUSSideBar,
         // socialLinks: [
         //   { icon: 'github', link: 'https://github.com/vuejs/vitepress' }
         // ]

+ 6 - 0
.vitepress/theme/index.ts

@@ -11,6 +11,12 @@ export default {
   extends: DefaultTheme,
   Layout: CustomLayout,
   enhanceApp({ app, router, siteData }) {
+    // NOTE polyfill `process` for library `path-browserify`,@SEE: https://github.com/browserify/path-browserify/issues/34
+    if (!import.meta.env.SSR) {
+      import('process/browser').then((module) => {
+        globalThis.process = module
+      })
+    }
     app.use(i18n);
     app.use(ElementPlus)
     // ...

+ 7 - 1
README.md

@@ -28,5 +28,11 @@ element-ui plus
 #### 新增icon
 
 - 导出 figma svg,放在 assets/icons 目录下,是plain还是colored看情况而定
-  - plain: 单色图标,可以通过css color属性设置颜色
+  - plain: 单色图标,可以通过css 属性设置颜色(color)和大小
   - colored: 双色图标,固定颜色,一般是多种颜色的图标
+- 导入组件使用
+  ```
+  import IconNotebook from "~icons/ccrbi-plain/notebook";
+  <IconNotebook color="blue" />
+  ```
+- 如果要新增一个单独的icon组,需要编辑config.mts vite.Icons里面的内容

BIN
assets/images/card1.png


BIN
assets/images/card2.png


BIN
assets/images/card3.png


BIN
assets/images/card4.png


BIN
assets/images/card5.png


+ 14 - 4
components/CustomLayout.vue

@@ -1,23 +1,29 @@
 <script setup lang="ts">
-import { watchEffect } from "vue";
+import { watchEffect, computed } from "vue";
 
 import DefaultTheme from "vitepress/theme";
 import { useI18n } from "vue-i18n";
-import { useData } from "vitepress";
+import { useData, withBase } from "vitepress";
 import i18n from "@/plugins/i18n";
 
 const Layout = DefaultTheme.Layout;
 
-const { lang } = useData();
+const { lang, localeIndex } = useData();
 const { t } = useI18n();
 watchEffect(() => {
   i18n.global.locale.value = lang.value;
 });
+const docsLink = computed(() => {
+  return withBase( `/${localeIndex.value === 'root' ? '' : localeIndex.value + '/'}docs` )
+})
 </script>
 <template>
   <Layout>
     <template #nav-bar-content-before>
-      <div class="content-before">{{ t("帮助中心") }}</div>
+      <a :href="docsLink" class="content-before">{{ t("帮助中心") }}</a>
+    </template>
+    <template #nav-bar-content-after>
+      <a :href="docsLink" class="content-after">{{ t("CocoClass") }}</a>
     </template>
   </Layout>
 </template>
@@ -34,4 +40,8 @@ watchEffect(() => {
   line-height: 24px;
   margin-left: 14px;
 }
+.content-after {
+  color: #2e5aa8;
+  /* TODO */
+}
 </style>

+ 15 - 2
components/CustomNavBar.vue

@@ -1,6 +1,6 @@
 <script lang="ts" setup>
 import { useWindowScroll } from "@vueuse/core";
-import { ref, watchPostEffect } from "vue";
+import { ref, watchPostEffect, computed } from "vue";
 import { useData } from "vitepress";
 import { useSidebar } from "vitepress/theme";
 import VPNavBarAppearance from "vitepress/dist/client/theme-default/components/VPNavBarAppearance.vue";
@@ -33,6 +33,10 @@ watchPostEffect(() => {
     top: y.value === 0,
   };
 });
+
+const hasSearch = computed(() => {
+  return frontmatter.value.layout !== 'home'
+})
 </script>
 
 <template>
@@ -51,7 +55,7 @@ watchPostEffect(() => {
         <div class="content">
           <div class="content-body">
             <slot name="nav-bar-content-before" />
-            <VPNavBarSearch class="search" />
+            <VPNavBarSearch class="search" :class="{ 'hide-search': !hasSearch }" />
             <VPNavBarMenu class="menu" />
             <VPNavBarTranslations class="translations" />
             <VPNavBarAppearance class="appearance" />
@@ -74,6 +78,15 @@ watchPostEffect(() => {
   </div>
 </template>
 
+<style scoped lang="scss">
+.search {
+  justify-content: end;
+  &.hide-search > :deep(div) {
+    display: none;
+  }
+}
+</style>
+
 <style scoped>
 .VPNavBar {
   position: relative;

+ 90 - 0
components/Edit/AppendModal.vue

@@ -0,0 +1,90 @@
+<script setup lang="ts">
+import { ref, reactive, toRaw, watch } from "vue";
+import type { FormInstance, FormRules } from "element-plus";
+import _ from "lodash";
+import type { TreeData } from "./Tree";
+
+const props = defineProps<{ contextData: TreeData; appendLoading: boolean }>();
+const show = defineModel<boolean>("show");
+const emit = defineEmits(["ok", "cancel"]);
+
+const form$ = ref<FormInstance>();
+const formData = reactive({
+  isDir: false,
+  filename: "",
+});
+const rules = reactive<FormRules<typeof formData>>({
+  filename: [
+    {
+      validator: (rule, value, callback, source, options) => {
+        if (
+          _.includes(
+            props.contextData.children?.map((c) => c.key),
+            `${props.contextData.key ? `${props.contextData.key}/` : ""}${value}`
+          )
+        ) {
+          callback("文件重名");
+        }
+        callback();
+      },
+      trigger: "change",
+    },
+    {
+      pattern: /[^\\/:"*?<>|]/,
+      message: '有非法字符: \\/:"*?<>|',
+      trigger: "change",
+    },
+  ],
+});
+
+const onOk = async () => {
+  try {
+    const result = await form$.value?.validate();
+    if (result) {
+      const data = _.cloneDeep(toRaw(formData))
+      if (!formData.isDir) {
+        data.filename = `${data.filename}.md`
+      }
+      emit("ok", data);
+    }
+  } catch (e) {
+    console.warn(e);
+  }
+};
+const onCancel = () => {
+  emit("cancel");
+  show.value = false;
+};
+watch(
+  () => show.value,
+  () => {
+    if (!show.value) {
+      form$.value?.resetFields();
+    }
+  }
+);
+</script>
+<template>
+  <el-dialog width="400px" v-model="show">
+    <el-form ref="form$" :model="formData" :rules="rules">
+      <el-form-item label="是否文件夹" prop="isDir">
+        <el-switch v-model="formData.isDir" inline-prompt></el-switch>
+      </el-form-item>
+      <el-form-item :label="formData.isDir ? '文件夹名' : '文件名'" prop="filename">
+        <el-input v-model="formData.filename">
+          <template v-if="!formData.isDir" #append>.md</template>
+        </el-input>
+      </el-form-item>
+      <el-form-item>
+        <el-button
+          :disabled="!formData.filename"
+          :loading="appendLoading"
+          @click="onOk"
+          type="primary"
+          >ok</el-button
+        >
+        <el-button @click="onCancel">cancel</el-button>
+      </el-form-item>
+    </el-form>
+  </el-dialog>
+</template>

+ 54 - 0
components/Edit/Node.vue

@@ -0,0 +1,54 @@
+<script setup lang="ts">
+import { toRefs } from "vue";
+import type Node from "element-plus/es/components/tree/src/model/node";
+import {
+  Delete,
+  Plus,
+} from "@element-plus/icons-vue";
+import _ from "lodash";
+import type { TreeData } from "./Tree";
+
+const props = defineProps<{
+  node: Node;
+  data: TreeData;
+}>();
+const emit = defineEmits(["append", "remove"]);
+const { node, data } = toRefs(props);
+</script>
+<template>
+  <div class="node">
+    <el-tooltip :content="node.label" :show-after="500">
+      <span class="label">{{ node.label }}</span>
+    </el-tooltip>
+    <el-button-group>
+      <el-button
+        v-if="data.isDir"
+        type="primary"
+        link
+        @click.stop="() => emit('append')"
+        :icon="Plus"
+      >
+      </el-button>
+      <el-button
+        type="danger"
+        link
+        @click.stop="() => emit('remove')"
+        :icon="Delete"
+      >
+      </el-button>
+    </el-button-group>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.node {
+  flex: 1;
+  display: flex;
+  overflow: hidden;
+  .label {
+    text-overflow: ellipsis;
+    overflow: hidden;
+    flex: 1;
+  }
+}
+</style>

+ 6 - 0
components/Edit/Tree.d.ts

@@ -0,0 +1,6 @@
+export type TreeData = {
+  key: string;
+  label: string;
+  isDir?: boolean;
+  children?: TreeData[]
+}

+ 445 - 0
components/Edit/index.vue

@@ -0,0 +1,445 @@
+<script setup lang="ts">
+import { ref, onMounted, computed, nextTick } from "vue";
+import _ from "lodash";
+import { v4 as uuid4 } from "uuid";
+import {
+  S3Client,
+  PutObjectCommand,
+  ListObjectsCommand,
+  GetObjectCommand,
+  DeleteObjectsCommand,
+  CopyObjectCommand,
+} from "@aws-sdk/client-s3";
+import { Plus, Refresh } from "@element-plus/icons-vue";
+import Node from "./Node.vue";
+import AppendModal from "./AppendModal.vue";
+import type { TreeData } from "./Tree";
+import { ElNotification, type ElTree } from "element-plus";
+import { MdEditor } from "md-editor-v3";
+import { s3ContentsToTree, getTreeFlatten } from "@/utils/s3Helper";
+import "md-editor-v3/lib/style.css";
+import type ETNode from "element-plus/es/components/tree/src/model/node";
+import type { DragEvents } from "element-plus/es/components/tree/src/model/useDragNode";
+import type {
+  AllowDropType,
+  NodeDropType,
+} from "element-plus/es/components/tree/src/tree.type";
+
+import path from "path-browserify";
+
+const s3 = new S3Client({
+  credentials: {
+    accessKeyId: import.meta.env.VITE_AWS_S3_ACCESS_KEY_ID,
+    secretAccessKey: import.meta.env.VITE_AWS_S3_SECRET_ACCESS_KEY,
+  },
+  region: import.meta.env.VITE_AWS_S3_REGION,
+});
+
+const SIDEBAR_SORTED_MAP_KEY = "SIDEBAR_SORTED_MAP.json";
+
+const tree$ = ref<InstanceType<typeof ElTree>>();
+
+const langSelect = ref<"zh-CN" | "zh-HK" | "en-US">("zh-CN");
+const langKeyPrefix = computed(() => {
+  return {
+    "zh-CN": "docs/",
+    "zh-HK": "zh-HK/docs/",
+    "en-US": "en-US/docs/",
+  }[langSelect.value];
+});
+const langDataSource = ref<
+  Partial<Record<"zh-CN" | "zh-HK" | "en-US", TreeData[]>>
+>({});
+const langSelectedDataSource = computed(() => {
+  return langDataSource.value[langSelect.value];
+});
+const dataSource = ref<unknown[]>([]);
+const sideLoading = ref(false);
+const loadS3DocsListObjects = async () => {
+  const command = new ListObjectsCommand({
+    Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
+  });
+  const result = await s3.send(command);
+  return result.Contents!;
+};
+const loadS3SideBarSort = async (lang) => {
+  try {
+    const command = new GetObjectCommand({
+      Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
+      Key: `${lang}::${SIDEBAR_SORTED_MAP_KEY}`,
+      ResponseCacheControl: "no-cache",
+    });
+    const file = await s3.send(command);
+    return JSON.parse(await file.Body?.transformToString()!);
+  } catch (e) {
+    console.warn(e);
+  }
+  return {};
+};
+const loadSideBar = async () => {
+  sideLoading.value = true;
+  dataSource.value = await loadS3DocsListObjects();
+  const allLangs: ("zh-CN" | "zh-HK" | "en-US")[] = ["zh-CN", "zh-HK", "en-US"];
+  await Promise.all(
+    allLangs.map(async (lang: "zh-CN" | "zh-HK" | "en-US") => {
+      const prefix = {
+        "zh-CN": "docs/",
+        "zh-HK": "zh-HK/docs/",
+        "en-US": "en-US/docs/",
+      }[lang];
+      const sideBarSortMap = await loadS3SideBarSort(lang);
+      const filtered = dataSource.value
+        .filter((cont) => cont.Key.startsWith(prefix))
+        .map((cont) => ({
+          ...cont,
+          Key: cont.Key.replace(new RegExp(`^${prefix}`), ""),
+        }))
+        .sort((a, b) => sideBarSortMap[a.Key] - sideBarSortMap[b.Key]);
+      // TODO sort
+      langDataSource.value[lang] = s3ContentsToTree(
+        filtered,
+        {},
+        (r, label, i, a, thisContent) => ({
+          isDir: a.length !== i + 1,
+          ...(a.length === i + 1 ? thisContent : {}),
+        })
+      );
+    })
+  );
+
+  sideLoading.value = false;
+};
+onMounted(() => {
+  loadSideBar();
+});
+
+const currentOpenData = ref<TreeData>();
+
+const onNodeClick = async (data: TreeData, node) => {
+  if (data.isDir) {
+    return;
+  }
+  textLoading.value = true;
+  const command = new GetObjectCommand({
+    Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
+    Key: `${langKeyPrefix.value}${data.key}`,
+    ResponseCacheControl: "no-cache",
+  });
+  const file = await s3.send(command);
+  text.value = await file.Body?.transformToString()!;
+  currentOpenData.value = data;
+
+  textLoading.value = false;
+};
+
+const text = ref("");
+const textLoading = ref(false);
+
+const onUploadImg = async (
+  files: File[],
+  callback: (
+    urls: string[] | { url: string; alt: string; title: string }[]
+  ) => void
+) => {
+  let result = [];
+  for (const file of files) {
+    try {
+      const key = `${uuid4()}::${file.name}`;
+      const command = new PutObjectCommand({
+        Bucket: import.meta.env.DOCS_MEDIA_BUCKET,
+        Key: key,
+        Body: file,
+        ACL: "public-read",
+      });
+      const res = await s3.send(command);
+      result.push({
+        url: `https://${
+          import.meta.env.DOCS_MEDIA_BUCKET
+        }.s3.amazonaws.com/${key}`,
+        alt: file.name,
+        title: file.name,
+      });
+    } catch (e) {
+      console.error(e);
+      ElNotification.error(`${file.name} 上传失败`);
+    }
+  }
+  callback(result);
+};
+const onSave = async () => {
+  if (!currentOpenData.value) {
+    ElNotification.info("请先选择文件");
+    return;
+  }
+  textLoading.value = true;
+  try {
+    const command = new PutObjectCommand({
+      Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
+      Key: `${langKeyPrefix.value}${currentOpenData.value?.key}`,
+      Body: text.value,
+    });
+    await s3.send(command);
+    ElNotification.success(`${currentOpenData.value?.key} 保存成功`);
+  } catch (e) {
+    console.error(e);
+    ElNotification.error(`${currentOpenData.value?.key} 保存失败`);
+  } finally {
+    textLoading.value = false;
+  }
+};
+
+let resolveAppend: Function | undefined;
+let rejectAppend: Function | undefined;
+const showAppendModal = ref(false);
+const appendLoading = ref(false);
+const appendContextData = ref();
+const onAppend = async (data: TreeData) => {
+  const modalConfirmPromise = new Promise((resolve, reject) => {
+    resolveAppend = resolve;
+    rejectAppend = reject;
+  });
+  appendContextData.value = data;
+  showAppendModal.value = true;
+  let filename, isDir;
+  try {
+    ({ filename, isDir } = await modalConfirmPromise);
+  } catch (e) {
+    return;
+  }
+  try {
+    appendLoading.value = true;
+    // const key = `${langKeyPrefix.value}${data.key ? `${data.key}/` : ''}${filename}`
+    const key = path.join(langKeyPrefix.value, data.key ?? "", filename);
+    if (!isDir) {
+      const command = new PutObjectCommand({
+        Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
+        Key: key,
+        Body: "",
+      });
+      await s3.send(command);
+    }
+    const newChild: TreeData = {
+      key: `${data.key ? `${data.key}/` : ""}${filename}`,
+      label: filename,
+      children: [],
+      isDir,
+    };
+    if (!data.children) {
+      data.children = [];
+    }
+    data.children.push(newChild);
+  } catch (e) {
+    console.error(e);
+    ElNotification.error("添加失败");
+  } finally {
+    appendLoading.value = false;
+    showAppendModal.value = false;
+  }
+  if (!isDir) {
+    saveSideBarSort();
+  }
+};
+const onRemove = async (node: ETNode, data: TreeData) => {
+  const parent = node.parent;
+  const children = parent.data.children || parent.data;
+  const index = children.findIndex((d) => d.key === data.key);
+
+  sideLoading.value = true;
+  try {
+    const leafDatas = getTreeFlatten(data);
+    const command = new DeleteObjectsCommand({
+      Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
+      Delete: {
+        Objects: leafDatas.map((d) => ({
+          Key: `${langKeyPrefix.value}${d.key}`,
+        })),
+        Quiet: false,
+      },
+    });
+    await s3.send(command);
+    children.splice(index, 1);
+  } catch (e) {
+    console.error(e);
+    ElNotification.error("删除失败");
+  } finally {
+    sideLoading.value = false;
+  }
+};
+const saveSideBarSort = async () => {
+  sideLoading.value = true;
+  const sortedNodes = getTreeFlatten(
+    { children: tree$.value!.data } as TreeData,
+    "preorder",
+    "all"
+  );
+  const sortedKeyMap = sortedNodes.reduce(
+    (a, v, i) => ({ ...a, [path.join(...v.__path__!)]: i }),
+    {}
+  );
+  const command = new PutObjectCommand({
+    Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
+    Key: `${langSelect.value}::${SIDEBAR_SORTED_MAP_KEY}`,
+    Body: JSON.stringify(sortedKeyMap),
+  });
+  await s3.send(command);
+  sideLoading.value = false;
+};
+const onDragEnd = async (
+  draggingNode: ETNode,
+  dropNode: ETNode,
+  dropType: NodeDropType,
+  ev: DragEvents
+) => {
+  if (dropType === "none") {
+    return;
+  }
+  sideLoading.value = true;
+
+  const copySourceNodes = getTreeFlatten(draggingNode.data);
+  const waitForDelete = [];
+  for (const sourceNode of copySourceNodes) {
+    if (sourceNode.isDir) {
+      continue;
+    }
+
+    const copySource = path.join(langKeyPrefix.value, sourceNode.key);
+    const copyTarget = path.join(
+      langKeyPrefix.value,
+      dropType === "inner" ? dropNode.data.key : dropNode.parent.data.key ?? "",
+      path.basename(draggingNode.data.key),
+      path.relative(draggingNode.data.key, sourceNode.key)
+    );
+    if (copySource === copyTarget) {
+      continue;
+    }
+    try {
+      const command = new CopyObjectCommand({
+        Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
+        CopySource: encodeURIComponent(
+          `/${import.meta.env.VITE_DOCS_LIST_BUCKET}/${copySource}`
+        ),
+        Key: copyTarget,
+      });
+      await s3.send(command);
+      waitForDelete.push({ Key: copySource });
+    } catch (e) {
+      console.error(e);
+      console.warn(`${copySource} 复制失败`);
+    }
+  }
+  if (waitForDelete.length) {
+    const command = new DeleteObjectsCommand({
+      Bucket: import.meta.env.VITE_DOCS_LIST_BUCKET,
+      Delete: {
+        Objects: waitForDelete,
+        Quiet: false,
+      },
+    });
+    await s3.send(command);
+  }
+
+  sideLoading.value = false;
+  nextTick(async () => {
+    await saveSideBarSort();
+    await loadSideBar();
+  });
+};
+const allowDrop = (draggingNode: Node, dropNode: Node, type: AllowDropType) => {
+  return !(!dropNode.data.isDir && type === "inner");
+};
+</script>
+<template>
+  <div class="md-container">
+    <div class="left" v-loading="sideLoading">
+      <div class="toolbar">
+        <el-segmented
+          v-model="langSelect"
+          :options="[
+            { label: '简体中文', value: 'zh-CN' },
+            { label: '繁体中文', value: 'zh-HK' },
+            { label: 'English', value: 'en-US' },
+          ]"
+        />
+        <el-button-group>
+          <el-button
+            @click="onAppend({ key: '', label: '', children: langSelectedDataSource })"
+            type="primary"
+            text
+            :icon="Plus"
+          >
+          </el-button>
+          <el-button @click="loadSideBar" type="info" text :icon="Refresh"></el-button>
+        </el-button-group>
+      </div>
+      <el-divider />
+      <el-tree
+        ref="tree$"
+        :data="langSelectedDataSource"
+        node-key="key"
+        default-expand-all
+        :expand-on-click-node="false"
+        @node-click="onNodeClick"
+        draggable
+        :allow-drop="allowDrop"
+        @node-drag-end="onDragEnd"
+      >
+        <template #default="{ node, data }">
+          <Node
+            :node="node"
+            :data="data"
+            @append="() => onAppend(data)"
+            @remove="() => onRemove(node, data)"
+          />
+        </template>
+      </el-tree>
+    </div>
+    <MdEditor
+      v-loading="textLoading"
+      :disabled="!currentOpenData"
+      v-model="text"
+      @save="onSave"
+      @uploadImg="onUploadImg"
+    />
+  </div>
+  <AppendModal
+    v-model:show="showAppendModal"
+    :contextData="appendContextData"
+    :appendLoading="appendLoading"
+    @ok="(formData) => resolveAppend?.(formData)"
+    @cancel="() => rejectAppend?.()"
+  />
+</template>
+
+<style lang="scss" scoped>
+.md-container {
+  width: 100vw;
+  height: 100dvh;
+  display: flex;
+  align-items: stretch;
+  .md-editor {
+    flex: 1;
+    height: 100%;
+  }
+  .left {
+    flex: 0 0 300px;
+    padding: 10px;
+    display: flex;
+    flex-direction: column;
+    align-items: stretch;
+    .toolbar {
+      display: flex;
+      flex-direction: column;
+      gap: 5px;
+    }
+    .el-tree {
+      overflow: auto;
+      flex: 1;
+      :deep(.el-tree-node.is-current) {
+        & > .el-tree-node__content {
+          background-color: aqua;
+        }
+      }
+    }
+  }
+}
+</style>

+ 94 - 0
components/HomeCard/HomeCard.vue

@@ -0,0 +1,94 @@
+<script setup lang="ts">
+import { toRefs, computed } from "vue";
+import _ from "lodash";
+
+const props = withDefaults(
+  defineProps<{
+    title?: string;
+    color?: "info" | "skyblue" | "white";
+    backgroundStyle?: Record<string, string>;
+  }>(),
+  {
+    color: "info",
+    backgroundStyle: { right: "16px", bottom: "24px" },
+  }
+);
+const { title, color } = toRefs(props);
+const styles = computed(() => {
+  return _.get(
+    {
+      info: {
+        backgroundColor: "#f5f9ff",
+        borderColor: "#e2eeff",
+      },
+      skyblue: {
+        backgroundColor: "#f6fdff",
+        borderColor: "#e3f8f4",
+      },
+      white: {
+        borderColor: "#e2eeff",
+        backgroundColor: "#fff",
+      },
+    },
+    color.value
+  );
+});
+</script>
+<template>
+  <div class="card" :style="styles">
+    <div v-if="$slots.background" class="background" :style="props.backgroundStyle">
+      <slot name="background"> </slot>
+    </div>
+    <slot name="title">
+      <h2 v-if="title">
+        {{ title }}
+      </h2>
+    </slot>
+    <el-row :gutter="20" tag="section">
+      <slot></slot>
+    </el-row>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+h2 {
+  border: none;
+  margin: 0;
+  padding: 0;
+}
+.card {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  gap: 24px;
+  border-radius: 20px;
+  padding: 32px;
+  background: #f5f9ff;
+  border: 1px solid #e2eeff;
+  min-height: 240px;
+  :deep(.card) {
+    min-height: 162px;
+  }
+  .background {
+    position: absolute;
+  }
+  h2 {
+    display: flex;
+    align-items: center;
+    .prefix {
+      margin-right: 8px;
+    }
+    .suffix {
+      width: 24px;
+      height: 24px;
+      margin-left: 8px;
+      color: #00000042;
+    }
+  }
+  .el-row {
+    row-gap: 20px;
+    align-items: stretch;
+  }
+}
+</style>

+ 5 - 0
components/HomeCard/HomeCardContent.vue

@@ -0,0 +1,5 @@
+<template>
+  <el-col>
+    <slot></slot>
+  </el-col>
+</template>

+ 30 - 0
components/HomeCard/HomeCardTitle.vue

@@ -0,0 +1,30 @@
+<script setup lang="ts">
+import { ArrowRightBold } from "@element-plus/icons-vue";
+const props = withDefaults(
+  defineProps<{
+    showArrow?: boolean;
+  }>(),
+  { showArrow: false }
+);
+</script>
+<template>
+  <h2>
+    <slot></slot>
+    <el-icon v-if="props.showArrow" class="suffix"><ArrowRightBold /></el-icon>
+  </h2>
+</template>
+<style lang="scss" scoped>
+h2 {
+  border: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  .suffix {
+    width: 24px;
+    height: 24px;
+    color: #00000042;
+  }
+}
+</style>

+ 9 - 0
components/HomeCard/index.ts

@@ -0,0 +1,9 @@
+import HomeCard from "./HomeCard.vue";
+import HomeCardContent from "./HomeCardContent.vue";
+import HomeCardTitle from "./HomeCardTitle.vue";
+
+HomeCard.Title = HomeCardTitle;
+HomeCard.Content = HomeCardContent;
+
+export default HomeCard;
+export { HomeCardTitle, HomeCardContent };

+ 328 - 56
components/HomeContent.vue

@@ -1,78 +1,350 @@
 <script setup lang="ts">
-import { ref } from "vue";
 import Search from "./Search/index.vue";
+import HomeCard, { HomeCardContent } from "./HomeCard";
+import HomeSection, { HomeSectionContent } from "./HomeSection";
 import { ArrowRightBold } from "@element-plus/icons-vue";
-const count = ref(0);
+import IconNotebook from "~icons/ccrbi-plain/notebook";
+import IconBook from "~icons/ccrbi-plain/book";
+import IconDirectory from "~icons/ccrbi-plain/directory";
+import IconXieTongJianGou from "~icons/ccrbi-colored/xie-tong-jian-gou";
+import IconAiZhuShou from "~icons/ccrbi-colored/ai-zhu-shou";
+import IconAiChuangJian from "~icons/ccrbi-colored/ai-chuang-jian";
+import { useData, withBase, useRouter } from "vitepress";
+import path from "path-browserify";
+
+const { localeIndex } = useData();
+const router = useRouter();
+const withLink = (href: string) => {
+  return withBase(
+    path.join(localeIndex.value === "root" ? "" : localeIndex.value, "docs", href)
+  );
+};
 </script>
+
 <template>
   <div class="home-content">
-    <h1>您好,需要提供什么帮助?</h1>
-    <Search />
-    <h1>新手入门</h1>
-    <section>
-      <div class="card type1">
-        <h2>
-          平台概览<el-icon><ArrowRightBold /></el-icon>
-        </h2>
-        <span class="content">TODO</span>
-      </div>
+    <HomeSection title="您好,需要提供什么帮助?">
+      <HomeSectionContent>
+        <Search />
+      </HomeSectionContent>
+    </HomeSection>
+
+    <HomeSection title="新手入门">
+      <HomeSectionContent :span="12">
+        <HomeCard
+          color="skyblue"
+          :backgroundStyle="{
+            right: '16px',
+            bottom: '24px',
+            width: '268px',
+            height: '148px',
+          }"
+        >
+          <template #background>
+            <img src="@/assets/images/card1.png" />
+          </template>
+          <template #title>
+            <HomeCard.Title showArrow> 平台概览 </HomeCard.Title>
+          </template>
+          <HomeCardContent>
+            <ul>
+              <li>什么是可可智慧教育平台?</li>
+              <li>功能概览</li>
+            </ul>
+          </HomeCardContent>
+        </HomeCard>
+      </HomeSectionContent>
+      <HomeSectionContent :span="12">
+        <HomeCard
+          :backgroundStyle="{
+            right: '40px',
+            bottom: '24px',
+            width: '128px',
+            height: '128px',
+          }"
+        >
+          <template #background>
+            <img src="@/assets/images/card2.png" />
+          </template>
+          <template #title>
+            <HomeCard.Title showArrow> 用户登录 </HomeCard.Title>
+          </template>
+          <HomeCardContent>
+            <ul>
+              <li>如何获取账户?</li>
+              <li>如何登录?</li>
+            </ul>
+          </HomeCardContent>
+        </HomeCard>
+      </HomeSectionContent>
+    </HomeSection>
+
+    <HomeSection title="平台使用">
+      <HomeSectionContent :span="24">
+        <HomeCard title="基础使用">
+          <HomeCardContent :span="8">
+            <HomeCard color="white">
+              <template #title>
+                <HomeCard.Title>
+                  <IconNotebook color="#3681FC" />
+                  创建课程
+                </HomeCard.Title>
+              </template>
+              <HomeCardContent>
+                教师通过个人、协同的方式创建多种模式下的平台数字化课程。
+              </HomeCardContent>
+            </HomeCard>
+          </HomeCardContent>
+          <HomeCardContent :span="8">
+            <HomeCard color="white">
+              <template #title>
+                <HomeCard.Title>
+                  <IconBook color="#FF822B" />
+                  实施课程
+                </HomeCard.Title>
+              </template>
+              <HomeCardContent>
+                开展数字化课程教学与学习,在实施中进行学习证据采集。
+              </HomeCardContent>
+            </HomeCard>
+          </HomeCardContent>
+          <HomeCardContent :span="8">
+            <HomeCard color="white">
+              <template #title>
+                <HomeCard.Title>
+                  <IconDirectory color="#34CEAE" />
+                  创建项目
+                </HomeCard.Title>
+              </template>
+              <HomeCardContent>
+                以项目式学习探究创建项目、记录项目、管理项目。
+              </HomeCardContent>
+            </HomeCard>
+          </HomeCardContent>
+        </HomeCard>
+      </HomeSectionContent>
+
+      <HomeSectionContent :span="24">
+        <HomeCard title="进阶使用">
+          <HomeCardContent :span="8">
+            <HomeCard color="white">
+              <template #title>
+                <HomeCard.Title>
+                  <IconXieTongJianGou />
+                  协同建构
+                </HomeCard.Title>
+              </template>
+              <HomeCardContent>
+                师生以协同建构(CocoNote)开展计算机支持下的协作问题解决。
+              </HomeCardContent>
+            </HomeCard>
+          </HomeCardContent>
+          <HomeCardContent :span="8">
+            <HomeCard color="white">
+              <template #title>
+                <HomeCard.Title>
+                  <IconAiZhuShou />
+                  AI助手
+                </HomeCard.Title>
+              </template>
+              <HomeCardContent> 使用生成式人工智能技术提供对话式工具。 </HomeCardContent>
+            </HomeCard>
+          </HomeCardContent>
+          <HomeCardContent :span="8">
+            <HomeCard color="white">
+              <template #title>
+                <HomeCard.Title>
+                  <IconAiChuangJian />
+                  教师管理
+                </HomeCard.Title>
+              </template>
+              <HomeCardContent>
+                创建可重复使用的表单,组织团队资料收集与汇总。
+              </HomeCardContent>
+            </HomeCard>
+          </HomeCardContent>
+        </HomeCard>
+      </HomeSectionContent>
+    </HomeSection>
 
-      <div class="card">
-        <h2>
-          平台概览<el-icon><ArrowRightBold /></el-icon>
-        </h2>
-        <span class="content">TODO</span>
-      </div>
-    </section>
+    <HomeSection title="更多资源">
+      <HomeSectionContent :span="12">
+        <HomeCard title="课程案例">
+          <HomeCardContent :span="24">
+            <ul class="fancy-list">
+              <li @click="router.go(withLink('TODO'))">
+                <el-text class="flex-1" size="large" truncated>
+                  Self element set wadslfkjsa;ldkjf;lsdkjf;lsdajf;lasdkjf;ladskjfidth
+                  100px
+                </el-text>
+                <el-icon class="el-icon--right"><ArrowRightBold /></el-icon>
+              </li>
+              <li @click="router.go(withLink('TODO'))">
+                <el-text class="flex-1" size="large" truncated>
+                  Self element set wadslfkjsa;ldkjf;lsdkjf;lsdajf;lasdkjf;ladskjfidth
+                  100px
+                </el-text>
+                <el-icon class="el-icon--right"><ArrowRightBold /></el-icon>
+              </li>
+              <li @click="router.go(withLink('TODO'))">
+                <el-text class="flex-1" size="large" truncated>
+                  Self element set wadslfkjsa;ldkjf;lsdkjf;lsdajf;lasdkjf;ladskjfidth
+                  100px
+                </el-text>
+                <el-icon class="el-icon--right"><ArrowRightBold /></el-icon>
+              </li>
+            </ul>
+          </HomeCardContent>
+          <HomeCardContent :span="24">
+            <el-button round size="large"
+              >查看更多<el-icon class="el-icon--right"><ArrowRightBold /></el-icon
+            ></el-button>
+          </HomeCardContent>
+        </HomeCard>
+      </HomeSectionContent>
+
+      <HomeSectionContent :span="12">
+        <HomeCard title="项目案例" :span="12">
+          <HomeCardContent :span="24">
+            <ul class="fancy-list">
+              <li>
+                <el-text class="flex-1" size="large" truncated>
+                  Self element set wadslfkjsa;ldkjf;lsdkjf;lsdajf;lasdkjf;ladskjfidth
+                  100px
+                </el-text>
+                <el-icon class="el-icon--right"><ArrowRightBold /></el-icon>
+              </li>
+              <li>
+                <el-text class="flex-1" size="large" truncated>
+                  Self element set wadslfkjsa;ldkjf;lsdkjf;lsdajf;lasdkjf;ladskjfidth
+                  100px
+                </el-text>
+                <el-icon class="el-icon--right"><ArrowRightBold /></el-icon>
+              </li>
+              <li>
+                <el-text class="flex-1" size="large" truncated>
+                  Self element set wadslfkjsa;ldkjf;lsdkjf;lsdajf;lasdkjf;ladskjfidth
+                  100px
+                </el-text>
+                <el-icon class="el-icon--right"><ArrowRightBold /></el-icon>
+              </li>
+            </ul>
+          </HomeCardContent>
+          <HomeCardContent :span="24">
+            <el-button round size="large"
+              >查看更多<el-icon class="el-icon--right"><ArrowRightBold /></el-icon
+            ></el-button>
+          </HomeCardContent>
+        </HomeCard>
+      </HomeSectionContent>
+
+      <HomeSectionContent>
+        <HomeCard>
+          <HomeCardContent :span="8">
+            <HomeCard
+              title="常见问题"
+              color="white"
+              :backgroundStyle="{
+                right: '10px',
+                bottom: '0',
+                width: '122px',
+                height: '128px',
+              }"
+            >
+              <template #background>
+                <img src="@/assets/images/card3.png" />
+              </template>
+              <HomeCardContent>
+                <!-- TODO -->
+              </HomeCardContent>
+            </HomeCard>
+          </HomeCardContent>
+          <HomeCardContent :span="8">
+            <HomeCard
+              title="更新日志"
+              color="white"
+              :backgroundStyle="{
+                right: '0',
+                bottom: '0',
+                width: '160px',
+                height: '128px',
+              }"
+            >
+              <template #background>
+                <img src="@/assets/images/card4.png" />
+              </template>
+              <HomeCardContent>
+                <!-- TODO -->
+              </HomeCardContent>
+            </HomeCard>
+          </HomeCardContent>
+          <HomeCardContent :span="8">
+            <HomeCard
+              title="如何与AI对话"
+              color="white"
+              :backgroundStyle="{
+                right: '17px',
+                bottom: '0',
+                width: '167px',
+                height: '92px',
+              }"
+            >
+              <template #background>
+                <img src="@/assets/images/card5.png" />
+              </template>
+              <HomeCardContent>
+                <!-- TODO -->
+              </HomeCardContent>
+            </HomeCard>
+          </HomeCardContent>
+        </HomeCard>
+      </HomeSectionContent>
+    </HomeSection>
   </div>
 </template>
 
 <style lang="scss" scoped>
-h2 {
-  border: none;
-  margin: 0;
-  padding: 0;
-}
 .home-content {
   display: flex;
   flex-direction: column;
   align-items: center;
-  section {
-    display: flex;
-    align-items: stretch;
-    justify-content: center;
-    width: 100%;
-    gap: 20px;
-  }
-  .card {
-    flex: 1;
+  gap: 80px;
+  padding-top: 80px;
+}
+.fancy-list {
+  list-style-type: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  gap: 10px;
+  li {
     display: flex;
-    flex-direction: column;
-    align-items: stretch;
-    gap: 8px;
-    border-radius: 20px;
-    padding: 32px;
-    background: #f5f9ff;
-    border: 1px solid #e2eeff;
-    min-height: 240px;
-    &.type1 {
-      background-color: #f6fdff;
-      border: 1px solid #e3f8f4;
+    height: 38px;
+    align-items: center;
+    transition: all 0.2s;
+    border: 1px solid transparent;
+    border-radius: 8px;
+    padding: 8px 4px;
+    margin: 0;
+    cursor: pointer;
+    &::before {
+      content: "•";
+      margin: 0 8px;
     }
-    h2 {
-      display: flex;
-      align-items: center;
-      .el-icon {
-        width: 24px;
-        height: 24px;
-        margin-left: 8px;
-        color: #00000042;
-      }
+    &:hover {
+      // border: 1px solid #3681fc;
+      border-color: #3681fc;
+      background: #ffffff;
     }
-    .content {
-      font-size: 14px;
-      font-weight: 400;
+    span {
+      flex: 1;
     }
   }
 }
+.el-button:not(:hover) {
+  background-color: transparent;
+  border: 1px solid #aeccfe;
+}
 </style>

+ 36 - 0
components/HomeSection/HomeSection.vue

@@ -0,0 +1,36 @@
+<script setup lang="ts">
+import { toRefs, computed, useSlots, h } from "vue";
+const props = defineProps<{
+  title?: string;
+}>();
+const { title } = toRefs(props);
+
+const slots = useSlots();
+const titleNode = computed(() => {
+  return slots.title ? slots.title() : title.value ? h("h1", {}, title.value) : null;
+});
+</script>
+<template>
+  <div class="home-section">
+    <component :is="titleNode" class="title" />
+    <el-row class="content" justify="center" :gutter="20" tag="section" v-bind="$attrs">
+      <slot></slot>
+    </el-row>
+  </div>
+</template>
+<style lang="scss" scoped>
+.home-section {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  gap: 20px;
+}
+.content {
+  width: 100%;
+}
+.el-row {
+  row-gap: 20px;
+  align-items: stretch;
+}
+</style>

+ 5 - 0
components/HomeSection/HomeSectionContent.vue

@@ -0,0 +1,5 @@
+<template>
+  <el-col>
+    <slot> </slot>
+  </el-col>
+</template>

+ 8 - 0
components/HomeSection/index.ts

@@ -0,0 +1,8 @@
+import HomeSection from "./HomeSection.vue";
+import HomeSectionContent from "./HomeSectionContent.vue";
+
+// HomeSection.Title = HomeSectionTitle;
+HomeSection.Content = HomeSectionContent;
+
+export default HomeSection;
+export { HomeSectionContent };

+ 230 - 41
components/Search/index.vue

@@ -4,61 +4,147 @@ export default {
 };
 </script>
 <script setup lang="ts">
-import { ref, computed, watch } from "vue";
+import { ref, computed, watch, shallowRef, markRaw, nextTick } from "vue";
+import Mark from 'mark.js/src/vanilla.js'
+import localSearchIndex from '@localSearchIndex'
+import MiniSearch, { type SearchResult } from 'minisearch'
 import { Search } from "@element-plus/icons-vue";
-import { watchDebounced, useFocus } from "@vueuse/core";
+import { watchDebounced, useFocus, computedAsync } from "@vueuse/core";
 import { useI18n } from "vue-i18n";
+import { useData } from 'vitepress'
+import { LRUCache } from 'vitepress/dist/client/theme-default/support/lru'
+import { pathToFile } from 'vitepress/dist/client/app/utils'
+import { escapeRegExp } from 'vitepress/dist/client/shared'
 import _ from "lodash";
 
 const { t } = useI18n();
 
-const input = ref("");
+/* Search */
+
+const searchIndexData = shallowRef(localSearchIndex)
+
+// hmr
+if (import.meta.hot) {
+  import.meta.hot.accept('/@localSearchIndex', (m) => {
+    if (m) {
+      searchIndexData.value = m.default
+    }
+  })
+}
+
+interface Result {
+  title: string
+  titles: string[]
+  text?: string
+}
+
+const vitePressData = useData()
+const { localeIndex, theme } = vitePressData
+const searchIndex = computedAsync(async () =>
+  markRaw(
+    MiniSearch.loadJSON<Result>(
+      (await searchIndexData.value[localeIndex.value]?.())?.default,
+      {
+        fields: ['title', 'titles', 'text'],
+        storeFields: ['title', 'titles'],
+        searchOptions: {
+          fuzzy: 0.2,
+          prefix: true,
+          boost: { title: 4, text: 2, titles: 1 },
+          ...(theme.value.search?.provider === 'local' &&
+            theme.value.search.options?.miniSearch?.searchOptions)
+        },
+        ...(theme.value.search?.provider === 'local' &&
+          theme.value.search.options?.miniSearch?.options)
+      }
+    )
+  )
+)
+
+const filterText = ref("");
 const input$ = ref();
 const { focused } = useFocus(computed(() => input$.value?.input));
-const suggestions = ref<unknown[]>([]);
+const results = shallowRef<(SearchResult & Result)[]>([])
 const loading = ref(false);
 
 const suggestionVisible = computed(() => {
   // TEST
   // return true
-  const isValidData = suggestions.value.length > 0;
-  return focused.value && (isValidData || loading.value);
+  const isValidData = results.value.length > 0;
+  return !!( focused.value && (isValidData || loading.value || filterText.value) );
 });
 
-watch(
-  () => input.value,
-  (val) => {
-    console.log(input$.value);
-    loading.value = !!val;
-    if (!val) {
-      suggestions.value = [];
-    }
-  }
-);
 
-const fetchSuggestions = (mock) => {
-  return new Promise((resolve, reject) => {
-    setTimeout(() => {
-      // MOCK
-      // TODO should we use local search and gpt search together?
-      resolve(["vue", "react", mock]);
-    }, 1000);
-  });
-};
+const resultsEl = shallowRef<HTMLElement>()
+const mark = computedAsync(async () => {
+  if (!resultsEl.value) return
+  return markRaw(new Mark(resultsEl.value))
+}, null)
 
+function formMarkRegex(terms: Set<string>) {
+  return new RegExp(
+    [...terms]
+      .sort((a, b) => b.length - a.length)
+      .map((term) => `(${escapeRegExp(term)})`)
+      .join('|'),
+    'gi'
+  )
+}
+watch(() => filterText.value, () => {
+  if (!results.value.length) {
+    loading.value = true
+  }
+})
 watchDebounced(
-  () => input.value,
-  async (taggedInput) => {
-    if (!taggedInput) {
-      return;
-    }
-    const result = await fetchSuggestions(taggedInput);
-    if (taggedInput === input.value) {
-      suggestions.value = result as unknown[];
-      loading.value = false;
-    }
+  () => [searchIndex.value, filterText.value] as const,
+  async ([index, filterTextValue], old, onCleanup) => {
+    let canceled = false
+    onCleanup(() => {
+      canceled = true
+    })
+
+    if (!index) return
+
+    // Search
+    results.value = index
+      .search(filterTextValue)
+      .slice(0, 16) as (SearchResult & Result)[]
+
+    console.log(results.value)
+
+    // enableNoResults.value = true
+
+    const terms = new Set<string>()
+
+    results.value = results.value.map((r) => {
+      for (const term in r.match) {
+        terms.add(term)
+      }
+      return r
+    })
+    loading.value = false
+
+    await nextTick()
+    if (canceled) return
+
+    await new Promise((r) => {
+      mark.value?.unmark({
+        done: () => {
+          mark.value?.markRegExp(formMarkRegex(terms), { done: r })
+        }
+      })
+    })
+
+    // const excerpts = el.value?.querySelectorAll('.result .excerpt') ?? []
+    // for (const excerpt of excerpts) {
+    //   excerpt
+    //     .querySelector('mark[data-markjs="true"]')
+    //     ?.scrollIntoView({ block: 'center' })
+    // }
+    // FIXME: without this whole page scrolls to the bottom
+    // resultsEl.value?.firstElementChild?.scrollIntoView({ block: 'start' })
   },
-  { debounce: 500 }
+  { debounce: 200, immediate: true }
 );
 </script>
 <template>
@@ -74,7 +160,7 @@ watchDebounced(
         <div class="search-trigger" :class="{ 'has-content': suggestionVisible }">
           <el-input
             :ref="(el) => (input$ = el)"
-            v-model="input"
+            v-model="filterText"
             clearable
             :prefix-icon="Search"
             :placeholder="t('请输入关键词,如:课程、协同、AI')"
@@ -83,13 +169,41 @@ watchDebounced(
       </template>
       <div class="search-content">
         <template v-if="loading">
-          <span>loading</span>
+          <el-skeleton animated />
         </template>
-        <template v-else>
-          <ul>
-            <li v-for="(suggest, _index) in suggestions" :key="_index">{{ suggest }}</li>
+        <template v-else-if="results.length">
+          <ul ref="resultsEl" class="results">
+            <li v-for="(p, index) in results" :key="index">
+              <a
+                :href="p.id"
+                class="result"
+                :aria-label="[...p.titles, p.title].join(' > ')"
+              >
+                <div>
+                  <div class="titles">
+                    <span class="title-icon">#</span>
+                    <span v-for="(t, index) in p.titles" :key="index" class="title">
+                      <span class="text" v-html="t" />
+                      <span class="vpi-chevron-right local-search-icon" />
+                    </span>
+                    <span class="title main">
+                      <span class="text" v-html="p.title" />
+                    </span>
+                  </div>
+                </div>
+              </a>
+            </li>
           </ul>
         </template>
+        <template v-else-if="filterText">
+          <el-empty :image-size="80">
+            <template #description>
+              <span
+                >无法找到相关结果 <strong>"{{ filterText }}"</strong>
+              </span>
+            </template>
+          </el-empty>
+        </template>
       </div>
     </el-popover>
   </div>
@@ -102,6 +216,7 @@ watchDebounced(
 <style lang="scss" scoped>
 .search-container {
   width: 514px;
+  margin: auto;
   position: relative;
   .search-trigger {
     border: 1px solid #aeccfe;
@@ -132,8 +247,82 @@ watchDebounced(
     border-radius: 0 0 26px 26px;
     border: none;
     clip-path: inset(0px -10px -10px -10px);
+    padding: 0;
+    overflow: hidden;
+    max-height: 300px;
+    overflow-y: scroll;
   }
   .search-content {
+    .el-skeleton {
+      padding: 10px 20px;
+    }
+    .results {
+      display: flex;
+      flex-direction: column;
+      overflow-x: hidden;
+      overflow-y: auto;
+      overscroll-behavior: contain;
+
+      list-style-type: none;
+      padding: 0 0 5px;
+      margin: 0;
+
+      li {
+        margin: 0;
+      }
+
+      .result {
+        display: flex;
+        align-items: center;
+        gap: 8px;
+        border-radius: 4px;
+        transition: none;
+        line-height: 1rem;
+        outline: none;
+
+        min-height: 40px;
+        padding: 5px 20px;
+        color: #333;
+
+        :deep(mark) {
+          color: #3681fc;
+          background: transparent;
+        }
+
+        &:hover {
+          background-color: #ecf5ff;
+        }
+
+        .titles {
+          display: flex;
+          flex-wrap: wrap;
+          gap: 4px;
+          position: relative;
+          z-index: 1001;
+          padding: 2px 0;
+        }
+
+        .title {
+          display: flex;
+          align-items: center;
+          gap: 4px;
+        }
+
+        .title.main {
+          font-weight: 500;
+        }
+
+        .title-icon {
+          opacity: 0.5;
+          font-weight: 500;
+          color: var(--vp-c-brand-1);
+        }
+
+        .title svg {
+          opacity: 0.5;
+        }
+      }
+    }
   }
 }
 </style>

BIN
media/AI模块/AI屏幕helloworld.jpeg


BIN
media/AI模块/AI摄像头.jpg


BIN
media/AI模块/AI模块1.png


BIN
media/AI模块/AI模块2.png


BIN
media/AI模块/AI模块3.png


BIN
media/AI模块/AI模块和屏幕模块和摄像头模块连接.jpg


BIN
media/AI模块/AI模块和屏幕模块和摄像头模块连接2.jpg


BIN
media/AI模块/AI模块和屏幕模块连接.jpg


BIN
media/AI模块/AI模块基础编程.png


BIN
media/AI模块/AI模块连接.jpeg


BIN
media/AI模块/AI模板基础1.gif


BIN
media/AI模块/IOT连接屏幕模块.jpg


BIN
media/AI模块/LCD屏幕初始化.png


BIN
media/AI模块/LCD屏幕填充颜色为.png


BIN
media/AI模块/LCD屏幕方向旋转.png


BIN
media/AI模块/LCD屏幕模块1.png


BIN
media/AI模块/LCD屏幕模块2.png


BIN
media/AI模块/LCD屏幕模块3.png


BIN
media/AI模块/LCD屏幕进行镜面翻转.png


BIN
media/AI模块/LED1.png


BIN
media/AI模块/LED2.png


BIN
media/AI模块/LED3.png


BIN
media/AI模块/X Y.png


BIN
media/AI模块/image6.png


BIN
media/AI模块/创建图像画布.png


BIN
media/AI模块/创建空白画布尺寸.png


BIN
media/AI模块/否则如果.gif


BIN
media/AI模块/启用AI摄像头.png


BIN
media/AI模块/在屏幕显示hello world.png


BIN
media/AI模块/在屏幕显示hello world(1).png


BIN
media/AI模块/在屏幕显示hello world(2).png


BIN
media/AI模块/在画布上绘制二维码.png


BIN
media/AI模块/在画布上绘制十字标.png


BIN
media/AI模块/在画布上绘制图片.png


BIN
media/AI模块/在画布上绘制实心圆形.png


BIN
media/AI模块/在画布上绘制实心矩形.png


BIN
media/AI模块/在画布上绘制文本.png


BIN
media/AI模块/在画布上绘制直线.png


BIN
media/AI模块/在画布上绘制箭头.png


BIN
media/AI模块/宽 高.png


BIN
media/AI模块/将LCD屏幕填充颜色为.png


BIN
media/AI模块/将LCD屏幕填充颜色为2.png


BIN
media/AI模块/将屏幕颜色填充为.png


BIN
media/AI模块/将画布铺满颜色.png


BIN
media/AI模块/屏幕连接AI模块.jpg


BIN
media/AI模块/按钮1.png


BIN
media/AI模块/按钮2.png


BIN
media/AI模块/摄像头10.png


BIN
media/AI模块/摄像头2.png


BIN
media/AI模块/摄像头3.png


BIN
media/AI模块/摄像头4.png


BIN
media/AI模块/摄像头5.png


BIN
media/AI模块/摄像头6.png


BIN
media/AI模块/摄像头7.png


BIN
media/AI模块/摄像头8.png


BIN
media/AI模块/摄像头9.png


BIN
media/AI模块/摄像头模块1.png


BIN
media/AI模块/摄像头模块2.png


BIN
media/AI模块/数据线连接AI模块.png


BIN
media/AI模块/数据线连接电脑.png


BIN
media/AI模块/显示画布.png


BIN
media/AI模块/清除屏幕.png


BIN
media/AI模块/清除画布.png


BIN
media/AI模块/画点.png


BIN
media/AI模块/相机初始化.png


BIN
media/AI模块/系统1.png


BIN
media/AI模块/系统2.png


BIN
media/AI模块/系统3.png


BIN
media/AI模块/系统4.png


BIN
media/AI模块/系统5.png


BIN
media/AI模块/系统6.png


BIN
media/AI模块/红 绿 蓝.png


BIN
media/AI模块/红绿蓝IOT.png


BIN
media/AI模块/绘制实心矩形.png


BIN
media/AI模块/绘制文本.png


Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác