4 Commits

Author SHA1 Message Date
Chuyaoyuan 8aa7387e4d Add storage 2026-05-15 19:01:10 +08:00
Chuyaoyuan 8a16b01e9e Update 2026-05-14 19:55:53 +08:00
Chuyaoyuan c764603dbd update 2026-04-30 18:53:50 +08:00
Chuyaoyuan 88c2fa5d03 update 2026-04-29 19:05:47 +08:00
116 changed files with 10298 additions and 8007 deletions
+282
View File
@@ -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-jwtJWT 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
View File
@@ -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 -1
View File
@@ -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'));
+2 -1
View File
@@ -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.
@@ -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
@@ -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
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# Copy to .env and fill in your values
VITE_API_BASE_URL=/api
VITE_WS_URL=ws://localhost:1234
+38
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
shamefully-hoist=true
+1
View File
@@ -0,0 +1 @@
20
+31
View File
@@ -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 },
],
},
},
);
+59 -67
View File
@@ -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"
}
}
+5537
View File
File diff suppressed because it is too large Load Diff
+24 -150
View File
@@ -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>
&nbsp;&nbsp;
<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('移动失败'),
);
}}
/>
</>
);
}
+30
View File
@@ -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;
+4 -20
View File
@@ -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