Compare commits
5 Commits
main
...
480697a52e
| Author | SHA1 | Date | |
|---|---|---|---|
| 480697a52e | |||
| 8aa7387e4d | |||
| 8a16b01e9e | |||
| c764603dbd | |||
| 88c2fa5d03 |
@@ -0,0 +1,282 @@
|
||||
# CyyNote
|
||||
|
||||
CyyNote 是一个前后端分离的在线笔记系统,后端基于 Solon + Wood + SQLite 提供鉴权、笔记树、笔记编辑、回收站和浏览历史接口;前端基于 React + Vite + Semi UI + Lexical 提供笔记管理与富文本编辑体验。
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端
|
||||
|
||||
- React 18
|
||||
- TypeScript 5.8
|
||||
- Vite 6
|
||||
- React Router 7
|
||||
- Zustand 5:客户端状态管理
|
||||
- Axios:统一 HTTP 客户端与鉴权拦截器
|
||||
- Semi UI 2.96:页面组件库
|
||||
- Lexical 0.44:富文本编辑器
|
||||
- Yjs + y-websocket:协作编辑基础能力
|
||||
- pnpm 9:包管理器
|
||||
- ESLint 9 Flat Config
|
||||
|
||||
### 后端
|
||||
|
||||
- JDK 17
|
||||
- Solon 3.0.5
|
||||
- Wood ORM / wood-solon-plugin
|
||||
- SQLite JDBC 3.46
|
||||
- HikariCP
|
||||
- solon-sessionstate-jwt:JWT Session
|
||||
- Fastjson2
|
||||
- Hutool
|
||||
- Maven
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
CyyNote/
|
||||
├── cyynote-backend/ # Solon 后端工程
|
||||
│ ├── pom.xml
|
||||
│ └── src/main/
|
||||
│ ├── java/com/cyynote/
|
||||
│ │ ├── controller/ # Auth / Note / Test 控制器
|
||||
│ │ ├── dso/ # DB 初始化、Wood Mapper
|
||||
│ │ ├── model/ # 数据模型
|
||||
│ │ ├── payload/ # 请求/响应 DTO
|
||||
│ │ └── util/ # 笔记树构建工具
|
||||
│ └── resources/
|
||||
│ ├── app.yml # 通用配置
|
||||
│ ├── app-dev.yml # 开发环境配置
|
||||
│ ├── app-pro.yml # 生产环境配置
|
||||
│ └── db/schema.sql # SQLite 初始化脚本
|
||||
└── cyynote-frontend/ # React 前端工程
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
└── src/
|
||||
├── components/ # 页面组件、Lexical 编辑器、弹窗、侧栏
|
||||
├── pages/ # 路由页面
|
||||
├── services/ # API Service
|
||||
├── stores/ # Zustand Store
|
||||
├── lib/axios.ts # Axios 实例与拦截器
|
||||
└── types/ # 前端类型定义
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| JDK | 17+ |
|
||||
| Maven | 3.8+ |
|
||||
| Node.js | 20+ |
|
||||
| pnpm | 9+ |
|
||||
|
||||
后端 Maven 编译目标已设置为 Java 17;前端 `package.json` 中声明了 Node 20 与 pnpm 9。
|
||||
|
||||
## 后端配置与运行
|
||||
|
||||
### 数据库配置
|
||||
|
||||
开发环境默认使用当前运行目录下的 SQLite 文件:
|
||||
|
||||
```yaml
|
||||
test.db1:
|
||||
jdbcUrl: "jdbc:sqlite:./cyynote.db"
|
||||
driverClassName: "org.sqlite.JDBC"
|
||||
```
|
||||
|
||||
首次启动时,如果数据库中不存在 `appx` 表,后端会执行 `src/main/resources/db/schema.sql` 初始化基础表结构和示例数据。
|
||||
|
||||
默认账号:
|
||||
|
||||
| 用户名 | 密码 | 角色 |
|
||||
| --- | --- | --- |
|
||||
| admin | cyy123 | ROLE_ADMIN |
|
||||
| test | test | ROLE_USER |
|
||||
|
||||
### 启动后端
|
||||
|
||||
```bash
|
||||
cd cyynote-backend
|
||||
mvn clean package
|
||||
mvn clean package -DskipTests
|
||||
mvn solon:run
|
||||
|
||||
|
||||
java -jar demo.jar
|
||||
|
||||
java -jar target/cyynote.jar
|
||||
```
|
||||
|
||||
默认服务端口:`8080`。
|
||||
|
||||
### 后端关键接口
|
||||
|
||||
接口统一以 `/api` 为前缀。
|
||||
|
||||
#### 鉴权接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/api/auth/signin` | 登录,返回 `accessToken`、`tokenType` 和 `user` |
|
||||
| GET | `/api/auth/logout` | 清理服务端 Session |
|
||||
| POST | `/api/auth/signup` | 注册普通用户 |
|
||||
| POST | `/api/auth/updatePassword` | 修改密码 |
|
||||
|
||||
登录成功响应中的 `data` 结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"accessToken": "...",
|
||||
"tokenType": "Bearer",
|
||||
"user": {
|
||||
"id": 1,
|
||||
"username": "admin",
|
||||
"email": "aaaaaaaaa",
|
||||
"roles": ["ROLE_ADMIN"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 笔记接口
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/api/note/all` | 获取笔记树 |
|
||||
| GET | `/api/note/home?type=1&search=0` | 首页列表、搜索、回收站列表 |
|
||||
| GET | `/api/note/get?id=1` | 获取笔记详情,同时更新浏览时间 |
|
||||
| GET | `/api/note/add?pid=0` | 新增笔记 |
|
||||
| POST | `/api/note/update` | 更新笔记标题和内容 |
|
||||
| POST | `/api/note/delete` | 软删除笔记 |
|
||||
| GET | `/api/note/deleteBack?id=1` | 从回收站恢复笔记 |
|
||||
| GET | `/api/note/historyQueryOrDelete?flag=0` | 查询或清理历史记录 |
|
||||
|
||||
`/api/note/home` 的 `type` 参数含义:
|
||||
|
||||
| type | 说明 |
|
||||
| --- | --- |
|
||||
| 0 | 搜索 |
|
||||
| 1 | 最近更新 |
|
||||
| 2 | 最新添加 |
|
||||
| 3 | 最近浏览 |
|
||||
| 4 | 回收站 |
|
||||
|
||||
## 前端配置与运行
|
||||
|
||||
### 环境变量
|
||||
|
||||
复制示例配置:
|
||||
|
||||
```bash
|
||||
cd cyynote-frontend
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
常用配置:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_WS_URL=ws://localhost:1234
|
||||
```
|
||||
|
||||
开发模式下,Vite 已配置 `/api` 代理到 `http://localhost:8080`。
|
||||
|
||||
### 安装与启动
|
||||
|
||||
```bash
|
||||
cd cyynote-frontend
|
||||
若网络不好
|
||||
pnpm config set registry https://registry.npmmirror.com/
|
||||
|
||||
pnpm install
|
||||
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 构建与检查
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 前端架构说明
|
||||
|
||||
### 路由
|
||||
|
||||
前端入口使用 `BrowserRouter`。主要路由:
|
||||
|
||||
| 路径 | 页面 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `/login` | SignIn | 登录页 |
|
||||
| `/register` | Register | 注册页 |
|
||||
| `/home` | NotePage | 笔记主界面 |
|
||||
| `/profile` | Profile | 用户信息 |
|
||||
| `/user` | BoardUser | 用户权限测试页 |
|
||||
| `/mod` | BoardModerator | Moderator 权限测试页 |
|
||||
| `/admin` | BoardAdmin | Admin 权限测试页 |
|
||||
|
||||
除登录和注册外,其他路由由 `ProtectedRoute` 保护;未登录用户会跳转到 `/login`。
|
||||
|
||||
### 状态管理
|
||||
|
||||
Zustand Store 分为三类:
|
||||
|
||||
- `useAuthStore`:用户信息、JWT Token、登录状态持久化到 `sessionStorage`
|
||||
- `useNoteStore`:当前笔记、笔记树、首页列表、最近浏览列表
|
||||
- `useUIStore`:当前视图、侧栏状态、搜索条件、弹窗显隐状态
|
||||
|
||||
### HTTP 客户端
|
||||
|
||||
`src/lib/axios.ts` 创建统一 Axios 实例:
|
||||
|
||||
- `baseURL` 读取 `VITE_API_BASE_URL`,默认 `/api`
|
||||
- 请求拦截器自动添加 `Authorization: Bearer <token>`
|
||||
- 响应拦截器在 HTTP 401 时自动登出并跳转登录页
|
||||
|
||||
### 编辑器
|
||||
|
||||
笔记编辑器基于 Lexical。`NoteEditor` 负责标题输入与编辑器挂载,`LexicalApp` 负责富文本编辑能力。协作相关配置位于 `components/lexical/collaboration.ts`,WebSocket 地址由 `VITE_WS_URL` 控制。
|
||||
|
||||
## 本次升级与修复说明
|
||||
|
||||
本次修复覆盖前端、后端和 JDK 17 运行兼容性:
|
||||
|
||||
- 后端 Maven 编译目标升级为 Java 17
|
||||
- 修复后端登录返回结构,使其与前端 `IAuthResponse` 匹配
|
||||
- 修复登录失败时空指针问题,并返回 HTTP 401
|
||||
- 修复注册接口只返回空对象的问题,改为实际创建普通用户
|
||||
- 修复修改密码接口用户不存在时空指针问题
|
||||
- 修复 JWT 拦截器在 401 后仍继续执行 Controller 的问题
|
||||
- 修复软删除接口把标题更新成 `null` 的问题
|
||||
- 修复历史表缺失 `flag` 字段的问题
|
||||
- 修复开发环境 SQLite 路径在 Linux 下不可用的问题
|
||||
- 修复前端 Profile 页面访问不存在 `accessToken` 字段导致崩溃的问题
|
||||
- 修复首页点击“查看”后未切换到笔记视图的问题
|
||||
- 修复 Board 页面仍使用旧 service 返回结构的问题
|
||||
- 修复回收站和密码弹窗状态字段 `Model` / `Modal` 拼写不一致问题
|
||||
- 修复前端笔记树 `key` 类型与后端返回值不一致的问题
|
||||
- 清理未使用的 `@tanstack/react-query` 依赖声明
|
||||
|
||||
## 验证建议
|
||||
|
||||
网络正常后建议执行:
|
||||
|
||||
```bash
|
||||
cd cyynote-backend
|
||||
mvn clean package
|
||||
|
||||
cd ../cyynote-frontend
|
||||
pnpm install
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm build
|
||||
```
|
||||
|
||||
如果 CI 使用 `pnpm install --frozen-lockfile`,需要提交 `pnpm-lock.yaml`。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 当前密码仍为明文存储,生产环境应改为 BCrypt/Argon2 等不可逆哈希。
|
||||
- Lexical 从旧版本升级到 0.44 跨度较大,建议重点回归测试自定义 Node、Plugin、序列化/反序列化和保存逻辑。
|
||||
- y-websocket 3.x 与旧版存在差异,协作编辑功能需要单独联调。
|
||||
- SQLite 适合单机和轻量部署;多人协作、高并发或云部署建议迁移到 MySQL/PostgreSQL。
|
||||
|
||||
Binary file not shown.
+20
-9
@@ -20,7 +20,11 @@
|
||||
</parent>
|
||||
|
||||
<properties>
|
||||
<java.version>11</java.version>
|
||||
<java.version>17</java.version>
|
||||
<maven.compiler.source>${java.version}</maven.compiler.source>
|
||||
<maven.compiler.target>${java.version}</maven.compiler.target>
|
||||
<maven.compiler.release>${java.version}</maven.compiler.release>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@@ -75,19 +79,18 @@
|
||||
<version>3.46.1.3</version>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- AWS S3 SDK v2 (Cloudflare R2 is S3-compatible) -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
<version>2.25.16</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
@@ -96,6 +99,14 @@
|
||||
<finalName>${project.artifactId}</finalName>
|
||||
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<release>${java.version}</release>
|
||||
<encoding>${project.build.sourceEncoding}</encoding>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.noear</groupId>
|
||||
<artifactId>solon-maven-plugin</artifactId>
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
package com.cyynote.controller;
|
||||
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.cyynote.controller.BaseController;
|
||||
import com.cyynote.model.NoteModel;
|
||||
import com.cyynote.model.UserModel;
|
||||
import com.cyynote.payload.request.LoginRequest;
|
||||
import com.cyynote.payload.request.SignupRequest;
|
||||
import com.cyynote.payload.request.UpdatePasswordRequest;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import com.cyynote.payload.request.UpdatePasswordRequest;
|
||||
import org.noear.solon.annotation.Body;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
@@ -26,7 +24,7 @@ import org.noear.wood.annotation.Db;
|
||||
@Mapping("/api/auth")
|
||||
@Controller
|
||||
public class AuthController extends BaseController {
|
||||
private static final Logger log = Logger.getLogger(com.cyynote.controller.AuthController.class.getName());
|
||||
private static final Logger log = Logger.getLogger(AuthController.class.getName());
|
||||
|
||||
@Db
|
||||
DbContext db;
|
||||
@@ -39,65 +37,148 @@ public class AuthController extends BaseController {
|
||||
@Post
|
||||
@Mapping("/signin")
|
||||
public Result signin(Context ctx, @Body LoginRequest loginRequest) throws SQLException {
|
||||
log.info("name:" + loginRequest.getUsername());
|
||||
log.info("password:" + loginRequest.getPassword());
|
||||
if (loginRequest == null || loginRequest.getUsername() == null || loginRequest.getPassword() == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "用户名和密码不能为空");
|
||||
}
|
||||
|
||||
long count = db.table("user").selectCount();
|
||||
log.info("countUser:" + count);
|
||||
List<UserModel> all = ((DbTableQuery)this.db.table("user")).selectList("*", UserModel.class);
|
||||
log.info( all.toString());
|
||||
log.info("signin username:" + loginRequest.getUsername());
|
||||
UserModel model = (UserModel) ((DbTableQuery) this.db.table("user")
|
||||
.whereEq("username", loginRequest.getUsername()))
|
||||
.selectItem("*", UserModel.class);
|
||||
|
||||
UserModel model = (UserModel)((DbTableQuery)((DbTableQuery)this.db.table("user").whereEq("username", loginRequest.getUsername())).andEq("password", loginRequest.getPassword())).selectItem("*", UserModel.class);
|
||||
if ("admin".equals(model.getUsername())) {
|
||||
ctx.sessionSet("user_name", loginRequest.getUsername());
|
||||
if (model == null) {
|
||||
ctx.status(401);
|
||||
return Result.failure(401, "用户名或密码错误");
|
||||
}
|
||||
|
||||
boolean passwordMatch;
|
||||
if (model.getPasswordHashed() == null || model.getPasswordHashed() == 0) {
|
||||
// Plain-text comparison; migrate to hash on success
|
||||
passwordMatch = loginRequest.getPassword().equals(model.getPassword());
|
||||
if (passwordMatch) {
|
||||
String hashed = DigestUtil.sha256Hex(loginRequest.getPassword());
|
||||
((DbTableQuery) this.db.table("user")
|
||||
.set("password", hashed)
|
||||
.set("password_hashed", 1)
|
||||
.whereEq("id", model.getId()))
|
||||
.update();
|
||||
model.setPassword(hashed);
|
||||
model.setPasswordHashed(1);
|
||||
}
|
||||
} else {
|
||||
passwordMatch = DigestUtil.sha256Hex(loginRequest.getPassword()).equals(model.getPassword());
|
||||
}
|
||||
|
||||
if (!passwordMatch) {
|
||||
ctx.status(401);
|
||||
return Result.failure(401, "用户名或密码错误");
|
||||
}
|
||||
|
||||
ctx.sessionSet("user_name", model.getUsername());
|
||||
ctx.sessionSet("user_id", model.getId());
|
||||
JSONObject obj = new JSONObject();
|
||||
String token = ctx.sessionState().sessionToken();
|
||||
obj.put("accessToken", token);
|
||||
obj.put("id", Integer.valueOf(1));
|
||||
obj.put("username", loginRequest.getUsername());
|
||||
obj.put("email", "1");
|
||||
obj.put("roles", "1");
|
||||
int flag = ((DbTableQuery)this.db.table("user").set("token", token).set("logintime", DateUtil.now()).whereEq("id", model.getId())).update();
|
||||
System.out.println(flag);
|
||||
return Result.succeed(obj);
|
||||
}
|
||||
return Result.failure();
|
||||
}
|
||||
((DbTableQuery) this.db.table("user")
|
||||
.set("token", token)
|
||||
.set("logintime", DateUtil.now())
|
||||
.whereEq("id", model.getId()))
|
||||
.update();
|
||||
|
||||
JSONObject user = new JSONObject();
|
||||
user.put("id", model.getId());
|
||||
user.put("username", model.getUsername());
|
||||
user.put("email", model.getEmail());
|
||||
user.put("roles", normalizeRoles(model.getRole()));
|
||||
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("accessToken", token);
|
||||
payload.put("tokenType", "Bearer");
|
||||
payload.put("user", user);
|
||||
|
||||
return Result.succeed(payload);
|
||||
}
|
||||
|
||||
@Post
|
||||
@Mapping("/updatePassword")
|
||||
public Result updatePassword(Context ctx, @Body UpdatePasswordRequest loginRequest) throws SQLException {
|
||||
log.info("name:" + loginRequest.getUsername());
|
||||
log.info("old - password:" + loginRequest.getOldpassword());
|
||||
log.info("new - password:" + loginRequest.getNewpassword());
|
||||
if (loginRequest == null || loginRequest.getUsername() == null || loginRequest.getOldpassword() == null || loginRequest.getNewpassword() == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不完整");
|
||||
}
|
||||
|
||||
log.info("update password username:" + loginRequest.getUsername());
|
||||
UserModel model = (UserModel) ((DbTableQuery) this.db.table("user")
|
||||
.whereEq("username", loginRequest.getUsername()))
|
||||
.selectItem("*", UserModel.class);
|
||||
if (model == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "用户名或旧密码错误");
|
||||
}
|
||||
|
||||
long count = db.table("user").selectCount();
|
||||
log.info("countUser:" + count);
|
||||
List<UserModel> all = ((DbTableQuery)this.db.table("user")).selectList("*", UserModel.class);
|
||||
log.info( all.toString());
|
||||
boolean oldPasswordMatch;
|
||||
if (model.getPasswordHashed() == null || model.getPasswordHashed() == 0) {
|
||||
oldPasswordMatch = loginRequest.getOldpassword().equals(model.getPassword());
|
||||
} else {
|
||||
oldPasswordMatch = DigestUtil.sha256Hex(loginRequest.getOldpassword()).equals(model.getPassword());
|
||||
}
|
||||
|
||||
if (!oldPasswordMatch) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "用户名或旧密码错误");
|
||||
}
|
||||
|
||||
UserModel model = (UserModel)((DbTableQuery)((DbTableQuery)this.db.table("user").whereEq("username", loginRequest.getUsername())).andEq("password", loginRequest.getOldpassword())).selectItem("*", UserModel.class);
|
||||
if ("admin".equals(model.getUsername())) {
|
||||
|
||||
int flag = ((DbTableQuery)this.db.table("user").set("password", loginRequest.getNewpassword()).whereEq("id", model.getId())).update();
|
||||
System.out.println(flag);
|
||||
String newHashed = DigestUtil.sha256Hex(loginRequest.getNewpassword());
|
||||
int flag = ((DbTableQuery) this.db.table("user")
|
||||
.set("password", newHashed)
|
||||
.set("password_hashed", 1)
|
||||
.whereEq("id", model.getId()))
|
||||
.update();
|
||||
return Result.succeed(flag);
|
||||
}
|
||||
return Result.failure();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Post
|
||||
@Mapping("/signup")
|
||||
public Result signup(@Body SignupRequest signUpRequest) {
|
||||
log.info("signUpRequest:" + JSONObject.toJSONString(signUpRequest, new com.alibaba.fastjson2.JSONWriter.Feature[0]));
|
||||
JSONObject obj = new JSONObject();
|
||||
return Result.succeed(obj);
|
||||
public Result signup(Context ctx, @Body SignupRequest signUpRequest) throws SQLException {
|
||||
log.info("signup username:" + (signUpRequest == null ? null : signUpRequest.getUsername()));
|
||||
if (signUpRequest == null || signUpRequest.getUsername() == null || signUpRequest.getPassword() == null || signUpRequest.getEmail() == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "用户名、邮箱和密码不能为空");
|
||||
}
|
||||
|
||||
long exists = ((DbTableQuery) this.db.table("user")
|
||||
.whereEq("username", signUpRequest.getUsername()))
|
||||
.selectCount();
|
||||
if (exists > 0) {
|
||||
ctx.status(409);
|
||||
return Result.failure(409, "用户名已存在");
|
||||
}
|
||||
|
||||
UserModel user = new UserModel();
|
||||
user.setUsername(signUpRequest.getUsername());
|
||||
user.setEmail(signUpRequest.getEmail());
|
||||
user.setPassword(DigestUtil.sha256Hex(signUpRequest.getPassword()));
|
||||
user.setPasswordHashed(1);
|
||||
user.setRole("ROLE_USER");
|
||||
this.db.table("user").setEntityIf(user, (k, v) -> Boolean.valueOf(v != null)).insert();
|
||||
return Result.succeed("注册成功");
|
||||
}
|
||||
|
||||
private List<String> normalizeRoles(String role) {
|
||||
if (role == null || role.isBlank()) {
|
||||
return List.of("ROLE_USER");
|
||||
}
|
||||
if (role.startsWith("ROLE_")) {
|
||||
return List.of(role);
|
||||
}
|
||||
if ("admin".equalsIgnoreCase(role)) {
|
||||
return List.of("ROLE_ADMIN");
|
||||
}
|
||||
if ("moderator".equalsIgnoreCase(role) || "mod".equalsIgnoreCase(role)) {
|
||||
return List.of("ROLE_MODERATOR");
|
||||
}
|
||||
if (role.contains(",")) {
|
||||
return Arrays.asList(role.split(","));
|
||||
}
|
||||
return List.of("ROLE_USER");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
package com.cyynote.controller;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.cyynote.model.DriveFileModel;
|
||||
import com.cyynote.payload.request.NoteRequest;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
import org.noear.solon.annotation.Body;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Get;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
import org.noear.solon.annotation.Post;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
import org.noear.solon.core.handle.Result;
|
||||
import org.noear.solon.core.handle.UploadedFile;
|
||||
import org.noear.wood.DataItem;
|
||||
import org.noear.wood.DbContext;
|
||||
import org.noear.wood.DbTableQuery;
|
||||
import org.noear.wood.annotation.Db;
|
||||
import org.noear.solon.sessionstate.jwt.JwtUtils;
|
||||
|
||||
@Mapping("/api/drive")
|
||||
@Controller
|
||||
public class DriveController extends BaseController {
|
||||
private static final Logger log = Logger.getLogger(DriveController.class.getName());
|
||||
private static final String DEFAULT_LOCAL_DIR = "./local_drive";
|
||||
|
||||
@Db
|
||||
DbContext db;
|
||||
|
||||
/** List files for current user */
|
||||
@Get
|
||||
@Mapping("/list")
|
||||
public Result listFiles(Context ctx) throws SQLException {
|
||||
String userId = getUserInfoId(ctx);
|
||||
DbTableQuery q = this.db.table("drive_file").whereEq("flag", 1);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
List<DriveFileModel> files = q.orderByDesc("createtime")
|
||||
.selectList("id,filename,original_name,size,mime_type,share_token,share_expiry,createtime,userid,storage_mode,local_path", DriveFileModel.class);
|
||||
return Result.succeed(JSONArray.parseArray(JSONArray.toJSONString(files)));
|
||||
}
|
||||
|
||||
/** Upload file to local server storage */
|
||||
@Post
|
||||
@Mapping("/upload-local")
|
||||
public Result uploadLocal(Context ctx) throws SQLException, IOException {
|
||||
String userId = getUserInfoId(ctx);
|
||||
if (userId == null) {
|
||||
ctx.status(401);
|
||||
return Result.failure(401, "未登录");
|
||||
}
|
||||
UploadedFile uploadedFile = ctx.file("file");
|
||||
if (uploadedFile == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "未选择文件");
|
||||
}
|
||||
|
||||
// Resolve storage directory from settings, fallback to default
|
||||
String localDir = getLocalStoragePath();
|
||||
Path dirPath = Paths.get(localDir);
|
||||
Files.createDirectories(dirPath);
|
||||
|
||||
// Generate unique filename to avoid collisions
|
||||
String ext = "";
|
||||
String originalName = uploadedFile.getName();
|
||||
int dotIdx = originalName.lastIndexOf('.');
|
||||
if (dotIdx >= 0) ext = originalName.substring(dotIdx);
|
||||
String uniqueFilename = IdUtil.fastSimpleUUID() + ext;
|
||||
Path filePath = dirPath.resolve(uniqueFilename);
|
||||
|
||||
// Save file
|
||||
Files.copy(uploadedFile.getContent(), filePath);
|
||||
|
||||
DriveFileModel file = new DriveFileModel();
|
||||
file.setFilename(uniqueFilename);
|
||||
file.setOriginalName(originalName);
|
||||
file.setSize(uploadedFile.getSize());
|
||||
file.setMimeType(uploadedFile.getContentType());
|
||||
file.setStorageMode("local");
|
||||
file.setLocalPath(filePath.toString());
|
||||
file.setUserid(Integer.valueOf(userId));
|
||||
file.setCreatetime(DateUtil.now());
|
||||
file.setFlag(1);
|
||||
this.db.table("drive_file").setEntityIf(file, (k, v) -> Boolean.valueOf(v != null)).insert();
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
/** Download a local file by record id */
|
||||
@Get
|
||||
@Mapping("/download/{id}")
|
||||
public void downloadLocal(Context ctx, int id) throws SQLException, IOException {
|
||||
String userId = getUserInfoId(ctx);
|
||||
DbTableQuery q = this.db.table("drive_file").whereEq("id", id).whereEq("flag", 1);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
DriveFileModel file = q.selectItem("id,original_name,local_path,mime_type,storage_mode", DriveFileModel.class);
|
||||
if (file == null || !"local".equals(file.getStorageMode())) {
|
||||
ctx.status(404);
|
||||
ctx.output("文件不存在");
|
||||
return;
|
||||
}
|
||||
File f = new File(file.getLocalPath());
|
||||
if (!f.exists()) {
|
||||
ctx.status(404);
|
||||
ctx.output("文件已被移除");
|
||||
return;
|
||||
}
|
||||
String mimeType = file.getMimeType() != null ? file.getMimeType() : "application/octet-stream";
|
||||
String encodedName = URLEncoder.encode(file.getOriginalName(), StandardCharsets.UTF_8).replace("+", "%20");
|
||||
ctx.headerSet("Content-Type", mimeType);
|
||||
ctx.headerSet("Content-Disposition", "attachment; filename*=UTF-8''" + encodedName);
|
||||
ctx.headerSet("Content-Length", String.valueOf(f.length()));
|
||||
try (FileInputStream fis = new FileInputStream(f); OutputStream out = ctx.outputStream()) {
|
||||
fis.transferTo(out);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a file record (called after frontend uploads directly to R2) */
|
||||
@Post
|
||||
@Mapping("/register")
|
||||
public Result registerFile(Context ctx, @Body JSONObject body) throws SQLException {
|
||||
if (body == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不能为空");
|
||||
}
|
||||
String userId = getUserInfoId(ctx);
|
||||
if (userId == null) {
|
||||
ctx.status(401);
|
||||
return Result.failure(401, "未登录");
|
||||
}
|
||||
DriveFileModel file = new DriveFileModel();
|
||||
file.setFilename(body.getString("filename"));
|
||||
file.setOriginalName(body.getString("original_name"));
|
||||
file.setSize(body.getLong("size"));
|
||||
file.setMimeType(body.getString("mime_type"));
|
||||
file.setR2Key(body.getString("r2_key"));
|
||||
file.setStorageMode("r2");
|
||||
file.setUserid(Integer.valueOf(userId));
|
||||
file.setCreatetime(DateUtil.now());
|
||||
file.setFlag(1);
|
||||
this.db.table("drive_file").setEntityIf(file, (k, v) -> Boolean.valueOf(v != null)).insert();
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
/** Generate or refresh share link for a file */
|
||||
@Post
|
||||
@Mapping("/share")
|
||||
public Result shareFile(Context ctx, @Body JSONObject body) throws SQLException {
|
||||
if (body == null || body.getInteger("id") == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不能为空");
|
||||
}
|
||||
String userId = getUserInfoId(ctx);
|
||||
int fileId = body.getInteger("id");
|
||||
DbTableQuery q = this.db.table("drive_file").whereEq("id", fileId);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
DriveFileModel file = q.selectItem("*", DriveFileModel.class);
|
||||
if (file == null) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "文件不存在");
|
||||
}
|
||||
String token = IdUtil.fastSimpleUUID();
|
||||
Integer days = body.getInteger("days");
|
||||
String expiry = (days != null && days > 0)
|
||||
? DateUtil.offsetDay(DateUtil.date(), days).toString()
|
||||
: null;
|
||||
this.db.table("drive_file")
|
||||
.set("share_token", token)
|
||||
.set("share_expiry", expiry)
|
||||
.whereEq("id", fileId)
|
||||
.update();
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("share_token", token);
|
||||
result.put("share_expiry", expiry);
|
||||
return Result.succeed(result);
|
||||
}
|
||||
|
||||
/** Revoke share link */
|
||||
@Post
|
||||
@Mapping("/unshare")
|
||||
public Result unshareFile(Context ctx, @Body JSONObject body) throws SQLException {
|
||||
if (body == null || body.getInteger("id") == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不能为空");
|
||||
}
|
||||
String userId = getUserInfoId(ctx);
|
||||
int fileId = body.getInteger("id");
|
||||
DbTableQuery q = this.db.table("drive_file")
|
||||
.set("share_token", null)
|
||||
.set("share_expiry", null)
|
||||
.whereEq("id", fileId);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
q.update();
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
/** Soft delete a file */
|
||||
@Post
|
||||
@Mapping("/delete")
|
||||
public Result deleteFile(Context ctx, @Body JSONObject body) throws SQLException {
|
||||
if (body == null || body.getInteger("id") == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不能为空");
|
||||
}
|
||||
String userId = getUserInfoId(ctx);
|
||||
int fileId = body.getInteger("id");
|
||||
DbTableQuery q = this.db.table("drive_file").set("flag", 0).whereEq("id", fileId);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
int rows = q.update();
|
||||
if (rows == 0) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "文件不存在");
|
||||
}
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
/** Public download via share token (no auth required) */
|
||||
@Get
|
||||
@Mapping("/public/{token}")
|
||||
public Result publicDownload(Context ctx, String token) throws SQLException {
|
||||
if (token == null || token.isBlank()) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "无效的分享链接");
|
||||
}
|
||||
DriveFileModel file = this.db.table("drive_file")
|
||||
.whereEq("share_token", token)
|
||||
.whereEq("flag", 1)
|
||||
.selectItem("id,filename,original_name,r2_key,share_expiry,mime_type,storage_mode,local_path", DriveFileModel.class);
|
||||
if (file == null) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "分享链接不存在或已失效");
|
||||
}
|
||||
if (file.getShareExpiry() != null && !file.getShareExpiry().isBlank()) {
|
||||
try {
|
||||
if (DateUtil.parseDateTime(file.getShareExpiry()).before(DateUtil.date())) {
|
||||
ctx.status(410);
|
||||
return Result.failure(410, "分享链接已过期");
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("original_name", file.getOriginalName());
|
||||
result.put("r2_key", file.getR2Key());
|
||||
result.put("mime_type", file.getMimeType());
|
||||
result.put("storage_mode", file.getStorageMode());
|
||||
result.put("local_download_url", "local".equals(file.getStorageMode())
|
||||
? "/api/drive/public-download/" + token : null);
|
||||
return Result.succeed(result);
|
||||
}
|
||||
|
||||
/** Public direct download for local files via share token */
|
||||
@Get
|
||||
@Mapping("/public-download/{token}")
|
||||
public void publicLocalDownload(Context ctx, String token) throws SQLException, IOException {
|
||||
DriveFileModel file = this.db.table("drive_file")
|
||||
.whereEq("share_token", token)
|
||||
.whereEq("flag", 1)
|
||||
.selectItem("id,original_name,local_path,mime_type,storage_mode,share_expiry", DriveFileModel.class);
|
||||
if (file == null || !"local".equals(file.getStorageMode())) {
|
||||
ctx.status(404); ctx.output("文件不存在"); return;
|
||||
}
|
||||
if (file.getShareExpiry() != null && !file.getShareExpiry().isBlank()) {
|
||||
try {
|
||||
if (DateUtil.parseDateTime(file.getShareExpiry()).before(DateUtil.date())) {
|
||||
ctx.status(410); ctx.output("分享链接已过期"); return;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
File f = new File(file.getLocalPath());
|
||||
if (!f.exists()) { ctx.status(404); ctx.output("文件已被移除"); return; }
|
||||
String mimeType = file.getMimeType() != null ? file.getMimeType() : "application/octet-stream";
|
||||
String encodedName = URLEncoder.encode(file.getOriginalName(), StandardCharsets.UTF_8).replace("+", "%20");
|
||||
ctx.headerSet("Content-Type", mimeType);
|
||||
ctx.headerSet("Content-Disposition", "attachment; filename*=UTF-8''" + encodedName);
|
||||
ctx.headerSet("Content-Length", String.valueOf(f.length()));
|
||||
try (FileInputStream fis = new FileInputStream(f); OutputStream out = ctx.outputStream()) {
|
||||
fis.transferTo(out);
|
||||
}
|
||||
}
|
||||
|
||||
private String getLocalStoragePath() {
|
||||
try {
|
||||
DataItem row = (DataItem) db.table("settings").whereEq("key", "local_storage_path").selectItem("value", DataItem.class);
|
||||
if (row != null) {
|
||||
String v = row.getString("value");
|
||||
if (v != null && !v.isBlank()) return v;
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return DEFAULT_LOCAL_DIR;
|
||||
}
|
||||
|
||||
private String getUserInfoId(Context ctx) {
|
||||
if (ctx.header("Authorization") != null) {
|
||||
String token = ctx.header("Authorization").replace("Bearer ", "");
|
||||
Claims claims = JwtUtils.parseJwt(token);
|
||||
if (claims != null) return claims.get("user_id").toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Mapping("/api/drive")
|
||||
@Controller
|
||||
public class DriveController extends BaseController {
|
||||
private static final Logger log = Logger.getLogger(DriveController.class.getName());
|
||||
|
||||
@Db
|
||||
DbContext db;
|
||||
|
||||
/** List files for current user */
|
||||
@Get
|
||||
@Mapping("/list")
|
||||
public Result listFiles(Context ctx) throws SQLException {
|
||||
String userId = getUserInfoId(ctx);
|
||||
DbTableQuery q = this.db.table("drive_file").whereEq("flag", 1);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
List<DriveFileModel> files = q.orderByDesc("createtime")
|
||||
.selectList("id,filename,original_name,size,mime_type,share_token,share_expiry,createtime,userid", DriveFileModel.class);
|
||||
return Result.succeed(JSONArray.parseArray(JSONArray.toJSONString(files)));
|
||||
}
|
||||
|
||||
/** Register a file record (called after frontend uploads directly to R2, or used for server-side upload) */
|
||||
@Post
|
||||
@Mapping("/register")
|
||||
public Result registerFile(Context ctx, @Body JSONObject body) throws SQLException {
|
||||
if (body == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不能为空");
|
||||
}
|
||||
String userId = getUserInfoId(ctx);
|
||||
if (userId == null) {
|
||||
ctx.status(401);
|
||||
return Result.failure(401, "未登录");
|
||||
}
|
||||
DriveFileModel file = new DriveFileModel();
|
||||
file.setFilename(body.getString("filename"));
|
||||
file.setOriginalName(body.getString("original_name"));
|
||||
file.setSize(body.getLong("size"));
|
||||
file.setMimeType(body.getString("mime_type"));
|
||||
file.setR2Key(body.getString("r2_key"));
|
||||
file.setUserid(Integer.valueOf(userId));
|
||||
file.setCreatetime(DateUtil.now());
|
||||
file.setFlag(1);
|
||||
this.db.table("drive_file").setEntityIf(file, (k, v) -> Boolean.valueOf(v != null)).insert();
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
/** Generate or refresh share link for a file */
|
||||
@Post
|
||||
@Mapping("/share")
|
||||
public Result shareFile(Context ctx, @Body JSONObject body) throws SQLException {
|
||||
if (body == null || body.getInteger("id") == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不能为空");
|
||||
}
|
||||
String userId = getUserInfoId(ctx);
|
||||
int fileId = body.getInteger("id");
|
||||
// Verify ownership
|
||||
DbTableQuery q = this.db.table("drive_file").whereEq("id", fileId);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
DriveFileModel file = q.selectItem("*", DriveFileModel.class);
|
||||
if (file == null) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "文件不存在");
|
||||
}
|
||||
String token = IdUtil.fastSimpleUUID();
|
||||
// Expiry: 7 days by default, or "forever" if expiry=0
|
||||
Integer days = body.getInteger("days");
|
||||
String expiry = (days != null && days > 0)
|
||||
? DateUtil.offsetDay(DateUtil.date(), days).toString()
|
||||
: null;
|
||||
this.db.table("drive_file")
|
||||
.set("share_token", token)
|
||||
.set("share_expiry", expiry)
|
||||
.whereEq("id", fileId)
|
||||
.update();
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("share_token", token);
|
||||
result.put("share_expiry", expiry);
|
||||
return Result.succeed(result);
|
||||
}
|
||||
|
||||
/** Revoke share link */
|
||||
@Post
|
||||
@Mapping("/unshare")
|
||||
public Result unshareFile(Context ctx, @Body JSONObject body) throws SQLException {
|
||||
if (body == null || body.getInteger("id") == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不能为空");
|
||||
}
|
||||
String userId = getUserInfoId(ctx);
|
||||
int fileId = body.getInteger("id");
|
||||
DbTableQuery q = this.db.table("drive_file")
|
||||
.set("share_token", null)
|
||||
.set("share_expiry", null)
|
||||
.whereEq("id", fileId);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
q.update();
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
/** Soft delete a file */
|
||||
@Post
|
||||
@Mapping("/delete")
|
||||
public Result deleteFile(Context ctx, @Body JSONObject body) throws SQLException {
|
||||
if (body == null || body.getInteger("id") == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不能为空");
|
||||
}
|
||||
String userId = getUserInfoId(ctx);
|
||||
int fileId = body.getInteger("id");
|
||||
DbTableQuery q = this.db.table("drive_file").set("flag", 0).whereEq("id", fileId);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
int rows = q.update();
|
||||
if (rows == 0) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "文件不存在");
|
||||
}
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
/** Public download via share token (no auth required) */
|
||||
@Get
|
||||
@Mapping("/public/{token}")
|
||||
public Result publicDownload(Context ctx, String token) throws SQLException {
|
||||
if (token == null || token.isBlank()) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "无效的分享链接");
|
||||
}
|
||||
DriveFileModel file = this.db.table("drive_file")
|
||||
.whereEq("share_token", token)
|
||||
.whereEq("flag", 1)
|
||||
.selectItem("id,filename,original_name,r2_key,share_expiry,mime_type", DriveFileModel.class);
|
||||
if (file == null) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "分享链接不存在或已失效");
|
||||
}
|
||||
// Check expiry
|
||||
if (file.getShareExpiry() != null && !file.getShareExpiry().isBlank()) {
|
||||
try {
|
||||
if (DateUtil.parseDateTime(file.getShareExpiry()).before(DateUtil.date())) {
|
||||
ctx.status(410);
|
||||
return Result.failure(410, "分享链接已过期");
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
// Return R2 key for frontend to construct download URL, or redirect to public R2 URL
|
||||
JSONObject result = new JSONObject();
|
||||
result.put("original_name", file.getOriginalName());
|
||||
result.put("r2_key", file.getR2Key());
|
||||
result.put("mime_type", file.getMimeType());
|
||||
return Result.succeed(result);
|
||||
}
|
||||
|
||||
private String getUserInfoId(Context ctx) {
|
||||
if (ctx.header("Authorization") != null) {
|
||||
String token = ctx.header("Authorization").replace("Bearer ", "");
|
||||
Claims claims = JwtUtils.parseJwt(token);
|
||||
if (claims != null) return claims.get("user_id").toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.cyynote.controller;
|
||||
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import org.noear.solon.annotation.Component;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
@@ -13,31 +12,27 @@ import org.noear.solon.sessionstate.jwt.JwtUtils;
|
||||
@Component
|
||||
public class JwtInterceptor implements RouterInterceptor {
|
||||
public void doIntercept(Context ctx, Handler mainHandler, RouterInterceptorChain chain) throws Throwable {
|
||||
boolean flag = true;
|
||||
if ("/api/auth/signup".equals(ctx.path()) ||
|
||||
"/api/auth/signin".equals(ctx.path()) ||
|
||||
"/demo".equals(ctx.path()) ||
|
||||
"/api/auth/logout".equals(ctx.path())
|
||||
boolean requireAuth = !("/api/auth/signup".equals(ctx.path())
|
||||
|| "/api/auth/signin".equals(ctx.path())
|
||||
|| "/demo".equals(ctx.path())
|
||||
|| "/api/auth/logout".equals(ctx.path())
|
||||
|| "OPTIONS".equalsIgnoreCase(ctx.method()));
|
||||
|
||||
)
|
||||
flag = false;
|
||||
if (flag)
|
||||
if (null != ctx.header("Authorization")) {
|
||||
if (requireAuth) {
|
||||
String token = ctx.header("Authorization");
|
||||
token = token.replace("Bearer ", "");
|
||||
System.out.println("token:" + token);
|
||||
Claims claims = JwtUtils.parseJwt(token);
|
||||
System.out.println("claims:" + claims);
|
||||
if (null != claims) {
|
||||
System.out.println("username:" + claims.get("user_name"));
|
||||
System.out.println("exp:" + claims.get("exp"));
|
||||
} else {
|
||||
if (token == null || token.isBlank()) {
|
||||
ctx.status(401);
|
||||
ctx.render(Result.failure(401, ""));
|
||||
ctx.render(Result.failure(401, "未登录或登录已过期"));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
|
||||
token = token.replace("Bearer ", "");
|
||||
Claims claims = JwtUtils.parseJwt(token);
|
||||
if (claims == null) {
|
||||
ctx.status(401);
|
||||
ctx.render(Result.failure(401, ""));
|
||||
ctx.render(Result.failure(401, "未登录或登录已过期"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
chain.doIntercept(ctx, mainHandler);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import cn.hutool.*;
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.cyynote.controller.BaseController;
|
||||
import com.cyynote.dso.NoteSqlAnnotation;
|
||||
import com.cyynote.model.HistoryModel;
|
||||
import com.cyynote.model.NoteModel;
|
||||
@@ -15,7 +13,6 @@ import com.cyynote.util.TreeNode;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
import org.noear.solon.annotation.Body;
|
||||
@@ -28,7 +25,6 @@ import org.noear.solon.annotation.Put;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
import org.noear.solon.core.handle.Result;
|
||||
import org.noear.solon.sessionstate.jwt.JwtUtils;
|
||||
import org.noear.solon.web.cors.annotation.CrossOrigin;
|
||||
import org.noear.wood.DbContext;
|
||||
import org.noear.wood.DbTableQuery;
|
||||
import org.noear.wood.annotation.Db;
|
||||
@@ -44,7 +40,7 @@ public class NoteController extends BaseController {
|
||||
@Db
|
||||
DbContext db;
|
||||
|
||||
private static final String QUERY_F = "id,pid,title,createtime,updatetime,viewtime,flag";
|
||||
private static final String QUERY_F = "id,pid,title,createtime,updatetime,viewtime,flag,tags";
|
||||
|
||||
@Get
|
||||
@Mapping("get1")
|
||||
@@ -68,7 +64,10 @@ public class NoteController extends BaseController {
|
||||
@Get
|
||||
@Mapping("/all")
|
||||
public Result noteAccess(Context ctx) throws Exception {
|
||||
List<NoteModel> all = ((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
String userId = getUserInfoId(ctx);
|
||||
DbTableQuery query = this.db.table("note").whereEq("flag", 1);
|
||||
if (userId != null) query = query.andEq("userid", Integer.valueOf(userId));
|
||||
List<NoteModel> all = query.selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
List<TreeNode> treeNodeList = new ArrayList<>();
|
||||
for (NoteModel n : all) {
|
||||
TreeNode treeNode = new TreeNode(n.getId().intValue(), n.getPid().intValue(), n.getTitle(), String.valueOf(n.getId()), String.valueOf(n.getId()));
|
||||
@@ -82,19 +81,29 @@ public class NoteController extends BaseController {
|
||||
@Get
|
||||
@Mapping("/home")
|
||||
public Result noteHome(Context ctx, @Path int type, @Path String search) throws Exception {
|
||||
System.out.println(type);
|
||||
System.out.println(search);
|
||||
String userId = getUserInfoId(ctx);
|
||||
List<NoteModel> all = new ArrayList<>();
|
||||
if (type == 0) {
|
||||
all = ((DbTableQuery)((DbTableQuery)this.db.table("note").whereLk("context", "%" + search + "%")).andLk("title", "%" + search + "%")).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
DbTableQuery q = this.db.table("note")
|
||||
.where("flag = 1 AND (title LIKE ? OR context LIKE ?)", "%" + search + "%", "%" + search + "%");
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
all = q.selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
} else if (type == 1) {
|
||||
all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).orderByDesc("updatetime")).limit(20)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
DbTableQuery q = this.db.table("note").whereEq("flag", 1);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
all = q.orderByDesc("updatetime").limit(20).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
} else if (type == 2) {
|
||||
all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).orderByDesc("createtime")).limit(20)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
DbTableQuery q = this.db.table("note").whereEq("flag", 1);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
all = q.orderByDesc("createtime").limit(20).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
} else if (type == 3) {
|
||||
all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).orderByDesc("viewtime")).limit(20)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
DbTableQuery q = this.db.table("note").whereEq("flag", 1);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
all = q.orderByDesc("viewtime").limit(20).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
} else if (type == 4) {
|
||||
all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(0))).orderByDesc("updatetime")).limit(10000)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
DbTableQuery q = this.db.table("note").whereEq("flag", 0);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
all = q.orderByDesc("updatetime").limit(10000).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
}
|
||||
return Result.succeed(JSONArray.parseArray(JSONArray.toJSONString(all)));
|
||||
}
|
||||
@@ -102,12 +111,17 @@ public class NoteController extends BaseController {
|
||||
@Get
|
||||
@Mapping("/get")
|
||||
public Result getNote(Context ctx, @Path int id) throws Exception {
|
||||
NoteModel model = (NoteModel)((DbTableQuery)this.db.table("note").whereEq("id", Integer.valueOf(id))).selectItem("*", NoteModel.class);
|
||||
String userId = getUserInfoId(ctx);
|
||||
DbTableQuery q = this.db.table("note").whereEq("id", id);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
NoteModel model = q.selectItem("*", NoteModel.class);
|
||||
if (model == null) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "笔记不存在");
|
||||
}
|
||||
String time = DateUtil.now();
|
||||
System.out.println(time);
|
||||
int flag = ((DbTableQuery)this.db.table("note").set("viewtime", time).whereEq("id", Integer.valueOf(id))).update();
|
||||
System.out.println(flag);
|
||||
log.info(JSONObject.toJSONString(model, new com.alibaba.fastjson2.JSONWriter.Feature[0]));
|
||||
this.db.table("note").set("viewtime", time).whereEq("id", id).update();
|
||||
model.setViewtime(time);
|
||||
return Result.succeed(model);
|
||||
}
|
||||
|
||||
@@ -122,67 +136,147 @@ public class NoteController extends BaseController {
|
||||
note.setTitle("未命名Note");
|
||||
note.setFlag(Integer.valueOf(1));
|
||||
note.setCreatetime(DateUtil.now());
|
||||
note.setUpdatetime(note.getCreatetime());
|
||||
note.setViewtime(note.getCreatetime());
|
||||
note.setUserid(Integer.valueOf(userid));
|
||||
note.setContext("{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"未命名Note\",\"type\":\"text\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}");
|
||||
|
||||
// note.setContext("{\"root\": {\"type\": \"root\", \"format\": \"\", \"indent\": 0, \"version\": 1, \"children\": [{\"type\": \"paragraph\", \"format\": \"\", \"indent\": 0, \"version\": 1, \"children\": [], \"direction\": null}], \"direction\": null}}");
|
||||
this.db.table("note").setEntityIf(note, (k, v) -> Boolean.valueOf((v != null))).insert();
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
@Post
|
||||
@Mapping("/update")
|
||||
public Result updateNote(@Body NoteRequest noteRequest) throws SQLException {
|
||||
NoteModel note = (NoteModel)((DbTableQuery)this.db.table("note").whereEq("id", noteRequest.getId())).selectItem("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
note.setTitle(noteRequest.getTitle());
|
||||
note.setUpdatetime(DateUtil.now());
|
||||
public Result updateNote(Context ctx, @Body NoteRequest noteRequest) throws SQLException {
|
||||
if (noteRequest == null || noteRequest.getId() == null || noteRequest.getTitle() == null || noteRequest.getContext() == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不完整");
|
||||
}
|
||||
String userId = getUserInfoId(ctx);
|
||||
DbTableQuery q = this.db.table("note").whereEq("id", noteRequest.getId());
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
NoteModel note = q.selectItem("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
if (note == null) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "笔记不存在");
|
||||
}
|
||||
String cc = noteRequest.getContext();
|
||||
JSONObject ar = JSONObject.parseObject(cc);
|
||||
note.setContext(ar.toJSONString());
|
||||
int flag = ((DbTableQuery)this.db.table("note").set("title", noteRequest.getTitle()).set("updatetime", DateUtil.now()).set("context", ar.toJSONString()).whereEq("id", noteRequest.getId())).update();
|
||||
log.info("updateNote+ flag");
|
||||
DbTableQuery uq = this.db.table("note")
|
||||
.set("title", noteRequest.getTitle())
|
||||
.set("updatetime", DateUtil.now())
|
||||
.set("context", ar.toJSONString())
|
||||
.set("tags", noteRequest.getTags() != null ? noteRequest.getTags() : "")
|
||||
.whereEq("id", noteRequest.getId());
|
||||
if (userId != null) uq = uq.andEq("userid", Integer.valueOf(userId));
|
||||
uq.update();
|
||||
log.info("updateNote id=" + noteRequest.getId());
|
||||
HistoryModel history = new HistoryModel();
|
||||
history.setNid(Integer.valueOf(noteRequest.getId().intValue()));
|
||||
history.setTitle(noteRequest.getTitle());
|
||||
history.setFlag(Integer.valueOf(1));
|
||||
history.setCreatetime(DateUtil.now());
|
||||
history.setContext(ar.toJSONString());
|
||||
long his = this.db.table("history").setEntityIf(history, (k, v) -> Boolean.valueOf((v != null))).insert();
|
||||
log.info("history+ his");
|
||||
this.db.table("history").setEntityIf(history, (k, v) -> Boolean.valueOf((v != null))).insert();
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
@Post
|
||||
@Mapping("/delete")
|
||||
public Result deleteNote(@Body NoteRequest noteRequest) throws SQLException {
|
||||
int flag = ((DbTableQuery)this.db.table("note").set("title", noteRequest.getTitle()).set("flag", Integer.valueOf(0)).whereEq("id", noteRequest.getId())).update();
|
||||
System.out.println(flag);
|
||||
public Result deleteNote(Context ctx, @Body NoteRequest noteRequest) throws SQLException {
|
||||
if (noteRequest == null || noteRequest.getId() == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "笔记ID不能为空");
|
||||
}
|
||||
String userId = getUserInfoId(ctx);
|
||||
DbTableQuery q = this.db.table("note").set("flag", 0).set("updatetime", DateUtil.now()).whereEq("id", noteRequest.getId());
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
int flag = q.update();
|
||||
if (flag == 0) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "笔记不存在");
|
||||
}
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
@Get
|
||||
@Mapping("/deleteBack")
|
||||
public Result deleteBack(@Path int id) throws Exception {
|
||||
int flag = ((DbTableQuery)this.db.table("note").set("flag", Integer.valueOf(1)).whereEq("id", Integer.valueOf(id))).update();
|
||||
System.out.println(flag);
|
||||
public Result deleteBack(Context ctx, @Path int id) throws Exception {
|
||||
String userId = getUserInfoId(ctx);
|
||||
DbTableQuery q = this.db.table("note").set("flag", 1).set("updatetime", DateUtil.now()).whereEq("id", id);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
int flag = q.update();
|
||||
if (flag == 0) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "笔记不存在");
|
||||
}
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
@Post
|
||||
@Mapping("/move")
|
||||
public Result moveNote(Context ctx, @Body NoteRequest noteRequest) throws SQLException {
|
||||
if (noteRequest == null || noteRequest.getId() == null || noteRequest.getPid() == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不完整");
|
||||
}
|
||||
String userId = getUserInfoId(ctx);
|
||||
DbTableQuery q = this.db.table("note").set("pid", noteRequest.getPid()).set("updatetime", DateUtil.now()).whereEq("id", noteRequest.getId());
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
int flag = q.update();
|
||||
if (flag == 0) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "笔记不存在");
|
||||
}
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
@Get
|
||||
@Mapping("/historyQueryOrDelete")
|
||||
public Result historyQueryOrDelete(@Path int flag) throws Exception {
|
||||
|
||||
log.info("history+ flag" + flag);
|
||||
|
||||
if(flag==0){
|
||||
if (flag == 0) {
|
||||
long count = db.table("history").selectCount();
|
||||
log.info("count:" + count);
|
||||
return Result.succeed(count);
|
||||
}else if(flag==1){
|
||||
int flagdb = db.table("history").whereEq("flag",1).delete();
|
||||
} else if (flag == 1) {
|
||||
int flagdb = db.table("history").whereEq("flag", 1).delete();
|
||||
log.info("flagdb:" + flagdb);
|
||||
return Result.succeed(0);
|
||||
}
|
||||
return null;
|
||||
return Result.failure(400, "不支持的操作类型");
|
||||
}
|
||||
|
||||
@Get
|
||||
@Mapping("/history")
|
||||
public Result getNoteHistory(Context ctx, @Path int nid) throws Exception {
|
||||
if (nid <= 0) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "笔记ID不能为空");
|
||||
}
|
||||
List<HistoryModel> list = this.db.table("history")
|
||||
.whereEq("nid", nid)
|
||||
.whereEq("flag", 1)
|
||||
.orderByDesc("createtime")
|
||||
.limit(50)
|
||||
.selectList("id,nid,title,createtime", HistoryModel.class);
|
||||
return Result.succeed(JSONArray.parseArray(JSONArray.toJSONString(list)));
|
||||
}
|
||||
|
||||
@Get
|
||||
@Mapping("/history/content")
|
||||
public Result getHistoryContent(Context ctx, @Path int id) throws Exception {
|
||||
if (id <= 0) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "历史ID不能为空");
|
||||
}
|
||||
HistoryModel model = this.db.table("history")
|
||||
.whereEq("id", id)
|
||||
.selectItem("*", HistoryModel.class);
|
||||
if (model == null) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "历史记录不存在");
|
||||
}
|
||||
return Result.succeed(model);
|
||||
}
|
||||
|
||||
private String getUserInfoId(Context ctx) {
|
||||
@@ -190,13 +284,8 @@ public class NoteController extends BaseController {
|
||||
if (null != ctx.header("Authorization")) {
|
||||
String token = ctx.header("Authorization");
|
||||
token = token.replace("Bearer ", "");
|
||||
System.out.println("token:" + token);
|
||||
Claims claims = JwtUtils.parseJwt(token);
|
||||
System.out.println("claims:" + claims);
|
||||
if (null != claims) {
|
||||
System.out.println("username:" + claims.get("user_name"));
|
||||
System.out.println("userid:" + claims.get("user_id"));
|
||||
System.out.println("exp:" + claims.get("exp"));
|
||||
userId = claims.get("user_id").toString();
|
||||
return userId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.cyynote.controller;
|
||||
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import java.sql.SQLException;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
import org.noear.solon.annotation.Body;
|
||||
import org.noear.solon.annotation.Controller;
|
||||
import org.noear.solon.annotation.Get;
|
||||
import org.noear.solon.annotation.Mapping;
|
||||
import org.noear.solon.annotation.Post;
|
||||
import org.noear.solon.core.handle.Context;
|
||||
import org.noear.solon.core.handle.Result;
|
||||
import org.noear.wood.DbContext;
|
||||
import org.noear.wood.DbTableQuery;
|
||||
import org.noear.wood.DataItem;
|
||||
import org.noear.wood.annotation.Db;
|
||||
|
||||
@Mapping("/api/settings")
|
||||
@Controller
|
||||
public class SettingsController extends BaseController {
|
||||
private static final Logger log = Logger.getLogger(SettingsController.class.getName());
|
||||
|
||||
@Db
|
||||
DbContext db;
|
||||
|
||||
// Allowed setting keys (whitelist for security)
|
||||
private static final List<String> ALLOWED_KEYS = List.of(
|
||||
"r2_endpoint", "r2_access_key_id", "r2_secret_access_key", "r2_bucket_name",
|
||||
"r2_public_url", "app_theme", "local_storage_path"
|
||||
);
|
||||
|
||||
@Get
|
||||
@Mapping("/get/{key}")
|
||||
public Result getSetting(Context ctx, String key) throws SQLException {
|
||||
if (!ALLOWED_KEYS.contains(key)) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "不支持的配置项");
|
||||
}
|
||||
DataItem row = (DataItem) this.db.table("settings").whereEq("key", key).selectItem("key,value", DataItem.class);
|
||||
if (row == null) return Result.succeed(null);
|
||||
String value = row.getString("value");
|
||||
if ("r2_secret_access_key".equals(key) && value != null && value.length() > 4) {
|
||||
value = "****" + value.substring(value.length() - 4);
|
||||
}
|
||||
return Result.succeed(value);
|
||||
}
|
||||
|
||||
@Get
|
||||
@Mapping("/all")
|
||||
public Result getAllSettings(Context ctx) throws SQLException {
|
||||
JSONObject result = new JSONObject();
|
||||
for (String key : ALLOWED_KEYS) {
|
||||
DataItem row = (DataItem) this.db.table("settings").whereEq("key", key).selectItem("key,value", DataItem.class);
|
||||
String value = row == null ? null : row.getString("value");
|
||||
if ("r2_secret_access_key".equals(key) && value != null && value.length() > 4) {
|
||||
value = "****" + value.substring(value.length() - 4);
|
||||
}
|
||||
result.put(key, value);
|
||||
}
|
||||
return Result.succeed(result);
|
||||
}
|
||||
|
||||
@Post
|
||||
@Mapping("/set")
|
||||
public Result setSetting(Context ctx, @Body JSONObject body) throws SQLException {
|
||||
if (body == null) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "参数不能为空");
|
||||
}
|
||||
for (String key : body.keySet()) {
|
||||
if (!ALLOWED_KEYS.contains(key)) {
|
||||
ctx.status(400);
|
||||
return Result.failure(400, "不支持的配置项: " + key);
|
||||
}
|
||||
String value = body.getString(key);
|
||||
long exists = this.db.table("settings").whereEq("key", key).selectCount();
|
||||
if (exists > 0) {
|
||||
this.db.table("settings").set("value", value).whereEq("key", key).update();
|
||||
} else {
|
||||
this.db.table("settings").set("key", key).set("value", value).insert();
|
||||
}
|
||||
}
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
}
|
||||
@@ -1,59 +1,174 @@
|
||||
package com.cyynote.dso;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
import javax.sql.DataSource;
|
||||
|
||||
public class DsHelper {
|
||||
private static final Logger log = Logger.getLogger(DsHelper.class.getName());
|
||||
private static boolean inited = false;
|
||||
|
||||
public static void initData(DataSource ds) {
|
||||
if (inited)
|
||||
return;
|
||||
if (inited) return;
|
||||
inited = true;
|
||||
Connection conn = null;
|
||||
try {
|
||||
conn = ds.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement("SELECT name FROM sqlite_master WHERE type='table' AND name='appx';");
|
||||
ResultSet res = ps.executeQuery();
|
||||
if (!res.next()) {
|
||||
String[] sqls = getSqlFromFile();
|
||||
for (String sql : sqls)
|
||||
runSql(conn, sql);
|
||||
try (Connection conn = ds.getConnection()) {
|
||||
// 1. Initial schema: create all base tables if not present
|
||||
if (!tableExists(conn, "appx")) {
|
||||
log.info("[DB] Running initial schema...");
|
||||
for (String sql : loadSqlFile("/db/schema.sql")) {
|
||||
runSqlIgnoreError(conn, sql, null);
|
||||
}
|
||||
ps.close();
|
||||
} catch (SQLException sqlException) {
|
||||
throw new RuntimeException(sqlException);
|
||||
} finally {
|
||||
try {
|
||||
if (conn != null)
|
||||
conn.close();
|
||||
} catch (SQLException sQLException) {}
|
||||
}
|
||||
// 2. Versioned migrations
|
||||
runMigrations(conn);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB init failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String[] getSqlFromFile() {
|
||||
private static void runMigrations(Connection conn) throws SQLException {
|
||||
runSql(conn, "CREATE TABLE IF NOT EXISTS db_version (" +
|
||||
"version INTEGER PRIMARY KEY, applied_at TEXT)");
|
||||
|
||||
List<String> migrationFiles = listMigrationFiles();
|
||||
Collections.sort(migrationFiles);
|
||||
|
||||
for (String fileName : migrationFiles) {
|
||||
int version = parseMigrationVersion(fileName);
|
||||
if (version < 0) continue;
|
||||
if (isMigrationApplied(conn, version)) continue;
|
||||
|
||||
log.info("[DB] Running migration: " + fileName);
|
||||
String[] statements = loadSqlFile("/db/migrations/" + fileName);
|
||||
for (String sql : statements) {
|
||||
runSqlIgnoreError(conn, sql, "duplicate column");
|
||||
}
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"INSERT OR IGNORE INTO db_version(version, applied_at) VALUES(?, datetime('now'))");
|
||||
ps.setInt(1, version);
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
log.info("[DB] Migration V" + version + " applied.");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isMigrationApplied(Connection conn, int version) throws SQLException {
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT 1 FROM db_version WHERE version = ?");
|
||||
ps.setInt(1, version);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
boolean exists = rs.next();
|
||||
ps.close();
|
||||
return exists;
|
||||
}
|
||||
|
||||
private static List<String> listMigrationFiles() {
|
||||
List<String> files = new ArrayList<>();
|
||||
try {
|
||||
InputStream ins = com.cyynote.dso.DsHelper.class.getResourceAsStream("/db/schema.sql");
|
||||
int len = ins.available();
|
||||
byte[] bs = new byte[len];
|
||||
ins.read(bs);
|
||||
String str = new String(bs, "UTF-8");
|
||||
String[] sql = str.split(";");
|
||||
return sql;
|
||||
URL dirUrl = DsHelper.class.getResource("/db/migrations/");
|
||||
if (dirUrl == null) return files;
|
||||
String protocol = dirUrl.getProtocol();
|
||||
if ("file".equals(protocol)) {
|
||||
java.io.File dir = new java.io.File(dirUrl.toURI());
|
||||
if (dir.isDirectory()) {
|
||||
for (java.io.File f : dir.listFiles()) {
|
||||
if (f.getName().startsWith("V") && f.getName().endsWith(".sql")) {
|
||||
files.add(f.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ("jar".equals(protocol)) {
|
||||
String jarPath = dirUrl.getPath();
|
||||
String jarFile = jarPath.substring(5, jarPath.indexOf("!"));
|
||||
String prefix = jarPath.substring(jarPath.indexOf("!") + 2);
|
||||
try (java.util.jar.JarFile jar = new java.util.jar.JarFile(jarFile)) {
|
||||
java.util.Enumeration<java.util.jar.JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
String name = entries.nextElement().getName();
|
||||
if (name.startsWith(prefix) && name.endsWith(".sql")) {
|
||||
String fileName = name.substring(name.lastIndexOf('/') + 1);
|
||||
if (fileName.startsWith("V")) {
|
||||
files.add(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warning("[DB] Could not list migration files: " + e.getMessage());
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private static int parseMigrationVersion(String fileName) {
|
||||
try {
|
||||
String num = fileName.substring(1, fileName.indexOf("__"));
|
||||
return Integer.parseInt(num);
|
||||
} catch (Exception e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean tableExists(Connection conn, String tableName) throws SQLException {
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?");
|
||||
ps.setString(1, tableName);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
boolean exists = rs.next();
|
||||
ps.close();
|
||||
return exists;
|
||||
}
|
||||
|
||||
private static String[] loadSqlFile(String path) {
|
||||
try {
|
||||
InputStream ins = DsHelper.class.getResourceAsStream(path);
|
||||
if (ins == null) return new String[0];
|
||||
byte[] bs = ins.readAllBytes();
|
||||
String str = new String(bs, StandardCharsets.UTF_8);
|
||||
String[] parts = str.split(";");
|
||||
List<String> result = new ArrayList<>();
|
||||
for (String p : parts) {
|
||||
String trimmed = p.trim();
|
||||
if (!trimmed.isEmpty()) result.add(trimmed);
|
||||
}
|
||||
return result.toArray(new String[0]);
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
throw new RuntimeException("Failed to load SQL file: " + path, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void runSql(Connection conn, String sql) throws SQLException {
|
||||
System.out.println(sql);
|
||||
PreparedStatement ps = conn.prepareStatement(sql);
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
Statement st = conn.createStatement();
|
||||
st.execute(sql);
|
||||
st.close();
|
||||
}
|
||||
|
||||
private static void runSqlIgnoreError(Connection conn, String sql, String ignoreContaining) {
|
||||
try {
|
||||
String trimmed = sql.trim();
|
||||
if (trimmed.isEmpty()) return;
|
||||
log.info("[DB SQL] " + trimmed.substring(0, Math.min(80, trimmed.length())));
|
||||
Statement st = conn.createStatement();
|
||||
st.execute(trimmed);
|
||||
st.close();
|
||||
} catch (SQLException e) {
|
||||
if (ignoreContaining != null && e.getMessage() != null &&
|
||||
e.getMessage().toLowerCase().contains(ignoreContaining.toLowerCase())) {
|
||||
log.info("[DB] Ignored: " + e.getMessage());
|
||||
} else {
|
||||
log.warning("[DB] SQL error (non-fatal): " + e.getMessage() + " | SQL: " + sql.trim().substring(0, Math.min(80, sql.trim().length())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.cyynote.model;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.wood.annotation.PrimaryKey;
|
||||
import org.noear.wood.annotation.Table;
|
||||
|
||||
@Data
|
||||
@Table("drive_file")
|
||||
public class DriveFileModel {
|
||||
@PrimaryKey
|
||||
public Integer id;
|
||||
public String filename;
|
||||
public String originalName;
|
||||
public Long size;
|
||||
public String mimeType;
|
||||
public String r2Key;
|
||||
public String shareToken;
|
||||
public String shareExpiry;
|
||||
public Integer userid;
|
||||
public String createtime;
|
||||
public Integer flag;
|
||||
public String storageMode;
|
||||
public String localPath;
|
||||
|
||||
public void setId(Integer id) { this.id = id; }
|
||||
public Integer getId() { return id; }
|
||||
|
||||
public void setFilename(String filename) { this.filename = filename; }
|
||||
public String getFilename() { return filename; }
|
||||
|
||||
public void setOriginalName(String originalName) { this.originalName = originalName; }
|
||||
public String getOriginalName() { return originalName; }
|
||||
|
||||
public void setSize(Long size) { this.size = size; }
|
||||
public Long getSize() { return size; }
|
||||
|
||||
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
|
||||
public String getMimeType() { return mimeType; }
|
||||
|
||||
public void setR2Key(String r2Key) { this.r2Key = r2Key; }
|
||||
public String getR2Key() { return r2Key; }
|
||||
|
||||
public void setShareToken(String shareToken) { this.shareToken = shareToken; }
|
||||
public String getShareToken() { return shareToken; }
|
||||
|
||||
public void setShareExpiry(String shareExpiry) { this.shareExpiry = shareExpiry; }
|
||||
public String getShareExpiry() { return shareExpiry; }
|
||||
|
||||
public void setUserid(Integer userid) { this.userid = userid; }
|
||||
public Integer getUserid() { return userid; }
|
||||
|
||||
public void setCreatetime(String createtime) { this.createtime = createtime; }
|
||||
public String getCreatetime() { return createtime; }
|
||||
|
||||
public void setFlag(Integer flag) { this.flag = flag; }
|
||||
public Integer getFlag() { return flag; }
|
||||
|
||||
public void setStorageMode(String storageMode) { this.storageMode = storageMode; }
|
||||
public String getStorageMode() { return storageMode; }
|
||||
|
||||
public void setLocalPath(String localPath) { this.localPath = localPath; }
|
||||
public String getLocalPath() { return localPath; }
|
||||
}
|
||||
@@ -25,8 +25,12 @@ public class NoteModel {
|
||||
|
||||
public String viewtime;
|
||||
|
||||
public Integer userid;
|
||||
|
||||
public Integer flag;
|
||||
|
||||
public String tags;
|
||||
|
||||
public void setId(Integer id) {
|
||||
this.id = id;
|
||||
}
|
||||
@@ -63,6 +67,14 @@ public class NoteModel {
|
||||
this.flag = flag;
|
||||
}
|
||||
|
||||
public void setUserid(Integer userid) {
|
||||
this.userid = userid;
|
||||
}
|
||||
|
||||
public Integer getUserid() {
|
||||
return this.userid;
|
||||
}
|
||||
|
||||
|
||||
public String toString() {
|
||||
return "NoteModel(id=" + getId() + ", pid=" + getPid() + ", title=" + getTitle() + ", context=" + getContext() + ", conjson=" + getConjson() + ", createtime=" + getCreatetime() + ", updatetime=" + getUpdatetime() + ", viewtime=" + getViewtime() + ", flag=" + getFlag() + ")";
|
||||
@@ -103,5 +115,13 @@ public class NoteModel {
|
||||
public Integer getFlag() {
|
||||
return this.flag;
|
||||
}
|
||||
|
||||
public void setTags(String tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
public String getTags() {
|
||||
return this.tags;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ public class UserModel {
|
||||
|
||||
public String logintime;
|
||||
|
||||
public Integer passwordHashed;
|
||||
|
||||
public void setId(Integer id) {
|
||||
this.id = id;
|
||||
}
|
||||
@@ -51,6 +53,14 @@ public class UserModel {
|
||||
this.logintime = logintime;
|
||||
}
|
||||
|
||||
public Integer getPasswordHashed() {
|
||||
return this.passwordHashed;
|
||||
}
|
||||
|
||||
public void setPasswordHashed(Integer passwordHashed) {
|
||||
this.passwordHashed = passwordHashed;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return "UserModel(id=" + getId() + ", email=" + getEmail() + ", password=" + getPassword() + ", username=" + getUsername() + ", role=" + getRole() + ", token=" + getToken() + ", logintime=" + getLogintime() + ")";
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ public class NoteRequest {
|
||||
|
||||
private String context;
|
||||
|
||||
private String tags;
|
||||
|
||||
public Long getId() {
|
||||
return this.id;
|
||||
}
|
||||
@@ -41,4 +43,12 @@ public class NoteRequest {
|
||||
public void setContext(String context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public String getTags() {
|
||||
return this.tags;
|
||||
}
|
||||
|
||||
public void setTags(String tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
test.db1:
|
||||
jdbcUrl: "jdbc:sqlite:D:/cyynote.db"
|
||||
jdbcUrl: "jdbc:sqlite:./cyynote.db"
|
||||
driverClassName: "org.sqlite.JDBC"
|
||||
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drive_file (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT,
|
||||
original_name TEXT,
|
||||
size INTEGER,
|
||||
mime_type TEXT,
|
||||
r2_key TEXT,
|
||||
share_token TEXT,
|
||||
share_expiry TEXT,
|
||||
userid INTEGER,
|
||||
createtime TEXT,
|
||||
flag INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
ALTER TABLE user ADD COLUMN password_hashed INTEGER DEFAULT 0;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Ensure note.userid column exists (for older databases created before this field was added)
|
||||
ALTER TABLE note ADD COLUMN userid INTEGER DEFAULT 0;
|
||||
|
||||
-- Index for faster per-user queries
|
||||
CREATE INDEX IF NOT EXISTS idx_note_userid ON note(userid);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_flag_userid ON note(flag, userid);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- V4: Assign existing notes without an owner to userid=1 (first admin)
|
||||
-- This ensures production data created before multi-user isolation is not lost.
|
||||
UPDATE note SET userid = 1 WHERE userid IS NULL OR userid = 0;
|
||||
|
||||
INSERT OR IGNORE INTO db_version (version, applied_at) VALUES (4, datetime('now'));
|
||||
@@ -0,0 +1,4 @@
|
||||
-- V5: Add tags column to note table
|
||||
ALTER TABLE note ADD COLUMN tags TEXT DEFAULT '';
|
||||
|
||||
INSERT OR IGNORE INTO db_version (version, applied_at) VALUES (5, datetime('now'));
|
||||
@@ -0,0 +1,5 @@
|
||||
-- V6: Add local storage support for drive files
|
||||
ALTER TABLE drive_file ADD COLUMN storage_mode TEXT DEFAULT 'r2';
|
||||
ALTER TABLE drive_file ADD COLUMN local_path TEXT;
|
||||
|
||||
INSERT OR IGNORE INTO db_version (version, applied_at) VALUES (6, datetime('now'));
|
||||
@@ -32,7 +32,8 @@ CREATE TABLE `history` (
|
||||
conjson TEXT,
|
||||
createtime TEXT,
|
||||
userid INTEGER,
|
||||
username TEXT
|
||||
username TEXT,
|
||||
flag INTEGER DEFAULT 1
|
||||
);
|
||||
CREATE TABLE appx (
|
||||
app_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
test.db1:
|
||||
jdbcUrl: "jdbc:sqlite:D:/cyynote.db"
|
||||
jdbcUrl: "jdbc:sqlite:./cyynote.db"
|
||||
driverClassName: "org.sqlite.JDBC"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drive_file (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT,
|
||||
original_name TEXT,
|
||||
size INTEGER,
|
||||
mime_type TEXT,
|
||||
r2_key TEXT,
|
||||
share_token TEXT,
|
||||
share_expiry TEXT,
|
||||
userid INTEGER,
|
||||
createtime TEXT,
|
||||
flag INTEGER DEFAULT 1
|
||||
);
|
||||
|
||||
ALTER TABLE user ADD COLUMN password_hashed INTEGER DEFAULT 0;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Ensure note.userid column exists (for older databases created before this field was added)
|
||||
ALTER TABLE note ADD COLUMN userid INTEGER DEFAULT 0;
|
||||
|
||||
-- Index for faster per-user queries
|
||||
CREATE INDEX IF NOT EXISTS idx_note_userid ON note(userid);
|
||||
CREATE INDEX IF NOT EXISTS idx_note_flag_userid ON note(flag, userid);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- V4: Assign existing notes without an owner to userid=1 (first admin)
|
||||
-- This ensures production data created before multi-user isolation is not lost.
|
||||
UPDATE note SET userid = 1 WHERE userid IS NULL OR userid = 0;
|
||||
|
||||
INSERT OR IGNORE INTO db_version (version, applied_at) VALUES (4, datetime('now'));
|
||||
@@ -0,0 +1,4 @@
|
||||
-- V5: Add tags column to note table
|
||||
ALTER TABLE note ADD COLUMN tags TEXT DEFAULT '';
|
||||
|
||||
INSERT OR IGNORE INTO db_version (version, applied_at) VALUES (5, datetime('now'));
|
||||
@@ -32,7 +32,8 @@ CREATE TABLE `history` (
|
||||
conjson TEXT,
|
||||
createtime TEXT,
|
||||
userid INTEGER,
|
||||
username TEXT
|
||||
username TEXT,
|
||||
flag INTEGER DEFAULT 1
|
||||
);
|
||||
CREATE TABLE appx (
|
||||
app_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
#Generated by Maven
|
||||
#Wed Feb 05 19:29:06 CST 2025
|
||||
#Fri May 15 17:58:56 CST 2026
|
||||
groupId=com.cyynote
|
||||
artifactId=cyynote
|
||||
version=1.0.0
|
||||
|
||||
+30
-27
@@ -1,27 +1,30 @@
|
||||
com\cyynote\payload\request\UpdatePasswordRequest.class
|
||||
com\cyynote\controller\BaseController.class
|
||||
com\cyynote\dso\DsHelper.class
|
||||
com\cyynote\model\HistoryModel.class
|
||||
com\cyynote\model\Appx.class
|
||||
com\cyynote\payload\request\SignupRequest.class
|
||||
com\cyynote\dso\AuthProcessorImpl.class
|
||||
com\cyynote\controller\Test2Controller.class
|
||||
com\cyynote\payload\response\MessageResponse.class
|
||||
com\cyynote\WebApp.class
|
||||
com\cyynote\dso\AuthSqlAnnotation.class
|
||||
com\cyynote\controller\JwtInterceptor.class
|
||||
com\cyynote\model\AppxModel.class
|
||||
com\cyynote\controller\DemoController.class
|
||||
com\cyynote\Config.class
|
||||
com\cyynote\util\TreeBuild.class
|
||||
com\cyynote\dso\SqlMapper.class
|
||||
com\cyynote\model\UserModel.class
|
||||
com\cyynote\payload\response\JwtResponse.class
|
||||
com\cyynote\dso\NoteSqlAnnotation.class
|
||||
com\cyynote\payload\request\LoginRequest.class
|
||||
com\cyynote\controller\NoteController.class
|
||||
com\cyynote\payload\request\NoteRequest.class
|
||||
com\cyynote\util\TreeNode.class
|
||||
com\cyynote\model\NoteModel.class
|
||||
com\cyynote\dso\SqlAnnotation.class
|
||||
com\cyynote\controller\AuthController.class
|
||||
com/cyynote/payload/request/LoginRequest.class
|
||||
com/cyynote/dso/DsHelper.class
|
||||
com/cyynote/controller/SettingsController.class
|
||||
com/cyynote/controller/JwtInterceptor.class
|
||||
com/cyynote/util/TreeNode.class
|
||||
com/cyynote/model/Appx.class
|
||||
com/cyynote/model/UserModel.class
|
||||
com/cyynote/payload/request/SignupRequest.class
|
||||
com/cyynote/dso/AuthProcessorImpl.class
|
||||
com/cyynote/payload/response/MessageResponse.class
|
||||
com/cyynote/controller/NoteController.class
|
||||
com/cyynote/controller/DemoController.class
|
||||
com/cyynote/model/NoteModel.class
|
||||
com/cyynote/Config.class
|
||||
com/cyynote/payload/request/UpdatePasswordRequest.class
|
||||
com/cyynote/payload/request/NoteRequest.class
|
||||
com/cyynote/WebApp.class
|
||||
com/cyynote/controller/DriveController.class
|
||||
com/cyynote/dso/AuthSqlAnnotation.class
|
||||
com/cyynote/dso/SqlMapper.class
|
||||
com/cyynote/payload/response/JwtResponse.class
|
||||
com/cyynote/controller/AuthController.class
|
||||
com/cyynote/dso/NoteSqlAnnotation.class
|
||||
com/cyynote/model/AppxModel.class
|
||||
com/cyynote/model/DriveFileModel.class
|
||||
com/cyynote/dso/SqlAnnotation.class
|
||||
com/cyynote/controller/BaseController.class
|
||||
com/cyynote/util/TreeBuild.class
|
||||
com/cyynote/controller/Test2Controller.class
|
||||
com/cyynote/model/HistoryModel.class
|
||||
|
||||
+30
-27
@@ -1,27 +1,30 @@
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\Config.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\controller\AuthController.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\controller\BaseController.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\controller\DemoController.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\controller\JwtInterceptor.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\controller\NoteController.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\controller\Test2Controller.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\dso\AuthProcessorImpl.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\dso\AuthSqlAnnotation.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\dso\DsHelper.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\dso\NoteSqlAnnotation.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\dso\SqlAnnotation.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\dso\SqlMapper.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\model\Appx.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\model\AppxModel.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\model\HistoryModel.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\model\NoteModel.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\model\UserModel.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\payload\request\LoginRequest.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\payload\request\NoteRequest.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\payload\request\SignupRequest.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\payload\request\UpdatePasswordRequest.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\payload\response\JwtResponse.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\payload\response\MessageResponse.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\util\TreeBuild.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\util\TreeNode.java
|
||||
D:\chuyy_code\cyynote\cyynote-backend\src\main\java\com\cyynote\WebApp.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/Config.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/WebApp.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/AuthController.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/BaseController.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/DemoController.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/DriveController.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/JwtInterceptor.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/NoteController.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/SettingsController.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/Test2Controller.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/AuthProcessorImpl.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/AuthSqlAnnotation.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/DsHelper.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/NoteSqlAnnotation.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/SqlAnnotation.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/SqlMapper.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/Appx.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/AppxModel.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/DriveFileModel.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/HistoryModel.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/NoteModel.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/UserModel.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/payload/request/LoginRequest.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/payload/request/NoteRequest.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/payload/request/SignupRequest.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/payload/request/UpdatePasswordRequest.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/payload/response/JwtResponse.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/payload/response/MessageResponse.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/util/TreeBuild.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/util/TreeNode.java
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Copy to .env and fill in your values
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_WS_URL=ws://localhost:1234
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Type check
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Unit tests
|
||||
run: pnpm test
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
@@ -0,0 +1 @@
|
||||
shamefully-hoist=true
|
||||
@@ -0,0 +1 @@
|
||||
20
|
||||
@@ -0,0 +1,31 @@
|
||||
// @ts-check
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import globals from 'globals';
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist', 'node_modules'] },
|
||||
{
|
||||
extends: [...tseslint.configs.recommendedTypeChecked],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
project: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,80 +1,72 @@
|
||||
|
||||
{
|
||||
"name": "cyynote",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"pnpm": ">=9.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
"build": "pnpm typecheck && vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alkhipce/editorjs-react": "^0.1.4",
|
||||
"@blocknote/core": "^0.10.0",
|
||||
"@blocknote/react": "^0.10.0",
|
||||
"@douyinfe/semi-icons": "^2.48.0",
|
||||
"@douyinfe/semi-icons-lab": "^2.48.0",
|
||||
"@douyinfe/semi-ui": "^2.48.0",
|
||||
"@editorjs/checklist": "^1.6.0",
|
||||
"@editorjs/code": "^2.9.0",
|
||||
"@editorjs/delimiter": "^1.4.0",
|
||||
"@editorjs/editorjs": "^2.28.2",
|
||||
"@editorjs/embed": "^2.7.0",
|
||||
"@editorjs/header": "^2.8.1",
|
||||
"@editorjs/image": "^2.9.0",
|
||||
"@editorjs/inline-code": "^1.5.0",
|
||||
"@editorjs/link": "^2.6.2",
|
||||
"@editorjs/list": "^1.9.0",
|
||||
"@editorjs/marker": "^1.4.0",
|
||||
"@editorjs/paragraph": "^2.11.3",
|
||||
"@editorjs/quote": "^2.6.0",
|
||||
"@editorjs/raw": "^2.5.0",
|
||||
"@editorjs/simple-image": "^1.6.0",
|
||||
"@editorjs/table": "^2.3.0",
|
||||
"@editorjs/warning": "^1.4.0",
|
||||
"@excalidraw/excalidraw": "^0.17.2",
|
||||
"@lexical/clipboard": "^0.12.5",
|
||||
"@lexical/code": "^0.12.5",
|
||||
"@lexical/file": "^0.12.5",
|
||||
"@lexical/hashtag": "^0.12.5",
|
||||
"@lexical/link": "^0.12.5",
|
||||
"@lexical/list": "^0.12.5",
|
||||
"@lexical/mark": "^0.12.5",
|
||||
"@lexical/overflow": "^0.12.5",
|
||||
"@lexical/plain-text": "^0.12.5",
|
||||
"@lexical/react": "^0.12.5",
|
||||
"@lexical/rich-text": "^0.12.5",
|
||||
"@lexical/selection": "^0.12.5",
|
||||
"@lexical/table": "^0.12.5",
|
||||
"@lexical/utils": "^0.12.5",
|
||||
"axios": "^1.6.2",
|
||||
"editorjs-html": "^3.4.3",
|
||||
"formik": "^2.4.5",
|
||||
"html-react-parser": "^5.0.7",
|
||||
"katex": "^0.16.9",
|
||||
"lexical": "^0.12.5",
|
||||
"prettier": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-editor-js": "^2.1.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"sass": "^1.69.5",
|
||||
"y-websocket": "^1.5.0",
|
||||
"yjs": "^13.6.10",
|
||||
"yup": "^1.3.2"
|
||||
"@douyinfe/semi-icons": "2.96.0",
|
||||
"@douyinfe/semi-icons-lab": "2.96.0",
|
||||
"@douyinfe/semi-ui": "2.96.0",
|
||||
"@excalidraw/excalidraw": "^0.17.6",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@lexical/clipboard": "0.44.0",
|
||||
"@lexical/code": "0.44.0",
|
||||
"@lexical/file": "0.44.0",
|
||||
"@lexical/hashtag": "0.44.0",
|
||||
"@lexical/link": "0.44.0",
|
||||
"@lexical/list": "0.44.0",
|
||||
"@lexical/mark": "0.44.0",
|
||||
"@lexical/overflow": "0.44.0",
|
||||
"@lexical/plain-text": "0.44.0",
|
||||
"@lexical/react": "0.44.0",
|
||||
"@lexical/rich-text": "0.44.0",
|
||||
"@lexical/selection": "0.44.0",
|
||||
"@lexical/table": "0.44.0",
|
||||
"@lexical/utils": "0.44.0",
|
||||
"@lexical/yjs": "0.44.0",
|
||||
"axios": "^1.8.0",
|
||||
"html-react-parser": "^5.2.0",
|
||||
"katex": "^0.16.21",
|
||||
"lexical": "0.44.0",
|
||||
"lodash-es": "^4.18.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.55.0",
|
||||
"react-router": "^7.5.0",
|
||||
"y-websocket": "^3.0.0",
|
||||
"yjs": "^13.6.22",
|
||||
"yup": "^1.6.0",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.0.0"
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "^18.3.20",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.30.0",
|
||||
"@typescript-eslint/parser": "^8.30.0",
|
||||
"@vitejs/plugin-react": "^4.4.0",
|
||||
"eslint": "^9.24.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.0.0",
|
||||
"prettier": "^3.5.0",
|
||||
"sass": "^1.86.0",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "^8.30.0",
|
||||
"vite": "^6.3.0",
|
||||
"vitest": "^3.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+5537
File diff suppressed because it is too large
Load Diff
+24
-150
@@ -1,163 +1,37 @@
|
||||
import { Component } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import "./App.css";
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router';
|
||||
import './App.css';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
import AuthService from "./services/auth.service";
|
||||
import IUser from './types/user.type';
|
||||
|
||||
// import Login from "./components/login.component";
|
||||
import Register from "./components/register.component";
|
||||
// import Home from "./components/home.component";
|
||||
import Profile from "./components/profile.component";
|
||||
import BoardUser from "./components/board-user.component";
|
||||
import BoardModerator from "./components/board-moderator.component";
|
||||
import BoardAdmin from "./components/board-admin.component";
|
||||
|
||||
|
||||
import NotePage from "./pages/note-page.tsx";
|
||||
import EventBus from "./common/EventBus";
|
||||
import SignIn from "./pages/sign-in.tsx";
|
||||
import Test from "./pages/test.tsx";
|
||||
// "build": "tsc && vite build",
|
||||
|
||||
|
||||
type Props = {};
|
||||
|
||||
type State = {
|
||||
showModeratorBoard: boolean,
|
||||
showAdminBoard: boolean,
|
||||
currentUser: IUser | undefined
|
||||
}
|
||||
|
||||
class App extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.logOut = this.logOut.bind(this);
|
||||
|
||||
this.state = {
|
||||
showModeratorBoard: false,
|
||||
showAdminBoard: false,
|
||||
currentUser: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const user = AuthService.getCurrentUser();
|
||||
|
||||
if (user) {
|
||||
this.setState({
|
||||
currentUser: user,
|
||||
showModeratorBoard: user.roles.includes("ROLE_MODERATOR"),
|
||||
showAdminBoard: user.roles.includes("ROLE_ADMIN"),
|
||||
});
|
||||
}
|
||||
|
||||
EventBus.on("logout", this.logOut);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
EventBus.remove("logout", this.logOut);
|
||||
}
|
||||
|
||||
logOut() {
|
||||
AuthService.logout();
|
||||
this.setState({
|
||||
showModeratorBoard: false,
|
||||
showAdminBoard: false,
|
||||
currentUser: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// const { currentUser, showModeratorBoard, showAdminBoard } = this.state;
|
||||
const NotePage = lazy(() => import('./pages/note-page'));
|
||||
const SignIn = lazy(() => import('./pages/sign-in'));
|
||||
const Register = lazy(() => import('./components/register.component'));
|
||||
const Profile = lazy(() => import('./components/profile.component'));
|
||||
const BoardUser = lazy(() => import('./components/board-user.component'));
|
||||
const BoardModerator = lazy(() => import('./components/board-moderator.component'));
|
||||
const BoardAdmin = lazy(() => import('./components/board-admin.component'));
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<div>
|
||||
{/*<nav className="navbar navbar-expand navbar-dark bg-dark">*/}
|
||||
{/* <Link to={"/"} className="navbar-brand">*/}
|
||||
{/* bezKoder*/}
|
||||
{/* </Link>*/}
|
||||
{/* <div className="navbar-nav mr-auto">*/}
|
||||
{/* <li className="nav-item">*/}
|
||||
{/* <Link to={"/home"} className="nav-link">*/}
|
||||
{/* Home*/}
|
||||
{/* </Link>*/}
|
||||
{/* </li>*/}
|
||||
|
||||
{/* {showModeratorBoard && (*/}
|
||||
{/* <li className="nav-item">*/}
|
||||
{/* <Link to={"/mod"} className="nav-link">*/}
|
||||
{/* Moderator Board*/}
|
||||
{/* </Link>*/}
|
||||
{/* </li>*/}
|
||||
{/* )}*/}
|
||||
|
||||
{/* {showAdminBoard && (*/}
|
||||
{/* <li className="nav-item">*/}
|
||||
{/* <Link to={"/admin"} className="nav-link">*/}
|
||||
{/* Admin Board*/}
|
||||
{/* </Link>*/}
|
||||
{/* </li>*/}
|
||||
{/* )}*/}
|
||||
|
||||
{/* {currentUser && (*/}
|
||||
{/* <li className="nav-item">*/}
|
||||
{/* <Link to={"/user"} className="nav-link">*/}
|
||||
{/* User*/}
|
||||
{/* </Link>*/}
|
||||
{/* </li>*/}
|
||||
{/* )}*/}
|
||||
{/* </div>*/}
|
||||
|
||||
{/* {currentUser ? (*/}
|
||||
{/* <div className="navbar-nav ml-auto">*/}
|
||||
{/* <li className="nav-item">*/}
|
||||
{/* <Link to={"/profile"} className="nav-link">*/}
|
||||
{/* {currentUser.username}*/}
|
||||
{/* </Link>*/}
|
||||
{/* </li>*/}
|
||||
{/* <li className="nav-item">*/}
|
||||
{/* <a href="/login" className="nav-link" onClick={this.logOut}>*/}
|
||||
{/* LogOut*/}
|
||||
{/* </a>*/}
|
||||
{/* </li>*/}
|
||||
{/* </div>*/}
|
||||
{/* ) : (*/}
|
||||
{/* <div className="navbar-nav ml-auto">*/}
|
||||
{/* <li className="nav-item">*/}
|
||||
{/* <Link to={"/login"} className="nav-link">*/}
|
||||
{/* Login*/}
|
||||
{/* </Link>*/}
|
||||
{/* </li>*/}
|
||||
|
||||
{/* <li className="nav-item">*/}
|
||||
{/* <Link to={"/register"} className="nav-link">*/}
|
||||
{/* Sign Up*/}
|
||||
{/* </Link>*/}
|
||||
{/* </li>*/}
|
||||
{/* </div>*/}
|
||||
{/* )}*/}
|
||||
{/*</nav>*/}
|
||||
|
||||
<div className="">
|
||||
<Suspense fallback={<div style={{ padding: 24 }}>Loading…</div>}>
|
||||
<Routes>
|
||||
<Route path="/" element={<NotePage />} />
|
||||
<Route path="/home" element={<NotePage />} />
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<SignIn />} />
|
||||
|
||||
<Route path="/test" element={<Test />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<Navigate to="/home" replace />} />
|
||||
<Route path="/home" element={<NotePage />} />
|
||||
<Route path="/profile" element={<Profile />} />
|
||||
<Route path="/user" element={<BoardUser />} />
|
||||
<Route path="/mod" element={<BoardModerator />} />
|
||||
<Route path="/admin" element={<BoardAdmin />} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
{ /*<AuthVerify logOut={this.logOut}/> */}
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Navigate, Outlet } from 'react-router';
|
||||
import { useAuthStore } from '../stores/useAuthStore';
|
||||
|
||||
/** Wraps routes that require authentication. Redirects to /login if not signed in. */
|
||||
export default function ProtectedRoute() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
return user ? <Outlet /> : <Navigate to="/login" replace />;
|
||||
}
|
||||
@@ -1,54 +1,23 @@
|
||||
import { Component } from "react";
|
||||
import { useEffect, useState } from 'react';
|
||||
import userService from '../services/user.service';
|
||||
|
||||
import UserService from "../services/user.service";
|
||||
import EventBus from "../common/EventBus";
|
||||
export default function BoardAdmin() {
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
type Props = {};
|
||||
|
||||
type State = {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default class BoardAdmin extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
content: ""
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
UserService.getAdminBoard().then(
|
||||
response => {
|
||||
this.setState({
|
||||
content: response.data
|
||||
});
|
||||
useEffect(() => {
|
||||
userService.getAdminBoard().then(
|
||||
(res) => setContent(String(res.data ?? '')),
|
||||
(error: unknown) => {
|
||||
setContent(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
error => {
|
||||
this.setState({
|
||||
content:
|
||||
(error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.message) ||
|
||||
error.message ||
|
||||
error.toString()
|
||||
});
|
||||
|
||||
if (error.response && error.response.status === 401) {
|
||||
EventBus.dispatch("logout");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="jumbotron">
|
||||
<h3>{this.state.content}</h3>
|
||||
<h3>{content}</h3>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,23 @@
|
||||
import { Component } from "react";
|
||||
import { useEffect, useState } from 'react';
|
||||
import userService from '../services/user.service';
|
||||
|
||||
import UserService from "../services/user.service";
|
||||
import EventBus from "../common/EventBus";
|
||||
export default function BoardModerator() {
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
type Props = {};
|
||||
|
||||
type State = {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default class BoardAdmin extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
content: ""
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
UserService.getModeratorBoard().then(
|
||||
response => {
|
||||
this.setState({
|
||||
content: response.data
|
||||
});
|
||||
useEffect(() => {
|
||||
userService.getModeratorBoard().then(
|
||||
(res) => setContent(String(res.data ?? '')),
|
||||
(error: unknown) => {
|
||||
setContent(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
error => {
|
||||
this.setState({
|
||||
content:
|
||||
(error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.message) ||
|
||||
error.message ||
|
||||
error.toString()
|
||||
});
|
||||
|
||||
if (error.response && error.response.status === 401) {
|
||||
EventBus.dispatch("logout");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="jumbotron">
|
||||
<h3>{this.state.content}</h3>
|
||||
<h3>{content}</h3>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,54 +1,23 @@
|
||||
import { Component } from "react";
|
||||
import { useEffect, useState } from 'react';
|
||||
import userService from '../services/user.service';
|
||||
|
||||
import UserService from "../services/user.service";
|
||||
import EventBus from "../common/EventBus";
|
||||
export default function BoardUser() {
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
type Props = {};
|
||||
|
||||
type State = {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default class BoardUser extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
content: ""
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
UserService.getUserBoard().then(
|
||||
response => {
|
||||
this.setState({
|
||||
content: response.data
|
||||
});
|
||||
useEffect(() => {
|
||||
userService.getUserBoard().then(
|
||||
(res) => setContent(String(res.data ?? '')),
|
||||
(error: unknown) => {
|
||||
setContent(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
error => {
|
||||
this.setState({
|
||||
content:
|
||||
(error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.message) ||
|
||||
error.message ||
|
||||
error.toString()
|
||||
});
|
||||
|
||||
if (error.response && error.response.status === 401) {
|
||||
EventBus.dispatch("logout");
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="container">
|
||||
<header className="jumbotron">
|
||||
<h3>{this.state.content}</h3>
|
||||
<h3>{content}</h3>
|
||||
</header>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
|
||||
import {CharacterLimitPlugin} from '@lexical/react/LexicalCharacterLimitPlugin';
|
||||
import {CheckListPlugin} from '@lexical/react/LexicalCheckListPlugin';
|
||||
import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
|
||||
import LexicalClickableLinkPlugin from '@lexical/react/LexicalClickableLinkPlugin';
|
||||
import {ClickableLinkPlugin} from '@lexical/react/LexicalClickableLinkPlugin';
|
||||
import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
|
||||
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
|
||||
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
|
||||
import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
|
||||
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
||||
import {HorizontalRulePlugin} from '@lexical/react/LexicalHorizontalRulePlugin';
|
||||
@@ -21,7 +21,7 @@ import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
|
||||
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
|
||||
import {TabIndentationPlugin} from '@lexical/react/LexicalTabIndentationPlugin';
|
||||
import {TablePlugin} from '@lexical/react/LexicalTablePlugin';
|
||||
import useLexicalEditable from '@lexical/react/useLexicalEditable';
|
||||
import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
|
||||
import {useEffect, useState,useLayoutEffect} from 'react';
|
||||
import {CAN_USE_DOM} from './shared/src/canUseDOM';
|
||||
|
||||
@@ -79,20 +79,19 @@ const skipCollaborationInit =
|
||||
window.parent != null && window.parent.frames.right === window;
|
||||
|
||||
|
||||
function OnChangePlugin({ onChange }) {
|
||||
function OnChangePlugin({ onChange }: { onChange: (editorState: unknown) => void }): null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({editorState}) => {
|
||||
onChange(editorState);
|
||||
});
|
||||
}, [editor, onChange]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Editor(props: { noteId: bigint; noteTitle: string; editData: any; handleLawClick_item2: any; }): JSX.Element {
|
||||
export default function Editor(props: { noteId: number; noteTitle: string; editData: string | null; handleLawClick_item2: any; }): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
console.log(props.noteId)
|
||||
|
||||
const {historyState} = useSharedHistoryContext();
|
||||
const {
|
||||
settings: {
|
||||
@@ -121,7 +120,7 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
|
||||
const [isSmallWidthViewport, setIsSmallWidthViewport] =
|
||||
useState<boolean>(false);
|
||||
const [isLinkEditMode, setIsLinkEditMode] = useState<boolean>(false);
|
||||
const [editorState, setEditorState] = useState();
|
||||
const [editorState, setEditorState] = useState<string | undefined>(undefined);
|
||||
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
|
||||
if (_floatingAnchorElem !== null) {
|
||||
setFloatingAnchorElem(_floatingAnchorElem);
|
||||
@@ -146,18 +145,11 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
|
||||
}, [isSmallWidthViewport]);
|
||||
|
||||
|
||||
function onChange(editorState) {
|
||||
// Call toJSON on the EditorState object, which produces a serialization safe string
|
||||
function onChange(editorState: { toJSON: () => unknown }) {
|
||||
const editorStateJSON = editorState.toJSON();
|
||||
|
||||
// However, we still have a JavaScript object, so we need to convert it to an actual string with JSON.stringify
|
||||
setEditorState(JSON.stringify(editorStateJSON));
|
||||
|
||||
}
|
||||
|
||||
console.log(props.noteId)
|
||||
// console.log(props.editData)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRichText && <ToolbarPlugin setIsLinkEditMode={setIsLinkEditMode} />}
|
||||
@@ -221,7 +213,7 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
|
||||
<TwitterPlugin />
|
||||
<YouTubePlugin />
|
||||
<FigmaPlugin />
|
||||
{!isEditable && <LexicalClickableLinkPlugin />}
|
||||
{!isEditable && <ClickableLinkPlugin />}
|
||||
<HorizontalRulePlugin />
|
||||
<EquationsPlugin />
|
||||
<ExcalidrawPlugin />
|
||||
@@ -270,7 +262,7 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
|
||||
{isAutocomplete && <AutocompletePlugin />}
|
||||
<div>{showTableOfContents && <TableOfContentsPlugin />}</div>
|
||||
{shouldUseLexicalContextMenu && <ContextMenuPlugin />}
|
||||
<ActionsPlugin isRichText={isRichText} noteId={props.noteId} noteTitle={props.noteTitle} editData ={props.editData} handleLawClick_item3={props.handleLawClick_item2}/>
|
||||
<ActionsPlugin noteId={props.noteId} noteTitle={props.noteTitle} editData={props.editData} handleLawClick_item3={props.handleLawClick_item2}/>
|
||||
</div>
|
||||
{showTreeView && <TreeViewPlugin />}
|
||||
</>
|
||||
|
||||
@@ -9,6 +9,7 @@ import "./index.css";
|
||||
import {$createLinkNode} from '@lexical/link';
|
||||
import {$createListItemNode, $createListNode} from '@lexical/list';
|
||||
import {LexicalComposer} from '@lexical/react/LexicalComposer';
|
||||
import {LexicalCollaboration} from '@lexical/react/LexicalCollaborationContext';
|
||||
import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text';
|
||||
import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';
|
||||
|
||||
@@ -111,23 +112,32 @@ function prepopulatedRichText() {
|
||||
}
|
||||
}
|
||||
|
||||
function LexicalApp(props: { noteId: bigint; editData: object | null; noteTitle: string; handleLawClick_item1: any; }): JSX.Element {
|
||||
function LexicalApp(props: { noteId: number; editData: string | null; noteTitle: string; handleLawClick_item1: any; }): JSX.Element {
|
||||
const {
|
||||
settings: {isCollab, emptyEditor, measureTypingPerf},
|
||||
} = useSettings();
|
||||
|
||||
console.log(props.noteId)
|
||||
// console.log(props.editData)
|
||||
console.log(props.editData!==null)
|
||||
console.log(prepopulatedRichText)
|
||||
const resolveEditorState = (): string | null | undefined => {
|
||||
if (props.editData === null) return undefined; // use prepopulatedRichText via function below
|
||||
const raw = props.editData as unknown as string;
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
JSON.parse(raw);
|
||||
return raw;
|
||||
} catch {
|
||||
return null; // not valid Lexical JSON, start with empty editor
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const editorState = resolveEditorState();
|
||||
const resolvedEditorState = editorState === undefined && props.editData === null
|
||||
? prepopulatedRichText
|
||||
: editorState;
|
||||
|
||||
const initialConfig = {
|
||||
// editorState: isCollab
|
||||
// ? null
|
||||
// : emptyEditor
|
||||
// ? undefined
|
||||
// : prepopulatedRichText,
|
||||
editorState: props.editData!==null?props.editData:prepopulatedRichText,
|
||||
editorState: resolvedEditorState,
|
||||
namespace: 'Playground',
|
||||
nodes: [...PlaygroundNodes],
|
||||
onError: (error: Error) => {
|
||||
@@ -137,6 +147,7 @@ function LexicalApp(props: { noteId: bigint; editData: object | null; noteTitle:
|
||||
};
|
||||
|
||||
return (
|
||||
<LexicalCollaboration>
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<SharedHistoryContext>
|
||||
<TableContext>
|
||||
@@ -159,10 +170,11 @@ function LexicalApp(props: { noteId: bigint; editData: object | null; noteTitle:
|
||||
</TableContext>
|
||||
</SharedHistoryContext>
|
||||
</LexicalComposer>
|
||||
</LexicalCollaboration>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PlaygroundApp(props: { noteId: bigint; noteTitle: string; editData: object; handleLawClick: any; }): JSX.Element {
|
||||
export default function PlaygroundApp(props: { noteId: number; noteTitle: string; editData: string | null; handleLawClick: any; }): JSX.Element {
|
||||
|
||||
return (
|
||||
// <Editor />
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
* WebSocket collaboration provider factory for Lexical.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
* Configuration via environment variables:
|
||||
* VITE_WS_URL – WebSocket server URL (default: ws://localhost:1234)
|
||||
*
|
||||
* See .env.example for a full list of supported variables.
|
||||
*/
|
||||
|
||||
import {Provider} from '@lexical/yjs';
|
||||
import {WebsocketProvider} from 'y-websocket';
|
||||
import {Doc} from 'yjs';
|
||||
import type { Provider } from '@lexical/yjs';
|
||||
import { WebsocketProvider } from 'y-websocket';
|
||||
import { Doc } from 'yjs';
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const WEBSOCKET_ENDPOINT =
|
||||
params.get('collabEndpoint') || 'ws://localhost:1234';
|
||||
const WEBSOCKET_SLUG = 'playground';
|
||||
const WEBSOCKET_ID = params.get('collabId') || '0';
|
||||
(import.meta.env.VITE_WS_URL as string | undefined) ?? 'ws://localhost:1234';
|
||||
|
||||
// Use a project-specific slug — not the Meta playground default.
|
||||
const WEBSOCKET_SLUG = 'cyynote';
|
||||
|
||||
// parent dom -> child doc
|
||||
export function createWebsocketProvider(
|
||||
id: string,
|
||||
yjsDocMap: Map<string, Doc>,
|
||||
@@ -31,13 +30,11 @@ export function createWebsocketProvider(
|
||||
doc.load();
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return new WebsocketProvider(
|
||||
WEBSOCKET_ENDPOINT,
|
||||
WEBSOCKET_SLUG + '/' + WEBSOCKET_ID + '/' + id,
|
||||
`${WEBSOCKET_SLUG}/${id}`,
|
||||
doc,
|
||||
{
|
||||
connect: false,
|
||||
},
|
||||
);
|
||||
{ connect: false },
|
||||
) as unknown as Provider;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,9 +46,8 @@ header h1 {
|
||||
}
|
||||
|
||||
.editor-shell {
|
||||
margin: 20px auto;
|
||||
margin: 0;
|
||||
border-radius: 2px;
|
||||
max-width: 1100px;
|
||||
color: #000;
|
||||
position: relative;
|
||||
line-height: 1.7;
|
||||
@@ -61,6 +60,7 @@ header h1 {
|
||||
display: block;
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
padding: 8px 15px 15px;
|
||||
}
|
||||
|
||||
.editor-shell .editor-container.tree-view {
|
||||
@@ -1428,7 +1428,6 @@ button.action-button:disabled {
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
margin-bottom: 1px;
|
||||
background: #fff;
|
||||
padding: 4px;
|
||||
border-top-left-radius: 10px;
|
||||
@@ -1438,7 +1437,9 @@ button.action-button:disabled {
|
||||
height: 36px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
z-index: 4;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
button.toolbar-item {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
|
||||
import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContext';
|
||||
import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
|
||||
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
|
||||
import {HashtagPlugin} from '@lexical/react/LexicalHashtagPlugin';
|
||||
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
||||
import {LexicalNestedComposer} from '@lexical/react/LexicalNestedComposer';
|
||||
|
||||
@@ -12,7 +12,7 @@ import './InlineImageNode.css';
|
||||
|
||||
import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
|
||||
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
|
||||
import {LexicalNestedComposer} from '@lexical/react/LexicalNestedComposer';
|
||||
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
|
||||
import {useLexicalNodeSelection} from '@lexical/react/useLexicalNodeSelection';
|
||||
|
||||
@@ -13,7 +13,7 @@ import './StickyNode.css';
|
||||
import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContext';
|
||||
import {CollaborationPlugin} from '@lexical/react/LexicalCollaborationPlugin';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
|
||||
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
|
||||
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
||||
import {LexicalNestedComposer} from '@lexical/react/LexicalNestedComposer';
|
||||
import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
|
||||
|
||||
@@ -39,6 +39,32 @@ import NoteService from "../../../../services/note.service.ts";
|
||||
import {Toast} from "@douyinfe/semi-ui";
|
||||
import {SAVE_COMMAND} from "../GlobalEventsPlugin";
|
||||
|
||||
function exportMarkdown(editor: LexicalEditor, title: string) {
|
||||
let markdown = '';
|
||||
editor.getEditorState().read(() => {
|
||||
markdown = $convertToMarkdownString(PLAYGROUND_TRANSFORMERS);
|
||||
});
|
||||
const blob = new Blob([`# ${title}\n\n${markdown}`], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'note'}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportHtml(title: string) {
|
||||
const content = document.querySelector('.editor-scroller')?.innerHTML ?? '';
|
||||
const html = `<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8"><title>${title}</title></head><body><h1>${title}</h1>${content}</body></html>`;
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'note'}.html`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function sendEditorState(editor: LexicalEditor): Promise<void> {
|
||||
const stringifiedEditorState = JSON.stringify(editor.getEditorState());
|
||||
try {
|
||||
@@ -77,7 +103,7 @@ async function validateEditorState(editor: LexicalEditor): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string;handleLawClick_item3:any; }): JSX.Element {
|
||||
export default function ActionsPlugin(props: { noteId: number; noteTitle: string; handleLawClick_item3: any; editData?: any }): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [isEditable, setIsEditable] = useState(() => editor.isEditable());
|
||||
const [isSpeechToText, setIsSpeechToText] = useState(false);
|
||||
@@ -169,29 +195,22 @@ export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
const saveData = (editor) => {
|
||||
const x = document.getElementsByClassName("semi-input semi-input-large");
|
||||
console.log('================当前noteTitle x: ', x)
|
||||
console.log('当前ID: ', props.noteId)
|
||||
console.log('当前noteTitle: ', props.noteTitle)
|
||||
console.log('当前noteTitle2: ', x[0].value)
|
||||
// console.log('当前editor3: ', editor.getEditorState().toJSON())
|
||||
const saveData = (editor: LexicalEditor) => {
|
||||
const currentTitle = (document.querySelector('.semi-input.semi-input-large') as HTMLInputElement)?.value ?? props.noteTitle;
|
||||
const obj = editor.getEditorState().toJSON();
|
||||
// console.log('当前editor4: ', JSON.stringify(obj))
|
||||
|
||||
NoteService.update(props.noteId,x[0].value, JSON.stringify(obj)).then(
|
||||
response => {
|
||||
console.log(response.data)
|
||||
const data = response.data
|
||||
console.log(data)
|
||||
console.log('handleLawClick_item3')
|
||||
import('../../../../stores/useNoteStore').then(({ useNoteStore }) => {
|
||||
const tags = useNoteStore.getState().currentTags;
|
||||
NoteService.update(props.noteId, currentTitle, JSON.stringify(obj), tags).then(
|
||||
() => {
|
||||
props.handleLawClick_item3('1');
|
||||
Toast.info('保存成功' );
|
||||
Toast.info('保存成功');
|
||||
useNoteStore.getState().setDirty(false);
|
||||
},
|
||||
error => {
|
||||
console.log(error)
|
||||
() => {
|
||||
Toast.error('保存失败');
|
||||
}
|
||||
);
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -235,10 +254,24 @@ export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string
|
||||
source: 'Playground',
|
||||
})
|
||||
}
|
||||
title="Export"
|
||||
title="Export JSON"
|
||||
aria-label="Export editor state to JSON">
|
||||
<i className="export" />
|
||||
</button>
|
||||
<button
|
||||
className="action-button export"
|
||||
onClick={() => exportMarkdown(editor, props.noteTitle)}
|
||||
title="导出 Markdown"
|
||||
aria-label="Export as Markdown">
|
||||
<i className="export" />
|
||||
</button>
|
||||
<button
|
||||
className="action-button export"
|
||||
onClick={() => exportHtml(props.noteTitle)}
|
||||
title="导出 HTML"
|
||||
aria-label="Export as HTML">
|
||||
<i className="export" />
|
||||
</button>
|
||||
<button
|
||||
className="action-button clear"
|
||||
disabled={isEditorEmpty}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContex
|
||||
import {LexicalComposer} from '@lexical/react/LexicalComposer';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {EditorRefPlugin} from '@lexical/react/LexicalEditorRefPlugin';
|
||||
import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
|
||||
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
|
||||
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
||||
import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin';
|
||||
import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
|
||||
|
||||
@@ -8,120 +8,40 @@
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {
|
||||
LexicalContextMenuPlugin,
|
||||
MenuOption,
|
||||
} from '@lexical/react/LexicalContextMenuPlugin';
|
||||
NodeContextMenuPlugin,
|
||||
NodeContextMenuOption,
|
||||
} from '@lexical/react/LexicalNodeContextMenuPlugin';
|
||||
import {
|
||||
type LexicalNode,
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
COPY_COMMAND,
|
||||
CUT_COMMAND,
|
||||
PASTE_COMMAND,
|
||||
} from 'lexical';
|
||||
import {useCallback, useMemo} from 'react';
|
||||
import {useMemo} from 'react';
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
|
||||
function ContextMenuItem({
|
||||
index,
|
||||
isSelected,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
option,
|
||||
}: {
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onMouseEnter: () => void;
|
||||
option: ContextMenuOption;
|
||||
}) {
|
||||
let className = 'item';
|
||||
if (isSelected) {
|
||||
className += ' selected';
|
||||
}
|
||||
return (
|
||||
<li
|
||||
key={option.key}
|
||||
tabIndex={-1}
|
||||
className={className}
|
||||
ref={option.setRefElement}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
id={'typeahead-item-' + index}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onClick={onClick}>
|
||||
<span className="text">{option.title}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenu({
|
||||
options,
|
||||
selectedItemIndex,
|
||||
onOptionClick,
|
||||
onOptionMouseEnter,
|
||||
}: {
|
||||
selectedItemIndex: number | null;
|
||||
onOptionClick: (option: ContextMenuOption, index: number) => void;
|
||||
onOptionMouseEnter: (index: number) => void;
|
||||
options: Array<ContextMenuOption>;
|
||||
}) {
|
||||
return (
|
||||
<div className="typeahead-popover">
|
||||
<ul>
|
||||
{options.map((option: ContextMenuOption, i: number) => (
|
||||
<ContextMenuItem
|
||||
index={i}
|
||||
isSelected={selectedItemIndex === i}
|
||||
onClick={() => onOptionClick(option, i)}
|
||||
onMouseEnter={() => onOptionMouseEnter(i)}
|
||||
key={option.key}
|
||||
option={option}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export class ContextMenuOption extends MenuOption {
|
||||
title: string;
|
||||
onSelect: (targetNode: LexicalNode | null) => void;
|
||||
constructor(
|
||||
title: string,
|
||||
options: {
|
||||
onSelect: (targetNode: LexicalNode | null) => void;
|
||||
},
|
||||
) {
|
||||
super(title);
|
||||
this.title = title;
|
||||
this.onSelect = options.onSelect.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ContextMenuPlugin(): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
const options = useMemo(() => {
|
||||
const items = useMemo(() => {
|
||||
return [
|
||||
new ContextMenuOption(`Copy`, {
|
||||
onSelect: (_node) => {
|
||||
new NodeContextMenuOption('Copy', {
|
||||
$onSelect: () => {
|
||||
editor.dispatchCommand(COPY_COMMAND, null);
|
||||
},
|
||||
}),
|
||||
new ContextMenuOption(`Cut`, {
|
||||
onSelect: (_node) => {
|
||||
new NodeContextMenuOption('Cut', {
|
||||
$onSelect: () => {
|
||||
editor.dispatchCommand(CUT_COMMAND, null);
|
||||
},
|
||||
}),
|
||||
new ContextMenuOption(`Paste`, {
|
||||
onSelect: (_node) => {
|
||||
navigator.clipboard.read().then(async (...args) => {
|
||||
new NodeContextMenuOption('Paste', {
|
||||
$onSelect: () => {
|
||||
navigator.clipboard.read().then(async () => {
|
||||
const data = new DataTransfer();
|
||||
|
||||
const items = await navigator.clipboard.read();
|
||||
const item = items[0];
|
||||
const clipItems = await navigator.clipboard.read();
|
||||
const item = clipItems[0];
|
||||
|
||||
const permission = await navigator.permissions.query({
|
||||
// @ts-ignore These types are incorrect.
|
||||
@@ -137,108 +57,48 @@ export default function ContextMenuPlugin(): JSX.Element {
|
||||
data.setData(type, dataString);
|
||||
}
|
||||
|
||||
const event = new ClipboardEvent('paste', {
|
||||
clipboardData: data,
|
||||
});
|
||||
|
||||
const event = new ClipboardEvent('paste', {clipboardData: data});
|
||||
editor.dispatchCommand(PASTE_COMMAND, event);
|
||||
});
|
||||
},
|
||||
}),
|
||||
new ContextMenuOption(`Paste as Plain Text`, {
|
||||
onSelect: (_node) => {
|
||||
navigator.clipboard.read().then(async (...args) => {
|
||||
new NodeContextMenuOption('Paste as Plain Text', {
|
||||
$onSelect: () => {
|
||||
navigator.clipboard.read().then(async () => {
|
||||
const permission = await navigator.permissions.query({
|
||||
// @ts-ignore These types are incorrect.
|
||||
name: 'clipboard-read',
|
||||
});
|
||||
|
||||
if (permission.state === 'denied') {
|
||||
alert('Not allowed to paste from clipboard.');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = new DataTransfer();
|
||||
const items = await navigator.clipboard.readText();
|
||||
data.setData('text/plain', items);
|
||||
const text = await navigator.clipboard.readText();
|
||||
data.setData('text/plain', text);
|
||||
|
||||
const event = new ClipboardEvent('paste', {
|
||||
clipboardData: data,
|
||||
});
|
||||
const event = new ClipboardEvent('paste', {clipboardData: data});
|
||||
editor.dispatchCommand(PASTE_COMMAND, event);
|
||||
});
|
||||
},
|
||||
}),
|
||||
new ContextMenuOption(`Delete Node`, {
|
||||
onSelect: (_node) => {
|
||||
new NodeContextMenuOption('Delete Node', {
|
||||
$onSelect: () => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
const currentNode = selection.anchor.getNode();
|
||||
const ancestorNodeWithRootAsParent = currentNode
|
||||
.getParents()
|
||||
.at(-2);
|
||||
|
||||
ancestorNodeWithRootAsParent?.remove();
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
];
|
||||
}, [editor]);
|
||||
|
||||
const onSelectOption = useCallback(
|
||||
(
|
||||
selectedOption: ContextMenuOption,
|
||||
targetNode: LexicalNode | null,
|
||||
closeMenu: () => void,
|
||||
) => {
|
||||
editor.update(() => {
|
||||
selectedOption.onSelect(targetNode);
|
||||
closeMenu();
|
||||
});
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
return (
|
||||
<LexicalContextMenuPlugin
|
||||
options={options}
|
||||
onSelectOption={onSelectOption}
|
||||
menuRenderFn={(
|
||||
anchorElementRef,
|
||||
{
|
||||
selectedIndex,
|
||||
options: _options,
|
||||
selectOptionAndCleanUp,
|
||||
setHighlightedIndex,
|
||||
},
|
||||
{setMenuRef},
|
||||
) =>
|
||||
anchorElementRef.current
|
||||
? ReactDOM.createPortal(
|
||||
<div
|
||||
className="typeahead-popover auto-embed-menu"
|
||||
style={{
|
||||
marginLeft: anchorElementRef.current.style.width,
|
||||
userSelect: 'none',
|
||||
width: 200,
|
||||
}}
|
||||
ref={setMenuRef}>
|
||||
<ContextMenu
|
||||
options={options}
|
||||
selectedItemIndex={selectedIndex}
|
||||
onOptionClick={(option: ContextMenuOption, index: number) => {
|
||||
setHighlightedIndex(index);
|
||||
selectOptionAndCleanUp(option);
|
||||
}}
|
||||
onOptionMouseEnter={(index: number) => {
|
||||
setHighlightedIndex(index);
|
||||
}}
|
||||
/>
|
||||
</div>,
|
||||
anchorElementRef.current,
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
);
|
||||
return <NodeContextMenuPlugin items={items} />;
|
||||
}
|
||||
|
||||
@@ -7,16 +7,16 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
DEPRECATED_GridCellNode,
|
||||
ElementNode,
|
||||
LexicalEditor,
|
||||
} from 'lexical';
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import useLexicalEditable from '@lexical/react/useLexicalEditable';
|
||||
import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
|
||||
import {
|
||||
$deleteTableColumn__EXPERIMENTAL,
|
||||
$deleteTableRow__EXPERIMENTAL,
|
||||
$getNodeTriplet,
|
||||
$getTableCellNodeFromLexicalNode,
|
||||
$getTableColumnIndexFromTableCellNode,
|
||||
$getTableNodeFromLexicalNodeOrThrow,
|
||||
@@ -25,11 +25,13 @@ import {
|
||||
$insertTableRow__EXPERIMENTAL,
|
||||
$isTableCellNode,
|
||||
$isTableRowNode,
|
||||
$isTableSelection,
|
||||
$unmergeCell,
|
||||
getTableSelectionFromTableElement,
|
||||
getTableObserverFromTableElement,
|
||||
HTMLTableElementWithWithTableSelectionState,
|
||||
TableCellHeaderStates,
|
||||
TableCellNode,
|
||||
TableSelection,
|
||||
} from '@lexical/table';
|
||||
import {
|
||||
$createParagraphNode,
|
||||
@@ -39,10 +41,6 @@ import {
|
||||
$isParagraphNode,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
DEPRECATED_$getNodeTriplet,
|
||||
DEPRECATED_$isGridCellNode,
|
||||
DEPRECATED_$isGridSelection,
|
||||
GridSelection,
|
||||
} from 'lexical';
|
||||
import * as React from 'react';
|
||||
import {ReactPortal, useCallback, useEffect, useRef, useState} from 'react';
|
||||
@@ -52,7 +50,7 @@ import invariant from '../../shared/src/invariant';
|
||||
import useModal from '../../hooks/useModal';
|
||||
import ColorPicker from '../../ui/ColorPicker';
|
||||
|
||||
function computeSelectionCount(selection: GridSelection): {
|
||||
function computeSelectionCount(selection: TableSelection): {
|
||||
columns: number;
|
||||
rows: number;
|
||||
} {
|
||||
@@ -65,7 +63,7 @@ function computeSelectionCount(selection: GridSelection): {
|
||||
|
||||
// This is important when merging cells as there is no good way to re-merge weird shapes (a result
|
||||
// of selecting merged cells and non-merged)
|
||||
function isGridSelectionRectangular(selection: GridSelection): boolean {
|
||||
function isTableSelectionRectangular(selection: TableSelection): boolean {
|
||||
const nodes = selection.getNodes();
|
||||
const currentRows: Array<number> = [];
|
||||
let currentRow = null;
|
||||
@@ -109,17 +107,17 @@ function $canUnmerge(): boolean {
|
||||
const selection = $getSelection();
|
||||
if (
|
||||
($isRangeSelection(selection) && !selection.isCollapsed()) ||
|
||||
(DEPRECATED_$isGridSelection(selection) &&
|
||||
($isTableSelection(selection) &&
|
||||
!selection.anchor.is(selection.focus)) ||
|
||||
(!$isRangeSelection(selection) && !DEPRECATED_$isGridSelection(selection))
|
||||
(!$isRangeSelection(selection) && !$isTableSelection(selection))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const [cell] = DEPRECATED_$getNodeTriplet(selection.anchor);
|
||||
const [cell] = $getNodeTriplet(selection.anchor);
|
||||
return cell.__colSpan > 1 || cell.__rowSpan > 1;
|
||||
}
|
||||
|
||||
function $cellContainsEmptyParagraph(cell: DEPRECATED_GridCellNode): boolean {
|
||||
function $cellContainsEmptyParagraph(cell: TableCellNode): boolean {
|
||||
if (cell.getChildrenSize() !== 1) {
|
||||
return false;
|
||||
}
|
||||
@@ -146,9 +144,9 @@ function currentCellBackgroundColor(editor: LexicalEditor): null | string {
|
||||
const selection = $getSelection();
|
||||
if (
|
||||
$isRangeSelection(selection) ||
|
||||
DEPRECATED_$isGridSelection(selection)
|
||||
$isTableSelection(selection)
|
||||
) {
|
||||
const [cell] = DEPRECATED_$getNodeTriplet(selection.anchor);
|
||||
const [cell] = $getNodeTriplet(selection.anchor);
|
||||
if ($isTableCellNode(cell)) {
|
||||
return cell.getBackgroundColor();
|
||||
}
|
||||
@@ -208,11 +206,11 @@ function TableActionMenu({
|
||||
editor.getEditorState().read(() => {
|
||||
const selection = $getSelection();
|
||||
// Merge cells
|
||||
if (DEPRECATED_$isGridSelection(selection)) {
|
||||
if ($isTableSelection(selection)) {
|
||||
const currentSelectionCounts = computeSelectionCount(selection);
|
||||
updateSelectionCounts(computeSelectionCount(selection));
|
||||
setCanMergeCells(
|
||||
isGridSelectionRectangular(selection) &&
|
||||
isTableSelectionRectangular(selection) &&
|
||||
(currentSelectionCounts.columns > 1 ||
|
||||
currentSelectionCounts.rows > 1),
|
||||
);
|
||||
@@ -286,9 +284,9 @@ function TableActionMenu({
|
||||
throw new Error('Expected to find tableElement in DOM');
|
||||
}
|
||||
|
||||
const tableSelection = getTableSelectionFromTableElement(tableElement);
|
||||
const tableSelection = getTableObserverFromTableElement(tableElement);
|
||||
if (tableSelection !== null) {
|
||||
tableSelection.clearHighlight();
|
||||
tableSelection.$clearHighlight();
|
||||
}
|
||||
|
||||
tableNode.markDirty();
|
||||
@@ -303,13 +301,13 @@ function TableActionMenu({
|
||||
const mergeTableCellsAtSelection = () => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if (DEPRECATED_$isGridSelection(selection)) {
|
||||
if ($isTableSelection(selection)) {
|
||||
const {columns, rows} = computeSelectionCount(selection);
|
||||
const nodes = selection.getNodes();
|
||||
let firstCell: null | DEPRECATED_GridCellNode = null;
|
||||
let firstCell: null | TableCellNode = null;
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
if (DEPRECATED_$isGridCellNode(node)) {
|
||||
if ($isTableCellNode(node)) {
|
||||
if (firstCell === null) {
|
||||
node.setColSpan(columns).setRowSpan(rows);
|
||||
firstCell = node;
|
||||
@@ -321,7 +319,7 @@ function TableActionMenu({
|
||||
) {
|
||||
firstChild.remove();
|
||||
}
|
||||
} else if (DEPRECATED_$isGridCellNode(firstCell)) {
|
||||
} else if ($isTableCellNode(firstCell)) {
|
||||
const isEmpty = $cellContainsEmptyParagraph(node);
|
||||
if (!isEmpty) {
|
||||
firstCell.append(...node.getChildren());
|
||||
@@ -473,19 +471,19 @@ function TableActionMenu({
|
||||
const selection = $getSelection();
|
||||
if (
|
||||
$isRangeSelection(selection) ||
|
||||
DEPRECATED_$isGridSelection(selection)
|
||||
$isTableSelection(selection)
|
||||
) {
|
||||
const [cell] = DEPRECATED_$getNodeTriplet(selection.anchor);
|
||||
const [cell] = $getNodeTriplet(selection.anchor);
|
||||
if ($isTableCellNode(cell)) {
|
||||
cell.setBackgroundColor(value);
|
||||
}
|
||||
|
||||
if (DEPRECATED_$isGridSelection(selection)) {
|
||||
if ($isTableSelection(selection)) {
|
||||
const nodes = selection.getNodes();
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
if (DEPRECATED_$isGridCellNode(node)) {
|
||||
if ($isTableCellNode(node)) {
|
||||
node.setBackgroundColor(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,26 +5,26 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import type {Cell} from '@lexical/table';
|
||||
import type {TableDOMCell} from '@lexical/table';
|
||||
import type {LexicalEditor} from 'lexical';
|
||||
|
||||
import './index.css';
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import useLexicalEditable from '@lexical/react/useLexicalEditable';
|
||||
import {useLexicalEditable} from '@lexical/react/useLexicalEditable';
|
||||
import {
|
||||
$getTableColumnIndexFromTableCellNode,
|
||||
$getTableNodeFromLexicalNodeOrThrow,
|
||||
$getTableRowIndexFromTableCellNode,
|
||||
$isTableCellNode,
|
||||
$isTableRowNode,
|
||||
getCellFromTarget,
|
||||
$isTableSelection,
|
||||
getDOMCellFromTarget,
|
||||
} from '@lexical/table';
|
||||
import {
|
||||
$getNearestNodeFromDOMNode,
|
||||
$getSelection,
|
||||
COMMAND_PRIORITY_HIGH,
|
||||
DEPRECATED_$isGridSelection,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
} from 'lexical';
|
||||
import * as React from 'react';
|
||||
@@ -58,7 +58,7 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element {
|
||||
const [mouseCurrentPos, updateMouseCurrentPos] =
|
||||
useState<MousePosition | null>(null);
|
||||
|
||||
const [activeCell, updateActiveCell] = useState<Cell | null>(null);
|
||||
const [activeCell, updateActiveCell] = useState<TableDOMCell | null>(null);
|
||||
const [isSelectingGrid, updateIsSelectingGrid] = useState<boolean>(false);
|
||||
const [draggingDirection, updateDraggingDirection] =
|
||||
useState<MouseDraggingDirection | null>(null);
|
||||
@@ -68,7 +68,7 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element {
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(payload) => {
|
||||
const selection = $getSelection();
|
||||
const isGridSelection = DEPRECATED_$isGridSelection(selection);
|
||||
const isGridSelection = $isTableSelection(selection);
|
||||
|
||||
if (isSelectingGrid !== isGridSelection) {
|
||||
updateIsSelectingGrid(isGridSelection);
|
||||
@@ -107,7 +107,7 @@ function TableCellResizer({editor}: {editor: LexicalEditor}): JSX.Element {
|
||||
|
||||
if (targetRef.current !== target) {
|
||||
targetRef.current = target as HTMLElement;
|
||||
const cell = getCellFromTarget(target as HTMLElement);
|
||||
const cell = getDOMCellFromTarget(target as HTMLElement);
|
||||
|
||||
if (cell && activeCell !== cell) {
|
||||
editor.update(() => {
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import type {TableOfContentsEntry} from '@lexical/react/LexicalTableOfContents';
|
||||
import type {TableOfContentsEntry} from '@lexical/react/LexicalTableOfContentsPlugin';
|
||||
import type {HeadingTagType} from '@lexical/rich-text';
|
||||
import type {NodeKey} from 'lexical';
|
||||
|
||||
import './index.css';
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import LexicalTableOfContents from '@lexical/react/LexicalTableOfContents';
|
||||
import {TableOfContentsPlugin} from '@lexical/react/LexicalTableOfContentsPlugin';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import { Collapsible, Button } from '@douyinfe/semi-ui';
|
||||
|
||||
@@ -243,12 +243,12 @@ function TableOfContentsList({
|
||||
);
|
||||
}
|
||||
|
||||
export default function TableOfContentsPlugin() {
|
||||
export default function TableOfContentsPluginWrapper() {
|
||||
return (
|
||||
<LexicalTableOfContents>
|
||||
<TableOfContentsPlugin>
|
||||
{(tableOfContents) => {
|
||||
return <TableOfContentsList tableOfContents={tableOfContents} />;
|
||||
}}
|
||||
</LexicalTableOfContents>
|
||||
</TableOfContentsPlugin>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ import {
|
||||
$getNodeByKey,
|
||||
$getRoot,
|
||||
$getSelection,
|
||||
$INTERNAL_isPointSelection,
|
||||
$isElementNode,
|
||||
$isRangeSelection,
|
||||
$isRootOrShadowRoot,
|
||||
@@ -213,7 +212,7 @@ function BlockFormatDropDown({
|
||||
const formatParagraph = () => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($INTERNAL_isPointSelection(selection)) {
|
||||
if ($isRangeSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createParagraphNode());
|
||||
}
|
||||
});
|
||||
@@ -223,7 +222,7 @@ function BlockFormatDropDown({
|
||||
if (blockType !== headingSize) {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($INTERNAL_isPointSelection(selection)) {
|
||||
if ($isRangeSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createHeadingNode(headingSize));
|
||||
}
|
||||
});
|
||||
@@ -258,7 +257,7 @@ function BlockFormatDropDown({
|
||||
if (blockType !== 'quote') {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($INTERNAL_isPointSelection(selection)) {
|
||||
if ($isRangeSelection(selection)) {
|
||||
$setBlocksType(selection, () => $createQuoteNode());
|
||||
}
|
||||
});
|
||||
@@ -270,7 +269,7 @@ function BlockFormatDropDown({
|
||||
editor.update(() => {
|
||||
let selection = $getSelection();
|
||||
|
||||
if ($INTERNAL_isPointSelection(selection)) {
|
||||
if ($isRangeSelection(selection)) {
|
||||
if (selection.isCollapsed()) {
|
||||
$setBlocksType(selection, () => $createCodeNode());
|
||||
} else {
|
||||
@@ -370,7 +369,7 @@ function FontDropDown({
|
||||
(option: string) => {
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($INTERNAL_isPointSelection(selection)) {
|
||||
if ($isRangeSelection(selection)) {
|
||||
$patchStyleText(selection, {
|
||||
[style]: option,
|
||||
});
|
||||
@@ -677,7 +676,7 @@ export default function ToolbarPlugin({
|
||||
activeEditor.registerUpdateListener(({editorState}) => {
|
||||
editorState.read(() => {
|
||||
$updateToolbar();
|
||||
});
|
||||
}, {editor: activeEditor});
|
||||
}),
|
||||
activeEditor.registerCommand<boolean>(
|
||||
CAN_UNDO_COMMAND,
|
||||
@@ -727,7 +726,7 @@ export default function ToolbarPlugin({
|
||||
(styles: Record<string, string>) => {
|
||||
activeEditor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($INTERNAL_isPointSelection(selection)) {
|
||||
if ($isRangeSelection(selection)) {
|
||||
$patchStyleText(selection, styles);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,151 +1,69 @@
|
||||
import { Component } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Formik, Field, Form, ErrorMessage } from "formik";
|
||||
import * as Yup from "yup";
|
||||
/**
|
||||
* @deprecated Use src/pages/sign-in.tsx instead.
|
||||
* This file is kept for reference and is not used in the router.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { Navigate } from 'react-router';
|
||||
import authService from '../services/auth.service';
|
||||
import { useAuthStore } from '../stores/useAuthStore';
|
||||
|
||||
import AuthService from "../services/auth.service";
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [redirect, setRedirect] = useState<string | null>(null);
|
||||
|
||||
type Props = {};
|
||||
const user = useAuthStore((s) => s.user);
|
||||
if (user || redirect) return <Navigate to={redirect ?? '/home'} />;
|
||||
|
||||
type State = {
|
||||
redirect: string | null,
|
||||
username: string,
|
||||
password: string,
|
||||
loading: boolean,
|
||||
message: string
|
||||
};
|
||||
|
||||
export default class Login extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.handleLogin = this.handleLogin.bind(this);
|
||||
|
||||
this.state = {
|
||||
redirect: null,
|
||||
username: "",
|
||||
password: "",
|
||||
loading: false,
|
||||
message: ""
|
||||
};
|
||||
const handleLogin = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage('');
|
||||
try {
|
||||
await authService.login(username, password);
|
||||
setRedirect('/home');
|
||||
} catch (error: unknown) {
|
||||
setMessage(error instanceof Error ? error.message : '登录失败');
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const currentUser = AuthService.getCurrentUser();
|
||||
|
||||
if (currentUser) {
|
||||
this.setState({ redirect: "/profile" });
|
||||
};
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
validationSchema() {
|
||||
return Yup.object().shape({
|
||||
username: Yup.string().required("This field is required!"),
|
||||
password: Yup.string().required("This field is required!"),
|
||||
});
|
||||
}
|
||||
|
||||
handleLogin(formValue: { username: string; password: string }) {
|
||||
const { username, password } = formValue;
|
||||
|
||||
this.setState({
|
||||
message: "",
|
||||
loading: true
|
||||
});
|
||||
|
||||
|
||||
AuthService.login(username, password).then(
|
||||
() => {
|
||||
this.setState({
|
||||
redirect: "/profile"
|
||||
});
|
||||
},
|
||||
error => {
|
||||
const resMessage =
|
||||
(error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.message) ||
|
||||
error.message ||
|
||||
error.toString();
|
||||
|
||||
this.setState({
|
||||
loading: false,
|
||||
message: resMessage
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.redirect) {
|
||||
return <Navigate to={this.state.redirect} />
|
||||
}
|
||||
|
||||
const { loading, message } = this.state;
|
||||
|
||||
const initialValues = {
|
||||
username: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="col-md-12">
|
||||
<div className="card card-container">
|
||||
<img
|
||||
src="//ssl.gstatic.com/accounts/ui/avatar_2x.png"
|
||||
alt="profile-img"
|
||||
className="profile-img-card"
|
||||
/>
|
||||
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={this.validationSchema}
|
||||
onSubmit={this.handleLogin}
|
||||
>
|
||||
<Form>
|
||||
<form onSubmit={(e) => void handleLogin(e)}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username">Username</label>
|
||||
<Field name="username" type="text" className="form-control" />
|
||||
<ErrorMessage
|
||||
name="username"
|
||||
component="div"
|
||||
className="alert alert-danger"
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
className="form-control"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<Field name="password" type="password" className="form-control" />
|
||||
<ErrorMessage
|
||||
name="password"
|
||||
component="div"
|
||||
className="alert alert-danger"
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="form-control"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<button type="submit" className="btn btn-primary btn-block" disabled={loading}>
|
||||
{loading && (
|
||||
<span className="spinner-border spinner-border-sm"></span>
|
||||
)}
|
||||
<span>Login</span>
|
||||
<button type="submit" className="btn btn-primary btn-block">
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className="form-group">
|
||||
<div className="alert alert-danger" role="alert">
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Formik>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Modal, Toast } from '@douyinfe/semi-ui';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { useNoteStore } from '../../stores/useNoteStore';
|
||||
import noteService from '../../services/note.service';
|
||||
|
||||
interface DeleteModalProps {
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
export default function DeleteModal({ onDeleted }: DeleteModalProps) {
|
||||
const { deleteVisible, deleteId, deleteNoteName, closeDeleteConfirm } = useUIStore();
|
||||
const clearCurrentNote = useNoteStore((s) => s.clearCurrentNote);
|
||||
|
||||
const handleOk = () => {
|
||||
if (deleteId === null) return;
|
||||
noteService.delete(deleteId).then(
|
||||
() => {
|
||||
Toast.info(`已删除:${deleteNoteName}`);
|
||||
closeDeleteConfirm();
|
||||
clearCurrentNote();
|
||||
onDeleted();
|
||||
},
|
||||
() => {
|
||||
Toast.error('删除失败,请重试');
|
||||
closeDeleteConfirm();
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="删除确认"
|
||||
visible={deleteVisible}
|
||||
onOk={handleOk}
|
||||
onCancel={closeDeleteConfirm}
|
||||
afterClose={closeDeleteConfirm}
|
||||
closeOnEsc
|
||||
>
|
||||
确认要删除「{deleteNoteName}」吗?删除后可在回收站恢复。
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, List, Button, Toast, Typography, Spin, Popconfirm } from '@douyinfe/semi-ui';
|
||||
import { IconHistory, IconRotate } from '@douyinfe/semi-icons';
|
||||
import { useNoteStore } from '../../stores/useNoteStore';
|
||||
import noteService from '../../services/note.service';
|
||||
import type { IHistory } from '../../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function NoteHistoryModal({ visible, onClose }: Props) {
|
||||
const { currentNoteId, setCurrentNote } = useNoteStore();
|
||||
const [list, setList] = useState<IHistory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !currentNoteId) return;
|
||||
setLoading(true);
|
||||
noteService
|
||||
.getHistory(currentNoteId)
|
||||
.then((res) => setList(res.data ?? []))
|
||||
.catch(() => Toast.error('加载历史版本失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [visible, currentNoteId]);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (item: IHistory) => {
|
||||
try {
|
||||
const res = await noteService.getHistoryContent(item.id);
|
||||
const h = res.data;
|
||||
// Restore: update note with history content
|
||||
await noteService.update(h.nid, h.title, h.context);
|
||||
// Reload the current note into the editor
|
||||
const noteRes = await noteService.get(h.nid);
|
||||
const note = noteRes.data;
|
||||
setCurrentNote(note.id, note.title, note.context);
|
||||
Toast.success(`已恢复到 ${h.createtime} 的版本`);
|
||||
onClose();
|
||||
} catch {
|
||||
Toast.error('恢复失败');
|
||||
}
|
||||
},
|
||||
[setCurrentNote, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<span>
|
||||
<IconHistory style={{ marginRight: 8 }} />
|
||||
历史版本
|
||||
</span>
|
||||
}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={520}
|
||||
>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 32 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 32, color: 'var(--semi-color-text-2)' }}>
|
||||
暂无历史版本
|
||||
</div>
|
||||
) : (
|
||||
<List
|
||||
dataSource={list}
|
||||
renderItem={(item: IHistory) => (
|
||||
<List.Item
|
||||
main={
|
||||
<div>
|
||||
<Text strong style={{ display: 'block' }}>{item.title}</Text>
|
||||
<Text type="tertiary" size="small">{item.createtime}</Text>
|
||||
</div>
|
||||
}
|
||||
extra={
|
||||
<Popconfirm
|
||||
title="确认恢复"
|
||||
content="恢复后当前内容将被该版本覆盖,确定吗?"
|
||||
onConfirm={() => void handleRestore(item)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="secondary"
|
||||
icon={<IconRotate />}
|
||||
>
|
||||
恢复
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Modal, Form, Button, Toast } from '@douyinfe/semi-ui';
|
||||
import { useAuthStore } from '../../stores/useAuthStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import authService from '../../services/auth.service';
|
||||
|
||||
export default function PasswordModal() {
|
||||
const { updatePasswordModalVisible, setUpdatePasswordModalVisible } = useUIStore();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const handleSubmit = (values: {
|
||||
username: string;
|
||||
oldpassword: string;
|
||||
newpassword: string;
|
||||
}) => {
|
||||
authService
|
||||
.updatePassword(values.username, values.oldpassword, values.newpassword)
|
||||
.then(
|
||||
() => {
|
||||
Toast.success('密码修改成功');
|
||||
setUpdatePasswordModalVisible(false);
|
||||
},
|
||||
(error: unknown) => {
|
||||
const msg =
|
||||
error instanceof Error ? error.message : '修改失败,请重试';
|
||||
Toast.error(msg);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="修改密码"
|
||||
visible={updatePasswordModalVisible}
|
||||
size="large"
|
||||
footer={null}
|
||||
onCancel={() => setUpdatePasswordModalVisible(false)}
|
||||
closeOnEsc
|
||||
>
|
||||
<div style={{ padding: 12, border: '1px solid var(--semi-color-border)', margin: 12 }}>
|
||||
<Form
|
||||
onSubmit={(values) =>
|
||||
handleSubmit(
|
||||
values as { username: string; oldpassword: string; newpassword: string },
|
||||
)
|
||||
}
|
||||
style={{ textAlign: 'center', width: 400 }}
|
||||
initValues={{ username: user?.username ?? '' }}
|
||||
>
|
||||
<Form.Input
|
||||
field="username"
|
||||
label="用户名"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
<Form.Input
|
||||
mode="password"
|
||||
field="oldpassword"
|
||||
label="旧密码"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请输入旧密码"
|
||||
/>
|
||||
<Form.Input
|
||||
mode="password"
|
||||
field="newpassword"
|
||||
label="新密码"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="请输入新密码"
|
||||
/>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 16 }}>
|
||||
<Button htmlType="submit" type="tertiary">
|
||||
确认修改
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Modal, List, Avatar, Button, ButtonGroup, Toast, Typography } from '@douyinfe/semi-ui';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { useNoteStore } from '../../stores/useNoteStore';
|
||||
import noteService from '../../services/note.service';
|
||||
import type { INote } from '../../types';
|
||||
|
||||
interface TrashListModalProps {
|
||||
trashList: INote[];
|
||||
onRestored: () => void;
|
||||
}
|
||||
|
||||
export default function TrashListModal({ trashList, onRestored }: TrashListModalProps) {
|
||||
const { deleteModalVisible, setDeleteModalVisible } = useUIStore();
|
||||
|
||||
const handleRestore = (id: number) => {
|
||||
noteService.restoreFromTrash(id).then(
|
||||
() => {
|
||||
Toast.success('已从回收站恢复');
|
||||
setDeleteModalVisible(false);
|
||||
onRestored();
|
||||
},
|
||||
() => Toast.error('恢复失败,请重试'),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="回收站"
|
||||
visible={deleteModalVisible}
|
||||
size="large"
|
||||
footer={null}
|
||||
onCancel={() => setDeleteModalVisible(false)}
|
||||
closeOnEsc
|
||||
>
|
||||
<div style={{ padding: 12, border: '1px solid var(--semi-color-border)', margin: 12 }}>
|
||||
<List
|
||||
dataSource={trashList}
|
||||
renderItem={(item: INote) => (
|
||||
<List.Item
|
||||
header={<Avatar color="blue">NOTE</Avatar>}
|
||||
main={
|
||||
<div>
|
||||
<span style={{ color: 'var(--semi-color-text-0)', fontWeight: 500 }}>
|
||||
{item.title}
|
||||
</span>
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: 'calc(100% - 68px)' }}
|
||||
>
|
||||
创建时间:{item.createtime},更新时间:{item.updatetime},
|
||||
最后浏览时间:{item.viewtime}。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
extra={
|
||||
<ButtonGroup theme="borderless">
|
||||
<Button onClick={() => handleRestore(item.id)}>恢复</Button>
|
||||
</ButtonGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Input, Skeleton, Breadcrumb, Button, Tag, TagInput } from '@douyinfe/semi-ui';
|
||||
import { IconHistory } from '@douyinfe/semi-icons';
|
||||
import { useNoteStore } from '../../stores/useNoteStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import PlaygroundApp from '../lexical/LexicalApp';
|
||||
import NoteHistoryModal from '../modals/NoteHistoryModal';
|
||||
import type { ITreeNode } from '../../types';
|
||||
|
||||
function getNotePath(
|
||||
nodes: ITreeNode[],
|
||||
targetKey: string | number,
|
||||
path: string[] = [],
|
||||
): string[] | null {
|
||||
for (const node of nodes) {
|
||||
const current = [...path, node.label];
|
||||
if (String(node.key) === String(targetKey)) return current;
|
||||
if (node.children) {
|
||||
const found = getNotePath(node.children, targetKey, current);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function NoteEditor() {
|
||||
const { currentNoteId, currentTitle, currentContent, currentTags, setTitle, setTags, treeData } = useNoteStore();
|
||||
const { isNoteLoading } = useUIStore();
|
||||
const [historyVisible, setHistoryVisible] = useState(false);
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(value: string) => {
|
||||
setTitle(value);
|
||||
},
|
||||
[setTitle],
|
||||
);
|
||||
|
||||
const handleTagsChange = useCallback(
|
||||
(tags: string[]) => {
|
||||
setTags(tags.join(','));
|
||||
},
|
||||
[setTags],
|
||||
);
|
||||
|
||||
const handleRefreshTree = useCallback(() => {
|
||||
// no-op: tree is refreshed by NotePage via store subscription
|
||||
}, []);
|
||||
|
||||
const notePath = currentNoteId != null ? getNotePath(treeData, currentNoteId) : null;
|
||||
const breadcrumbRoutes = ['CyyNote', ...(notePath ?? ['笔记标题', currentTitle])];
|
||||
const tagValues = currentTags ? currentTags.split(',').filter(Boolean) : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '10px', marginBottom: '16px' }}>
|
||||
<Breadcrumb routes={breadcrumbRoutes} />
|
||||
{currentNoteId !== null && (
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
icon={<IconHistory />}
|
||||
onClick={() => setHistoryVisible(true)}
|
||||
>
|
||||
历史版本
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<NoteHistoryModal visible={historyVisible} onClose={() => setHistoryVisible(false)} />
|
||||
<Skeleton
|
||||
placeholder={<Skeleton.Paragraph rows={2} />}
|
||||
loading={isNoteLoading}
|
||||
>
|
||||
<Input
|
||||
placeholder="笔记标题"
|
||||
size="large"
|
||||
value={currentTitle}
|
||||
onChange={handleTitleChange}
|
||||
style={{ marginBottom: '8px' }}
|
||||
/>
|
||||
{currentNoteId !== null && (
|
||||
<TagInput
|
||||
value={tagValues}
|
||||
onChange={handleTagsChange}
|
||||
placeholder="添加标签(回车确认)"
|
||||
style={{ marginBottom: '12px' }}
|
||||
renderTagItem={(value: string) => (
|
||||
<Tag size="small" style={{ margin: '2px' }}>{value}</Tag>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '10px',
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
overflow: 'clip',
|
||||
}}
|
||||
>
|
||||
{currentNoteId !== null && currentContent !== null ? (
|
||||
<PlaygroundApp
|
||||
key={currentNoteId}
|
||||
noteId={currentNoteId}
|
||||
noteTitle={currentTitle}
|
||||
editData={currentContent}
|
||||
handleLawClick={handleRefreshTree}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ padding: '15px', color: 'var(--semi-color-text-2)' }}>请从左侧选择笔记</div>
|
||||
)}
|
||||
</div>
|
||||
</Skeleton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +1,37 @@
|
||||
import { Component } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import AuthService from "../services/auth.service";
|
||||
import IUser from "../types/user.type";
|
||||
import { Navigate } from 'react-router';
|
||||
import { useAuthStore } from '../stores/useAuthStore';
|
||||
|
||||
type Props = {};
|
||||
export default function Profile() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const token = useAuthStore((s) => s.token);
|
||||
|
||||
type State = {
|
||||
redirect: string | null,
|
||||
userReady: boolean,
|
||||
currentUser: IUser & { accessToken: string }
|
||||
}
|
||||
export default class Profile extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
redirect: null,
|
||||
userReady: false,
|
||||
currentUser: { accessToken: "" }
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const currentUser = AuthService.getCurrentUser();
|
||||
|
||||
if (!currentUser) this.setState({ redirect: "/home" });
|
||||
this.setState({ currentUser: currentUser, userReady: true })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.redirect) {
|
||||
return <Navigate to={this.state.redirect} />
|
||||
}
|
||||
|
||||
const { currentUser } = this.state;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
{(this.state.userReady) ?
|
||||
<div>
|
||||
<header className="jumbotron">
|
||||
<h3>
|
||||
<strong>{currentUser.username}</strong> Profile
|
||||
<strong>{user.username}</strong> Profile
|
||||
</h3>
|
||||
</header>
|
||||
<p>
|
||||
<strong>Token:</strong>{" "}
|
||||
{currentUser.accessToken.substring(0, 20)} ...{" "}
|
||||
{currentUser.accessToken.substr(currentUser.accessToken.length - 20)}
|
||||
<strong>Token:</strong>{' '}
|
||||
{token ? `${token.substring(0, 20)} ... ${token.substring(token.length - 20)}` : 'N/A'}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Id:</strong>{" "}
|
||||
{currentUser.id}
|
||||
<strong>Id:</strong> {user.id}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Email:</strong>{" "}
|
||||
{currentUser.email}
|
||||
<strong>Email:</strong> {user.email}
|
||||
</p>
|
||||
<strong>Authorities:</strong>
|
||||
<ul>
|
||||
{currentUser.roles &&
|
||||
currentUser.roles.map((role, index) => <li key={index}>{role}</li>)}
|
||||
{user.roles.map((role, index) => (
|
||||
<li key={index}>{role}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,103 +1,49 @@
|
||||
import { Component } from "react";
|
||||
import { Formik, Field, Form, ErrorMessage } from "formik";
|
||||
import * as Yup from "yup";
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import * as Yup from 'yup';
|
||||
import authService from '../services/auth.service';
|
||||
|
||||
import AuthService from "../services/auth.service";
|
||||
|
||||
type Props = {};
|
||||
|
||||
type State = {
|
||||
username: string,
|
||||
email: string,
|
||||
password: string,
|
||||
successful: boolean,
|
||||
message: string
|
||||
};
|
||||
|
||||
export default class Register extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.handleRegister = this.handleRegister.bind(this);
|
||||
|
||||
this.state = {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
successful: false,
|
||||
message: ""
|
||||
};
|
||||
}
|
||||
|
||||
validationSchema() {
|
||||
return Yup.object().shape({
|
||||
const schema = Yup.object().shape({
|
||||
username: Yup.string()
|
||||
.test(
|
||||
"len",
|
||||
"The username must be between 3 and 20 characters.",
|
||||
(val: any) =>
|
||||
val &&
|
||||
val.toString().length >= 3 &&
|
||||
val.toString().length <= 20
|
||||
)
|
||||
.required("This field is required!"),
|
||||
email: Yup.string()
|
||||
.email("This is not a valid email.")
|
||||
.required("This field is required!"),
|
||||
.min(3, '用户名最少 3 个字符')
|
||||
.max(20, '用户名最多 20 个字符')
|
||||
.required('请输入用户名'),
|
||||
email: Yup.string().email('邮箱格式不正确').required('请输入邮箱'),
|
||||
password: Yup.string()
|
||||
.test(
|
||||
"len",
|
||||
"The password must be between 6 and 40 characters.",
|
||||
(val: any) =>
|
||||
val &&
|
||||
val.toString().length >= 6 &&
|
||||
val.toString().length <= 40
|
||||
)
|
||||
.required("This field is required!"),
|
||||
});
|
||||
.min(6, '密码最少 6 个字符')
|
||||
.max(40, '密码最多 40 个字符')
|
||||
.required('请输入密码'),
|
||||
});
|
||||
|
||||
interface RegisterForm {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export default function Register() {
|
||||
const [message, setMessage] = useState('');
|
||||
const [successful, setSuccessful] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegisterForm>({ resolver: yupResolver(schema) });
|
||||
|
||||
const onSubmit = async (data: RegisterForm) => {
|
||||
setMessage('');
|
||||
setSuccessful(false);
|
||||
try {
|
||||
await authService.register(data.username, data.email, data.password);
|
||||
setSuccessful(true);
|
||||
setMessage('注册成功!请登录。');
|
||||
} catch (error: unknown) {
|
||||
const msg = error instanceof Error ? error.message : '注册失败,请重试';
|
||||
setSuccessful(false);
|
||||
setMessage(msg);
|
||||
}
|
||||
|
||||
handleRegister(formValue: { username: string; email: string; password: string }) {
|
||||
const { username, email, password } = formValue;
|
||||
|
||||
this.setState({
|
||||
message: "",
|
||||
successful: false
|
||||
});
|
||||
|
||||
AuthService.register(
|
||||
username,
|
||||
email,
|
||||
password
|
||||
).then(
|
||||
response => {
|
||||
this.setState({
|
||||
message: response.data.message,
|
||||
successful: true
|
||||
});
|
||||
},
|
||||
error => {
|
||||
const resMessage =
|
||||
(error.response &&
|
||||
error.response.data &&
|
||||
error.response.data.message) ||
|
||||
error.message ||
|
||||
error.toString();
|
||||
|
||||
this.setState({
|
||||
successful: false,
|
||||
message: resMessage
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { successful, message } = this.state;
|
||||
|
||||
const initialValues = {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -109,50 +55,56 @@ export default class Register extends Component<Props, State> {
|
||||
className="profile-img-card"
|
||||
/>
|
||||
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={this.validationSchema}
|
||||
onSubmit={this.handleRegister}
|
||||
>
|
||||
<Form>
|
||||
<form onSubmit={(e) => void handleSubmit(onSubmit)(e)}>
|
||||
{!successful && (
|
||||
<div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="username"> Username </label>
|
||||
<Field name="username" type="text" className="form-control" />
|
||||
<ErrorMessage
|
||||
name="username"
|
||||
component="div"
|
||||
className="alert alert-danger"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email"> Email </label>
|
||||
<Field name="email" type="email" className="form-control" />
|
||||
<ErrorMessage
|
||||
name="email"
|
||||
component="div"
|
||||
className="alert alert-danger"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password"> Password </label>
|
||||
<Field
|
||||
name="password"
|
||||
type="password"
|
||||
<label htmlFor="username">Username</label>
|
||||
<input
|
||||
{...register('username')}
|
||||
type="text"
|
||||
id="username"
|
||||
className="form-control"
|
||||
/>
|
||||
<ErrorMessage
|
||||
name="password"
|
||||
component="div"
|
||||
className="alert alert-danger"
|
||||
/>
|
||||
{errors.username && (
|
||||
<div className="alert alert-danger">{errors.username.message}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<button type="submit" className="btn btn-primary btn-block">Sign Up</button>
|
||||
<label htmlFor="email">Email</label>
|
||||
<input
|
||||
{...register('email')}
|
||||
type="email"
|
||||
id="email"
|
||||
className="form-control"
|
||||
/>
|
||||
{errors.email && (
|
||||
<div className="alert alert-danger">{errors.email.message}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
{...register('password')}
|
||||
type="password"
|
||||
id="password"
|
||||
className="form-control"
|
||||
/>
|
||||
{errors.password && (
|
||||
<div className="alert alert-danger">{errors.password.message}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-block"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '注册中…' : 'Sign Up'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -160,19 +112,15 @@ export default class Register extends Component<Props, State> {
|
||||
{message && (
|
||||
<div className="form-group">
|
||||
<div
|
||||
className={
|
||||
successful ? "alert alert-success" : "alert alert-danger"
|
||||
}
|
||||
className={successful ? 'alert alert-success' : 'alert alert-danger'}
|
||||
role="alert"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Formik>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { CSSProperties, ReactNode, KeyboardEvent } from 'react';
|
||||
import {
|
||||
Tree,
|
||||
List,
|
||||
Input,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
Typography,
|
||||
Toast,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCopyAdd,
|
||||
IconDelete,
|
||||
IconSearch,
|
||||
IconSetting,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { IconModal, IconToken as LabIconToken, IconTreeSelect } from '@douyinfe/semi-icons-lab';
|
||||
import { useNoteStore } from '../../stores/useNoteStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import noteService from '../../services/note.service';
|
||||
import type { INote, ITreeNode } from '../../types';
|
||||
|
||||
const NAV_ITEMS = ['首页', '网盘', '设置', '已删除'] as const;
|
||||
type NavItem = (typeof NAV_ITEMS)[number];
|
||||
|
||||
interface NoteTreeProps {
|
||||
onRefreshTree: () => void;
|
||||
onNavItemClick: (item: NavItem) => void;
|
||||
onSearch: () => void;
|
||||
}
|
||||
|
||||
export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: NoteTreeProps) {
|
||||
const { treeData, viewData, currentNoteId, setCurrentNote, isDirty } = useNoteStore();
|
||||
const { openDeleteConfirm, setSearchQuery, searchQuery, setActiveView } = useUIStore();
|
||||
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
|
||||
const toNoteId = (id: string | number) => Number(id);
|
||||
|
||||
const loadNote = useCallback(
|
||||
(id: number) => {
|
||||
const doLoad = () => {
|
||||
noteService.get(id).then(
|
||||
(res) => {
|
||||
const note = res.data;
|
||||
setCurrentNote(note.id, note.title, note.context, note.tags ?? '');
|
||||
setActiveView('note');
|
||||
},
|
||||
() => Toast.error('加载笔记失败'),
|
||||
);
|
||||
};
|
||||
|
||||
if (isDirty) {
|
||||
Modal.confirm({
|
||||
title: '未保存的内容',
|
||||
content: '当前笔记有未保存的内容,确定要离开吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: doLoad,
|
||||
});
|
||||
} else {
|
||||
doLoad();
|
||||
}
|
||||
},
|
||||
[setCurrentNote, setActiveView, isDirty],
|
||||
);
|
||||
|
||||
const addNote = useCallback(
|
||||
(pid: number) => {
|
||||
noteService.add(pid).then(
|
||||
() => {
|
||||
Toast.success('已添加');
|
||||
onRefreshTree();
|
||||
},
|
||||
() => Toast.error('添加失败'),
|
||||
);
|
||||
},
|
||||
[onRefreshTree],
|
||||
);
|
||||
|
||||
const renameNote = useCallback(
|
||||
async (id: number, newTitle: string) => {
|
||||
try {
|
||||
const res = await noteService.get(id);
|
||||
await noteService.update(id, newTitle, res.data.context);
|
||||
onRefreshTree();
|
||||
} catch {
|
||||
Toast.error('重命名失败');
|
||||
}
|
||||
},
|
||||
[onRefreshTree],
|
||||
);
|
||||
|
||||
const treeStyle: CSSProperties = {
|
||||
height: 550,
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
position: 'sticky',
|
||||
top: '85px',
|
||||
};
|
||||
|
||||
const renderLabel = (label: ReactNode, item: ITreeNode) => {
|
||||
const key = String(item.key);
|
||||
const id = toNoteId(item.key);
|
||||
|
||||
if (editingKey === key) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center' }} onClick={(e) => e.stopPropagation()}>
|
||||
<Input
|
||||
autoFocus
|
||||
size="small"
|
||||
value={editingValue}
|
||||
onChange={(v) => setEditingValue(v)}
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
void renameNote(id, editingValue);
|
||||
setEditingKey(null);
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingKey(null);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
void renameNote(id, editingValue);
|
||||
setEditingKey(null);
|
||||
}}
|
||||
style={{ width: 'calc(100% - 68px)' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: 'calc(100% - 68px)', cursor: 'text' }}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingKey(key);
|
||||
setEditingValue(String(label));
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<ButtonGroup size="small" theme="borderless">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconCopyAdd />}
|
||||
onClick={(e) => {
|
||||
addNote(id);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="warning"
|
||||
icon={<IconDelete />}
|
||||
onClick={(e) => {
|
||||
openDeleteConfirm(id, String(label));
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<br />
|
||||
<div>
|
||||
<Input
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
value={searchQuery}
|
||||
onChange={(v) => setSearchQuery(v)}
|
||||
onEnterPress={onSearch}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
position: 'sticky',
|
||||
top: '3px',
|
||||
paddingLeft: '10px',
|
||||
paddingTop: '3px',
|
||||
}}
|
||||
>
|
||||
CYY Note
|
||||
</h3>
|
||||
|
||||
<List
|
||||
bordered
|
||||
style={{
|
||||
flexBasis: '100%',
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid var(--semi-color-border)',
|
||||
}}
|
||||
dataSource={NAV_ITEMS as unknown as NavItem[]}
|
||||
renderItem={(item: NavItem) => (
|
||||
<div
|
||||
style={{ margin: 5, border: '1px solid var(--semi-color-border)', cursor: 'pointer' }}
|
||||
className="list-item"
|
||||
onClick={() => onNavItemClick(item)}
|
||||
>
|
||||
{item === '首页' && <Button type="danger" theme="borderless" icon={<LabIconToken />} style={{ marginRight: 4 }} />}
|
||||
{item === '网盘' && <Button type="danger" theme="borderless" icon={<IconModal />} style={{ marginRight: 4 }} />}
|
||||
{item === '设置' && <Button type="danger" theme="borderless" icon={<IconTreeSelect />} style={{ marginRight: 4 }} />}
|
||||
{item === '已删除' && <Button type="danger" theme="borderless" icon={<IconSetting />} style={{ marginRight: 4 }} />}
|
||||
{item}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
marginTop: 16,
|
||||
textAlign: 'center',
|
||||
position: 'sticky',
|
||||
top: '40px',
|
||||
}}
|
||||
>
|
||||
<Button type="secondary" onClick={() => addNote(0)}>
|
||||
添加主菜单
|
||||
</Button>
|
||||
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
showTick
|
||||
position="bottomLeft"
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Title>最近浏览</Dropdown.Title>
|
||||
<Dropdown.Divider />
|
||||
{viewData.map((item: INote) => (
|
||||
<Dropdown.Item
|
||||
key={item.id}
|
||||
type="primary"
|
||||
onClick={() => loadNote(item.id)}
|
||||
>
|
||||
{item.title}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
>
|
||||
<Button type="secondary">最近浏览</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<Tree
|
||||
filterTreeNode
|
||||
searchPlaceholder=" "
|
||||
showFilteredOnly
|
||||
directory
|
||||
draggable
|
||||
virtualize={{ itemSize: 28 }}
|
||||
value={currentNoteId === null ? undefined : String(currentNoteId)}
|
||||
treeData={treeData as unknown as any}
|
||||
renderLabel={renderLabel as any}
|
||||
style={treeStyle}
|
||||
onSelect={(id) => loadNote(toNoteId(id as string | number))}
|
||||
onDrop={({ node, dragNode }: { node: any; dragNode: any }) => {
|
||||
const id = toNoteId(dragNode.key);
|
||||
const newPid = toNoteId(node.key);
|
||||
noteService.move(id, newPid).then(
|
||||
() => { Toast.success('移动成功'); onRefreshTree(); },
|
||||
() => Toast.error('移动失败'),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../stores/useAuthStore';
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const { token } = useAuthStore.getState();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error: unknown) => {
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
useAuthStore.getState().logout();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default axiosInstance;
|
||||
@@ -1,28 +1,12 @@
|
||||
// import React from 'react'
|
||||
// import ReactDOM from 'react-dom/client'
|
||||
// import App from './App.tsx'
|
||||
// import './index.css'
|
||||
//
|
||||
// ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
// <React.StrictMode>
|
||||
// <App />
|
||||
// </React.StrictMode>,
|
||||
// )
|
||||
|
||||
|
||||
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { BrowserRouter } from 'react-router';
|
||||
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
|
||||
root.render(
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Toast,
|
||||
Typography,
|
||||
Upload,
|
||||
Modal,
|
||||
Input,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Spin,
|
||||
Empty,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Progress,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { IconUpload, IconLink, IconDelete, IconRefresh, IconDownload } from '@douyinfe/semi-icons';
|
||||
import type { ColumnProps } from '@douyinfe/semi-ui/lib/es/table';
|
||||
import driveService from '../../services/drive.service';
|
||||
import settingsService from '../../services/settings.service';
|
||||
import type { IDriveFile } from '../../types';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
function formatSize(bytes: number | null): string {
|
||||
if (!bytes) return '—';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export default function DrivePage() {
|
||||
const [files, setFiles] = useState<IDriveFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [r2PublicUrl, setR2PublicUrl] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<'local' | 'r2'>('local');
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
|
||||
// Share modal state
|
||||
const [shareModalVisible, setShareModalVisible] = useState(false);
|
||||
const [shareTarget, setShareTarget] = useState<IDriveFile | null>(null);
|
||||
const [shareDays, setShareDays] = useState('7');
|
||||
const [generatedLink, setGeneratedLink] = useState('');
|
||||
|
||||
const loadFiles = useCallback(() => {
|
||||
setLoading(true);
|
||||
driveService
|
||||
.list()
|
||||
.then((res) => setFiles(res.data ?? []))
|
||||
.catch(() => Toast.error('加载文件列表失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
settingsService.getAll().then((res) => {
|
||||
if (res.data?.r2_public_url) setR2PublicUrl(res.data.r2_public_url as string);
|
||||
}).catch(() => {});
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleDelete = useCallback(async (id: number) => {
|
||||
try {
|
||||
await driveService.delete(id);
|
||||
Toast.success('已删除');
|
||||
loadFiles();
|
||||
} catch {
|
||||
Toast.error('删除失败');
|
||||
}
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleShare = useCallback(async () => {
|
||||
if (!shareTarget) return;
|
||||
try {
|
||||
const days = parseInt(shareDays, 10);
|
||||
const res = await driveService.share(shareTarget.id, isNaN(days) ? undefined : days);
|
||||
const token = res.data.share_token;
|
||||
const baseUrl = window.location.origin;
|
||||
if (shareTarget.storageMode === 'local') {
|
||||
setGeneratedLink(`${baseUrl}/api/drive/public-download/${token}`);
|
||||
} else {
|
||||
setGeneratedLink(`${baseUrl}/api/drive/public/${token}`);
|
||||
}
|
||||
Toast.success('分享链接已生成');
|
||||
} catch {
|
||||
Toast.error('生成分享链接失败');
|
||||
}
|
||||
}, [shareTarget, shareDays]);
|
||||
|
||||
const handleUnshare = useCallback(async (id: number) => {
|
||||
try {
|
||||
await driveService.unshare(id);
|
||||
Toast.success('分享链接已撤销');
|
||||
loadFiles();
|
||||
} catch {
|
||||
Toast.error('操作失败');
|
||||
}
|
||||
}, [loadFiles]);
|
||||
|
||||
const handleLocalUpload = useCallback(async (file: File) => {
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
await driveService.uploadLocal(file, setUploadProgress);
|
||||
Toast.success('上传成功');
|
||||
loadFiles();
|
||||
} catch {
|
||||
Toast.error('上传失败');
|
||||
} finally {
|
||||
setUploadProgress(null);
|
||||
}
|
||||
return false; // prevent default upload
|
||||
}, [loadFiles]);
|
||||
|
||||
const actionColumn = (record: IDriveFile) => (
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{record.storageMode === 'local' ? (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDownload />}
|
||||
onClick={() => {
|
||||
const a = document.createElement('a');
|
||||
a.href = driveService.getDownloadUrl(record.id);
|
||||
a.download = record.originalName;
|
||||
a.click();
|
||||
}}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconLink />}
|
||||
onClick={() => {
|
||||
setShareTarget(record);
|
||||
setGeneratedLink('');
|
||||
setShareDays('7');
|
||||
setShareModalVisible(true);
|
||||
}}
|
||||
>
|
||||
分享
|
||||
</Button>
|
||||
{record.shareToken && (
|
||||
<Popconfirm
|
||||
title="撤销分享链接?"
|
||||
onConfirm={() => void handleUnshare(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button size="small" type="warning">撤销</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
onConfirm={() => void handleDelete(record.id)}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button size="small" type="danger" icon={<IconDelete />} />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
);
|
||||
|
||||
const columns: ColumnProps<IDriveFile>[] = [
|
||||
{
|
||||
title: '文件名',
|
||||
dataIndex: 'originalName',
|
||||
render: (text: string) => <Text ellipsis={{ showTooltip: true }} style={{ maxWidth: 200 }}>{text}</Text>,
|
||||
},
|
||||
{
|
||||
title: '大小',
|
||||
dataIndex: 'size',
|
||||
width: 100,
|
||||
render: (size: number) => formatSize(size),
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'mimeType',
|
||||
width: 140,
|
||||
render: (type: string) => <Tag size="small">{type || '未知'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '上传时间',
|
||||
dataIndex: 'createtime',
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: '分享状态',
|
||||
dataIndex: 'shareToken',
|
||||
width: 90,
|
||||
render: (token: string | null) =>
|
||||
token ? <Tag color="green" size="small">已分享</Tag> : <Tag size="small">未分享</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
render: (_: unknown, record: IDriveFile) => actionColumn(record),
|
||||
},
|
||||
];
|
||||
|
||||
const localFiles = files.filter((f) => f.storageMode === 'local');
|
||||
const r2Files = files.filter((f) => !f.storageMode || f.storageMode === 'r2');
|
||||
|
||||
const renderTable = (data: IDriveFile[]) => {
|
||||
if (loading) return <div style={{ textAlign: 'center', padding: 60 }}><Spin size="large" /></div>;
|
||||
if (data.length === 0) return <Empty title="暂无文件" description="上传文件后将在此显示" style={{ paddingTop: 60 }} />;
|
||||
return <Table columns={columns} dataSource={data} rowKey="id" pagination={{ pageSize: 20 }} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Title heading={5} style={{ margin: 0 }}>我的网盘</Title>
|
||||
<Button icon={<IconRefresh />} onClick={loadFiles}>刷新</Button>
|
||||
</div>
|
||||
|
||||
<Tabs activeKey={activeTab} onChange={(k) => setActiveTab(k as 'local' | 'r2')}>
|
||||
{/* 本地存储 Tab */}
|
||||
<TabPane tab="本地存储" itemKey="local">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Upload
|
||||
action=""
|
||||
customRequest={({ file, onSuccess, onError }) => {
|
||||
void handleLocalUpload(file as unknown as File).then(() => onSuccess?.({})).catch(() => onError?.({ status: 500 }));
|
||||
}}
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<IconUpload />} theme="solid" type="primary">上传到本地服务器</Button>
|
||||
</Upload>
|
||||
{uploadProgress !== null && (
|
||||
<div style={{ marginTop: 8, maxWidth: 300 }}>
|
||||
<Progress percent={uploadProgress} showInfo size="small" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{renderTable(localFiles)}
|
||||
</TabPane>
|
||||
|
||||
{/* R2 存储 Tab */}
|
||||
<TabPane tab="R2 存储" itemKey="r2">
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{!r2PublicUrl && (
|
||||
<div style={{
|
||||
padding: '10px 16px',
|
||||
marginBottom: 12,
|
||||
background: 'var(--semi-color-warning-light-default)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--semi-color-warning)',
|
||||
}}>
|
||||
⚠️ 尚未配置 R2 存储,请前往 <strong>设置 → 网盘配置</strong> 完成配置后再使用上传功能。
|
||||
</div>
|
||||
)}
|
||||
<Upload
|
||||
action=""
|
||||
customRequest={({ file, onSuccess, onError }) => {
|
||||
Toast.info('上传功能需要配置 R2 存储,请先在设置页面配置 R2 信息');
|
||||
onError?.({ status: 0 });
|
||||
}}
|
||||
showUploadList={false}
|
||||
>
|
||||
<Button icon={<IconUpload />} theme="solid" type="primary">上传到 R2</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
{renderTable(r2Files)}
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
|
||||
{/* Share Modal */}
|
||||
<Modal
|
||||
title="生成分享链接"
|
||||
visible={shareModalVisible}
|
||||
onCancel={() => setShareModalVisible(false)}
|
||||
footer={null}
|
||||
width={480}
|
||||
>
|
||||
<Text>文件:{shareTarget?.originalName}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, margin: '16px 0' }}>
|
||||
<Text style={{ flexShrink: 0 }}>有效天数(0=永久):</Text>
|
||||
<Input
|
||||
value={shareDays}
|
||||
onChange={setShareDays}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
/>
|
||||
<Button theme="solid" type="primary" onClick={() => void handleShare()}>
|
||||
生成链接
|
||||
</Button>
|
||||
</div>
|
||||
{generatedLink && (
|
||||
<div>
|
||||
<Input
|
||||
value={generatedLink}
|
||||
readonly
|
||||
suffix={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(generatedLink);
|
||||
Toast.success('已复制');
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Text type="tertiary" size="small" style={{ marginTop: 6, display: 'block' }}>
|
||||
分享者可通过此链接直接下载文件
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
List,
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Typography,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Toast,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { useNoteStore } from '../../stores/useNoteStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import noteService from '../../services/note.service';
|
||||
import type { INote } from '../../types';
|
||||
|
||||
export default function HomePage() {
|
||||
const { homeData, setHomeData, setCurrentNote } = useNoteStore();
|
||||
const setActiveView = useUIStore((s) => s.setActiveView);
|
||||
|
||||
const loadNote = (id: number) => {
|
||||
noteService.get(id).then(
|
||||
(res) => {
|
||||
const note = res.data;
|
||||
setCurrentNote(note.id, note.title, note.context);
|
||||
setActiveView('note');
|
||||
},
|
||||
() => Toast.error('加载笔记失败'),
|
||||
);
|
||||
};
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
noteService.getHomeData(key, '0').then(
|
||||
(res) => setHomeData(res.data),
|
||||
() => Toast.error('加载列表失败'),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs type="button" onChange={handleTabChange} defaultActiveKey="1">
|
||||
<TabPane tab="最近更新" itemKey="1" />
|
||||
<TabPane tab="最新添加" itemKey="2" />
|
||||
<TabPane tab="最近浏览" itemKey="3" />
|
||||
</Tabs>
|
||||
<div style={{ padding: 5, border: '1px solid var(--semi-color-border)', margin: 5 }}>
|
||||
<List
|
||||
dataSource={homeData}
|
||||
renderItem={(item: INote) => (
|
||||
<List.Item
|
||||
header={<Avatar color="blue">NOTE</Avatar>}
|
||||
main={
|
||||
<div>
|
||||
<span style={{ color: 'var(--semi-color-text-0)', fontWeight: 500 }}>
|
||||
{item.title}
|
||||
</span>
|
||||
<Typography.Text
|
||||
ellipsis={{ showTooltip: true }}
|
||||
style={{ width: 'calc(100% - 68px)' }}
|
||||
>
|
||||
创建时间:{item.createtime},更新时间:{item.updatetime},
|
||||
最后浏览时间:{item.viewtime}。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
extra={
|
||||
<ButtonGroup theme="borderless">
|
||||
<Button onClick={() => loadNote(item.id)}>查看</Button>
|
||||
</ButtonGroup>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user