瀏覽代碼

first commit

Jerry Liang 1 年之前
當前提交
a9a5dcc432
共有 100 個文件被更改,包括 4503 次插入 和 0 次删除
  1. 3 0
      .env.waline
  2. 25 0
      .gitignore
  3. 69 0
      check-build-vitepress.ts
  4. 180 0
      docs/.vitepress/config.mts
  5. 280 0
      docs/.vitepress/menuConfig.ts
  6. 37 0
      docs/.vitepress/plugins/SSE/client.ts
  7. 49 0
      docs/.vitepress/plugins/SSE/fifo.ts
  8. 2 0
      docs/.vitepress/plugins/SSE/index.ts
  9. 78 0
      docs/.vitepress/plugins/SSE/server.ts
  10. 9 0
      docs/.vitepress/plugins/SSE/stateMap.ts
  11. 38 0
      docs/.vitepress/plugins/casdoor/config.ts
  12. 202 0
      docs/.vitepress/plugins/casdoor/mid.ts
  13. 18 0
      docs/.vitepress/plugins/waline/index.ts
  14. 18 0
      docs/.vitepress/plugins/waline/userMeta.ts
  15. 56 0
      docs/.vitepress/script/crypto/decrypt_pdf.ts
  16. 114 0
      docs/.vitepress/script/crypto/decrypt_pdf_wed.ts
  17. 61 0
      docs/.vitepress/script/crypto/encrypt_pdf.ts
  18. 42 0
      docs/.vitepress/script/decrypt_pdf.ts
  19. 58 0
      docs/.vitepress/script/encrypt_pdf.ts
  20. 173 0
      docs/.vitepress/theme/Layout.vue
  21. 64 0
      docs/.vitepress/theme/components/CasdoorCallback.vue
  22. 120 0
      docs/.vitepress/theme/components/ExcalidrawView.vue
  23. 145 0
      docs/.vitepress/theme/components/FilterComponent.vue
  24. 526 0
      docs/.vitepress/theme/components/FilterForm.vue
  25. 152 0
      docs/.vitepress/theme/components/NormalTable.vue
  26. 44 0
      docs/.vitepress/theme/components/ParameterTable.vue
  27. 30 0
      docs/.vitepress/theme/components/PdfViewer.vue
  28. 54 0
      docs/.vitepress/theme/components/SecurePdfViewer.vue
  29. 66 0
      docs/.vitepress/theme/components/SimpleTable.vue
  30. 26 0
      docs/.vitepress/theme/components/SseListener.vue
  31. 102 0
      docs/.vitepress/theme/components/Waline.vue
  32. 7 0
      docs/.vitepress/theme/index.css
  33. 130 0
      docs/.vitepress/theme/index.ts
  34. 168 0
      docs/Hardware/Gate/MSA_C.md
  35. 11 0
      docs/Hardware/Gate/MSA_C2.md
  36. 115 0
      docs/Hardware/Gate/aGate_1.0.md
  37. 5 0
      docs/Hardware/Gate/aGate_1.2.md
  38. 244 0
      docs/Hardware/Gate/aGate_1.3.1.md
  39. 134 0
      docs/Hardware/Gate/aGate_1.3.md
  40. 二進制
      docs/Hardware/Gate/assets/MSA_C/ConnectDER-MSA外观.png
  41. 二進制
      docs/Hardware/Gate/assets/MSA_C/EQB-MSA外观.png
  42. 二進制
      docs/Hardware/Gate/assets/MSA_C/MSA-C+CND+aPbox2.0电气方案图.png
  43. 二進制
      docs/Hardware/Gate/assets/MSA_C/MSA-C+CND电气方案图.png
  44. 二進制
      docs/Hardware/Gate/assets/MSA_C/MSA-C+EQB+aPbox2.0电气方案图.png
  45. 二進制
      docs/Hardware/Gate/assets/MSA_C/MSA-C+EQB电气方案图.png
  46. 二進制
      docs/Hardware/Gate/assets/MSA_C/MSA-C+aPbox2.0典型应用系统图.png
  47. 二進制
      docs/Hardware/Gate/assets/MSA_C/MSA-C+aPbox2.0系统架构图.jpg
  48. 二進制
      docs/Hardware/Gate/assets/MSA_C/MSA-C+aPbox2.0系统架构图.png
  49. 二進制
      docs/Hardware/Gate/assets/MSA_C/MSA-C+aPbox2.0系统组成示意图.png
  50. 二進制
      docs/Hardware/Gate/assets/MSA_C/MSA-C不连接MSA电气方案图.png
  51. 二進制
      docs/Hardware/Gate/assets/aGate_1.0/aGate1.0-EMS接口原理图.png
  52. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1-EMS接口原理图.png
  53. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1光伏CT采样原理图.png
  54. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1内门图.png
  55. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1内门开孔图.png
  56. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1外门内侧图.png
  57. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1市电CT采样原理图.png
  58. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1继电器盒开孔图.png
  59. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1铜牌加宽图.png
  60. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3光伏CT采样原理图.png
  61. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3内门图.png
  62. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3外门内侧图.png
  63. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3市电CT采样原理图.png
  64. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aPower电流100A铭牌.png
  65. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aPower电流80A铭牌.png
  66. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开100A丝印.png
  67. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开100A标签.png
  68. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开125A丝印.png
  69. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开125A手册变更1.png
  70. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开125A手册变更2.png
  71. 二進制
      docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开125A标签.png
  72. 二進制
      docs/Hardware/Gate/assets/aGate_1.3/aGate1.3-EMS接口原理图.png
  73. 二進制
      docs/Hardware/Gate/assets/public/aGate1.3-EMS接口图.jpg
  74. 21 0
      docs/Hardware/Gate/index.md
  75. 0 0
      docs/Hardware/Other/GeneratorModule.md
  76. 0 0
      docs/Hardware/Other/MeterKit.md
  77. 0 0
      docs/Hardware/Other/aPbox_1.0.md
  78. 0 0
      docs/Hardware/Other/aPbox_2.0.md
  79. 二進制
      docs/Hardware/Other/assets/aPbox_2.0/aPbox2.0整体布局示意图.png
  80. 二進制
      docs/Hardware/Other/assets/aPbox_2.0/aPbox2.0电气原理示意图.png
  81. 二進制
      docs/Hardware/Other/assets/aPbox_2.0/aPbox2.0硬件架构图.png
  82. 二進制
      docs/Hardware/Other/assets/aPbox_2.0/aPbox2.0造型设计图.png
  83. 17 0
      docs/Hardware/Other/index.md
  84. 7 0
      docs/Hardware/Power/aPower_1.0.md
  85. 7 0
      docs/Hardware/Power/aPower_1.2.md
  86. 7 0
      docs/Hardware/Power/aPower_1.3.md
  87. 13 0
      docs/Hardware/Power/aPower_2.md
  88. 7 0
      docs/Hardware/Power/aPower_S.md
  89. 19 0
      docs/Hardware/Power/index.md
  90. 41 0
      docs/Hardware/index.md
  91. 7 0
      docs/Other/TariffScheduleTemplates.md
  92. 4 0
      docs/Software/BatteryHeating.md
  93. 4 0
      docs/Software/BlackStart.md
  94. 17 0
      docs/Software/ModeManagement.md
  95. 366 0
      docs/Software/PCS.md
  96. 23 0
      docs/Software/PV_FreqWatt.md
  97. 284 0
      docs/Software/ScriptingForSchedulingConfiguration.md
  98. 4 0
      docs/Software/SocBalance.md
  99. 二進制
      docs/Software/assets/PCS/GridImportAndExport.png
  100. 二進制
      docs/Software/assets/PCS/PartialBackupSchematic-OnlyUseSplitCT-1.0.png

+ 3 - 0
.env.waline

@@ -0,0 +1,3 @@
+SECURE_DOMAINS=raspberrypi.dev,192.168.25.186
+SQLITE_PATH=./waline/db/
+JWT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkYXRhIjpbeyJ1cmwiOiJodHRwczovL3Jhc3BiZXJyeXBpIn1dLCJpYXQiOjE3NDU0NzU1NjYsImV4cCI6NDA5OTY1MTE5OSwiYXVkIjoiIiwiaXNzIjoiIiwic3ViIjoiIn0.7vpJUdEU4w2-8PtDdyfhNFZsuUXb7aIT_YbzYp_DWzw

+ 25 - 0
.gitignore

@@ -0,0 +1,25 @@
+.vscode
+node_modules
+cache
+dist
+temp
+
+# vitepress
+/packages/vitepress/coverage
+/packages/vitepress/src/client/shared.ts
+/packages/vitepress/src/node/shared.ts
+*.log
+*.tgz
+.DS_Store
+.idea
+.temp
+.vite_opt_cache
+.vscode
+dist
+cache
+temp
+examples-temp
+node_modules
+pnpm-global
+TODOs.md
+*.timestamp-*.mjs

+ 69 - 0
check-build-vitepress.ts

@@ -0,0 +1,69 @@
+#!/usr/bin/env ts-node
+
+import fs from 'fs';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { execSync } from 'child_process';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const pkgName = 'vitepress'; // 本地包名
+const workspaceRoot = path.resolve(__dirname, '.');
+const pkgPath = path.resolve(workspaceRoot, 'packages', pkgName);
+const distPath = path.resolve(pkgPath, 'dist');
+const srcPath = path.resolve(pkgPath, 'src');
+
+// 遍历目录文件的修改时间
+function getAllTsFileMtimeMs(dirPath) {
+  const results: number[] = [];
+
+  function walk(currentPath) {
+    const entries = fs.readdirSync(currentPath, { withFileTypes: true });
+    for (const entry of entries) {
+      const fullPath = path.join(currentPath, entry.name);
+      if (entry.isDirectory()) {
+        walk(fullPath); // 递归处理子目录
+      } else if (entry.isFile() && entry.name.endsWith('.ts')) {
+        const stat = fs.statSync(fullPath);
+        results.push(stat.mtimeMs);
+      }
+    }
+  }
+
+  walk(dirPath);
+  return results;
+}
+
+// 检查 dist 是否存在
+function hasBuilt(): boolean {
+  return fs.existsSync(distPath);
+}
+
+// 检查 src 是否更新过
+function isSourceNewerThanDist(): boolean {
+  if (!fs.existsSync(distPath)) return true;
+
+  const distMtime = fs.statSync(distPath).mtimeMs;
+
+  const srcFiles = getAllTsFileMtimeMs(srcPath)
+
+  console.log("distMtime: ", distMtime)
+  console.log("srcFiles: ", srcFiles)
+
+  return Math.max(...srcFiles) > distMtime;
+}
+
+function buildPackage() {
+  console.log(`[vitepress] 正在构建本地包 "${pkgName}"...`);
+  execSync(`pnpm --filter ${pkgName} build`, { stdio: 'inherit' });
+}
+
+// 主流程
+(function main() {
+  if (!hasBuilt() || isSourceNewerThanDist()) {
+    buildPackage();
+  } else {
+    console.log(`[vitepress] 构建已是最新,跳过构建`);
+  }
+})();

+ 180 - 0
docs/.vitepress/config.mts

@@ -0,0 +1,180 @@
+import { defineConfig } from 'vitepress'
+import menuConfig from './menuConfig'
+import vueJsx from '@vitejs/plugin-vue-jsx'
+import veaury from 'veaury/vite';
+import { withMermaid } from 'vitepress-plugin-mermaid'
+import AuthMid from './plugins/casdoor/mid'
+import SseMid from './plugins/SSE/server'
+
+const fileAndStyles: Record<string, string> = {}
+
+const BaseUrl = '/fwhwiki/'
+process.env.BASE_URL = BaseUrl
+const MalinePrefix = `${BaseUrl}waline`
+
+// https://vitepress.dev/reference/site-config
+export default defineConfig(
+  withMermaid({
+    base: BaseUrl,
+    lang: 'zh-CN',
+    title: 'FranklinWH Wiki',
+    description: 'FranklinWH Wiki',
+    head: [
+      ['link', { rel: 'icon', type: 'image/svg+xml', href: BaseUrl + 'favicon.svg' }]
+    ],
+    themeConfig: {
+      logo: {
+        src: '/F_logo.svg',
+        width: 24,
+        height: 24
+      },
+      // https://vitepress.dev/reference/default-theme-config
+      ...menuConfig,
+      outline: {
+        level: 'deep',
+        label: '页面导航'
+      },
+
+      search: {
+        provider: 'local',
+        options: {
+          translations: {
+            button: {
+              buttonText: '搜索文档',
+              buttonAriaLabel: '搜索文档'
+            },
+            modal: {
+              noResultsText: '无法找到相关结果',
+              resetButtonTitle: '清除查询条件',
+              footer: {
+                selectText: '选择',
+                navigateText: '切换',
+                closeText: '关闭'
+              }
+            }
+          },
+          locales: {
+            zh: {
+              translations: {
+                button: {
+                  buttonText: '搜索文档',
+                  buttonAriaLabel: '搜索文档'
+                },
+                modal: {
+                  noResultsText: '无法找到相关结果',
+                  resetButtonTitle: '清除查询条件',
+                  footer: {
+                    selectText: '选择',
+                    navigateText: '切换'
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    markdown: {
+      // markdown-it-anchor 的选项
+      // https://github.com/valeriangalliat/markdown-it-anchor#usage
+      container: {
+        tipLabel: ':bell: 提示',
+        warningLabel: ':warning: 警告',
+        dangerLabel: ':no_entry: 危险',
+        infoLabel: ':receipt: 信息',
+        detailsLabel: '详细信息'
+      },
+      lineNumbers: true, // 代码块启用行号
+      math: true, // 启用数学方程
+      image: {
+        lazyLoading: true // 为所有图片启用懒加载
+      }
+    },
+    // optionally, you can pass MermaidConfig
+    mermaid: {
+      // refer https://mermaid.js.org/config/setup/modules/mermaidAPI.html#mermaidapi-configuration-defaults for options
+    },
+    // optionally set additional config for plugin itself with MermaidPluginConfig
+    mermaidPlugin: {
+      class: "mermaid my-class", // set additional css classes for parent container 
+    },
+
+    // vite config
+    vite: {
+      server: {
+        host: '0.0.0.0',
+        port: 8361,
+        allowedHosts: [
+          'raspberrypi',      // 允许来自 raspberrypi 主机的访问
+          'raspberrypi.dev',      // 允许来自 raspberrypi.dev 主机的访问
+          'localhost'         // 允许 localhost
+        ],
+        // allowedHosts: true, // 允许任何请求
+        proxy: {
+          [MalinePrefix]: {
+            target: 'http://localhost:8360', // 后端Waline服务地址
+            changeOrigin: true,
+            rewrite: (path) => path.replace(new RegExp(`^${MalinePrefix}`), ''),
+            // configure: (proxy, options) => {
+            //   proxy.on('proxyReq', (proxyReq, req) => {
+            //     console.log(`[proxyReq] ${req.method} ${req.url}`)
+            //   })
+            // }
+          }
+        }
+      },
+      plugins: [
+        // veaury({
+        //   type: 'vue',
+        // }),
+        vueJsx(),
+        {
+          name: "middleware",
+          configureServer(server) {
+            server.middlewares
+              .use(SseMid)
+              .use(AuthMid);
+          }
+        }
+      ],
+      ssr: {
+        noExternal: ['naive-ui', 'date-fns', 'vueuc', 'vitepress'] // SSR 时强制编译
+      },
+      resolve: {
+        alias: {
+          // 'vitepress': resolve(__dirname, '../../packages/vitepress/src/client'),
+          // 'dayjs/dayjs.min.js': 'dayjs/esm/index.js',
+          // 'dayjs': 'dayjs/esm/index.js',
+        },
+      },
+      optimizeDeps: {
+        exclude: ['vitepress'], // 避免预构建
+      },
+      // Add this define to fix process.env error in Excalidraw
+      // https://docs.excalidraw.com/docs/@excalidraw/excalidraw/faq#referenceerror-process-is-not-defined
+      define: {
+        'process.env.IS_PREACT': JSON.stringify('true'),
+      }
+    },
+    postRender(context) {
+      const styleRegex = /<css-render-style>((.|\s)+)<\/css-render-style>/
+      const vitepressPathRegex = /<vitepress-path>(.+)<\/vitepress-path>/
+      const style = styleRegex.exec(context.content)?.[1]
+      const vitepressPath = vitepressPathRegex.exec(context.content)?.[1]
+      if (vitepressPath && style) {
+        fileAndStyles[vitepressPath] = style
+      }
+      context.content = context.content.replace(styleRegex, '')
+      context.content = context.content.replace(vitepressPathRegex, '')
+    },
+    transformHtml(code, id) {
+      const html = id.split('/').pop()
+      if (!html)
+        return
+      const style = fileAndStyles[`/${html}`]
+      if (style) {
+        return code.replace(/<\/head>/, `${style}</head>`)
+      }
+    }
+  })
+)

+ 280 - 0
docs/.vitepress/menuConfig.ts

@@ -0,0 +1,280 @@
+import type { DefaultTheme } from 'vitepress/theme'
+
+interface Page {
+  title: string;
+  path: string;
+  child?: Page[];
+  collapsed?: boolean, // 添加折叠,false:显示,true:隐藏
+}
+
+const menu : Page[] = [
+  {
+    title: '硬件产品',
+    path: '/Hardware',
+    child: [
+      {
+        title: 'Gate',
+        path: '/Gate',
+        collapsed: false,
+        child: [
+          {
+            title: 'aGate 1.0/1.1',
+            path: '/aGate_1.0.md'
+          },
+          {
+            title: 'aGate 1.2',
+            path: '/aGate_1.2.md'
+          },
+          {
+            title: 'aGate 1.3',
+            path: '/aGate_1.3.md'
+          },
+          {
+            title: 'aGate 1.3.1',
+            path: '/aGate_1.3.1.md'
+          },
+          {
+            title: 'MSA-C',
+            path: '/MSA_C.md'
+          },
+          {
+            title: 'MSA-C2',
+            path: '/MSA_C2.md'
+          }
+        ]
+      },
+      {
+        title: 'Power',
+        path: '/Power',
+        collapsed: false,
+        child: [
+          {
+            title: 'aPower 1.0',
+            path: '/aPower_1.0.md'
+          },
+          {
+            title: 'aPower 1.2',
+            path: '/aPower_1.2.md'
+          },
+          {
+            title: 'aPower 1.3',
+            path: '/aPower_1.3.md'
+          },
+          {
+            title: 'aPower 2',
+            path: '/aPower_2.md'
+          },
+          {
+            title: 'aPower S',
+            path: '/aPower_S.md'
+          }
+        ]
+      },
+      {
+        title: '配件',
+        path: '/Other',
+        collapsed: false,
+        child: [
+          {
+            title: 'aPbox 1.0',
+            path: '/aPbox_1.0.md'
+          },
+          {
+            title: 'aPbox 2.0',
+            path: '/aPbox_2.0.md'
+          },
+          {
+            title: 'Meter Kit',
+            path: '/MeterKit.md'
+          },
+          {
+            title: 'Generator Module',
+            path: '/GeneratorModule.md'
+          }
+        ]
+      }
+    ]
+  },
+  {
+    title: '软件功能',
+    path: '/Software',
+    child: [
+      {
+        title: '模式管理',
+        path: '/ModeManagement.md'
+      },
+      {
+        title: 'PCS',
+        path: '/PCS.md'
+      },
+      {
+        title: '黑启动',
+        path: '/BlackStart.md'
+      },
+      {
+        title: '光伏调频',
+        path: '/PV_FreqWatt.md'
+      },
+      {
+        title: '电池加热',
+        path: '/BatteryHeating.md'
+      },
+      {
+        title: 'SOC均衡',
+        path: '/SocBalance.md'
+      },
+      {
+        title: '调度配置脚本化',
+        path: '/ScriptingForSchedulingConfiguration.md'
+      }
+    ]
+  },
+  {
+    title: '系统',
+    path: '/System',
+    child: [
+      {
+        title: '系统搭配',
+        path: '/SystemConfig.md'
+      },
+      {
+        title: '系统配电',
+        path: '/SystemDistribution.md'
+      }
+    ]
+  },
+  {
+    title: '其他',
+    path: '/Other',
+    child: [
+      {
+        title: '资费套餐模版',
+        path: '/TariffScheduleTemplates.md'
+      }
+    ]
+  }
+]
+
+type NavItem = DefaultTheme.NavItem
+type ChildNavItem = (DefaultTheme.NavItemComponent | DefaultTheme.NavItemChildren | DefaultTheme.NavItemWithLink)
+type SubChildNavItem = DefaultTheme.NavItemWithLink
+type SidebarItem = DefaultTheme.SidebarItem
+type SidebarMulti = DefaultTheme.SidebarMulti
+
+interface MenuConfig {
+  nav?: NavItem[],
+  sidebar?: SidebarMulti
+}
+
+// 遍历导航栏,items最大深度3
+function traverseNavigation(pages: Page[]): NavItem[] | undefined {
+  const navItem = pages.map<NavItem>(item => {
+    if (item.child && item.child.length > 0) {
+      const parentPath = item.path
+      const childNavItem = item.child.map<ChildNavItem>(childItem => {
+        if (childItem.child && childItem.child.length > 0) {
+          const childPath = parentPath + childItem.path
+          const subChildItem = childItem.child.map<SubChildNavItem>(subChildItem => {
+            return {
+              text: subChildItem.title,
+              link: childPath + subChildItem.path
+            }
+          })
+          return {
+            text: childItem.title,
+            items: subChildItem
+          }
+        } else {
+          return {
+            text: childItem.title,
+            link: parentPath + childItem.path
+          }
+        }
+      })
+      return {
+        text: item.title,
+        items: childNavItem
+      }
+    } else {
+      return {
+        text: item.title,
+        link: item.path
+      }
+    }
+  })
+
+  if (navItem.length === 0) {
+    return undefined
+  } else {
+    return navItem
+  }
+}
+
+function traverseSidebar(path: string, pages: Page[]): SidebarItem[] {
+  let config : SidebarItem[] = []
+  for (const page of pages) {
+    // 将当前页面加入配置中
+    
+    if (page.child && page.child.length > 0) {
+      const childConfig = traverseSidebar(path + page.path, page.child); // 递归遍历子页面
+      config.push({
+        text: page.title,
+        link: path + page.path,
+        collapsed: page.collapsed,
+        items: childConfig
+      })
+    } else {
+      config.push({
+        text: page.title,
+        link: path + page.path,
+        collapsed: page.collapsed
+      })
+    }
+  }
+
+  return config
+}
+
+function traverseMainSidebar(pages: Page[]): SidebarMulti {
+  let config : SidebarMulti = {}
+  for (const page of pages) {
+    // 将当前页面加入配置中
+    
+    if (page.child && page.child.length > 0) {
+      const childConfig = traverseSidebar("", page.child); // 递归遍历子页面
+      config[page.path] = {
+        base: page.path,
+        items: [
+          {
+            text: page.title,
+            items: childConfig
+          }
+        ]
+      }
+    } else {
+      config[page.path] = {
+        base: page.path,
+        items: [
+          {
+            text: page.title
+          }
+        ]
+      }
+    }
+  }
+
+  return config
+}
+
+const config : MenuConfig = {
+  nav: traverseNavigation(menu),
+  // sidebar: [
+  //   {
+  //     text: '目录',
+  //     items: traverseSidebar('', menu)
+  //   }
+  // ]
+  sidebar: traverseMainSidebar(menu)
+}
+
+export default config

+ 37 - 0
docs/.vitepress/plugins/SSE/client.ts

@@ -0,0 +1,37 @@
+import { setOriginalUrl } from './stateMap'
+
+export default class EventClient {
+  private source : EventSource | undefined = undefined
+  private readonly signinUrl: string
+  constructor(signinUrl: string) {
+    this.signinUrl = signinUrl
+    this.source = new EventSource((import.meta.env.BASE_URL || '/') + 'sse')
+    this.source.onmessage = (e: MessageEvent) => {
+      this.handleMsg(JSON.parse(e.data))
+    }
+    this.source.onerror = () => {
+      this.source?.close()
+      this.source = undefined
+      console.warn('SSE 连接关闭或出错')
+    }
+  }
+
+  close() {
+    this.source?.close()
+    this.source = undefined
+  }
+
+  handleMsg(data: any) {
+    if(data.type === 'command' && data.value === 'Re-login') {
+      // 跳转前,保存原始地址
+      const urlParam = new URLSearchParams(this.signinUrl)
+      const state = urlParam.get('state')
+      if (state) {
+        setOriginalUrl(state, window.location.href)
+      }
+      
+      // 重新登录,跳转到登录页面
+      window.location.href = this.signinUrl.replace(/^https?:\/\/[^/]+/, '')
+    }
+  }
+}

+ 49 - 0
docs/.vitepress/plugins/SSE/fifo.ts

@@ -0,0 +1,49 @@
+export class FIFOBuffer<T> {
+  private buffer: T[] = [];
+  private readonly maxSize: number;
+
+  constructor(maxSize: number) {
+    if (maxSize <= 0) {
+      throw new Error("Buffer size must be greater than 0");
+    }
+    this.maxSize = maxSize;
+  }
+
+  // 添加一个元素(如果超出大小则移除最旧的)
+  enqueue(item: T): void {
+    if (this.buffer.length >= this.maxSize) {
+      this.buffer.shift(); // 移除最早的元素
+    }
+    this.buffer.push(item);
+  }
+
+  // 移除并返回最早的元素
+  dequeue(): T | undefined {
+    return this.buffer.shift();
+  }
+
+  // 查看最早的元素但不移除
+  peek(): T | undefined {
+    return this.buffer[0];
+  }
+
+  // 获取当前缓冲区大小
+  size(): number {
+    return this.buffer.length;
+  }
+
+  // 是否为空
+  isEmpty(): boolean {
+    return this.buffer.length === 0;
+  }
+
+  // 清空缓冲器
+  clear(): void {
+    this.buffer = [];
+  }
+
+  // 查看当前所有元素(按顺序)
+  toArray(): T[] {
+    return [...this.buffer];
+  }
+}

+ 2 - 0
docs/.vitepress/plugins/SSE/index.ts

@@ -0,0 +1,2 @@
+export * as Client from './client'
+export * as Server from './server'

+ 78 - 0
docs/.vitepress/plugins/SSE/server.ts

@@ -0,0 +1,78 @@
+import Connect from 'connect'
+import { ServerResponse } from 'node:http';
+import { parse } from 'cookie'
+
+interface Massage {
+  type: string;
+  value: string;
+  time: number;
+}
+
+let sessionList: {[session: string]: Set<ServerResponse<Connect.IncomingMessage>>} = {}
+
+export default async function SseMid(req: Connect.IncomingMessage, res: ServerResponse<Connect.IncomingMessage>, next: Connect.NextFunction) {
+  const BaseUrl = process.env.BASE_URL
+
+  // 检查session_id
+  let session_id: string | undefined = undefined
+  const cookies = parse(req.headers.cookie ?? '')
+  if (!cookies.session_id) {
+    session_id = crypto.randomUUID()
+    res.setHeader('Set-Cookie', `session_id=${session_id}; HttpOnly; Path=/`)
+  } else {
+    session_id = cookies.session_id
+  }
+
+  // 处理SSE
+  if (req.url === BaseUrl + 'sse') {
+    res.writeHead(200, {
+      'Content-Type': 'text/event-stream',
+      'Cache-Control': 'no-cache',
+      Connection: 'keep-alive',
+    })
+  
+    const interval = setInterval(() => {
+      const msg = {
+        type: 'pending',
+        value: session_id,
+        time: Date.now()
+      }
+      res.write(`data: ${JSON.stringify(msg)}\n\n`)
+    }, 60 * 1000)
+  
+    req.on('close', () => {
+      clearInterval(interval)
+      res.end()
+      if (sessionList[session_id]) {
+        sessionList[session_id].delete(res)
+      }
+    })
+
+    // 插入res
+    if (!sessionList[session_id]) {
+      sessionList[session_id] = new Set<ServerResponse<Connect.IncomingMessage>>
+    }
+    sessionList[session_id].add(res)
+
+    return
+  }
+
+  // 继续执行下一个中间件
+  next();
+}
+
+export function sendReLogin(session_id: string): boolean {
+  if (!sessionList[session_id]) return false
+
+  console.log("send relogin: ", session_id)
+  sessionList[session_id].forEach(res => {
+    const msg: Massage = {
+      type: 'command',
+      value: 'Re-login',
+      time: Date.now()
+    }
+    res.write(`data: ${JSON.stringify(msg)}\n\n`)
+  });
+
+  return true
+}

+ 9 - 0
docs/.vitepress/plugins/SSE/stateMap.ts

@@ -0,0 +1,9 @@
+export function setOriginalUrl(state: string, url: string) {
+  sessionStorage.setItem(state, url);
+}
+
+export function getOriginalUrl(state: string): string | null {
+  const url = sessionStorage.getItem(state);
+  sessionStorage.removeItem(state);
+  return url
+}

+ 38 - 0
docs/.vitepress/plugins/casdoor/config.ts

@@ -0,0 +1,38 @@
+export const ServerUrl = "https://raspberrypi.dev/casdoor"
+export const ClientId = "37ded064c1576151ccdd"
+export const SecretKey = "0212a4cf97f1a78595f77ad3190f8542e31273e3"
+// cert-built-in
+export const Certificate = `
+-----BEGIN CERTIFICATE-----
+MIIE3TCCAsWgAwIBAgIDAeJAMA0GCSqGSIb3DQEBCwUAMCgxDjAMBgNVBAoTBWFk
+bWluMRYwFAYDVQQDEw1jZXJ0LWJ1aWx0LWluMB4XDTI1MDUwNzA5NDMwN1oXDTQ1
+MDUwNzA5NDMwN1owKDEOMAwGA1UEChMFYWRtaW4xFjAUBgNVBAMTDWNlcnQtYnVp
+bHQtaW4wggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC6bhrTnADekQLx
+KtKiOqehyevrHzXKvBykfjwO6k0oLV9VSMntki2FNASDYTs3eFtG7wqPJoWJuDPC
++XISWIwKc0Bc8trIeQkSiJOvLWF57wOX0HlxvGd6ooRrPe6yAHjMcMtNP5s+DzpO
+qfOIOCH9oOFBxHN02VIRoo+ACflI9mkS6TRIAJ8fG5L631qulyYwyq3TJ6vaBxte
+1ZWV0Fh72VD3FaME9pmHdv3IcnX6U2pvwZoN+jSOiaTSCI15+5PFsC3av1k5ZJTH
++Yp7okwVvQRJbwOwM4Lr3eQE7tcUVxLbstVKwhsR2EqtVHRlt+r0SUE72aASaM3m
+iWZS8lKJIJxbSRgtf3zOkpZR4zIBU3zdB9OoHykxLlbg4POOLloWMsdVflXbWzRt
+Abwo5SSsMq+Yr7+E4EB6ricXz1OvUPELEZa1c6QhTUzeBhrV5ro/QNNN32HCFgu4
+ivLxijF7FFGdxFykUArWxEd+d5+lCuHn8lDxYGBvp65G36f8knyOtN4K02cFoNtY
+5l+s9yqkyELfw8it0bkxO609KZd9Xkp3wUloBMdiJ7UxUNIVS0bpL7wkwt/CzPdM
+Bm/RAPzkjazKgireWZ0cKv3QfYu4+QC8E7QeXI9Oqm6YCSMPsGJBOM9arA7xZdUc
+JZnNstHWHrFihbY1nG6WQwT8ndMYpwIDAQABoxAwDjAMBgNVHRMBAf8EAjAAMA0G
+CSqGSIb3DQEBCwUAA4ICAQAri/3HicFl5pu3B2udfdVgxmgUmf1nvPFhuiV7hgIP
+5CVHe2IiDPJYGL55NNqYQpRLTE7Egnz6TMRiVuYKYBHLM2Tb133GpTvXntiwb+D/
+FSha4clBJBBHlx7lX8xGy+FIwlWQQzB/O8S1hbRi869Kb91QFIRgbRsW6Bt+0Y+9
+OtilWi5scqV28Lt2XyzxsQgsYAu0VOSxFs8Rk1WIvAAciefiPMGAlIpsm52c/0H+
+s1LqdPLoj+GF3/iMOd4EIRFGiH3ntdBqYjvf39w90uNn0uWBnVdyu8IsbNU92v84
+jdE7sTYMD74Q9rt9JH/dr2Z4ufjRSn6x7mRNxw3oPnMd5qdbB1ux8j5zrJAARRrQ
+QKju4s5SB+6WWjALhoMfO7N0wIqoKDleoaGxUj1r4AX9P+wRP0H1W2PV/H0y+el1
+gjpPCfs/IZGgWkSvpHI0b6yikTaCZeAgoBPB2sjTWZyeBnkXEM8BGgVjgaoFUwIl
+OgE9eIWpTKBvPYYellO+hB47j75BnIF7GImDBIwHvEtk7jw3psaLSYGGoNhcG5X9
+xJe+ghCvXu1cz5mvUxMIrUsbUL4zSCsJpkzQQQ+95ZJroGsl6dwewMY8hYhXo7wJ
+EikVj20dYSjAC2azqGic6fIDosE+qdT8zzCkmEOJQg42D1eRl+E2KmIThFpIyNp/
+Vw==
+-----END CERTIFICATE-----
+`
+export const AppName = "app-franklinwh-wiki"
+export const OrganizationName = "organization-franklinwh"
+export const RedirectPath = "/fwhwiki/casdoor/callback"

+ 202 - 0
docs/.vitepress/plugins/casdoor/mid.ts

@@ -0,0 +1,202 @@
+import Connect from 'connect';
+import { ServerResponse } from 'node:http';
+import https from 'https';
+import { parse as parseCookie } from 'cookie';
+import { sendReLogin } from '../SSE/server';
+
+import casdoorServer from 'casdoor-nodejs-sdk';
+import { 
+  ServerUrl, 
+  ClientId, 
+  SecretKey, 
+  Certificate, 
+  AppName, 
+  OrganizationName
+} from './config';
+
+const sdk = new casdoorServer.SDK({
+  endpoint: ServerUrl,
+  clientId: ClientId,
+  clientSecret: SecretKey,
+  certificate: Certificate,
+  orgName: OrganizationName,
+  appName: AppName
+}, {
+  httpsAgent: new https.Agent({ rejectUnauthorized: false })
+})
+
+// 未授权响应
+function resUnauthorized(res: ServerResponse<Connect.IncomingMessage>, cookies?: Record<string, string | undefined>) {
+  res.statusCode = 401; // Unauthorized
+  res.setHeader('Content-Type', 'text/plain')
+  res.end('Unauthorized')
+
+  if (cookies) {
+    const session_id = cookies['session_id']
+    if (session_id) {
+      // 重新登录
+      setTimeout(() => {
+        sendReLogin(session_id);
+      }, 3000);
+    }
+  }
+}
+
+// 处理登录请求
+async function handleSignin(url: URL): Promise<{ access_token: string; refresh_token: string; } | undefined> {
+  const code = url.searchParams.get('code');
+  const state = url.searchParams.get('state');
+  if (code && state) {
+    const token = await sdk.getAuthToken(code);
+    return token
+  }
+
+  return undefined
+}
+
+// 验证Token
+function verifyToken(token: string): boolean {
+  try {
+    sdk.parseJwtToken(token);
+    return true;
+  } catch (err) {
+    console.error("token verify failed.");
+    console.error(err);
+  }
+
+  return false;
+}
+
+// 刷新Token
+async function refreshToken(refresh_token: string) {
+  const tokenGroup = await sdk.refreshToken(refresh_token).catch(err => {
+    console.error("refresh token failed.")
+    console.error(err)
+    return undefined
+  })
+
+  return tokenGroup;
+}
+
+export default async function AuthMid(req: Connect.IncomingMessage, res: ServerResponse<Connect.IncomingMessage>, next: Connect.NextFunction) {
+  const BaseUrl = process.env.BASE_URL
+
+  if (req.url) {
+    const url = new URL(req.url, "https://raspberrypi.dev");
+
+    // 请求保护资源,验证JWT
+    if (!url.pathname.startsWith(BaseUrl + 'casdoor') && url.pathname.endsWith('.md')) {
+      console.log("verify JWT")
+      const cookieStr = req.headers.cookie
+      if (!cookieStr) {
+        resUnauthorized(res);
+        return
+      } else {
+        const cookies = parseCookie(cookieStr);
+        console.log(cookies)
+        const token = cookies['token'];
+        const refresh_token = cookies['refresh_token'];
+
+        // 验证Token
+        if (token && verifyToken(token)) {
+          // 验证成功
+          next();
+          return
+        }
+
+        // 验证刷新Token
+        if (refresh_token && verifyToken(refresh_token)) {
+          // 刷新Token
+          const tokenGroup = await refreshToken(refresh_token);
+          if (tokenGroup) {
+            // 刷新成功,设置token
+            res.setHeader('Set-Cookie', [
+              `token=${tokenGroup.access_token}; HttpOnly; Path=/; Max-Age=86400; SameSite=Strict`,
+              `refresh_token=${tokenGroup.refresh_token}; HttpOnly; Path=/; SameSite=Strict`
+            ]);
+            next();
+            return
+          }
+        }
+
+        resUnauthorized(res, cookies);
+        return
+      }
+    }
+
+    // 处理登录查询
+    if (url.pathname.startsWith(BaseUrl + 'api/islogin')) {
+      console.log("islogin")
+      const cookieStr = req.headers.cookie
+      if (cookieStr) {
+        const cookies = parseCookie(cookieStr);
+        const token = cookies['token'];
+        const refresh_token = cookies['refresh_token'];
+
+        // 验证Token
+        if (token && verifyToken(token)) {
+          // 验证成功
+          res.statusCode = 200;
+          res.setHeader('Content-Type', 'text/plain')
+          res.end(token)
+          return
+        }
+
+        // 验证刷新Token
+        if (refresh_token && verifyToken(refresh_token)) {
+          // 刷新Token
+          const tokenGroup = await refreshToken(refresh_token);
+          if (tokenGroup) {
+            // 刷新成功,设置token
+            res.setHeader('Set-Cookie', [
+              `token=${tokenGroup.access_token}; HttpOnly; Path=/; Max-Age=86400; SameSite=Strict`,
+              `refresh_token=${tokenGroup.refresh_token}; HttpOnly; Path=/; SameSite=Strict`
+            ]);
+            res.statusCode = 200;
+            res.setHeader('Content-Type', 'text/plain')
+            res.end(token)
+            return
+          }
+        }
+      }
+      resUnauthorized(res);
+      return
+    }
+
+    // 处理登录请求
+    if (url.pathname.startsWith(BaseUrl + 'api/signin')) {
+      console.log("handleSignin")
+      const signinToken = await handleSignin(url).catch((err) => {
+        console.log("handleSignin: ", err)
+      });
+      console.log("signinToken: ", signinToken)
+      if (signinToken) {
+        res.setHeader('Set-Cookie', [
+          `token=${signinToken.access_token}; HttpOnly; Path=/; Max-Age=86400; SameSite=Strict`,
+          `refresh_token=${signinToken.refresh_token}; HttpOnly; Path=/; SameSite=Strict`
+        ])
+        res.statusCode = 200;
+        res.setHeader('Content-Type', 'application/json')
+        res.end(JSON.stringify(signinToken))
+        return
+      }
+    }
+
+    // 处理登录请求
+    if (url.pathname.startsWith(BaseUrl + 'api/exit')) {
+      console.log("exit")
+      // 清除token
+      res.setHeader('Set-Cookie', [
+        `token=; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Strict`,
+        `refresh_token=; HttpOnly; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Strict`
+      ])
+      res.statusCode = 200;
+      res.setHeader('Content-Type', 'text/plain')
+      res.end("Exit successful.")
+      return
+    }
+  }
+  
+  // 继续执行下一个中间件
+  next();
+} 

+ 18 - 0
docs/.vitepress/plugins/waline/index.ts

@@ -0,0 +1,18 @@
+import userMeta from './userMeta';
+import { jwtDecode } from "jwt-decode";
+import { User } from 'casdoor-nodejs-sdk/lib/cjs/user'
+
+export const setWalineUserInfo = (nick: string, mail: string, link: string) => {
+  userMeta.value.nick = nick;
+  userMeta.value.mail = mail;
+  userMeta.value.link = link;
+}
+
+export const setWalineUserInfoFromToken = (token: string) => {
+  const payload = jwtDecode<User>(token);
+  setWalineUserInfo(
+    payload.displayName ?? "",
+    payload.email ?? "",
+    ""
+  )
+}

+ 18 - 0
docs/.vitepress/plugins/waline/userMeta.ts

@@ -0,0 +1,18 @@
+import type { RemovableRef } from '@vueuse/core';
+import { useStorage } from '@vueuse/core';
+
+export interface UserMeta {
+  nick: string;
+  mail: string;
+  link: string;
+}
+
+const useUserMeta = (): RemovableRef<UserMeta> =>
+  useStorage<UserMeta>('WALINE_USER_META', {
+    nick: '',
+    mail: '',
+    link: '',
+  });
+
+const UserMeta = useUserMeta();
+export default UserMeta;

+ 56 - 0
docs/.vitepress/script/crypto/decrypt_pdf.ts

@@ -0,0 +1,56 @@
+// scripts/decrypt_pdf.ts
+import * as fs from 'fs'
+import * as path from 'path'
+import * as crypto from 'crypto'
+
+import { fileURLToPath } from 'url'
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const PDF_DIR = path.resolve(__dirname, '../../public/pdf')
+
+function getKey(password: string): Buffer {
+  return crypto.createHash('sha256').update(password).digest()
+}
+
+function decryptGCM(inputPath: string, outputPath: string): void {
+  const input = fs.readFileSync(inputPath)
+
+  const iv = input.subarray(0, 12)
+  const lenBuf = input.subarray(input.length - 4)
+  const pLen = lenBuf.readUint32BE()
+  const passwordBuffer = input.subarray(input.length - pLen - 4, input.length - 4)
+  const password = passwordBuffer.toString('base64')
+  const authTag = input.subarray(input.length - pLen - 4 - 16, input.length - pLen - 4)
+  const encrypted = input.subarray(12, input.length - pLen - 4 - 16)
+
+  const key = getKey(password)
+
+  const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
+  decipher.setAuthTag(authTag)
+
+  const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
+
+  fs.writeFileSync(outputPath, Buffer.from(decrypted))
+
+  console.log(`✔ Decrypted (GCM): ${path.basename(inputPath)}`)
+}
+
+function decryptAllPDFs(): void {
+  const files = fs.readdirSync(PDF_DIR)
+  for (const file of files) {
+    const fullPath = path.join(PDF_DIR, file)
+    if (!fs.statSync(fullPath).isFile() || !file.endsWith('.enc')) continue
+
+    const decPath = fullPath + '.dec'
+    if (fs.existsSync(decPath)) {
+      console.log(`⏭ Skipped (already decrypted): ${file}`)
+      continue
+    }
+
+    decryptGCM(fullPath, decPath)
+  }
+}
+
+decryptAllPDFs()

+ 114 - 0
docs/.vitepress/script/crypto/decrypt_pdf_wed.ts

@@ -0,0 +1,114 @@
+function arrayBufferToBase64(buffer: ArrayBuffer): string {
+  let binary = '';
+  const bytes = new Uint8Array(buffer);
+  const len = bytes.byteLength;
+
+  for (let i = 0; i < len; i++) {
+    binary += String.fromCharCode(bytes[i]);
+  }
+
+  return btoa(binary);
+}
+
+async function getKey(password: string): Promise<ArrayBuffer> {
+  const encoder = new TextEncoder()
+  const data = encoder.encode(password)
+  const hash = await crypto.subtle.digest('SHA-256', data)
+  return hash
+}
+
+function readUint32BE(buffer: ArrayBuffer, offset: number = 0): number {
+  const view = new DataView(buffer);
+  return view.getUint32(offset, false); // false 表示使用大端(Big Endian)
+}
+
+/**
+ * Web端 AES-256-GCM 解密
+ * @param encrypted - 加密后的数据(不包含认证标签)
+ * @param keyBuffer - 原始密钥(32 字节)
+ * @param ivBuffer - 初始化向量(12 字节)
+ * @param authTag - 认证标签(16 字节)
+ * @returns 解密后的明文字符串
+ */
+async function decryptAES_GCM(
+  encrypted: ArrayBuffer,
+  keyBuffer: ArrayBuffer,
+  ivBuffer: ArrayBuffer,
+  authTag: ArrayBuffer
+): Promise<ArrayBuffer> {
+  // 1. 导入密钥
+  const key = await crypto.subtle.importKey(
+    'raw',
+    keyBuffer,
+    'AES-GCM',
+    false,
+    ['decrypt']
+  );
+
+  // 2. 将认证标签附加到加密数据末尾
+  const combinedData = new Uint8Array(encrypted.byteLength + authTag.byteLength);
+  combinedData.set(new Uint8Array(encrypted), 0);
+  combinedData.set(new Uint8Array(authTag), encrypted.byteLength);
+
+  // 3. 解密操作
+  try {
+    const decrypted = await crypto.subtle.decrypt(
+      {
+        name: 'AES-GCM',
+        iv: ivBuffer,
+        tagLength: 128, // 默认的 GCM 标签长度
+      },
+      key,
+      combinedData.buffer
+    );
+
+    // 将解密后的数据转换为字符串
+    // return new TextDecoder().decode(decrypted);
+    return decrypted
+  } catch (error) {
+    console.error('Decryption failed:', error);
+    throw error;
+  }
+}
+
+async function decryptGCM(url: string): Promise<Uint8Array | undefined> {
+  const res = await fetch(url).catch((err) => {
+    console.error('Error during fetch:', err)
+    return undefined
+  })
+  if (!res) return undefined
+  const buffer = await res.arrayBuffer().catch((err) => {
+    console.error('Error during arrayBuffer:', err)
+    return undefined
+  })
+  if (!buffer) return undefined
+
+  const iv = buffer.slice(0, 12)
+  const lenBuf = buffer.slice(buffer.byteLength - 4)
+  const pLen = readUint32BE(lenBuf)
+  const passwordBuffer = buffer.slice(buffer.byteLength - pLen - 4, buffer.byteLength - 4)
+  const password = arrayBufferToBase64(passwordBuffer)
+  const authTag = buffer.slice(buffer.byteLength - pLen - 4 - 16, buffer.byteLength - pLen - 4)
+  const encrypted = buffer.slice(12, buffer.byteLength - pLen - 4 - 16)
+
+  const key = await getKey(password).catch((err) => {
+    console.error('Error during get key:', err)
+    return undefined
+  })
+  if (!key) return undefined
+
+  const decrypted = await decryptAES_GCM(encrypted, key, iv, authTag).catch((err) => {
+    console.error('Error during decrypt:', err)
+    return undefined
+  })
+  if (!decrypted) return undefined
+
+  return new Uint8Array(decrypted)
+}
+
+// 解密示例
+const decryptedData = await decryptGCM(withBase(props.fileUrl)).catch((err) => {
+  console.error('Error during decrypt GCM:', err)
+  return undefined
+})
+if (!decryptedData) return

+ 61 - 0
docs/.vitepress/script/crypto/encrypt_pdf.ts

@@ -0,0 +1,61 @@
+// scripts/encrypt_pdf.ts
+import * as fs from 'fs'
+import * as path from 'path'
+import * as crypto from 'crypto'
+
+import { fileURLToPath } from 'url'
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const PDF_DIR = path.resolve(__dirname, '../../public/pdf')
+
+// 生成一个256位(32字节)的密钥
+function generateKey(): Buffer {
+  return crypto.randomBytes(32) // 256 bits = 32 bytes
+}
+
+function getKey(password: string): Buffer {
+  return crypto.createHash('sha256').update(password).digest()
+}
+
+function encryptGCM(inputPath: string, outputPath: string, password: string): void {
+  const key = getKey(password)
+  const iv = crypto.randomBytes(12) // GCM 推荐的 IV 长度
+  console.log(`✔ iv: ${iv.toString('base64')}`)
+  const input = fs.readFileSync(inputPath)
+
+  const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
+  const encrypted = Buffer.concat([cipher.update(input), cipher.final()])
+  // console.log(`✔ encrypted: ${encrypted.toString('base64')}`)
+  const authTag = cipher.getAuthTag()
+  console.log(`✔ authTag: ${authTag.toString('base64')}`)
+
+  const passwordBuffer = Buffer.from(password, 'base64')
+  const passwordLen = passwordBuffer.length
+  const lenBuf = Buffer.alloc(4)
+  lenBuf.writeUInt32BE(passwordLen)
+  const output = Buffer.concat([iv, encrypted, authTag, passwordBuffer, lenBuf]) // 格式:[IV][DATA][TAG][PASSWORD][PASSWORD_LEN]
+  fs.writeFileSync(outputPath, output)
+
+  console.log(`✔ Encrypted (GCM): ${path.basename(inputPath)}`)
+}
+
+function encryptAllPDFs(): void {
+  const files = fs.readdirSync(PDF_DIR)
+  for (const file of files) {
+    const fullPath = path.join(PDF_DIR, file)
+    if (!fs.statSync(fullPath).isFile() || !file.endsWith('.pdf')) continue
+
+    const encPath = fullPath + '.enc'
+    if (fs.existsSync(encPath)) {
+      console.log(`⏭ Skipped (already encrypted): ${file}`)
+      continue
+    }
+
+    const key = generateKey()
+    encryptGCM(fullPath, encPath, key.toString('base64'))
+  }
+}
+
+encryptAllPDFs()

+ 42 - 0
docs/.vitepress/script/decrypt_pdf.ts

@@ -0,0 +1,42 @@
+import pako from 'pako';
+
+// 反混淆:URL安全 Base64 -> 标准 Base64
+function deobfuscate(obfStr: string) {
+  let base64 = obfStr.replace(/-/g, '+').replace(/_/g, '/');
+  while (base64.length % 4) base64 += '=';
+  return base64;
+}
+
+// Base64 转 Uint8Array
+function b642ab(base64: string) {
+  const binary = atob(base64);
+  const len = binary.length;
+  const bytes = new Uint8Array(len);
+  for (let i = 0; i < len; i++) {
+    bytes[i] = binary.charCodeAt(i);
+  }
+  return bytes.buffer;
+}
+
+// 主函数:反混淆 + 解压成字符串
+function deobfuscateAndDecompress(obfStr: string): Uint8Array {
+  const b64 = deobfuscate(obfStr);
+  const compressedBuf = b642ab(b64);
+  const decompressed = pako.inflate(new Uint8Array(compressedBuf));
+  return decompressed;
+}
+
+export async function decrypt(url: string): Promise<Uint8Array | undefined> {
+  const res = await fetch(url).catch((err) => {
+    console.error('Error during fetch:', err)
+    return undefined
+  })
+  if (!res) return undefined
+  const buffer = await res.text().catch((err) => {
+    console.error('Error during get text:', err)
+    return undefined
+  })
+  if (!buffer) return undefined
+
+  return deobfuscateAndDecompress(buffer)
+}

+ 58 - 0
docs/.vitepress/script/encrypt_pdf.ts

@@ -0,0 +1,58 @@
+// scripts/encrypt_pdf.ts
+import * as fs from 'fs'
+import * as path from 'path'
+import pako from 'pako'
+
+import { fileURLToPath } from 'url'
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const PDF_DIR = path.resolve(__dirname, '../../public/pdf')
+
+// 工具:ArrayBuffer / Uint8Array 转 Base64
+function ab2b64(buffer: ArrayBuffer) {
+  let binary = '';
+  const bytes = new Uint8Array(buffer);
+  bytes.forEach(b => binary += String.fromCharCode(b));
+  return btoa(binary);
+}
+
+// 简单混淆:Base64 -> URL安全 + 去掉 '='
+function obfuscate(base64str: string) {
+  return base64str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+// 主函数:压缩 + 混淆
+function compressAndObfuscate(inputPath: string, outputPath: string) {
+  const input = fs.readFileSync(inputPath)
+
+  // 压缩成 Uint8Array
+  const compressed = pako.deflate(new Uint8Array(input));
+  // 转 Base64 字符串
+  const b64 = ab2b64(compressed.buffer);
+  // 混淆返回
+  const output = obfuscate(b64);
+
+  fs.writeFileSync(outputPath, output)
+
+  console.log(`✔ Compress: ${path.basename(inputPath)}`)
+}
+
+function encryptAllPDFs(): void {
+  const files = fs.readdirSync(PDF_DIR)
+  for (const file of files) {
+    const fullPath = path.join(PDF_DIR, file)
+    if (!fs.statSync(fullPath).isFile() || !file.endsWith('.pdf')) continue
+
+    const encPath = fullPath + '.enc'
+    if (fs.existsSync(encPath)) {
+      console.log(`⏭ Skipped (already Compress): ${file}`)
+      continue
+    }
+
+    compressAndObfuscate(fullPath, encPath)
+  }
+}
+
+encryptAllPDFs()

+ 173 - 0
docs/.vitepress/theme/Layout.vue

@@ -0,0 +1,173 @@
+<template>
+  <Layout v-if="showLayout">
+    <template #doc-after> <!-- 正文插槽 -->
+      <ClientOnly>
+        <!-- 页面正文之后插入评论 -->
+        <Waline v-if="showWaline" ref="walineRef" />
+      </ClientOnly>
+    </template>
+    <template #nav-bar-content-after>
+      <div style="margin-left: 15px">
+        <n-dropdown :options="personOptions" @select="handlePersonSelect">
+          <n-button text style="display: flex; justify-content: center" >
+            <template #icon>
+              <n-icon><PersonIcon /></n-icon>
+            </template>
+          </n-button>
+        </n-dropdown>
+      </div>
+    </template>
+  </Layout>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted, watch, Component, h } from 'vue'
+import { useRouter, useRoute, withBase } from 'vitepress'
+import DefaultTheme from 'vitepress/theme'
+import { createDiscreteApi, NButton, NIcon, NDropdown, DropdownOption } from 'naive-ui'
+import { Exit as ExitIcon, Person as PersonIcon } from '@vicons/ionicons5'
+import { useCasdoor } from 'casdoor-vue-sdk'
+import Waline from './components/Waline.vue'
+import { setWalineUserInfoFromToken } from '../plugins/waline'
+
+function renderIcon(icon: Component) {
+  return () => {
+    return h(NIcon, null, {
+      default: () => h(icon)
+    })
+  }
+}
+
+const walineRef = ref<InstanceType<typeof Waline> | undefined>(undefined)
+const personOptions = ref([
+  {
+    label: '退出登录',
+    key: 'logout',
+    icon: renderIcon(ExitIcon)
+  }
+])
+
+const { Layout } = DefaultTheme
+
+const route = useRoute()
+const router = useRouter()
+
+watch(
+  () => route.path,
+  (newPath, oldPath) => {
+    console.log('📦 路由变化:', oldPath, '➡️', newPath)
+    renderWaline()
+    renderLayout()
+  }
+)
+
+let islogin = false
+
+const showLayout = ref(false)
+const showWaline = ref(false)
+
+const url = new URL(window.location.href)
+const pathname = url.pathname
+
+const { dialog, massage } = createDiscreteApi(["dialog", "massage"])
+const casdoorSdk = useCasdoor()
+
+console.log("route: ", route)
+console.log("url: ", url)
+console.log("pathname: ", pathname)
+
+function renderLayout() {
+  if (!islogin && !pathname.startsWith(withBase('/casdoor/'))) {
+    // 查询登录状态
+    fetch<string>(withBase('/api/islogin'), {
+      method: "GET",
+      credentials: "include"
+    }).then(async res => {
+      // 已登录
+      const token = await res.text()
+      // 尝试提取用户信息
+      setWalineUserInfoFromToken(token)
+      
+      islogin = true
+      showLayout.value = true
+    }).catch(err => {
+      console.error("未登录")
+      console.error(err)
+
+      // 跳转到登录页面
+      const signinUrl = casdoorSdk.getSigninUrl()
+      window.location.href = signinUrl.replace(/^https?:\/\/[^/]+/, '')
+    })
+  } else if (islogin || pathname.startsWith(withBase('/casdoor/'))) {
+    showLayout.value = true
+  } else {
+    showLayout.value = false
+  }
+}
+
+function renderWaline() {
+  // Casdoor,不加载waline
+  if (pathname.startsWith(withBase('/casdoor/'))) {
+    showWaline.value = false
+  } else {
+    if (showWaline.value) {
+      // 更新waline
+      walineRef.value?.update()
+    } else {
+      showWaline.value = true
+    }
+  }
+}
+
+onMounted(async () => {
+  renderLayout()
+  renderWaline()
+})
+
+const handlePersonSelect = (key: string | number, option: DropdownOption) => {
+  if (key === 'logout') {
+    handleExitClick()
+  }
+}
+
+const handleExitClick = async () => {
+  dialog.warning({
+    title: '提示',
+    content: '确定要退出登录?',
+    positiveText: '确定',
+    negativeText: '取消',
+    draggable: true,
+    onPositiveClick: async () => {
+      // 请求登出
+      await fetch(withBase('/api/exit'), {
+        method: "POST",
+        credentials: "include"
+      }).then(res => {
+        const text = res.text()
+        console.log(text)
+
+        islogin = false
+
+        const signinUrl = casdoorSdk.getSigninUrl()
+        // 跳转到登录页面
+        window.location.href = signinUrl.replace(/^https?:\/\/[^/]+/, '')
+      }).catch(err => {
+        massage.error("登出失败")
+        console.error("登出失败")
+        console.error(err)
+      })
+    },
+    onNegativeClick: () => {
+      return
+    }
+  })
+}
+
+</script>
+
+<style lang="css">
+.vp-doc h5 {
+  margin: 24px 0 0;
+  letter-spacing: -0.01em;
+}
+</style>

+ 64 - 0
docs/.vitepress/theme/components/CasdoorCallback.vue

@@ -0,0 +1,64 @@
+<template>
+    <h1>{{ text }}</h1>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { withBase } from 'vitepress'
+import { createDiscreteApi } from 'naive-ui'
+import { useCasdoor } from "casdoor-vue-sdk"
+import { getOriginalUrl } from '../../plugins/SSE/stateMap'
+import { setWalineUserInfoFromToken } from '../../plugins/waline'
+
+const text = ref("Callback")
+
+// const { message } = createDiscreteApi(['message'])
+// message.success('Login success')
+// message.error(`Login failed: ${err}`)
+
+onMounted(() => {
+	const url = new URL(window.location.href)
+	const param = url.searchParams
+
+  const code = param.get('code')
+  const state = param.get('state')
+  console.log("code: ", code);
+  console.log("state: ", state);
+
+  const sdk = useCasdoor()
+  if (!sdk) {
+    return
+  }
+
+  // sdk.signin("https://raspberrypi.dev", withBase("/api/signin")).then((token => {
+	sdk.signin("", withBase("/api/signin")).then((token => {
+		// 登录成功
+		console.log("Login successful.")
+		console.log(token)
+		// 尝试提取用户信息
+		setWalineUserInfoFromToken(token.access_token)
+
+		// 尝试跳转登录前页面
+		const originalUrl = getOriginalUrl(state)
+		if (originalUrl) {
+			window.location.href = originalUrl
+			return
+		}
+
+		// 跳转回主页
+		window.location.href = withBase("/")
+	})).catch(err => {
+		console.log(err)
+		// 登录失败,需要重新登录,跳转到登录页面
+		const sdk = useCasdoor()
+		let signinUrl = "/"
+		if (sdk) {
+			signinUrl = sdk.getSigninUrl()
+		}
+		window.location.href = signinUrl.replace(/^https?:\/\/[^/]+/, '')
+	})
+  
+})
+
+
+</script>

+ 120 - 0
docs/.vitepress/theme/components/ExcalidrawView.vue

@@ -0,0 +1,120 @@
+<template>
+  <div ref="canvasContainer" class="excalidraw-wrapper" @click="showCanvasTop" />
+  <div ref="canvasFloating" v-if="showFullView" class="floating-canvas" @click="closeFullView" />
+</template>
+
+<script setup lang="ts">
+import { onMounted, ref, nextTick } from 'vue'
+import { withBase } from 'vitepress';
+import { exportToCanvas } from '@excalidraw/excalidraw'
+import { ExportedDataState } from '@excalidraw/excalidraw/data/types'
+
+interface Props {
+  filePath: string;
+}
+
+const props = defineProps<Props>()
+
+const canvasContainer = ref<HTMLCanvasElement | null>(null)
+const canvasFloating = ref<HTMLCanvasElement | null>(null)
+
+const showFullView = ref(false);
+
+let excalidrawJson: ExportedDataState | undefined = undefined
+
+function showCanvasTop() {
+  showFullView.value = true;
+
+  // 等待 DOM 渲染后复制内容
+  nextTick(async () => {
+    if (canvasFloating.value && excalidrawJson) {
+      const canvas = await drawCanvas(excalidrawJson, canvasFloating.value.clientWidth, canvasFloating.value.clientHeight)
+
+      if (canvas) {
+        canvasFloating.value.appendChild(canvas)
+      }
+    }
+  });
+}
+
+function closeFullView() {
+  showFullView.value = false;
+}
+
+// 可选:窗口大小变化时自适应
+// window.addEventListener("resize", resizeCanvas);
+
+const getExcalidraw = async (path: string): Promise<ExportedDataState | undefined> => {
+  const res = await fetch(withBase(path)).catch((err) => {
+    console.log("excalidraw file err: ", err)
+  })
+  if (!res) return undefined
+
+  const json = await res.json()
+  return json
+}
+
+const drawCanvas = async (data: ExportedDataState, width: number, height: number): Promise<HTMLCanvasElement> => {
+  const maxWidthOrHeight = width > height ? height : width
+  const canvas = await exportToCanvas({
+    ...data,
+    maxWidthOrHeight,// 绘制的最大高度或宽度,设置maxWidthOrHeight后getDimensions则不生效
+    // getDimensions: (width: number, height: number) => {
+    //   return {
+    //     width,
+    //     height,
+    //     scale: 1
+    //   }
+    // }, // 参数为原始绘制尺寸,返回尺寸和比例 {width: number; height: number; scale: number}
+    exportPadding: 10 // 添加边距,默认10
+  })
+  return canvas
+}
+
+onMounted(async () => {
+  excalidrawJson = await getExcalidraw(props.filePath)
+  if (excalidrawJson && canvasContainer.value) {
+    let excalidrawCanvas = await drawCanvas(excalidrawJson, canvasContainer.value.clientWidth, canvasContainer.value.clientHeight)
+
+    if (excalidrawCanvas) {
+      canvasContainer.value.appendChild(excalidrawCanvas)
+    }
+
+    // 使用 ResizeObserver 监听父容器宽度变化
+    const resizeObserver = new ResizeObserver(async (entries: ResizeObserverEntry[]) => {
+      for (const entry of entries) {
+        const el = entry.target
+        if (el === canvasContainer.value && excalidrawJson) {
+          excalidrawCanvas = await drawCanvas(excalidrawJson, el.clientWidth, el.clientHeight)
+        }
+      }
+    });
+    resizeObserver.observe(canvasContainer.value!);
+  }
+})
+</script>
+
+<style scoped>
+.excalidraw-wrapper {
+  height: 600px;
+  width: 100%;
+  border: 1px solid #ddd;
+}
+.floating-canvas {
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100vw;
+  height: 100vh;
+  background: rgba(0, 0, 0, 0.8);
+  z-index: 9999;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+.floating-canvas canvas {
+  max-width: 90vw;
+  max-height: 90vh;
+  box-shadow: 0 0 12px #fff;
+}
+</style>

+ 145 - 0
docs/.vitepress/theme/components/FilterComponent.vue

@@ -0,0 +1,145 @@
+<template>
+  <FilterForm ref="refFilterForm" :config="formConfig" :form-value="formValue" />
+  <div class="excalidraw" v-for="view in filterViewMap">
+    <n-card :title="setTitle(view.title)">
+      <n-tag v-for="tag in view.tags" type="info">
+        {{ tag }}
+      </n-tag>
+      <div class="description">
+        {{ view.description }}
+      </div>
+      <a :href="withBase(view.filePath)" download class="download-btn">下载配电图</a>
+      <ExcalidrawView :file-path="view.filePath" />
+    </n-card>
+  </div>
+</template>
+
+<script setup lang="tsx">
+import FilterForm, { FormValueType, SupportItemConfig } from './FilterForm.vue'
+import ExcalidrawView from './ExcalidrawView.vue'
+import { ref, onMounted, reactive, watch, computed, VNodeChild, nextTick } from 'vue';
+import { withBase } from 'vitepress'
+import { useLayout } from 'vitepress/dist/client/theme-default/without-fonts.js'
+
+import { NCard, NTag } from 'naive-ui';
+
+const layout = useLayout()
+
+const refFilterForm = ref<InstanceType<typeof FilterForm> | undefined>(undefined)
+
+interface ViewMap {
+  filePath: string;
+  tags: string[];
+  state?: { // state由代码判断,不需要填写
+    show: boolean;
+  }
+  
+  title: string;
+  description?: string;
+}
+
+interface Props {
+  formConfig?: SupportItemConfig[];
+  viewMap?: ViewMap[];
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  formConfig: () => [],
+  viewMap: () => []
+})
+
+const reactViewMap = reactive(props.viewMap)
+const filterViewMap = computed(() => reactViewMap.filter(item => item.state?.show))
+
+const formValue = reactive<FormValueType>({})
+
+watch(
+  formValue,
+  (newVal) => {
+    filterView()
+    nextTick(() => {
+      // 刷新导航栏
+      layout.refreshLocalNav()
+    })
+  },
+  {
+    deep: true
+  }
+)
+
+const setTitle = (title: string): () => VNodeChild => {
+  return () => (
+    <h3 id={title}>
+      {title}
+      <a class="header-anchor" href={'#'+ title} aria-label={"Permalink to “" + title + "”"}>​</a>
+    </h3>
+  )
+}
+
+// 在stringTable查找是否全包含findString
+function includesAll(stringTable: string[], findString: string[]): boolean {
+  const set1 = new Set(stringTable);
+  return findString.every(item => set1.has(item));
+}
+
+function filterView() {
+  // 获取所有选项字符串
+  const filterStrList = refFilterForm.value?.getSelectTags()
+
+  // 没有任何选项,显示全部图表
+  if (!filterStrList || filterStrList.length === 0) {
+    for(const element of reactViewMap) {
+      if (element.state === undefined) {
+        element.state = reactive({
+          show: true
+        })
+      } else {
+        element.state.show = true
+      }
+    }
+    return
+  }
+
+  reactViewMap.map((view, index, array) => {
+    if (array[index].state === undefined) {
+      array[index].state = reactive({
+        show: false
+      })
+    }
+
+    if (view.tags.length === 0) {
+      array[index].state.show = false
+    } else if (includesAll(filterStrList, view.tags)) {
+      array[index].state.show = true
+    } else {
+      array[index].state.show = false
+    }
+  })
+}
+
+onMounted(() => {
+  filterView()
+  nextTick(() => {
+    // 刷新导航栏
+    layout.refreshLocalNav()
+  })
+
+  console.log(formValue)
+})
+
+</script>
+
+<style lang="css" scoped>
+.description {
+  margin-top: 16px;
+  margin-bottom: 16px;
+}
+.excalidraw {
+  margin-top: 16px;
+  margin-bottom: 16px;
+}
+/* 覆盖.vp-doc h3 */
+.excalidraw :deep(h3) {
+  margin: 0 0 !important;
+}
+</style>

+ 526 - 0
docs/.vitepress/theme/components/FilterForm.vue

@@ -0,0 +1,526 @@
+<template>
+  <n-form
+    ref="formRef"
+    class="form-spacing"
+    :label-width="80"
+    :model="formValue"
+    size="small"
+  >
+    <n-collapse>
+      <n-collapse-item
+        :title="config[index].name"
+        v-for="(node, index) in vnodes"
+        :key="index"
+      >
+        <div style="width: 100%; display: inline-flex; align-items: flex-start; align-content: space-around;" >
+          <component :is="node" />
+        </div>
+      </n-collapse-item>
+    </n-collapse>
+  </n-form>
+</template>
+
+<script setup lang="tsx">
+import { ref, reactive, Reactive, VNode, computed, onMounted, watch } from 'vue'
+import type { FormInst } from 'naive-ui'
+import { 
+  NCollapse,
+  NCollapseItem,
+  NForm,
+  NFormItem,
+  NInput,
+  NInputNumber,
+  NRadio,
+  NRadioGroup,
+  NCheckbox,
+  NCheckboxGroup,
+  NRadioButton,
+  NSelect,
+  NSwitch,
+  NDatePicker,
+  NTimePicker
+} from 'naive-ui'
+
+interface FormField {
+  value: NonNullable<any>;
+  enable: boolean;
+  child?: FormValueType;
+  options?: Record<string, FormValueType>;
+}
+
+export interface FormValueType extends Record<string, Reactive<FormField>> {}
+
+const formRef = ref<FormInst | undefined>(undefined)
+// const formValue = reactive<FormValueType>({})
+
+type SupportItemType = 'Input'
+| 'InputNumber' 
+| 'RadioGroup' 
+| 'Checkbox' 
+| 'CheckboxGroup' 
+| 'ButtonGroup' 
+| 'Select' 
+| 'Switch' 
+| 'DatePicker' 
+| 'TimePicker'
+
+export type SupportItemConfig = InputConfig
+| InputNumberConfig
+| RadioGroupConfig
+| CheckboxConfig
+| CheckboxGroupConfig
+| ButtonGroupConfig
+| SelectConfig
+| SwitchConfig
+| DatePickerConfig
+| TimePickerConfig
+
+type OptionsType = string | OptionsExt
+
+interface OptionsExt {
+  name: string;
+  ext: SupportItemConfig[];
+}
+
+interface ItemBaseConfig {
+  name: string;
+  type: SupportItemType;
+}
+
+interface InputConfig extends ItemBaseConfig {
+  default?: string;
+}
+
+interface InputNumberConfig extends ItemBaseConfig {
+  default?: number;
+}
+
+interface RadioGroupConfig extends ItemBaseConfig {
+  options: OptionsType[];
+  default: string;
+}
+
+interface CheckboxConfig extends ItemBaseConfig {
+  default: boolean;
+  child?: SupportItemConfig[];
+}
+
+interface CheckboxGroupConfig extends ItemBaseConfig {
+  options: OptionsType[];
+  default: string[];
+}
+
+interface ButtonGroupConfig extends ItemBaseConfig {
+  options: OptionsType[];
+  default?: string;
+}
+
+interface SelectConfig extends ItemBaseConfig {
+  options: OptionsType[];
+  default?: string | string[];
+  multiple: boolean;
+}
+
+interface SwitchConfig extends ItemBaseConfig {
+  default?: boolean;
+  child?: SupportItemConfig[];
+}
+
+interface DatePickerConfig extends ItemBaseConfig {
+  default?: number | [number, number];
+  formatter?: string; // 时间格式化字符串,详情见 format https://date-fns.org/v2.23.0/docs/format
+  dateType?: 'date' | 'datetime' | 'daterange' | 'datetimerange' | 'month' | 'monthrange' | 'year' | 'yearrange' | 'quarter' | 'quarterrange' | 'week';
+}
+
+interface TimePickerConfig extends ItemBaseConfig {
+  default?: number;
+  formatter?: string; // 时间格式化字符串,详情见 format https://date-fns.org/v2.23.0/docs/format
+}
+
+// interface ItemConfig {
+//   config: SupportItemConfig;
+//   childConfig?: ItemConfig[];
+// }
+
+type FormConfig = SupportItemConfig[];
+type childConfig = SupportItemConfig[];
+
+interface Props {
+  config?: FormConfig;
+  formValue?: FormValueType;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  config: () => [],
+  formValue: () => ({})
+})
+
+const praseInputConfig = (config: InputConfig, model: { value: FormValueType }, enable: boolean): (() => VNode) => {
+  model.value[config.name] = reactive({
+    value: config.default ?? "",
+    enable
+  })
+  return () => (
+    <>
+    {
+      model.value[config.name].enable && 
+      <NFormItem label={config.name} >
+        <NInput v-model:value={model.value[config.name].value} />
+      </NFormItem>
+    }
+    </>
+  )
+}
+
+const praseInputNumberConfig = (config: InputNumberConfig, model: { value: FormValueType }, enable: boolean): (() => VNode) => {
+  model.value[config.name] = reactive({
+    value: config.default ?? 0,
+    enable
+  })
+  return () => (
+    <>
+    {
+      model.value[config.name].enable &&
+      <NFormItem label={config.name} >
+        <NInputNumber v-model:value={model.value[config.name].value} />
+      </NFormItem>
+    }
+    </>
+  )
+}
+
+const praseRadioGroupConfig = (config: RadioGroupConfig, model: { value: FormValueType }, enable: boolean): (() => VNode) => {
+  model.value[config.name] = reactive({
+    value: config.default ?? "",
+    enable
+  })
+  const renderFn = optionsExtRender(config, model)
+  return () => (
+    <>
+    {
+      model.value[config.name].enable &&
+      <>
+      <NFormItem label={config.name} >
+        <NRadioGroup v-model:value={model.value[config.name].value} name={config.name}>
+          {config.options.map((radioOption, index) => {
+            const optionName = typeof radioOption === 'string' ? radioOption : radioOption.name
+            return (
+              <NRadio key={optionName} value={optionName}>
+                { optionName }
+              </NRadio>
+            )
+          })}
+        </NRadioGroup>
+      </NFormItem>
+      {
+        renderFn && renderFn.map((vnode) => (vnode()))
+      }
+      </>
+    }
+    </>
+  )
+}
+
+const praseCheckboxConfig = (config: CheckboxConfig, model: { value: FormValueType }, enable: boolean): (() => VNode) => {
+  model.value[config.name] = reactive({
+    value: config.default ?? false,
+    enable
+  })
+  const renderFn = optionsExtRender(config, model)
+  return () => (
+    <>
+    {
+      model.value[config.name].enable &&
+      <>
+      <NFormItem>
+        <NCheckbox v-model:checked={model.value[config.name].value}>
+          { config.name }
+        </NCheckbox>
+      </NFormItem>
+      {
+        renderFn && renderFn.map((vnode) => (vnode()))
+      }
+      </>
+    }
+    </>
+  )
+}
+
+const praseCheckboxGroupConfig = (config: CheckboxGroupConfig, model: { value: FormValueType }, enable: boolean): (() => VNode) => {
+  model.value[config.name] = reactive({
+    value: config.default ?? [],
+    enable
+  })
+  const renderFn = optionsExtRender(config, model)
+  return () => (
+    <>
+    {
+      model.value[config.name].enable &&
+      <>
+      <NFormItem label={config.name} >
+        <NCheckboxGroup v-model:value={model.value[config.name].value} onUpdate:value={(value: (string|number)[]) => {
+          const valueArray = value as string[]
+          config.options.map(opt => {
+            if (typeof opt === 'string') return
+            const optName = opt.name
+            console.log("config.name: ", config.name)
+            console.log("optName: ", optName)
+            Object.keys(model.value[config.name].options![optName]).map((key) => {
+              model.value[config.name].options![optName][key].enable = valueArray.includes(optName)
+            })
+            console.log("model.value: ", model.value)
+          })
+        }} >
+          {config.options.map((checkboxOption, index) => {
+            const optionName = typeof checkboxOption === 'string' ? checkboxOption : checkboxOption.name
+            return (
+              <NCheckbox value={optionName} key={optionName} label={optionName} />
+            )
+          })}
+        </NCheckboxGroup>
+      </NFormItem>
+      {
+        renderFn && renderFn.map((vnode) => (vnode()))
+      }
+      </>
+    }
+    </>
+  )
+}
+
+const praseButtonGroupConfig = (config: ButtonGroupConfig, model: { value: FormValueType }, enable: boolean): (() => VNode) => {
+  model.value[config.name] = reactive({
+    value: config.default ?? "",
+    enable
+  })
+  const renderFn = optionsExtRender(config, model)
+  return () => (
+    <>
+    {
+      model.value[config.name].enable &&
+      <>
+      <NFormItem label={config.name} >
+        <NRadioGroup v-model:value={model.value[config.name].value} name={config.name}>
+          {config.options.map((radioOption, index) => {
+            const optionName = typeof radioOption === 'string' ? radioOption : radioOption.name
+            return (
+              <NRadioButton key={optionName} value={optionName}>
+                { optionName }
+              </NRadioButton>
+            )
+          })}
+        </NRadioGroup>
+      </NFormItem>
+      {
+        renderFn && renderFn.map((vnode) => (vnode()))
+      }
+      </>
+    }
+    </>
+  )
+}
+
+const praseSelectConfig = (config: SelectConfig, model: { value: FormValueType }, enable: boolean): (() => VNode) => {
+  model.value[config.name] = reactive({
+    value: config.default ?? (config.multiple ? [] : ""),
+    enable
+  })
+  const renderFn = optionsExtRender(config, model)
+  return () => (
+    <>
+    {
+      model.value[config.name].enable &&
+      <>
+      <NFormItem label={config.name} >
+        <NSelect v-model:value={model.value[config.name].value} options={config.options.map(a => {
+          const optionName = typeof a === 'string' ? a : a.name
+          return {label: optionName, value: optionName}
+        })} multiple={config.multiple} />
+      </NFormItem>
+      {
+        renderFn && renderFn.map((vnode) => (vnode()))
+      }
+      </>
+    }
+    </>
+  )
+}
+
+const praseSwitchConfig = (config: SwitchConfig, model: { value: FormValueType }, enable: boolean): (() => VNode) => {
+  model.value[config.name] = reactive({
+    value: config.default ?? false,
+    enable
+  })
+  const renderFn = optionsExtRender(config, model)
+  return () => (
+    <>
+    {
+      model.value[config.name].enable &&
+      <>
+      <NFormItem label={config.name} >
+        <NSwitch v-model:value={model.value[config.name].value} onUpdate:value={(value: boolean) => {
+          if (model.value[config.name].child) {
+            Object.keys(model.value[config.name].child!).map((key) => {
+              model.value[config.name].child![key].enable = value
+            })
+          }
+        }} />
+      </NFormItem>
+      {
+        renderFn && renderFn.map((vnode) => (vnode()))
+      }
+      </>
+    }
+    </>
+  )
+}
+
+const praseDatePickerConfig = (config: DatePickerConfig, model: { value: FormValueType }, enable: boolean): (() => VNode) => {
+  model.value[config.name] = reactive({
+    value: config.default,
+    enable
+  })
+  return () => (
+    <>
+    {
+      model.value[config.name].enable &&
+      <NFormItem label={config.name} >
+        <NDatePicker v-model:value={model.value[config.name].value} type={config.dateType} format={config.formatter} />
+      </NFormItem>
+    }
+    </>
+  )
+}
+
+const praseTimePickerConfig = (config: TimePickerConfig, model: { value: FormValueType }, enable: boolean): (() => VNode) => {
+  model.value[config.name] = reactive({
+    value: config.default,
+    enable
+  })
+  return () => (
+    <>
+    {
+      model.value[config.name].enable &&
+      <NFormItem label={config.name} >
+        <NTimePicker v-model:value={model.value[config.name].value} format={config.formatter} />
+      </NFormItem>
+    }
+    </>
+  )
+}
+
+type RenderMap = {
+  Input: (config: InputConfig, model: { value: FormValueType }, enable: boolean) => (() => VNode);
+  InputNumber: (config: InputNumberConfig, model: { value: FormValueType }, enable: boolean) => (() => VNode);
+  RadioGroup: (config: RadioGroupConfig, model: { value: FormValueType }, enable: boolean) => (() => VNode);
+  Checkbox: (config: CheckboxConfig, model: { value: FormValueType }, enable: boolean) => (() => VNode);
+  CheckboxGroup: (config: CheckboxGroupConfig, model: { value: FormValueType }, enable: boolean) => (() => VNode);
+  ButtonGroup: (config: ButtonGroupConfig, model: { value: FormValueType }, enable: boolean) => (() => VNode);
+  Select: (config: SelectConfig, model: { value: FormValueType }, enable: boolean) => (() => VNode);
+  Switch: (config: SwitchConfig, model: { value: FormValueType }, enable: boolean) => (() => VNode);
+  DatePicker: (config: DatePickerConfig, model: { value: FormValueType }, enable: boolean) => (() => VNode);
+  TimePicker: (config: TimePickerConfig, model: { value: FormValueType }, enable: boolean) => (() => VNode);
+};
+
+const itemConfigMap: RenderMap = {
+  'Input': praseInputConfig,
+  'InputNumber': praseInputNumberConfig,
+  'RadioGroup': praseRadioGroupConfig,
+  'Checkbox': praseCheckboxConfig,
+  'CheckboxGroup': praseCheckboxGroupConfig,
+  'ButtonGroup': praseButtonGroupConfig,
+  'Select': praseSelectConfig,
+  'Switch': praseSwitchConfig,
+  'DatePicker': praseDatePickerConfig,
+  'TimePicker': praseTimePickerConfig,
+}
+
+function optionsExtRender(config: SupportItemConfig, model: { value: FormValueType }) {
+  if ('options' in config) {
+    if (model.value[config.name].options === undefined) {
+      model.value[config.name].options = {} as Record<string, FormValueType>
+    }
+    return config.options.map((itemOption, index) => {
+      if (typeof itemOption === 'string') {
+        return null;
+      } else {
+        const enable = model.value[config.name].value.includes(itemOption.name)
+        if (model.value[config.name].options![itemOption.name] === undefined) {
+          model.value[config.name].options![itemOption.name] = {} as FormValueType
+        }
+        return configRender(itemOption.ext, { value: model.value[config.name].options![itemOption.name] }, enable)
+      }
+    }).flat().filter(Boolean) as (() => VNode)[] // 移除 null
+  } else if ('child' in config && config.child) { // boolean 类型选项
+    const enable = model.value[config.name].value as boolean
+    if (model.value[config.name].child === undefined) {
+      model.value[config.name].child = {} as FormValueType
+    }
+    return configRender(config.child, { value: model.value[config.name].child! }, enable)
+  } else {
+    return null;
+  }
+}
+
+function configRender(config: SupportItemConfig[], model: { value: FormValueType }, enable: boolean) {
+  return config.map(cfg => {
+    const type = cfg.type;
+    const renderFn = itemConfigMap[type as SupportItemType];
+    if (renderFn) {
+      return renderFn(cfg as any, { value: model.value }, enable);
+    }
+    return null;
+  }).filter(Boolean) as (() => VNode)[]; // 移除 null
+}
+
+function praseFormConfig(config: FormConfig) {
+  return configRender(config, { value: props.formValue }, true)
+}
+
+const formRender = praseFormConfig(props.config)
+const vnodes = computed(() => formRender.map(fn => fn()))
+
+onMounted(() => {})
+
+function isStringArray(data: any): data is string[] {
+  return Array.isArray(data) && data.every(item => typeof item === 'string')
+}
+
+const getStringList = (v: FormValueType): string[] => {
+    let stringList: string[] = []
+    Object.entries(v).forEach(([key, field]) => {
+      if (typeof field.value === 'boolean' && field.value === true) {
+        stringList.push(key)
+      } else if (typeof field.value === 'string' || isStringArray(field.value)) {
+        stringList.push(...field.value)
+      }
+
+      if (field.child) {
+        const childList = getStringList(field.child)
+        stringList.push(...childList)
+      }
+    })
+    return stringList
+  }
+
+const getSelectTags = (): string[] => {
+  return getStringList(props.formValue)
+}
+
+// 暴露方法给父组件
+defineExpose({
+  getSelectTags
+})
+
+</script>
+
+<style lang="css" scoped>
+.form-spacing {
+  margin-top: 16px;   /* 上间距 */
+  margin-bottom: 16px; /* 下间距 */
+}
+
+.form-spacing n-checkbox-group {
+  margin-right: 16px;
+}
+</style>

+ 152 - 0
docs/.vitepress/theme/components/NormalTable.vue

@@ -0,0 +1,152 @@
+<template>
+  <n-data-table class="normal-table"
+    :columns="columns"
+    :data="tableData"
+    :single-line="true"
+  />
+</template>
+
+<script setup lang="ts">
+import { NDataTable, DataTableColumn } from 'naive-ui'
+
+interface ColumnConfig {
+  title: string;
+  children?: ColumnConfig[];
+  align?: 'left' | 'right' | 'center';      // 列文本对齐方式,默认left
+  titleAlign?: 'left' | 'right' | 'center'; // 列标题对齐方式,默认同文本
+  minWidth?: number | string;
+  maxWidth?: number | string;
+  width?: number | string;
+}
+
+type TableConfig = (string | ColumnConfig)[]
+
+interface CellDataExt {
+  text: string;
+  colspan?: number | undefined; // 向右合并 N 个单元格
+  rowspan?: number | undefined; // 向下合并 N 个单元格
+}
+
+interface CellDataType {
+  [title: string]: string | CellDataExt
+}
+
+interface Props {
+  config?: TableConfig;
+  data?: CellDataType[];
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  config: () => ({}),
+  data: () => []
+})
+
+const handleRowSpan = (rowData: DataTableColumn, rowIndex: number, key: string): number => {
+  const data = props.data[rowIndex]
+  if (!data) return 1
+  const keyValue = data[key]
+  if (!keyValue) return 1
+  if (typeof keyValue === "string") return 1
+  return keyValue.rowspan ?? 1 
+}
+
+const handleColSpan = (rowData: DataTableColumn, rowIndex: number, key: string): number => {
+  const data = props.data[rowIndex]
+  if (!data) return 1
+  const keyValue = data[key]
+  if (!keyValue) return 1
+  if (typeof keyValue === "string") return 1
+  return keyValue.colspan ?? 1 
+}
+
+function traversalChildren(child: ColumnConfig[]) {
+  return child.map(item => {
+    let obj: DataTableColumn = {}
+    obj.title = item.title
+    obj.key = item.title
+    obj.align = item.align
+    obj.titleAlign = item.titleAlign
+    obj.minWidth = item.minWidth
+    obj.maxWidth = item.maxWidth
+    obj.width = item.width
+    if (item.children) {
+      obj.children = traversalChildren(item.children)
+    } else {
+      obj.rowSpan = (rowData, rowIndex) => {
+        return handleRowSpan(rowData, rowIndex, item.title)
+      }
+      obj.colSpan = (rowData, rowIndex) => {
+        return handleColSpan(rowData, rowIndex, item.title)
+      }
+    }
+    return obj
+  })
+}
+
+function createColumns(config: TableConfig) {
+  return config.map(column => {
+    let obj: DataTableColumn = {}
+    if (typeof column === 'string') {
+      obj.title = column
+      obj.key = column
+      obj.rowSpan = (rowData, rowIndex) => {
+        return handleRowSpan(rowData, rowIndex, column)
+      }
+      obj.colSpan = (rowData, rowIndex) => {
+        return handleColSpan(rowData, rowIndex, column)
+      }
+    } else {
+      obj.title = column.title
+      obj.key = column.title
+      obj.align = column.align
+      obj.titleAlign = column.titleAlign
+      obj.minWidth = column.minWidth
+      obj.maxWidth = column.maxWidth
+      obj.width = column.width
+      if (column.children) {
+        obj.children = traversalChildren(column.children)
+      } else {
+        obj.rowSpan = (rowData, rowIndex) => {
+          return handleRowSpan(rowData, rowIndex, column.title)
+        }
+        obj.colSpan = (rowData, rowIndex) => {
+          return handleColSpan(rowData, rowIndex, column.title)
+        }
+      }
+    }
+    return obj
+  })
+}
+
+function createData(data: CellDataType[]) {
+  return data.map((row, index) => {
+    let obj = {
+      key: index
+    }
+    for (const key in row) {
+      const value = row[key as keyof typeof row];
+      if (typeof value === "string") {
+        obj[key] = value
+      } else {
+        obj[key] = value.text
+      }
+    }
+    return obj
+  })
+}
+
+const columns = createColumns(props.config)
+const tableData = createData(props.data)
+
+</script>
+
+<style scoped>
+/* 覆盖.vp-doc table */
+.normal-table :deep(table) {
+  margin: 0 0 !important;
+}
+
+.normal-table :deep(td) {
+  white-space: pre-wrap; /* 保留换行和空格 */
+}
+</style>

+ 44 - 0
docs/.vitepress/theme/components/ParameterTable.vue

@@ -0,0 +1,44 @@
+<template>
+  <div class="para-tab-wrap">
+    <div v-for="(item, index) in items" :key="index" class="para-tab-row">
+      <div class="para-tab-name" :style="{ width: labelWidth }">{{ item.name }}</div>
+      <div class="para-tab-content" v-html="item.content"></div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  labelWidth: {
+    type: String,
+    default: '150px' // 默认 150px
+  },
+  items: {
+    type: Array,
+    required: true,
+    default: () => []
+  }
+});
+</script>
+
+<style scoped>
+.para-tab-wrap {
+  display: flex;
+  flex-direction: column;
+  gap: 20px;
+  width: 100%;
+  padding-top: 30px;
+  padding-bottom: 30px;
+}
+.para-tab-row {
+  display: flex;
+  gap: 20px;
+}
+.para-tab-name {
+  font-weight: bold;
+  color: #333;
+}
+.para-tab-content {
+  flex: 1;
+}
+</style>

+ 30 - 0
docs/.vitepress/theme/components/PdfViewer.vue

@@ -0,0 +1,30 @@
+<template>
+  <iframe :title="title" :src="pdfSrc" :width="width" :height="height" style="border: none;" />
+</template>
+
+<script setup lang="ts">
+import { withBase } from 'vitepress'
+import { computed } from 'vue'
+
+const props = defineProps({
+  title: {
+    type: String,
+    default: ''
+  },
+  file: {
+    type: String,
+    required: true
+  },
+  width: {
+    type: String,
+    default: '100%'
+  },
+  height: {
+    type: String,
+    default: '600px'
+  }
+})
+
+// const props = defineProps(['title', 'file', 'width', 'height'])
+const pdfSrc = computed(() => withBase(props.file))
+</script>

+ 54 - 0
docs/.vitepress/theme/components/SecurePdfViewer.vue

@@ -0,0 +1,54 @@
+<template>
+  <div ref="container" style="border: 1px solid #ccc; width: 100%; height: 90vh; overflow-y: auto;" >
+    <!-- <canvas ref="container" style="border: 1px solid #ccc; width: 100%; height: 100vh; overflow-y: auto;" /> -->
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { ref, onMounted } from 'vue'
+import { withBase } from 'vitepress'
+import { decrypt } from '../../script/decrypt_pdf'
+import * as pdfjsLib from 'pdfjs-dist'
+import workerSrc from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
+pdfjsLib.GlobalWorkerOptions.workerSrc = workerSrc
+
+const props = defineProps<{
+  fileUrl: string
+}>()
+
+const container = ref<HTMLCanvasElement | null>(null)
+
+onMounted(async () => {
+  const decryptedData = await decrypt(withBase(props.fileUrl)).catch((err) => {
+    console.error('Error during decrypt:', err)
+    return undefined
+  })
+  if (!decryptedData) return
+  const pdf = await pdfjsLib.getDocument({
+    data: decryptedData
+  }).promise.catch((err) => {
+    console.error('Error during get document:', err)
+    return undefined
+  })
+  if (!pdf) return
+
+  for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
+    const page = await pdf.getPage(pageNum).catch((err) => {
+      console.error(`Error during get page ${pageNum}:`, err)
+      return undefined
+    })
+    if (!page) return
+
+    const viewport = page.getViewport({ scale: 1.1 })
+    const canvas = document.createElement('canvas');
+    const ctx = canvas.getContext('2d')
+    canvas.width = viewport.width
+    canvas.height = viewport.height
+    if (ctx) await page.render({ canvasContext: ctx, viewport }).promise.catch((err) => {
+      console.error('Error during page render:', err)
+    })
+    canvas.style.marginBottom = '20px';
+    container.value?.appendChild(canvas);
+  }
+})
+</script>

+ 66 - 0
docs/.vitepress/theme/components/SimpleTable.vue

@@ -0,0 +1,66 @@
+<template>
+  <div class="borderless-table">
+    <table v-if="data.length">
+      <thead>
+        <tr>
+          <th v-for="(key, index) in keys" :key="index">
+            {{ key }}
+          </th>
+        </tr>
+      </thead>
+      <tbody>
+        <tr v-for="(item, rowIndex) in data" :key="rowIndex">
+          <td v-for="(key, colIndex) in keys" :key="colIndex">
+            {{ item[key] }}
+          </td>
+        </tr>
+      </tbody>
+    </table>
+    <div v-else class="empty">暂无数据</div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+interface CellDataType {
+  [title: string]: string
+}
+
+const props = defineProps({
+  data: {
+    type: Array as PropType<CellDataType[]>,
+    default: () => []
+  }
+})
+
+const keys = computed(() => {
+  return props.data.length ? Object.keys(props.data[0]) : []
+})
+
+</script>
+
+<style scoped>
+.borderless-table table {
+  width: 100%;
+  border-collapse: collapse;
+}
+
+.borderless-table th,
+.borderless-table td {
+  padding: 8px 12px;
+  text-align: left;
+  border: none;
+}
+
+.borderless-table thead {
+  font-weight: bold;
+  background-color: #f9f9f9;
+}
+
+.borderless-table .empty {
+  text-align: center;
+  color: #aaa;
+  padding: 20px;
+}
+</style>

+ 26 - 0
docs/.vitepress/theme/components/SseListener.vue

@@ -0,0 +1,26 @@
+<template>
+  <div style="display:none"></div>
+</template>
+
+<script setup lang="ts">
+import { onMounted, onUnmounted } from 'vue-demi'
+import EventClient from '../../plugins/SSE/client'
+import { useCasdoor } from 'casdoor-vue-sdk'
+
+let client: EventClient | undefined = undefined
+
+onMounted(() => {
+  const sdk = useCasdoor()
+  let signinUrl = ""
+  if (sdk) {
+    signinUrl = sdk.getSigninUrl()
+    console.log(signinUrl)
+  }
+  client = new EventClient(signinUrl)
+})
+
+onUnmounted(() => {
+  client?.close()
+  client = undefined
+})
+</script>

+ 102 - 0
docs/.vitepress/theme/components/Waline.vue

@@ -0,0 +1,102 @@
+<template>
+  <div id="comment" class="waline-wrapper vp-comment" vp-comment style="display: block; margin-top: 2em;" />
+</template>
+
+<script setup lang="ts">
+import { onMounted,  } from 'vue'
+import { init, WalineInstance  } from '@waline/client'
+import '@waline/client/style'
+
+let instance: WalineInstance | undefined = undefined
+
+onMounted(() => {
+  instance = init({
+    el: '#comment',
+    serverURL: window.location.origin + import.meta.env.BASE_URL + 'waline', // 本地 Waline 服务地址
+    path: window.location.pathname.replace(/\/?(\.html)?$/, ''),
+    login: "disable"
+  })
+})
+
+const update = () => {
+  instance.update({
+    path: window.location.pathname.replace(/\/?(\.html)?$/, '')
+  })
+}
+
+// 将方法暴露给父组件
+defineExpose({
+  update,
+})
+
+</script>
+
+<style lang="css">
+:root {
+  /* 字体大小 */
+  --waline-font-size: 16px;
+
+  /* 常规颜色 */
+  --waline-white: #fff;
+  --waline-light-grey: #999;
+  --waline-dark-grey: #666;
+
+  /* 主题色 */
+  --waline-theme-color: #27ae60;
+  --waline-active-color: #2ecc71;
+
+  /* 布局颜色 */
+  --waline-color: #444;
+  --waline-bg-color: #fff;
+  --waline-bg-color-light: #f8f8f8;
+  --waline-bg-color-hover: #f0f0f0;
+  --waline-border-color: #ddd;
+  --waline-disable-bg-color: #f8f8f8;
+  --waline-disable-color: #bbb;
+  --waline-code-bg-color: #282c34;
+
+  /* 特殊颜色 */
+  --waline-bq-color: #f0f0f0;
+
+  /* 头像 */
+  --waline-avatar-size: 3.25rem;
+  --waline-m-avatar-size: calc(var(--waline-avatar-size) * 9 / 13);
+
+  /* 徽章 */
+  --waline-badge-color: #3498db;
+  --waline-badge-font-size: 0.775em;
+
+  /* 信息 */
+  --waline-info-bg-color: #f8f8f8;
+  --waline-info-color: #999;
+  --waline-info-font-size: 0.625em;
+
+  /* 渲染选择 */
+  --waline-border: 1px solid var(--waline-border-color);
+  --waline-avatar-radius: 50%;
+  --waline-box-shadow: none;
+}
+
+/* 根据用户设置 ↓ */
+darkmode-selector {
+  /* 常规颜色 */
+  --waline-white: #000;
+  --waline-light-grey: #666;
+  --waline-dark-grey: #999;
+
+  /* 布局颜色 */
+  --waline-color: #888;
+  --waline-bg-color: #1e1e1e;
+  --waline-bg-color-light: #272727;
+  --waline-border-color: #333;
+  --waline-disable-bg-color: #444;
+  --waline-disable-color: #272727;
+
+  /* 特殊颜色 */
+  --waline-bq-color: #272727;
+
+  /* 其他颜色 */
+  --waline-info-bg-color: #272727;
+  --waline-info-color: #666;
+}
+</style>

+ 7 - 0
docs/.vitepress/theme/index.css

@@ -0,0 +1,7 @@
+.medium-zoom-overlay {
+  z-index: 20;
+}
+
+.medium-zoom-image {
+  z-index: 9999 !important;/* 给的值是21,但是实测盖不住,直接999 */
+}

+ 130 - 0
docs/.vitepress/theme/index.ts

@@ -0,0 +1,130 @@
+import type { Theme, Router } from 'vitepress'
+import DefaultTheme from 'vitepress/theme'
+import Layout from './Layout.vue'
+import './index.css';
+
+// about Naive UI
+import { App, defineComponent, h, inject, onMounted, watch, nextTick } from 'vue'
+import { setup } from '@css-render/vue3-ssr'
+import { NConfigProvider } from 'naive-ui'
+import { useRoute } from 'vitepress'
+
+// SDK
+// import CasdoorPlugin, { SdkConfig } from './sdk/casdoor'
+import casdoor from 'casdoor-vue-sdk'
+import { 
+  ServerUrl, 
+  ClientId, 
+  AppName, 
+  OrganizationName,
+  RedirectPath
+} from '../plugins/casdoor/config'
+
+// Component
+import SseListener from './components/SseListener.vue'
+import Waline from './components/Waline.vue'
+import CasdoorCallback from './components/CasdoorCallback.vue'
+import ParameterTable from './components/ParameterTable.vue'
+import SimpleTable from './components/SimpleTable.vue'
+import NormalTable from './components/NormalTable.vue'
+import PdfViewer from './components/PdfViewer.vue'
+import SecurePdfViewer from './components/SecurePdfViewer.vue'
+import FilterComponent from './components/FilterComponent.vue'
+import ExcalidrawView from './components/ExcalidrawView.vue';
+
+const CssRenderStyle = defineComponent({
+  setup() {
+    const collect = inject<() => string>('css-render-collect')
+    return {
+      style: collect ? collect() : ''
+    }
+  },
+  render() {
+    return h('css-render-style', {
+      innerHTML: this.style
+    })
+  }
+})
+
+const VitepressPath = defineComponent({
+  setup() {
+    const route = useRoute()
+    return () => {
+      return h('vitepress-path', null, [route.path])
+    }
+  }
+})
+
+const NaiveUIProvider = defineComponent({
+  render() {
+    return h(
+      NConfigProvider,
+      { abstract: true, inlineThemeDisabled: true },
+      {
+        default: () => [
+          h(SseListener),  // <-- SSE 监听组件
+          h(Layout, null, { default: this.$slots.default?.() }),
+          import.meta.env.SSR ? [h(CssRenderStyle), h(VitepressPath)] : null
+        ]
+      }
+    )
+  }
+})
+
+import mediumZoom from 'medium-zoom'
+// import type { Zoom } from 'medium-zoom'
+// let zoomInstance : Zoom | undefined = undefined
+
+function pictureZoom() {
+  const route = useRoute();
+  const initZoom = () => {
+    // mediumZoom('[data-zoomable]', { background: 'var(--vp-c-bg)' }); // 默认
+    // mediumZoom('.main img', { background: 'var(--vp-c-bg)' }); // 不显式添加{data-zoomable}的情况下为所有图像启用此功能
+    // 除a标签以下的所有图像都进行缩放
+    // mediumZoom(':not(a) > img:not(.VPImage)', { background: 'var(--vp-c-bg)' }); // 不显式添加{data-zoomable}的情况下为所有图像启用此功能
+    mediumZoom('.VPDoc img', { background: 'var(--vp-c-bg)' });
+  };
+  onMounted(() => {
+    initZoom();
+  });
+  watch(
+    () => route.path,
+    () => nextTick(() => initZoom())
+  );
+}
+
+/** @type {import('vitepress').Theme} */
+export default {
+  Layout: NaiveUIProvider,
+  enhanceApp({ app }: { app: App }) {
+    if (import.meta.env.SSR) {
+      const { collect } = setup(app)
+      app.provide('css-render-collect', collect)
+    }
+
+    app.use(casdoor, {
+      serverUrl: ServerUrl,
+      clientId: ClientId,
+      appName: AppName,
+      organizationName: OrganizationName,
+      redirectPath: RedirectPath
+    })
+
+    // 注册自定义全局组件
+    // app.component('...', ...)
+    app.component('SseListener', SseListener) // 全局监听SSE
+    app.component('Waline', Waline) // 评率组件
+    app.component('CasdoorCallback', CasdoorCallback)
+    app.component('ParameterTable', ParameterTable)
+    app.component('SimpleTable', SimpleTable)
+    app.component('NormalTable', NormalTable)
+    app.component('PdfViewer', PdfViewer)
+    app.component('SecurePdfViewer', SecurePdfViewer)
+    app.component('FilterComponent', FilterComponent)
+    app.component('ExcalidrawView', ExcalidrawView)
+  },
+  setup() {
+    pictureZoom()
+  },
+  extends: DefaultTheme
+} satisfies Theme

+ 168 - 0
docs/Hardware/Gate/MSA_C.md

@@ -0,0 +1,168 @@
+---
+title: MSA-C
+editLink: false
+---
+# MSA-C
+
+## 系统介绍
+
+**MSA** 全称Meter Socket Adapter,是一种电表插座的适配器,安装在市电电表插座与市电电表之间,里面包含继电器、市电电压采样电路、市电电流采样电流等一系列市电控制、采样功能器件和电路。适配器中并无执行逻辑,需搭配控制器(**MSA-C**)一同使用,实现市电能量管理和接入/脱离控制。
+
+**MSA-C** 全称MSA-Controller,是一种EMS(能源管理系统)控制器,本身不具备执行机构,需配合以下设备实现相关功能:结合MSA继电器控制的能源管理控制盒。
+* 搭配**MSA**,实现市电市电能量管理和接入/脱离控制
+* 搭配**aPower**,实现电池能量管理和充放电控制
+* 搭配**aPbox2.0**,实现油机、PV、智能负载等设备的能量管理与控制
+
+### MSA-C典型应用
+
+![MSA-C+aPbox2.0典型应用系统图](./assets/MSA_C/MSA-C+aPbox2.0典型应用系统图.png)
+
+### MSA-C系统组成
+
+![MSA-C+aPbox2.0系统组成示意图](./assets/MSA_C/MSA-C+aPbox2.0系统组成示意图.png)
+
+### MSA-C系统架构
+
+![MSA-C+aPbox2.0系统架构图](./assets/MSA_C/MSA-C+aPbox2.0系统架构图.png)
+
+### 搭配MSA的电气方案
+
+**MSA-C + EQB**
+
+![MSA-C+EQB电气方案图](./assets/MSA_C/MSA-C+EQB电气方案图.png)
+
+带aPbox2.0
+
+![MSA-C+EQB+aPbox2.0电气方案图](./assets/MSA_C/MSA-C+EQB+aPbox2.0电气方案图.png)
+
+**MSA-C + CND**
+
+![MSA-C+CND电气方案图](./assets/MSA_C/MSA-C+CND电气方案图.png)
+
+带aPbox2.0
+
+![MSA-C+CND+aPbox2.0电气方案图](./assets/MSA_C/MSA-C+CND+aPbox2.0电气方案图.png)
+
+**MSA-C不与MSA连接**
+
+![MSA-C不连接MSA电气方案图](./assets/MSA_C/MSA-C不连接MSA电气方案图.png)
+
+## MSA-C与aGate 1.3差异说明
+
+<ParameterTable :items="[
+  {
+    name: '➖️配电电路',
+    content: 'MSA-C没有配电电路,仅包含控制电路板。'
+  },
+  {
+    name: '🔁市电继电器控制',
+    content: '使用MSA提供的继电器控制线,同时状态检测也使用由MSA提供的检测线。'
+  },
+  {
+    name: '➕️MSA自识别',
+    content: 'MSA-C通过判断MSA的反馈控制信号识别接入的类型,详情请参考<a href=\'#msa自动识别功能\'>MSA自动识别功能</a>'
+  },
+  {
+    name: '➕️MSA信号采样',
+    content: '系统适配两种MSA信号采样,包括电压、电流、继电器状态、温度等。'
+  },
+  {
+    name: '🔁市电CT故障检测',
+    content: '市电CT故障检测逻辑需在aGate市电CT故障检测逻辑基础上,兼容MSA的CT特性。'
+  },
+  {
+    name: '🔁EMS-ARM与EMS-DSP通讯协议',
+    content: '删除原有油机、V2L、光伏、智能负载相关协议,这部分相关协议将移到aPbox2.0协议中实现。'
+  },
+  {
+    name: '🔁主配电盘保护',
+    content: '。。。待确认。。。'
+  },
+  {
+    name: '❓️手拉手-aPower空开限流',
+    content: '。。。待确认。。。'
+  },
+  {
+    name: '❓️功率限制-aPower空开限流',
+    content: '。。。待确认。。。'
+  },
+  {
+    name: '🔁PCS功能',
+    content: 'MSA场景属于全屋备电场景,但其备电端接法五花八门,需要更新PCS的全屋备电场景设计来支持MSA。'
+  },
+  {
+    name: '➕️aPbox2.0 Busbar过流保护',
+    content: 'PCS功能中需包括aPbox2.0 Busbar过流保护功能。'
+  },
+  {
+    name: '🔁软件升级',
+    content: '增加aPbox2.0软件升级。'
+  },
+  {
+    name: '🔁故障录波',
+    content: '增加aPbox2.0故障录波'
+  },
+  {
+    name: '🔁参数同步',
+    content: '增加aPbox2.0运行参数同步'
+  },
+  {
+    name: '➕️本地WIFI-AP升级系统软件包',
+    content: '实现安装商使用手机连接EMS本地WIFI-AP升级系统软件包。'
+  },
+  {
+    name: '🔁编址',
+    content: '增加aPbox2.0编址'
+  },
+  {
+    name: '🔁黑启动',
+    content: '没有黑启动继电器,其他逻辑和aGate1.3相似。'
+  },{
+    name: '⚠️市电计量',
+    content: 'ConnectDER-MSA没有提供单相电压值,电表计量可能有误差。'
+  },
+  {
+    name: '🔁相序检测',
+    content: 'EMS-DSP相序检测功能采样依赖于aPower,逻辑更复杂。'
+  },
+  {
+    name: '⚠️并网合规',
+    content: 'ConnectDER-MSA没有提供单相电压值,并网合规快速动作响应依赖于aPower,对响应速度有挑战。'
+  }
+]" />
+
+## 适配的MSA介绍
+
+目前MSA-C仅适配两种MSA,分别为:EQB-MSA和ConnectDER-MSA。
+
+### EQB-MSA
+
+![EQB-MSA外观](./assets/MSA_C/EQB-MSA外观.png)
+
+**开发手册**
+
+<ClientOnly>
+  <SecurePdfViewer fileUrl="/pdf/EQB_MIM_200_PLUS_PE_Manual_2025.pdf.enc" />
+</ClientOnly>
+
+### ConnectDER-MSA
+
+![ConnectDER-MSA外观](./assets/MSA_C/ConnectDER-MSA外观.png)
+
+**开发手册**
+
+<ClientOnly>
+  <SecurePdfViewer fileUrl="/pdf/IslandDER_Electrical Interface_RevC.0.pdf.enc" />
+</ClientOnly>
+
+## MSA-C功能详细说明
+
+### MSA自动识别功能
+
+MSA的连接分三种情况:EQB-MSA、ConnectDER-MSA、不接MSA。为了快速安装,实现MSA自动识别功能。
+
+EQB MSA接入时,两个市电继电器状态反馈信号(EQB_RELAY_FB_ON 、EQB_RELAY_FB_OFF)至少有一个是有效信号,若这两个信号都是无效信号,则判出没有EQB的MSA接入。
+
+ConnectDER MSA接入时,可以通过ConnectDER MSA送给从DSP的温度信号(Temp_Sense)判断,送入ADC口前有接高阻值的下拉电阻,如果不接MSA,则输入电压为0V,若有接MSA,输入电压范围为:0.1V(对应-40℃)至2.0V(对应150℃)。另外从DSP本身有环境温度采样信号(Envir_Temp_AD),可以通过二者的比对确保判断的正确性。
+
+MSA自动识别只在系统上电后的一段时间内进行,初次识别MSA型号后存入Flash,若重新上电识别的MSA型号与Flash中存储的型号不相同,则告警进入故障状态;若接收到commission命令,则重新检测MSA型号并存Flash。

+ 11 - 0
docs/Hardware/Gate/MSA_C2.md

@@ -0,0 +1,11 @@
+---
+title: MSA-C2
+editLink: false
+---
+# MSA-C2
+
+## 系统介绍
+
+基于MSA-C开发的2代产品,总体上功能无变化,仅为缩小体积,嵌入aPower S中设计开发的产品。
+
+项目开发阶段,暂无更多资料,待更新。。。

+ 115 - 0
docs/Hardware/Gate/aGate_1.0.md

@@ -0,0 +1,115 @@
+---
+title: aGate 1.0/1.1
+editLink: false
+---
+# aGate 1.0/1.1
+
+## EMS接口说明
+
+**接口原理图**
+![aGate1.0-EMS接口原理图](./assets/aGate_1.0/aGate1.0-EMS接口原理图.png)
+
+**接口说明表**
+<NormalTable
+:config = "[
+  '端口', 
+  '应用', 
+  {
+    title: '出厂自带',
+    align: 'center',
+    width: '60px'
+  }, 
+  '备注'
+]"
+:data="[
+  {
+    端口: 'CT1',
+    应用: 'Secondary solar meter (NEM CT)',
+    出厂自带: '❌',
+    备注: '测量NEM PV输入功率(NEM PV不限制馈网),只能接到Grid CT & EX Grid CT的市电侧'
+  },
+  {
+    端口: 'CT2',
+    应用: 'Null',
+    出厂自带: '❌',
+    备注: ''
+  },
+  {
+    端口: 'CT3',
+    应用: 'Null',
+    出厂自带: '❌',
+    备注: ''
+  },
+  {
+    端口: {
+      text: 'Gen CT',
+      rowspan: 2 // 合并下方单元格
+    },
+    应用: 'Generator CT',
+    出厂自带: '✔️',
+    备注: '测量发电机输入功率'
+  },
+  {
+    // 端口上方已合并
+    应用: 'Partial backup Grid CT',
+    出厂自带: '❌',
+    备注: '部分备电场景的市电CT'
+  },
+  {
+    端口: {
+      text: 'RS485-A1&B1',
+      rowspan: 2 // 合并下方单元格
+    },
+    应用: 'aPbox 1 Meter',
+    出厂自带: '❌',
+    备注: '测量aPbox1 PV输入功率'
+  },
+  {
+    // 端口上方已合并
+    应用: 'Meter Kit',
+    出厂自带: '❌',
+    备注: '测量NEM PV输入功率(NEM PV不限制馈网),只能接到Grid CT & EX Grid CT的市电侧'
+  },
+  {
+    端口: 'RS485-A2&B2',
+    应用: 'aPbox 2 Meter',
+    出厂自带: '❌',
+    备注: '测量aPbox2 PV输入功率'
+  },
+  {
+    端口: {
+      text: 'RS485-A3&B3',
+      rowspan: 2 // 合并下方单元格
+    },
+    应用: {
+      text: 'PV Meter',
+      rowspan: 2 // 合并下方单元格
+    },
+    出厂自带: '✔️',
+    备注: '测量aGate PV输入功率'
+  },
+  {
+    // 端口上方已合并
+    // 应用上方已合并
+    出厂自带: '❌',
+    备注: 'Split CT 并联到PV Meter CT接口'
+  },
+  {
+    端口: {
+      text: 'RS485-A4&B4',
+      rowspan: 2 // 合并下方单元格
+    },
+    应用: {
+      text: 'Grid Meter',
+      rowspan: 2 // 合并下方单元格
+    },
+    出厂自带: '✔️',
+    备注: '测量aGate市电输入/输出功率'
+  },
+  {
+    // 端口上方已合并
+    // 应用上方已合并
+    出厂自带: '❌',
+    备注: 'Split CT 并联到Grid Meter CT接口'
+  }
+]" />

+ 5 - 0
docs/Hardware/Gate/aGate_1.2.md

@@ -0,0 +1,5 @@
+---
+title: aGate 1.2
+editLink: false
+---
+# aGate 1.2

+ 244 - 0
docs/Hardware/Gate/aGate_1.3.1.md

@@ -0,0 +1,244 @@
+---
+title: aGate 1.3.1
+editLink: false
+---
+# aGate 1.3.1
+
+aGate 1.3.1基于aGate 1.3进行升级优化,以满足SUNRUN客户的需求。
+
+## 编码说明
+
+### 编码变更
+
+* aGate 1.3.1整机编码: 10060010, 原aGate 1.3编码:10060005
+* aGate 1.3.1-EMS编码: 10080083, 原aGate 1.3-EMS编码:10080023
+* aGate 1.3.1-EMS备件编码: 10130095, 原aGate 1.3-EMS编码:10130071
+
+### 优化升级点
+
+* [**aPower Breaker功率提升:100A -> 125A**](#Breaker功率提升)
+  * aPower Breaker可以选择125A,接入2个aPower2.0 可以选择11.6kVA(10kW)功率段
+  * (旧1.3) 100A,接入2个aPower2.0,只能选择9.6kVA(8.4kw)
+  * 铭牌内容:aPower电流 80A->100A,位置挪到外门板
+  * 相关铜牌和塑胶外壳优化,满足散热需求
+* [**预留手动断市电继电器窗口**](#预留手动断市电继电器窗口)
+* [**CT2/3/4 硬件独立采集**](#CT硬件独立采集)
+  * CT2、CT3/4硬件变更为独立电路,单独计量(内部控制用,用户不可见)
+  * 可使用CT3/4实现部分备电PCS功能
+  * 市电总计量范围:Meter(GridCT+SplitCT3/4) < 200A,变为 Meter(GridCT) <200A 和 Meter(CT3/4)<200A,可以计量市电到400A
+
+#### aPower Breaker功率提升(100A -> 125A) {#Breaker功率提升}
+
+##### 产品变更(外观、标签、手册等)
+
+* 外门内侧样式变更
+<div style="display: flex; align-items: center; gap: 10px;">
+  <img src="./assets/aGate_1.3.1/aGate1.3外门内侧图.png" style="width: 45%;" />
+  <span style="font-size: 24px;"> -> </span>
+  <img src="./assets/aGate_1.3.1/aGate1.3.1外门内侧图.png" style="width: 45%;" />
+</div>
+
+* 内门样式变更
+<div style="display: flex; align-items: center; gap: 10px;">
+  <img src="./assets/aGate_1.3.1/aGate1.3内门图.png" style="width: 45%;" />
+  <span style="font-size: 24px;"> -> </span>
+  <img src="./assets/aGate_1.3.1/aGate1.3.1内门图.png" style="width: 45%;" />
+</div>
+
+* aGate的aPower空开电流变更(100A -> 125A)
+  * 安装手册变更
+  ![aPower空开125A手册变更1](./assets/aGate_1.3.1/aPower空开125A手册变更1.png)
+  ![aPower空开125A手册变更2](./assets/aGate_1.3.1/aPower空开125A手册变更2.png)
+
+  * 门板内侧原理图丝印更改
+  <div style="display: flex; align-items: center; gap: 10px;">
+    <img src="./assets/aGate_1.3.1/aPower空开100A丝印.png" style="width: 45%;" />
+    <span style="font-size: 24px;"> -> </span>
+    <img src="./assets/aGate_1.3.1/aPower空开125A丝印.png" style="width: 45%;" />
+  </div>
+
+  * aGate现场标签更改
+  <div style="display: flex; align-items: center; gap: 10px;">
+    <img src="./assets/aGate_1.3.1/aPower空开100A标签.png" style="width: 45%;" />
+    <span style="font-size: 24px;"> -> </span>
+    <img src="./assets/aGate_1.3.1/aPower空开125A标签.png" style="width: 45%;" />
+  </div>
+
+* aGate铭牌变更(aPower电流80A -> 100A, Model变更)
+<div style="display: flex; align-items: center; gap: 10px;">
+  <img src="./assets/aGate_1.3.1/aPower电流80A铭牌.png" style="width: 45%;" />
+  <span style="font-size: 24px;"> -> </span>
+  <img src="./assets/aGate_1.3.1/aPower电流100A铭牌.png" style="width: 45%;" />
+</div>
+
+##### 硬件变更(增强散热)
+
+* 与aPower Breaker连接的铜牌距离加宽增强散热
+![aGate1.3.1铜牌加宽图](./assets/aGate_1.3.1/aGate1.3.1铜牌加宽图.png)
+* 塑胶件做开孔增强散热
+![aGate1.3.1继电器盒开孔图](./assets/aGate_1.3.1/aGate1.3.1继电器盒开孔图.png)
+
+##### 软件变更
+
+* aPower空开电流限制调整
+  * EMS软件需将aPower空开电流限制100A变更为125A
+
+::: danger
+EMS可能未实现aPower空开电流限制功能,待确认。
+:::
+
+::: warning
+事实上,aPower空开电流限制功能是对接入aPower空开的aPower设备进行功率限制,以确保这些aPower总功率不超出空开的额定功率导致跳闸。但实际情况是aPower也可能接在备电端的配电盘中,此处的aPower是不需要限制功率的,但从软件的角度看无法将其识别。
+:::
+
+#### 预留手动断市电继电器窗口
+
+* aGate内面板开孔(在系统故障且继电器断开时,客户可根据Franklin客服指导操作闭合继电器,保证家庭供电)
+![aGate1.3.1内门开孔图](./assets/aGate_1.3.1/aGate1.3.1内门开孔图.png)
+
+#### CT2/3/4 硬件独立采集 {#CT硬件独立采集}
+
+* 市电CT电路变更(CT3/CT4)
+<div style="display: flex; align-items: center; gap: 10px;">
+  <img src="./assets/aGate_1.3.1/aGate1.3市电CT采样原理图.png" style="width: 45%;" />
+  <span style="font-size: 24px;"> -> </span>
+  <img src="./assets/aGate_1.3.1/aGate1.3.1市电CT采样原理图.png" style="width: 45%;" />
+</div>
+
+* 光伏CT电路变更(CT2)
+<div style="display: flex; align-items: center; gap: 10px;">
+  <img src="./assets/aGate_1.3.1/aGate1.3光伏CT采样原理图.png" style="width: 45%;" />
+  <span style="font-size: 24px;"> -> </span>
+  <img src="./assets/aGate_1.3.1/aGate1.3.1光伏CT采样原理图.png" style="width: 45%;" />
+</div>
+
+独立采样,配置更灵活,可支持配电方案,具体可支持的配电方案,请参考[系统配电](/System/SystemDistribution.md)。
+
+## EMS接口说明
+
+**接口图**
+![aGate1.3-EMS接口图](./assets/public/aGate1.3-EMS接口图.jpg)
+
+**接口原理图**
+![aGate1.3.1-EMS接口原理图](./assets/aGate_1.3.1/aGate1.3.1-EMS接口原理图.png)
+
+**接口说明表**
+<NormalTable
+:config = "[
+  '端口', 
+  '应用', 
+  {
+    title: '出厂自带',
+    align: 'center',
+    width: '60px'
+  }, 
+  '备注'
+]"
+:data="[
+  {
+    端口: 'PV-CT',
+    应用: 'PV CT',
+    出厂自带: '✔️',
+    备注: '测量aGate PV输入功率'
+  },
+  {
+    端口: 'Grid-CT-L2',
+    应用: 'Grid CT L2',
+    出厂自带: '✔️',
+    备注: '测量aGate市电L2输入/输出功率'
+  },
+  {
+    端口: 'Grid-CT-L1',
+    应用: 'Grid CT L1',
+    出厂自带: '✔️',
+    备注: '测量aGate市电L1输入/输出功率'
+  },
+  {
+    端口: 'CT1',
+    应用: 'Secondary solar meter (NEM CT)',
+    出厂自带: '❌',
+    备注: '测量NEM PV输入功率(NEM PV不限制馈网),只能接到Grid CT & EX Grid CT的市电侧'
+  },
+  {
+    端口: 'CT2',
+    应用: 'EX PV CT',
+    出厂自带: '❌',
+    备注: 'Split CT,独立采样,默认功率累加到PV CT,可进行功能定制'
+  },
+  {
+    端口: 'CT3',
+    应用: 'EX Grid CT L2',
+    出厂自带: '❌',
+    备注: 'Split CT,独立采样,默认功率累加到Grid CT L2,可进行功能定制'
+  },
+  {
+    端口: 'CT4',
+    应用: 'EX Grid CT L1',
+    出厂自带: '❌',
+    备注: 'Split CT,独立采样,默认功率累加到Grid CT L1,可进行功能定制'
+  },
+  {
+    端口: {
+      text: 'Gen CT',
+      rowspan: 2
+    },
+    应用: 'Generator CT',
+    出厂自带: '❌',
+    备注: '1、测量发电机输入功率\n2、紧急电源输入(V2L),车辆给家庭负载供电和充电'
+  },
+  {
+    应用: 'Partial backup Grid CT',
+    出厂自带: '❌',
+    备注: '部分备电场景的市电CT'
+  },
+  {
+    端口: 'SMT-CT 120V-L1',
+    应用: 'Smart Circuit 1 CT',
+    出厂自带: '✔️',
+    备注: '测量智能负载1输入/输出功率'
+  },
+  {
+    端口: 'SMT-CT 120V-L2',
+    应用: 'Smart Circuit 2 CT',
+    出厂自带: '✔️',
+    备注: '测量智能负载2输入/输出功率'
+  },
+  {
+    端口: 'SMT-CT 240V-L2',
+    应用: 'Smart Circuit 3 CT',
+    出厂自带: '✔️',
+    备注: '测量智能负载3输入/输出功率(EV charging,etc.)'
+  },
+  {
+    端口: {
+      text: 'RS485-A1&B1',
+      rowspan: 2
+    },
+    应用: 'aPbox 1 Meter',
+    出厂自带: '❌',
+    备注: '测量aPbox1 PV输入功率'
+  },
+  {
+    应用: 'Meter Kit',
+    出厂自带: '❌',
+    备注: '测量NEM PV输入功率(NEM PV不限制馈网),只能接到Grid CT & EX Grid CT的市电侧'
+  },
+  {
+    端口: 'RS485-A2&B2',
+    应用: 'aPbox 2 Meter',
+    出厂自带: '❌',
+    备注: '测量aPbox2 PV输入功率'
+  },
+  {
+    端口: 'RS485-A3&B3',
+    应用: 'Null',
+    出厂自带: '❌',
+    备注: ''
+  },
+  {
+    端口: 'RS485-A4&B4',
+    应用: 'Null',
+    出厂自带: '❌',
+    备注: ''
+  }
+]" />

+ 134 - 0
docs/Hardware/Gate/aGate_1.3.md

@@ -0,0 +1,134 @@
+---
+title: aGate 1.3
+editLink: false
+---
+# aGate 1.3
+
+## EMS接口说明
+
+**接口图**
+![aGate1.3-EMS接口图](./assets/public/aGate1.3-EMS接口图.jpg)
+
+**接口原理图**
+![aGate1.3-EMS接口原理图](./assets/aGate_1.3/aGate1.3-EMS接口原理图.png)
+
+**接口说明表**
+<NormalTable
+:config = "[
+  '端口', 
+  '应用', 
+  {
+    title: '出厂自带',
+    align: 'center',
+    width: '60px'
+  }, 
+  '备注'
+]"
+:data="[
+  {
+    端口: 'PV-CT',
+    应用: 'PV CT',
+    出厂自带: '✔️',
+    备注: '测量aGate PV输入功率'
+  },
+  {
+    端口: 'Grid-CT-L2',
+    应用: 'Grid CT L2',
+    出厂自带: '✔️',
+    备注: '测量aGate市电L2输入/输出功率'
+  },
+  {
+    端口: 'Grid-CT-L1',
+    应用: 'Grid CT L1',
+    出厂自带: '✔️',
+    备注: '测量aGate市电L1输入/输出功率'
+  },
+  {
+    端口: 'CT1',
+    应用: 'Secondary solar meter (NEM CT)',
+    出厂自带: '❌',
+    备注: '测量NEM PV输入功率(NEM PV不限制馈网),只能接到Grid CT & EX Grid CT的市电侧'
+  },
+  {
+    端口: 'CT2',
+    应用: 'EX PV CT',
+    出厂自带: '❌',
+    备注: 'Split CT,硬件与PV-CT并联,功率累加到PV CT'
+  },
+  {
+    端口: 'CT3',
+    应用: 'EX Grid CT L2',
+    出厂自带: '❌',
+    备注: 'Split CT,硬件与Grid-CT-L2并联,功率累加到Grid CT L2'
+  },
+  {
+    端口: 'CT4',
+    应用: 'EX Grid CT L1',
+    出厂自带: '❌',
+    备注: 'Split CT,硬件与Grid-CT-L1并联,功率累加到Grid CT L1'
+  },
+  {
+    端口: {
+      text: 'Gen CT',
+      rowspan: 2
+    },
+    应用: 'Generator CT',
+    出厂自带: '❌',
+    备注: '1、测量发电机输入功率\n2、紧急电源输入(V2L),车辆给家庭负载供电和充电'
+  },
+  {
+    应用: 'Partial backup Grid CT',
+    出厂自带: '❌',
+    备注: '部分备电场景的市电CT'
+  },
+  {
+    端口: 'SMT-CT 120V-L1',
+    应用: 'Smart Circuit 1 CT',
+    出厂自带: '✔️',
+    备注: '测量智能负载1输入/输出功率'
+  },
+  {
+    端口: 'SMT-CT 120V-L2',
+    应用: 'Smart Circuit 2 CT',
+    出厂自带: '✔️',
+    备注: '测量智能负载2输入/输出功率'
+  },
+  {
+    端口: 'SMT-CT 240V-L2',
+    应用: 'Smart Circuit 3 CT',
+    出厂自带: '✔️',
+    备注: '测量智能负载3输入/输出功率(EV charging,etc.)'
+  },
+  {
+    端口: {
+      text: 'RS485-A1&B1',
+      rowspan: 2
+    },
+    应用: 'aPbox 1 Meter',
+    出厂自带: '❌',
+    备注: '测量aPbox1 PV输入功率'
+  },
+  {
+    应用: 'Meter Kit',
+    出厂自带: '❌',
+    备注: '测量NEM PV输入功率(NEM PV不限制馈网),只能接到Grid CT & EX Grid CT的市电侧'
+  },
+  {
+    端口: 'RS485-A2&B2',
+    应用: 'aPbox 2 Meter',
+    出厂自带: '❌',
+    备注: '测量aPbox2 PV输入功率'
+  },
+  {
+    端口: 'RS485-A3&B3',
+    应用: 'Null',
+    出厂自带: '❌',
+    备注: ''
+  },
+  {
+    端口: 'RS485-A4&B4',
+    应用: 'Null',
+    出厂自带: '❌',
+    备注: ''
+  }
+]" />

二進制
docs/Hardware/Gate/assets/MSA_C/ConnectDER-MSA外观.png


二進制
docs/Hardware/Gate/assets/MSA_C/EQB-MSA外观.png


二進制
docs/Hardware/Gate/assets/MSA_C/MSA-C+CND+aPbox2.0电气方案图.png


二進制
docs/Hardware/Gate/assets/MSA_C/MSA-C+CND电气方案图.png


二進制
docs/Hardware/Gate/assets/MSA_C/MSA-C+EQB+aPbox2.0电气方案图.png


二進制
docs/Hardware/Gate/assets/MSA_C/MSA-C+EQB电气方案图.png


二進制
docs/Hardware/Gate/assets/MSA_C/MSA-C+aPbox2.0典型应用系统图.png


二進制
docs/Hardware/Gate/assets/MSA_C/MSA-C+aPbox2.0系统架构图.jpg


二進制
docs/Hardware/Gate/assets/MSA_C/MSA-C+aPbox2.0系统架构图.png


二進制
docs/Hardware/Gate/assets/MSA_C/MSA-C+aPbox2.0系统组成示意图.png


二進制
docs/Hardware/Gate/assets/MSA_C/MSA-C不连接MSA电气方案图.png


二進制
docs/Hardware/Gate/assets/aGate_1.0/aGate1.0-EMS接口原理图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1-EMS接口原理图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1光伏CT采样原理图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1内门图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1内门开孔图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1外门内侧图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1市电CT采样原理图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1继电器盒开孔图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3.1铜牌加宽图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3光伏CT采样原理图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3内门图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3外门内侧图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aGate1.3市电CT采样原理图.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aPower电流100A铭牌.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aPower电流80A铭牌.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开100A丝印.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开100A标签.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开125A丝印.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开125A手册变更1.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开125A手册变更2.png


二進制
docs/Hardware/Gate/assets/aGate_1.3.1/aPower空开125A标签.png


二進制
docs/Hardware/Gate/assets/aGate_1.3/aGate1.3-EMS接口原理图.png


二進制
docs/Hardware/Gate/assets/public/aGate1.3-EMS接口图.jpg


+ 21 - 0
docs/Hardware/Gate/index.md

@@ -0,0 +1,21 @@
+---
+title: Gate
+editLink: false
+---
+# Gate
+
+Gate产品主要用于家庭能源管理。监控家庭能量流(电网、PV、发电机、负载等),通过调度电池能源、控制PV输出、接入发电机、切断家庭负载等方式实现家庭能源管理。同时Gate还将提供基础的配电电路,简化家庭配电设计,便于安装。
+
+## 产品列表
+
+[aGate 1.0/1.1](./aGate_1.0.md) - 第一代家庭智能网关(北美版本)
+
+[aGate 1.2](./aGate_1.2.md) - 第一代家庭智能网关(澳洲版本)
+
+[aGate 1.3](./aGate_1.3.md) - 第一代家庭智能网关升级版(北美版本)
+
+[aGate 1.3.1](./aGate_1.3.1.md) - 第一代家庭智能网关升级版(北美版本) - 优化CT采样和继电器散热
+
+[MSA-C](./MSA-C.md) - 适配电表座(Meter Socket Adapter)的家庭智能网关(北美版本) - 无配电电路
+
+[MSA-C2](./MSA-C2.md) - 适配电表座(Meter Socket Adapter)的家庭智能网关(北美版本) - 嵌入安装到aPower S中

+ 0 - 0
docs/Hardware/Other/GeneratorModule.md


+ 0 - 0
docs/Hardware/Other/MeterKit.md


+ 0 - 0
docs/Hardware/Other/aPbox_1.0.md


+ 0 - 0
docs/Hardware/Other/aPbox_2.0.md


二進制
docs/Hardware/Other/assets/aPbox_2.0/aPbox2.0整体布局示意图.png


二進制
docs/Hardware/Other/assets/aPbox_2.0/aPbox2.0电气原理示意图.png


二進制
docs/Hardware/Other/assets/aPbox_2.0/aPbox2.0硬件架构图.png


二進制
docs/Hardware/Other/assets/aPbox_2.0/aPbox2.0造型设计图.png


+ 17 - 0
docs/Hardware/Other/index.md

@@ -0,0 +1,17 @@
+---
+title: 配件
+editLink: false
+---
+# 配件
+
+各种配件用于拓展可用场景,对多样化的安装场景提供功能支持。
+
+## 产品列表
+
+[aPbox 1.0](./aPbox_1.0.md) - 第一代aPbox,实现远端PV接入监控和控制
+
+[aPbox 2.0](./aPbox_2.0.md) - 第二代aPbox,多功能智能配电盘,可连接发电机、V2L、PV、智能负载,配合MSA-C实现配电管理
+
+[Meter Kit](./MeterKit.md) - 电表组件,用于监控非备电远端PV
+
+[Generator Module](./GeneratorModule.md) - 发电机模块,用于实现发电机接入的硬件模块

+ 7 - 0
docs/Hardware/Power/aPower_1.0.md

@@ -0,0 +1,7 @@
+---
+title: aPower 1.0
+editLink: false
+---
+# aPower 1.0
+
+

+ 7 - 0
docs/Hardware/Power/aPower_1.2.md

@@ -0,0 +1,7 @@
+---
+title: aPower 1.2
+editLink: false
+---
+# aPower 1.2
+
+

+ 7 - 0
docs/Hardware/Power/aPower_1.3.md

@@ -0,0 +1,7 @@
+---
+title: aPower 1.3
+editLink: false
+---
+# aPower 1.3
+
+

+ 13 - 0
docs/Hardware/Power/aPower_2.md

@@ -0,0 +1,13 @@
+---
+title: aPower 2
+editLink: false
+---
+# aPower 2
+
+**功率等级**
+ | 功率等级(kva) | 功率等级(kw) |
+ | ------- | ------ |
+ | 11.5kva | 10kw |
+ | 11kva | 9.6kw |
+ | 8.74kva | 7.68kw |
+ | 5.8kva | 5kw |

+ 7 - 0
docs/Hardware/Power/aPower_S.md

@@ -0,0 +1,7 @@
+---
+title: aPower S
+editLink: false
+---
+# aPower S
+
+

+ 19 - 0
docs/Hardware/Power/index.md

@@ -0,0 +1,19 @@
+---
+title: Power
+editLink: false
+---
+# Power
+
+Power产品主要为家庭提供储能支持,主要用于存储来自太阳的清洁能源,并为家庭提供电力支持。
+
+## 产品列表
+
+[aPower 1.0](./aPower_1.0.md) - 第一代家庭储能电池(北美版本)
+
+[aPower 1.2](./aPower_1.2.md) - 第一代家庭储能电池(澳洲版本)
+
+[aPower 1.3](./aPower_1.3.md) - 第一代家庭储能电池升级版(北美版本) - 功率升级,性能提升
+
+[aPower 2](./aPower_2.md) - 第二代家庭储能电池(北美版本) - 功率性能全面提升
+
+[aPower S](./aPower_S.md) - 基于第二代家庭储能电池(北美版本)加入PV接入功能

+ 41 - 0
docs/Hardware/index.md

@@ -0,0 +1,41 @@
+---
+title: 硬件产品
+editLink: false
+---
+# 硬件产品
+
+## [Gate](./Gate) - 家庭智能网关
+
+[aGate 1.0/1.1](./Gate/aGate_1.0.md) - 第一代家庭智能网关(北美版本)
+
+[aGate 1.2](./Gate/aGate_1.2.md) - 第一代家庭智能网关(澳洲版本)
+
+[aGate 1.3](./Gate/aGate_1.3.md) - 第一代家庭智能网关升级版(北美版本)
+
+[aGate 1.3.1](./Gate/aGate_1.3.1.md) - 第一代家庭智能网关升级版(北美版本) - 优化CT采样和继电器散热
+
+[MSA-C](./Gate/MSA-C.md) - 适配电表座(Meter Socket Adapter)的家庭智能网关(北美版本) - 无配电电路
+
+[MSA-C2](./Gate/MSA-C2.md) - 适配电表座(Meter Socket Adapter)的家庭智能网关(北美版本) - 嵌入安装到aPower S中
+
+## [Power](./Power) - 家庭储能电池
+
+[aPower 1.0](./Power/aPower_1.0.md) - 第一代家庭储能电池(北美版本)
+
+[aPower 1.2](./Power/aPower_1.2.md) - 第一代家庭储能电池(澳洲版本)
+
+[aPower 1.3](./Power/aPower_1.3.md) - 第一代家庭储能电池升级版(北美版本) - 功率升级,性能提升
+
+[aPower 2](./Power/aPower_2.md) - 第二代家庭储能电池(北美版本) - 功率性能全面提升
+
+[aPower S](./Power/aPower_S.md) - 基于第二代家庭储能电池(北美版本)加入PV接入功能
+
+## [配件](./Other)
+
+[aPbox 1.0](./Other/aPbox_1.0.md) - 第一代aPbox,实现远端PV接入监控和控制
+
+[aPbox 2.0](./Other/aPbox_2.0.md) - 第二代aPbox,多功能智能配电盘,可连接发电机、V2L、PV、智能负载,配合MSA-C实现配电管理
+
+[Meter Kit](./Other/MeterKit.md) - 电表组件,用于监控非备电远端PV
+
+[Generator Module](./Other/GeneratorModule.md) - 发电机模块,用于实现发电机接入的硬件模块

+ 7 - 0
docs/Other/TariffScheduleTemplates.md

@@ -0,0 +1,7 @@
+---
+title: 资费套餐模版
+editLink: false
+---
+# 资费套餐模版
+
+

+ 4 - 0
docs/Software/BatteryHeating.md

@@ -0,0 +1,4 @@
+---
+title: 电池加热
+editLink: false
+---

+ 4 - 0
docs/Software/BlackStart.md

@@ -0,0 +1,4 @@
+---
+title: 黑启动
+editLink: false
+---

+ 17 - 0
docs/Software/ModeManagement.md

@@ -0,0 +1,17 @@
+---
+title: 模式管理
+editLink: false
+---
+# 模式管理
+
+模式管理功能主要用于管理系统的并网运行模式,包括:[自发自用(Self-Consumption)](#自发自用self-consumption)、[分时电价(Time of Use)](#分时电价time-of-use)、[备电(Emergency Backup)](#备电emergency-backup)。
+
+## 自发自用(Self-Consumption)
+
+自发自用模式下,电池仅使用PV能量进行充电,PV能量优先为负载提供支持,剩余能量将用于给电池充电,电池充满后,剩余能量会倒送电网。当PV能量不足以为负载提供支持时,电池将放电为负载提供支持。此模式将尽可能少的使用电网能量,达到减少电费的目的。
+
+自发自用模式可设置预留电量SOC,保证设备在市电停电(系统离网)时有足够的电量继续支撑家庭负载一段时间。
+
+## 分时电价(Time of Use)
+
+## 备电(Emergency Backup)

+ 366 - 0
docs/Software/PCS.md

@@ -0,0 +1,366 @@
+---
+title: PCS
+editLink: false
+---
+# PCS
+
+## 概述
+
+PCS(Power Control System - 电源控制系统)是一种能源管理系统,是UL 1741、NEC和FranklinWH使用的术语,指的是通过软件控制电力生产源(太阳能和电池)来维持系统功率限制。PCS软件可以限制aPower系统的功率,以便安全地与各种额定功率的家用电力系统互连。因此,PCS可以帮助避免昂贵的电力网升级、复杂的家庭负载再布置或系统性能下降。
+
+::: info
+PCS功能属于并网控制,光伏调频控制仅在电网中断期间可用,不被视为PCS的一部分,因此不属于本文档的范围。
+:::
+
+UL1741 CRD 针对 PCS 和 NEC 705.13(于 2020 年修订版推出)概述了系统符合电源控制系统(PCS)要求的各项要求。FranklinWH已根据这些标准开发了相关软件。PCS的实施是在审查系统风险之后进行的,以确保 PCS 控制与传统设计方法和过流保护相结合,能够确保系统安全有效地运行。FranklinWH已审查了系统所有部件的行为,以确保即使在通信中断或硬件故障的情况下也能做出正确的响应。
+
+电源控制系统 (PCS) 方法和实施可用于管理系统以强制执行以下限制:
+
+* 站点限制(Site Limit):防止站点经过市电电表从电网进行过多的导入/导出
+* 导线限制(Conductor Limit):通过特定的监控和控制,防止导线产生过大电流
+* 面板限制(Panel Limit):防止[虚拟面板](#虚拟面板)由市电、太阳能和电池供电时产生过大电流
+* 功率/电流限制(Power/Current Limit - 仅限aPower 2):限制aPower 2的功率和电流输出
+
+::: info
+站点、导线、配电盘以及功率/电流限制均属于PCS范畴。这些术语是FranklinWH整套PCS控制特定的具体功能,并且可以同时运行。
+:::
+
+FranklinWH和国家认可测试实验室(NRTL)已认证电源控制系统的实施,其所有这些功能均符合 UL1741-CRD-PCS 中规定的标准,适用于配备240V/208V-200A裂项电网的aGate 1.0/1.1、aGate1.3等设备。
+
+### 词汇表 TODO
+
+<ParameterTable :items="[
+{
+name: '能源管理系统(EMS)',
+content: 'NEC中使用的术语,指生产源(太阳能和电池)的软件控制,以维持系统限制。'
+},
+{
+name: '电源控制系统(PCS)',
+content: 'UL 1741、NEC 和Tesla使用的术语,指用于维持系统限值的发电源(太阳能和电池)的软件控制。PCS 是一种能源管理系统 (EMS)。'
+},
+{
+name: '电源墙',
+content: 'Powerwall 是一款可充电家用电池,也称为电池储能系统 (BESS)。Powerwall 内置电能表,可自行计量电池电量。'
+},
+{
+name: 'Powerwall 3 扩展',
+content: 'Powerwall 3 扩展装置是一款可充电家用电池,可为 Powerwall 3 系统提供额外的存储空间。扩展装置必须与 Powerwall 3 一起安装,并且只能通过扩展线束相互连接或连接到 Powerwall 3。有关更多信息,请参阅 Powerwall 3 安装手册。'
+},
+{
+name: '合格人员',
+content: '特斯拉安装人员或认证安装人员具备与电气设备安装和操作相关的技能和知识,能够识别和避免所涉及的危险。'
+},
+{
+name: '站点限制',
+content: '防止通过现场仪表从/到公用设施连接过度导入和/或导出。'
+},
+{
+name: '导体限制',
+content: '防止过大电流通过特定的监控和控制导体。'
+},
+{
+name: '面板限制',
+content: '防止由场地、太阳能和电池供电的虚拟面板出现过大电流。'
+},
+{
+name: '功率/电流限制(仅限Powerwall 3 )',
+content: '配置Powerwall 3的最大功率/电流输出。'
+},
+{
+name: '网格',
+content: '通过一个或多个物理现场仪表进行测量,以测量从电网流入虚拟面板的全部电流。'
+},
+{
+name: '不受控制的来源',
+content: '任何未通过直接通信连接的设备,包括独立太阳能逆变器或类似的虚拟面板电源。'
+},
+{
+name: '受控源',
+content: '任何通过直接通信连接的设备,包括任意数量和组合(例如一个 Powerwall+ 和两个 Powerwall 2)的Powerwall 2、Powerwall+ 或 Powerwall 3,都可以限制电流。'
+},
+{
+name: '虚拟面板',
+content: '可能包含多个配电盘和导线,但根据配电盘限制功能,视为单个配电盘。由于可能包含多个物理配电盘和导线,因此配电盘限制设置为“虚拟配电盘”中最小母线的额定值。'
+},
+{
+name: '电能表',
+content: '测量电压和电流以计算净功率流。电表类型包括站点电表、太阳能电表、导体电表或负载电表。有关电表类型的更多信息,请参阅《住宅电表指南》 。'
+},
+{
+name: '电流互感器(CT)',
+content: '安装在导体周围,用于监测流经导体的电流。电流互感器连接到电能表,电能表利用电流测量值来计算产生或消耗的电量。'
+},
+{
+name: '现场控制器',
+content: '特斯拉的站点控制器是协调站点功能(如充电、放电、自耗、电源控制等)的计算机。所有 Powerwall 系统都有一个站点控制器。'
+},
+{
+name: 'X米',
+content: '备用网关2内置电表,其CT不可移动。'
+},
+{
+name: 'Y 仪表',
+content: '备份网关 2 内置电表,带有三个 CT 端子。Tesla CT 已安装并连接到电表 Y。'
+},
+{
+name: 'Z 仪表',
+content: '备用交换机和网关3内置电表,其CT不可移动。'
+},
+{
+name: '远程电能表',
+content: '可安装并与站点控制器配对的外部电表,以增强计量功能。Tesla远程电能表以及 Neurio W1 和 W2 电表均支持远程电能表。'
+}
+]" />
+
+### EMS控制软件
+
+“面板限制”、“站点限制”、“导体限制”和“电流限制”功能均在FranklinWH EMS控制软件中实现,该软件可在aGate 1.0/1.1、aGate 1.3、MSA-C、MSA-C2上运行,具体取决于系统配置。所有FranklinWH系统站点均使用FranklinWH EMS控制软件。EMS控制软件控制整个储能站点,并通过 CAN 和 Wi-Fi 进行通信。aPower 1.0、aPower 1.3、aPower 2、aPower S、Meter Kit、aPbox 1.0、aPbox 2.0(如有)是FranklinWH系统的一部分,并根据需要包含每相电流传感器,用作站点电表或太阳能电表,用于测量电网和不受控制的发电源。
+
+该软件符合 UL 1741 PCS 的要求,并符合 2020 年和 2023 年 NEC 第 705.13 条的规定,如UL 1741 PCS 合规性的重要说明中所述。
+
+### 功能配置概述 TODO
+
+本文档更详细地介绍了以下每个设置;此表提供了用户可以配置每个功能的简单参考。
+
+<SimpleTable :data="[
+  {
+    特征: '面板限制(数值限制)',
+    描述: '限制从所有受控和不受控源(电网、太阳能逆变器和 Powerwall)流入电气面板/母线的电流强度。',
+    配置方式: 'Powerwall 2、Powerwall+ 或 Powerwall 3的默认限制为 80 A ,可由合格人员配置为 10-200 A'
+  },
+  {
+    特征: 'Powerwall 3功率/电流限制(数值限制)',
+    描述: '改变Powerwall 3的最大功率/电流输出。',
+    配置方式: '合格人员(请参阅Powerwall 3 的功率/电流限制功能)'
+  },
+  {
+    特征: '站点导入权限(导入样式)',
+    描述: 'Powerwall 是否可以通过太阳能、电网或两者充电。',
+    配置方式: '合格人员'
+  },
+  {
+    特征: '站点导入限制(数字限制)',
+    描述: '限制站点可以从电网导入的数量。',
+    配置方式: '合格人员'
+  },
+  {
+    特征: '站点导出权限(导出样式)',
+    描述: 'Powerwall 和/或太阳能是否可以输出到电网。',
+    配置方式: '合格人员'
+  },
+  {
+    特征: '站点导出限制(数值限制)',
+    描述: '限制可输出到电网的电量。',
+    配置方式: '合格人员'
+  },
+  {
+    特征: '导体限制(数值限制)',
+    描述: '减少系统中所有 Powerwall 的功率,以限制导体中的总电流,使限制值等于“电源输出电路电流”。2020 NEC / 2022 CEC 第 705.12(B) 条的要求对此进行了概述。',
+    配置方式: '合格人员'
+  }
+]" />
+
+## 合规性要求 TODO
+
+互连公用事业公司或管辖机构 (AHJ)(也称为站点主机)可能要求符合 UL 1741 PCS 标准。如果适用的站点设计要求符合 UL 1741 PCS 标准,请遵循本节中的指导。
+
+Powerwall和受控太阳能系统符合 UL 1741 PCS 标准,其标称电压为:240V(分相)。
+
+根据本节所述的要求,配备备用开关、备用网关 2 或网关 3 的系统大小最高可达 200 A,可满足 UL 1741 PCS 的要求 。
+
+### 设计方案(Plan Set)标注要求 TODO
+
+所有受PCS控制的导线和母线均应在单线图中标明。
+
+图1. 示例单线图中标注的受控导线
+图纸 TODO
+
+### 面板标签要求 TODO
+
+受控系统内所有配电盘的PCS控制电流设定值应使用现场标记标签在导体上或配电盘附近标明。请参考下方标签,并在空白处填写相应的值:
+
+图 2. PCS 现场标记标签
+图片 TODO
+
+### CT标配要求 TODO
+
+必须在 CT 安装附近张贴以下标签:
+
+* 对于面板限制,标记所有站点和太阳能 CT(可能包括备用网关 2(电表 X 和电表 Y)CT、备用开关(电表 Z)、网关 3(电表 Z)和/或远程能量计 CT,不包括为收入级计量安装的 CT)。
+* 对于站点限制,标记所有站点 CT(可能包括备用网关 2(电表 X 和电表 Y)CT、备用开关(电表 Z)、网关 3(电表 Z)和/或远程能量计 CT)。
+* 对于导体限值,标记所有导体 CT(可能包括备用网关 2(电表 X 或电表 Y)CT 或远程电能表 CT)。
+
+图 3. CT 场标记标签
+图片 TODO
+
+### 关于 UL 1741 PCS 合规性的重要说明 TODO
+
+:::info
+配备电源控制系统 (PCS) 的系统必须具有适当的额定值,以提供分支电路过流保护。控制电流设置不得超过任何控制面板或导线载流量的额定值。
+:::
+
+:::warning
+电源控制系统的配置或设置更改必须由合格人员进行。错误的电源控制配置或设置可能会导致不安全状况。
+:::
+
+:::info
+最大 PCS 控制电流设置:200 A。
+:::
+
+:::info
+受控母线或导体的最大工作电流受电力控制系统 (PCS) 的设定值限制,并且可能低于所连接受控电源的电流总和。PCS 控制电流的设定值可用于计算 NEC 第 690 条和第 705 条相关章节中使用的设计电流。
+:::
+
+**监控(2020 NEC,705.13(A),UL 1741 PCS)**
+
+* Tesla 站点控制器从备用开关(电表 Z)、网关 3(电表 Z)或备用网关 2(电表 X 和/或电表 Y)和/或远程电能表接收电表数据。
+* Tesla 站点控制器接收 Powerwall 和受控太阳能的交流输出的受控电流数据。
+* 每个限值均由计量装置监控,如下所示:
+  * 通过将流入虚拟面板的电流(来自站点、太阳能和电池的净电流)相加来监控面板限值。该限值适用于所有相的总电流。
+  * 站点限制由所有站点仪表的总和监控。该限制对所有阶段的总和强制执行。
+  * 导线限值由每个导线电表监控。限值在每个相上独立执行。
+* 当与相关电表的通信中断时,所有受控的电力生产源都会受到限制。
+
+**设置(2020 NEC 705.13(B)、2023 NEC 750.30(C)(1)、UL 1741 PCS 201.9)**
+
+* 各默认值如下:
+  * 默认情况下,所有 Powerwall 2、 Powerwall+和Powerwall 3的面板电流限制均自动配置为 80 A。根据 NEC 230.79 (C) 标准,100 A 是单户住宅的最小允许电流,因此,无论安装多少台 Tesla Powerwall,这都能保护单户住宅的任何主面板母线(80 A 是 100 A 最小服务电流的 80%)。
+    :::info
+    多户住宅的最小允许尺寸为 60 A,下游子面板也可能小于 100 A。
+    :::
+  * Powerwall 3默认最大电流输出为 48 A。使用 Powerwall 3 的功率/电流限制功能时,安装人员可以为 Tesla One 配置 41.7 A、31.7 A 或 24 A 的最大电流输出。
+  * 站点导入权限默认设置为允许通过电网充电。但是,只有客户才能在其 Tesla 应用程序中启用通过电网充电。除非客户将“电网充电”设置为“是”,否则系统将无法通过电网充电。
+  * 站点导出权限默认设置为允许仅导出太阳能。
+  * 默认情况下未设置站点限制。
+  * 默认情况下未设置导体限制。
+* 可以通过查看Tesla One中的“摘要”页面来验证 PCS 限制。
+
+**过流保护(2020 NEC 705.13(C) 和 UL 1741 PCS 201.2)**
+
+* Tesla Site Controller 软件已通过 UL 1741 PCS 认证,可作为二次过流保护。
+* 根据国家电气规范的要求,每个电路仍然需要安装过流保护装置。
+* 过流保护装置的尺寸应按照国家电气规范要求的导体、面板和相关设备的额定值来确定。
+* 软件尽力管理电池和受控太阳能的输出,以防止超过每个配置的限制。
+  * 对于面板限制,软件会限制电池和/或受控太阳能,以防止超过配置的限制。
+  * 对于功率/电流限制(仅限Powerwall 3),软件会限制受控太阳能,以防止超过配置的最大电流输出。
+  * 对于站点限制,软件既会限制电池和/或受控太阳能,又会通过 Powerwall 充电或放电进行补偿,以防止超过配置的限制。
+  * 对于导体限制,软件既会限制电池和/或受控太阳能,又会通过 Powerwall 充电或放电进行补偿,以防止超过配置的限制。
+  * 一旦电池和受控太阳能被削减,或者它们没有额外的电力或能量来补偿负载,则平衡将由电网供电。
+  * 因此,断路器的尺寸应能保护现场的所有导体、母线和设备,就像添加二次电源之前预期的那样。
+
+**单一电源额定值(2020 NEC 705.13(D) 和 UL 1741 PCS 201.9)**
+
+* 每个 Powerwall 2 与配电板的连接都需要一个独立的 30 A 断路器。
+* 每个 Powerwall+ 与配电板的连接都需要一个独立的 50 A 断路器。
+* 每个 Powerwall 3与配电板的连接都需要一个独立的断路器;默认断路器尺寸为 60 A,但如果配置功率/电流限制,则可能会更小(请参阅Powerwall 3 的功率/电流限制功能)。
+  * Powerwall 3 扩展未连接到断路器;它仅通过扩展线束连接到Powerwall 3和/或另一个扩展单元。
+* 该断路器用作设备的断路装置,必须按照当地接线规范和规定进行接线。
+* 该断路器的设计比系统内使用的最小面板还要小。
+* 通过使用 PCS,服务断开过电流额定值可以保持不变。
+
+**访问设置(2020 NEC 705.13(E)、2023 NEC 750.30(C)(3)、UL 1741 PCS 208.1(F))**
+
+* 面板限制、站点限制、导体限制和/或功率/电流限制可由合格人员(特斯拉或认证安装人员)在受密码保护的Tesla One应用程序中配置。
+  * 对于面板限制,所有 Powerwall 2、 Powerwall+和Powerwall 3系统均自动启用 80 A 的限制。合格人员可以在Tesla One应用程序中更改此限制。
+
+## PCS使用场景介绍
+
+### 全屋备电(Whole Backup)
+
+**aGate**
+
+全屋备电场景中,市电入户直接连接到aGate,是标准的使用场景,全屋备电示意图如下图所示:
+
+![WholeBackupSchematic](./assets/PCS/WholeBackupSchematic.png)
+
+**MSA**
+
+待补充。。。
+
+### 部分备电(Partial Backup)
+
+**aGate**
+
+部分备电分为两个子场景:
+* **场景一** - aGate连接市电入户一分二接线盒
+* **场景二** - aGate连接用户配电盘
+
+在场景一中,aGate连接到市电入户一分二接线盒,市电入户仅有两个分支,我们可以利用市电split CT来采集另一分支的电流来计算总市电功率,市电Split CT统计的能量视为非备电能量,配电示意图如下图所示:
+
+![PartialBackupSchematic-UseSplitCT](./assets/PCS/PartialBackupSchematic-UseSplitCT.png)
+
+在场景二中,aGate连接用户配电盘,市电入口连接在用户配电盘上,需要统计配电盘市电入口总功率,可以使用市电Split CT或Gen CT来对配电盘市电入口总功率进行统计。市电Split CT接法如下图所示:
+
+<div style="display: flex; align-items: center; gap: 10px;">
+  <div style="text-align: center; width: 45%;">
+    <img src="./assets/PCS/PartialBackupSchematic-OnlyUseSplitCT-1.0.png" />
+    <span>aGate 1.0/1.1</span>
+  </div>
+  <div style="text-align: center; width: 45%;">
+    <img src="./assets/PCS/PartialBackupSchematic-OnlyUseSplitCT-1.3.png" />
+    <span>aGate 1.3</span>
+  </div>
+</div>
+
+Gen CT接法如下图所示:
+
+![PartialBackupSchematic-UseGenCT](./assets/PCS/PartialBackupSchematic-UseGenCT.png)
+
+**MSA**
+
+待补充。。。
+
+## PCS限制/保护功能
+
+### 市电电流限制(Site Limit)
+
+控制市电入口总功率,避免受控源功率调节不当导致市电入口功率过大引发电网使用违规或市电总空开跳闸。
+
+::: info
+由不可控源功率过大导致的市电入口功率过大引发市电总空开跳闸,不在本功能考虑范围之内。此情况由市电空开正常跳闸保护。
+因不可控源导致的引发电网使用违规,本系统不承担任何责任。
+:::
+
+市电电流限制分为两个设置项Grid Export Limit和Grid Import Limit,分别限制市电口倒送功率和用电功率。APP设置项如下图所示:
+
+![GridImportAndExport](./assets/PCS/GridImportAndExport.png){ style="width: 40%; height: auto;" }
+
+**Grid Export Limit**
+
+当市电倒送功率超过限制值时:
+1. 先调节aPower功率,尝试增加aPower充电功率或减少aPower放电功率。
+2. 当18s内还不能调节市电倒送功率到限制功率以内时,切断PV继电器(多光伏场景参见下方说明)。
+3. PV继电器将在15min后自动恢复。
+
+多光伏场景下,根据倒送功率值和PV实际发电功率,切断接近倒送功率值的最小功率的PV。
+
+**Grid Import Limit**
+
+当市电取电功率超过限制值时:
+1. 先调节aPower功率,尝试减少aPower充电功率或增加aPower放电功率。
+2. 当18s内还不能调节市电取电功率到限制功率以内时,提醒用户已超过Grid Import Limit限制(**提醒未实现**),提醒用户卸掉部分负载。
+3. 当市电取电电流超过市电空开(Mains breaker)额定电流时,将可能触发空开跳闸。(空开跳闸后,系统将进入离网运行,储能系统可能也无法支撑如此大的负载功率)
+
+::: info
+可以考虑智能甩载,但处理不好容易适得其反。
+:::
+
+### Busbar电流限制(Panel Limit)
+
+
+
+### Conductor Limit
+
+### aPower总电流限制
+
+### aPower额定功率限制(仅aPower 2/S)
+
+## 面板限制功能
+
+## 站点限制功能
+
+## 导线限制功能
+
+## 功率/电流限制功能(仅aPower 2/S)
+
+## 附录A:

+ 23 - 0
docs/Software/PV_FreqWatt.md

@@ -0,0 +1,23 @@
+---
+title: 光伏调频
+editLink: false
+---
+# 光伏调频
+
+## 概述
+
+光伏安装容量大于储能容量场景下,往往会有限制光伏功率的场景需求。当前FWH产品只通过继电器对光伏进行通断控制。在离网光伏能量较大的场景下,无法实现光伏能量的高效利用。
+
+在离网场景下,对支持IEEE Std 1547-2018的光伏设备调节频率实现功率输出限制功能,满足该标准中光伏系统的强制频率跳闸“Mandatory frequency tripping”和频率-功率曲线“Frequency-droop (frequency-power)”特性。
+
+:::info
+光伏调频功能工作场景有一定限制,必须满足系统中仅接入aPower、PV、负载,在其他源(包括:市电、发电机、V2L等)接入时,将关闭光伏调频暂时功能,待满足工作条件后继续工作。
+:::
+
+## 依赖关系
+
+光伏调频功能依赖于标准《IEEE Std 1547-2018》中的强制频率跳闸功能和频率-功率曲线调节功能。
+
+:::info
+当然,FranklinWH系统满足《IEEE Std 1547-2018》标准,但本功能中的标准依赖实际上是对PV逆变器的要求,PV逆变器必须具备强制频率跳闸功能和频率-功率曲线调节功能,FranklinWH系统通过调节交流母线频率,PV逆变器通过对频率的响应实现对输出功率的限制。
+:::

+ 284 - 0
docs/Software/ScriptingForSchedulingConfiguration.md

@@ -0,0 +1,284 @@
+---
+title: 调度配置脚本化
+editLink: false
+---
+# 调度配置脚本化
+
+为适应用户多变的调度需求,在EMS中实现了一套调度配置解析功能,通过云后台下发设备调度配置脚本给EMS,实现EMS的实时调度定制化功能。
+
+## 配置协议
+
+- 355 - 能量调度策略查询
+- 356 - 能量调度策略查询应答
+- 357 - 能量调度策略增加、删除和应用设置
+- 358 - 能量调度策略增加、删除和应用设置应答
+
+### 调度策略查询
+
+```mermaid
+sequenceDiagram
+    云后台-->>+EMS: 355 - 查询调度策略
+    EMS-->>-云后台: 356 - 响应当前调度策略
+```
+
+**协议格式**
+
+请求
+```JSON
+// 355 - 查询调度策略
+{
+  opt: number,  // 操作项:1-查询调度策略列表;2-查询调度策略详情
+  sn: string,   // 设备序列号
+
+  // opt=2时,填写项
+  id: number   // 指定调度策略ID
+}
+```
+
+响应
+```JSON
+// 356 - 能量调度策略查询应答
+{
+  opt: number,          // 操作项:1-查询调度策略列表;2-查询调度策略详情
+  result: number,       // 请求结果:0-成功;1-失败
+
+  // opt=1时,返回项
+  current_id: number,   // 当前应用的调度策略ID
+  list: [               // EMS存在的调度策略总表
+    {
+      id: number,               // 调度策略ID
+      name: string,             // 调度策略名
+      electricity_type: number, // 电价类型:1-分时电价;2-阶梯电价;3-固定电价;4-分时&阶梯电价;5-BB套餐;6-Peak Demand
+      reserved_soc: number,     // 备电预留SOC(用户设定)
+      scheduling_type: number   // 调度模式类型:1-TOU;2-自发自用;3-仅备电
+    },
+    ...
+  ],
+
+  // opt=2时,返回项
+  data: Schedule   // 详细电度策略(后面章节详细介绍)
+}
+```
+
+### 调度策略增删
+
+```mermaid
+sequenceDiagram
+    云后台-->>+EMS: 357 - 增加调度策略
+    EMS-->>-云后台: 358 - 响应操作成功或失败
+    云后台-->>+EMS: 357 - 删除调度策略
+    EMS-->>-云后台: 358 - 响应操作成功或失败
+    云后台-->>+EMS: 357 - 切换当前调度策略
+    EMS-->>-云后台: 358 - 响应操作成功或失败
+```
+
+**协议格式**
+
+请求
+```JSON
+// 357 - 能量调度策略增加、删除和应用设置
+{
+  opt: number,          // 操作项:1-增加或编辑(覆盖);2-删除;3-切换当前调度策略
+  sn: string,
+
+  // opt=1时,填写项
+  data: Schedule,       // 详细电度策略(后面章节详细介绍)
+  
+  // opt=2时,填写项
+  id: number[],         // 电度策略ID的数组,要删除的策略ID
+
+  // opt=3时,填写项
+  current_id: number    // 需要切换的调度策略ID
+}
+```
+
+响应
+```JSON
+// 358 - 能量调度策略增加、删除和应用设置应答
+{
+  opt: number,      // 操作项:1-增加或编辑(覆盖);2-删除;3-切换当前调度策略
+  result: number    // 操作结果:0-成功;1-失败
+}
+```
+
+### 详细调度策略数据格式
+
+脚本数据基本格式为[JSON](https://www.json.org/json-zh.html)格式
+
+```JSON
+// Schedule
+{
+  yearStra: number[],         // 年策略选择表,表中数值代表策略ID,最多366个数据,每个数据代表1天,从1月1日开始。
+  calendar:                   // 日历表???
+  name: string,               // 策略表名称
+  id: number,                 // 策略表ID
+  startTime: string,          // 策略表生效时间,格式:“YYYY-MM-DD hh:mm:ss”,eg:”2024-03-05 13:41:00”,基于设备时区
+  duration: number,           // 策略表生效持续时间,单位:秒,0表示生效后永久持续
+  intervalTime: number,       // 间隔时间,单位:秒,默认0-无间隔                                       ???如何执行
+  startEvent: number,         // 开始事件,被关联的事件触发后,执行本策略表。填写事件ID,默认0-无关联事件   ???理解是否正确
+  endEvent: number,           // 结束事件,被关联的事件触发后,退出本策略表。填写事件ID,默认0-无关联事件   ???理解是否正确,退出会进入哪个策略表?
+  nextmode: number,           // 事假退出后,运行的模式。默认2-自发自用模式(系统标准模式)                ???是否可以填写策略ID,退到进入前的策略表?
+  electricity_type: number,   // 电价类型:1-分时电价;2-阶梯电价;3-固定电价;4-分时&阶梯电价;5-BB套餐;6-Peak Demand
+  scheduling_type: number,    // 调度模式类型:1-TOU;2-自发自用;3-仅备电
+  reserved_soc: number,       // 备电预留SOC,默认20%,只支持整数,精度1%
+  loop: number,               // 模式执行次数                                                           ???如何理解这个循环
+  priority: number,           // 模式优先级,可选,默认为 0,0 优先级最低,数字越⼤优先级越⾼               ???优先级如何应用?
+  env: number,                // 模式使⽤条件,可选,默认为 0,⽬前包括是否安装光伏,是否允许馈⽹,是否允许电⽹充电。
+                              // bit0~1:00,不涉及,01,未安装光伏,10,已安装光伏;
+                              // bit2~bit3:00,不涉及,01,不允许馈⽹,10,允许馈⽹;
+                              // bit4~bit5:00,不涉及,01,不允许电⽹充电,10,允许电⽹充电;其他预留
+
+  list: [ // 策略类型表,年策略选择表中使用的数据出自这个list,将策略设置到每一天
+    {
+      id: number,               // 策略ID
+      season: string,           // 季节名,也可以是别的名称,用于用户标识策略的范围,可自定义填写
+      dayType: string,          // 日期类型,可自定义填写,一般为:workday、weekday、everyday等
+
+      list: [ // 时间段策略表,所有配置的时间需覆盖一整天
+        {
+          start_time: string,   // 开始时间,格式:“hh:mm”
+          end_time: string,     // 结束时间,格式:“hh:mm”,end_time需与下一个start_time重叠
+          flag: number,         // 峰平⾕定义,可选,0:谷;1:平;2:峰;3:尖峰;4:低谷
+          name: string,         // 时段名,可自定义,一般为:supper-off-peak、off-peak、mid-peak、on-peak、supper-on-peak等
+          describe: string,     // 时段描述
+
+          solar_priority: [number, number, number],   // 光伏供电优先级,[0]:市电, [1]:负载, [2]:FHP;优先级为0/1/2/3
+                                                      // 可相同,若相同则表明需要按比例或者功率分配,0为不涉及,数字越大优先级越高
+          load_priority: [number, number, number],    // 负载取电优先级,可选,[0]:市电, [1]:光伏, [2]:FHP;优先级为 0/1/2/3
+                                                      // 可相同,若相同则表明需要按⽐例或者功率分配,0 为不涉及,数字越⼤优先级越⾼
+          
+          grid_feed_max: number,        // 电⽹允许最⼤馈⽹功率,0:不允许,-1:不限制,-2:未设置,可选,默认-2(aPbox 功率算在这⾥吗?不算)LoadSide也不算?
+          grid_max: number,             // 电⽹允许最⼤取电功率,0:不允许,-1:不限制,可选,默认-1不限制      ???包括负载功率吗?
+          grid_discharge_max: number,   // 电池最⼤允许馈⽹功率,可选,默认0不能馈⽹;0:不可馈⽹,-1:不限制;默认不使能,如果使能之后没有设置值,则为5kw*N(⽤-2标识)   ???此处5kw*N是否有问题?
+          grid_charge_max: number,      // 电⽹允许最⼤充电功率,可选,默认0不允许;0:不允许,-1:不限制;默认不使能,如果使能之后没有设置值,则为5kw*N(⽤-2标识)     ???此处5kw*N是否有问题?
+          charge_max: number,           // 电池最⼤允许充电功率,可选,按单台功率计算,-1:⽆限制,默认-1
+          discharge_max: number,        // 电池最⼤允许放电功率,可选,按单台功率计算,-1:⽆限制,默认-1
+
+          charge_power: number,         // 设置固定电池充电功率,可选,按单台功率计算,默认为-1:未设置
+          discharge_power: number,      // 设置固定电池放电功率,可选,按单台功率计算,默认为-1:未设置
+          
+          GCAO_max: number,             // GCAO(grid charge after an outage),设备补电至预留SOC的单台市电取电功率
+                                        // 可选;0:不允许,-1:不限制;默认未设置(-3);如果使能之后没有设置值,则为5kw*N(⽤-2标识)
+          
+          min_discharge_soc: number,    // 放电截⽌SOC,预留 SOC,达到此SOC后电池不放电,可选,默认 0%,如果reserved_soc大于min_discharge_soc,则以reserved_soc为准。       ???需确认是否正确
+          max_charge_soc: number,       // 充电截⽌SOC,可选,默认 100%
+
+          heat_enable: number,          // 充电加热使能,0:未设置,1:不使能,2:使能,默认0未设置  ???生效项目?和充电是什么关系
+          ramp_time: number,            // 爬坡时间,默认值 0,BB 套餐默认 300
+          poweroff_apower: number[],    // 主动关闭 APower,可选,0:不控制(默认);1:关机;2:开机(预留)
+          off_grid: number,             // 主动断开市电继电器,可选,0:不控制(默认);1:断开;2:闭合(预留)
+          solar_cutoff: number          // 主动断开光伏继电器,可选,0:不控制(默认);1:断开;2:闭合(预留)
+
+          // 注:以上涉及到的所有功率值设置均以单台aPower为基准,计算总功率需乘以台数N。单位均为kw。
+        },
+        ...
+      ]
+    },
+    ...
+  ]
+}
+```
+
+## 配置项与算法介绍
+
+### 功率限制算法
+
+算法与PCS相关联,待PCS文档完成后补充相关内容
+
+单台aPower最终充电功率限制计算:
+$$ P_{apower-chrg-max} = \min(\verb|grid_max| - P_{Load}, \verb|grid_charge_max|, \verb|charge_max|) $$
+
+单台固定aPower充电功率计算:
+$$ P_{apower-chrg} = \min(\verb|charge_power|, P_{apower-chrg-max}) $$
+
+单台aPower最终放电功率限制计算:
+$$ P_{apower-dsch-max} = \min(\verb|grid_feed_max| - P_{PV}, \verb|grid_discharge_max|, \verb|discharge_max|) $$
+
+单台固定aPower放电功率计算:
+$$ P_{apower-dsch} = \min(\verb|discharge_power|, P_{apower-dsch-max}) $$
+
+当$P_{PV} > \verb|grid_feed_max|$是,设备会切断部分或全部光伏继电器,以削减光伏输出功率。
+
+### 调度优先级配置说明
+
+调度配置中,包含两个调度优先级的配置,**光伏供电优先级(solar_priority)**和**负载取电优先级(load_priority)**。
+
+**光伏供电优先级**
+
+光伏供电优先级是一个三个数值组成的一个数组配置,三个值分别代表三个供电目标的优先级:\[**市电**,**负载**,**aPower**\]。数值越大优先级越高,但0为特殊值,代表不为此目标供电。
+
+**负载取电优先级**
+
+负载优先级是一个三个数值组成的一个数组配置,三个值分别代表三个供电源的优先级:\[**市电**,**光伏**,**aPower**\]。数值越大优先级越高,但0为特殊值,代表不使用此供电源。
+
+#### 优先级组合说明
+
+:::info
+以下优先级配置组合代表所有可配置的情景,不在此列表中的配置均不支持。
+:::
+
+##### P[1,2,0] - L[1,3,2]
+
+光伏优先级:[1,2,0],负载优先级:[1,3,2]
+
+* 光伏优先供载,剩余倒送电网
+* 光伏供载不足,由aPower支持,再不足市电支持
+* aPower不会充电
+
+```mermaid
+---
+config:
+  sankey: 
+    showValues: false
+---
+sankey-beta
+
+%% source,target,value
+Electricity grid imports,Home loads,10
+Electricity grid imports,aPower charge,0
+PV,Home loads,60
+PV,aPower charge,0
+PV,Electricity grid exports,20
+aPower discharge,Home loads,30
+aPower discharge,Electricity grid exports,0
+```
+
+##### P[1,2,0] - L[1,2,3]
+
+光伏优先级:[1,2,0],负载优先级:[1,2,3]
+
+##### P[1,2,0] - L[1,2,0]
+
+光伏优先级:[1,2,0],负载优先级:[1,2,0]
+
+##### P[1,2,0] - L[1,2,1]
+
+光伏优先级:[1,2,0],负载优先级:[1,2,1]
+
+##### P[1,3,2] - L[1,2,0]
+
+光伏优先级:[1,3,2],负载优先级:[1,2,0]
+
+##### P[1,3,2] - L[1,3,2]
+
+光伏优先级:[1,3,2],负载优先级:[1,3,2]
+
+##### P[1,3,2] - L[2,3,1]
+
+光伏优先级:[1,3,2],负载优先级:[2,3,1]
+
+##### P[1,2,3] - L[1,2,0]
+
+光伏优先级:[1,2,3],负载优先级:[1,2,0]
+
+##### P[1,2,2] - L[1,2,2]
+
+光伏优先级:[1,2,2],负载优先级:[1,2,2]
+
+## 常规调度配置
+
+常规调度配置,只提供时间段策略的JSON配置。
+
+### 自发自用
+
+### 仅备电

+ 4 - 0
docs/Software/SocBalance.md

@@ -0,0 +1,4 @@
+---
+title: SOC均衡
+editLink: false
+---

二進制
docs/Software/assets/PCS/GridImportAndExport.png


二進制
docs/Software/assets/PCS/PartialBackupSchematic-OnlyUseSplitCT-1.0.png


Some files were not shown because too many files changed in this diff