Initial commit
This commit is contained in:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm run:*)",
|
||||||
|
"Bash(curl:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
52
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
name: 🐛 Bug 报告
|
||||||
|
about: 报告一个 Bug 帮助我们改进
|
||||||
|
title: '[Bug] '
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bug 描述
|
||||||
|
|
||||||
|
请简洁清晰地描述这个 Bug 是什么。
|
||||||
|
|
||||||
|
## 复现步骤
|
||||||
|
|
||||||
|
请提供详细的复现步骤:
|
||||||
|
|
||||||
|
1. 进入 '...'
|
||||||
|
2. 点击 '...'
|
||||||
|
3. 执行 '...'
|
||||||
|
4. 出现错误
|
||||||
|
|
||||||
|
## 预期行为
|
||||||
|
|
||||||
|
请描述你期望发生的正确行为。
|
||||||
|
|
||||||
|
## 实际行为
|
||||||
|
|
||||||
|
请描述实际发生的错误行为。
|
||||||
|
|
||||||
|
## 截图
|
||||||
|
|
||||||
|
如果可以,请提供截图帮助说明问题。
|
||||||
|
|
||||||
|
## 环境信息
|
||||||
|
|
||||||
|
- 操作系统: [例如 Windows 11]
|
||||||
|
- 应用版本: [例如 0.0.1]
|
||||||
|
- 浏览器版本: [如果适用]
|
||||||
|
|
||||||
|
## 附加信息
|
||||||
|
|
||||||
|
添加任何其他有用的信息,如日志、堆栈跟踪等。
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- 在这里粘贴日志或错误信息 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## 检查清单
|
||||||
|
|
||||||
|
- [ ] 我已经搜索过类似的问题,确认没有重复 Issue
|
||||||
|
- [ ] 我提供了完整的复现步骤
|
||||||
|
- [ ] 我提供了环境信息
|
||||||
33
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
33
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
name: 💡 功能请求
|
||||||
|
about: 为项目提出新功能或改进建议
|
||||||
|
title: '[Feature] '
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## 功能描述
|
||||||
|
|
||||||
|
请简洁清晰地描述你希望添加的功能或改进。
|
||||||
|
|
||||||
|
## 解决的问题
|
||||||
|
|
||||||
|
这个功能将解决什么问题?或者会带来什么价值?
|
||||||
|
|
||||||
|
## 建议的解决方案
|
||||||
|
|
||||||
|
请描述你建议如何实现这个功能。
|
||||||
|
|
||||||
|
## 替代方案
|
||||||
|
|
||||||
|
请描述你考虑过的其他替代方案。
|
||||||
|
|
||||||
|
## 设计稿
|
||||||
|
|
||||||
|
如果有设计想法,请提供草图或描述。
|
||||||
|
|
||||||
|
## 检查清单
|
||||||
|
|
||||||
|
- [ ] 我已经搜索过类似的功能请求,确认没有重复 Issue
|
||||||
|
- [ ] 我清晰地描述了期望的功能
|
||||||
|
- [ ] 我解释了为什么需要这个功能
|
||||||
35
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
35
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
## 描述
|
||||||
|
|
||||||
|
请简要描述这个 Pull Request 解决了什么问题或添加了什么功能。
|
||||||
|
|
||||||
|
## 变更类型
|
||||||
|
|
||||||
|
- [ ] 🐛 Bug 修复
|
||||||
|
- [ ] 💡 新功能
|
||||||
|
- [ ] 📝 文档更新
|
||||||
|
- [ ] ♻️ 代码重构
|
||||||
|
- [ ] ⚡ 性能优化
|
||||||
|
- [ ] 🔧 构建或工具变动
|
||||||
|
|
||||||
|
## 如何测试
|
||||||
|
|
||||||
|
请描述如何验证这些变更是否正常工作。
|
||||||
|
|
||||||
|
## 检查清单
|
||||||
|
|
||||||
|
- [ ] 我的代码遵循项目的代码规范
|
||||||
|
- [ ] 我已经进行了自测
|
||||||
|
- [ ] 我已经更新了相关文档(如果需要)
|
||||||
|
- [ ] 我的变更没有引入新的警告或错误
|
||||||
|
|
||||||
|
## 截图(可选)
|
||||||
|
|
||||||
|
如果适用,请提供变更前后的对比截图。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**关联的 Issue**: 关闭 #(如果有)
|
||||||
|
|
||||||
|
## 其他信息
|
||||||
|
|
||||||
|
添加任何其他需要审查者注意的信息。
|
||||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
.vite
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
release/
|
||||||
|
|
||||||
|
# Tools output
|
||||||
|
tools/tongyi/ppt_output/
|
||||||
|
tools/tongyi/downloads/
|
||||||
|
tools/tongyi/__pycache__/
|
||||||
|
tools/mineru/__pycache__/
|
||||||
|
tools/blog/__pycache__/
|
||||||
|
|
||||||
|
# Notebook pydemos backup
|
||||||
|
notebook/
|
||||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||||
|
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||||
1
.vercel/project.json
Normal file
1
.vercel/project.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"projectName":"trae_byb2803z"}
|
||||||
7
.vercelignore
Normal file
7
.vercelignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.trae
|
||||||
|
.log
|
||||||
|
.figma
|
||||||
202
CONTRIBUTING.md
Normal file
202
CONTRIBUTING.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
# 贡献指南
|
||||||
|
|
||||||
|
感谢你对 XCNote 项目的兴趣!我们欢迎任何形式的贡献,包括但不限于:
|
||||||
|
|
||||||
|
- 🐛 报告 Bug
|
||||||
|
- 💡 提出新功能建议
|
||||||
|
- 📝 完善文档
|
||||||
|
- 💻 提交代码修复或新功能
|
||||||
|
- 🔧 改进构建流程
|
||||||
|
|
||||||
|
## 开发环境搭建
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm 9+
|
||||||
|
- Git
|
||||||
|
|
||||||
|
### 克隆项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/your-repo/XCNote.git
|
||||||
|
cd XCNote
|
||||||
|
```
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动前端开发服务器
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 或启动完整 Electron 开发环境(推荐)
|
||||||
|
npm run electron:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 代码检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 运行 ESLint
|
||||||
|
npm run lint
|
||||||
|
|
||||||
|
# 运行 TypeScript 类型检查
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码规范
|
||||||
|
|
||||||
|
### 命名规范
|
||||||
|
|
||||||
|
- **文件命名**: 使用 kebab-case(如 `file-system.tsx`)
|
||||||
|
- **组件命名**: 使用 PascalCase(如 `FileTree.tsx`)
|
||||||
|
- **函数命名**: 使用 camelCase(如 `useFileTree`)
|
||||||
|
- **常量命名**: 使用 UPPER_SNAKE_CASE
|
||||||
|
|
||||||
|
### TypeScript 规范
|
||||||
|
|
||||||
|
- 尽量使用明确的类型定义,避免使用 `any`
|
||||||
|
- 优先使用接口(interface)而不是类型别名(type)定义对象结构
|
||||||
|
- 导出类型时使用 `export type`
|
||||||
|
|
||||||
|
### React 规范
|
||||||
|
|
||||||
|
- 使用函数组件和 Hooks
|
||||||
|
- 组件文件以 `.tsx` 为扩展名
|
||||||
|
- 纯逻辑文件以 `.ts` 为扩展名
|
||||||
|
|
||||||
|
### CSS 规范
|
||||||
|
|
||||||
|
- 使用 Tailwind CSS 进行样式开发
|
||||||
|
- 避免直接编写 CSS,优先使用 Tailwind 工具类
|
||||||
|
- 自定义样式放在组件同名的 `.module.css` 文件中
|
||||||
|
|
||||||
|
### Git 提交规范
|
||||||
|
|
||||||
|
我们使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 类型(type)
|
||||||
|
|
||||||
|
| 类型 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| feat | 新功能 |
|
||||||
|
| fix | Bug 修复 |
|
||||||
|
| docs | 文档更新 |
|
||||||
|
| style | 代码格式调整 |
|
||||||
|
| refactor | 代码重构 |
|
||||||
|
| perf | 性能优化 |
|
||||||
|
| test | 测试相关 |
|
||||||
|
| chore | 构建或辅助工具变动 |
|
||||||
|
|
||||||
|
#### 示例
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 新功能
|
||||||
|
git commit -m "feat(file-system): 添加文件拖拽功能"
|
||||||
|
|
||||||
|
# Bug 修复
|
||||||
|
git commit -m "fix(editor): 修复数学公式渲染错误"
|
||||||
|
|
||||||
|
# 文档更新
|
||||||
|
git commit -m "docs: 更新 README 安装步骤"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pull Request 流程
|
||||||
|
|
||||||
|
### 创建分支
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从主分支创建新分支
|
||||||
|
git checkout -b feature/your-feature-name
|
||||||
|
|
||||||
|
# 或创建修复分支
|
||||||
|
git checkout -b fix/bug-description
|
||||||
|
```
|
||||||
|
|
||||||
|
### 提交代码
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 暂存修改
|
||||||
|
git add .
|
||||||
|
|
||||||
|
# 提交修改
|
||||||
|
git commit -m "feat(module): 添加新功能"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 推送分支
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 推送到远程仓库
|
||||||
|
git push origin feature/your-feature-name
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建 Pull Request
|
||||||
|
|
||||||
|
1. 访问项目仓库
|
||||||
|
2. 点击 "Compare & pull request"
|
||||||
|
3. 填写 PR 标题和描述
|
||||||
|
4. 提交 PR
|
||||||
|
|
||||||
|
### PR 标题规范
|
||||||
|
|
||||||
|
请使用与提交信息相同的格式:
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(file-system): 添加文件搜索功能
|
||||||
|
fix(editor): 修复保存内容丢失问题
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构概览
|
||||||
|
|
||||||
|
```
|
||||||
|
XCNote/
|
||||||
|
├── src/ # 前端源码
|
||||||
|
│ ├── components/ # UI 组件
|
||||||
|
│ ├── contexts/ # React Context
|
||||||
|
│ ├── hooks/ # 自定义 Hooks
|
||||||
|
│ ├── lib/ # 工具库
|
||||||
|
│ ├── modules/ # 功能模块
|
||||||
|
│ └── pages/ # 页面组件
|
||||||
|
├── api/ # 后端 API
|
||||||
|
├── electron/ # Electron 主进程
|
||||||
|
├── shared/ # 共享类型定义
|
||||||
|
└── notebook/ # 笔记存储目录
|
||||||
|
```
|
||||||
|
|
||||||
|
### 添加新模块
|
||||||
|
|
||||||
|
如需添加新功能模块,请参考以下步骤:
|
||||||
|
|
||||||
|
1. 在 `src/modules/` 下创建新模块目录
|
||||||
|
2. 在 `shared/modules/` 下定义模块配置
|
||||||
|
3. 在后端 `api/modules/` 下实现 API 接口
|
||||||
|
4. 使用模块注册系统进行注册
|
||||||
|
|
||||||
|
## 问题反馈
|
||||||
|
|
||||||
|
如果你发现了 Bug 或有新功能建议,请:
|
||||||
|
|
||||||
|
1. 搜索是否已有类似问题
|
||||||
|
2. 如果没有,创建新的 Issue
|
||||||
|
3. 使用对应的模板并提供详细信息
|
||||||
|
|
||||||
|
## 行为准则
|
||||||
|
|
||||||
|
请尊重所有参与项目的开发者,保持友好和专业的交流环境。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
感谢你的贡献!🎉
|
||||||
193
README.md
Normal file
193
README.md
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
# XCDesktop
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://www.electronjs.org/)
|
||||||
|
[](https://react.dev/)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://vitejs.dev/)
|
||||||
|
[](https://tailwindcss.com/)
|
||||||
|
|
||||||
|
一站式 AI 工作台 - 集成笔记管理、时间追踪、任务管理、AI 辅助等多种功能
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## ✨ 核心特性
|
||||||
|
|
||||||
|
- **🤖 AI 集成** - 深度集成通义万相、豆包等 AI 能力,支持语音转文字、视频解析、PPT 提取等
|
||||||
|
- **📝 Markdown 笔记管理** - 基于 Milkdown 编辑器,支持数学公式(KaTeX)、代码高亮(Prism)、表格、任务列表等
|
||||||
|
- **⏱️ 时间追踪** - 记录学习和工作时间,生成生产力统计图表
|
||||||
|
- **✅ 任务管理** - 完善的 TODO 功能,支持时间规划和任务统计
|
||||||
|
- **🗂️ 文件管理** - 树形文件结构,支持拖拽、重命名、删除等操作
|
||||||
|
- **🔍 全文搜索** - 快速搜索笔记内容
|
||||||
|
- **🌐 远程网页** - 内置浏览器,集成微信读书、B 站等常用网站
|
||||||
|
- **🧩 模块化架构** - 前端模块热插拔,易于扩展新功能
|
||||||
|
- **🖥️ 桌面原生体验** - Electron 打包,离线可用,支持系统主题
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm 9+
|
||||||
|
- Windows 10+
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行开发模式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动前端开发服务器
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 或者使用 Electron 开发模式(推荐)
|
||||||
|
npm run electron:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建项目
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建前端
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 构建 Electron 应用
|
||||||
|
npm run electron:build
|
||||||
|
```
|
||||||
|
|
||||||
|
构建完成后,安装包位于 `release` 目录下。
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
XCDesktop/
|
||||||
|
├── src/ # 前端源代码
|
||||||
|
│ ├── components/ # React 组件
|
||||||
|
│ ├── contexts/ # React Context
|
||||||
|
│ ├── hooks/ # 自定义 Hooks
|
||||||
|
│ │ ├── domain/ # 业务逻辑 Hooks
|
||||||
|
│ │ ├── events/ # 事件处理
|
||||||
|
│ │ ├── ui/ # UI 交互 Hooks
|
||||||
|
│ │ └── utils/ # 工具函数
|
||||||
|
│ ├── lib/ # 工具库
|
||||||
|
│ │ ├── api/ # API 客户端
|
||||||
|
│ │ ├── editor/ # 编辑器配置
|
||||||
|
│ │ └── utils/ # 工具函数
|
||||||
|
│ ├── modules/ # 功能模块
|
||||||
|
│ │ ├── home/ # 首页
|
||||||
|
│ │ ├── settings/ # 设置
|
||||||
|
│ │ ├── search/ # 搜索
|
||||||
|
│ │ ├── todo/ # 任务管理
|
||||||
|
│ │ ├── time-tracking/ # 时间追踪
|
||||||
|
│ │ ├── recycle-bin/ # 回收站
|
||||||
|
│ │ ├── pydemos/ # Python Demo
|
||||||
|
│ │ ├── weread/ # 微信读书
|
||||||
|
│ │ └── remote/ # 远程网页
|
||||||
|
│ ├── stores/ # Zustand 状态管理
|
||||||
|
│ └── types/ # 类型定义
|
||||||
|
├── api/ # 后端 API (Express)
|
||||||
|
│ ├── config/ # 配置文件
|
||||||
|
│ ├── core/ # 核心功能
|
||||||
|
│ ├── errors/ # 错误处理
|
||||||
|
│ ├── modules/ # API 模块
|
||||||
|
│ │ ├── ai/ # AI 集成
|
||||||
|
│ │ ├── document-parser/ # 文档解析
|
||||||
|
│ │ ├── pydemos/ # Python Demo
|
||||||
|
│ │ ├── recycle-bin/ # 回收站
|
||||||
|
│ │ ├── remote/ # 远程网页
|
||||||
|
│ │ ├── time-tracking/ # 时间追踪
|
||||||
|
│ │ └── todo/ # 任务管理
|
||||||
|
│ ├── middlewares/ # 中间件
|
||||||
|
│ ├── schemas/ # 数据验证
|
||||||
|
│ ├── utils/ # 工具函数
|
||||||
|
│ ├── watcher/ # 文件监控
|
||||||
|
│ └── events/ # 事件总线
|
||||||
|
├── electron/ # Electron 主进程
|
||||||
|
├── shared/ # 共享类型和配置
|
||||||
|
├── tools/ # 工具脚本
|
||||||
|
│ └── tongyi/ # 通义万相 AI 工具
|
||||||
|
├── notebook/ # 笔记数据存储(运行时)
|
||||||
|
└── release/ # 构建输出
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧩 功能模块
|
||||||
|
|
||||||
|
### 核心模块
|
||||||
|
|
||||||
|
| 模块 | 功能描述 |
|
||||||
|
|------|----------|
|
||||||
|
| 首页 | 欢迎页面,快速访问常用功能 |
|
||||||
|
| 文件管理 | 树形目录结构,管理 Markdown 笔记 |
|
||||||
|
| 编辑器 | 基于 Milkdown 的 Markdown 编辑器 |
|
||||||
|
| 搜索 | 全文搜索笔记内容 |
|
||||||
|
| 设置 | 主题、壁纸、字体大小等个性化配置 |
|
||||||
|
|
||||||
|
### 扩展模块
|
||||||
|
|
||||||
|
| 模块 | 功能描述 |
|
||||||
|
|------|----------|
|
||||||
|
| AI 集成 | 通义万相语音转文字、视频解析、PPT 提取等 |
|
||||||
|
| 文档解析 | 支持导入博客、PDF 等格式 |
|
||||||
|
| 时间追踪 | 记录工作/学习时间,统计生产力 |
|
||||||
|
| 任务管理 | TODO 列表,支持时间规划 |
|
||||||
|
| 回收站 | 误删恢复 |
|
||||||
|
| Python Demo | Python 脚本管理 |
|
||||||
|
| 微信读书 | 微信读书网页版集成 |
|
||||||
|
| 远程网页 | 内置浏览器,访问任意网页 |
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
|
||||||
|
- **框架**: React 18 + TypeScript
|
||||||
|
- **构建工具**: Vite 6
|
||||||
|
- **样式**: Tailwind CSS + Tailwind Typography
|
||||||
|
- **编辑器**: Milkdown (Markdown)
|
||||||
|
- **图表**: Recharts
|
||||||
|
- **图标**: Lucide React
|
||||||
|
- **路由**: React Router DOM
|
||||||
|
- **状态管理**: Zustand
|
||||||
|
- **拖拽**: @dnd-kit
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
|
||||||
|
- **运行时**: Node.js
|
||||||
|
- **框架**: Express
|
||||||
|
- **验证**: Zod
|
||||||
|
- **文件监控**: Chokidar
|
||||||
|
|
||||||
|
### 桌面
|
||||||
|
|
||||||
|
- **框架**: Electron 40
|
||||||
|
- **日志**: electron-log
|
||||||
|
|
||||||
|
### AI 工具
|
||||||
|
|
||||||
|
- **语音识别**: 通义听悟
|
||||||
|
- **视频解析**: Bilibili 视频下载与分析
|
||||||
|
- **PPT 提取**: 自动提取 PPT 内容
|
||||||
|
|
||||||
|
## 📝 数据存储
|
||||||
|
|
||||||
|
数据文件存储在系统文档目录下的 `XCDesktop` 文件夹中:
|
||||||
|
|
||||||
|
```
|
||||||
|
~/Documents/XCDesktop/
|
||||||
|
├── notebook/
|
||||||
|
│ ├── markdowns/ # Markdown 笔记
|
||||||
|
│ ├── pydemos/ # Python Demo
|
||||||
|
│ ├── images/ # 图片资源
|
||||||
|
│ └── time/ # 时间追踪数据
|
||||||
|
└── downloads/ # 下载文件
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 贡献指南
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
101
api/app.ts
Normal file
101
api/app.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* API 服务器
|
||||||
|
*/
|
||||||
|
|
||||||
|
import express, {
|
||||||
|
type Request,
|
||||||
|
type Response,
|
||||||
|
type NextFunction,
|
||||||
|
} from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import dotenv from 'dotenv'
|
||||||
|
import filesRoutes from './core/files/routes.js'
|
||||||
|
import eventsRoutes from './core/events/routes.js'
|
||||||
|
import settingsRoutes from './core/settings/routes.js'
|
||||||
|
import uploadRoutes from './core/upload/routes.js'
|
||||||
|
import searchRoutes from './core/search/routes.js'
|
||||||
|
import type { ApiResponse } from '../shared/types.js'
|
||||||
|
import { errorHandler } from './middlewares/errorHandler.js'
|
||||||
|
import { NOTEBOOK_ROOT } from './config/paths.js'
|
||||||
|
import { ModuleManager } from './infra/moduleManager.js'
|
||||||
|
import { ServiceContainer } from './infra/container.js'
|
||||||
|
import { apiModules } from './modules/index.js'
|
||||||
|
import { validateModuleConsistency } from './infra/moduleValidator.js'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
|
||||||
|
dotenv.config()
|
||||||
|
|
||||||
|
const app: express.Application = express()
|
||||||
|
export const container = new ServiceContainer()
|
||||||
|
export const moduleManager = new ModuleManager(container)
|
||||||
|
|
||||||
|
app.use(cors())
|
||||||
|
app.use(express.json({ limit: '200mb' }))
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '200mb' }))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core Routes
|
||||||
|
*/
|
||||||
|
app.use('/api/files', filesRoutes)
|
||||||
|
app.use('/api/events', eventsRoutes)
|
||||||
|
app.use('/api/settings', settingsRoutes)
|
||||||
|
app.use('/api/upload', uploadRoutes)
|
||||||
|
app.use('/api/search', searchRoutes)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module Routes (loaded dynamically via ModuleManager)
|
||||||
|
*/
|
||||||
|
for (const module of apiModules) {
|
||||||
|
await moduleManager.register(module)
|
||||||
|
}
|
||||||
|
|
||||||
|
await validateModuleConsistency(apiModules)
|
||||||
|
|
||||||
|
for (const module of moduleManager.getAllModules()) {
|
||||||
|
await moduleManager.activate(module.metadata.id)
|
||||||
|
const router = await module.createRouter(container)
|
||||||
|
app.use('/api' + module.metadata.basePath, router)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/background.png', (req, res, next) => {
|
||||||
|
const customBgPath = path.join(NOTEBOOK_ROOT, '.config', 'background.png')
|
||||||
|
if (fs.existsSync(customBgPath)) {
|
||||||
|
res.sendFile(customBgPath)
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* health
|
||||||
|
*/
|
||||||
|
app.use(
|
||||||
|
'/api/health',
|
||||||
|
(_req: Request, res: Response): void => {
|
||||||
|
const response: ApiResponse<{ message: string }> = {
|
||||||
|
success: true,
|
||||||
|
data: { message: 'ok' },
|
||||||
|
}
|
||||||
|
res.status(200).json(response)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 404 handler
|
||||||
|
*/
|
||||||
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
||||||
|
if (req.path.startsWith('/api')) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
error: { code: 'NOT_FOUND', message: 'API不存在' },
|
||||||
|
}
|
||||||
|
res.status(404).json(response)
|
||||||
|
} else {
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use(errorHandler)
|
||||||
|
|
||||||
|
export default app
|
||||||
47
api/config/index.ts
Normal file
47
api/config/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import os from 'os'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
get projectRoot(): string {
|
||||||
|
if (__dirname.includes('app.asar')) {
|
||||||
|
return path.resolve(__dirname, '..').replace('app.asar', 'app.asar.unpacked')
|
||||||
|
}
|
||||||
|
return path.resolve(__dirname, '../../')
|
||||||
|
},
|
||||||
|
|
||||||
|
get notebookRoot(): string {
|
||||||
|
return process.env.NOTEBOOK_ROOT
|
||||||
|
? path.resolve(process.env.NOTEBOOK_ROOT)
|
||||||
|
: path.join(this.projectRoot, 'notebook')
|
||||||
|
},
|
||||||
|
|
||||||
|
get tempRoot(): string {
|
||||||
|
return path.join(os.tmpdir(), 'xcnote_uploads')
|
||||||
|
},
|
||||||
|
|
||||||
|
get serverPort(): number {
|
||||||
|
return parseInt(process.env.PORT || '3001', 10)
|
||||||
|
},
|
||||||
|
|
||||||
|
get isVercel(): boolean {
|
||||||
|
return !!process.env.VERCEL
|
||||||
|
},
|
||||||
|
|
||||||
|
get isElectron(): boolean {
|
||||||
|
return __dirname.includes('app.asar')
|
||||||
|
},
|
||||||
|
|
||||||
|
get isDev(): boolean {
|
||||||
|
return !this.isElectron && !this.isVercel
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PATHS = {
|
||||||
|
get PROJECT_ROOT() { return config.projectRoot },
|
||||||
|
get NOTEBOOK_ROOT() { return config.notebookRoot },
|
||||||
|
get TEMP_ROOT() { return config.tempRoot },
|
||||||
|
}
|
||||||
7
api/config/paths.ts
Normal file
7
api/config/paths.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { PATHS, config } from './index.js'
|
||||||
|
|
||||||
|
export { PATHS, config }
|
||||||
|
|
||||||
|
export const PROJECT_ROOT = PATHS.PROJECT_ROOT
|
||||||
|
export const NOTEBOOK_ROOT = PATHS.NOTEBOOK_ROOT
|
||||||
|
export const TEMP_ROOT = PATHS.TEMP_ROOT
|
||||||
0
api/core/.gitkeep
Normal file
0
api/core/.gitkeep
Normal file
32
api/core/events/routes.ts
Normal file
32
api/core/events/routes.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import express, { Request, Response } from 'express'
|
||||||
|
import type { ApiResponse } from '../../../shared/types.js'
|
||||||
|
import { eventBus } from '../../events/eventBus.js'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.get('/', (req: Request, res: Response) => {
|
||||||
|
if (process.env.VERCEL) {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
error: { code: 'SSE_UNSUPPORTED', message: 'SSE在无服务器运行时中不受支持' },
|
||||||
|
}
|
||||||
|
return res.status(501).json(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Cache-Control': 'no-cache'
|
||||||
|
}
|
||||||
|
res.writeHead(200, headers)
|
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ event: 'connected' })}\n\n`)
|
||||||
|
|
||||||
|
eventBus.addClient(res)
|
||||||
|
|
||||||
|
req.on('close', () => {
|
||||||
|
eventBus.removeClient(res)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
280
api/core/files/routes.ts
Normal file
280
api/core/files/routes.ts
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import express, { type Request, type Response } from 'express'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
|
import type { FileItemDTO, PathExistsDTO } from '../../../shared/types.js'
|
||||||
|
import { toPosixPath } from '../../../shared/utils/path.js'
|
||||||
|
import { pad2 } from '../../../shared/utils/date.js'
|
||||||
|
import { validateBody, validateQuery } from '../../middlewares/validate.js'
|
||||||
|
import {
|
||||||
|
listFilesQuerySchema,
|
||||||
|
contentQuerySchema,
|
||||||
|
rawQuerySchema,
|
||||||
|
saveFileSchema,
|
||||||
|
pathSchema,
|
||||||
|
renameSchema,
|
||||||
|
createDirSchema,
|
||||||
|
createFileSchema,
|
||||||
|
} from '../../schemas/index.js'
|
||||||
|
import {
|
||||||
|
NotFoundError,
|
||||||
|
NotADirectoryError,
|
||||||
|
BadRequestError,
|
||||||
|
AlreadyExistsError,
|
||||||
|
ForbiddenError,
|
||||||
|
ResourceLockedError,
|
||||||
|
InternalError,
|
||||||
|
isNodeError,
|
||||||
|
} from '../../../shared/errors/index.js'
|
||||||
|
import { logger } from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
validateQuery(listFilesQuerySchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const relPath = req.query.path as string
|
||||||
|
const { safeRelPath, fullPath } = resolveNotebookPath(relPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath)
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundError('路径不存在')
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await fs.stat(fullPath)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
throw new NotADirectoryError()
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await fs.readdir(fullPath)
|
||||||
|
const items = await Promise.all(
|
||||||
|
files.map(async (name): Promise<FileItemDTO | null> => {
|
||||||
|
const filePath = path.join(fullPath, name)
|
||||||
|
try {
|
||||||
|
const fileStats = await fs.stat(filePath)
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
type: fileStats.isDirectory() ? 'dir' : 'file',
|
||||||
|
size: fileStats.size,
|
||||||
|
modified: fileStats.mtime.toISOString(),
|
||||||
|
path: toPosixPath(path.join(safeRelPath, name)),
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const visibleItems = items.filter((i): i is FileItemDTO => i !== null && !i.name.startsWith('.'))
|
||||||
|
visibleItems.sort((a, b) => {
|
||||||
|
if (a.type === b.type) return a.name.localeCompare(b.name)
|
||||||
|
return a.type === 'dir' ? -1 : 1
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, { items: visibleItems })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/content',
|
||||||
|
validateQuery(contentQuerySchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const relPath = req.query.path as string
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
const stats = await fs.stat(fullPath).catch(() => {
|
||||||
|
throw new NotFoundError('文件不存在')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!stats.isFile()) throw new BadRequestError('不是文件')
|
||||||
|
|
||||||
|
const content = await fs.readFile(fullPath, 'utf-8')
|
||||||
|
successResponse(res, {
|
||||||
|
content,
|
||||||
|
metadata: {
|
||||||
|
size: stats.size,
|
||||||
|
modified: stats.mtime.toISOString(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/raw',
|
||||||
|
validateQuery(rawQuerySchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const relPath = req.query.path as string
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
const stats = await fs.stat(fullPath).catch(() => {
|
||||||
|
throw new NotFoundError('文件不存在')
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!stats.isFile()) throw new BadRequestError('不是文件')
|
||||||
|
|
||||||
|
const ext = path.extname(fullPath).toLowerCase()
|
||||||
|
const mimeTypes: Record<string, string> = {
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = mimeTypes[ext]
|
||||||
|
if (mimeType) res.setHeader('Content-Type', mimeType)
|
||||||
|
res.sendFile(fullPath)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/save',
|
||||||
|
validateBody(saveFileSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { path: relPath, content } = req.body
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
||||||
|
await fs.writeFile(fullPath, content, 'utf-8')
|
||||||
|
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/delete',
|
||||||
|
validateBody(pathSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { path: relPath } = req.body
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
|
||||||
|
await fs.stat(fullPath).catch(() => {
|
||||||
|
throw new NotFoundError('文件或目录不存在')
|
||||||
|
})
|
||||||
|
|
||||||
|
const { fullPath: rbDir } = resolveNotebookPath('RB')
|
||||||
|
|
||||||
|
await fs.mkdir(rbDir, { recursive: true })
|
||||||
|
|
||||||
|
const originalName = path.basename(fullPath)
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = pad2(now.getMonth() + 1)
|
||||||
|
const day = pad2(now.getDate())
|
||||||
|
const timestamp = `${year}${month}${day}`
|
||||||
|
const newName = `${timestamp}_${originalName}`
|
||||||
|
const rbDestPath = path.join(rbDir, newName)
|
||||||
|
|
||||||
|
await fs.rename(fullPath, rbDestPath)
|
||||||
|
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/exists',
|
||||||
|
validateBody(pathSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { path: relPath } = req.body
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(fullPath)
|
||||||
|
const type: PathExistsDTO['type'] = stats.isDirectory() ? 'dir' : stats.isFile() ? 'file' : null
|
||||||
|
successResponse<PathExistsDTO>(res, { exists: true, type })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||||
|
successResponse<PathExistsDTO>(res, { exists: false, type: null })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/create/dir',
|
||||||
|
validateBody(createDirSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { path: relPath } = req.body
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(fullPath, { recursive: true })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isNodeError(err)) {
|
||||||
|
if (err.code === 'EEXIST') {
|
||||||
|
throw new AlreadyExistsError('路径已存在')
|
||||||
|
}
|
||||||
|
if (err.code === 'EACCES') {
|
||||||
|
throw new ForbiddenError('没有权限创建目录')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/create/file',
|
||||||
|
validateBody(createFileSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { path: relPath } = req.body
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
await fs.mkdir(path.dirname(fullPath), { recursive: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileName = path.basename(relPath, '.md')
|
||||||
|
const content = `# ${fileName}`
|
||||||
|
await fs.writeFile(fullPath, content, { encoding: 'utf-8', flag: 'wx' })
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isNodeError(err)) {
|
||||||
|
if (err.code === 'EEXIST') throw new AlreadyExistsError('路径已存在')
|
||||||
|
if (err.code === 'EACCES') throw new ForbiddenError('没有权限创建文件')
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/rename',
|
||||||
|
validateBody(renameSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { oldPath, newPath } = req.body
|
||||||
|
const { fullPath: oldFullPath } = resolveNotebookPath(oldPath)
|
||||||
|
const { fullPath: newFullPath } = resolveNotebookPath(newPath)
|
||||||
|
|
||||||
|
await fs.mkdir(path.dirname(newFullPath), { recursive: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.rename(oldFullPath, newFullPath)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isNodeError(err)) {
|
||||||
|
if (err.code === 'ENOENT') {
|
||||||
|
throw new NotFoundError('文件不存在')
|
||||||
|
}
|
||||||
|
if (err.code === 'EEXIST') {
|
||||||
|
throw new AlreadyExistsError('路径已存在')
|
||||||
|
}
|
||||||
|
if (err.code === 'EPERM' || err.code === 'EACCES') {
|
||||||
|
throw new ForbiddenError('没有权限重命名文件或目录')
|
||||||
|
}
|
||||||
|
if (err.code === 'EBUSY') {
|
||||||
|
throw new ResourceLockedError('文件或目录正在使用中或被锁定')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.error('重命名错误:', err)
|
||||||
|
throw new InternalError('重命名文件或目录失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
97
api/core/search/routes.ts
Normal file
97
api/core/search/routes.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import express, { type Request, type Response } from 'express'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
|
import type { FileItemDTO } from '../../../shared/types.js'
|
||||||
|
import { toPosixPath } from '../../../shared/utils/path.js'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { keywords } = req.body as { keywords?: string[] }
|
||||||
|
if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
|
||||||
|
successResponse(res, { items: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerms = keywords.map(k => k.trim().toLowerCase()).filter(k => k.length > 0)
|
||||||
|
|
||||||
|
if (searchTerms.length === 0) {
|
||||||
|
successResponse(res, { items: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullPath: rootPath } = resolveNotebookPath('')
|
||||||
|
const results: FileItemDTO[] = []
|
||||||
|
const maxResults = 100
|
||||||
|
|
||||||
|
const searchDir = async (dir: string, relativeDir: string) => {
|
||||||
|
if (results.length >= maxResults) return
|
||||||
|
|
||||||
|
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (results.length >= maxResults) break
|
||||||
|
|
||||||
|
const entryPath = path.join(dir, entry.name)
|
||||||
|
const entryRelativePath = path.join(relativeDir, entry.name)
|
||||||
|
|
||||||
|
if (entry.name.startsWith('.') || entry.name === 'RB' || entry.name === 'node_modules') continue
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await searchDir(entryPath, entryRelativePath)
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
const fileNameLower = entry.name.toLowerCase()
|
||||||
|
let contentLower = ''
|
||||||
|
let contentLoaded = false
|
||||||
|
|
||||||
|
const checkKeyword = async (term: string) => {
|
||||||
|
if (fileNameLower.includes(term)) return true
|
||||||
|
|
||||||
|
if (entry.name.toLowerCase().endsWith('.md')) {
|
||||||
|
if (!contentLoaded) {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(entryPath, 'utf-8')
|
||||||
|
contentLower = content.toLowerCase()
|
||||||
|
contentLoaded = true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return contentLower.includes(term)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let allMatched = true
|
||||||
|
for (const term of searchTerms) {
|
||||||
|
const matched = await checkKeyword(term)
|
||||||
|
if (!matched) {
|
||||||
|
allMatched = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allMatched) {
|
||||||
|
results.push({
|
||||||
|
name: entry.name,
|
||||||
|
path: toPosixPath(entryRelativePath),
|
||||||
|
type: 'file',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await searchDir(rootPath, '')
|
||||||
|
successResponse(res, { items: results, limited: results.length >= maxResults })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
54
api/core/settings/routes.ts
Normal file
54
api/core/settings/routes.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import express, { type Request, type Response } from 'express'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { NOTEBOOK_ROOT } from '../../config/paths.js'
|
||||||
|
import type { SettingsDTO } from '../../../shared/types.js'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const getSettingsPath = () => path.join(NOTEBOOK_ROOT, '.config', 'settings.json')
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const settingsPath = getSettingsPath()
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(settingsPath, 'utf-8')
|
||||||
|
const settings = JSON.parse(content)
|
||||||
|
successResponse(res, settings)
|
||||||
|
} catch (error) {
|
||||||
|
successResponse(res, {})
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const settings = req.body as SettingsDTO
|
||||||
|
const settingsPath = getSettingsPath()
|
||||||
|
const configDir = path.dirname(settingsPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(configDir, { recursive: true })
|
||||||
|
|
||||||
|
let existingSettings = {}
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(settingsPath, 'utf-8')
|
||||||
|
existingSettings = JSON.parse(content)
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSettings = { ...existingSettings, ...settings }
|
||||||
|
await fs.writeFile(settingsPath, JSON.stringify(newSettings, null, 2), 'utf-8')
|
||||||
|
|
||||||
|
successResponse(res, newSettings)
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
100
api/core/upload/routes.ts
Normal file
100
api/core/upload/routes.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import express, { type Request, type Response } from 'express'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
|
import { getUniqueFilename, mimeToExt, validateImageBuffer, detectImageMimeType } from '../../utils/file.js'
|
||||||
|
import { NOTEBOOK_ROOT } from '../../config/paths.js'
|
||||||
|
import { toPosixPath } from '../../../shared/utils/path.js'
|
||||||
|
import { pad2, formatTimestamp } from '../../../shared/utils/date.js'
|
||||||
|
import { ValidationError, UnsupportedMediaTypeError } from '../../../shared/errors/index.js'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const parseImageDataUrl = (dataUrl: string): { mimeType: string; base64Data: string } | null => {
|
||||||
|
const match = dataUrl.match(/^data:(image\/[a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/=\s]+)$/)
|
||||||
|
if (!match) return null
|
||||||
|
const [, mimeType, base64Data] = match
|
||||||
|
return { mimeType, base64Data: base64Data.replace(/\s/g, '') }
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/image',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { image } = req.body as { image?: string }
|
||||||
|
if (!image) throw new ValidationError('需要图片数据')
|
||||||
|
|
||||||
|
const parsed = parseImageDataUrl(image)
|
||||||
|
if (!parsed) {
|
||||||
|
throw new ValidationError('无效的图片数据URL')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = mimeToExt[parsed.mimeType]
|
||||||
|
if (!ext) {
|
||||||
|
throw new UnsupportedMediaTypeError('不支持的图片类型')
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(parsed.base64Data, 'base64')
|
||||||
|
|
||||||
|
validateImageBuffer(buffer, parsed.mimeType)
|
||||||
|
|
||||||
|
const detectedMimeType = detectImageMimeType(buffer)
|
||||||
|
if (!detectedMimeType || detectedMimeType !== parsed.mimeType) {
|
||||||
|
throw new ValidationError('图片内容类型不匹配或图片已损坏')
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = pad2(now.getMonth() + 1)
|
||||||
|
const day = pad2(now.getDate())
|
||||||
|
|
||||||
|
const imagesSubDir = `images/${year}/${month}/${day}`
|
||||||
|
const { fullPath: imagesDirFullPath } = resolveNotebookPath(imagesSubDir)
|
||||||
|
await fs.mkdir(imagesDirFullPath, { recursive: true })
|
||||||
|
|
||||||
|
const baseName = formatTimestamp(now)
|
||||||
|
const filename = await getUniqueFilename(imagesDirFullPath, baseName, ext)
|
||||||
|
const relPath = `${imagesSubDir}/${filename}`
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
|
||||||
|
await fs.writeFile(fullPath, buffer)
|
||||||
|
successResponse(res, { name: toPosixPath(relPath), path: toPosixPath(relPath) })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/wallpaper',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { image } = req.body as { image?: string }
|
||||||
|
if (!image) throw new ValidationError('需要图片数据')
|
||||||
|
|
||||||
|
const parsed = parseImageDataUrl(image)
|
||||||
|
if (!parsed) {
|
||||||
|
throw new ValidationError('无效的图片数据URL')
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedWallpaperTypes = ['image/png', 'image/jpeg', 'image/webp']
|
||||||
|
if (!allowedWallpaperTypes.includes(parsed.mimeType)) {
|
||||||
|
throw new UnsupportedMediaTypeError('壁纸只支持PNG、JPEG和WebP格式')
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(parsed.base64Data, 'base64')
|
||||||
|
|
||||||
|
validateImageBuffer(buffer, parsed.mimeType)
|
||||||
|
|
||||||
|
const detectedMimeType = detectImageMimeType(buffer)
|
||||||
|
if (!detectedMimeType || detectedMimeType !== parsed.mimeType) {
|
||||||
|
throw new ValidationError('图片内容类型不匹配或图片已损坏')
|
||||||
|
}
|
||||||
|
|
||||||
|
const configDir = path.join(NOTEBOOK_ROOT, '.config')
|
||||||
|
const backgroundPath = path.join(configDir, 'background.png')
|
||||||
|
|
||||||
|
await fs.mkdir(configDir, { recursive: true })
|
||||||
|
await fs.writeFile(backgroundPath, buffer)
|
||||||
|
successResponse(res, { message: '壁纸已更新' })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
1
api/errors/errorCodes.ts
Normal file
1
api/errors/errorCodes.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ERROR_CODES as ErrorCodes, type ErrorCode } from '../../shared/constants/errors.js'
|
||||||
35
api/events/eventBus.ts
Normal file
35
api/events/eventBus.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Response } from 'express'
|
||||||
|
import { logger } from '../utils/logger.js'
|
||||||
|
|
||||||
|
type NotebookEvent = {
|
||||||
|
event: string
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let clients: Response[] = []
|
||||||
|
|
||||||
|
export const eventBus = {
|
||||||
|
addClient: (res: Response) => {
|
||||||
|
clients.push(res)
|
||||||
|
logger.info(`SSE client connected. Total clients: ${clients.length}`)
|
||||||
|
},
|
||||||
|
removeClient: (res: Response) => {
|
||||||
|
clients = clients.filter((c) => c !== res)
|
||||||
|
logger.info(`SSE client disconnected. Total clients: ${clients.length}`)
|
||||||
|
},
|
||||||
|
broadcast: (payload: NotebookEvent) => {
|
||||||
|
const data = `data: ${JSON.stringify(payload)}
|
||||||
|
|
||||||
|
`
|
||||||
|
logger.info(`Broadcasting to ${clients.length} clients: ${payload.event} - ${payload.path || ''}`)
|
||||||
|
clients = clients.filter((client) => {
|
||||||
|
try {
|
||||||
|
client.write(data)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('SSE client write failed, removing')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
9
api/index.ts
Normal file
9
api/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Vercel deploy entry handler, for serverless deployment, please don't modify this file
|
||||||
|
*/
|
||||||
|
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
||||||
|
import app from './app.js';
|
||||||
|
|
||||||
|
export default function handler(req: VercelRequest, res: VercelResponse) {
|
||||||
|
return app(req, res);
|
||||||
|
}
|
||||||
242
api/infra/container.ts
Normal file
242
api/infra/container.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
export enum ServiceLifetime {
|
||||||
|
Singleton = 'singleton',
|
||||||
|
Transient = 'transient',
|
||||||
|
Scoped = 'scoped'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceDescriptor<T = any> {
|
||||||
|
name: string
|
||||||
|
factory: () => T | Promise<T>
|
||||||
|
lifetime: ServiceLifetime
|
||||||
|
dependencies?: string[]
|
||||||
|
onDispose?: (instance: T) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ServiceFactory<T> = () => T | Promise<T>
|
||||||
|
|
||||||
|
export class ServiceContainer {
|
||||||
|
private descriptors = new Map<string, ServiceDescriptor>()
|
||||||
|
private singletonInstances = new Map<string, unknown>()
|
||||||
|
private scopedInstances = new Map<string, Map<string, unknown>>()
|
||||||
|
private resolutionStack: string[] = []
|
||||||
|
private disposed = false
|
||||||
|
|
||||||
|
register<T>(name: string, factory: ServiceFactory<T>): void
|
||||||
|
register<T>(descriptor: ServiceDescriptor<T>): void
|
||||||
|
register<T>(nameOrDescriptor: string | ServiceDescriptor<T>, factory?: ServiceFactory<T>): void {
|
||||||
|
this.ensureNotDisposed()
|
||||||
|
|
||||||
|
if (typeof nameOrDescriptor === 'string') {
|
||||||
|
const descriptor: ServiceDescriptor<T> = {
|
||||||
|
name: nameOrDescriptor,
|
||||||
|
factory: factory!,
|
||||||
|
lifetime: ServiceLifetime.Singleton
|
||||||
|
}
|
||||||
|
this.descriptors.set(nameOrDescriptor, descriptor)
|
||||||
|
} else {
|
||||||
|
this.descriptors.set(nameOrDescriptor.name, nameOrDescriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(name: string): Promise<T> {
|
||||||
|
this.ensureNotDisposed()
|
||||||
|
return this.resolveInternal<T>(name, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
getSync<T>(name: string): T {
|
||||||
|
this.ensureNotDisposed()
|
||||||
|
|
||||||
|
const descriptor = this.descriptors.get(name)
|
||||||
|
if (!descriptor) {
|
||||||
|
throw new Error(`Service '${name}' not registered`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptor.lifetime === ServiceLifetime.Singleton) {
|
||||||
|
if (this.singletonInstances.has(name)) {
|
||||||
|
return this.singletonInstances.get(name) as T
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = descriptor.factory()
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
throw new Error(
|
||||||
|
`Service '${name}' has an async factory but getSync() was called. Use get() instead.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptor.lifetime === ServiceLifetime.Singleton) {
|
||||||
|
this.singletonInstances.set(name, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as T
|
||||||
|
}
|
||||||
|
|
||||||
|
createScope(scopeId: string): ServiceScope {
|
||||||
|
this.ensureNotDisposed()
|
||||||
|
return new ServiceScope(this, scopeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
has(name: string): boolean {
|
||||||
|
return this.descriptors.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
if (this.disposed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, instance] of this.singletonInstances) {
|
||||||
|
const descriptor = this.descriptors.get(name)
|
||||||
|
if (descriptor?.onDispose) {
|
||||||
|
try {
|
||||||
|
await descriptor.onDispose(instance)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error disposing service '${name}':`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [, scopeMap] of this.scopedInstances) {
|
||||||
|
for (const [name, instance] of scopeMap) {
|
||||||
|
const descriptor = this.descriptors.get(name)
|
||||||
|
if (descriptor?.onDispose) {
|
||||||
|
try {
|
||||||
|
await descriptor.onDispose(instance)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error disposing scoped service '${name}':`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.singletonInstances.clear()
|
||||||
|
this.scopedInstances.clear()
|
||||||
|
this.descriptors.clear()
|
||||||
|
this.resolutionStack = []
|
||||||
|
this.disposed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.singletonInstances.clear()
|
||||||
|
this.scopedInstances.clear()
|
||||||
|
this.descriptors.clear()
|
||||||
|
this.resolutionStack = []
|
||||||
|
}
|
||||||
|
|
||||||
|
isDisposed(): boolean {
|
||||||
|
return this.disposed
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveInternal<T>(name: string, scopeId: string | null): Promise<T> {
|
||||||
|
if (this.resolutionStack.includes(name)) {
|
||||||
|
const cycle = [...this.resolutionStack, name].join(' -> ')
|
||||||
|
throw new Error(`Circular dependency detected: ${cycle}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const descriptor = this.descriptors.get(name)
|
||||||
|
if (!descriptor) {
|
||||||
|
throw new Error(`Service '${name}' not registered`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptor.lifetime === ServiceLifetime.Singleton) {
|
||||||
|
if (this.singletonInstances.has(name)) {
|
||||||
|
return this.singletonInstances.get(name) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resolutionStack.push(name)
|
||||||
|
try {
|
||||||
|
const instance = await descriptor.factory()
|
||||||
|
this.singletonInstances.set(name, instance)
|
||||||
|
return instance as T
|
||||||
|
} finally {
|
||||||
|
this.resolutionStack.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (descriptor.lifetime === ServiceLifetime.Scoped) {
|
||||||
|
if (!scopeId) {
|
||||||
|
throw new Error(
|
||||||
|
`Scoped service '${name}' cannot be resolved outside of a scope. Use createScope() first.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let scopeMap = this.scopedInstances.get(scopeId)
|
||||||
|
if (!scopeMap) {
|
||||||
|
scopeMap = new Map()
|
||||||
|
this.scopedInstances.set(scopeId, scopeMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeMap.has(name)) {
|
||||||
|
return scopeMap.get(name) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resolutionStack.push(name)
|
||||||
|
try {
|
||||||
|
const instance = await descriptor.factory()
|
||||||
|
scopeMap.set(name, instance)
|
||||||
|
return instance as T
|
||||||
|
} finally {
|
||||||
|
this.resolutionStack.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.resolutionStack.push(name)
|
||||||
|
try {
|
||||||
|
const instance = await descriptor.factory()
|
||||||
|
return instance as T
|
||||||
|
} finally {
|
||||||
|
this.resolutionStack.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureNotDisposed(): void {
|
||||||
|
if (this.disposed) {
|
||||||
|
throw new Error('ServiceContainer has been disposed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ServiceScope {
|
||||||
|
private container: ServiceContainer
|
||||||
|
private scopeId: string
|
||||||
|
private disposed = false
|
||||||
|
|
||||||
|
constructor(container: ServiceContainer, scopeId: string) {
|
||||||
|
this.container = container
|
||||||
|
this.scopeId = scopeId
|
||||||
|
}
|
||||||
|
|
||||||
|
async get<T>(name: string): Promise<T> {
|
||||||
|
if (this.disposed) {
|
||||||
|
throw new Error('ServiceScope has been disposed')
|
||||||
|
}
|
||||||
|
return this.container['resolveInternal']<T>(name, this.scopeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async dispose(): Promise<void> {
|
||||||
|
if (this.disposed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopeMap = this.container['scopedInstances'].get(this.scopeId)
|
||||||
|
if (scopeMap) {
|
||||||
|
for (const [name, instance] of scopeMap) {
|
||||||
|
const descriptor = this.container['descriptors'].get(name)
|
||||||
|
if (descriptor?.onDispose) {
|
||||||
|
try {
|
||||||
|
await descriptor.onDispose(instance)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error disposing scoped service '${name}':`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.container['scopedInstances'].delete(this.scopeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disposed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
isDisposed(): boolean {
|
||||||
|
return this.disposed
|
||||||
|
}
|
||||||
|
}
|
||||||
41
api/infra/createModule.ts
Normal file
41
api/infra/createModule.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import type { Router } from 'express'
|
||||||
|
import type { ServiceContainer } from './container.js'
|
||||||
|
import type { ApiModule, ModuleMetadata, ModuleLifecycle } from './types.js'
|
||||||
|
import type { ApiModuleConfig, ModuleEndpoints } from '../../shared/modules/types.js'
|
||||||
|
|
||||||
|
export interface CreateModuleOptions<TEndpoints extends ModuleEndpoints> {
|
||||||
|
routes: (container: ServiceContainer) => Router | Promise<Router>
|
||||||
|
lifecycle?: ModuleLifecycle
|
||||||
|
services?: (container: ServiceContainer) => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createApiModule<
|
||||||
|
TId extends string,
|
||||||
|
TEndpoints extends ModuleEndpoints
|
||||||
|
>(
|
||||||
|
config: ApiModuleConfig<TId, TEndpoints>,
|
||||||
|
options: CreateModuleOptions<TEndpoints>
|
||||||
|
): ApiModule {
|
||||||
|
const metadata: ModuleMetadata = {
|
||||||
|
id: config.id,
|
||||||
|
name: config.name,
|
||||||
|
version: config.version,
|
||||||
|
basePath: config.basePath,
|
||||||
|
order: config.order,
|
||||||
|
dependencies: config.dependencies,
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycle: ModuleLifecycle | undefined = options.lifecycle
|
||||||
|
? options.lifecycle
|
||||||
|
: options.services
|
||||||
|
? {
|
||||||
|
onLoad: options.services,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
lifecycle,
|
||||||
|
createRouter: options.routes,
|
||||||
|
}
|
||||||
|
}
|
||||||
5
api/infra/index.ts
Normal file
5
api/infra/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { ServiceContainer } from './container.js'
|
||||||
|
export type { ServiceFactory } from './container.js'
|
||||||
|
export { loadModules } from './moduleLoader.js'
|
||||||
|
export { ModuleManager } from './moduleManager.js'
|
||||||
|
export type { ApiModule, ModuleMetadata, ModuleLifecycle } from './types.js'
|
||||||
24
api/infra/moduleLoader.ts
Normal file
24
api/infra/moduleLoader.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Application } from 'express'
|
||||||
|
import type { ServiceContainer } from './container.js'
|
||||||
|
import type { ApiModule } from './types.js'
|
||||||
|
import { ModuleManager } from './moduleManager.js'
|
||||||
|
|
||||||
|
export async function loadModules(
|
||||||
|
app: Application,
|
||||||
|
container: ServiceContainer,
|
||||||
|
modules: ApiModule[]
|
||||||
|
): Promise<ModuleManager> {
|
||||||
|
const manager = new ModuleManager(container)
|
||||||
|
|
||||||
|
for (const module of modules) {
|
||||||
|
await manager.register(module)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const module of manager.getAllModules()) {
|
||||||
|
await manager.activate(module.metadata.id)
|
||||||
|
const router = await module.createRouter(container)
|
||||||
|
app.use('/api' + module.metadata.basePath, router)
|
||||||
|
}
|
||||||
|
|
||||||
|
return manager
|
||||||
|
}
|
||||||
76
api/infra/moduleManager.ts
Normal file
76
api/infra/moduleManager.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { ApiModule, ModuleMetadata } from './types.js'
|
||||||
|
import { ServiceContainer } from './container.js'
|
||||||
|
|
||||||
|
export class ModuleManager {
|
||||||
|
private modules = new Map<string, ApiModule>()
|
||||||
|
private activeModules = new Set<string>()
|
||||||
|
private container: ServiceContainer
|
||||||
|
|
||||||
|
constructor(container: ServiceContainer) {
|
||||||
|
this.container = container
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(module: ApiModule): Promise<void> {
|
||||||
|
const { id, dependencies = [] } = module.metadata
|
||||||
|
|
||||||
|
for (const dep of dependencies) {
|
||||||
|
if (!this.modules.has(dep)) {
|
||||||
|
throw new Error(`Module '${id}' depends on '${dep}' which is not registered`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.modules.set(id, module)
|
||||||
|
|
||||||
|
if (module.lifecycle?.onLoad) {
|
||||||
|
await module.lifecycle.onLoad(this.container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async activate(id: string): Promise<void> {
|
||||||
|
const module = this.modules.get(id)
|
||||||
|
if (!module) {
|
||||||
|
throw new Error(`Module '${id}' not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeModules.has(id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { dependencies = [] } = module.metadata
|
||||||
|
for (const dep of dependencies) {
|
||||||
|
await this.activate(dep)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (module.lifecycle?.onActivate) {
|
||||||
|
await module.lifecycle.onActivate(this.container)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeModules.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivate(id: string): Promise<void> {
|
||||||
|
const module = this.modules.get(id)
|
||||||
|
if (!module) return
|
||||||
|
|
||||||
|
if (!this.activeModules.has(id)) return
|
||||||
|
|
||||||
|
if (module.lifecycle?.onDeactivate) {
|
||||||
|
await module.lifecycle.onDeactivate(this.container)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeModules.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getModule(id: string): ApiModule | undefined {
|
||||||
|
return this.modules.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllModules(): ApiModule[] {
|
||||||
|
return Array.from(this.modules.values())
|
||||||
|
.sort((a, b) => (a.metadata.order || 0) - (b.metadata.order || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveModules(): string[] {
|
||||||
|
return Array.from(this.activeModules)
|
||||||
|
}
|
||||||
|
}
|
||||||
113
api/infra/moduleValidator.ts
Normal file
113
api/infra/moduleValidator.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { readdirSync, statSync, existsSync } from 'fs'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import type { ApiModule } from './types.js'
|
||||||
|
import type { ModuleDefinition } from '../../shared/modules/types.js'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
|
function getSharedModulesPath(): string | null {
|
||||||
|
const possiblePaths = [
|
||||||
|
join(__dirname, '../../shared/modules'),
|
||||||
|
join(__dirname, '../../../shared/modules'),
|
||||||
|
join((process as unknown as { resourcesPath?: string })?.resourcesPath || '', 'shared/modules'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const p of possiblePaths) {
|
||||||
|
if (existsSync(p)) {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedModuleDefinition {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
backend?: {
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsBackendImplementation(moduleDef: SharedModuleDefinition): boolean {
|
||||||
|
return moduleDef.backend?.enabled !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModuleDefinitions(): Promise<SharedModuleDefinition[]> {
|
||||||
|
const modules: SharedModuleDefinition[] = []
|
||||||
|
const sharedModulesPath = getSharedModulesPath()
|
||||||
|
|
||||||
|
if (!sharedModulesPath) {
|
||||||
|
return modules
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = readdirSync(sharedModulesPath)
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(sharedModulesPath, entry)
|
||||||
|
const stat = statSync(entryPath)
|
||||||
|
|
||||||
|
if (!stat.isDirectory()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moduleExports = await import(`../../shared/modules/${entry}/index.js`)
|
||||||
|
|
||||||
|
for (const key of Object.keys(moduleExports)) {
|
||||||
|
if (key.endsWith('_MODULE')) {
|
||||||
|
const moduleDef = moduleExports[key] as ModuleDefinition
|
||||||
|
if (moduleDef && moduleDef.id) {
|
||||||
|
modules.push({
|
||||||
|
id: moduleDef.id,
|
||||||
|
name: moduleDef.name,
|
||||||
|
backend: moduleDef.backend,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 模块加载失败,跳过
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modules
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateModuleConsistency(apiModules: ApiModule[]): Promise<void> {
|
||||||
|
const sharedModules = await loadModuleDefinitions()
|
||||||
|
|
||||||
|
if (sharedModules.length === 0) {
|
||||||
|
console.log('[ModuleValidator] Skipping validation (shared modules not found, likely packaged mode)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiModuleIds = new Set(apiModules.map((m) => m.metadata.id))
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
for (const sharedModule of sharedModules) {
|
||||||
|
const needsBackend = needsBackendImplementation(sharedModule)
|
||||||
|
const hasApiModule = apiModuleIds.has(sharedModule.id)
|
||||||
|
|
||||||
|
if (needsBackend && !hasApiModule) {
|
||||||
|
errors.push(
|
||||||
|
`Module '${sharedModule.id}' is defined in shared but not registered in API modules`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!needsBackend && hasApiModule) {
|
||||||
|
errors.push(
|
||||||
|
`Module '${sharedModule.id}' has backend disabled but is registered in API modules`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
throw new Error(`Module consistency validation failed:\n - ${errors.join('\n - ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[ModuleValidator] ✓ Module consistency validated: ${sharedModules.length} shared, ${apiModules.length} API`
|
||||||
|
)
|
||||||
|
}
|
||||||
31
api/infra/types.ts
Normal file
31
api/infra/types.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Router, Application } from 'express'
|
||||||
|
import type { ServiceContainer } from './container.js'
|
||||||
|
|
||||||
|
export interface ModuleMetadata {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
basePath: string
|
||||||
|
dependencies?: string[]
|
||||||
|
order?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModuleLifecycle {
|
||||||
|
onLoad?(container: ServiceContainer): void | Promise<void>
|
||||||
|
onUnload?(container: ServiceContainer): void | Promise<void>
|
||||||
|
onActivate?(container: ServiceContainer): void | Promise<void>
|
||||||
|
onDeactivate?(container: ServiceContainer): void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiModule {
|
||||||
|
metadata: ModuleMetadata
|
||||||
|
lifecycle?: ModuleLifecycle
|
||||||
|
createRouter: (container: ServiceContainer) => Router | Promise<Router>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyApiModule {
|
||||||
|
name: string
|
||||||
|
basePath: string
|
||||||
|
init?: (app: Application, container: ServiceContainer) => void | Promise<void>
|
||||||
|
createRouter: (container: ServiceContainer) => Router | Promise<Router>
|
||||||
|
}
|
||||||
199
api/middlewares/__tests__/errorHandler.test.ts
Normal file
199
api/middlewares/__tests__/errorHandler.test.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
|
|
||||||
|
const { mockIsAppError, mockIsNodeError, mockLoggerError, MockAppError, MockValidationError } = vi.hoisted(() => {
|
||||||
|
const mockFn = () => ({})
|
||||||
|
return {
|
||||||
|
mockIsAppError: vi.fn(),
|
||||||
|
mockIsNodeError: vi.fn(),
|
||||||
|
mockLoggerError: vi.fn(),
|
||||||
|
MockAppError: class MockAppError extends Error {
|
||||||
|
statusCode: number
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
code: string
|
||||||
|
constructor(
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
statusCode: number = 500,
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'MockAppError'
|
||||||
|
this.code = code
|
||||||
|
this.statusCode = statusCode
|
||||||
|
this.details = details
|
||||||
|
}
|
||||||
|
},
|
||||||
|
MockValidationError: class MockValidationError extends Error {
|
||||||
|
statusCode: number
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
code: string
|
||||||
|
constructor(message: string, details?: Record<string, unknown>) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'MockValidationError'
|
||||||
|
this.code = 'VALIDATION_ERROR'
|
||||||
|
this.statusCode = 400
|
||||||
|
this.details = details
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@shared/errors', () => ({
|
||||||
|
isAppError: mockIsAppError,
|
||||||
|
isNodeError: mockIsNodeError,
|
||||||
|
AppError: MockAppError,
|
||||||
|
ValidationError: MockValidationError,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/api/utils/logger', () => ({
|
||||||
|
logger: {
|
||||||
|
error: mockLoggerError,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { errorHandler } from '../errorHandler'
|
||||||
|
|
||||||
|
describe('errorHandler', () => {
|
||||||
|
let mockReq: Request
|
||||||
|
let mockRes: Response
|
||||||
|
let mockNext: NextFunction
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
mockReq = {} as Request
|
||||||
|
mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response
|
||||||
|
mockNext = vi.fn()
|
||||||
|
mockIsAppError.mockReturnValue(false)
|
||||||
|
mockIsNodeError.mockReturnValue(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('AppError 处理', () => {
|
||||||
|
it('AppError 应发送自定义状态码和错误码', () => {
|
||||||
|
const appError = new MockAppError('VALIDATION_ERROR', '验证失败', 400, { field: 'name' })
|
||||||
|
mockIsAppError.mockReturnValue(true)
|
||||||
|
|
||||||
|
errorHandler(appError, mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: '验证失败',
|
||||||
|
details: { field: 'name' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ValidationError 应发送正确的错误信息', () => {
|
||||||
|
const validationError = new MockValidationError('字段不能为空', { field: 'email' })
|
||||||
|
mockIsAppError.mockReturnValue(true)
|
||||||
|
|
||||||
|
errorHandler(validationError, mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: '字段不能为空',
|
||||||
|
details: { field: 'email' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('AppError 在生产环境不应包含 details', () => {
|
||||||
|
const originalEnv = process.env.NODE_ENV
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appError = new MockAppError('VALIDATION_ERROR', '验证失败', 400, { field: 'name' })
|
||||||
|
mockIsAppError.mockReturnValue(true)
|
||||||
|
|
||||||
|
errorHandler(appError, mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: '验证失败',
|
||||||
|
details: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
process.env.NODE_ENV = originalEnv
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Node.js 系统错误处理', () => {
|
||||||
|
it('Node.js 系统错误应包含 stack 信息(非生产环境)', () => {
|
||||||
|
const nodeError = new Error('系统错误') as NodeJS.ErrnoException
|
||||||
|
nodeError.code = 'ENOENT'
|
||||||
|
nodeError.stack = 'Error: 系统错误\n at Test.<anonymous>'
|
||||||
|
|
||||||
|
mockIsAppError.mockReturnValue(false)
|
||||||
|
mockIsNodeError.mockReturnValue(true)
|
||||||
|
|
||||||
|
errorHandler(nodeError, mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(500)
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: '系统错误',
|
||||||
|
details: {
|
||||||
|
stack: nodeError.stack,
|
||||||
|
nodeErrorCode: 'ENOENT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('普通 Error 应包含 stack 信息(非生产环境)', () => {
|
||||||
|
const error = new Error('普通错误')
|
||||||
|
error.stack = 'Error: 普通错误\n at Test.<anonymous>'
|
||||||
|
|
||||||
|
errorHandler(error, mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: '普通错误',
|
||||||
|
details: {
|
||||||
|
stack: error.stack,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('在生产环境不包含敏感信息', () => {
|
||||||
|
const originalEnv = process.env.NODE_ENV
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const error = new Error('错误信息')
|
||||||
|
error.stack = 'Error: 错误信息\n at Test.<anonymous>'
|
||||||
|
|
||||||
|
errorHandler(error, mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'INTERNAL_ERROR',
|
||||||
|
message: '错误信息',
|
||||||
|
details: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
process.env.NODE_ENV = originalEnv
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
42
api/middlewares/errorHandler.ts
Normal file
42
api/middlewares/errorHandler.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
import type { ApiResponse } from '../../shared/types.js'
|
||||||
|
import { ERROR_CODES } from '../../shared/constants/errors.js'
|
||||||
|
import { isAppError, isNodeError } from '../../shared/errors/index.js'
|
||||||
|
import { logger } from '../utils/logger.js'
|
||||||
|
|
||||||
|
export const errorHandler = (err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
let statusCode: number = 500
|
||||||
|
let code: string = ERROR_CODES.INTERNAL_ERROR
|
||||||
|
let message: string = 'Server internal error'
|
||||||
|
let details: unknown = undefined
|
||||||
|
|
||||||
|
if (isAppError(err)) {
|
||||||
|
statusCode = err.statusCode
|
||||||
|
code = err.code
|
||||||
|
message = err.message
|
||||||
|
details = err.details
|
||||||
|
} else if (isNodeError(err)) {
|
||||||
|
message = err.message
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
details = { stack: err.stack, nodeErrorCode: err.code }
|
||||||
|
}
|
||||||
|
} else if (err instanceof Error) {
|
||||||
|
message = err.message
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
details = { stack: err.stack }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(err)
|
||||||
|
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
details: process.env.NODE_ENV === 'production' ? undefined : details,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(statusCode).json(response)
|
||||||
|
}
|
||||||
33
api/middlewares/validate.ts
Normal file
33
api/middlewares/validate.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
|
import { ZodSchema, ZodError } from 'zod'
|
||||||
|
import { ValidationError } from '../../shared/errors/index.js'
|
||||||
|
|
||||||
|
export const validateBody = (schema: ZodSchema) => {
|
||||||
|
return (req: Request, _res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
req.body = schema.parse(req.body)
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
next(new ValidationError('Request validation failed', { issues: error.issues }))
|
||||||
|
} else {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateQuery = (schema: ZodSchema) => {
|
||||||
|
return (req: Request, _res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
req.query = schema.parse(req.query) as typeof req.query
|
||||||
|
next()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
next(new ValidationError('Query validation failed', { issues: error.issues }))
|
||||||
|
} else {
|
||||||
|
next(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
0
api/modules/.gitkeep
Normal file
0
api/modules/.gitkeep
Normal file
17
api/modules/ai/index.ts
Normal file
17
api/modules/ai/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Router } from 'express'
|
||||||
|
import type { ServiceContainer } from '../../infra/container.js'
|
||||||
|
import { createApiModule } from '../../infra/createModule.js'
|
||||||
|
import { AI_MODULE } from '../../../shared/modules/ai/index.js'
|
||||||
|
import { createAiRoutes } from './routes.js'
|
||||||
|
|
||||||
|
export * from './routes.js'
|
||||||
|
|
||||||
|
export const createAiModule = () => {
|
||||||
|
return createApiModule(AI_MODULE, {
|
||||||
|
routes: (_container: ServiceContainer): Router => {
|
||||||
|
return createAiRoutes()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createAiModule
|
||||||
112
api/modules/ai/routes.ts
Normal file
112
api/modules/ai/routes.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import express, { type Request, type Response } from 'express'
|
||||||
|
import { spawn } from 'child_process'
|
||||||
|
import path from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import fsSync from 'fs'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
|
import { ValidationError, NotFoundError, InternalError } from '../../../shared/errors/index.js'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = path.dirname(__filename)
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const PYTHON_TIMEOUT_MS = 30000
|
||||||
|
|
||||||
|
const spawnPythonWithTimeout = (
|
||||||
|
scriptPath: string,
|
||||||
|
args: string[],
|
||||||
|
stdinContent: string,
|
||||||
|
timeoutMs: number = PYTHON_TIMEOUT_MS
|
||||||
|
): Promise<string> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const pythonProcess = spawn('python', args, {
|
||||||
|
env: { ...process.env },
|
||||||
|
})
|
||||||
|
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
timeoutId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
cleanup()
|
||||||
|
pythonProcess.kill()
|
||||||
|
reject(new Error(`Python script timed out after ${timeoutMs}ms`))
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
pythonProcess.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
pythonProcess.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
pythonProcess.on('close', (code) => {
|
||||||
|
cleanup()
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(`Python script exited with code ${code}. Stderr: ${stderr}`))
|
||||||
|
} else {
|
||||||
|
resolve(stdout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
pythonProcess.on('error', (err) => {
|
||||||
|
cleanup()
|
||||||
|
reject(new Error(`Failed to start python process: ${err.message}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
pythonProcess.stdin.write(stdinContent)
|
||||||
|
pythonProcess.stdin.end()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/doubao',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { task, path: relPath } = req.body as { task?: string; path?: string }
|
||||||
|
|
||||||
|
if (!task) throw new ValidationError('Task is required')
|
||||||
|
if (!relPath) throw new ValidationError('Path is required')
|
||||||
|
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath)
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundError('File not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await fs.readFile(fullPath, 'utf-8')
|
||||||
|
|
||||||
|
const projectRoot = path.resolve(__dirname, '..', '..', '..')
|
||||||
|
const scriptPath = path.join(projectRoot, 'tools', 'doubao', 'main.py')
|
||||||
|
|
||||||
|
if (!fsSync.existsSync(scriptPath)) {
|
||||||
|
throw new InternalError(`Python script not found: ${scriptPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await spawnPythonWithTimeout(scriptPath, ['--task', task], content)
|
||||||
|
await fs.writeFile(fullPath, result, 'utf-8')
|
||||||
|
successResponse(res, { message: 'Task completed successfully' })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||||
|
throw new InternalError(`AI task failed: ${message}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
export const createAiRoutes = (): express.Router => router
|
||||||
|
|
||||||
|
export default createAiRoutes
|
||||||
217
api/modules/document-parser/blogRoutes.ts
Normal file
217
api/modules/document-parser/blogRoutes.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import express, { type Request, type Response } from 'express'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
|
import { getUniqueFilename } from '../../utils/file.js'
|
||||||
|
import { formatTimestamp } from '../../../shared/utils/date.js'
|
||||||
|
import { getTempDir } from '../../utils/tempDir.js'
|
||||||
|
import {
|
||||||
|
createJobContext,
|
||||||
|
spawnPythonScript,
|
||||||
|
findImageDestinations,
|
||||||
|
applyReplacements,
|
||||||
|
copyLocalImage,
|
||||||
|
cleanupJob,
|
||||||
|
getScriptPath,
|
||||||
|
ensureScriptExists,
|
||||||
|
} from './documentParser.js'
|
||||||
|
import type { ImageReplacement } from './documentParser.js'
|
||||||
|
import { ValidationError, InternalError } from '../../../shared/errors/index.js'
|
||||||
|
import { logger } from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const tempDir = getTempDir()
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/parse-local',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { htmlPath, htmlDir, assetsDirName, assetsFiles, targetPath } = req.body as {
|
||||||
|
htmlPath?: string
|
||||||
|
htmlDir?: string
|
||||||
|
assetsDirName?: string
|
||||||
|
assetsFiles?: string[]
|
||||||
|
targetPath?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!htmlPath || !htmlDir || !targetPath) {
|
||||||
|
throw new ValidationError('htmlPath, htmlDir and targetPath are required')
|
||||||
|
}
|
||||||
|
|
||||||
|
let fullTargetPath: string
|
||||||
|
try {
|
||||||
|
const resolved = resolveNotebookPath(targetPath)
|
||||||
|
fullTargetPath = resolved.fullPath
|
||||||
|
} catch (error) {
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptPath = getScriptPath('blog', 'parse_blog.py')
|
||||||
|
if (!ensureScriptExists(scriptPath)) {
|
||||||
|
throw new InternalError('Parser script not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobContext = await createJobContext('blog')
|
||||||
|
|
||||||
|
let htmlPathInJob = ''
|
||||||
|
try {
|
||||||
|
htmlPathInJob = path.join(jobContext.jobDir, 'input.html')
|
||||||
|
await fs.copyFile(htmlPath, htmlPathInJob)
|
||||||
|
|
||||||
|
if (assetsDirName && assetsFiles && assetsFiles.length > 0) {
|
||||||
|
const assetsDirPath = path.join(htmlDir, assetsDirName)
|
||||||
|
for (const relPath of assetsFiles) {
|
||||||
|
const srcPath = path.join(assetsDirPath, relPath)
|
||||||
|
if (existsSync(srcPath)) {
|
||||||
|
const destPath = path.join(jobContext.jobDir, assetsDirName, relPath)
|
||||||
|
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
||||||
|
await fs.copyFile(srcPath, destPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
await cleanupJob(jobContext.jobDir)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
processHtmlInBackground({
|
||||||
|
jobDir: jobContext.jobDir,
|
||||||
|
htmlPath: htmlPathInJob,
|
||||||
|
targetPath: fullTargetPath,
|
||||||
|
cwd: path.dirname(scriptPath),
|
||||||
|
jobContext,
|
||||||
|
originalHtmlDir: htmlDir,
|
||||||
|
originalAssetsDirName: assetsDirName,
|
||||||
|
}).catch(err => {
|
||||||
|
logger.error('Background HTML processing failed:', err)
|
||||||
|
fs.writeFile(fullTargetPath, `# 解析失败\n\n> 错误信息: ${err.message}`, 'utf-8').catch(() => { })
|
||||||
|
cleanupJob(jobContext.jobDir).catch(() => { })
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
message: 'HTML parsing started in background.',
|
||||||
|
status: 'processing'
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ProcessHtmlArgs {
|
||||||
|
jobDir: string
|
||||||
|
htmlPath: string
|
||||||
|
targetPath: string
|
||||||
|
cwd: string
|
||||||
|
jobContext: ReturnType<typeof createJobContext> extends Promise<infer T> ? T : never
|
||||||
|
originalHtmlDir?: string
|
||||||
|
originalAssetsDirName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processHtmlInBackground(args: ProcessHtmlArgs) {
|
||||||
|
const { jobDir, htmlPath, targetPath, cwd, jobContext, originalHtmlDir, originalAssetsDirName } = args
|
||||||
|
try {
|
||||||
|
await spawnPythonScript({
|
||||||
|
scriptPath: 'parse_blog.py',
|
||||||
|
args: [htmlPath],
|
||||||
|
cwd,
|
||||||
|
})
|
||||||
|
|
||||||
|
const parsedPathObj = path.parse(htmlPath)
|
||||||
|
const markdownPath = path.join(parsedPathObj.dir, `${parsedPathObj.name}.md`)
|
||||||
|
|
||||||
|
if (!existsSync(markdownPath)) {
|
||||||
|
throw new Error('Markdown result file not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
let mdContent = await fs.readFile(markdownPath, 'utf-8')
|
||||||
|
const ctx = await jobContext
|
||||||
|
|
||||||
|
const htmlDir = path.dirname(htmlPath)
|
||||||
|
const replacements: ImageReplacement[] = []
|
||||||
|
|
||||||
|
const destinations = findImageDestinations(mdContent)
|
||||||
|
for (const dest of destinations) {
|
||||||
|
const originalSrc = dest.url
|
||||||
|
if (!originalSrc) continue
|
||||||
|
|
||||||
|
if (originalSrc.startsWith('http://') || originalSrc.startsWith('https://')) {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(originalSrc, { responseType: 'arraybuffer', timeout: 10000 })
|
||||||
|
const contentType = response.headers['content-type']
|
||||||
|
let ext = '.jpg'
|
||||||
|
if (contentType) {
|
||||||
|
if (contentType.includes('png')) ext = '.png'
|
||||||
|
else if (contentType.includes('gif')) ext = '.gif'
|
||||||
|
else if (contentType.includes('webp')) ext = '.webp'
|
||||||
|
else if (contentType.includes('svg')) ext = '.svg'
|
||||||
|
else if (contentType.includes('jpeg') || contentType.includes('jpg')) ext = '.jpg'
|
||||||
|
}
|
||||||
|
const urlExt = path.extname(originalSrc.split('?')[0])
|
||||||
|
if (urlExt) ext = urlExt
|
||||||
|
|
||||||
|
const baseName = formatTimestamp(ctx.now)
|
||||||
|
const newFilename = await getUniqueFilename(ctx.destImagesDir, baseName, ext)
|
||||||
|
const newPath = path.join(ctx.destImagesDir, newFilename)
|
||||||
|
await fs.writeFile(newPath, response.data)
|
||||||
|
replacements.push({
|
||||||
|
start: dest.start,
|
||||||
|
end: dest.end,
|
||||||
|
original: originalSrc,
|
||||||
|
replacement: `/${ctx.imagesSubDir}/${newFilename}`
|
||||||
|
})
|
||||||
|
} catch { }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalSrc.startsWith('data:')) continue
|
||||||
|
|
||||||
|
let result = await copyLocalImage(
|
||||||
|
originalSrc,
|
||||||
|
jobDir,
|
||||||
|
htmlDir,
|
||||||
|
ctx.destImagesDir,
|
||||||
|
ctx.imagesSubDir,
|
||||||
|
ctx.now
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result && originalHtmlDir && originalAssetsDirName) {
|
||||||
|
const srcWithFiles = originalSrc.replace(/^\.\//, '').replace(/^\//, '')
|
||||||
|
const possiblePaths = [
|
||||||
|
path.join(originalHtmlDir, originalAssetsDirName, srcWithFiles),
|
||||||
|
path.join(originalHtmlDir, originalAssetsDirName, path.basename(srcWithFiles)),
|
||||||
|
]
|
||||||
|
for (const p of possiblePaths) {
|
||||||
|
if (existsSync(p)) {
|
||||||
|
const ext = path.extname(p) || '.jpg'
|
||||||
|
const baseName = formatTimestamp(ctx.now)
|
||||||
|
const newFilename = await getUniqueFilename(ctx.destImagesDir, baseName, ext)
|
||||||
|
const newPath = path.join(ctx.destImagesDir, newFilename)
|
||||||
|
await fs.copyFile(p, newPath)
|
||||||
|
result = { newLink: `/${ctx.imagesSubDir}/${newFilename}` }
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
replacements.push({
|
||||||
|
start: dest.start,
|
||||||
|
end: dest.end,
|
||||||
|
original: originalSrc,
|
||||||
|
replacement: result.newLink
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mdContent = applyReplacements(mdContent, replacements)
|
||||||
|
|
||||||
|
await fs.writeFile(targetPath, mdContent, 'utf-8')
|
||||||
|
await fs.unlink(markdownPath).catch(() => { })
|
||||||
|
} finally {
|
||||||
|
await cleanupJob(jobDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router
|
||||||
184
api/modules/document-parser/documentParser.ts
Normal file
184
api/modules/document-parser/documentParser.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { spawn } from 'child_process'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import { existsSync, mkdirSync } from 'fs'
|
||||||
|
import { PROJECT_ROOT, NOTEBOOK_ROOT, TEMP_ROOT } from '../../config/paths.js'
|
||||||
|
import { getUniqueFilename } from '../../utils/file.js'
|
||||||
|
import { formatTimestamp, pad2 } from '../../../shared/utils/date.js'
|
||||||
|
import { logger } from '../../utils/logger.js'
|
||||||
|
|
||||||
|
if (!existsSync(TEMP_ROOT)) {
|
||||||
|
mkdirSync(TEMP_ROOT, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobContext {
|
||||||
|
jobDir: string
|
||||||
|
now: Date
|
||||||
|
imagesSubDir: string
|
||||||
|
destImagesDir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createJobContext = async (prefix: string): Promise<JobContext> => {
|
||||||
|
const now = new Date()
|
||||||
|
const jobDir = path.join(TEMP_ROOT, `${prefix}_${formatTimestamp(now)}`)
|
||||||
|
await fs.mkdir(jobDir, { recursive: true })
|
||||||
|
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = pad2(now.getMonth() + 1)
|
||||||
|
const day = pad2(now.getDate())
|
||||||
|
const imagesSubDir = `images/${year}/${month}/${day}`
|
||||||
|
const destImagesDir = path.join(NOTEBOOK_ROOT, imagesSubDir)
|
||||||
|
await fs.mkdir(destImagesDir, { recursive: true })
|
||||||
|
|
||||||
|
return { jobDir, now, imagesSubDir, destImagesDir }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpawnPythonOptions {
|
||||||
|
scriptPath: string
|
||||||
|
args: string[]
|
||||||
|
cwd: string
|
||||||
|
inputContent?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const spawnPythonScript = async (options: SpawnPythonOptions): Promise<string> => {
|
||||||
|
const { scriptPath, args, cwd, inputContent } = options
|
||||||
|
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const pythonProcess = spawn('python', ['-X', 'utf8', scriptPath, ...args], {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, PYTHONIOENCODING: 'utf-8', PYTHONUTF8: '1' },
|
||||||
|
})
|
||||||
|
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
|
||||||
|
pythonProcess.stdout.on('data', (data) => {
|
||||||
|
stdout += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
pythonProcess.stderr.on('data', (data) => {
|
||||||
|
stderr += data.toString()
|
||||||
|
})
|
||||||
|
|
||||||
|
pythonProcess.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
logger.error('Python script error:', stderr)
|
||||||
|
reject(new Error(`Process exited with code ${code}. Error: ${stderr}`))
|
||||||
|
} else {
|
||||||
|
resolve(stdout)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
pythonProcess.on('error', (err) => {
|
||||||
|
reject(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (inputContent !== undefined) {
|
||||||
|
pythonProcess.stdin.write(inputContent)
|
||||||
|
pythonProcess.stdin.end()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageReplacement {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
original: string
|
||||||
|
replacement: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findImageDestinations = (md: string): Array<{ url: string; start: number; end: number }> => {
|
||||||
|
const results: Array<{ url: string; start: number; end: number }> = []
|
||||||
|
let i = 0
|
||||||
|
while (i < md.length) {
|
||||||
|
const bang = md.indexOf('![', i)
|
||||||
|
if (bang === -1) break
|
||||||
|
const closeBracket = md.indexOf(']', bang + 2)
|
||||||
|
if (closeBracket === -1) break
|
||||||
|
if (md[closeBracket + 1] !== '(') {
|
||||||
|
i = closeBracket + 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlStart = closeBracket + 2
|
||||||
|
let depth = 1
|
||||||
|
let j = urlStart
|
||||||
|
for (; j < md.length; j++) {
|
||||||
|
const ch = md[j]
|
||||||
|
if (ch === '(') depth++
|
||||||
|
else if (ch === ')') {
|
||||||
|
depth--
|
||||||
|
if (depth === 0) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (depth !== 0) break
|
||||||
|
results.push({ url: md.slice(urlStart, j), start: urlStart, end: j })
|
||||||
|
i = j + 1
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyReplacements = (md: string, replacements: ImageReplacement[]): string => {
|
||||||
|
const sorted = [...replacements].sort((a, b) => b.start - a.start)
|
||||||
|
let result = md
|
||||||
|
for (const r of sorted) {
|
||||||
|
result = `${result.slice(0, r.start)}${r.replacement}${result.slice(r.end)}`
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const copyLocalImage = async (
|
||||||
|
src: string,
|
||||||
|
jobDir: string,
|
||||||
|
htmlDir: string,
|
||||||
|
destImagesDir: string,
|
||||||
|
imagesSubDir: string,
|
||||||
|
now: Date
|
||||||
|
): Promise<{ newLink: string } | null> => {
|
||||||
|
const s0 = src.trim().replace(/^<|>$/g, '')
|
||||||
|
if (!s0) return null
|
||||||
|
|
||||||
|
let decoded = s0
|
||||||
|
try {
|
||||||
|
decoded = decodeURI(s0)
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const s1 = decoded.replace(/\\/g, '/')
|
||||||
|
const s2 = s1.startsWith('./') ? s1.slice(2) : s1
|
||||||
|
const candidates = s2.startsWith('/')
|
||||||
|
? [path.join(jobDir, s2.slice(1)), path.join(htmlDir, s2.slice(1))]
|
||||||
|
: [path.resolve(htmlDir, s2), path.resolve(jobDir, s2)]
|
||||||
|
|
||||||
|
let foundFile: string | null = null
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (existsSync(c)) {
|
||||||
|
foundFile = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!foundFile) return null
|
||||||
|
|
||||||
|
const ext = path.extname(foundFile) || '.jpg'
|
||||||
|
const baseName = formatTimestamp(now)
|
||||||
|
const newFilename = await getUniqueFilename(destImagesDir, baseName, ext)
|
||||||
|
const newPath = path.join(destImagesDir, newFilename)
|
||||||
|
await fs.copyFile(foundFile, newPath)
|
||||||
|
|
||||||
|
return { newLink: `/${imagesSubDir}/${newFilename}` }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const cleanupJob = async (jobDir: string, additionalPaths: string[] = []): Promise<void> => {
|
||||||
|
await fs.rm(jobDir, { recursive: true, force: true }).catch(() => {})
|
||||||
|
for (const p of additionalPaths) {
|
||||||
|
await fs.unlink(p).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getScriptPath = (toolName: string, scriptName: string): string => {
|
||||||
|
return path.join(PROJECT_ROOT, 'tools', toolName, scriptName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ensureScriptExists = (scriptPath: string): boolean => {
|
||||||
|
return existsSync(scriptPath)
|
||||||
|
}
|
||||||
23
api/modules/document-parser/index.ts
Normal file
23
api/modules/document-parser/index.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import express, { type Router } from 'express'
|
||||||
|
import type { ServiceContainer } from '../../infra/container.js'
|
||||||
|
import { createApiModule } from '../../infra/createModule.js'
|
||||||
|
import { DOCUMENT_PARSER_MODULE } from '../../../shared/modules/document-parser/index.js'
|
||||||
|
import blogRoutes from './blogRoutes.js'
|
||||||
|
import mineruRoutes from './mineruRoutes.js'
|
||||||
|
|
||||||
|
export * from './documentParser.js'
|
||||||
|
export { default as blogRoutes } from './blogRoutes.js'
|
||||||
|
export { default as mineruRoutes } from './mineruRoutes.js'
|
||||||
|
|
||||||
|
export const createDocumentParserModule = () => {
|
||||||
|
return createApiModule(DOCUMENT_PARSER_MODULE, {
|
||||||
|
routes: (_container: ServiceContainer): Router => {
|
||||||
|
const router = express.Router()
|
||||||
|
router.use('/blog', blogRoutes)
|
||||||
|
router.use('/mineru', mineruRoutes)
|
||||||
|
return router
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createDocumentParserModule
|
||||||
158
api/modules/document-parser/mineruRoutes.ts
Normal file
158
api/modules/document-parser/mineruRoutes.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import express, { type Request, type Response } from 'express'
|
||||||
|
import multer from 'multer'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
|
import { getUniqueFilename } from '../../utils/file.js'
|
||||||
|
import { formatTimestamp } from '../../../shared/utils/date.js'
|
||||||
|
import { getTempDir } from '../../utils/tempDir.js'
|
||||||
|
import {
|
||||||
|
createJobContext,
|
||||||
|
spawnPythonScript,
|
||||||
|
findImageDestinations,
|
||||||
|
applyReplacements,
|
||||||
|
cleanupJob,
|
||||||
|
getScriptPath,
|
||||||
|
ensureScriptExists,
|
||||||
|
} from './documentParser.js'
|
||||||
|
import type { ImageReplacement } from './documentParser.js'
|
||||||
|
import { ValidationError, InternalError } from '../../../shared/errors/index.js'
|
||||||
|
import { logger } from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
const tempDir = getTempDir()
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
dest: tempDir,
|
||||||
|
limits: {
|
||||||
|
fileSize: 50 * 1024 * 1024
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/parse',
|
||||||
|
upload.single('file'),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
if (!req.file) {
|
||||||
|
throw new ValidationError('File is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { targetPath } = req.body as { targetPath?: string }
|
||||||
|
if (!targetPath) {
|
||||||
|
await fs.unlink(req.file.path).catch(() => {})
|
||||||
|
throw new ValidationError('Target path is required')
|
||||||
|
}
|
||||||
|
|
||||||
|
let fullTargetPath: string
|
||||||
|
try {
|
||||||
|
const resolved = resolveNotebookPath(targetPath)
|
||||||
|
fullTargetPath = resolved.fullPath
|
||||||
|
} catch (error) {
|
||||||
|
await fs.unlink(req.file.path).catch(() => {})
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptPath = getScriptPath('mineru', 'mineru_parser.py')
|
||||||
|
if (!ensureScriptExists(scriptPath)) {
|
||||||
|
await fs.unlink(req.file.path).catch(() => {})
|
||||||
|
throw new InternalError('Parser script not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
processPdfInBackground(req.file.path, fullTargetPath, path.dirname(scriptPath))
|
||||||
|
.catch(err => {
|
||||||
|
logger.error('Background PDF processing failed:', err)
|
||||||
|
fs.writeFile(fullTargetPath, `# 解析失败\n\n> 错误信息: ${err.message}`, 'utf-8').catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
message: 'PDF upload successful. Parsing started in background.',
|
||||||
|
status: 'processing'
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
async function processPdfInBackground(filePath: string, targetPath: string, cwd: string) {
|
||||||
|
try {
|
||||||
|
const output = await spawnPythonScript({
|
||||||
|
scriptPath: 'mineru_parser.py',
|
||||||
|
args: [filePath],
|
||||||
|
cwd,
|
||||||
|
})
|
||||||
|
|
||||||
|
const match = output.match(/JSON_RESULT:(.*)/)
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Failed to parse Python script output: JSON_RESULT not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = JSON.parse(match[1])
|
||||||
|
const markdownPath = result.markdown_file
|
||||||
|
const outputDir = result.output_dir
|
||||||
|
|
||||||
|
if (!existsSync(markdownPath)) {
|
||||||
|
throw new Error('Markdown result file not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
let mdContent = await fs.readFile(markdownPath, 'utf-8')
|
||||||
|
|
||||||
|
const imagesDir = path.join(outputDir, 'images')
|
||||||
|
if (existsSync(imagesDir)) {
|
||||||
|
const jobContext = await createJobContext('pdf_images')
|
||||||
|
|
||||||
|
const destinations = findImageDestinations(mdContent)
|
||||||
|
const replacements: ImageReplacement[] = []
|
||||||
|
|
||||||
|
for (const dest of destinations) {
|
||||||
|
const originalSrc = dest.url
|
||||||
|
if (!originalSrc) continue
|
||||||
|
|
||||||
|
const possibleFilenames = [originalSrc, path.basename(originalSrc)]
|
||||||
|
let foundFile: string | null = null
|
||||||
|
|
||||||
|
for (const fname of possibleFilenames) {
|
||||||
|
const localPath = path.join(imagesDir, fname)
|
||||||
|
if (existsSync(localPath)) {
|
||||||
|
foundFile = localPath
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
const directPath = path.join(outputDir, originalSrc)
|
||||||
|
if (existsSync(directPath)) {
|
||||||
|
foundFile = directPath
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundFile) {
|
||||||
|
const ext = path.extname(foundFile)
|
||||||
|
const baseName = formatTimestamp(jobContext.now)
|
||||||
|
const newFilename = await getUniqueFilename(jobContext.destImagesDir, baseName, ext)
|
||||||
|
const newPath = path.join(jobContext.destImagesDir, newFilename)
|
||||||
|
await fs.copyFile(foundFile, newPath)
|
||||||
|
replacements.push({
|
||||||
|
start: dest.start,
|
||||||
|
end: dest.end,
|
||||||
|
original: originalSrc,
|
||||||
|
replacement: `${jobContext.imagesSubDir}/${newFilename}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mdContent = applyReplacements(mdContent, replacements)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(targetPath, mdContent, 'utf-8')
|
||||||
|
await fs.unlink(markdownPath).catch(() => {})
|
||||||
|
|
||||||
|
if (outputDir && outputDir.includes('temp')) {
|
||||||
|
await fs.rm(outputDir, { recursive: true, force: true }).catch(() => {})
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fs.unlink(filePath).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default router
|
||||||
68
api/modules/index.ts
Normal file
68
api/modules/index.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { readdirSync, statSync } from 'fs'
|
||||||
|
import { join, dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import type { ApiModule } from '../infra/types.js'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
|
const moduleFactoryPattern = /^create\w+Module$/
|
||||||
|
|
||||||
|
async function discoverModules(): Promise<ApiModule[]> {
|
||||||
|
const modules: ApiModule[] = []
|
||||||
|
const entries = readdirSync(__dirname)
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(__dirname, entry)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = statSync(entryPath)
|
||||||
|
if (!stats.isDirectory()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleIndexPath = join(entryPath, 'index.ts')
|
||||||
|
let moduleIndexStats: ReturnType<typeof statSync>
|
||||||
|
try {
|
||||||
|
moduleIndexStats = statSync(moduleIndexPath)
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!moduleIndexStats.isFile()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const moduleExports = await import(`./${entry}/index.js`)
|
||||||
|
|
||||||
|
for (const exportName of Object.keys(moduleExports)) {
|
||||||
|
if (moduleFactoryPattern.test(exportName)) {
|
||||||
|
const factory = moduleExports[exportName]
|
||||||
|
if (typeof factory === 'function') {
|
||||||
|
const module = factory() as ApiModule
|
||||||
|
modules.push(module)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[ModuleLoader] Failed to load module '${entry}':`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modules.sort((a, b) => {
|
||||||
|
const orderA = a.metadata.order ?? 0
|
||||||
|
const orderB = b.metadata.order ?? 0
|
||||||
|
return orderA - orderB
|
||||||
|
})
|
||||||
|
|
||||||
|
return modules
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiModules: ApiModule[] = await discoverModules()
|
||||||
|
|
||||||
|
export * from './todo/index.js'
|
||||||
|
export * from './time-tracking/index.js'
|
||||||
|
export * from './recycle-bin/index.js'
|
||||||
|
export * from './pydemos/index.js'
|
||||||
|
export * from './document-parser/index.js'
|
||||||
|
export * from './ai/index.js'
|
||||||
|
export * from './remote/index.js'
|
||||||
17
api/modules/pydemos/index.ts
Normal file
17
api/modules/pydemos/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Router } from 'express'
|
||||||
|
import type { ServiceContainer } from '../../infra/container.js'
|
||||||
|
import { createApiModule } from '../../infra/createModule.js'
|
||||||
|
import { PYDEMOS_MODULE } from '../../../shared/modules/pydemos/index.js'
|
||||||
|
import { createPyDemosRoutes } from './routes.js'
|
||||||
|
|
||||||
|
export * from './routes.js'
|
||||||
|
|
||||||
|
export const createPyDemosModule = () => {
|
||||||
|
return createApiModule(PYDEMOS_MODULE, {
|
||||||
|
routes: (_container: ServiceContainer): Router => {
|
||||||
|
return createPyDemosRoutes()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPyDemosModule
|
||||||
258
api/modules/pydemos/routes.ts
Normal file
258
api/modules/pydemos/routes.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import express, { type Request, type Response, type Router } from 'express'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import multer from 'multer'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
|
import { getTempDir } from '../../utils/tempDir.js'
|
||||||
|
import { validateBody, validateQuery } from '../../middlewares/validate.js'
|
||||||
|
import {
|
||||||
|
listPyDemosQuerySchema,
|
||||||
|
createPyDemoSchema,
|
||||||
|
deletePyDemoSchema,
|
||||||
|
renamePyDemoSchema,
|
||||||
|
} from '../../schemas/index.js'
|
||||||
|
import { NotFoundError, AlreadyExistsError, isNodeError, ValidationError } from '../../../shared/errors/index.js'
|
||||||
|
import type { PyDemoItem, PyDemoMonth } from '../../../shared/types/pydemos.js'
|
||||||
|
|
||||||
|
const tempDir = getTempDir()
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
dest: tempDir,
|
||||||
|
limits: {
|
||||||
|
fileSize: 50 * 1024 * 1024
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const toPosixPath = (p: string) => p.replace(/\\/g, '/')
|
||||||
|
|
||||||
|
const getYearPath = (year: number): { relPath: string; fullPath: string } => {
|
||||||
|
const relPath = `pydemos/${year}`
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
return { relPath, fullPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMonthPath = (year: number, month: number): { relPath: string; fullPath: string } => {
|
||||||
|
const monthStr = month.toString().padStart(2, '0')
|
||||||
|
const relPath = `pydemos/${year}/${monthStr}`
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
return { relPath, fullPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
const countFilesInDir = async (dirPath: string): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||||
|
return entries.filter(e => e.isFile()).length
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPyDemosRoutes = (): Router => {
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
validateQuery(listPyDemosQuerySchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const year = parseInt(req.query.year as string) || new Date().getFullYear()
|
||||||
|
|
||||||
|
const { fullPath: yearPath } = getYearPath(year)
|
||||||
|
const months: PyDemoMonth[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(yearPath)
|
||||||
|
} catch {
|
||||||
|
successResponse(res, { months })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthEntries = await fs.readdir(yearPath, { withFileTypes: true })
|
||||||
|
|
||||||
|
for (const monthEntry of monthEntries) {
|
||||||
|
if (!monthEntry.isDirectory()) continue
|
||||||
|
|
||||||
|
const monthNum = parseInt(monthEntry.name)
|
||||||
|
if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) continue
|
||||||
|
|
||||||
|
const monthPath = path.join(yearPath, monthEntry.name)
|
||||||
|
const demoEntries = await fs.readdir(monthPath, { withFileTypes: true })
|
||||||
|
|
||||||
|
const demos: PyDemoItem[] = []
|
||||||
|
|
||||||
|
for (const demoEntry of demoEntries) {
|
||||||
|
if (!demoEntry.isDirectory()) continue
|
||||||
|
|
||||||
|
const demoPath = path.join(monthPath, demoEntry.name)
|
||||||
|
const relDemoPath = `pydemos/${year}/${monthEntry.name}/${demoEntry.name}`
|
||||||
|
|
||||||
|
let created: string
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(demoPath)
|
||||||
|
created = stats.birthtime.toISOString()
|
||||||
|
} catch {
|
||||||
|
created = new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileCount = await countFilesInDir(demoPath)
|
||||||
|
|
||||||
|
demos.push({
|
||||||
|
name: demoEntry.name,
|
||||||
|
path: toPosixPath(relDemoPath),
|
||||||
|
created,
|
||||||
|
fileCount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
demos.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
|
||||||
|
|
||||||
|
if (demos.length > 0) {
|
||||||
|
months.push({
|
||||||
|
month: monthNum,
|
||||||
|
demos
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
months.sort((a, b) => a.month - b.month)
|
||||||
|
|
||||||
|
successResponse(res, { months })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/create',
|
||||||
|
upload.array('files'),
|
||||||
|
validateBody(createPyDemoSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { name, year, month, folderStructure } = req.body
|
||||||
|
|
||||||
|
const yearNum = parseInt(year)
|
||||||
|
const monthNum = parseInt(month)
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(name)) {
|
||||||
|
throw new ValidationError('Invalid name format')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullPath: monthPath, relPath: monthRelPath } = getMonthPath(yearNum, monthNum)
|
||||||
|
const demoPath = path.join(monthPath, name)
|
||||||
|
const relDemoPath = `${monthRelPath}/${name}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(demoPath)
|
||||||
|
throw new AlreadyExistsError('Demo already exists')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||||
|
// 目录不存在,可以创建
|
||||||
|
} else if (err instanceof AlreadyExistsError) {
|
||||||
|
throw err
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.mkdir(demoPath, { recursive: true })
|
||||||
|
|
||||||
|
const files = req.files as Express.Multer.File[] | undefined
|
||||||
|
let fileCount = 0
|
||||||
|
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
let structure: Record<string, string> = {}
|
||||||
|
if (folderStructure) {
|
||||||
|
try {
|
||||||
|
structure = JSON.parse(folderStructure)
|
||||||
|
} catch {
|
||||||
|
structure = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const relativePath = structure[file.originalname] || file.originalname
|
||||||
|
const targetPath = path.join(demoPath, relativePath)
|
||||||
|
const targetDir = path.dirname(targetPath)
|
||||||
|
|
||||||
|
await fs.mkdir(targetDir, { recursive: true })
|
||||||
|
await fs.copyFile(file.path, targetPath)
|
||||||
|
await fs.unlink(file.path).catch(() => { })
|
||||||
|
fileCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, { path: toPosixPath(relDemoPath), fileCount })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/delete',
|
||||||
|
validateBody(deletePyDemoSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { path: demoPath } = req.body
|
||||||
|
|
||||||
|
if (!demoPath.startsWith('pydemos/')) {
|
||||||
|
throw new ValidationError('Invalid path')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullPath } = resolveNotebookPath(demoPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath)
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundError('Demo not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(fullPath, { recursive: true, force: true })
|
||||||
|
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/rename',
|
||||||
|
validateBody(renamePyDemoSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { oldPath, newName } = req.body
|
||||||
|
|
||||||
|
if (!oldPath.startsWith('pydemos/')) {
|
||||||
|
throw new ValidationError('Invalid path')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-zA-Z0-9_\-\u4e00-\u9fa5]+$/.test(newName)) {
|
||||||
|
throw new ValidationError('Invalid name format')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullPath: oldFullPath } = resolveNotebookPath(oldPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(oldFullPath)
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundError('Demo not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentDir = path.dirname(oldFullPath)
|
||||||
|
const newFullPath = path.join(parentDir, newName)
|
||||||
|
const newPath = toPosixPath(path.join(path.dirname(oldPath), newName))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(newFullPath)
|
||||||
|
throw new AlreadyExistsError('Demo with this name already exists')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isNodeError(err) && err.code === 'ENOENT') {
|
||||||
|
// 目录不存在,可以重命名
|
||||||
|
} else if (err instanceof AlreadyExistsError) {
|
||||||
|
throw err
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rename(oldFullPath, newFullPath)
|
||||||
|
|
||||||
|
successResponse(res, { newPath })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createPyDemosRoutes()
|
||||||
17
api/modules/recycle-bin/index.ts
Normal file
17
api/modules/recycle-bin/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Router } from 'express'
|
||||||
|
import type { ServiceContainer } from '../../infra/container.js'
|
||||||
|
import { createApiModule } from '../../infra/createModule.js'
|
||||||
|
import { RECYCLE_BIN_MODULE } from '../../../shared/modules/recycle-bin/index.js'
|
||||||
|
import router from './routes.js'
|
||||||
|
|
||||||
|
export * from './recycleBinService.js'
|
||||||
|
|
||||||
|
export const createRecycleBinModule = () => {
|
||||||
|
return createApiModule(RECYCLE_BIN_MODULE, {
|
||||||
|
routes: (_container: ServiceContainer): Router => {
|
||||||
|
return router
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createRecycleBinModule
|
||||||
78
api/modules/recycle-bin/recycleBinService.ts
Normal file
78
api/modules/recycle-bin/recycleBinService.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
|
|
||||||
|
export async function restoreFile(
|
||||||
|
srcPath: string,
|
||||||
|
destPath: string,
|
||||||
|
deletedDate: string,
|
||||||
|
year: string,
|
||||||
|
month: string,
|
||||||
|
day: string
|
||||||
|
) {
|
||||||
|
const { fullPath: imagesDir } = resolveNotebookPath(`images/${year}/${month}/${day}`)
|
||||||
|
|
||||||
|
let content = await fs.readFile(srcPath, 'utf-8')
|
||||||
|
|
||||||
|
const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
|
||||||
|
let match
|
||||||
|
const imageReplacements: { oldPath: string; newPath: string }[] = []
|
||||||
|
|
||||||
|
while ((match = imageRegex.exec(content)) !== null) {
|
||||||
|
const imagePath = match[2]
|
||||||
|
const imageName = path.basename(imagePath)
|
||||||
|
|
||||||
|
const rbImageName = `${deletedDate}_${imageName}`
|
||||||
|
const { fullPath: srcImagePath } = resolveNotebookPath(`RB/${rbImageName}`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(srcImagePath)
|
||||||
|
await fs.mkdir(imagesDir, { recursive: true })
|
||||||
|
const destImagePath = path.join(imagesDir, imageName)
|
||||||
|
|
||||||
|
await fs.rename(srcImagePath, destImagePath)
|
||||||
|
|
||||||
|
const newImagePath = `images/${year}/${month}/${day}/${imageName}`
|
||||||
|
imageReplacements.push({ oldPath: imagePath, newPath: newImagePath })
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { oldPath, newPath } of imageReplacements) {
|
||||||
|
content = content.replace(new RegExp(oldPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(destPath, content, 'utf-8')
|
||||||
|
await fs.unlink(srcPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreFolder(
|
||||||
|
srcPath: string,
|
||||||
|
destPath: string,
|
||||||
|
deletedDate: string,
|
||||||
|
year: string,
|
||||||
|
month: string,
|
||||||
|
day: string
|
||||||
|
) {
|
||||||
|
await fs.mkdir(destPath, { recursive: true })
|
||||||
|
|
||||||
|
const entries = await fs.readdir(srcPath, { withFileTypes: true })
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const srcEntryPath = path.join(srcPath, entry.name)
|
||||||
|
const destEntryPath = path.join(destPath, entry.name)
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await restoreFolder(srcEntryPath, destEntryPath, deletedDate, year, month, day)
|
||||||
|
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||||
|
await restoreFile(srcEntryPath, destEntryPath, deletedDate, year, month, day)
|
||||||
|
} else {
|
||||||
|
await fs.rename(srcEntryPath, destEntryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = await fs.readdir(srcPath)
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
await fs.rmdir(srcPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
175
api/modules/recycle-bin/routes.ts
Normal file
175
api/modules/recycle-bin/routes.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import express, { type Request, type Response } from 'express'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
|
import { restoreFile, restoreFolder } from './recycleBinService.js'
|
||||||
|
import {
|
||||||
|
NotFoundError,
|
||||||
|
BadRequestError,
|
||||||
|
ValidationError,
|
||||||
|
AlreadyExistsError,
|
||||||
|
} from '../../../shared/errors/index.js'
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { fullPath: rbDir } = resolveNotebookPath('RB')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(rbDir)
|
||||||
|
} catch {
|
||||||
|
successResponse(res, { groups: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await fs.readdir(rbDir, { withFileTypes: true })
|
||||||
|
|
||||||
|
const items: { name: string; originalName: string; type: 'file' | 'dir'; deletedDate: string; path: string }[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const match = entry.name.match(/^(\d{8})_(.+)$/)
|
||||||
|
if (!match) continue
|
||||||
|
|
||||||
|
const [, dateStr, originalName] = match
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
items.push({
|
||||||
|
name: entry.name,
|
||||||
|
originalName,
|
||||||
|
type: 'dir',
|
||||||
|
deletedDate: dateStr,
|
||||||
|
path: `RB/${entry.name}`,
|
||||||
|
})
|
||||||
|
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) {
|
||||||
|
items.push({
|
||||||
|
name: entry.name,
|
||||||
|
originalName,
|
||||||
|
type: 'file',
|
||||||
|
deletedDate: dateStr,
|
||||||
|
path: `RB/${entry.name}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupedMap = new Map<string, typeof items>()
|
||||||
|
for (const item of items) {
|
||||||
|
const existing = groupedMap.get(item.deletedDate) || []
|
||||||
|
existing.push(item)
|
||||||
|
groupedMap.set(item.deletedDate, existing)
|
||||||
|
}
|
||||||
|
|
||||||
|
const groups = Array.from(groupedMap.entries())
|
||||||
|
.map(([date, items]) => ({
|
||||||
|
date,
|
||||||
|
items: items.sort((a, b) => a.originalName.localeCompare(b.originalName)),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.date.localeCompare(a.date))
|
||||||
|
|
||||||
|
successResponse(res, { groups })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/restore',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { path: relPath, type } = req.body as { path?: string; type?: 'file' | 'dir' }
|
||||||
|
if (!relPath || !type) {
|
||||||
|
throw new ValidationError('Path and type are required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullPath: itemPath } = resolveNotebookPath(relPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(itemPath)
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundError('Item not found in recycle bin')
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = path.basename(itemPath).match(/^(\d{8})_(.+)$/)
|
||||||
|
if (!match) {
|
||||||
|
throw new BadRequestError('Invalid recycle bin item name')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, dateStr, originalName] = match
|
||||||
|
const year = dateStr.substring(0, 4)
|
||||||
|
const month = dateStr.substring(4, 6)
|
||||||
|
const day = dateStr.substring(6, 8)
|
||||||
|
|
||||||
|
const { fullPath: markdownsDir } = resolveNotebookPath('markdowns')
|
||||||
|
await fs.mkdir(markdownsDir, { recursive: true })
|
||||||
|
|
||||||
|
const destPath = path.join(markdownsDir, originalName)
|
||||||
|
|
||||||
|
const existing = await fs.stat(destPath).catch(() => null)
|
||||||
|
if (existing) {
|
||||||
|
throw new AlreadyExistsError('A file or folder with this name already exists')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'dir') {
|
||||||
|
await restoreFolder(itemPath, destPath, dateStr, year, month, day)
|
||||||
|
} else {
|
||||||
|
await restoreFile(itemPath, destPath, dateStr, year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/permanent',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { path: relPath, type } = req.body as { path?: string; type?: 'file' | 'dir' }
|
||||||
|
if (!relPath || !type) {
|
||||||
|
throw new ValidationError('Path and type are required')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fullPath: itemPath } = resolveNotebookPath(relPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(itemPath)
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundError('Item not found in recycle bin')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'dir') {
|
||||||
|
await fs.rm(itemPath, { recursive: true, force: true })
|
||||||
|
} else {
|
||||||
|
await fs.unlink(itemPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/empty',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { fullPath: rbDir } = resolveNotebookPath('RB')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(rbDir)
|
||||||
|
} catch {
|
||||||
|
successResponse(res, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await fs.readdir(rbDir, { withFileTypes: true })
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = path.join(rbDir, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await fs.rm(entryPath, { recursive: true, force: true })
|
||||||
|
} else {
|
||||||
|
await fs.unlink(entryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export default router
|
||||||
25
api/modules/remote/index.ts
Normal file
25
api/modules/remote/index.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Router } from 'express'
|
||||||
|
import type { ServiceContainer } from '../../infra/container.js'
|
||||||
|
import { createApiModule } from '../../infra/createModule.js'
|
||||||
|
import { REMOTE_MODULE } from '../../../shared/modules/remote/index.js'
|
||||||
|
import { RemoteService } from './service.js'
|
||||||
|
import { createRemoteRoutes } from './routes.js'
|
||||||
|
|
||||||
|
export * from './service.js'
|
||||||
|
export * from './routes.js'
|
||||||
|
|
||||||
|
export const createRemoteModule = () => {
|
||||||
|
return createApiModule(REMOTE_MODULE, {
|
||||||
|
routes: (container: ServiceContainer): Router => {
|
||||||
|
const remoteService = container.getSync<RemoteService>('remoteService')
|
||||||
|
return createRemoteRoutes({ remoteService })
|
||||||
|
},
|
||||||
|
lifecycle: {
|
||||||
|
onLoad: (container: ServiceContainer): void => {
|
||||||
|
container.register('remoteService', () => new RemoteService())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createRemoteModule
|
||||||
80
api/modules/remote/routes.ts
Normal file
80
api/modules/remote/routes.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import express, { type Request, type Response, type Router } from 'express'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { RemoteService, type DeviceData } from './service.js'
|
||||||
|
|
||||||
|
export interface RemoteRoutesDependencies {
|
||||||
|
remoteService: RemoteService
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRemoteRoutes = (deps: RemoteRoutesDependencies): Router => {
|
||||||
|
const router = express.Router()
|
||||||
|
const { remoteService } = deps
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/config',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const config = await remoteService.getConfig()
|
||||||
|
successResponse(res, config)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/config',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const config = req.body
|
||||||
|
await remoteService.saveConfig(config)
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/screenshot',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const deviceName = req.query.device as string | undefined
|
||||||
|
const buffer = await remoteService.getScreenshot(deviceName)
|
||||||
|
if (!buffer) {
|
||||||
|
return successResponse(res, '')
|
||||||
|
}
|
||||||
|
const base64 = `data:image/png;base64,${buffer.toString('base64')}`
|
||||||
|
successResponse(res, base64)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/screenshot',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { dataUrl, deviceName } = req.body
|
||||||
|
console.log('[Remote] saveScreenshot called:', { deviceName, hasDataUrl: !!dataUrl })
|
||||||
|
await remoteService.saveScreenshot(dataUrl, deviceName)
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/data',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const deviceName = req.query.device as string | undefined
|
||||||
|
const data = await remoteService.getData(deviceName)
|
||||||
|
successResponse(res, data)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/data',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { deviceName, lastConnected } = req.body
|
||||||
|
const data: DeviceData = {}
|
||||||
|
if (lastConnected !== undefined) {
|
||||||
|
data.lastConnected = lastConnected
|
||||||
|
}
|
||||||
|
await remoteService.saveData(data, deviceName)
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteService = new RemoteService()
|
||||||
|
export default createRemoteRoutes({ remoteService })
|
||||||
178
api/modules/remote/service.ts
Normal file
178
api/modules/remote/service.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
|
import type { RemoteConfig, RemoteDevice } from '../../../shared/modules/remote/types.js'
|
||||||
|
|
||||||
|
export interface RemoteServiceDependencies { }
|
||||||
|
|
||||||
|
const REMOTE_DIR = 'remote'
|
||||||
|
|
||||||
|
export interface DeviceData {
|
||||||
|
lastConnected?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoteService {
|
||||||
|
constructor(private deps: RemoteServiceDependencies = {}) { }
|
||||||
|
|
||||||
|
private getRemoteDir(): { relPath: string; fullPath: string } {
|
||||||
|
const { fullPath } = resolveNotebookPath(REMOTE_DIR)
|
||||||
|
return { relPath: REMOTE_DIR, fullPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceDir(deviceName: string): { relPath: string; fullPath: string } {
|
||||||
|
const safeName = this.sanitizeFileName(deviceName)
|
||||||
|
const { fullPath } = resolveNotebookPath(path.join(REMOTE_DIR, safeName))
|
||||||
|
return { relPath: path.join(REMOTE_DIR, safeName), fullPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeFileName(name: string): string {
|
||||||
|
return name.replace(/[<>:"/\\|?*]/g, '_').trim() || 'unnamed'
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceConfigPath(deviceName: string): string {
|
||||||
|
const { fullPath } = this.getDeviceDir(deviceName)
|
||||||
|
return path.join(fullPath, 'config.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceScreenshotPath(deviceName: string): string {
|
||||||
|
const { fullPath } = this.getDeviceDir(deviceName)
|
||||||
|
return path.join(fullPath, 'screenshot.png')
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDeviceDataPath(deviceName: string): string {
|
||||||
|
const { fullPath } = this.getDeviceDir(deviceName)
|
||||||
|
return path.join(fullPath, 'data.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureDir(dirPath: string): Promise<void> {
|
||||||
|
await fs.mkdir(dirPath, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDeviceNames(): Promise<string[]> {
|
||||||
|
const { fullPath } = this.getRemoteDir()
|
||||||
|
try {
|
||||||
|
const entries = await fs.readdir(fullPath, { withFileTypes: true })
|
||||||
|
const dirs = entries.filter(e => e.isDirectory()).map(e => e.name)
|
||||||
|
return dirs
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConfig(): Promise<RemoteConfig> {
|
||||||
|
const deviceNames = await this.getDeviceNames()
|
||||||
|
const devices: RemoteDevice[] = await Promise.all(
|
||||||
|
deviceNames.map(async (name) => {
|
||||||
|
try {
|
||||||
|
const configPath = this.getDeviceConfigPath(name)
|
||||||
|
const content = await fs.readFile(configPath, 'utf-8')
|
||||||
|
const deviceConfig = JSON.parse(content)
|
||||||
|
return {
|
||||||
|
id: deviceConfig.id || name,
|
||||||
|
deviceName: name,
|
||||||
|
serverHost: deviceConfig.serverHost || '',
|
||||||
|
desktopPort: deviceConfig.desktopPort || 3000,
|
||||||
|
gitPort: deviceConfig.gitPort || 3001,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
id: name,
|
||||||
|
deviceName: name,
|
||||||
|
serverHost: '',
|
||||||
|
desktopPort: 3000,
|
||||||
|
gitPort: 3001,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return { devices }
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveConfig(config: RemoteConfig): Promise<void> {
|
||||||
|
const { fullPath: remoteDirFullPath } = this.getRemoteDir()
|
||||||
|
await this.ensureDir(remoteDirFullPath)
|
||||||
|
|
||||||
|
const existingDevices = await this.getDeviceNames()
|
||||||
|
const newDeviceNames = config.devices.map(d => this.sanitizeFileName(d.deviceName))
|
||||||
|
|
||||||
|
for (const oldDevice of existingDevices) {
|
||||||
|
if (!newDeviceNames.includes(oldDevice)) {
|
||||||
|
try {
|
||||||
|
const oldDir = path.join(remoteDirFullPath, oldDevice)
|
||||||
|
await fs.rm(oldDir, { recursive: true, force: true })
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const device of config.devices) {
|
||||||
|
const deviceDir = this.getDeviceDir(device.deviceName)
|
||||||
|
await this.ensureDir(deviceDir.fullPath)
|
||||||
|
|
||||||
|
const deviceConfigPath = this.getDeviceConfigPath(device.deviceName)
|
||||||
|
const deviceConfig = {
|
||||||
|
id: device.id,
|
||||||
|
serverHost: device.serverHost,
|
||||||
|
desktopPort: device.desktopPort,
|
||||||
|
gitPort: device.gitPort,
|
||||||
|
}
|
||||||
|
await fs.writeFile(deviceConfigPath, JSON.stringify(deviceConfig, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getScreenshot(deviceName?: string): Promise<Buffer | null> {
|
||||||
|
if (!deviceName) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const screenshotPath = this.getDeviceScreenshotPath(deviceName)
|
||||||
|
try {
|
||||||
|
return await fs.readFile(screenshotPath)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveScreenshot(dataUrl: string, deviceName?: string): Promise<void> {
|
||||||
|
console.log('[RemoteService] saveScreenshot:', { deviceName, dataUrlLength: dataUrl?.length })
|
||||||
|
if (!deviceName || deviceName.trim() === '') {
|
||||||
|
console.warn('[RemoteService] saveScreenshot skipped: no deviceName')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const deviceDir = this.getDeviceDir(deviceName)
|
||||||
|
await this.ensureDir(deviceDir.fullPath)
|
||||||
|
|
||||||
|
const base64Data = dataUrl.replace(/^data:image\/png;base64,/, '')
|
||||||
|
const buffer = Buffer.from(base64Data, 'base64')
|
||||||
|
|
||||||
|
const screenshotPath = this.getDeviceScreenshotPath(deviceName)
|
||||||
|
await fs.writeFile(screenshotPath, buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getData(deviceName?: string): Promise<DeviceData | null> {
|
||||||
|
if (!deviceName || deviceName.trim() === '') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const dataPath = this.getDeviceDataPath(deviceName)
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(dataPath, 'utf-8')
|
||||||
|
return JSON.parse(content)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveData(data: DeviceData, deviceName?: string): Promise<void> {
|
||||||
|
if (!deviceName || deviceName.trim() === '') {
|
||||||
|
console.warn('[RemoteService] saveData skipped: no deviceName')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const deviceDir = this.getDeviceDir(deviceName)
|
||||||
|
await this.ensureDir(deviceDir.fullPath)
|
||||||
|
|
||||||
|
const dataPath = this.getDeviceDataPath(deviceName)
|
||||||
|
await fs.writeFile(dataPath, JSON.stringify(data, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createRemoteService = (deps?: RemoteServiceDependencies): RemoteService => {
|
||||||
|
return new RemoteService(deps)
|
||||||
|
}
|
||||||
80
api/modules/time-tracking/heartbeatService.ts
Normal file
80
api/modules/time-tracking/heartbeatService.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { logger } from '../../utils/logger.js'
|
||||||
|
|
||||||
|
export interface HeartbeatCallback {
|
||||||
|
(): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeartbeatState {
|
||||||
|
lastHeartbeat: Date
|
||||||
|
isRunning: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_HEARTBEAT_INTERVAL = 60000
|
||||||
|
|
||||||
|
export class HeartbeatService {
|
||||||
|
private interval: NodeJS.Timeout | null = null
|
||||||
|
private lastHeartbeat: Date = new Date()
|
||||||
|
private readonly intervalMs: number
|
||||||
|
private callback: HeartbeatCallback | null = null
|
||||||
|
|
||||||
|
constructor(intervalMs: number = DEFAULT_HEARTBEAT_INTERVAL) {
|
||||||
|
this.intervalMs = intervalMs
|
||||||
|
}
|
||||||
|
|
||||||
|
setCallback(callback: HeartbeatCallback): void {
|
||||||
|
this.callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.interval) {
|
||||||
|
this.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.interval = setInterval(async () => {
|
||||||
|
if (this.callback) {
|
||||||
|
try {
|
||||||
|
this.lastHeartbeat = new Date()
|
||||||
|
await this.callback()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Heartbeat callback failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, this.intervalMs)
|
||||||
|
|
||||||
|
this.lastHeartbeat = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
if (this.interval) {
|
||||||
|
clearInterval(this.interval)
|
||||||
|
this.interval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this.interval !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
getLastHeartbeat(): Date {
|
||||||
|
return this.lastHeartbeat
|
||||||
|
}
|
||||||
|
|
||||||
|
updateHeartbeat(): void {
|
||||||
|
this.lastHeartbeat = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
getState(): HeartbeatState {
|
||||||
|
return {
|
||||||
|
lastHeartbeat: this.lastHeartbeat,
|
||||||
|
isRunning: this.isRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreState(state: { lastHeartbeat: string }): void {
|
||||||
|
this.lastHeartbeat = new Date(state.lastHeartbeat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createHeartbeatService = (intervalMs?: number): HeartbeatService => {
|
||||||
|
return new HeartbeatService(intervalMs)
|
||||||
|
}
|
||||||
38
api/modules/time-tracking/index.ts
Normal file
38
api/modules/time-tracking/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Router } from 'express'
|
||||||
|
import type { ServiceContainer } from '../../infra/container.js'
|
||||||
|
import { createApiModule } from '../../infra/createModule.js'
|
||||||
|
import { TIME_TRACKING_MODULE } from '../../../shared/modules/time-tracking/index.js'
|
||||||
|
import {
|
||||||
|
TimeTrackerService,
|
||||||
|
initializeTimeTrackerService,
|
||||||
|
type TimeTrackerServiceConfig
|
||||||
|
} from './timeService.js'
|
||||||
|
import { createTimeTrackingRoutes } from './routes.js'
|
||||||
|
|
||||||
|
export * from './timeService.js'
|
||||||
|
export * from './heartbeatService.js'
|
||||||
|
export * from './sessionPersistence.js'
|
||||||
|
export * from './routes.js'
|
||||||
|
|
||||||
|
export interface TimeTrackingModuleConfig {
|
||||||
|
config?: TimeTrackerServiceConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTimeTrackingModule = (moduleConfig: TimeTrackingModuleConfig = {}) => {
|
||||||
|
let serviceInstance: TimeTrackerService | undefined
|
||||||
|
|
||||||
|
return createApiModule(TIME_TRACKING_MODULE, {
|
||||||
|
routes: (container: ServiceContainer): Router => {
|
||||||
|
const timeTrackerService = container.getSync<TimeTrackerService>('timeTrackerService')
|
||||||
|
return createTimeTrackingRoutes({ timeTrackerService })
|
||||||
|
},
|
||||||
|
lifecycle: {
|
||||||
|
onLoad: async (container: ServiceContainer): Promise<void> => {
|
||||||
|
serviceInstance = await initializeTimeTrackerService(moduleConfig.config)
|
||||||
|
container.register('timeTrackerService', () => serviceInstance!)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createTimeTrackingModule
|
||||||
131
api/modules/time-tracking/routes.ts
Normal file
131
api/modules/time-tracking/routes.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import express, { type Request, type Response, type Router } from 'express'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { TimeTrackerService } from './timeService.js'
|
||||||
|
import type { TimeTrackingEvent } from '../../../shared/types.js'
|
||||||
|
|
||||||
|
export interface TimeTrackingRoutesDependencies {
|
||||||
|
timeTrackerService: TimeTrackerService
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTimeTrackingRoutes = (deps: TimeTrackingRoutesDependencies): Router => {
|
||||||
|
const router = express.Router()
|
||||||
|
const { timeTrackerService } = deps
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/current',
|
||||||
|
asyncHandler(async (_req: Request, res: Response) => {
|
||||||
|
const state = timeTrackerService.getCurrentState()
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
isRunning: state.isRunning,
|
||||||
|
isPaused: state.isPaused,
|
||||||
|
currentSession: state.currentSession ? {
|
||||||
|
id: state.currentSession.id,
|
||||||
|
startTime: state.currentSession.startTime,
|
||||||
|
duration: state.currentSession.duration,
|
||||||
|
currentTab: state.currentTabRecord ? {
|
||||||
|
tabId: state.currentTabRecord.tabId,
|
||||||
|
fileName: state.currentTabRecord.fileName,
|
||||||
|
tabType: state.currentTabRecord.tabType
|
||||||
|
} : null
|
||||||
|
} : null,
|
||||||
|
todayDuration: state.todayDuration
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/event',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const event = req.body as TimeTrackingEvent
|
||||||
|
await timeTrackerService.handleEvent(event)
|
||||||
|
successResponse(res, null)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/day/:date',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { date } = req.params
|
||||||
|
const [year, month, day] = date.split('-').map(Number)
|
||||||
|
const data = await timeTrackerService.getDayData(year, month, day)
|
||||||
|
|
||||||
|
const sessionsCount = data.sessions.length
|
||||||
|
const averageSessionDuration = sessionsCount > 0
|
||||||
|
? Math.floor(data.totalDuration / sessionsCount)
|
||||||
|
: 0
|
||||||
|
const longestSession = data.sessions.reduce((max, s) =>
|
||||||
|
s.duration > max ? s.duration : max, 0)
|
||||||
|
|
||||||
|
const topTabs = Object.entries(data.tabSummary)
|
||||||
|
.map(([_, summary]) => ({
|
||||||
|
fileName: summary.fileName,
|
||||||
|
duration: summary.totalDuration
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.duration - a.duration)
|
||||||
|
.slice(0, 5)
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
...data,
|
||||||
|
stats: {
|
||||||
|
sessionsCount,
|
||||||
|
averageSessionDuration,
|
||||||
|
longestSession,
|
||||||
|
topTabs
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/week/:startDate',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { startDate } = req.params
|
||||||
|
const [year, month, day] = startDate.split('-').map(Number)
|
||||||
|
const start = new Date(year, month - 1, day)
|
||||||
|
const data = await timeTrackerService.getWeekData(start)
|
||||||
|
|
||||||
|
const totalDuration = data.reduce((sum, d) => sum + d.totalDuration, 0)
|
||||||
|
const activeDays = data.filter(d => d.totalDuration > 0).length
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
days: data,
|
||||||
|
totalDuration,
|
||||||
|
activeDays,
|
||||||
|
averageDaily: activeDays > 0 ? Math.floor(totalDuration / activeDays) : 0
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/month/:yearMonth',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { yearMonth } = req.params
|
||||||
|
const [year, month] = yearMonth.split('-').map(Number)
|
||||||
|
const data = await timeTrackerService.getMonthData(year, month)
|
||||||
|
successResponse(res, data)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/year/:year',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { year } = req.params
|
||||||
|
const data = await timeTrackerService.getYearData(parseInt(year))
|
||||||
|
successResponse(res, data)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/stats',
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const year = req.query.year ? parseInt(req.query.year as string) : undefined
|
||||||
|
const month = req.query.month ? parseInt(req.query.month as string) : undefined
|
||||||
|
const stats = await timeTrackerService.getStats(year, month)
|
||||||
|
successResponse(res, stats)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
367
api/modules/time-tracking/sessionPersistence.ts
Normal file
367
api/modules/time-tracking/sessionPersistence.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { NOTEBOOK_ROOT } from '../../config/paths.js'
|
||||||
|
import type {
|
||||||
|
TimingSession,
|
||||||
|
TabRecord,
|
||||||
|
DayTimeData,
|
||||||
|
MonthTimeData,
|
||||||
|
YearTimeData
|
||||||
|
} from '../../../shared/types.js'
|
||||||
|
import { logger } from '../../utils/logger.js'
|
||||||
|
|
||||||
|
const TIME_ROOT = path.join(NOTEBOOK_ROOT, 'time')
|
||||||
|
|
||||||
|
export interface PersistedSessionState {
|
||||||
|
session: TimingSession | null
|
||||||
|
currentTabRecord: TabRecord | null
|
||||||
|
isPaused: boolean
|
||||||
|
lastHeartbeat: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionPersistence {
|
||||||
|
loadCurrentState(): Promise<PersistedSessionState>
|
||||||
|
saveCurrentState(state: PersistedSessionState): Promise<void>
|
||||||
|
clearCurrentState(): Promise<void>
|
||||||
|
saveSessionToDay(session: TimingSession): Promise<void>
|
||||||
|
getDayData(year: number, month: number, day: number): Promise<DayTimeData>
|
||||||
|
getMonthData(year: number, month: number): Promise<MonthTimeData>
|
||||||
|
getYearData(year: number): Promise<YearTimeData>
|
||||||
|
updateDayDataRealtime(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
day: number,
|
||||||
|
session: TimingSession,
|
||||||
|
currentTabRecord: TabRecord | null
|
||||||
|
): Promise<DayTimeData>
|
||||||
|
updateMonthSummary(year: number, month: number, day: number, duration: number): Promise<void>
|
||||||
|
updateYearSummary(year: number, month: number, duration: number): Promise<void>
|
||||||
|
recalculateMonthSummary(year: number, month: number, todayDuration: number): Promise<void>
|
||||||
|
recalculateYearSummary(year: number): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDayFilePath = (year: number, month: number, day: number): string => {
|
||||||
|
const monthStr = month.toString().padStart(2, '0')
|
||||||
|
const dayStr = day.toString().padStart(2, '0')
|
||||||
|
return path.join(TIME_ROOT, year.toString(), `${year}${monthStr}${dayStr}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMonthFilePath = (year: number, month: number): string => {
|
||||||
|
const monthStr = month.toString().padStart(2, '0')
|
||||||
|
return path.join(TIME_ROOT, year.toString(), `${year}${monthStr}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getYearFilePath = (year: number): string => {
|
||||||
|
return path.join(TIME_ROOT, 'summary', `${year}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureDirExists = async (filePath: string): Promise<void> => {
|
||||||
|
const dir = path.dirname(filePath)
|
||||||
|
await fs.mkdir(dir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const createEmptyDayData = (year: number, month: number, day: number): DayTimeData => ({
|
||||||
|
date: `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`,
|
||||||
|
totalDuration: 0,
|
||||||
|
sessions: [],
|
||||||
|
tabSummary: {},
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
const createEmptyMonthData = (year: number, month: number): MonthTimeData => ({
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
days: {},
|
||||||
|
monthlyTotal: 0,
|
||||||
|
averageDaily: 0,
|
||||||
|
activeDays: 0,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
const createEmptyYearData = (year: number): YearTimeData => ({
|
||||||
|
year,
|
||||||
|
months: {},
|
||||||
|
yearlyTotal: 0,
|
||||||
|
averageMonthly: 0,
|
||||||
|
averageDaily: 0,
|
||||||
|
totalActiveDays: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
class SessionPersistenceService implements SessionPersistence {
|
||||||
|
private readonly stateFilePath: string
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.stateFilePath = path.join(TIME_ROOT, '.current-session.json')
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadCurrentState(): Promise<PersistedSessionState> {
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(this.stateFilePath, 'utf-8')
|
||||||
|
const state = JSON.parse(content)
|
||||||
|
return {
|
||||||
|
session: state.session || null,
|
||||||
|
currentTabRecord: state.currentTabRecord || null,
|
||||||
|
isPaused: state.isPaused || false,
|
||||||
|
lastHeartbeat: state.lastHeartbeat || new Date().toISOString()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('No existing session to load or session file corrupted')
|
||||||
|
return {
|
||||||
|
session: null,
|
||||||
|
currentTabRecord: null,
|
||||||
|
isPaused: false,
|
||||||
|
lastHeartbeat: new Date().toISOString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveCurrentState(state: PersistedSessionState): Promise<void> {
|
||||||
|
await ensureDirExists(this.stateFilePath)
|
||||||
|
await fs.writeFile(this.stateFilePath, JSON.stringify({
|
||||||
|
session: state.session,
|
||||||
|
currentTabRecord: state.currentTabRecord,
|
||||||
|
isPaused: state.isPaused,
|
||||||
|
lastHeartbeat: state.lastHeartbeat
|
||||||
|
}), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearCurrentState(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await fs.unlink(this.stateFilePath)
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Session state file already removed or does not exist')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSessionToDay(session: TimingSession): Promise<void> {
|
||||||
|
const startTime = new Date(session.startTime)
|
||||||
|
const year = startTime.getFullYear()
|
||||||
|
const month = startTime.getMonth() + 1
|
||||||
|
const day = startTime.getDate()
|
||||||
|
|
||||||
|
const filePath = getDayFilePath(year, month, day)
|
||||||
|
await ensureDirExists(filePath)
|
||||||
|
|
||||||
|
let dayData = await this.getDayData(year, month, day)
|
||||||
|
|
||||||
|
dayData.sessions.push(session)
|
||||||
|
dayData.totalDuration += session.duration
|
||||||
|
|
||||||
|
for (const record of session.tabRecords) {
|
||||||
|
const key = record.filePath || record.fileName
|
||||||
|
if (!dayData.tabSummary[key]) {
|
||||||
|
dayData.tabSummary[key] = {
|
||||||
|
fileName: record.fileName,
|
||||||
|
tabType: record.tabType,
|
||||||
|
totalDuration: 0,
|
||||||
|
focusCount: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dayData.tabSummary[key].totalDuration += record.duration
|
||||||
|
dayData.tabSummary[key].focusCount += record.focusedPeriods.length
|
||||||
|
}
|
||||||
|
|
||||||
|
dayData.lastUpdated = new Date().toISOString()
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(dayData, null, 2), 'utf-8')
|
||||||
|
|
||||||
|
await this.updateMonthSummary(year, month, day, session.duration)
|
||||||
|
await this.updateYearSummary(year, month, session.duration)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDayData(year: number, month: number, day: number): Promise<DayTimeData> {
|
||||||
|
const filePath = getDayFilePath(year, month, day)
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8')
|
||||||
|
return JSON.parse(content)
|
||||||
|
} catch (err) {
|
||||||
|
return createEmptyDayData(year, month, day)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMonthData(year: number, month: number): Promise<MonthTimeData> {
|
||||||
|
const filePath = getMonthFilePath(year, month)
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8')
|
||||||
|
return JSON.parse(content)
|
||||||
|
} catch (err) {
|
||||||
|
return createEmptyMonthData(year, month)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getYearData(year: number): Promise<YearTimeData> {
|
||||||
|
const filePath = getYearFilePath(year)
|
||||||
|
try {
|
||||||
|
const content = await fs.readFile(filePath, 'utf-8')
|
||||||
|
return JSON.parse(content)
|
||||||
|
} catch (err) {
|
||||||
|
return createEmptyYearData(year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDayDataRealtime(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
day: number,
|
||||||
|
session: TimingSession,
|
||||||
|
currentTabRecord: TabRecord | null
|
||||||
|
): Promise<DayTimeData> {
|
||||||
|
const filePath = getDayFilePath(year, month, day)
|
||||||
|
await ensureDirExists(filePath)
|
||||||
|
|
||||||
|
let dayData = await this.getDayData(year, month, day)
|
||||||
|
|
||||||
|
const currentSessionDuration = session.tabRecords.reduce((sum, r) => sum + r.duration, 0) +
|
||||||
|
(currentTabRecord?.duration || 0)
|
||||||
|
|
||||||
|
const existingSessionIndex = dayData.sessions.findIndex(s => s.id === session.id)
|
||||||
|
|
||||||
|
const realtimeSession: TimingSession = {
|
||||||
|
...session,
|
||||||
|
duration: currentSessionDuration,
|
||||||
|
tabRecords: currentTabRecord
|
||||||
|
? [...session.tabRecords, currentTabRecord]
|
||||||
|
: session.tabRecords
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingSessionIndex >= 0) {
|
||||||
|
const oldDuration = dayData.sessions[existingSessionIndex].duration
|
||||||
|
dayData.sessions[existingSessionIndex] = realtimeSession
|
||||||
|
dayData.totalDuration += currentSessionDuration - oldDuration
|
||||||
|
} else {
|
||||||
|
dayData.sessions.push(realtimeSession)
|
||||||
|
dayData.totalDuration += currentSessionDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
dayData.tabSummary = {}
|
||||||
|
for (const s of dayData.sessions) {
|
||||||
|
for (const record of s.tabRecords) {
|
||||||
|
const key = record.filePath || record.fileName
|
||||||
|
if (!dayData.tabSummary[key]) {
|
||||||
|
dayData.tabSummary[key] = {
|
||||||
|
fileName: record.fileName,
|
||||||
|
tabType: record.tabType,
|
||||||
|
totalDuration: 0,
|
||||||
|
focusCount: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dayData.tabSummary[key].totalDuration += record.duration
|
||||||
|
dayData.tabSummary[key].focusCount += record.focusedPeriods.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dayData.lastUpdated = new Date().toISOString()
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(dayData, null, 2), 'utf-8')
|
||||||
|
|
||||||
|
return dayData
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMonthSummary(year: number, month: number, day: number, duration: number): Promise<void> {
|
||||||
|
const filePath = getMonthFilePath(year, month)
|
||||||
|
await ensureDirExists(filePath)
|
||||||
|
|
||||||
|
let monthData = await this.getMonthData(year, month)
|
||||||
|
|
||||||
|
const dayStr = day.toString().padStart(2, '0')
|
||||||
|
if (!monthData.days[dayStr]) {
|
||||||
|
monthData.days[dayStr] = { totalDuration: 0, sessions: 0, topTabs: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
monthData.days[dayStr].totalDuration += duration
|
||||||
|
monthData.days[dayStr].sessions += 1
|
||||||
|
monthData.monthlyTotal += duration
|
||||||
|
monthData.activeDays = Object.keys(monthData.days).length
|
||||||
|
monthData.averageDaily = Math.floor(monthData.monthlyTotal / monthData.activeDays)
|
||||||
|
monthData.lastUpdated = new Date().toISOString()
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(monthData, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateYearSummary(year: number, month: number, duration: number): Promise<void> {
|
||||||
|
const filePath = getYearFilePath(year)
|
||||||
|
await ensureDirExists(filePath)
|
||||||
|
|
||||||
|
let yearData = await this.getYearData(year)
|
||||||
|
|
||||||
|
const monthStr = month.toString().padStart(2, '0')
|
||||||
|
if (!yearData.months[monthStr]) {
|
||||||
|
yearData.months[monthStr] = { totalDuration: 0, activeDays: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
yearData.months[monthStr].totalDuration += duration
|
||||||
|
yearData.yearlyTotal += duration
|
||||||
|
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0)
|
||||||
|
|
||||||
|
const monthCount = Object.keys(yearData.months).length
|
||||||
|
yearData.averageMonthly = Math.floor(yearData.yearlyTotal / monthCount)
|
||||||
|
yearData.averageDaily = yearData.totalActiveDays > 0
|
||||||
|
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
await fs.writeFile(filePath, JSON.stringify(yearData, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async recalculateMonthSummary(year: number, month: number, todayDuration: number): Promise<void> {
|
||||||
|
const monthFilePath = getMonthFilePath(year, month)
|
||||||
|
await ensureDirExists(monthFilePath)
|
||||||
|
|
||||||
|
let monthData = await this.getMonthData(year, month)
|
||||||
|
|
||||||
|
const dayStr = new Date().getDate().toString().padStart(2, '0')
|
||||||
|
|
||||||
|
if (!monthData.days[dayStr]) {
|
||||||
|
monthData.days[dayStr] = { totalDuration: 0, sessions: 0, topTabs: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldDayDuration = monthData.days[dayStr].totalDuration
|
||||||
|
monthData.days[dayStr].totalDuration = todayDuration
|
||||||
|
monthData.monthlyTotal = monthData.monthlyTotal - oldDayDuration + todayDuration
|
||||||
|
monthData.activeDays = Object.keys(monthData.days).length
|
||||||
|
monthData.averageDaily = monthData.activeDays > 0
|
||||||
|
? Math.floor(monthData.monthlyTotal / monthData.activeDays)
|
||||||
|
: 0
|
||||||
|
monthData.lastUpdated = new Date().toISOString()
|
||||||
|
|
||||||
|
await fs.writeFile(monthFilePath, JSON.stringify(monthData, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async recalculateYearSummary(year: number): Promise<void> {
|
||||||
|
const yearFilePath = getYearFilePath(year)
|
||||||
|
await ensureDirExists(yearFilePath)
|
||||||
|
|
||||||
|
let yearData = await this.getYearData(year)
|
||||||
|
|
||||||
|
const monthStr = (new Date().getMonth() + 1).toString().padStart(2, '0')
|
||||||
|
const monthFilePath = getMonthFilePath(year, new Date().getMonth() + 1)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const monthContent = await fs.readFile(monthFilePath, 'utf-8')
|
||||||
|
const monthData = JSON.parse(monthContent)
|
||||||
|
|
||||||
|
if (!yearData.months[monthStr]) {
|
||||||
|
yearData.months[monthStr] = { totalDuration: 0, activeDays: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldMonthTotal = yearData.months[monthStr].totalDuration
|
||||||
|
yearData.months[monthStr].totalDuration = monthData.monthlyTotal
|
||||||
|
yearData.months[monthStr].activeDays = monthData.activeDays
|
||||||
|
yearData.yearlyTotal = yearData.yearlyTotal - oldMonthTotal + monthData.monthlyTotal
|
||||||
|
yearData.totalActiveDays = Object.values(yearData.months).reduce((sum, m) => sum + m.activeDays, 0)
|
||||||
|
|
||||||
|
const monthCount = Object.keys(yearData.months).length
|
||||||
|
yearData.averageMonthly = monthCount > 0 ? Math.floor(yearData.yearlyTotal / monthCount) : 0
|
||||||
|
yearData.averageDaily = yearData.totalActiveDays > 0
|
||||||
|
? Math.floor(yearData.yearlyTotal / yearData.totalActiveDays)
|
||||||
|
: 0
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Month file not found for year summary calculation')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.writeFile(yearFilePath, JSON.stringify(yearData, null, 2), 'utf-8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createSessionPersistence = (): SessionPersistence => {
|
||||||
|
return new SessionPersistenceService()
|
||||||
|
}
|
||||||
|
|
||||||
|
export { SessionPersistenceService }
|
||||||
442
api/modules/time-tracking/timeService.ts
Normal file
442
api/modules/time-tracking/timeService.ts
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
import type {
|
||||||
|
DayTimeData,
|
||||||
|
MonthTimeData,
|
||||||
|
YearTimeData,
|
||||||
|
TimingSession,
|
||||||
|
TabRecord,
|
||||||
|
TabType,
|
||||||
|
TimeTrackingEvent,
|
||||||
|
} from '../../../shared/types.js'
|
||||||
|
import { getTabTypeFromPath, getFileNameFromPath } from '../../../shared/utils/tabType.js'
|
||||||
|
import { logger } from '../../utils/logger.js'
|
||||||
|
import { HeartbeatService, createHeartbeatService } from './heartbeatService.js'
|
||||||
|
import { SessionPersistence, createSessionPersistence } from './sessionPersistence.js'
|
||||||
|
|
||||||
|
const generateId = (): string => {
|
||||||
|
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeTrackerServiceDependencies {
|
||||||
|
heartbeatService: HeartbeatService
|
||||||
|
persistence: SessionPersistence
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeTrackerServiceConfig {
|
||||||
|
heartbeatIntervalMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
class TimeTrackerService {
|
||||||
|
private currentSession: TimingSession | null = null
|
||||||
|
private currentTabRecord: TabRecord | null = null
|
||||||
|
private isPaused: boolean = false
|
||||||
|
private todayDuration: number = 0
|
||||||
|
private _initialized: boolean = false
|
||||||
|
private static _initializationPromise: Promise<void> | null = null
|
||||||
|
|
||||||
|
private readonly heartbeatService: HeartbeatService
|
||||||
|
private readonly persistence: SessionPersistence
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
dependencies: TimeTrackerServiceDependencies
|
||||||
|
) {
|
||||||
|
this.heartbeatService = dependencies.heartbeatService
|
||||||
|
this.persistence = dependencies.persistence
|
||||||
|
}
|
||||||
|
|
||||||
|
static async create(
|
||||||
|
config?: TimeTrackerServiceConfig
|
||||||
|
): Promise<TimeTrackerService> {
|
||||||
|
const heartbeatService = createHeartbeatService(config?.heartbeatIntervalMs)
|
||||||
|
const persistence = createSessionPersistence()
|
||||||
|
|
||||||
|
const instance = new TimeTrackerService({
|
||||||
|
heartbeatService,
|
||||||
|
persistence
|
||||||
|
})
|
||||||
|
|
||||||
|
await instance.initialize()
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
static async createWithDependencies(
|
||||||
|
dependencies: TimeTrackerServiceDependencies
|
||||||
|
): Promise<TimeTrackerService> {
|
||||||
|
const instance = new TimeTrackerService(dependencies)
|
||||||
|
await instance.initialize()
|
||||||
|
return instance
|
||||||
|
}
|
||||||
|
|
||||||
|
private async initialize(): Promise<void> {
|
||||||
|
if (this._initialized) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TimeTrackerService._initializationPromise) {
|
||||||
|
await TimeTrackerService._initializationPromise
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeTrackerService._initializationPromise = this.loadCurrentState()
|
||||||
|
await TimeTrackerService._initializationPromise
|
||||||
|
this._initialized = true
|
||||||
|
TimeTrackerService._initializationPromise = null
|
||||||
|
|
||||||
|
this.heartbeatService.setCallback(async () => {
|
||||||
|
if (this.currentSession && !this.isPaused) {
|
||||||
|
try {
|
||||||
|
this.heartbeatService.updateHeartbeat()
|
||||||
|
await this.updateCurrentTabDuration()
|
||||||
|
await this.saveCurrentState()
|
||||||
|
await this.updateTodayDataRealtime()
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Heartbeat update failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureInitialized(): void {
|
||||||
|
if (!this._initialized) {
|
||||||
|
throw new Error('TimeTrackerService 未初始化,请使用 TimeTrackerService.create() 创建实例')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCurrentState(): Promise<void> {
|
||||||
|
const now = new Date()
|
||||||
|
const todayData = await this.persistence.getDayData(now.getFullYear(), now.getMonth() + 1, now.getDate())
|
||||||
|
this.todayDuration = todayData.totalDuration
|
||||||
|
|
||||||
|
const state = await this.persistence.loadCurrentState()
|
||||||
|
if (state.session && state.session.status === 'active') {
|
||||||
|
const sessionStart = new Date(state.session.startTime)
|
||||||
|
const now = new Date()
|
||||||
|
if (now.getTime() - sessionStart.getTime() < 24 * 60 * 60 * 1000) {
|
||||||
|
this.currentSession = state.session
|
||||||
|
this.isPaused = state.isPaused
|
||||||
|
if (state.currentTabRecord) {
|
||||||
|
this.currentTabRecord = state.currentTabRecord
|
||||||
|
}
|
||||||
|
this.heartbeatService.restoreState({ lastHeartbeat: state.lastHeartbeat })
|
||||||
|
} else {
|
||||||
|
await this.endSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveCurrentState(): Promise<void> {
|
||||||
|
await this.persistence.saveCurrentState({
|
||||||
|
session: this.currentSession,
|
||||||
|
currentTabRecord: this.currentTabRecord,
|
||||||
|
isPaused: this.isPaused,
|
||||||
|
lastHeartbeat: this.heartbeatService.getLastHeartbeat().toISOString()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async startSession(): Promise<TimingSession> {
|
||||||
|
if (this.currentSession && this.currentSession.status === 'active') {
|
||||||
|
return this.currentSession
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
this.currentSession = {
|
||||||
|
id: generateId(),
|
||||||
|
startTime: now.toISOString(),
|
||||||
|
duration: 0,
|
||||||
|
status: 'active',
|
||||||
|
tabRecords: []
|
||||||
|
}
|
||||||
|
this.isPaused = false
|
||||||
|
this.heartbeatService.updateHeartbeat()
|
||||||
|
|
||||||
|
this.heartbeatService.start()
|
||||||
|
await this.saveCurrentState()
|
||||||
|
|
||||||
|
return this.currentSession
|
||||||
|
}
|
||||||
|
|
||||||
|
async pauseSession(): Promise<void> {
|
||||||
|
if (!this.currentSession || this.isPaused) return
|
||||||
|
|
||||||
|
this.isPaused = true
|
||||||
|
await this.updateCurrentTabDuration()
|
||||||
|
await this.saveCurrentState()
|
||||||
|
}
|
||||||
|
|
||||||
|
async resumeSession(): Promise<void> {
|
||||||
|
if (!this.currentSession || !this.isPaused) return
|
||||||
|
|
||||||
|
this.isPaused = false
|
||||||
|
this.heartbeatService.updateHeartbeat()
|
||||||
|
|
||||||
|
if (this.currentTabRecord) {
|
||||||
|
const now = new Date()
|
||||||
|
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
|
||||||
|
this.currentTabRecord.focusedPeriods.push({ start: timeStr, end: timeStr })
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.saveCurrentState()
|
||||||
|
}
|
||||||
|
|
||||||
|
async endSession(): Promise<void> {
|
||||||
|
if (!this.currentSession) return
|
||||||
|
|
||||||
|
this.heartbeatService.stop()
|
||||||
|
|
||||||
|
await this.updateCurrentTabDuration()
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
this.currentSession.endTime = now.toISOString()
|
||||||
|
this.currentSession.status = 'ended'
|
||||||
|
|
||||||
|
const startTime = new Date(this.currentSession.startTime)
|
||||||
|
this.currentSession.duration = Math.floor((now.getTime() - startTime.getTime()) / 1000)
|
||||||
|
|
||||||
|
await this.persistence.saveSessionToDay(this.currentSession)
|
||||||
|
|
||||||
|
this.todayDuration += this.currentSession.duration
|
||||||
|
|
||||||
|
this.currentSession = null
|
||||||
|
this.currentTabRecord = null
|
||||||
|
this.isPaused = false
|
||||||
|
|
||||||
|
await this.persistence.clearCurrentState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateCurrentTabDuration(): Promise<void> {
|
||||||
|
if (!this.currentSession || !this.currentTabRecord) return
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const periods = this.currentTabRecord.focusedPeriods
|
||||||
|
|
||||||
|
if (periods.length > 0) {
|
||||||
|
const lastPeriod = periods[periods.length - 1]
|
||||||
|
const [h, m, s] = lastPeriod.start.split(':').map(Number)
|
||||||
|
const startSeconds = h * 3600 + m * 60 + s
|
||||||
|
const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
|
||||||
|
|
||||||
|
this.currentTabRecord.duration = currentSeconds - startSeconds
|
||||||
|
lastPeriod.end = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateTodayDataRealtime(): Promise<void> {
|
||||||
|
if (!this.currentSession) return
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const year = now.getFullYear()
|
||||||
|
const month = now.getMonth() + 1
|
||||||
|
const day = now.getDate()
|
||||||
|
|
||||||
|
const dayData = await this.persistence.updateDayDataRealtime(
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
this.currentSession,
|
||||||
|
this.currentTabRecord
|
||||||
|
)
|
||||||
|
|
||||||
|
this.todayDuration = dayData.totalDuration
|
||||||
|
|
||||||
|
await this.persistence.recalculateMonthSummary(year, month, this.todayDuration)
|
||||||
|
await this.persistence.recalculateYearSummary(year)
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleTabSwitch(tabInfo: { tabId: string; filePath: string | null }): Promise<void> {
|
||||||
|
if (!this.currentSession || this.isPaused) return
|
||||||
|
|
||||||
|
await this.updateCurrentTabDuration()
|
||||||
|
|
||||||
|
if (this.currentTabRecord && this.currentTabRecord.duration > 0) {
|
||||||
|
this.currentSession.tabRecords.push({ ...this.currentTabRecord })
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`
|
||||||
|
|
||||||
|
this.currentTabRecord = {
|
||||||
|
tabId: tabInfo.tabId,
|
||||||
|
filePath: tabInfo.filePath,
|
||||||
|
fileName: getFileNameFromPath(tabInfo.filePath),
|
||||||
|
tabType: getTabTypeFromPath(tabInfo.filePath),
|
||||||
|
duration: 0,
|
||||||
|
focusedPeriods: [{ start: timeStr, end: timeStr }]
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.saveCurrentState()
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleEvent(event: TimeTrackingEvent): Promise<void> {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'window-focus':
|
||||||
|
if (!this.currentSession) {
|
||||||
|
await this.startSession()
|
||||||
|
if (event.tabInfo) {
|
||||||
|
await this.handleTabSwitch(event.tabInfo)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await this.resumeSession()
|
||||||
|
await this.updateTodayDataRealtime()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'window-blur':
|
||||||
|
await this.pauseSession()
|
||||||
|
await this.updateTodayDataRealtime()
|
||||||
|
break
|
||||||
|
case 'app-quit':
|
||||||
|
await this.endSession()
|
||||||
|
break
|
||||||
|
case 'tab-switch':
|
||||||
|
case 'tab-open':
|
||||||
|
if (!this.currentSession) {
|
||||||
|
await this.startSession()
|
||||||
|
}
|
||||||
|
if (event.tabInfo) {
|
||||||
|
await this.handleTabSwitch(event.tabInfo)
|
||||||
|
}
|
||||||
|
await this.updateTodayDataRealtime()
|
||||||
|
break
|
||||||
|
case 'tab-close':
|
||||||
|
await this.updateCurrentTabDuration()
|
||||||
|
await this.updateTodayDataRealtime()
|
||||||
|
break
|
||||||
|
case 'heartbeat':
|
||||||
|
if (this.currentSession && !this.isPaused) {
|
||||||
|
this.heartbeatService.updateHeartbeat()
|
||||||
|
await this.updateCurrentTabDuration()
|
||||||
|
await this.saveCurrentState()
|
||||||
|
await this.updateTodayDataRealtime()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDayData(year: number, month: number, day: number): Promise<DayTimeData> {
|
||||||
|
return this.persistence.getDayData(year, month, day)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWeekData(startDate: Date): Promise<DayTimeData[]> {
|
||||||
|
const result: DayTimeData[] = []
|
||||||
|
for (let i = 0; i < 7; i++) {
|
||||||
|
const date = new Date(startDate)
|
||||||
|
date.setDate(date.getDate() + i)
|
||||||
|
const data = await this.persistence.getDayData(date.getFullYear(), date.getMonth() + 1, date.getDate())
|
||||||
|
result.push(data)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMonthData(year: number, month: number): Promise<MonthTimeData> {
|
||||||
|
return this.persistence.getMonthData(year, month)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getYearData(year: number): Promise<YearTimeData> {
|
||||||
|
return this.persistence.getYearData(year)
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentState(): { isRunning: boolean; isPaused: boolean; currentSession: TimingSession | null; todayDuration: number; currentTabRecord: TabRecord | null } {
|
||||||
|
return {
|
||||||
|
isRunning: this.currentSession !== null,
|
||||||
|
isPaused: this.isPaused,
|
||||||
|
currentSession: this.currentSession,
|
||||||
|
todayDuration: this.todayDuration,
|
||||||
|
currentTabRecord: this.currentTabRecord
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats(year?: number, month?: number): Promise<{
|
||||||
|
totalDuration: number
|
||||||
|
activeDays: number
|
||||||
|
averageDaily: number
|
||||||
|
longestDay: { date: string; duration: number } | null
|
||||||
|
longestSession: { date: string; duration: number } | null
|
||||||
|
topTabs: Array<{ fileName: string; duration: number; percentage: number }>
|
||||||
|
tabTypeDistribution: Array<{ tabType: TabType; duration: number; percentage: number }>
|
||||||
|
}> {
|
||||||
|
const now = new Date()
|
||||||
|
const targetYear = year || now.getFullYear()
|
||||||
|
const targetMonth = month
|
||||||
|
|
||||||
|
let totalDuration = 0
|
||||||
|
let activeDays = 0
|
||||||
|
let longestDay: { date: string; duration: number } | null = null
|
||||||
|
let longestSession: { date: string; duration: number } | null = null
|
||||||
|
const tabDurations: Record<string, number> = {}
|
||||||
|
const tabTypeDurations: Record<TabType, number> = {} as Record<TabType, number>
|
||||||
|
|
||||||
|
if (targetMonth) {
|
||||||
|
const monthData = await this.persistence.getMonthData(targetYear, targetMonth)
|
||||||
|
totalDuration = monthData.monthlyTotal
|
||||||
|
activeDays = monthData.activeDays
|
||||||
|
|
||||||
|
for (const [day, summary] of Object.entries(monthData.days)) {
|
||||||
|
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
||||||
|
longestDay = { date: `${targetYear}-${targetMonth.toString().padStart(2, '0')}-${day}`, duration: summary.totalDuration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const yearData = await this.persistence.getYearData(targetYear)
|
||||||
|
totalDuration = yearData.yearlyTotal
|
||||||
|
activeDays = yearData.totalActiveDays
|
||||||
|
|
||||||
|
for (const [month, summary] of Object.entries(yearData.months)) {
|
||||||
|
if (!longestDay || summary.totalDuration > longestDay.duration) {
|
||||||
|
longestDay = { date: `${targetYear}-${month}`, duration: summary.totalDuration }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalDuration,
|
||||||
|
activeDays,
|
||||||
|
averageDaily: activeDays > 0 ? Math.floor(totalDuration / activeDays) : 0,
|
||||||
|
longestDay,
|
||||||
|
longestSession,
|
||||||
|
topTabs: Object.entries(tabDurations)
|
||||||
|
.map(([fileName, duration]) => ({
|
||||||
|
fileName,
|
||||||
|
duration,
|
||||||
|
percentage: totalDuration > 0 ? Math.round((duration / totalDuration) * 100) : 0
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.duration - a.duration)
|
||||||
|
.slice(0, 10),
|
||||||
|
tabTypeDistribution: Object.entries(tabTypeDurations)
|
||||||
|
.map(([tabType, duration]) => ({
|
||||||
|
tabType: tabType as TabType,
|
||||||
|
duration,
|
||||||
|
percentage: totalDuration > 0 ? Math.round((duration / totalDuration) * 100) : 0
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.duration - a.duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _timeTrackerService: TimeTrackerService | null = null
|
||||||
|
|
||||||
|
export const getTimeTrackerService = (): TimeTrackerService => {
|
||||||
|
if (!_timeTrackerService) {
|
||||||
|
throw new Error('TimeTrackerService 未初始化,请先调用 initializeTimeTrackerService()')
|
||||||
|
}
|
||||||
|
return _timeTrackerService
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initializeTimeTrackerService = async (
|
||||||
|
config?: TimeTrackerServiceConfig
|
||||||
|
): Promise<TimeTrackerService> => {
|
||||||
|
if (_timeTrackerService) {
|
||||||
|
return _timeTrackerService
|
||||||
|
}
|
||||||
|
_timeTrackerService = await TimeTrackerService.create(config)
|
||||||
|
return _timeTrackerService
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initializeTimeTrackerServiceWithDependencies = async (
|
||||||
|
dependencies: TimeTrackerServiceDependencies
|
||||||
|
): Promise<TimeTrackerService> => {
|
||||||
|
if (_timeTrackerService) {
|
||||||
|
return _timeTrackerService
|
||||||
|
}
|
||||||
|
_timeTrackerService = await TimeTrackerService.createWithDependencies(dependencies)
|
||||||
|
return _timeTrackerService
|
||||||
|
}
|
||||||
|
|
||||||
|
export { TimeTrackerService }
|
||||||
171
api/modules/todo/__tests__/parser.test.ts
Normal file
171
api/modules/todo/__tests__/parser.test.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { parseTodoContent, generateTodoContent } from '../parser.js'
|
||||||
|
import type { DayTodo } from '../types.js'
|
||||||
|
|
||||||
|
describe('parseTodoContent', () => {
|
||||||
|
it('should parse basic todo content correctly', () => {
|
||||||
|
const content = `## 2024-01-01
|
||||||
|
- √ 完成工作
|
||||||
|
- ○ 购物`
|
||||||
|
|
||||||
|
const result = parseTodoContent(content)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].date).toBe('2024-01-01')
|
||||||
|
expect(result[0].items).toHaveLength(2)
|
||||||
|
expect(result[0].items[0].content).toBe('完成工作')
|
||||||
|
expect(result[0].items[1].content).toBe('购物')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should correctly identify completed status with √', () => {
|
||||||
|
const content = `## 2024-01-01
|
||||||
|
- √ 已完成任务`
|
||||||
|
|
||||||
|
const result = parseTodoContent(content)
|
||||||
|
|
||||||
|
expect(result[0].items[0].completed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should correctly identify incomplete status with ○', () => {
|
||||||
|
const content = `## 2024-01-01
|
||||||
|
- ○ 未完成任务`
|
||||||
|
|
||||||
|
const result = parseTodoContent(content)
|
||||||
|
|
||||||
|
expect(result[0].items[0].completed).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse multiple days correctly', () => {
|
||||||
|
const content = `## 2024-01-01
|
||||||
|
- √ 第一天任务
|
||||||
|
|
||||||
|
## 2024-01-02
|
||||||
|
- ○ 第二天任务
|
||||||
|
|
||||||
|
## 2024-01-03
|
||||||
|
- √ 第三天任务`
|
||||||
|
|
||||||
|
const result = parseTodoContent(content)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(3)
|
||||||
|
expect(result[0].date).toBe('2024-01-01')
|
||||||
|
expect(result[1].date).toBe('2024-01-02')
|
||||||
|
expect(result[2].date).toBe('2024-01-03')
|
||||||
|
expect(result[0].items[0].content).toBe('第一天任务')
|
||||||
|
expect(result[1].items[0].content).toBe('第二天任务')
|
||||||
|
expect(result[2].items[0].content).toBe('第三天任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty content', () => {
|
||||||
|
const content = ''
|
||||||
|
|
||||||
|
const result = parseTodoContent(content)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore invalid format lines', () => {
|
||||||
|
const content = `## 2024-01-01
|
||||||
|
这是一行普通文本
|
||||||
|
- 无效格式
|
||||||
|
- x 错误的标记
|
||||||
|
random line
|
||||||
|
- √ 有效的任务`
|
||||||
|
|
||||||
|
const result = parseTodoContent(content)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].items).toHaveLength(1)
|
||||||
|
expect(result[0].items[0].content).toBe('有效的任务')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate unique IDs for items', () => {
|
||||||
|
const content = `## 2024-01-01
|
||||||
|
- √ 任务一
|
||||||
|
- ○ 任务二
|
||||||
|
- √ 任务三`
|
||||||
|
|
||||||
|
const result = parseTodoContent(content)
|
||||||
|
|
||||||
|
expect(result[0].items[0].id).toBe('2024-01-01-0')
|
||||||
|
expect(result[0].items[1].id).toBe('2024-01-01-1')
|
||||||
|
expect(result[0].items[2].id).toBe('2024-01-01-2')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateTodoContent', () => {
|
||||||
|
it('should generate basic todo content correctly', () => {
|
||||||
|
const dayTodos: DayTodo[] = [
|
||||||
|
{
|
||||||
|
date: '2024-01-01',
|
||||||
|
items: [
|
||||||
|
{ id: '2024-01-01-0', content: '完成工作', completed: true },
|
||||||
|
{ id: '2024-01-01-1', content: '购物', completed: false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = generateTodoContent(dayTodos)
|
||||||
|
|
||||||
|
expect(result).toBe(`## 2024-01-01
|
||||||
|
- √ 完成工作
|
||||||
|
- ○ 购物`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include completed status in generated content', () => {
|
||||||
|
const dayTodos: DayTodo[] = [
|
||||||
|
{
|
||||||
|
date: '2024-01-01',
|
||||||
|
items: [
|
||||||
|
{ id: '2024-01-01-0', content: '已完成', completed: true },
|
||||||
|
{ id: '2024-01-01-1', content: '未完成', completed: false }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = generateTodoContent(dayTodos)
|
||||||
|
|
||||||
|
expect(result).toContain('√ 已完成')
|
||||||
|
expect(result).toContain('○ 未完成')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should sort dates in ascending order', () => {
|
||||||
|
const dayTodos: DayTodo[] = [
|
||||||
|
{ date: '2024-01-03', items: [{ id: '1', content: '第三天', completed: false }] },
|
||||||
|
{ date: '2024-01-01', items: [{ id: '2', content: '第一天', completed: false }] },
|
||||||
|
{ date: '2024-01-02', items: [{ id: '3', content: '第二天', completed: false }] }
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = generateTodoContent(dayTodos)
|
||||||
|
|
||||||
|
const firstDateIndex = result.indexOf('2024-01-01')
|
||||||
|
const secondDateIndex = result.indexOf('2024-01-02')
|
||||||
|
const thirdDateIndex = result.indexOf('2024-01-03')
|
||||||
|
|
||||||
|
expect(firstDateIndex).toBeLessThan(secondDateIndex)
|
||||||
|
expect(secondDateIndex).toBeLessThan(thirdDateIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty array', () => {
|
||||||
|
const dayTodos: DayTodo[] = []
|
||||||
|
|
||||||
|
const result = generateTodoContent(dayTodos)
|
||||||
|
|
||||||
|
expect(result).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate content for multiple days with sorting', () => {
|
||||||
|
const dayTodos: DayTodo[] = [
|
||||||
|
{ date: '2024-01-02', items: [{ id: '1', content: '第二天', completed: true }] },
|
||||||
|
{ date: '2024-01-01', items: [{ id: '2', content: '第一天', completed: false }] }
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = generateTodoContent(dayTodos)
|
||||||
|
|
||||||
|
expect(result).toBe(`## 2024-01-01
|
||||||
|
- ○ 第一天
|
||||||
|
|
||||||
|
## 2024-01-02
|
||||||
|
- √ 第二天`)
|
||||||
|
})
|
||||||
|
})
|
||||||
28
api/modules/todo/index.ts
Normal file
28
api/modules/todo/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Router } from 'express'
|
||||||
|
import type { ServiceContainer } from '../../infra/container.js'
|
||||||
|
import { createApiModule } from '../../infra/createModule.js'
|
||||||
|
import { TODO_MODULE } from '../../../shared/modules/todo/index.js'
|
||||||
|
import { TodoService } from './service.js'
|
||||||
|
import { createTodoRoutes } from './routes.js'
|
||||||
|
|
||||||
|
export * from './types.js'
|
||||||
|
export * from './parser.js'
|
||||||
|
export * from './service.js'
|
||||||
|
export * from './schemas.js'
|
||||||
|
export * from './routes.js'
|
||||||
|
|
||||||
|
export const createTodoModule = () => {
|
||||||
|
return createApiModule(TODO_MODULE, {
|
||||||
|
routes: (container: ServiceContainer): Router => {
|
||||||
|
const todoService = container.getSync<TodoService>('todoService')
|
||||||
|
return createTodoRoutes({ todoService })
|
||||||
|
},
|
||||||
|
lifecycle: {
|
||||||
|
onLoad: (container: ServiceContainer): void => {
|
||||||
|
container.register('todoService', () => new TodoService())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createTodoModule
|
||||||
51
api/modules/todo/parser.ts
Normal file
51
api/modules/todo/parser.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import type { TodoItem, DayTodo } from './types.js'
|
||||||
|
|
||||||
|
export const parseTodoContent = (content: string): DayTodo[] => {
|
||||||
|
const lines = content.split('\n')
|
||||||
|
const result: DayTodo[] = []
|
||||||
|
let currentDate: string | null = null
|
||||||
|
let currentItems: TodoItem[] = []
|
||||||
|
let itemId = 0
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const dateMatch = line.match(/^##\s*(\d{4}-\d{2}-\d{2})/)
|
||||||
|
if (dateMatch) {
|
||||||
|
if (currentDate) {
|
||||||
|
result.push({ date: currentDate, items: currentItems })
|
||||||
|
}
|
||||||
|
currentDate = dateMatch[1]
|
||||||
|
currentItems = []
|
||||||
|
} else if (currentDate) {
|
||||||
|
const todoMatch = line.match(/^- (√|○) (.*)$/)
|
||||||
|
if (todoMatch) {
|
||||||
|
currentItems.push({
|
||||||
|
id: `${currentDate}-${itemId++}`,
|
||||||
|
content: todoMatch[2],
|
||||||
|
completed: todoMatch[1] === '√'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentDate) {
|
||||||
|
result.push({ date: currentDate, items: currentItems })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateTodoContent = (dayTodos: DayTodo[]): string => {
|
||||||
|
const lines: string[] = []
|
||||||
|
const sortedDays = [...dayTodos].sort((a, b) => a.date.localeCompare(b.date))
|
||||||
|
|
||||||
|
for (const day of sortedDays) {
|
||||||
|
lines.push(`## ${day.date}`)
|
||||||
|
for (const item of day.items) {
|
||||||
|
const checkbox = item.completed ? '√' : '○'
|
||||||
|
lines.push(`- ${checkbox} ${item.content}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n').trimEnd()
|
||||||
|
}
|
||||||
99
api/modules/todo/routes.ts
Normal file
99
api/modules/todo/routes.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import express, { type Request, type Response, type Router } from 'express'
|
||||||
|
import { asyncHandler } from '../../utils/asyncHandler.js'
|
||||||
|
import { successResponse } from '../../utils/response.js'
|
||||||
|
import { validateBody, validateQuery } from '../../middlewares/validate.js'
|
||||||
|
import { TodoService } from './service.js'
|
||||||
|
import {
|
||||||
|
getTodoQuerySchema,
|
||||||
|
saveTodoSchema,
|
||||||
|
addTodoSchema,
|
||||||
|
toggleTodoSchema,
|
||||||
|
updateTodoSchema,
|
||||||
|
deleteTodoSchema,
|
||||||
|
} from './schemas.js'
|
||||||
|
|
||||||
|
export interface TodoRoutesDependencies {
|
||||||
|
todoService: TodoService
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTodoRoutes = (deps: TodoRoutesDependencies): Router => {
|
||||||
|
const router = express.Router()
|
||||||
|
const { todoService } = deps
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
validateQuery(getTodoQuerySchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const year = parseInt(req.query.year as string) || new Date().getFullYear()
|
||||||
|
const month = parseInt(req.query.month as string) || new Date().getMonth() + 1
|
||||||
|
|
||||||
|
const result = await todoService.getTodo(year, month)
|
||||||
|
successResponse(res, result)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/save',
|
||||||
|
validateBody(saveTodoSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { year, month, dayTodos } = req.body
|
||||||
|
|
||||||
|
await todoService.saveTodo(year, month, dayTodos)
|
||||||
|
|
||||||
|
successResponse(res, null)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/add',
|
||||||
|
validateBody(addTodoSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { year, month, date, content: todoContent } = req.body
|
||||||
|
|
||||||
|
const dayTodos = await todoService.addTodo(year, month, date, todoContent)
|
||||||
|
|
||||||
|
successResponse(res, { dayTodos })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/toggle',
|
||||||
|
validateBody(toggleTodoSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { year, month, date, itemIndex, completed } = req.body
|
||||||
|
|
||||||
|
const dayTodos = await todoService.toggleTodo(year, month, date, itemIndex, completed)
|
||||||
|
|
||||||
|
successResponse(res, { dayTodos })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/update',
|
||||||
|
validateBody(updateTodoSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { year, month, date, itemIndex, content: newContent } = req.body
|
||||||
|
|
||||||
|
const dayTodos = await todoService.updateTodo(year, month, date, itemIndex, newContent)
|
||||||
|
|
||||||
|
successResponse(res, { dayTodos })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
router.delete(
|
||||||
|
'/delete',
|
||||||
|
validateBody(deleteTodoSchema),
|
||||||
|
asyncHandler(async (req: Request, res: Response) => {
|
||||||
|
const { year, month, date, itemIndex } = req.body
|
||||||
|
|
||||||
|
const dayTodos = await todoService.deleteTodo(year, month, date, itemIndex)
|
||||||
|
|
||||||
|
successResponse(res, { dayTodos })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoService = new TodoService()
|
||||||
|
export default createTodoRoutes({ todoService })
|
||||||
53
api/modules/todo/schemas.ts
Normal file
53
api/modules/todo/schemas.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const todoItemSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
completed: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const dayTodoSchema = z.object({
|
||||||
|
date: z.string(),
|
||||||
|
items: z.array(todoItemSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getTodoQuerySchema = z.object({
|
||||||
|
year: z.string().optional(),
|
||||||
|
month: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const saveTodoSchema = z.object({
|
||||||
|
year: z.number().int().positive(),
|
||||||
|
month: z.number().int().min(1).max(12),
|
||||||
|
dayTodos: z.array(dayTodoSchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const addTodoSchema = z.object({
|
||||||
|
year: z.number().int().positive(),
|
||||||
|
month: z.number().int().min(1).max(12),
|
||||||
|
date: z.string(),
|
||||||
|
content: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toggleTodoSchema = z.object({
|
||||||
|
year: z.number().int().positive(),
|
||||||
|
month: z.number().int().min(1).max(12),
|
||||||
|
date: z.string(),
|
||||||
|
itemIndex: z.number().int().nonnegative(),
|
||||||
|
completed: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateTodoSchema = z.object({
|
||||||
|
year: z.number().int().positive(),
|
||||||
|
month: z.number().int().min(1).max(12),
|
||||||
|
date: z.string(),
|
||||||
|
itemIndex: z.number().int().nonnegative(),
|
||||||
|
content: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteTodoSchema = z.object({
|
||||||
|
year: z.number().int().positive(),
|
||||||
|
month: z.number().int().min(1).max(12),
|
||||||
|
date: z.string(),
|
||||||
|
itemIndex: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
216
api/modules/todo/service.ts
Normal file
216
api/modules/todo/service.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { resolveNotebookPath } from '../../utils/pathSafety.js'
|
||||||
|
import { NotFoundError } from '../../../shared/errors/index.js'
|
||||||
|
import { parseTodoContent, generateTodoContent } from './parser.js'
|
||||||
|
import type { DayTodo, TodoFilePath, ParsedTodoFile, GetTodoResult } from './types.js'
|
||||||
|
|
||||||
|
export interface TodoServiceDependencies {
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TodoService {
|
||||||
|
constructor(private deps: TodoServiceDependencies = {}) {}
|
||||||
|
|
||||||
|
getTodoFilePath(year: number, month: number): TodoFilePath {
|
||||||
|
const yearStr = year.toString()
|
||||||
|
const monthStr = month.toString().padStart(2, '0')
|
||||||
|
const relPath = `TODO/${yearStr}/${yearStr}${monthStr}TODO.md`
|
||||||
|
const { fullPath } = resolveNotebookPath(relPath)
|
||||||
|
return { relPath, fullPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureTodoFileExists(fullPath: string): Promise<void> {
|
||||||
|
const dir = path.dirname(fullPath)
|
||||||
|
await fs.mkdir(dir, { recursive: true })
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath)
|
||||||
|
} catch {
|
||||||
|
await fs.writeFile(fullPath, '', 'utf-8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAndParseTodoFile(year: number, month: number): Promise<ParsedTodoFile> {
|
||||||
|
const { fullPath } = this.getTodoFilePath(year, month)
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath)
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundError('TODO file not found')
|
||||||
|
}
|
||||||
|
const content = await fs.readFile(fullPath, 'utf-8')
|
||||||
|
return { fullPath, dayTodos: parseTodoContent(content) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveTodoFile(fullPath: string, dayTodos: DayTodo[]): Promise<void> {
|
||||||
|
const content = generateTodoContent(dayTodos)
|
||||||
|
await fs.writeFile(fullPath, content, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTodo(year: number, month: number): Promise<GetTodoResult> {
|
||||||
|
const { fullPath } = this.getTodoFilePath(year, month)
|
||||||
|
|
||||||
|
let dayTodos: DayTodo[] = []
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath)
|
||||||
|
const content = await fs.readFile(fullPath, 'utf-8')
|
||||||
|
dayTodos = parseTodoContent(content)
|
||||||
|
} catch {
|
||||||
|
// 文件不存在
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const todayStr = `${now.getFullYear()}-${(now.getMonth() + 1).toString().padStart(2, '0')}-${now.getDate().toString().padStart(2, '0')}`
|
||||||
|
|
||||||
|
const yesterday = new Date(now)
|
||||||
|
yesterday.setDate(yesterday.getDate() - 1)
|
||||||
|
const yesterdayStr = `${yesterday.getFullYear()}-${(yesterday.getMonth() + 1).toString().padStart(2, '0')}-${yesterday.getDate().toString().padStart(2, '0')}`
|
||||||
|
|
||||||
|
if (year === now.getFullYear() && month === now.getMonth() + 1) {
|
||||||
|
const migrated = this.migrateIncompleteItems(dayTodos, todayStr, yesterdayStr)
|
||||||
|
|
||||||
|
if (migrated) {
|
||||||
|
const newContent = generateTodoContent(dayTodos)
|
||||||
|
await this.ensureTodoFileExists(fullPath)
|
||||||
|
await fs.writeFile(fullPath, newContent, 'utf-8')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { dayTodos, year, month }
|
||||||
|
}
|
||||||
|
|
||||||
|
private migrateIncompleteItems(dayTodos: DayTodo[], todayStr: string, yesterdayStr: string): boolean {
|
||||||
|
let migrated = false
|
||||||
|
|
||||||
|
const yesterdayTodo = dayTodos.find(d => d.date === yesterdayStr)
|
||||||
|
if (yesterdayTodo) {
|
||||||
|
const incompleteItems = yesterdayTodo.items.filter(item => !item.completed)
|
||||||
|
if (incompleteItems.length > 0) {
|
||||||
|
const todayTodo = dayTodos.find(d => d.date === todayStr)
|
||||||
|
if (todayTodo) {
|
||||||
|
const existingIds = new Set(todayTodo.items.map(i => i.id))
|
||||||
|
const itemsToAdd = incompleteItems.map((item, idx) => ({
|
||||||
|
...item,
|
||||||
|
id: existingIds.has(item.id) ? `${todayStr}-migrated-${idx}` : item.id
|
||||||
|
}))
|
||||||
|
todayTodo.items = [...itemsToAdd, ...todayTodo.items]
|
||||||
|
} else {
|
||||||
|
dayTodos.push({
|
||||||
|
date: todayStr,
|
||||||
|
items: incompleteItems.map((item, idx) => ({
|
||||||
|
...item,
|
||||||
|
id: `${todayStr}-migrated-${idx}`
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
yesterdayTodo.items = yesterdayTodo.items.filter(item => item.completed)
|
||||||
|
if (yesterdayTodo.items.length === 0) {
|
||||||
|
const index = dayTodos.findIndex(d => d.date === yesterdayStr)
|
||||||
|
if (index !== -1) {
|
||||||
|
dayTodos.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrated
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveTodo(year: number, month: number, dayTodos: DayTodo[]): Promise<void> {
|
||||||
|
const { fullPath } = this.getTodoFilePath(year, month)
|
||||||
|
await this.ensureTodoFileExists(fullPath)
|
||||||
|
const content = generateTodoContent(dayTodos)
|
||||||
|
await fs.writeFile(fullPath, content, 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTodo(year: number, month: number, date: string, todoContent: string): Promise<DayTodo[]> {
|
||||||
|
const { fullPath } = this.getTodoFilePath(year, month)
|
||||||
|
await this.ensureTodoFileExists(fullPath)
|
||||||
|
|
||||||
|
let fileContent = await fs.readFile(fullPath, 'utf-8')
|
||||||
|
const dayTodos = parseTodoContent(fileContent)
|
||||||
|
|
||||||
|
const existingDay = dayTodos.find(d => d.date === date)
|
||||||
|
if (existingDay) {
|
||||||
|
const newId = `${date}-${existingDay.items.length}`
|
||||||
|
existingDay.items.push({
|
||||||
|
id: newId,
|
||||||
|
content: todoContent,
|
||||||
|
completed: false
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
dayTodos.push({
|
||||||
|
date,
|
||||||
|
items: [{
|
||||||
|
id: `${date}-0`,
|
||||||
|
content: todoContent,
|
||||||
|
completed: false
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fileContent = generateTodoContent(dayTodos)
|
||||||
|
await fs.writeFile(fullPath, fileContent, 'utf-8')
|
||||||
|
|
||||||
|
return dayTodos
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleTodo(year: number, month: number, date: string, itemIndex: number, completed: boolean): Promise<DayTodo[]> {
|
||||||
|
const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month)
|
||||||
|
|
||||||
|
const day = dayTodos.find(d => d.date === date)
|
||||||
|
if (!day || itemIndex >= day.items.length) {
|
||||||
|
throw new NotFoundError('TODO item not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
day.items[itemIndex].completed = completed
|
||||||
|
|
||||||
|
await this.saveTodoFile(fullPath, dayTodos)
|
||||||
|
|
||||||
|
return dayTodos
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateTodo(year: number, month: number, date: string, itemIndex: number, newContent: string): Promise<DayTodo[]> {
|
||||||
|
const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month)
|
||||||
|
|
||||||
|
const day = dayTodos.find(d => d.date === date)
|
||||||
|
if (!day || itemIndex >= day.items.length) {
|
||||||
|
throw new NotFoundError('TODO item not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
day.items[itemIndex].content = newContent
|
||||||
|
|
||||||
|
await this.saveTodoFile(fullPath, dayTodos)
|
||||||
|
|
||||||
|
return dayTodos
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteTodo(year: number, month: number, date: string, itemIndex: number): Promise<DayTodo[]> {
|
||||||
|
const { fullPath, dayTodos } = await this.loadAndParseTodoFile(year, month)
|
||||||
|
|
||||||
|
const dayIndex = dayTodos.findIndex(d => d.date === date)
|
||||||
|
if (dayIndex === -1) {
|
||||||
|
throw new NotFoundError('Day not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const day = dayTodos[dayIndex]
|
||||||
|
if (itemIndex >= day.items.length) {
|
||||||
|
throw new NotFoundError('TODO item not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
day.items.splice(itemIndex, 1)
|
||||||
|
|
||||||
|
if (day.items.length === 0) {
|
||||||
|
dayTodos.splice(dayIndex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.saveTodoFile(fullPath, dayTodos)
|
||||||
|
|
||||||
|
return dayTodos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTodoService = (deps?: TodoServiceDependencies): TodoService => {
|
||||||
|
return new TodoService(deps)
|
||||||
|
}
|
||||||
7
api/modules/todo/types.ts
Normal file
7
api/modules/todo/types.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export { type TodoItem, type DayTodo } from '../../../shared/types/todo.js'
|
||||||
|
export {
|
||||||
|
type TodoFilePath,
|
||||||
|
type ParsedTodoFile,
|
||||||
|
type GetTodoResult,
|
||||||
|
type MigrationContext,
|
||||||
|
} from '../../../shared/modules/todo/types.js'
|
||||||
43
api/schemas/files.ts
Normal file
43
api/schemas/files.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const listFilesQuerySchema = z.object({
|
||||||
|
path: z.string().optional().default(''),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const contentQuerySchema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const rawQuerySchema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const pathSchema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const saveFileSchema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
content: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const renameSchema = z.object({
|
||||||
|
oldPath: z.string().min(1),
|
||||||
|
newPath: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const searchSchema = z.object({
|
||||||
|
keywords: z.array(z.string()).min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const existsSchema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createDirSchema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createFileSchema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
2
api/schemas/index.ts
Normal file
2
api/schemas/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './files.js'
|
||||||
|
export * from './pydemos.js'
|
||||||
21
api/schemas/pydemos.ts
Normal file
21
api/schemas/pydemos.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const listPyDemosQuerySchema = z.object({
|
||||||
|
year: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createPyDemoSchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
year: z.string().min(1),
|
||||||
|
month: z.string().min(1),
|
||||||
|
folderStructure: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deletePyDemoSchema = z.object({
|
||||||
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const renamePyDemoSchema = z.object({
|
||||||
|
oldPath: z.string().min(1),
|
||||||
|
newName: z.string().min(1),
|
||||||
|
})
|
||||||
48
api/server.ts
Normal file
48
api/server.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import app, { moduleManager, container } from './app.js';
|
||||||
|
import { startWatcher, stopWatcher } from './watcher/watcher.js';
|
||||||
|
import { logger } from './utils/logger.js';
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
startWatcher();
|
||||||
|
|
||||||
|
const server = app.listen(PORT, () => {
|
||||||
|
logger.info(`Server ready on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function gracefulShutdown(signal: string) {
|
||||||
|
logger.info(`${signal} signal received`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stopWatcher();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error stopping watcher:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeModules = moduleManager.getActiveModules();
|
||||||
|
for (const moduleId of activeModules.reverse()) {
|
||||||
|
try {
|
||||||
|
await moduleManager.deactivate(moduleId);
|
||||||
|
logger.info(`Module '${moduleId}' deactivated`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error deactivating module '${moduleId}':`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await container.dispose();
|
||||||
|
logger.info('Service container disposed');
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error disposing container:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
server.close(() => {
|
||||||
|
logger.info('Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
|
||||||
|
export default app;
|
||||||
79
api/utils/__tests__/asyncHandler.test.ts
Normal file
79
api/utils/__tests__/asyncHandler.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { asyncHandler } from '../asyncHandler'
|
||||||
|
import type { Request, Response, NextFunction } from 'express'
|
||||||
|
|
||||||
|
describe('asyncHandler', () => {
|
||||||
|
const mockReq = {} as Request
|
||||||
|
const mockRes = {} as Response
|
||||||
|
const mockNext = vi.fn()
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('成功调用', () => {
|
||||||
|
it('应正常执行函数并返回结果', async () => {
|
||||||
|
const mockHandler = vi.fn().mockResolvedValue('操作成功')
|
||||||
|
const wrappedHandler = asyncHandler(mockHandler)
|
||||||
|
|
||||||
|
await wrappedHandler(mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockHandler).toHaveBeenCalledWith(mockReq, mockRes, mockNext)
|
||||||
|
expect(mockNext).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应处理返回同步值的函数', async () => {
|
||||||
|
const mockHandler = vi.fn().mockResolvedValue({ id: 1, name: 'test' })
|
||||||
|
const wrappedHandler = asyncHandler(mockHandler)
|
||||||
|
|
||||||
|
await wrappedHandler(mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockHandler).toHaveBeenCalled()
|
||||||
|
expect(mockNext).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('异常传播', () => {
|
||||||
|
it('应正确传播异步错误', async () => {
|
||||||
|
const asyncError = new Error('异步错误')
|
||||||
|
const mockHandler = vi.fn().mockRejectedValue(asyncError)
|
||||||
|
const wrappedHandler = asyncHandler(mockHandler)
|
||||||
|
|
||||||
|
await wrappedHandler(mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockHandler).toHaveBeenCalled()
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(asyncError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应处理 Promise.reject 的错误', async () => {
|
||||||
|
const error = new Error('Promise rejected')
|
||||||
|
const mockHandler = vi.fn().mockReturnValue(Promise.reject(error))
|
||||||
|
const wrappedHandler = asyncHandler(mockHandler)
|
||||||
|
|
||||||
|
await wrappedHandler(mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(error)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应处理非 Error 对象的异步错误', async () => {
|
||||||
|
const error = '字符串错误'
|
||||||
|
const mockHandler = vi.fn().mockRejectedValue(error)
|
||||||
|
const wrappedHandler = asyncHandler(mockHandler)
|
||||||
|
|
||||||
|
await wrappedHandler(mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockNext).toHaveBeenCalledWith(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('函数调用时机', () => {
|
||||||
|
it('应立即调用底层函数', () => {
|
||||||
|
const mockHandler = vi.fn().mockResolvedValue('result')
|
||||||
|
const wrappedHandler = asyncHandler(mockHandler)
|
||||||
|
|
||||||
|
wrappedHandler(mockReq, mockRes, mockNext)
|
||||||
|
|
||||||
|
expect(mockHandler).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
137
api/utils/__tests__/response.test.ts
Normal file
137
api/utils/__tests__/response.test.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import { successResponse, errorResponse } from '../response'
|
||||||
|
import type { Response } from 'express'
|
||||||
|
|
||||||
|
vi.mock('express', () => ({
|
||||||
|
default: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('successResponse', () => {
|
||||||
|
it('应返回正确格式的成功响应(默认状态码)', () => {
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response
|
||||||
|
|
||||||
|
const data = { message: '操作成功' }
|
||||||
|
successResponse(mockRes, data)
|
||||||
|
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(200)
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
data: { message: '操作成功' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应使用自定义状态码', () => {
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response
|
||||||
|
|
||||||
|
const data = { id: 123 }
|
||||||
|
successResponse(mockRes, data, 201)
|
||||||
|
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(201)
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
data: { id: 123 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应正确处理数组数据', () => {
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response
|
||||||
|
|
||||||
|
const data = [1, 2, 3]
|
||||||
|
successResponse(mockRes, data)
|
||||||
|
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
data: [1, 2, 3],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('errorResponse', () => {
|
||||||
|
it('应返回正确格式的错误响应', () => {
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response
|
||||||
|
|
||||||
|
errorResponse(mockRes, 400, 'BAD_REQUEST', '请求参数错误')
|
||||||
|
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(400)
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'BAD_REQUEST',
|
||||||
|
message: '请求参数错误',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应在非生产环境包含 details', () => {
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response
|
||||||
|
|
||||||
|
const details = { field: 'username', reason: 'required' }
|
||||||
|
errorResponse(mockRes, 422, 'VALIDATION_ERROR', '验证失败', details)
|
||||||
|
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: '验证失败',
|
||||||
|
details: { field: 'username', reason: 'required' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应在生产环境不包含 details', () => {
|
||||||
|
const originalEnv = process.env.NODE_ENV
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response
|
||||||
|
|
||||||
|
const details = { field: 'username', reason: 'required' }
|
||||||
|
errorResponse(mockRes, 422, 'VALIDATION_ERROR', '验证失败', details)
|
||||||
|
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'VALIDATION_ERROR',
|
||||||
|
message: '验证失败',
|
||||||
|
details: undefined,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
process.env.NODE_ENV = originalEnv
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应正确处理不带 details 的错误响应', () => {
|
||||||
|
const mockRes = {
|
||||||
|
status: vi.fn().mockReturnThis(),
|
||||||
|
json: vi.fn().mockReturnThis(),
|
||||||
|
} as unknown as Response
|
||||||
|
|
||||||
|
errorResponse(mockRes, 404, 'NOT_FOUND', '资源不存在')
|
||||||
|
|
||||||
|
expect(mockRes.status).toHaveBeenCalledWith(404)
|
||||||
|
expect(mockRes.json).toHaveBeenCalledWith({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code: 'NOT_FOUND',
|
||||||
|
message: '资源不存在',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
10
api/utils/asyncHandler.ts
Normal file
10
api/utils/asyncHandler.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express'
|
||||||
|
|
||||||
|
export const asyncHandler =
|
||||||
|
<TReq extends Request = Request, TRes extends Response = Response>(
|
||||||
|
fn: (req: TReq, res: TRes, next: NextFunction) => unknown | Promise<unknown>,
|
||||||
|
) =>
|
||||||
|
(req: TReq, res: TRes, next: NextFunction) => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next)
|
||||||
|
}
|
||||||
|
|
||||||
126
api/utils/file.ts
Normal file
126
api/utils/file.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import fs from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { InternalError, ValidationError } from '../../shared/errors/index.js'
|
||||||
|
|
||||||
|
export const getUniqueFilename = async (imagesDirFullPath: string, baseName: string, ext: string) => {
|
||||||
|
const maxAttempts = 1000
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
const suffix = i === 0 ? '' : `-${i + 1}`
|
||||||
|
const filename = `${baseName}${suffix}${ext}`
|
||||||
|
const fullPath = path.join(imagesDirFullPath, filename)
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath)
|
||||||
|
} catch {
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new InternalError('Failed to generate unique filename')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mimeToExt: Record<string, string> = {
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/jpg': '.jpg',
|
||||||
|
'image/gif': '.gif',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_MAGIC_BYTES: Record<string, { bytes: number[]; offset?: number }> = {
|
||||||
|
'image/png': { bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
||||||
|
'image/jpeg': { bytes: [0xff, 0xd8, 0xff] },
|
||||||
|
'image/gif': { bytes: [0x47, 0x49, 0x46, 0x38] },
|
||||||
|
'image/webp': { bytes: [0x52, 0x49, 0x46, 0x46], offset: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEBP_RIFF_HEADER = [0x52, 0x49, 0x46, 0x46]
|
||||||
|
const WEBP_WEBP_MARKER = [0x57, 0x45, 0x42, 0x50]
|
||||||
|
|
||||||
|
const MIN_IMAGE_SIZE = 16
|
||||||
|
const MAX_IMAGE_SIZE = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
export const validateImageBuffer = (buffer: Buffer, claimedMimeType: string): void => {
|
||||||
|
if (buffer.byteLength < MIN_IMAGE_SIZE) {
|
||||||
|
throw new ValidationError('Image file is too small or corrupted')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.byteLength > MAX_IMAGE_SIZE) {
|
||||||
|
throw new ValidationError('Image file is too large')
|
||||||
|
}
|
||||||
|
|
||||||
|
const magicInfo = IMAGE_MAGIC_BYTES[claimedMimeType]
|
||||||
|
if (!magicInfo) {
|
||||||
|
throw new ValidationError('Unsupported image type for content validation')
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = magicInfo.offset || 0
|
||||||
|
const expectedBytes = magicInfo.bytes
|
||||||
|
|
||||||
|
for (let i = 0; i < expectedBytes.length; i++) {
|
||||||
|
if (buffer[offset + i] !== expectedBytes[i]) {
|
||||||
|
throw new ValidationError('Image content does not match the claimed file type')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (claimedMimeType === 'image/webp') {
|
||||||
|
if (buffer.byteLength < 12) {
|
||||||
|
throw new ValidationError('WebP image is corrupted')
|
||||||
|
}
|
||||||
|
for (let i = 0; i < WEBP_WEBP_MARKER.length; i++) {
|
||||||
|
if (buffer[8 + i] !== WEBP_WEBP_MARKER[i]) {
|
||||||
|
throw new ValidationError('WebP image content is invalid')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const detectImageMimeType = (buffer: Buffer): string | null => {
|
||||||
|
if (buffer.byteLength < 8) return null
|
||||||
|
|
||||||
|
if (
|
||||||
|
buffer[0] === 0x89 &&
|
||||||
|
buffer[1] === 0x50 &&
|
||||||
|
buffer[2] === 0x4e &&
|
||||||
|
buffer[3] === 0x47 &&
|
||||||
|
buffer[4] === 0x0d &&
|
||||||
|
buffer[5] === 0x0a &&
|
||||||
|
buffer[6] === 0x1a &&
|
||||||
|
buffer[7] === 0x0a
|
||||||
|
) {
|
||||||
|
return 'image/png'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||||
|
return 'image/jpeg'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38) {
|
||||||
|
return 'image/gif'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
buffer[0] === 0x52 &&
|
||||||
|
buffer[1] === 0x49 &&
|
||||||
|
buffer[2] === 0x46 &&
|
||||||
|
buffer[3] === 0x46 &&
|
||||||
|
buffer[8] === 0x57 &&
|
||||||
|
buffer[9] === 0x45 &&
|
||||||
|
buffer[10] === 0x42 &&
|
||||||
|
buffer[11] === 0x50
|
||||||
|
) {
|
||||||
|
return 'image/webp'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sanitizeFilename = (filename: string): string => {
|
||||||
|
let sanitized = filename.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_')
|
||||||
|
sanitized = sanitized.replace(/^\.+|\.+$/g, '')
|
||||||
|
sanitized = sanitized.replace(/\.{2,}/g, '.')
|
||||||
|
if (sanitized.length > 200) {
|
||||||
|
const ext = path.extname(filename)
|
||||||
|
const baseName = path.basename(filename, ext)
|
||||||
|
sanitized = baseName.substring(0, 200 - ext.length) + ext
|
||||||
|
}
|
||||||
|
return sanitized || 'unnamed'
|
||||||
|
}
|
||||||
15
api/utils/logger.ts
Normal file
15
api/utils/logger.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
type LogFn = (...args: unknown[]) => void
|
||||||
|
|
||||||
|
const createLogger = () => {
|
||||||
|
const isProd = process.env.NODE_ENV === 'production'
|
||||||
|
|
||||||
|
const debug: LogFn = isProd ? () => {} : console.debug.bind(console)
|
||||||
|
const info: LogFn = console.info.bind(console)
|
||||||
|
const warn: LogFn = console.warn.bind(console)
|
||||||
|
const error: LogFn = console.error.bind(console)
|
||||||
|
|
||||||
|
return { debug, info, warn, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = createLogger()
|
||||||
|
|
||||||
106
api/utils/pathSafety.ts
Normal file
106
api/utils/pathSafety.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs/promises'
|
||||||
|
import { NOTEBOOK_ROOT } from '../config/paths.js'
|
||||||
|
import { AccessDeniedError } from '../../shared/errors/index.js'
|
||||||
|
|
||||||
|
const DANGEROUS_PATTERNS = [
|
||||||
|
/\.\./,
|
||||||
|
/\0/,
|
||||||
|
/%2e%2e[%/]/i,
|
||||||
|
/%252e%252e[%/]/i,
|
||||||
|
/\.\.%2f/i,
|
||||||
|
/\.\.%5c/i,
|
||||||
|
/%c0%ae/i,
|
||||||
|
/%c1%9c/i,
|
||||||
|
/%c0%ae%c0%ae/i,
|
||||||
|
/%c1%9c%c1%9c/i,
|
||||||
|
/\.\.%c0%af/i,
|
||||||
|
/\.\.%c1%9c/i,
|
||||||
|
/%252e/i,
|
||||||
|
/%uff0e/i,
|
||||||
|
/%u002e/i,
|
||||||
|
]
|
||||||
|
|
||||||
|
const WINDOWS_RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]|CLOCK\$)$/i
|
||||||
|
|
||||||
|
const DOUBLE_ENCODE_PATTERNS = [
|
||||||
|
/%25[0-9a-fA-F]{2}/,
|
||||||
|
/%[0-9a-fA-F]{2}%[0-9a-fA-F]{2}/,
|
||||||
|
]
|
||||||
|
|
||||||
|
export const normalizeRelPath = (input: string) => {
|
||||||
|
const trimmed = input.replace(/\0/g, '').trim()
|
||||||
|
return trimmed.replace(/^[/\\]+/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const containsPathTraversal = (input: string): boolean => {
|
||||||
|
const decoded = decodeURIComponentSafe(input)
|
||||||
|
return DANGEROUS_PATTERNS.some(pattern => pattern.test(input) || pattern.test(decoded))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const containsDoubleEncoding = (input: string): boolean => {
|
||||||
|
return DOUBLE_ENCODE_PATTERNS.some(pattern => pattern.test(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasPathSecurityIssues = (input: string): boolean => {
|
||||||
|
return containsPathTraversal(input) || containsDoubleEncoding(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodeURIComponentSafe = (input: string): string => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(input)
|
||||||
|
} catch {
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveNotebookPath = (relPath: string) => {
|
||||||
|
if (hasPathSecurityIssues(relPath)) {
|
||||||
|
throw new AccessDeniedError('Path traversal detected')
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeRelPath = normalizeRelPath(relPath)
|
||||||
|
const notebookRoot = path.resolve(NOTEBOOK_ROOT)
|
||||||
|
const fullPath = path.resolve(notebookRoot, safeRelPath)
|
||||||
|
|
||||||
|
if (!fullPath.startsWith(notebookRoot)) {
|
||||||
|
throw new AccessDeniedError('Access denied')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { safeRelPath, fullPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveNotebookPathSafe = async (relPath: string) => {
|
||||||
|
if (hasPathSecurityIssues(relPath)) {
|
||||||
|
throw new AccessDeniedError('Path traversal detected')
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeRelPath = normalizeRelPath(relPath)
|
||||||
|
const notebookRoot = path.resolve(NOTEBOOK_ROOT)
|
||||||
|
const fullPath = path.resolve(notebookRoot, safeRelPath)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.access(fullPath)
|
||||||
|
} catch {
|
||||||
|
return { safeRelPath, fullPath, realPath: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const realFullPath = await fs.realpath(fullPath)
|
||||||
|
const realRoot = await fs.realpath(notebookRoot)
|
||||||
|
|
||||||
|
if (!realFullPath.startsWith(realRoot)) {
|
||||||
|
throw new AccessDeniedError('Symbolic link escapes notebook root')
|
||||||
|
}
|
||||||
|
|
||||||
|
return { safeRelPath, fullPath, realPath: realFullPath }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateFileName = (name: string): boolean => {
|
||||||
|
if (!name || name.length === 0) return false
|
||||||
|
if (name.length > 255) return false
|
||||||
|
const invalidChars = /[<>:"/\\|?*\x00-\x1f]/
|
||||||
|
if (invalidChars.test(name)) return false
|
||||||
|
if (WINDOWS_RESERVED_NAMES.test(name)) return false
|
||||||
|
if (name.startsWith('.') || name.endsWith('.')) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
34
api/utils/response.ts
Normal file
34
api/utils/response.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Response } from 'express'
|
||||||
|
import type { ApiResponse } from '../../shared/types.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a successful API response
|
||||||
|
*/
|
||||||
|
export const successResponse = <T>(res: Response, data: T, statusCode: number = 200): void => {
|
||||||
|
const response: ApiResponse<T> = {
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
res.status(statusCode).json(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an error API response
|
||||||
|
*/
|
||||||
|
export const errorResponse = (
|
||||||
|
res: Response,
|
||||||
|
statusCode: number,
|
||||||
|
code: string,
|
||||||
|
message: string,
|
||||||
|
details?: unknown
|
||||||
|
): void => {
|
||||||
|
const response: ApiResponse<null> = {
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
details: process.env.NODE_ENV === 'production' ? undefined : details,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
res.status(statusCode).json(response)
|
||||||
|
}
|
||||||
23
api/utils/tempDir.ts
Normal file
23
api/utils/tempDir.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { existsSync, mkdirSync } from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { PATHS } from '../config/paths.js'
|
||||||
|
|
||||||
|
let tempDir: string | null = null
|
||||||
|
|
||||||
|
export const getTempDir = (): string => {
|
||||||
|
if (!tempDir) {
|
||||||
|
tempDir = PATHS.TEMP_ROOT
|
||||||
|
if (!existsSync(tempDir)) {
|
||||||
|
mkdirSync(tempDir, { recursive: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tempDir
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getTempFilePath = (filename: string): string => {
|
||||||
|
return path.join(getTempDir(), filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ensureTempDir = (): string => {
|
||||||
|
return getTempDir()
|
||||||
|
}
|
||||||
43
api/watcher/watcher.ts
Normal file
43
api/watcher/watcher.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import chokidar, { FSWatcher } from 'chokidar';
|
||||||
|
import path from 'path';
|
||||||
|
import { NOTEBOOK_ROOT } from '../config/paths.js';
|
||||||
|
import { eventBus } from '../events/eventBus.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { toPosixPath } from '../../shared/utils/path.js';
|
||||||
|
|
||||||
|
let watcher: FSWatcher | null = null;
|
||||||
|
|
||||||
|
export const startWatcher = (): void => {
|
||||||
|
if (watcher) return;
|
||||||
|
|
||||||
|
logger.info(`Starting file watcher for: ${NOTEBOOK_ROOT}`);
|
||||||
|
|
||||||
|
watcher = chokidar.watch(NOTEBOOK_ROOT, {
|
||||||
|
ignored: /(^|[\/\\])\../,
|
||||||
|
persistent: true,
|
||||||
|
ignoreInitial: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const broadcast = (event: string, changedPath: string) => {
|
||||||
|
const rel = path.relative(NOTEBOOK_ROOT, changedPath);
|
||||||
|
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return;
|
||||||
|
logger.info(`File event: ${event} - ${rel}`);
|
||||||
|
eventBus.broadcast({ event, path: toPosixPath(rel) });
|
||||||
|
};
|
||||||
|
|
||||||
|
watcher
|
||||||
|
.on('add', (p) => broadcast('add', p))
|
||||||
|
.on('change', (p) => broadcast('change', p))
|
||||||
|
.on('unlink', (p) => broadcast('unlink', p))
|
||||||
|
.on('addDir', (p) => broadcast('addDir', p))
|
||||||
|
.on('unlinkDir', (p) => broadcast('unlinkDir', p))
|
||||||
|
.on('ready', () => logger.info('File watcher ready'))
|
||||||
|
.on('error', (err) => logger.error('File watcher error:', err));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stopWatcher = async (): Promise<void> => {
|
||||||
|
if (watcher) {
|
||||||
|
await watcher.close();
|
||||||
|
watcher = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
141
command/Markdown整理要求.md
Normal file
141
command/Markdown整理要求.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Markdown文本全维度修复+标准化整理+视觉化美化要求
|
||||||
|
注意一定要按照章节来制定计划!!!!!!!!!!!!!!!
|
||||||
|
没让你翻译就别乱翻译,一定要保证原文内容不变!!!!!!!!!!
|
||||||
|
一定注意主章节要跟标题一样用一级#
|
||||||
|
>引用块里面的结构也需要注意,现在引用块里面的层级都被打乱了,需要你重新整理。
|
||||||
|
## 一、任务拆分
|
||||||
|
|
||||||
|
### 1.1 章节划分
|
||||||
|
严格根据文档原有章节划分处理单元,按章节依次推进修复、整理、美化工作,不一次性对全文档操作,单章节处理完成后再进行下一章节,跨章节格式保持完全统一。
|
||||||
|
|
||||||
|
### 1.2 操作原则
|
||||||
|
直接在原文档中改,不要新建修改版。
|
||||||
|
乱码需要清理干净,如:
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、核心格式修复(重点解决高频错误)
|
||||||
|
|
||||||
|
### 2.1 列表格式修复
|
||||||
|
- **修正列表层级错位**:核心解决主项与子项无缩进的问题(如主项* 标题,其子项需空两格后加*,而非与主项平齐),确保父子列表层级语法、视觉均清晰,比如「多人地图 / 单人战役」这类主项的子说明,必须作为子项缩进展示,杜绝所有子项与主项同级的错误;
|
||||||
|
- **修正无序列表基础错误**:规范无序列表的使用逻辑,保留原文列表意图,杜绝*被错误删除、替换、乱序的情况;
|
||||||
|
- **修正有序列表错误**:确保有序列表使用数字+点的格式(如1. 内容),避免使用错误的标记。
|
||||||
|
|
||||||
|
### 2.2 文本格式修复
|
||||||
|
- **修正加粗符号错误**:确保加粗内容为**内容**正确渲染格式,杜绝**内容**转义符未生效的错误,无加粗符号缺失、多余情况;
|
||||||
|
- **修正斜体标记错误**:确保斜体内容为*内容*正确渲染格式,无斜体符号缺失、多余情况;
|
||||||
|
- **修正删除线格式**:确保删除线内容为~~内容~~正确渲染格式。
|
||||||
|
|
||||||
|
### 2.3 表格格式修复
|
||||||
|
- 修正表格格式错误:完整保留表格原有结构,杜绝表格被分散展开、行列错乱、分隔符缺失的问题,确保表格语法完整、渲染正常;
|
||||||
|
- 调整表格列宽与对齐方式,确保表格美观易读。
|
||||||
|
|
||||||
|
### 2.4 代码块格式修复
|
||||||
|
- 确保代码块使用```语言```和```的格式,正确标注代码语言;
|
||||||
|
- 修正未闭合的代码块,确保代码块独立成段。
|
||||||
|
|
||||||
|
### 2.5 链接格式修复
|
||||||
|
- 确保链接使用[锚文本](URL)的格式,无未闭合的链接;
|
||||||
|
- 长链接做合理处理,避免影响文档排版。
|
||||||
|
|
||||||
|
### 2.6 数学公式格式修复
|
||||||
|
- 确保行内公式使用$公式$的格式;
|
||||||
|
- 有些公式是块级公式却误用了行内公式格式,需修正为$$公式$$的格式,且$$符号需要单独占一行;
|
||||||
|
- 修正公式中的转义符错误,确保公式正确渲染。
|
||||||
|
|
||||||
|
### 2.7 基础语法兜底
|
||||||
|
- 同步修正错位的标题层级、未闭合的链接/代码块、乱码的特殊符号、错误的斜体标记,确保所有Markdown语法准确生效,无失效或错误格式。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、结构整理
|
||||||
|
|
||||||
|
### 3.1 段落划分
|
||||||
|
- 梳理各章节内文本逻辑脉络,按内容模块划分段落(段落间空一行);
|
||||||
|
- 确保每个段落中心思想明确,内容连贯。
|
||||||
|
|
||||||
|
### 3.2 列表规范
|
||||||
|
- 统一有序/无序列表的缩进规范,确保列表层级清晰;
|
||||||
|
- 列表项内容过长时,确保换行后缩进对齐。
|
||||||
|
|
||||||
|
### 3.3 标题层级
|
||||||
|
- 统一标题层级:一级# 文档标题和主章节、二级 ## 大模块/子章节、三级 ### 子分类,不越级使用;
|
||||||
|
- 标题与正文间保留合理间距,章节标题样式统一。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、视觉美化
|
||||||
|
|
||||||
|
### 4.1 标题美化
|
||||||
|
- 标题:按「一级# 文档标题和主章节、二级 ## 大模块/子章节、三级 ### 子分类」分层,不越级使用,标题与正文间保留合理间距,章节标题样式统一;
|
||||||
|
- 标题前后添加适当的空行,增强视觉层次感。
|
||||||
|
|
||||||
|
### 4.2 文本美化
|
||||||
|
- 重点:关键信息用粗体标注(避免大面积粗体),辅助说明用斜体,禁用过多花哨格式;
|
||||||
|
- 避免一行文本过长,适当控制行宽,提高可读性。
|
||||||
|
|
||||||
|
### 4.3 分隔美化
|
||||||
|
- 不同章节间、章节内不同模块间用---分隔线清晰区隔,避免内容堆砌;
|
||||||
|
- 分隔线前后添加适当的空行,增强视觉效果。
|
||||||
|
|
||||||
|
### 4.4 图片美化
|
||||||
|
- 图片格式不要在方括号中加任何东西,直接![]即可;
|
||||||
|
- 图片添加简洁说明且居中(若支持),图片前后添加适当的空行。
|
||||||
|
|
||||||
|
### 4.5 代码块美化
|
||||||
|
- 代码块独立成段并标注对应语言,代码块前后添加适当的空行;
|
||||||
|
- 确保代码块格式正确,缩进统一。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、细节优化
|
||||||
|
|
||||||
|
### 5.1 空格规范
|
||||||
|
- 统一中英文/数字与标点间的空格,确保格式一致;
|
||||||
|
- 避免多余的空格和制表符。
|
||||||
|
|
||||||
|
### 5.2 文本优化
|
||||||
|
- 修正各章节内错别字与不通顺语句,确保文本流畅;
|
||||||
|
- 删除冗余空行、重复内容和无意义符号,文本整洁紧凑。
|
||||||
|
|
||||||
|
### 5.3 特殊符号处理
|
||||||
|
- 修正乱码的特殊符号,确保特殊符号正确显示;
|
||||||
|
- 避免使用过多的特殊符号,影响文档可读性。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、核心原则
|
||||||
|
|
||||||
|
### 6.1 保留原义
|
||||||
|
- 所有修改均基于原文核心内容,不增删、不篡改各章节原文,仅优化格式与排版!!!这个是最重要的
|
||||||
|
|
||||||
|
### 6.2 一致性原则
|
||||||
|
- 跨章节格式保持完全统一,确保文档整体风格一致;
|
||||||
|
- 相同类型的内容使用相同的格式处理。
|
||||||
|
|
||||||
|
### 6.3 可读性优先
|
||||||
|
- 所有修改以提高文档可读性为首要目标;
|
||||||
|
- 避免过度格式化,确保文档简洁明了。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、输出要求
|
||||||
|
|
||||||
|
### 7.1 输出格式
|
||||||
|
- 请按章节输出处理后的Markdown文本,每章节处理完成后标注该章节主要修改点,确保修复后的文本在任意Markdown编辑器中正常渲染,阅读体验更佳。
|
||||||
|
|
||||||
|
### 7.2 图片格式
|
||||||
|
- 图片格式不要在方括号中加任何东西,直接![]即可。整理格式,直接给我。
|
||||||
|
|
||||||
|
### 7.3 检查清单
|
||||||
|
- 整体:划分清晰章节,使用一级、二级、三级标题分层,不越级,符合Markdown标题规范;
|
||||||
|
- 任务拆分章节:拆分独立段落,修正段落间距,使核心要求(按章节处理、不新建版本)清晰突出;
|
||||||
|
- 核心格式修复章节:将原有零散要求整理为无序列表,规范列表缩进(无层级错位),修正语句间距,确保每类错误修复要求独立明确;
|
||||||
|
- 结构整理/细节优化/核心原则/输出要求章节:拆分独立段落,删除冗余空格,统一段落间距,关键提示(保留原义)突出;
|
||||||
|
- 视觉美化章节:将原有零散美化要求整理为无序列表,规范缩进,明确每类美化标准;
|
||||||
|
- 全局:添加合理分隔线(章节间),统一段落间距,修正语句排版,确保无加粗错误、无格式错乱,保留原文所有核心要求,未增删任何内容。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、总结
|
||||||
|
|
||||||
|
Markdown文档的整理和美化是一个需要细心和耐心的工作,遵循以上规范和建议,可以确保文档格式正确、结构清晰、视觉美观,提高文档的可读性和专业度。在处理过程中,始终牢记核心原则:保留原义,仅优化格式与排版。
|
||||||
19
command/简化项目.md
Normal file
19
command/简化项目.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# 代码审查与冗余问题优化任务要求
|
||||||
|
对整个项目进行系统性代码审查,识别并定位一处对项目简洁性具有决定性影响的关键冗余问题。该问题可涵盖逻辑冗余、代码冗余、架构冗余或文件夹结构以及文件名不合理中的任意一种,但需满足以下核心条件:
|
||||||
|
- 必须是单一问题点;
|
||||||
|
- 必须是最最最严重的冗余问题,小问题可以忽略;
|
||||||
|
- 对当前代码库简洁性造成**显著负面影响**。
|
||||||
|
- 每次执行前先制定计划,等我审核通过之后再执行。
|
||||||
|
|
||||||
|
## 需提交的具体修改建议包含以下维度
|
||||||
|
1. 问题定位:明确冗余问题的具体位置、表现形式及核心特征;
|
||||||
|
2. 重构方案:针对该冗余问题提出可落地的代码/架构重构方案;
|
||||||
|
3. 预期收益:说明重构后在可读性、可维护性、架构清晰度等维度的具体提升效果;
|
||||||
|
4. 实施步骤:拆解重构的具体执行步骤,确保方案可落地。
|
||||||
|
|
||||||
|
### 核心目标
|
||||||
|
修改后需显著提升代码的可读性、可维护性和整体架构清晰度。
|
||||||
|
|
||||||
|
注意:完成修复之后,针对涉及到的功能,需要给我验收测试方案,我来检查验收,看功能是否收到影响。
|
||||||
|
|
||||||
|
注意的注意:最最最关键的是不能影响原有的功能,不能产生bug!!!
|
||||||
3026
dist-api/server.js
Normal file
3026
dist-api/server.js
Normal file
File diff suppressed because it is too large
Load Diff
316
dist-electron/main.js
Normal file
316
dist-electron/main.js
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
var __defProp = Object.defineProperty;
|
||||||
|
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
|
||||||
|
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
||||||
|
|
||||||
|
// electron/main.ts
|
||||||
|
import { app, BrowserWindow as BrowserWindow3, shell, ipcMain, dialog as dialog2, nativeTheme, globalShortcut, clipboard } from "electron";
|
||||||
|
import path2 from "path";
|
||||||
|
import { fileURLToPath, pathToFileURL } from "url";
|
||||||
|
import fs2 from "fs";
|
||||||
|
import log2 from "electron-log";
|
||||||
|
|
||||||
|
// electron/services/pdfGenerator.ts
|
||||||
|
import { BrowserWindow } from "electron";
|
||||||
|
async function generatePdf(htmlContent) {
|
||||||
|
const printWin = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false
|
||||||
|
// 与 main.ts 保持一致,确保脚本执行权限
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await printWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`);
|
||||||
|
await printWin.webContents.executeJavaScript(`
|
||||||
|
new Promise(resolve => {
|
||||||
|
const check = () => {
|
||||||
|
if (window.__PRINT_READY__) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check();
|
||||||
|
})
|
||||||
|
`);
|
||||||
|
const pdfData = await printWin.webContents.printToPDF({
|
||||||
|
printBackground: true,
|
||||||
|
pageSize: "A4",
|
||||||
|
margins: { top: 0, bottom: 0, left: 0, right: 0 }
|
||||||
|
});
|
||||||
|
return pdfData;
|
||||||
|
} finally {
|
||||||
|
printWin.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// electron/services/htmlImport.ts
|
||||||
|
import { dialog } from "electron";
|
||||||
|
import path from "path";
|
||||||
|
import fs from "fs";
|
||||||
|
import log from "electron-log";
|
||||||
|
var selectHtmlFile = async (win) => {
|
||||||
|
if (!win) return { success: false, error: "No window found" };
|
||||||
|
try {
|
||||||
|
const { filePaths, canceled } = await dialog.showOpenDialog(win, {
|
||||||
|
title: "\u9009\u62E9 HTML \u6587\u4EF6",
|
||||||
|
filters: [
|
||||||
|
{ name: "HTML Files", extensions: ["html", "htm"] }
|
||||||
|
],
|
||||||
|
properties: ["openFile"]
|
||||||
|
});
|
||||||
|
if (canceled || filePaths.length === 0) {
|
||||||
|
return { success: false, canceled: true };
|
||||||
|
}
|
||||||
|
const htmlPath = filePaths[0];
|
||||||
|
const htmlDir = path.dirname(htmlPath);
|
||||||
|
const htmlFileName = path.basename(htmlPath, path.extname(htmlPath));
|
||||||
|
const assetsDirName = `${htmlFileName}_files`;
|
||||||
|
const assetsDirPath = path.join(htmlDir, assetsDirName);
|
||||||
|
const assetsFiles = [];
|
||||||
|
if (fs.existsSync(assetsDirPath)) {
|
||||||
|
const collectFiles = (dir, baseDir) => {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
collectFiles(fullPath, baseDir);
|
||||||
|
} else {
|
||||||
|
const relPath = path.relative(baseDir, fullPath);
|
||||||
|
assetsFiles.push(relPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
collectFiles(assetsDirPath, assetsDirPath);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
htmlPath,
|
||||||
|
htmlDir,
|
||||||
|
htmlFileName,
|
||||||
|
assetsDirName,
|
||||||
|
assetsFiles
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
log.error("Select HTML file failed:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// electron/state.ts
|
||||||
|
var ElectronState = class {
|
||||||
|
constructor() {
|
||||||
|
__publicField(this, "state", {
|
||||||
|
mainWindow: null,
|
||||||
|
serverPort: 3001,
|
||||||
|
isDev: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
getMainWindow() {
|
||||||
|
return this.state.mainWindow;
|
||||||
|
}
|
||||||
|
setMainWindow(window) {
|
||||||
|
this.state.mainWindow = window;
|
||||||
|
}
|
||||||
|
getServerPort() {
|
||||||
|
return this.state.serverPort;
|
||||||
|
}
|
||||||
|
setServerPort(port) {
|
||||||
|
this.state.serverPort = port;
|
||||||
|
}
|
||||||
|
isDevelopment() {
|
||||||
|
return this.state.isDev;
|
||||||
|
}
|
||||||
|
setDevelopment(isDev) {
|
||||||
|
this.state.isDev = isDev;
|
||||||
|
}
|
||||||
|
reset() {
|
||||||
|
this.state = {
|
||||||
|
mainWindow: null,
|
||||||
|
serverPort: 3001,
|
||||||
|
isDev: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var electronState = new ElectronState();
|
||||||
|
|
||||||
|
// electron/main.ts
|
||||||
|
log2.initialize();
|
||||||
|
var __filename = fileURLToPath(import.meta.url);
|
||||||
|
var __dirname = path2.dirname(__filename);
|
||||||
|
process.env.NOTEBOOK_ROOT = path2.join(app.getPath("documents"), "XCNote");
|
||||||
|
if (!fs2.existsSync(process.env.NOTEBOOK_ROOT)) {
|
||||||
|
try {
|
||||||
|
fs2.mkdirSync(process.env.NOTEBOOK_ROOT, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
log2.error("Failed to create notebook directory:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
electronState.setDevelopment(!app.isPackaged);
|
||||||
|
var lastClipboardText = "";
|
||||||
|
function startClipboardWatcher() {
|
||||||
|
lastClipboardText = clipboard.readText();
|
||||||
|
setInterval(() => {
|
||||||
|
try {
|
||||||
|
const currentText = clipboard.readText();
|
||||||
|
if (currentText && currentText !== lastClipboardText) {
|
||||||
|
lastClipboardText = currentText;
|
||||||
|
log2.info("Clipboard changed, syncing to remote");
|
||||||
|
const win = electronState.getMainWindow();
|
||||||
|
if (win) {
|
||||||
|
win.webContents.send("remote-clipboard-auto-sync", currentText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}, 1e3);
|
||||||
|
}
|
||||||
|
async function createWindow() {
|
||||||
|
const initialSymbolColor = nativeTheme.shouldUseDarkColors ? "#ffffff" : "#000000";
|
||||||
|
const mainWindow = new BrowserWindow3({
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 1600,
|
||||||
|
minHeight: 900,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
titleBarStyle: "hidden",
|
||||||
|
titleBarOverlay: {
|
||||||
|
color: "#00000000",
|
||||||
|
symbolColor: initialSymbolColor,
|
||||||
|
height: 32
|
||||||
|
},
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false,
|
||||||
|
webviewTag: true,
|
||||||
|
preload: path2.join(__dirname, "preload.cjs")
|
||||||
|
}
|
||||||
|
});
|
||||||
|
electronState.setMainWindow(mainWindow);
|
||||||
|
mainWindow.setMenu(null);
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (url.startsWith("http:") || url.startsWith("https:")) {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: "deny" };
|
||||||
|
}
|
||||||
|
return { action: "allow" };
|
||||||
|
});
|
||||||
|
if (electronState.isDevelopment()) {
|
||||||
|
log2.info("Loading development URL...");
|
||||||
|
try {
|
||||||
|
await mainWindow.loadURL("http://localhost:5173");
|
||||||
|
} catch (e) {
|
||||||
|
log2.error("Failed to load dev URL. Make sure npm run electron:dev is used.", e);
|
||||||
|
}
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
} else {
|
||||||
|
log2.info(`Loading production URL with port ${electronState.getServerPort()}...`);
|
||||||
|
await mainWindow.loadURL(`http://localhost:${electronState.getServerPort()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ipcMain.handle("export-pdf", async (event, title, htmlContent) => {
|
||||||
|
const win = BrowserWindow3.fromWebContents(event.sender);
|
||||||
|
if (!win) return { success: false, error: "No window found" };
|
||||||
|
try {
|
||||||
|
const { filePath } = await dialog2.showSaveDialog(win, {
|
||||||
|
title: "\u5BFC\u51FA PDF",
|
||||||
|
defaultPath: `${title}.pdf`,
|
||||||
|
filters: [{ name: "PDF Files", extensions: ["pdf"] }]
|
||||||
|
});
|
||||||
|
if (!filePath) return { success: false, canceled: true };
|
||||||
|
if (!htmlContent) {
|
||||||
|
throw new Error("No HTML content provided for PDF export");
|
||||||
|
}
|
||||||
|
const pdfData = await generatePdf(htmlContent);
|
||||||
|
fs2.writeFileSync(filePath, pdfData);
|
||||||
|
return { success: true, filePath };
|
||||||
|
} catch (error) {
|
||||||
|
log2.error("Export PDF failed:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle("select-html-file", async (event) => {
|
||||||
|
const win = BrowserWindow3.fromWebContents(event.sender);
|
||||||
|
return selectHtmlFile(win);
|
||||||
|
});
|
||||||
|
ipcMain.handle("update-titlebar-buttons", async (event, symbolColor) => {
|
||||||
|
const win = BrowserWindow3.fromWebContents(event.sender);
|
||||||
|
if (win) {
|
||||||
|
win.setTitleBarOverlay({ symbolColor });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
});
|
||||||
|
ipcMain.handle("clipboard-read-text", async () => {
|
||||||
|
try {
|
||||||
|
const text = clipboard.readText();
|
||||||
|
return { success: true, text };
|
||||||
|
} catch (error) {
|
||||||
|
log2.error("Clipboard read failed:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ipcMain.handle("clipboard-write-text", async (event, text) => {
|
||||||
|
try {
|
||||||
|
clipboard.writeText(text);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
log2.error("Clipboard write failed:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
async function startServer() {
|
||||||
|
if (electronState.isDevelopment()) {
|
||||||
|
log2.info("In dev mode, assuming external servers are running.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const serverPath = path2.join(__dirname, "../dist-api/server.js");
|
||||||
|
const serverUrl = pathToFileURL(serverPath).href;
|
||||||
|
log2.info(`Starting internal server from: ${serverPath}`);
|
||||||
|
try {
|
||||||
|
const serverModule = await import(serverUrl);
|
||||||
|
if (serverModule.startServer) {
|
||||||
|
const port = await serverModule.startServer();
|
||||||
|
electronState.setServerPort(port);
|
||||||
|
log2.info(`Internal server started successfully on port ${port}`);
|
||||||
|
} else {
|
||||||
|
log2.warn("startServer function not found in server module, using default port 3001");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log2.error("Failed to start internal server:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
await startServer();
|
||||||
|
await createWindow();
|
||||||
|
startClipboardWatcher();
|
||||||
|
globalShortcut.register("CommandOrControl+Shift+C", () => {
|
||||||
|
log2.info("Global shortcut: sync clipboard to remote");
|
||||||
|
const win = electronState.getMainWindow();
|
||||||
|
if (win) {
|
||||||
|
win.webContents.send("remote-clipboard-sync-to-remote");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
globalShortcut.register("CommandOrControl+Shift+V", () => {
|
||||||
|
log2.info("Global shortcut: sync clipboard from remote");
|
||||||
|
const win = electronState.getMainWindow();
|
||||||
|
if (win) {
|
||||||
|
win.webContents.send("remote-clipboard-sync-from-remote");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.on("activate", () => {
|
||||||
|
if (BrowserWindow3.getAllWindows().length === 0) {
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.on("window-all-closed", () => {
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
if (process.platform !== "darwin") {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
//# sourceMappingURL=main.js.map
|
||||||
1
dist-electron/main.js.map
Normal file
1
dist-electron/main.js.map
Normal file
File diff suppressed because one or more lines are too long
24
dist-electron/preload.cjs
Normal file
24
dist-electron/preload.cjs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// electron/preload.ts
|
||||||
|
var import_electron = require("electron");
|
||||||
|
console.log("--- PRELOAD SCRIPT LOADED SUCCESSFULLY ---");
|
||||||
|
import_electron.contextBridge.exposeInMainWorld("electronAPI", {
|
||||||
|
exportPDF: (title, htmlContent) => import_electron.ipcRenderer.invoke("export-pdf", title, htmlContent),
|
||||||
|
selectHtmlFile: () => import_electron.ipcRenderer.invoke("select-html-file"),
|
||||||
|
updateTitlebarButtons: (symbolColor) => import_electron.ipcRenderer.invoke("update-titlebar-buttons", symbolColor),
|
||||||
|
onRemoteClipboardSyncToRemote: (callback) => {
|
||||||
|
import_electron.ipcRenderer.on("remote-clipboard-sync-to-remote", callback);
|
||||||
|
return () => import_electron.ipcRenderer.removeListener("remote-clipboard-sync-to-remote", callback);
|
||||||
|
},
|
||||||
|
onRemoteClipboardSyncFromRemote: (callback) => {
|
||||||
|
import_electron.ipcRenderer.on("remote-clipboard-sync-from-remote", callback);
|
||||||
|
return () => import_electron.ipcRenderer.removeListener("remote-clipboard-sync-from-remote", callback);
|
||||||
|
},
|
||||||
|
onRemoteClipboardAutoSync: (callback) => {
|
||||||
|
const handler = (_event, text) => callback(text);
|
||||||
|
import_electron.ipcRenderer.on("remote-clipboard-auto-sync", handler);
|
||||||
|
return () => import_electron.ipcRenderer.removeListener("remote-clipboard-auto-sync", handler);
|
||||||
|
},
|
||||||
|
clipboardReadText: () => import_electron.ipcRenderer.invoke("clipboard-read-text"),
|
||||||
|
clipboardWriteText: (text) => import_electron.ipcRenderer.invoke("clipboard-write-text", text)
|
||||||
|
});
|
||||||
|
//# sourceMappingURL=preload.cjs.map
|
||||||
1
dist-electron/preload.cjs.map
Normal file
1
dist-electron/preload.cjs.map
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"sources":["../electron/preload.ts"],"sourcesContent":["import { contextBridge, ipcRenderer } from 'electron'\n\nconsole.log('--- PRELOAD SCRIPT LOADED SUCCESSFULLY ---')\n\ncontextBridge.exposeInMainWorld('electronAPI', {\n exportPDF: (title: string, htmlContent?: string) => ipcRenderer.invoke('export-pdf', title, htmlContent),\n selectHtmlFile: () => ipcRenderer.invoke('select-html-file'),\n updateTitlebarButtons: (symbolColor: string) => ipcRenderer.invoke('update-titlebar-buttons', symbolColor),\n onRemoteClipboardSyncToRemote: (callback: () => void) => {\n ipcRenderer.on('remote-clipboard-sync-to-remote', callback);\n return () => ipcRenderer.removeListener('remote-clipboard-sync-to-remote', callback);\n },\n onRemoteClipboardSyncFromRemote: (callback: () => void) => {\n ipcRenderer.on('remote-clipboard-sync-from-remote', callback);\n return () => ipcRenderer.removeListener('remote-clipboard-sync-from-remote', callback);\n },\n onRemoteClipboardAutoSync: (callback: (text: string) => void) => {\n const handler = (_event: Electron.IpcRendererEvent, text: string) => callback(text);\n ipcRenderer.on('remote-clipboard-auto-sync', handler);\n return () => ipcRenderer.removeListener('remote-clipboard-auto-sync', handler);\n },\n clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'),\n clipboardWriteText: (text: string) => ipcRenderer.invoke('clipboard-write-text', text),\n})\n"],"mappings":";AAAA,sBAA2C;AAE3C,QAAQ,IAAI,4CAA4C;AAExD,8BAAc,kBAAkB,eAAe;AAAA,EAC7C,WAAW,CAAC,OAAe,gBAAyB,4BAAY,OAAO,cAAc,OAAO,WAAW;AAAA,EACvG,gBAAgB,MAAM,4BAAY,OAAO,kBAAkB;AAAA,EAC3D,uBAAuB,CAAC,gBAAwB,4BAAY,OAAO,2BAA2B,WAAW;AAAA,EACzG,+BAA+B,CAAC,aAAyB;AACvD,gCAAY,GAAG,mCAAmC,QAAQ;AAC1D,WAAO,MAAM,4BAAY,eAAe,mCAAmC,QAAQ;AAAA,EACrF;AAAA,EACA,iCAAiC,CAAC,aAAyB;AACzD,gCAAY,GAAG,qCAAqC,QAAQ;AAC5D,WAAO,MAAM,4BAAY,eAAe,qCAAqC,QAAQ;AAAA,EACvF;AAAA,EACA,2BAA2B,CAAC,aAAqC;AAC/D,UAAM,UAAU,CAAC,QAAmC,SAAiB,SAAS,IAAI;AAClF,gCAAY,GAAG,8BAA8B,OAAO;AACpD,WAAO,MAAM,4BAAY,eAAe,8BAA8B,OAAO;AAAA,EAC/E;AAAA,EACA,mBAAmB,MAAM,4BAAY,OAAO,qBAAqB;AAAA,EACjE,oBAAoB,CAAC,SAAiB,4BAAY,OAAO,wBAAwB,IAAI;AACvF,CAAC;","names":[]}
|
||||||
217
electron/main.ts
Normal file
217
electron/main.ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { app, BrowserWindow, shell, ipcMain, dialog, nativeTheme, globalShortcut, clipboard } from 'electron';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
import log from 'electron-log';
|
||||||
|
import { generatePdf } from './services/pdfGenerator';
|
||||||
|
import { selectHtmlFile } from './services/htmlImport';
|
||||||
|
import { electronState } from './state';
|
||||||
|
|
||||||
|
log.initialize();
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
process.env.NOTEBOOK_ROOT = path.join(app.getPath('documents'), 'XCNote');
|
||||||
|
|
||||||
|
if (!fs.existsSync(process.env.NOTEBOOK_ROOT)) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(process.env.NOTEBOOK_ROOT, { recursive: true });
|
||||||
|
} catch (err) {
|
||||||
|
log.error('Failed to create notebook directory:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
electronState.setDevelopment(!app.isPackaged);
|
||||||
|
|
||||||
|
let lastClipboardText = '';
|
||||||
|
|
||||||
|
function startClipboardWatcher() {
|
||||||
|
lastClipboardText = clipboard.readText();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
try {
|
||||||
|
const currentText = clipboard.readText();
|
||||||
|
if (currentText && currentText !== lastClipboardText) {
|
||||||
|
lastClipboardText = currentText;
|
||||||
|
log.info('Clipboard changed, syncing to remote');
|
||||||
|
const win = electronState.getMainWindow();
|
||||||
|
if (win) {
|
||||||
|
win.webContents.send('remote-clipboard-auto-sync', currentText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWindow() {
|
||||||
|
const initialSymbolColor = nativeTheme.shouldUseDarkColors ? '#ffffff' : '#000000';
|
||||||
|
|
||||||
|
const mainWindow = new BrowserWindow({
|
||||||
|
width: 1280,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 1600,
|
||||||
|
minHeight: 900,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
titleBarStyle: 'hidden',
|
||||||
|
titleBarOverlay: {
|
||||||
|
color: '#00000000',
|
||||||
|
symbolColor: initialSymbolColor,
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false,
|
||||||
|
webviewTag: true,
|
||||||
|
preload: path.join(__dirname, 'preload.cjs'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
electronState.setMainWindow(mainWindow);
|
||||||
|
mainWindow.setMenu(null);
|
||||||
|
|
||||||
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
if (url.startsWith('http:') || url.startsWith('https:')) {
|
||||||
|
shell.openExternal(url);
|
||||||
|
return { action: 'deny' };
|
||||||
|
}
|
||||||
|
return { action: 'allow' };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (electronState.isDevelopment()) {
|
||||||
|
log.info('Loading development URL...');
|
||||||
|
try {
|
||||||
|
await mainWindow.loadURL('http://localhost:5173');
|
||||||
|
} catch (e) {
|
||||||
|
log.error('Failed to load dev URL. Make sure npm run electron:dev is used.', e);
|
||||||
|
}
|
||||||
|
mainWindow.webContents.openDevTools();
|
||||||
|
} else {
|
||||||
|
log.info(`Loading production URL with port ${electronState.getServerPort()}...`);
|
||||||
|
await mainWindow.loadURL(`http://localhost:${electronState.getServerPort()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.handle('export-pdf', async (event, title, htmlContent) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (!win) return { success: false, error: 'No window found' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { filePath } = await dialog.showSaveDialog(win, {
|
||||||
|
title: '导出 PDF',
|
||||||
|
defaultPath: `${title}.pdf`,
|
||||||
|
filters: [{ name: 'PDF Files', extensions: ['pdf'] }]
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!filePath) return { success: false, canceled: true };
|
||||||
|
|
||||||
|
if (!htmlContent) {
|
||||||
|
throw new Error('No HTML content provided for PDF export');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pdfData = await generatePdf(htmlContent);
|
||||||
|
fs.writeFileSync(filePath, pdfData);
|
||||||
|
|
||||||
|
return { success: true, filePath };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Export PDF failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('select-html-file', async (event) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
return selectHtmlFile(win);
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('update-titlebar-buttons', async (event, symbolColor: string) => {
|
||||||
|
const win = BrowserWindow.fromWebContents(event.sender);
|
||||||
|
if (win) {
|
||||||
|
win.setTitleBarOverlay({ symbolColor });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
return { success: false };
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('clipboard-read-text', async () => {
|
||||||
|
try {
|
||||||
|
const text = clipboard.readText();
|
||||||
|
return { success: true, text };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Clipboard read failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('clipboard-write-text', async (event, text: string) => {
|
||||||
|
try {
|
||||||
|
clipboard.writeText(text);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Clipboard write failed:', error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startServer() {
|
||||||
|
if (electronState.isDevelopment()) {
|
||||||
|
log.info('In dev mode, assuming external servers are running.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverPath = path.join(__dirname, '../dist-api/server.js');
|
||||||
|
const serverUrl = pathToFileURL(serverPath).href;
|
||||||
|
|
||||||
|
log.info(`Starting internal server from: ${serverPath}`);
|
||||||
|
try {
|
||||||
|
const serverModule = await import(serverUrl);
|
||||||
|
if (serverModule.startServer) {
|
||||||
|
const port = await serverModule.startServer();
|
||||||
|
electronState.setServerPort(port);
|
||||||
|
log.info(`Internal server started successfully on port ${port}`);
|
||||||
|
} else {
|
||||||
|
log.warn('startServer function not found in server module, using default port 3001');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log.error('Failed to start internal server:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
await startServer();
|
||||||
|
await createWindow();
|
||||||
|
|
||||||
|
startClipboardWatcher();
|
||||||
|
|
||||||
|
globalShortcut.register('CommandOrControl+Shift+C', () => {
|
||||||
|
log.info('Global shortcut: sync clipboard to remote');
|
||||||
|
const win = electronState.getMainWindow();
|
||||||
|
if (win) {
|
||||||
|
win.webContents.send('remote-clipboard-sync-to-remote');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
globalShortcut.register('CommandOrControl+Shift+V', () => {
|
||||||
|
log.info('Global shortcut: sync clipboard from remote');
|
||||||
|
const win = electronState.getMainWindow();
|
||||||
|
if (win) {
|
||||||
|
win.webContents.send('remote-clipboard-sync-from-remote');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
globalShortcut.unregisterAll();
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
24
electron/preload.ts
Normal file
24
electron/preload.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
|
||||||
|
console.log('--- PRELOAD SCRIPT LOADED SUCCESSFULLY ---')
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
exportPDF: (title: string, htmlContent?: string) => ipcRenderer.invoke('export-pdf', title, htmlContent),
|
||||||
|
selectHtmlFile: () => ipcRenderer.invoke('select-html-file'),
|
||||||
|
updateTitlebarButtons: (symbolColor: string) => ipcRenderer.invoke('update-titlebar-buttons', symbolColor),
|
||||||
|
onRemoteClipboardSyncToRemote: (callback: () => void) => {
|
||||||
|
ipcRenderer.on('remote-clipboard-sync-to-remote', callback);
|
||||||
|
return () => ipcRenderer.removeListener('remote-clipboard-sync-to-remote', callback);
|
||||||
|
},
|
||||||
|
onRemoteClipboardSyncFromRemote: (callback: () => void) => {
|
||||||
|
ipcRenderer.on('remote-clipboard-sync-from-remote', callback);
|
||||||
|
return () => ipcRenderer.removeListener('remote-clipboard-sync-from-remote', callback);
|
||||||
|
},
|
||||||
|
onRemoteClipboardAutoSync: (callback: (text: string) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, text: string) => callback(text);
|
||||||
|
ipcRenderer.on('remote-clipboard-auto-sync', handler);
|
||||||
|
return () => ipcRenderer.removeListener('remote-clipboard-auto-sync', handler);
|
||||||
|
},
|
||||||
|
clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'),
|
||||||
|
clipboardWriteText: (text: string) => ipcRenderer.invoke('clipboard-write-text', text),
|
||||||
|
})
|
||||||
36
electron/server.ts
Normal file
36
electron/server.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import app from '../api/app';
|
||||||
|
import path from 'path';
|
||||||
|
import express from 'express';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { logger } from '../api/utils/logger';
|
||||||
|
import { AddressInfo } from 'net';
|
||||||
|
import { startWatcher } from '../api/watcher/watcher.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
startWatcher();
|
||||||
|
|
||||||
|
const distPath = path.join(__dirname, '../dist');
|
||||||
|
|
||||||
|
app.use(express.static(distPath));
|
||||||
|
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(distPath, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
export const startServer = (): Promise<number> => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = app.listen(0, () => {
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
const port = address.port;
|
||||||
|
logger.info(`Electron internal server running on port ${port}`);
|
||||||
|
resolve(port);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('error', (err) => {
|
||||||
|
logger.error('Failed to start server:', err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
69
electron/services/htmlImport.ts
Normal file
69
electron/services/htmlImport.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { dialog, BrowserWindow } from 'electron'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
|
import log from 'electron-log'
|
||||||
|
|
||||||
|
export interface HtmlImportResult {
|
||||||
|
success: boolean
|
||||||
|
canceled?: boolean
|
||||||
|
error?: string
|
||||||
|
htmlPath?: string
|
||||||
|
htmlDir?: string
|
||||||
|
htmlFileName?: string
|
||||||
|
assetsDirName?: string
|
||||||
|
assetsFiles?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const selectHtmlFile = async (win: BrowserWindow | null): Promise<HtmlImportResult> => {
|
||||||
|
if (!win) return { success: false, error: 'No window found' }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { filePaths, canceled } = await dialog.showOpenDialog(win, {
|
||||||
|
title: '选择 HTML 文件',
|
||||||
|
filters: [
|
||||||
|
{ name: 'HTML Files', extensions: ['html', 'htm'] }
|
||||||
|
],
|
||||||
|
properties: ['openFile']
|
||||||
|
})
|
||||||
|
|
||||||
|
if (canceled || filePaths.length === 0) {
|
||||||
|
return { success: false, canceled: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlPath = filePaths[0]
|
||||||
|
const htmlDir = path.dirname(htmlPath)
|
||||||
|
const htmlFileName = path.basename(htmlPath, path.extname(htmlPath))
|
||||||
|
|
||||||
|
const assetsDirName = `${htmlFileName}_files`
|
||||||
|
const assetsDirPath = path.join(htmlDir, assetsDirName)
|
||||||
|
|
||||||
|
const assetsFiles: string[] = []
|
||||||
|
if (fs.existsSync(assetsDirPath)) {
|
||||||
|
const collectFiles = (dir: string, baseDir: string) => {
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
collectFiles(fullPath, baseDir)
|
||||||
|
} else {
|
||||||
|
const relPath = path.relative(baseDir, fullPath)
|
||||||
|
assetsFiles.push(relPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collectFiles(assetsDirPath, assetsDirPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
htmlPath,
|
||||||
|
htmlDir,
|
||||||
|
htmlFileName,
|
||||||
|
assetsDirName,
|
||||||
|
assetsFiles
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
log.error('Select HTML file failed:', error)
|
||||||
|
return { success: false, error: error.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
46
electron/services/pdfGenerator.ts
Normal file
46
electron/services/pdfGenerator.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { BrowserWindow } from 'electron';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 PDF 的服务
|
||||||
|
* @param htmlContent 完整的 HTML 字符串
|
||||||
|
* @returns PDF 文件的二进制数据
|
||||||
|
*/
|
||||||
|
export async function generatePdf(htmlContent: string): Promise<Uint8Array> {
|
||||||
|
const printWin = new BrowserWindow({
|
||||||
|
show: false,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: false, // 与 main.ts 保持一致,确保脚本执行权限
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await printWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`);
|
||||||
|
|
||||||
|
// 等待资源加载完成 (由 generatePrintHtml 注入的脚本控制)
|
||||||
|
await printWin.webContents.executeJavaScript(`
|
||||||
|
new Promise(resolve => {
|
||||||
|
const check = () => {
|
||||||
|
if (window.__PRINT_READY__) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check();
|
||||||
|
})
|
||||||
|
`);
|
||||||
|
|
||||||
|
const pdfData = await printWin.webContents.printToPDF({
|
||||||
|
printBackground: true,
|
||||||
|
pageSize: 'A4',
|
||||||
|
margins: { top: 0, bottom: 0, left: 0, right: 0 }
|
||||||
|
});
|
||||||
|
|
||||||
|
return pdfData;
|
||||||
|
} finally {
|
||||||
|
// 确保窗口被关闭,防止内存泄漏
|
||||||
|
printWin.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
49
electron/state.ts
Normal file
49
electron/state.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { BrowserWindow } from 'electron'
|
||||||
|
|
||||||
|
interface ElectronAppState {
|
||||||
|
mainWindow: BrowserWindow | null
|
||||||
|
serverPort: number
|
||||||
|
isDev: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class ElectronState {
|
||||||
|
private state: ElectronAppState = {
|
||||||
|
mainWindow: null,
|
||||||
|
serverPort: 3001,
|
||||||
|
isDev: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
getMainWindow(): BrowserWindow | null {
|
||||||
|
return this.state.mainWindow
|
||||||
|
}
|
||||||
|
|
||||||
|
setMainWindow(window: BrowserWindow | null): void {
|
||||||
|
this.state.mainWindow = window
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerPort(): number {
|
||||||
|
return this.state.serverPort
|
||||||
|
}
|
||||||
|
|
||||||
|
setServerPort(port: number): void {
|
||||||
|
this.state.serverPort = port
|
||||||
|
}
|
||||||
|
|
||||||
|
isDevelopment(): boolean {
|
||||||
|
return this.state.isDev
|
||||||
|
}
|
||||||
|
|
||||||
|
setDevelopment(isDev: boolean): void {
|
||||||
|
this.state.isDev = isDev
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.state = {
|
||||||
|
mainWindow: null,
|
||||||
|
serverPort: 3001,
|
||||||
|
isDev: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const electronState = new ElectronState()
|
||||||
32
eslint.config.js
Normal file
32
eslint.config.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-unused-vars': [
|
||||||
|
'error',
|
||||||
|
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
24
index.html
Normal file
24
index.html
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>XCNote</title>
|
||||||
|
<script type="module">
|
||||||
|
if (import.meta.hot?.on) {
|
||||||
|
import.meta.hot.on('vite:error', (error) => {
|
||||||
|
if (error.err) {
|
||||||
|
console.error(
|
||||||
|
[error.err.message, error.err.frame].filter(Boolean).join('\n'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
nodemon.json
Normal file
10
nodemon.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"watch": ["api"],
|
||||||
|
"ext": "ts,mts,js,json",
|
||||||
|
"ignore": ["api/dist/*"],
|
||||||
|
"exec": "tsx api/server.ts",
|
||||||
|
"env": {
|
||||||
|
"NODE_ENV": "development"
|
||||||
|
},
|
||||||
|
"delay": 1000
|
||||||
|
}
|
||||||
16074
package-lock.json
generated
Normal file
16074
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
138
package.json
Normal file
138
package.json
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
{
|
||||||
|
"name": "xcnote",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "一个功能强大的本地 Markdown 笔记管理工具,支持时间追踪、任务管理、AI 集成等高级功能",
|
||||||
|
"keywords": [
|
||||||
|
"markdown",
|
||||||
|
"note",
|
||||||
|
"笔记",
|
||||||
|
"electron",
|
||||||
|
"react",
|
||||||
|
"typescript",
|
||||||
|
"时间追踪",
|
||||||
|
"任务管理"
|
||||||
|
],
|
||||||
|
"author": "Your Name",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/your-username/xcnote.git"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"main": "dist-electron/main.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"client:dev": "vite",
|
||||||
|
"build": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.api.json && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.api.json",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"server:dev": "nodemon",
|
||||||
|
"dev": "concurrently \"npm run client:dev\" \"npm run server:dev\"",
|
||||||
|
"watch:electron": "tsup --config tsup.electron.ts --watch",
|
||||||
|
"electron:dev": "concurrently -k \"npm run server:dev\" \"npm run client:dev\" \"wait-on tcp:3001 tcp:5173 && npm run watch:electron\"",
|
||||||
|
"build:electron": "tsup --config tsup.electron.ts",
|
||||||
|
"build:api": "tsup electron/server.ts --format esm --out-dir dist-api --clean --no-splitting --target esnext",
|
||||||
|
"electron:build": "npm run build && npm run build:electron && npm run build:api && electron-builder"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@milkdown/core": "^7.18.0",
|
||||||
|
"@milkdown/plugin-block": "^7.18.0",
|
||||||
|
"@milkdown/plugin-history": "^7.18.0",
|
||||||
|
"@milkdown/plugin-listener": "^7.18.0",
|
||||||
|
"@milkdown/plugin-math": "^7.5.9",
|
||||||
|
"@milkdown/plugin-prism": "^7.18.0",
|
||||||
|
"@milkdown/preset-commonmark": "^7.18.0",
|
||||||
|
"@milkdown/preset-gfm": "^7.18.0",
|
||||||
|
"@milkdown/react": "^7.18.0",
|
||||||
|
"@types/jszip": "^3.4.0",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/prismjs": "^1.26.5",
|
||||||
|
"axios": "^1.13.5",
|
||||||
|
"chokidar": "^5.0.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.1",
|
||||||
|
"electron-log": "^5.4.3",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"github-slugger": "^2.0.0",
|
||||||
|
"jszip": "^3.10.1",
|
||||||
|
"lucide-react": "^0.511.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"prismjs": "^1.30.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.3.0",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
"remark-breaks": "^4.0.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/node": "^22.19.13",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vercel/node": "^5.3.6",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"@vitest/ui": "^4.0.18",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"babel-plugin-react-dev-locator": "^1.0.0",
|
||||||
|
"concurrently": "^9.2.0",
|
||||||
|
"electron": "^40.2.1",
|
||||||
|
"electron-builder": "^26.7.0",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"happy-dom": "^20.7.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tsup": "^8.5.1",
|
||||||
|
"tsx": "^4.20.3",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.30.1",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^4.0.18",
|
||||||
|
"wait-on": "^9.0.3"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.xcnote.app",
|
||||||
|
"productName": "XCNote",
|
||||||
|
"directories": {
|
||||||
|
"output": "release"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"dist-electron/**/*",
|
||||||
|
"dist-api/**/*",
|
||||||
|
"shared/**/*",
|
||||||
|
"tools/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"asarUnpack": [
|
||||||
|
"tools/**/*"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": "nsis"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
postcss.config.js
Normal file
10
postcss.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
/** WARNING: DON'T EDIT THIS FILE */
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
public/background.png
Normal file
BIN
public/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 MiB |
4
public/favicon.svg
Normal file
4
public/favicon.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="32" height="32" fill="#0A0B0D"/>
|
||||||
|
<path d="M26.6677 23.7149H8.38057V20.6496H5.33301V8.38159H26.6677V23.7149ZM8.38057 20.6496H23.6201V11.4482H8.38057V20.6496ZM16.0011 16.0021L13.8461 18.1705L11.6913 16.0021L13.8461 13.8337L16.0011 16.0021ZM22.0963 16.0008L19.9414 18.1691L17.7865 16.0008L19.9414 13.8324L22.0963 16.0008Z" fill="#32F08C"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 453 B |
7
remote/.gitignore
vendored
Normal file
7
remote/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
logs/
|
||||||
|
.env
|
||||||
|
config/custom.json
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
367
remote/.trae/documents/ring-notification-plan.md
Normal file
367
remote/.trae/documents/ring-notification-plan.md
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
# Ring 通知功能实现计划
|
||||||
|
|
||||||
|
## 一、功能概述
|
||||||
|
|
||||||
|
### 1.1 使用场景
|
||||||
|
|
||||||
|
远程电脑执行长链任务(如 opencode、trae 等工具)时,通过 `ring.py` 脚本向主控电脑发送通知,提醒用户任务完成。
|
||||||
|
|
||||||
|
### 1.2 调用方式
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ring.py "任务完成"
|
||||||
|
python ring.py "编译成功" --title "Build"
|
||||||
|
python ring.py "下载完成" --sound
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 架构设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 主控电脑 (被控端) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐│
|
||||||
|
│ │ Ring Server (端口 3002) ││
|
||||||
|
│ │ - HTTP POST /ring ││
|
||||||
|
│ │ - 接收通知请求 ││
|
||||||
|
│ │ - 触发系统通知 (Windows Toast) ││
|
||||||
|
│ │ - 可选:播放提示音 ││
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘│
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
▲
|
||||||
|
│ HTTP POST
|
||||||
|
│
|
||||||
|
┌─────────────────────────────┴───────────────────────────────────┐
|
||||||
|
│ 远程电脑 (控制端) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐│
|
||||||
|
│ │ ring.py ││
|
||||||
|
│ │ - 发送 HTTP 请求到主控电脑 ││
|
||||||
|
│ │ - 支持自定义消息、标题、提示音 ││
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘│
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、实现步骤
|
||||||
|
|
||||||
|
### 步骤 1:创建 RingService 服务
|
||||||
|
|
||||||
|
**文件:** `src/services/ring/RingService.js`
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 使用 Windows Toast 通知 API
|
||||||
|
- 支持自定义标题和消息
|
||||||
|
- 可选播放提示音
|
||||||
|
|
||||||
|
**实现方式:**
|
||||||
|
- 使用 PowerShell 调用 Windows Toast 通知
|
||||||
|
- 或使用 `node-notifier` 库
|
||||||
|
|
||||||
|
### 步骤 2:创建 Ring Server
|
||||||
|
|
||||||
|
**文件:** `src/server/RingServer.js`
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 独立的 HTTP 服务器,监听端口 3002
|
||||||
|
- 提供 `/ring` POST 接口
|
||||||
|
- 调用 RingService 发送通知
|
||||||
|
|
||||||
|
**API 设计:**
|
||||||
|
```
|
||||||
|
POST /ring
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"message": "任务完成",
|
||||||
|
"title": "Ring", // 可选,默认 "Ring"
|
||||||
|
"sound": true // 可选,默认 false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:集成到 App.js
|
||||||
|
|
||||||
|
**修改文件:** `src/core/App.js`
|
||||||
|
|
||||||
|
**修改内容:**
|
||||||
|
- 注册 RingService
|
||||||
|
- 注册 RingServer
|
||||||
|
- 启动时同时启动 Ring Server
|
||||||
|
|
||||||
|
### 步骤 4:更新配置
|
||||||
|
|
||||||
|
**修改文件:** `src/config/schema.js`
|
||||||
|
|
||||||
|
**新增配置:**
|
||||||
|
```javascript
|
||||||
|
ring: {
|
||||||
|
port: { type: 'number', default: 3002 },
|
||||||
|
enabled: { type: 'boolean', default: true }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 5:创建 ring.py 客户端脚本
|
||||||
|
|
||||||
|
**文件:** `scripts/ring.py`
|
||||||
|
|
||||||
|
**功能:**
|
||||||
|
- 命令行参数解析
|
||||||
|
- 发送 HTTP 请求到主控电脑
|
||||||
|
- 支持配置目标地址
|
||||||
|
|
||||||
|
**使用方式:**
|
||||||
|
```bash
|
||||||
|
python ring.py "消息内容"
|
||||||
|
python ring.py "消息" --title "标题" --sound
|
||||||
|
python ring.py "消息" --host 192.168.1.100 --port 3002
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、文件清单
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
| 文件路径 | 说明 |
|
||||||
|
|---------|------|
|
||||||
|
| `src/services/ring/RingService.js` | 通知服务,调用系统通知 |
|
||||||
|
| `src/services/ring/index.js` | 服务导出 |
|
||||||
|
| `src/server/RingServer.js` | 独立 HTTP 服务器 |
|
||||||
|
| `scripts/ring.py` | Python 客户端脚本 |
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
| 文件路径 | 修改内容 |
|
||||||
|
|---------|---------|
|
||||||
|
| `src/core/App.js` | 注册并启动 Ring 服务 |
|
||||||
|
| `src/config/schema.js` | 添加 ring 配置项 |
|
||||||
|
| `config/default.json` | 添加 ring 默认配置 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、详细实现
|
||||||
|
|
||||||
|
### 4.1 RingService.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const logger = require('../../utils/logger');
|
||||||
|
|
||||||
|
class RingService {
|
||||||
|
async notify({ message, title = 'Ring', sound = false }) {
|
||||||
|
// 使用 PowerShell 发送 Windows Toast 通知
|
||||||
|
const psScript = `
|
||||||
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
||||||
|
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
|
||||||
|
|
||||||
|
$template = @"
|
||||||
|
<toast duration="short">
|
||||||
|
<visual>
|
||||||
|
<binding template="ToastText02">
|
||||||
|
<text id="1">${title}</text>
|
||||||
|
<text id="2">${message}</text>
|
||||||
|
</binding>
|
||||||
|
</visual>
|
||||||
|
${sound ? '<audio src="ms-winsoundevent:Notification.Default" loop="false"/>' : '<audio silent="true"/>'}
|
||||||
|
</toast>
|
||||||
|
"@
|
||||||
|
|
||||||
|
$xml = New-Object Windows.Data.Xml.Dom.XmlDocument
|
||||||
|
$xml.LoadXml($template)
|
||||||
|
$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
|
||||||
|
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Ring").Show($toast)
|
||||||
|
`;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ps = spawn('powershell', ['-NoProfile', '-Command', psScript]);
|
||||||
|
ps.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
logger.info('Ring notification sent', { title, message });
|
||||||
|
resolve(true);
|
||||||
|
} else {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ps.on('error', (err) => {
|
||||||
|
logger.error('Ring notification failed', { error: err.message });
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = RingService;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 RingServer.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const http = require('http');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
class RingServer {
|
||||||
|
constructor(config = {}) {
|
||||||
|
this.port = config.port || 3002;
|
||||||
|
this.host = config.host || '0.0.0.0';
|
||||||
|
this.server = null;
|
||||||
|
this.ringService = config.ringService;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.server = http.createServer((req, res) => {
|
||||||
|
if (req.method === 'POST' && req.url === '/ring') {
|
||||||
|
this.handleRing(req, res);
|
||||||
|
} else {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not Found');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.server.listen(this.port, this.host, () => {
|
||||||
|
logger.info('Ring server started', { port: this.port });
|
||||||
|
resolve({ port: this.port, host: this.host });
|
||||||
|
});
|
||||||
|
this.server.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleRing(req, res) {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', chunk => body += chunk);
|
||||||
|
req.on('end', async () => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(body);
|
||||||
|
await this.ringService.notify(data);
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ success: true }));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (this.server) {
|
||||||
|
this.server.close(() => {
|
||||||
|
logger.info('Ring server stopped');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = RingServer;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 ring.py
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import argparse
|
||||||
|
import requests
|
||||||
|
import sys
|
||||||
|
|
||||||
|
DEFAULT_HOST = "localhost"
|
||||||
|
DEFAULT_PORT = 3002
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='Send notification to remote computer')
|
||||||
|
parser.add_argument('message', help='Notification message')
|
||||||
|
parser.add_argument('--title', '-t', default='Ring', help='Notification title')
|
||||||
|
parser.add_argument('--sound', '-s', action='store_true', help='Play notification sound')
|
||||||
|
parser.add_argument('--host', default=DEFAULT_HOST, help='Target host')
|
||||||
|
parser.add_argument('--port', type=int, default=DEFAULT_PORT, help='Target port')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"http://{args.host}:{args.port}/ring",
|
||||||
|
json={
|
||||||
|
"message": args.message,
|
||||||
|
"title": args.title,
|
||||||
|
"sound": args.sound
|
||||||
|
},
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("Notification sent successfully")
|
||||||
|
else:
|
||||||
|
print(f"Failed to send notification: {response.text}")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、使用示例
|
||||||
|
|
||||||
|
### 5.1 基本使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 发送简单通知
|
||||||
|
python ring.py "任务完成"
|
||||||
|
|
||||||
|
# 自定义标题
|
||||||
|
python ring.py "编译成功" --title "Build"
|
||||||
|
|
||||||
|
# 带提示音
|
||||||
|
python ring.py "下载完成" --sound
|
||||||
|
|
||||||
|
# 指定目标主机
|
||||||
|
python ring.py "远程任务完成" --host 192.168.1.100 --port 3002
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 在脚本中使用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 长时间任务完成后通知
|
||||||
|
npm run build && python ring.py "Build completed" --sound
|
||||||
|
|
||||||
|
# 或者在脚本中
|
||||||
|
long_running_command
|
||||||
|
python ring.py "Command finished: $?"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、配置说明
|
||||||
|
|
||||||
|
### config/default.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ring": {
|
||||||
|
"enabled": true,
|
||||||
|
"port": 3002
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、安全考虑
|
||||||
|
|
||||||
|
1. **内网使用** - Ring Server 默认只监听内网,不暴露到公网
|
||||||
|
2. **可选认证** - 后续可添加简单 token 认证
|
||||||
|
3. **频率限制** - 防止通知轰炸
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、测试计划
|
||||||
|
|
||||||
|
1. 启动应用,验证 Ring Server 在 3002 端口启动
|
||||||
|
2. 使用 curl 测试:`curl -X POST http://localhost:3002/ring -H "Content-Type: application/json" -d '{"message":"test"}'`
|
||||||
|
3. 验证 Windows 通知弹出
|
||||||
|
4. 测试 ring.py 脚本
|
||||||
|
5. 测试远程主机调用
|
||||||
587
remote/README.md
Normal file
587
remote/README.md
Normal file
@@ -0,0 +1,587 @@
|
|||||||
|
# 远程屏幕监控系统
|
||||||
|
|
||||||
|
一个基于 Node.js 的实时远程屏幕监控和控制系统,支持鼠标和键盘的远程操作、剪贴板同步、文件传输等功能。
|
||||||
|
|
||||||
|
## 功能特点
|
||||||
|
|
||||||
|
- 🎥 **实时屏幕流** - 低延迟的屏幕视频流传输,使用 FFmpeg 进行 MPEG1 编码
|
||||||
|
- 🖱️ **鼠标控制** - 远程鼠标移动、点击和滚轮操作
|
||||||
|
- ⌨️ **键盘控制** - 远程键盘输入,支持特殊键和组合键
|
||||||
|
- 📋 **剪贴板同步** - 支持文本和图片的双向剪贴板同步
|
||||||
|
- **文件传输** - 支持大文件分块上传、下载和文件管理
|
||||||
|
- 🔒 **安全认证** - bcrypt 密码哈希 + JWT Token 认证
|
||||||
|
- 🌐 **内网穿透** - 集成 FRP 客户端,支持外网访问
|
||||||
|
- 📦 **Git 服务** - 可选集成 Gitea,提供代码托管服务
|
||||||
|
- 📝 **日志系统** - 完整的运行日志记录
|
||||||
|
- ⚙️ **灵活配置** - 支持配置文件和环境变量配置
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Node.js >= 16.0.0
|
||||||
|
- Windows 操作系统(输入控制功能需要)
|
||||||
|
- FFmpeg(已内置安装)
|
||||||
|
|
||||||
|
### 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# 或
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 http://localhost:3000 查看屏幕流。
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
|
||||||
|
配置文件位于 `config/default.json`,可配置项包括:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"port": 3000,
|
||||||
|
"host": "0.0.0.0"
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"fps": 30,
|
||||||
|
"bitrate": "4000k",
|
||||||
|
"gop": 10,
|
||||||
|
"preset": "ultrafast",
|
||||||
|
"resolution": {
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"mouseEnabled": true,
|
||||||
|
"keyboardEnabled": true,
|
||||||
|
"sensitivity": 1.0
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"password": "",
|
||||||
|
"tokenExpiry": 3600
|
||||||
|
},
|
||||||
|
"frp": {
|
||||||
|
"enabled": true,
|
||||||
|
"frpcPath": "./frp/frpc.exe",
|
||||||
|
"configPath": "./frp/frpc.toml"
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置说明
|
||||||
|
|
||||||
|
| 配置项 | 说明 | 默认值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| server.port | 服务器端口 | 3000 |
|
||||||
|
| server.host | 服务器监听地址 | 0.0.0.0 |
|
||||||
|
| stream.fps | 视频帧率 | 30 |
|
||||||
|
| stream.bitrate | 视频码率 | 4000k |
|
||||||
|
| stream.gop | GOP 大小(关键帧间隔) | 10 |
|
||||||
|
| stream.preset | 编码预设 | ultrafast |
|
||||||
|
| stream.resolution | 视频分辨率 | 1920x1080 |
|
||||||
|
| input.mouseEnabled | 是否启用鼠标控制 | true |
|
||||||
|
| input.keyboardEnabled | 是否启用键盘控制 | true |
|
||||||
|
| input.sensitivity | 鼠标灵敏度 | 1.0 |
|
||||||
|
| security.password | 访问密码(空表示不需要密码) | "" |
|
||||||
|
| security.tokenExpiry | Token 有效期(秒) | 3600 |
|
||||||
|
| frp.enabled | 是否启用 FRP 内网穿透 | true |
|
||||||
|
| gitea.enabled | 是否启用 Gitea 服务 | true |
|
||||||
|
|
||||||
|
### 环境变量配置
|
||||||
|
|
||||||
|
所有配置项都可以通过环境变量覆盖,格式为 `REMOTE_<SECTION>_<KEY>`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 设置服务器端口
|
||||||
|
REMOTE_SERVER_PORT=8080
|
||||||
|
|
||||||
|
# 设置密码
|
||||||
|
REMOTE_SECURITY_PASSWORD=your_password
|
||||||
|
|
||||||
|
# 设置 JWT 密钥
|
||||||
|
JWT_SECRET=your_jwt_secret
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 认证接口
|
||||||
|
|
||||||
|
#### 登录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"password": "your_password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:设置 `auth` Cookie 并重定向到首页。
|
||||||
|
|
||||||
|
#### API 登录
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"password": "your_password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"token": "jwt_token_here"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 验证 Token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/auth/verify
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"valid": true,
|
||||||
|
"userId": "default-user"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 鼠标控制
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/input/mouse/move
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"x": 100,
|
||||||
|
"y": 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/input/mouse/down
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"button": "left" # "left", "right" 或 "middle"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/input/mouse/up
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"button": "left"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/input/mouse/click
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"button": "left"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/input/mouse/wheel
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"delta": 120 # 正值向上滚动,负值向下滚动
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 键盘控制
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/input/keyboard/down
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"key": "enter"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/input/keyboard/up
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"key": "enter"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/input/keyboard/press
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"key": "enter"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/input/keyboard/type
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"text": "Hello World"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
支持的特殊键:`enter`, `backspace`, `tab`, `escape`, `delete`, `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right`, `f1`-`f12`, `ctrl`, `alt`, `shift`, `win`, `space`
|
||||||
|
|
||||||
|
### 流媒体接口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/stream/info
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"stream": {
|
||||||
|
"status": "running",
|
||||||
|
"resolution": {
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080
|
||||||
|
},
|
||||||
|
"fps": 30,
|
||||||
|
"bitrate": "4000k",
|
||||||
|
"gop": 10,
|
||||||
|
"encoder": "mpeg1video"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/stream/start
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/stream/stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 文件传输接口
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/files
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "example.txt",
|
||||||
|
"size": 1024,
|
||||||
|
"modified": "2026-03-05T10:00:00.000Z",
|
||||||
|
"type": ".txt"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/files/browse?path=relative/path
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"name": "folder",
|
||||||
|
"isDirectory": true,
|
||||||
|
"size": 0,
|
||||||
|
"modified": "2026-03-05T10:00:00.000Z",
|
||||||
|
"type": "directory"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"currentPath": "relative/path",
|
||||||
|
"parentPath": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/files/upload/start
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"filename": "large_file.zip",
|
||||||
|
"totalChunks": 10,
|
||||||
|
"fileSize": 50000000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileId": "abc123",
|
||||||
|
"chunkSize": 5242880,
|
||||||
|
"message": "Upload session started"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/files/upload/chunk
|
||||||
|
Content-Type: multipart/form-data
|
||||||
|
|
||||||
|
fileId: abc123
|
||||||
|
chunkIndex: 0
|
||||||
|
chunk: <binary data>
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/files/upload/merge
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"fileId": "abc123",
|
||||||
|
"totalChunks": 10,
|
||||||
|
"filename": "large_file.zip"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/files/:filename
|
||||||
|
```
|
||||||
|
|
||||||
|
支持 Range 请求头进行断点续传。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DELETE /api/files/:filename
|
||||||
|
```
|
||||||
|
|
||||||
|
### WebSocket 消息类型
|
||||||
|
|
||||||
|
连接地址:`ws://localhost:3000/ws`
|
||||||
|
|
||||||
|
#### 客户端发送
|
||||||
|
|
||||||
|
| 类型 | 说明 | 数据 |
|
||||||
|
|------|------|------|
|
||||||
|
| mouseMove | 鼠标移动 | `{ type: "mouseMove", x: 100, y: 200 }` |
|
||||||
|
| mouseDown | 鼠标按下 | `{ type: "mouseDown", button: "left" }` |
|
||||||
|
| mouseUp | 鼠标释放 | `{ type: "mouseUp", button: "left" }` |
|
||||||
|
| mouseWheel | 鼠标滚轮 | `{ type: "mouseWheel", delta: 120 }` |
|
||||||
|
| keyDown | 键盘按下 | `{ type: "keyDown", key: "enter" }` |
|
||||||
|
| keyUp | 键盘释放 | `{ type: "keyUp", key: "enter" }` |
|
||||||
|
| clipboardGet | 获取剪贴板 | `{ type: "clipboardGet" }` |
|
||||||
|
| clipboardSet | 设置剪贴板 | `{ type: "clipboardSet", contentType: "text", data: "content" }` |
|
||||||
|
|
||||||
|
#### 服务端发送
|
||||||
|
|
||||||
|
| 类型 | 说明 | 数据 |
|
||||||
|
|------|------|------|
|
||||||
|
| screenInfo | 屏幕信息 | `{ type: "screenInfo", width: 1920, height: 1080 }` |
|
||||||
|
| clipboardData | 剪贴板数据 | `{ type: "clipboardData", contentType: "text", data: "content", size: 100 }` |
|
||||||
|
| clipboardResult | 剪贴板操作结果 | `{ type: "clipboardResult", success: true }` |
|
||||||
|
| clipboardTooLarge | 剪贴板内容过大 | `{ type: "clipboardTooLarge", size: 1000000 }` |
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
remote/
|
||||||
|
├── src/
|
||||||
|
│ ├── config/ # 配置管理
|
||||||
|
│ │ ├── index.js # 配置加载器
|
||||||
|
│ │ └── schema.js # 配置验证模式
|
||||||
|
│ ├── controllers/ # 控制器层
|
||||||
|
│ │ ├── AuthController.js # 认证控制器
|
||||||
|
│ │ ├── InputController.js# 输入控制器
|
||||||
|
│ │ └── StreamController.js# 流媒体控制器
|
||||||
|
│ ├── core/ # 核心模块
|
||||||
|
│ │ ├── App.js # 应用主类
|
||||||
|
│ │ ├── Container.js # 依赖注入容器
|
||||||
|
│ │ ├── EventBus.js # 事件总线
|
||||||
|
│ │ ├── ErrorHandler.js # 错误处理器
|
||||||
|
│ │ └── events.js # 事件类型定义
|
||||||
|
│ ├── middlewares/ # 中间件
|
||||||
|
│ │ ├── auth.js # 认证中间件
|
||||||
|
│ │ ├── error.js # 错误处理中间件
|
||||||
|
│ │ └── rateLimit.js # 限流中间件
|
||||||
|
│ ├── routes/ # 路由层
|
||||||
|
│ │ ├── index.js # 路由汇总
|
||||||
|
│ │ ├── auth.js # 认证路由
|
||||||
|
│ │ ├── files.js # 文件路由
|
||||||
|
│ │ ├── input.js # 输入路由
|
||||||
|
│ │ └── stream.js # 流媒体路由
|
||||||
|
│ ├── server/ # 服务器层
|
||||||
|
│ │ ├── Server.js # HTTP 服务器
|
||||||
|
│ │ ├── WebSocketServer.js# WebSocket 服务器
|
||||||
|
│ │ ├── StreamBroadcaster.js# 流广播器
|
||||||
|
│ │ ├── InputHandler.js # 输入处理器
|
||||||
|
│ │ └── messageTypes.js # 消息类型定义
|
||||||
|
│ ├── services/ # 服务层
|
||||||
|
│ │ ├── auth/ # 认证服务
|
||||||
|
│ │ │ ├── AuthService.js
|
||||||
|
│ │ │ └── TokenManager.js
|
||||||
|
│ │ ├── clipboard/ # 剪贴板服务
|
||||||
|
│ │ │ └── ClipboardService.js
|
||||||
|
│ │ ├── file/ # 文件服务
|
||||||
|
│ │ │ └── FileService.js
|
||||||
|
│ │ ├── input/ # 输入服务
|
||||||
|
│ │ │ ├── InputService.js
|
||||||
|
│ │ │ └── PowerShellInput.js
|
||||||
|
│ │ ├── network/ # 网络服务
|
||||||
|
│ │ │ ├── FRPService.js
|
||||||
|
│ │ │ └── GiteaService.js
|
||||||
|
│ │ ├── stream/ # 流媒体服务
|
||||||
|
│ │ │ ├── FFmpegEncoder.js
|
||||||
|
│ │ │ ├── ScreenCapture.js
|
||||||
|
│ │ │ └── StreamService.js
|
||||||
|
│ │ └── index.js # 服务汇总
|
||||||
|
│ ├── utils/ # 工具类
|
||||||
|
│ │ ├── config.js # 配置工具
|
||||||
|
│ │ ├── logger.js # 日志工具
|
||||||
|
│ │ └── paths.js # 路径工具
|
||||||
|
│ └── index.js # 应用入口
|
||||||
|
├── config/
|
||||||
|
│ └── default.json # 默认配置
|
||||||
|
├── docs/ # 文档目录
|
||||||
|
│ ├── 开发/ # 开发文档
|
||||||
|
│ └── 指南/ # 使用指南
|
||||||
|
├── frp/ # FRP 内网穿透
|
||||||
|
│ ├── frpc.exe
|
||||||
|
│ ├── frpc.toml
|
||||||
|
│ └── frpc-runtime.toml
|
||||||
|
├── gitea/ # Gitea Git 服务
|
||||||
|
├── logs/ # 日志目录
|
||||||
|
│ ├── combined.log # 所有日志
|
||||||
|
│ └── error.log # 错误日志
|
||||||
|
├── public/ # 前端静态文件
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── main.css
|
||||||
|
│ ├── js/
|
||||||
|
│ │ ├── app.js # 应用入口
|
||||||
|
│ │ ├── file-panel.js # 文件传输面板
|
||||||
|
│ │ ├── input.js # 输入处理
|
||||||
|
│ │ ├── jsmpeg.min.js # JSMpeg 播放器
|
||||||
|
│ │ ├── player.js # 视频播放器
|
||||||
|
│ │ └── utils.js # 工具函数
|
||||||
|
│ └── index.html
|
||||||
|
├── scripts/
|
||||||
|
│ └── migrate-password.js # 密码迁移脚本
|
||||||
|
├── uploads/ # 上传文件目录
|
||||||
|
├── .gitignore
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## 构建与部署
|
||||||
|
|
||||||
|
### 开发环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生产环境打包
|
||||||
|
|
||||||
|
使用 pkg 打包为可执行文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
打包后的文件位于 `dist/` 目录:
|
||||||
|
- `remote-screen-monitor.exe` - 主程序
|
||||||
|
- `public/` - 前端静态文件
|
||||||
|
- `config/` - 配置文件
|
||||||
|
- `frp/` - FRP 客户端
|
||||||
|
- `ffmpeg.exe` - FFmpeg 编码器
|
||||||
|
|
||||||
|
### Windows 服务安装
|
||||||
|
|
||||||
|
使用 NSSM 将应用安装为 Windows 服务:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 安装服务
|
||||||
|
nssm install RemoteApp "C:\path\to\remote-screen-monitor.exe"
|
||||||
|
|
||||||
|
# 设置工作目录
|
||||||
|
nssm set RemoteApp AppDirectory "C:\path\to\app"
|
||||||
|
|
||||||
|
# 设置启动类型
|
||||||
|
nssm set RemoteApp Start SERVICE_AUTO_START
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
nssm start RemoteApp
|
||||||
|
```
|
||||||
|
|
||||||
|
详细说明请参考 [NSSM使用指南](docs/指南/NSSM使用指南.md)。
|
||||||
|
|
||||||
|
## 安全注意事项
|
||||||
|
|
||||||
|
⚠️ **重要提示**:
|
||||||
|
- 在公共网络上使用时,务必设置密码保护
|
||||||
|
- 建议使用 HTTPS/WSS(通过反向代理如 Nginx)
|
||||||
|
- 不要使用 root/Administrator 权限运行此服务
|
||||||
|
- 定期更换密码和 JWT 密钥
|
||||||
|
|
||||||
|
### 密码安全
|
||||||
|
|
||||||
|
系统支持 bcrypt 密码哈希。使用迁移脚本生成安全的密码哈希:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/migrate-password.js
|
||||||
|
```
|
||||||
|
|
||||||
|
将生成的哈希值设置到环境变量 `REMOTE_SECURITY_PASSWORD` 中。
|
||||||
|
|
||||||
|
## 故障排除
|
||||||
|
|
||||||
|
### 鼠标/键盘控制不工作
|
||||||
|
|
||||||
|
确保以管理员权限运行(Windows),某些操作可能需要提升的权限。
|
||||||
|
|
||||||
|
### 视频流卡顿
|
||||||
|
|
||||||
|
- 降低 FPS 或分辨率
|
||||||
|
- 降低视频码率
|
||||||
|
- 检查网络带宽
|
||||||
|
|
||||||
|
### 日志查看
|
||||||
|
|
||||||
|
日志文件位于 `logs/` 目录:
|
||||||
|
- `logs/combined.log` - 所有日志
|
||||||
|
- `logs/error.log` - 错误日志
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- **后端**: Node.js, Express 5.x, ws (WebSocket), winston (日志)
|
||||||
|
- **前端**: HTML5 Canvas, JSMpeg 播放器
|
||||||
|
- **视频编码**: FFmpeg (mpeg1video)
|
||||||
|
- **输入模拟**: PowerShell + Windows API (user32.dll)
|
||||||
|
- **认证**: bcrypt, jsonwebtoken
|
||||||
|
- **文件处理**: multer, fs-extra
|
||||||
|
- **内网穿透**: FRP (Fast Reverse Proxy)
|
||||||
|
- **打包**: pkg
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
ISC
|
||||||
33
remote/config/default.json
Normal file
33
remote/config/default.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"port": 3000,
|
||||||
|
"host": "0.0.0.0"
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"fps": 30,
|
||||||
|
"bitrate": "4000k",
|
||||||
|
"gop": 10,
|
||||||
|
"preset": "ultrafast",
|
||||||
|
"resolution": {
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"input": {
|
||||||
|
"mouseEnabled": true,
|
||||||
|
"keyboardEnabled": true,
|
||||||
|
"sensitivity": 1.0
|
||||||
|
},
|
||||||
|
"security": {
|
||||||
|
"password": "wzw20040525",
|
||||||
|
"tokenExpiry": 3600
|
||||||
|
},
|
||||||
|
"frp": {
|
||||||
|
"enabled": true,
|
||||||
|
"frpcPath": "./frp/frpc.exe",
|
||||||
|
"configPath": "./frp/frpc.toml"
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
784
remote/docs/开发/API文档.md
Normal file
784
remote/docs/开发/API文档.md
Normal file
@@ -0,0 +1,784 @@
|
|||||||
|
# API 文档
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
|
||||||
|
- [认证接口](#认证接口)
|
||||||
|
- [输入控制接口](#输入控制接口)
|
||||||
|
- [流媒体接口](#流媒体接口)
|
||||||
|
- [文件传输接口](#文件传输接口)
|
||||||
|
- [WebSocket 消息类型](#websocket-消息类型)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 认证接口
|
||||||
|
|
||||||
|
### POST /login
|
||||||
|
|
||||||
|
Web 登录接口,用于用户身份认证。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"password": "your_password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| password | string | 否 | 用户密码(如果服务器未配置密码则不需要) |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
成功响应(200):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"message": "Authentication disabled"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败响应(400):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "Password is required"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败响应(401):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "Invalid password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/auth/login
|
||||||
|
|
||||||
|
API 登录接口,功能与 `/login` 相同。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"password": "your_password"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
成功响应(200):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/auth/verify
|
||||||
|
|
||||||
|
Token 验证接口,用于验证 JWT Token 是否有效。
|
||||||
|
|
||||||
|
**请求头**
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
成功响应(200):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"valid": true,
|
||||||
|
"userId": "default-user"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败响应(401):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"valid": false,
|
||||||
|
"error": "No token provided"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 输入控制接口
|
||||||
|
|
||||||
|
所有输入控制接口需要在请求头中携带有效的 Token:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 鼠标操作
|
||||||
|
|
||||||
|
#### POST /api/input/mouse/move
|
||||||
|
|
||||||
|
移动鼠标到指定坐标。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"x": 100,
|
||||||
|
"y": 200
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| x | number | 是 | 目标 X 坐标 |
|
||||||
|
| y | number | 是 | 目标 Y 坐标 |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
成功响应(200):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败响应(400):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "Invalid coordinates"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/input/mouse/down
|
||||||
|
|
||||||
|
按下鼠标按键。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"button": "left"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| button | string | 否 | left | 鼠标按键:`left`、`right`、`middle` |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/input/mouse/up
|
||||||
|
|
||||||
|
释放鼠标按键。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"button": "left"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| button | string | 否 | left | 鼠标按键:`left`、`right`、`middle` |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/input/mouse/click
|
||||||
|
|
||||||
|
点击鼠标按键。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"button": "left"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| button | string | 否 | left | 鼠标按键:`left`、`right`、`middle` |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/input/mouse/wheel
|
||||||
|
|
||||||
|
鼠标滚轮滚动。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"delta": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| delta | number | 是 | 滚动量(正数向上,负数向下) |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 键盘操作
|
||||||
|
|
||||||
|
#### POST /api/input/keyboard/down
|
||||||
|
|
||||||
|
按下键盘按键。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "a"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| key | string | 是 | 按键名称(如 `a`、`enter`、`ctrl`、`shift` 等) |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/input/keyboard/up
|
||||||
|
|
||||||
|
释放键盘按键。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "a"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| key | string | 是 | 按键名称 |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/input/keyboard/press
|
||||||
|
|
||||||
|
按下并释放键盘按键(单击)。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "enter"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| key | string | 是 | 按键名称 |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### POST /api/input/keyboard/type
|
||||||
|
|
||||||
|
输入文本字符串。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "Hello World"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| text | string | 是 | 要输入的文本内容 |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 流媒体接口
|
||||||
|
|
||||||
|
### GET /api/stream/info
|
||||||
|
|
||||||
|
获取流媒体信息。
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"stream": {
|
||||||
|
"status": "running",
|
||||||
|
"resolution": {
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080
|
||||||
|
},
|
||||||
|
"fps": 30,
|
||||||
|
"bitrate": 2000,
|
||||||
|
"gop": 60,
|
||||||
|
"encoder": "libx264"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| status | string | 流状态:`running` 或 `stopped` |
|
||||||
|
| resolution | object | 屏幕分辨率 |
|
||||||
|
| fps | number | 帧率 |
|
||||||
|
| bitrate | number | 比特率(kbps) |
|
||||||
|
| gop | number | GOP 大小 |
|
||||||
|
| encoder | string | 编码器名称 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/stream/start
|
||||||
|
|
||||||
|
启动流媒体。
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Stream started"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
已运行时响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Stream is already running"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/stream/stop
|
||||||
|
|
||||||
|
停止流媒体。
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Stream stopped"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
未运行时响应:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Stream is not running"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件传输接口
|
||||||
|
|
||||||
|
### GET /api/files
|
||||||
|
|
||||||
|
获取文件列表。
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "example.txt",
|
||||||
|
"size": 1024,
|
||||||
|
"modifiedTime": "2024-01-01T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/files/browse
|
||||||
|
|
||||||
|
浏览目录内容。
|
||||||
|
|
||||||
|
**查询参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| path | string | 否 | 目录路径(默认为根目录) |
|
||||||
|
|
||||||
|
**请求示例**
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/files/browse?path=documents
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"path": "documents",
|
||||||
|
"directories": ["folder1", "folder2"],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "file1.txt",
|
||||||
|
"size": 1024,
|
||||||
|
"modifiedTime": "2024-01-01T12:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/files/upload/start
|
||||||
|
|
||||||
|
开始上传会话(分块上传第一步)。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"filename": "example.zip",
|
||||||
|
"totalChunks": 10,
|
||||||
|
"fileSize": 52428800
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| filename | string | 是 | 文件名 |
|
||||||
|
| totalChunks | number | 是 | 总分块数 |
|
||||||
|
| fileSize | number | 是 | 文件总大小(字节) |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileId": "a1b2c3d4e5f6g7h8",
|
||||||
|
"chunkSize": 5242880,
|
||||||
|
"message": "Upload session started"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/files/upload/chunk
|
||||||
|
|
||||||
|
上传文件分块(分块上传第二步)。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
Content-Type: `multipart/form-data`
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| fileId | string | 上传会话 ID |
|
||||||
|
| chunkIndex | number | 分块索引(从 0 开始) |
|
||||||
|
| chunk | file | 分块数据 |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"chunkIndex": 0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### POST /api/files/upload/merge
|
||||||
|
|
||||||
|
合并文件分块(分块上传第三步)。
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fileId": "a1b2c3d4e5f6g7h8",
|
||||||
|
"totalChunks": 10,
|
||||||
|
"filename": "example.zip"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| fileId | string | 是 | 上传会话 ID |
|
||||||
|
| totalChunks | number | 是 | 总分块数 |
|
||||||
|
| filename | string | 是 | 最终文件名 |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"filename": "example.zip"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### GET /api/files/:filename
|
||||||
|
|
||||||
|
下载文件。
|
||||||
|
|
||||||
|
**路径参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| filename | string | 文件名 |
|
||||||
|
|
||||||
|
**请求头**
|
||||||
|
|
||||||
|
支持 Range 请求,可用于断点续传:
|
||||||
|
|
||||||
|
```
|
||||||
|
Range: bytes=0-1023
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
成功响应(200 或 206):
|
||||||
|
- Content-Type: `application/octet-stream`
|
||||||
|
- Accept-Ranges: `bytes`
|
||||||
|
- Content-Length: 文件大小
|
||||||
|
- Content-Range: 范围(仅 206 响应)
|
||||||
|
|
||||||
|
失败响应(404):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "File not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### DELETE /api/files/:filename
|
||||||
|
|
||||||
|
删除文件。
|
||||||
|
|
||||||
|
**路径参数**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| filename | string | 文件名 |
|
||||||
|
|
||||||
|
**响应示例**
|
||||||
|
|
||||||
|
成功响应(200):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
失败响应(404):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "File not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WebSocket 消息类型
|
||||||
|
|
||||||
|
### 客户端发送的消息类型
|
||||||
|
|
||||||
|
| 消息类型 | 说明 | 数据格式 |
|
||||||
|
|----------|------|----------|
|
||||||
|
| `mouseMove` | 鼠标移动 | `{ x: number, y: number }` |
|
||||||
|
| `mouseDown` | 鼠标按下 | `{ button: 'left' \| 'right' \| 'middle' }` |
|
||||||
|
| `mouseUp` | 鼠标释放 | `{ button: 'left' \| 'right' \| 'middle' }` |
|
||||||
|
| `mouseWheel` | 鼠标滚轮 | `{ delta: number }` |
|
||||||
|
| `keyDown` | 键盘按下 | `{ key: string }` |
|
||||||
|
| `keyUp` | 键盘释放 | `{ key: string }` |
|
||||||
|
| `clipboardGet` | 获取剪贴板内容 | 无额外数据 |
|
||||||
|
| `clipboardSet` | 设置剪贴板内容 | `{ content: string }` |
|
||||||
|
|
||||||
|
### 服务端发送的消息类型
|
||||||
|
|
||||||
|
| 消息类型 | 说明 | 数据格式 |
|
||||||
|
|----------|------|----------|
|
||||||
|
| `screenInfo` | 屏幕信息 | `{ width: number, height: number }` |
|
||||||
|
| `clipboardData` | 剪贴板数据 | `{ content: string }` |
|
||||||
|
| `clipboardResult` | 剪贴板操作结果 | `{ success: boolean }` |
|
||||||
|
| `clipboardTooLarge` | 剪贴板数据过大 | `{ message: string }` |
|
||||||
|
|
||||||
|
### 消息格式示例
|
||||||
|
|
||||||
|
**客户端发送鼠标移动:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "mouseMove",
|
||||||
|
"data": {
|
||||||
|
"x": 100,
|
||||||
|
"y": 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**客户端发送键盘按键:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "keyDown",
|
||||||
|
"data": {
|
||||||
|
"key": "ctrl"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**服务端发送屏幕信息:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "screenInfo",
|
||||||
|
"data": {
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**客户端请求剪贴板:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "clipboardGet"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**服务端返回剪贴板数据:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "clipboardData",
|
||||||
|
"data": {
|
||||||
|
"content": "复制的文本内容"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误响应格式
|
||||||
|
|
||||||
|
所有接口在发生错误时返回统一的错误格式:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": "错误描述信息"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
常见 HTTP 状态码:
|
||||||
|
|
||||||
|
| 状态码 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| 200 | 请求成功 |
|
||||||
|
| 400 | 请求参数错误 |
|
||||||
|
| 401 | 未授权(Token 无效或过期) |
|
||||||
|
| 404 | 资源不存在 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
382
remote/docs/开发/Electron迁移计划.md
Normal file
382
remote/docs/开发/Electron迁移计划.md
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
# Electron 客户端迁移计划
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
将现有的 `remote-screen-monitor` Web 应用迁移为 Electron 桌面客户端,解决浏览器安全限制导致的剪贴板和文件上传功能受限问题。
|
||||||
|
|
||||||
|
## 当前问题分析
|
||||||
|
|
||||||
|
### 浏览器安全限制
|
||||||
|
|
||||||
|
| 功能 | 当前状态 | 限制原因 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 剪贴板读取 | ❌ 受限 | `navigator.clipboard.readText()` 需要安全上下文 (HTTPS/localhost) |
|
||||||
|
| 剪贴板写入 | ❌ 受限 | 同上 |
|
||||||
|
| 文件选择 | ⚠️ 部分可用 | 只能通过 `<input type="file">` 选择,无法直接访问文件系统 |
|
||||||
|
| 文件下载 | ⚠️ 部分可用 | 只能通过 Blob 下载,无法指定保存位置 |
|
||||||
|
| 目录浏览 | ❌ 不可用 | File System Access API 需要安全上下文 |
|
||||||
|
|
||||||
|
### 受影响代码位置
|
||||||
|
|
||||||
|
- `public/index.html` - 剪贴板同步逻辑 (第 47-51, 93-97 行)
|
||||||
|
- `public/js/file-panel.js` - 文件传输面板
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 迁移方案
|
||||||
|
|
||||||
|
### 架构设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Electron 主进程 (Main Process) │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||||
|
│ │ 剪贴板服务 │ │ 文件服务 │ │ 窗口管理 & 生命周期 │ │
|
||||||
|
│ │ - readText │ │ - 浏览目录 │ │ - BrowserWindow │ │
|
||||||
|
│ │ - writeText │ │ - 选择文件 │ │ - 托盘图标 │ │
|
||||||
|
│ │ - readImage │ │ - 保存文件 │ │ - 系统菜单 │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ IPC Handler │
|
||||||
|
└──────────────────────────┬──────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
IPC (进程间通信)
|
||||||
|
│
|
||||||
|
┌──────────────────────────▼──────────────────────────────────────┐
|
||||||
|
│ Preload Script (预加载脚本) │
|
||||||
|
│ - contextBridge.exposeInMainWorld │
|
||||||
|
│ - 暴露安全的 API 给渲染进程 │
|
||||||
|
│ - 验证和清理输入参数 │
|
||||||
|
└──────────────────────────┬──────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
window.electronAPI
|
||||||
|
│
|
||||||
|
┌──────────────────────────▼──────────────────────────────────────┐
|
||||||
|
│ 渲染进程 (Renderer Process) │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────┐│
|
||||||
|
│ │ 现有 Web 应用 (public/) ││
|
||||||
|
│ │ - index.html ││
|
||||||
|
│ │ - js/app.js, player.js, input.js, file-panel.js ││
|
||||||
|
│ │ - 通过 window.electronAPI 调用主进程功能 ││
|
||||||
|
│ └─────────────────────────────────────────────────────────────┘│
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 详细任务清单
|
||||||
|
|
||||||
|
### 阶段一:项目初始化 (预计 30 分钟)
|
||||||
|
|
||||||
|
#### 1.1 安装 Electron 依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install electron --save-dev
|
||||||
|
npm install electron-builder --save-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2 创建 Electron 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
remote/
|
||||||
|
├── electron/
|
||||||
|
│ ├── main.js # 主进程入口
|
||||||
|
│ ├── preload.js # 预加载脚本
|
||||||
|
│ ├── ipc/
|
||||||
|
│ │ ├── clipboard.js # 剪贴板 IPC 处理
|
||||||
|
│ │ └── file.js # 文件系统 IPC 处理
|
||||||
|
│ └── utils/
|
||||||
|
│ └── paths.js # 路径工具函数
|
||||||
|
├── public/ # 现有前端代码 (保持不变)
|
||||||
|
├── src/ # 现有后端代码 (保持不变)
|
||||||
|
└── package.json # 更新配置
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3 更新 package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"main": "electron/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"electron:dev": "electron .",
|
||||||
|
"electron:build": "electron-builder",
|
||||||
|
"electron:build:win": "electron-builder --win --x64"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段二:主进程开发 (预计 1 小时)
|
||||||
|
|
||||||
|
#### 2.1 创建主进程入口 (electron/main.js)
|
||||||
|
|
||||||
|
**功能需求:**
|
||||||
|
- 创建 BrowserWindow
|
||||||
|
- 加载远程服务器 URL 或本地 HTML
|
||||||
|
- 配置 webPreferences (contextIsolation, nodeIntegration)
|
||||||
|
- 注册 IPC Handler
|
||||||
|
- 创建系统托盘图标
|
||||||
|
- 处理窗口生命周期
|
||||||
|
|
||||||
|
**关键配置:**
|
||||||
|
```javascript
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false, // 安全:禁用 Node.js 集成
|
||||||
|
contextIsolation: true, // 安全:启用上下文隔离
|
||||||
|
preload: path.join(__dirname, 'preload.js')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载远程服务器
|
||||||
|
win.loadURL('http://192.168.x.x:3000')
|
||||||
|
// 或加载本地文件 (离线模式)
|
||||||
|
// win.loadFile('public/index.html')
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.2 创建预加载脚本 (electron/preload.js)
|
||||||
|
|
||||||
|
**功能需求:**
|
||||||
|
- 使用 contextBridge 暴露安全 API
|
||||||
|
- 封装 IPC 调用
|
||||||
|
- 提供类型安全的接口
|
||||||
|
|
||||||
|
**暴露的 API:**
|
||||||
|
```javascript
|
||||||
|
window.electronAPI = {
|
||||||
|
// 剪贴板
|
||||||
|
clipboard: {
|
||||||
|
readText: () => Promise<string>,
|
||||||
|
writeText: (text: string) => Promise<void>,
|
||||||
|
readImage: () => Promise<string>, // base64
|
||||||
|
writeImage: (base64: string) => Promise<void>
|
||||||
|
},
|
||||||
|
|
||||||
|
// 文件系统
|
||||||
|
file: {
|
||||||
|
showOpenDialog: (options) => Promise<string[]>,
|
||||||
|
showSaveDialog: (options) => Promise<string>,
|
||||||
|
browseDirectory: (path: string) => Promise<FileInfo[]>,
|
||||||
|
readFile: (path: string) => Promise<Buffer>,
|
||||||
|
writeFile: (path: string, data: Buffer) => Promise<void>
|
||||||
|
},
|
||||||
|
|
||||||
|
// 平台信息
|
||||||
|
platform: {
|
||||||
|
isElectron: true,
|
||||||
|
os: 'win32' | 'darwin' | 'linux'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2.3 实现剪贴板 IPC 处理 (electron/ipc/clipboard.js)
|
||||||
|
|
||||||
|
**功能需求:**
|
||||||
|
- 使用 Electron 原生 `clipboard` 模块
|
||||||
|
- 支持文本和图片格式
|
||||||
|
- 替代现有的 PowerShell 方案
|
||||||
|
|
||||||
|
**优势:**
|
||||||
|
- 无需启动 PowerShell 进程
|
||||||
|
- 更快的响应速度
|
||||||
|
- 更好的跨平台支持
|
||||||
|
|
||||||
|
#### 2.4 实现文件系统 IPC 处理 (electron/ipc/file.js)
|
||||||
|
|
||||||
|
**功能需求:**
|
||||||
|
- 使用 Electron `dialog` 模块显示文件选择对话框
|
||||||
|
- 支持本地目录浏览
|
||||||
|
- 支持直接文件读写
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段三:渲染进程适配 (预计 1 小时)
|
||||||
|
|
||||||
|
#### 3.1 创建适配层 (public/js/electron-adapter.js)
|
||||||
|
|
||||||
|
**功能需求:**
|
||||||
|
- 检测运行环境 (Electron vs Browser)
|
||||||
|
- 统一 API 接口
|
||||||
|
- 自动选择最优实现
|
||||||
|
|
||||||
|
**设计模式:**
|
||||||
|
```javascript
|
||||||
|
const ClipboardAPI = {
|
||||||
|
async readText() {
|
||||||
|
if (window.electronAPI) {
|
||||||
|
return window.electronAPI.clipboard.readText()
|
||||||
|
}
|
||||||
|
// 回退到浏览器 API
|
||||||
|
return navigator.clipboard.readText()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 修改剪贴板同步逻辑
|
||||||
|
|
||||||
|
**文件:** `public/index.html`
|
||||||
|
|
||||||
|
**修改内容:**
|
||||||
|
- 替换 `navigator.clipboard` 调用为适配层 API
|
||||||
|
- 添加 Electron 环境检测
|
||||||
|
- 保留浏览器环境兼容性
|
||||||
|
|
||||||
|
#### 3.3 增强文件面板功能
|
||||||
|
|
||||||
|
**文件:** `public/js/file-panel.js`
|
||||||
|
|
||||||
|
**新增功能:**
|
||||||
|
- 本地目录浏览 (Electron 环境)
|
||||||
|
- 拖拽上传支持
|
||||||
|
- 右键菜单 (删除、重命名)
|
||||||
|
- 文件预览
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段四:构建配置 (预计 30 分钟)
|
||||||
|
|
||||||
|
#### 4.1 配置 electron-builder
|
||||||
|
|
||||||
|
**package.json 配置:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"appId": "com.xuanchi.remote-screen",
|
||||||
|
"productName": "XC Remote",
|
||||||
|
"directories": {
|
||||||
|
"output": "dist-electron"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"electron/**/*",
|
||||||
|
"public/**/*"
|
||||||
|
],
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{ "target": "nsis", "arch": ["x64"] },
|
||||||
|
{ "target": "portable", "arch": ["x64"] }
|
||||||
|
],
|
||||||
|
"icon": "public/icon.ico"
|
||||||
|
},
|
||||||
|
"nsis": {
|
||||||
|
"oneClick": false,
|
||||||
|
"allowToChangeInstallationDirectory": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2 创建安装程序图标
|
||||||
|
|
||||||
|
- 需要 .ico 格式图标文件
|
||||||
|
- 建议尺寸:256x256
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 阶段五:测试与优化 (预计 1 小时)
|
||||||
|
|
||||||
|
#### 5.1 功能测试清单
|
||||||
|
|
||||||
|
- [ ] 剪贴板文本同步 (Ctrl+C / Ctrl+V)
|
||||||
|
- [ ] 剪贴板图片同步
|
||||||
|
- [ ] 文件上传
|
||||||
|
- [ ] 文件下载
|
||||||
|
- [ ] 目录浏览
|
||||||
|
- [ ] 远程屏幕显示
|
||||||
|
- [ ] 鼠标键盘控制
|
||||||
|
|
||||||
|
#### 5.2 性能优化
|
||||||
|
|
||||||
|
- 剪贴板轮询优化 (避免频繁读取)
|
||||||
|
- 文件传输进度优化
|
||||||
|
- 内存使用监控
|
||||||
|
|
||||||
|
#### 5.3 安全检查
|
||||||
|
|
||||||
|
- [ ] contextIsolation 已启用
|
||||||
|
- [ ] nodeIntegration 已禁用
|
||||||
|
- [ ] preload 脚本无安全漏洞
|
||||||
|
- [ ] IPC 消息验证
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文件变更清单
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
| 文件路径 | 说明 |
|
||||||
|
|---------|------|
|
||||||
|
| `electron/main.js` | 主进程入口 |
|
||||||
|
| `electron/preload.js` | 预加载脚本 |
|
||||||
|
| `electron/ipc/clipboard.js` | 剪贴板 IPC 处理 |
|
||||||
|
| `electron/ipc/file.js` | 文件系统 IPC 处理 |
|
||||||
|
| `electron/utils/paths.js` | 路径工具 |
|
||||||
|
| `public/js/electron-adapter.js` | 渲染进程适配层 |
|
||||||
|
| `public/icon.ico` | 应用图标 |
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
| 文件路径 | 修改内容 |
|
||||||
|
|---------|---------|
|
||||||
|
| `package.json` | 添加 Electron 配置和脚本 |
|
||||||
|
| `public/index.html` | 引入适配层,修改剪贴板逻辑 |
|
||||||
|
| `public/js/file-panel.js` | 增强文件功能,添加 Electron 支持 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 时间估算
|
||||||
|
|
||||||
|
| 阶段 | 预计时间 |
|
||||||
|
|------|---------|
|
||||||
|
| 阶段一:项目初始化 | 30 分钟 |
|
||||||
|
| 阶段二:主进程开发 | 1 小时 |
|
||||||
|
| 阶段三:渲染进程适配 | 1 小时 |
|
||||||
|
| 阶段四:构建配置 | 30 分钟 |
|
||||||
|
| 阶段五:测试与优化 | 1 小时 |
|
||||||
|
| **总计** | **4 小时** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风险与注意事项
|
||||||
|
|
||||||
|
### 安全风险
|
||||||
|
|
||||||
|
1. **IPC 消息验证** - 所有 IPC 消息需要验证来源和参数
|
||||||
|
2. **路径遍历攻击** - 文件操作需要验证路径合法性
|
||||||
|
3. **远程代码加载** - 如果加载远程 URL,需要确保服务器安全
|
||||||
|
|
||||||
|
### 兼容性考虑
|
||||||
|
|
||||||
|
1. **保留浏览器兼容** - 应用应同时支持浏览器和 Electron 环境
|
||||||
|
2. **优雅降级** - 在浏览器环境中使用现有方案
|
||||||
|
|
||||||
|
### 性能考虑
|
||||||
|
|
||||||
|
1. **剪贴板监听** - 避免频繁轮询剪贴板
|
||||||
|
2. **大文件传输** - 使用流式处理避免内存溢出
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 后续增强
|
||||||
|
|
||||||
|
1. **自动更新** - 集成 electron-updater
|
||||||
|
2. **离线模式** - 支持本地 HTML 加载
|
||||||
|
3. **多窗口** - 支持多显示器场景
|
||||||
|
4. **快捷键** - 全局快捷键支持
|
||||||
|
5. **系统托盘** - 最小化到托盘
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
通过 Electron 迁移,可以完全解决浏览器安全限制问题:
|
||||||
|
|
||||||
|
| 功能 | 迁移前 | 迁移后 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| 剪贴板读取 | ❌ 受限 | ✅ 完全可用 |
|
||||||
|
| 剪贴板写入 | ❌ 受限 | ✅ 完全可用 |
|
||||||
|
| 文件选择 | ⚠️ 受限 | ✅ 完全可用 |
|
||||||
|
| 目录浏览 | ❌ 不可用 | ✅ 完全可用 |
|
||||||
|
| 文件保存 | ⚠️ 受限 | ✅ 完全可用 |
|
||||||
|
|
||||||
|
迁移后,用户体验将显著提升,同时保持与现有服务端的完全兼容。
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user