Add storage

This commit is contained in:
2026-05-15 19:01:10 +08:00
parent 8a16b01e9e
commit 8aa7387e4d
49 changed files with 2112 additions and 223 deletions
Binary file not shown.
+7
View File
@@ -84,6 +84,13 @@
<artifactId>solon-test</artifactId> <artifactId>solon-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </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> </dependencies>
@@ -1,6 +1,7 @@
package com.cyynote.controller; package com.cyynote.controller;
import cn.hutool.core.date.DateUtil; import cn.hutool.core.date.DateUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.alibaba.fastjson2.JSONObject; import com.alibaba.fastjson2.JSONObject;
import com.cyynote.model.UserModel; import com.cyynote.model.UserModel;
import com.cyynote.payload.request.LoginRequest; import com.cyynote.payload.request.LoginRequest;
@@ -42,9 +43,8 @@ public class AuthController extends BaseController {
} }
log.info("signin username:" + loginRequest.getUsername()); log.info("signin username:" + loginRequest.getUsername());
UserModel model = (UserModel) ((DbTableQuery) ((DbTableQuery) this.db.table("user") UserModel model = (UserModel) ((DbTableQuery) this.db.table("user")
.whereEq("username", loginRequest.getUsername())) .whereEq("username", loginRequest.getUsername()))
.andEq("password", loginRequest.getPassword()))
.selectItem("*", UserModel.class); .selectItem("*", UserModel.class);
if (model == null) { if (model == null) {
@@ -52,6 +52,29 @@ public class AuthController extends BaseController {
return Result.failure(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_name", model.getUsername());
ctx.sessionSet("user_id", model.getId()); ctx.sessionSet("user_id", model.getId());
String token = ctx.sessionState().sessionToken(); String token = ctx.sessionState().sessionToken();
@@ -84,17 +107,30 @@ public class AuthController extends BaseController {
} }
log.info("update password username:" + loginRequest.getUsername()); log.info("update password username:" + loginRequest.getUsername());
UserModel model = (UserModel) ((DbTableQuery) ((DbTableQuery) this.db.table("user") UserModel model = (UserModel) ((DbTableQuery) this.db.table("user")
.whereEq("username", loginRequest.getUsername())) .whereEq("username", loginRequest.getUsername()))
.andEq("password", loginRequest.getOldpassword()))
.selectItem("*", UserModel.class); .selectItem("*", UserModel.class);
if (model == null) { if (model == null) {
ctx.status(400); ctx.status(400);
return Result.failure(400, "用户名或旧密码错误"); return Result.failure(400, "用户名或旧密码错误");
} }
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, "用户名或旧密码错误");
}
String newHashed = DigestUtil.sha256Hex(loginRequest.getNewpassword());
int flag = ((DbTableQuery) this.db.table("user") int flag = ((DbTableQuery) this.db.table("user")
.set("password", loginRequest.getNewpassword()) .set("password", newHashed)
.set("password_hashed", 1)
.whereEq("id", model.getId())) .whereEq("id", model.getId()))
.update(); .update();
return Result.succeed(flag); return Result.succeed(flag);
@@ -120,7 +156,8 @@ public class AuthController extends BaseController {
UserModel user = new UserModel(); UserModel user = new UserModel();
user.setUsername(signUpRequest.getUsername()); user.setUsername(signUpRequest.getUsername());
user.setEmail(signUpRequest.getEmail()); user.setEmail(signUpRequest.getEmail());
user.setPassword(signUpRequest.getPassword()); user.setPassword(DigestUtil.sha256Hex(signUpRequest.getPassword()));
user.setPasswordHashed(1);
user.setRole("ROLE_USER"); user.setRole("ROLE_USER");
this.db.table("user").setEntityIf(user, (k, v) -> Boolean.valueOf(v != null)).insert(); this.db.table("user").setEntityIf(user, (k, v) -> Boolean.valueOf(v != null)).insert();
return Result.succeed("注册成功"); return Result.succeed("注册成功");
@@ -145,4 +182,3 @@ public class AuthController extends BaseController {
return List.of("ROLE_USER"); 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;
}
}
@@ -40,7 +40,7 @@ public class NoteController extends BaseController {
@Db @Db
DbContext 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 @Get
@Mapping("get1") @Mapping("get1")
@@ -64,7 +64,10 @@ public class NoteController extends BaseController {
@Get @Get
@Mapping("/all") @Mapping("/all")
public Result noteAccess(Context ctx) throws Exception { 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<>(); List<TreeNode> treeNodeList = new ArrayList<>();
for (NoteModel n : all) { for (NoteModel n : all) {
TreeNode treeNode = new TreeNode(n.getId().intValue(), n.getPid().intValue(), n.getTitle(), String.valueOf(n.getId()), String.valueOf(n.getId())); TreeNode treeNode = new TreeNode(n.getId().intValue(), n.getPid().intValue(), n.getTitle(), String.valueOf(n.getId()), String.valueOf(n.getId()));
@@ -78,17 +81,29 @@ public class NoteController extends BaseController {
@Get @Get
@Mapping("/home") @Mapping("/home")
public Result noteHome(Context ctx, @Path int type, @Path String search) throws Exception { public Result noteHome(Context ctx, @Path int type, @Path String search) throws Exception {
String userId = getUserInfoId(ctx);
List<NoteModel> all = new ArrayList<>(); List<NoteModel> all = new ArrayList<>();
if (type == 0) { 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) { } 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) { } 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) { } 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) { } 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))); return Result.succeed(JSONArray.parseArray(JSONArray.toJSONString(all)));
} }
@@ -96,15 +111,17 @@ public class NoteController extends BaseController {
@Get @Get
@Mapping("/get") @Mapping("/get")
public Result getNote(Context ctx, @Path int id) throws Exception { 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) { if (model == null) {
ctx.status(404); ctx.status(404);
return Result.failure(404, "笔记不存在"); return Result.failure(404, "笔记不存在");
} }
String time = DateUtil.now(); String time = DateUtil.now();
((DbTableQuery)this.db.table("note").set("viewtime", time).whereEq("id", Integer.valueOf(id))).update(); this.db.table("note").set("viewtime", time).whereEq("id", id).update();
model.setViewtime(time); model.setViewtime(time);
log.info(JSONObject.toJSONString(model, new com.alibaba.fastjson2.JSONWriter.Feature[0]));
return Result.succeed(model); return Result.succeed(model);
} }
@@ -121,9 +138,8 @@ public class NoteController extends BaseController {
note.setCreatetime(DateUtil.now()); note.setCreatetime(DateUtil.now());
note.setUpdatetime(note.getCreatetime()); note.setUpdatetime(note.getCreatetime());
note.setViewtime(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\":{\"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(); this.db.table("note").setEntityIf(note, (k, v) -> Boolean.valueOf((v != null))).insert();
return Result.succeed("ok"); return Result.succeed("ok");
} }
@@ -135,26 +151,32 @@ public class NoteController extends BaseController {
ctx.status(400); ctx.status(400);
return Result.failure(400, "参数不完整"); return Result.failure(400, "参数不完整");
} }
NoteModel note = (NoteModel)((DbTableQuery)this.db.table("note").whereEq("id", noteRequest.getId())).selectItem("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class); 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) { if (note == null) {
ctx.status(404); ctx.status(404);
return Result.failure(404, "笔记不存在"); return Result.failure(404, "笔记不存在");
} }
note.setTitle(noteRequest.getTitle());
note.setUpdatetime(DateUtil.now());
String cc = noteRequest.getContext(); String cc = noteRequest.getContext();
JSONObject ar = JSONObject.parseObject(cc); JSONObject ar = JSONObject.parseObject(cc);
note.setContext(ar.toJSONString()); DbTableQuery uq = this.db.table("note")
int flag = ((DbTableQuery)this.db.table("note").set("title", noteRequest.getTitle()).set("updatetime", DateUtil.now()).set("context", ar.toJSONString()).whereEq("id", noteRequest.getId())).update(); .set("title", noteRequest.getTitle())
log.info("updateNote+ flag"); .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(); HistoryModel history = new HistoryModel();
history.setNid(Integer.valueOf(noteRequest.getId().intValue())); history.setNid(Integer.valueOf(noteRequest.getId().intValue()));
history.setTitle(noteRequest.getTitle()); history.setTitle(noteRequest.getTitle());
history.setFlag(Integer.valueOf(1)); history.setFlag(Integer.valueOf(1));
history.setCreatetime(DateUtil.now()); history.setCreatetime(DateUtil.now());
history.setContext(ar.toJSONString()); history.setContext(ar.toJSONString());
long his = this.db.table("history").setEntityIf(history, (k, v) -> Boolean.valueOf((v != null))).insert(); this.db.table("history").setEntityIf(history, (k, v) -> Boolean.valueOf((v != null))).insert();
log.info("history+ his");
return Result.succeed("ok"); return Result.succeed("ok");
} }
@@ -165,7 +187,10 @@ public class NoteController extends BaseController {
ctx.status(400); ctx.status(400);
return Result.failure(400, "笔记ID不能为空"); return Result.failure(400, "笔记ID不能为空");
} }
int flag = ((DbTableQuery)this.db.table("note").set("flag", Integer.valueOf(0)).set("updatetime", DateUtil.now()).whereEq("id", noteRequest.getId())).update(); 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) { if (flag == 0) {
ctx.status(404); ctx.status(404);
return Result.failure(404, "笔记不存在"); return Result.failure(404, "笔记不存在");
@@ -176,7 +201,28 @@ public class NoteController extends BaseController {
@Get @Get
@Mapping("/deleteBack") @Mapping("/deleteBack")
public Result deleteBack(Context ctx, @Path int id) throws Exception { public Result deleteBack(Context ctx, @Path int id) throws Exception {
int flag = ((DbTableQuery)this.db.table("note").set("flag", Integer.valueOf(1)).set("updatetime", DateUtil.now()).whereEq("id", Integer.valueOf(id))).update(); 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) { if (flag == 0) {
ctx.status(404); ctx.status(404);
return Result.failure(404, "笔记不存在"); return Result.failure(404, "笔记不存在");
@@ -187,21 +233,52 @@ public class NoteController extends BaseController {
@Get @Get
@Mapping("/historyQueryOrDelete") @Mapping("/historyQueryOrDelete")
public Result historyQueryOrDelete(@Path int flag) throws Exception { public Result historyQueryOrDelete(@Path int flag) throws Exception {
log.info("history+ flag" + flag); log.info("history+ flag" + flag);
if (flag == 0) {
if(flag==0){
long count = db.table("history").selectCount(); long count = db.table("history").selectCount();
log.info("count:" + count); log.info("count:" + count);
return Result.succeed(count); return Result.succeed(count);
}else if(flag==1){ } else if (flag == 1) {
int flagdb = db.table("history").whereEq("flag",1).delete(); int flagdb = db.table("history").whereEq("flag", 1).delete();
log.info("flagdb:" + flagdb); log.info("flagdb:" + flagdb);
return Result.succeed(0); return Result.succeed(0);
} }
return Result.failure(400, "不支持的操作类型"); 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) { private String getUserInfoId(Context ctx) {
String userId = ""; String userId = "";
if (null != ctx.header("Authorization")) { if (null != ctx.header("Authorization")) {
@@ -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; package com.cyynote.dso;
import java.io.InputStream; import java.io.InputStream;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.sql.Connection; import java.sql.Connection;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; 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; import javax.sql.DataSource;
public class DsHelper { public class DsHelper {
private static final Logger log = Logger.getLogger(DsHelper.class.getName());
private static boolean inited = false; private static boolean inited = false;
public static void initData(DataSource ds) { public static void initData(DataSource ds) {
if (inited) if (inited) return;
return;
inited = true; inited = true;
Connection conn = null; try (Connection conn = ds.getConnection()) {
try { // 1. Initial schema: create all base tables if not present
conn = ds.getConnection(); if (!tableExists(conn, "appx")) {
PreparedStatement ps = conn.prepareStatement("SELECT name FROM sqlite_master WHERE type='table' AND name='appx';"); log.info("[DB] Running initial schema...");
ResultSet res = ps.executeQuery(); for (String sql : loadSqlFile("/db/schema.sql")) {
if (!res.next()) { runSqlIgnoreError(conn, sql, null);
String[] sqls = getSqlFromFile();
for (String sql : sqls)
runSql(conn, sql);
} }
ps.close(); }
} catch (SQLException sqlException) { // 2. Versioned migrations
throw new RuntimeException(sqlException); runMigrations(conn);
} finally { } catch (SQLException e) {
try { throw new RuntimeException("DB init failed", e);
if (conn != null)
conn.close();
} catch (SQLException sQLException) {}
} }
} }
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 { try {
InputStream ins = com.cyynote.dso.DsHelper.class.getResourceAsStream("/db/schema.sql"); URL dirUrl = DsHelper.class.getResource("/db/migrations/");
int len = ins.available(); if (dirUrl == null) return files;
byte[] bs = new byte[len]; String protocol = dirUrl.getProtocol();
ins.read(bs); if ("file".equals(protocol)) {
String str = new String(bs, "UTF-8"); java.io.File dir = new java.io.File(dirUrl.toURI());
String[] sql = str.split(";"); if (dir.isDirectory()) {
return sql; 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) { } 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 { private static void runSql(Connection conn, String sql) throws SQLException {
System.out.println(sql); Statement st = conn.createStatement();
PreparedStatement ps = conn.prepareStatement(sql); st.execute(sql);
ps.executeUpdate(); st.close();
ps.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 String viewtime;
public Integer userid;
public Integer flag; public Integer flag;
public String tags;
public void setId(Integer id) { public void setId(Integer id) {
this.id = id; this.id = id;
} }
@@ -63,6 +67,14 @@ public class NoteModel {
this.flag = flag; this.flag = flag;
} }
public void setUserid(Integer userid) {
this.userid = userid;
}
public Integer getUserid() {
return this.userid;
}
public String toString() { public String toString() {
return "NoteModel(id=" + getId() + ", pid=" + getPid() + ", title=" + getTitle() + ", context=" + getContext() + ", conjson=" + getConjson() + ", createtime=" + getCreatetime() + ", updatetime=" + getUpdatetime() + ", viewtime=" + getViewtime() + ", flag=" + getFlag() + ")"; 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() { public Integer getFlag() {
return this.flag; 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 String logintime;
public Integer passwordHashed;
public void setId(Integer id) { public void setId(Integer id) {
this.id = id; this.id = id;
} }
@@ -51,6 +53,14 @@ public class UserModel {
this.logintime = logintime; this.logintime = logintime;
} }
public Integer getPasswordHashed() {
return this.passwordHashed;
}
public void setPasswordHashed(Integer passwordHashed) {
this.passwordHashed = passwordHashed;
}
public String toString() { public String toString() {
return "UserModel(id=" + getId() + ", email=" + getEmail() + ", password=" + getPassword() + ", username=" + getUsername() + ", role=" + getRole() + ", token=" + getToken() + ", logintime=" + getLogintime() + ")"; 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 context;
private String tags;
public Long getId() { public Long getId() {
return this.id; return this.id;
} }
@@ -41,4 +43,12 @@ public class NoteRequest {
public void setContext(String context) { public void setContext(String context) {
this.context = context; this.context = context;
} }
public String getTags() {
return this.tags;
}
public void setTags(String tags) {
this.tags = tags;
}
} }
@@ -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'));
@@ -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'));
Binary file not shown.
Binary file not shown.
@@ -1,5 +1,5 @@
#Generated by Maven #Generated by Maven
#Thu May 14 15:10:41 CST 2026 #Fri May 15 17:58:56 CST 2026
groupId=com.cyynote groupId=com.cyynote
artifactId=cyynote artifactId=cyynote
version=1.0.0 version=1.0.0
@@ -1,5 +1,6 @@
com/cyynote/payload/request/LoginRequest.class com/cyynote/payload/request/LoginRequest.class
com/cyynote/dso/DsHelper.class com/cyynote/dso/DsHelper.class
com/cyynote/controller/SettingsController.class
com/cyynote/controller/JwtInterceptor.class com/cyynote/controller/JwtInterceptor.class
com/cyynote/util/TreeNode.class com/cyynote/util/TreeNode.class
com/cyynote/model/Appx.class com/cyynote/model/Appx.class
@@ -14,12 +15,14 @@ com/cyynote/Config.class
com/cyynote/payload/request/UpdatePasswordRequest.class com/cyynote/payload/request/UpdatePasswordRequest.class
com/cyynote/payload/request/NoteRequest.class com/cyynote/payload/request/NoteRequest.class
com/cyynote/WebApp.class com/cyynote/WebApp.class
com/cyynote/controller/DriveController.class
com/cyynote/dso/AuthSqlAnnotation.class com/cyynote/dso/AuthSqlAnnotation.class
com/cyynote/dso/SqlMapper.class com/cyynote/dso/SqlMapper.class
com/cyynote/payload/response/JwtResponse.class com/cyynote/payload/response/JwtResponse.class
com/cyynote/controller/AuthController.class com/cyynote/controller/AuthController.class
com/cyynote/dso/NoteSqlAnnotation.class com/cyynote/dso/NoteSqlAnnotation.class
com/cyynote/model/AppxModel.class com/cyynote/model/AppxModel.class
com/cyynote/model/DriveFileModel.class
com/cyynote/dso/SqlAnnotation.class com/cyynote/dso/SqlAnnotation.class
com/cyynote/controller/BaseController.class com/cyynote/controller/BaseController.class
com/cyynote/util/TreeBuild.class com/cyynote/util/TreeBuild.class
@@ -3,8 +3,10 @@
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/AuthController.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/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/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/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/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/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/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/AuthSqlAnnotation.java
@@ -14,6 +16,7 @@
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/SqlMapper.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/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/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/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/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/model/UserModel.java
@@ -79,20 +79,19 @@ const skipCollaborationInit =
window.parent != null && window.parent.frames.right === window; window.parent != null && window.parent.frames.right === window;
function OnChangePlugin({ onChange }) { function OnChangePlugin({ onChange }: { onChange: (editorState: unknown) => void }): null {
const [editor] = useLexicalComposerContext(); const [editor] = useLexicalComposerContext();
useEffect(() => { useEffect(() => {
return editor.registerUpdateListener(({editorState}) => { return editor.registerUpdateListener(({editorState}) => {
onChange(editorState); onChange(editorState);
}); });
}, [editor, onChange]); }, [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(); const [editor] = useLexicalComposerContext();
console.log(props.noteId)
const {historyState} = useSharedHistoryContext(); const {historyState} = useSharedHistoryContext();
const { const {
settings: { settings: {
@@ -121,7 +120,7 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
const [isSmallWidthViewport, setIsSmallWidthViewport] = const [isSmallWidthViewport, setIsSmallWidthViewport] =
useState<boolean>(false); useState<boolean>(false);
const [isLinkEditMode, setIsLinkEditMode] = useState<boolean>(false); const [isLinkEditMode, setIsLinkEditMode] = useState<boolean>(false);
const [editorState, setEditorState] = useState(); const [editorState, setEditorState] = useState<string | undefined>(undefined);
const onRef = (_floatingAnchorElem: HTMLDivElement) => { const onRef = (_floatingAnchorElem: HTMLDivElement) => {
if (_floatingAnchorElem !== null) { if (_floatingAnchorElem !== null) {
setFloatingAnchorElem(_floatingAnchorElem); setFloatingAnchorElem(_floatingAnchorElem);
@@ -146,18 +145,11 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
}, [isSmallWidthViewport]); }, [isSmallWidthViewport]);
function onChange(editorState) { function onChange(editorState: { toJSON: () => unknown }) {
// Call toJSON on the EditorState object, which produces a serialization safe string
const editorStateJSON = editorState.toJSON(); 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)); setEditorState(JSON.stringify(editorStateJSON));
} }
console.log(props.noteId)
// console.log(props.editData)
return ( return (
<> <>
{isRichText && <ToolbarPlugin setIsLinkEditMode={setIsLinkEditMode} />} {isRichText && <ToolbarPlugin setIsLinkEditMode={setIsLinkEditMode} />}
@@ -270,7 +262,7 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
{isAutocomplete && <AutocompletePlugin />} {isAutocomplete && <AutocompletePlugin />}
<div>{showTableOfContents && <TableOfContentsPlugin />}</div> <div>{showTableOfContents && <TableOfContentsPlugin />}</div>
{shouldUseLexicalContextMenu && <ContextMenuPlugin />} {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> </div>
{showTreeView && <TreeViewPlugin />} {showTreeView && <TreeViewPlugin />}
</> </>
@@ -112,16 +112,11 @@ 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 { const {
settings: {isCollab, emptyEditor, measureTypingPerf}, settings: {isCollab, emptyEditor, measureTypingPerf},
} = useSettings(); } = useSettings();
console.log(props.noteId)
// console.log(props.editData)
console.log(props.editData!==null)
console.log(prepopulatedRichText)
const resolveEditorState = (): string | null | undefined => { const resolveEditorState = (): string | null | undefined => {
if (props.editData === null) return undefined; // use prepopulatedRichText via function below if (props.editData === null) return undefined; // use prepopulatedRichText via function below
const raw = props.editData as unknown as string; const raw = props.editData as unknown as string;
@@ -179,7 +174,7 @@ function LexicalApp(props: { noteId: bigint; editData: object | null; noteTitle:
); );
} }
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 ( return (
// <Editor /> // <Editor />
@@ -39,6 +39,32 @@ import NoteService from "../../../../services/note.service.ts";
import {Toast} from "@douyinfe/semi-ui"; import {Toast} from "@douyinfe/semi-ui";
import {SAVE_COMMAND} from "../GlobalEventsPlugin"; 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> { async function sendEditorState(editor: LexicalEditor): Promise<void> {
const stringifiedEditorState = JSON.stringify(editor.getEditorState()); const stringifiedEditorState = JSON.stringify(editor.getEditorState());
try { 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 [editor] = useLexicalComposerContext();
const [isEditable, setIsEditable] = useState(() => editor.isEditable()); const [isEditable, setIsEditable] = useState(() => editor.isEditable());
const [isSpeechToText, setIsSpeechToText] = useState(false); const [isSpeechToText, setIsSpeechToText] = useState(false);
@@ -169,29 +195,22 @@ export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string
}); });
}, [editor]); }, [editor]);
const saveData = (editor) => { const saveData = (editor: LexicalEditor) => {
const x = document.getElementsByClassName("semi-input semi-input-large"); const currentTitle = (document.querySelector('.semi-input.semi-input-large') as HTMLInputElement)?.value ?? props.noteTitle;
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 obj = editor.getEditorState().toJSON(); const obj = editor.getEditorState().toJSON();
// console.log('当前editor4: ', JSON.stringify(obj)) import('../../../../stores/useNoteStore').then(({ useNoteStore }) => {
const tags = useNoteStore.getState().currentTags;
NoteService.update(props.noteId,x[0].value, JSON.stringify(obj)).then( NoteService.update(props.noteId, currentTitle, JSON.stringify(obj), tags).then(
response => { () => {
console.log(response.data)
const data = response.data
console.log(data)
console.log('handleLawClick_item3')
props.handleLawClick_item3('1'); props.handleLawClick_item3('1');
Toast.info('保存成功' ); Toast.info('保存成功');
useNoteStore.getState().setDirty(false);
}, },
error => { () => {
console.log(error) Toast.error('保存失败');
} }
); );
}).catch(() => {});
}; };
return ( return (
@@ -235,10 +254,24 @@ export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string
source: 'Playground', source: 'Playground',
}) })
} }
title="Export" title="Export JSON"
aria-label="Export editor state to JSON"> aria-label="Export editor state to JSON">
<i className="export" /> <i className="export" />
</button> </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 <button
className="action-button clear" className="action-button clear"
disabled={isEditorEmpty} disabled={isEditorEmpty}
@@ -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>
);
}
@@ -1,12 +1,32 @@
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { Input, Skeleton, Breadcrumb } from '@douyinfe/semi-ui'; import { Input, Skeleton, Breadcrumb, Button, Tag, TagInput } from '@douyinfe/semi-ui';
import { IconHistory } from '@douyinfe/semi-icons';
import { useNoteStore } from '../../stores/useNoteStore'; import { useNoteStore } from '../../stores/useNoteStore';
import { useUIStore } from '../../stores/useUIStore'; import { useUIStore } from '../../stores/useUIStore';
import PlaygroundApp from '../lexical/LexicalApp'; 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() { export default function NoteEditor() {
const { currentNoteId, currentTitle, currentContent, setTitle } = useNoteStore(); const { currentNoteId, currentTitle, currentContent, currentTags, setTitle, setTags, treeData } = useNoteStore();
const { isNoteLoading } = useUIStore(); const { isNoteLoading } = useUIStore();
const [historyVisible, setHistoryVisible] = useState(false);
const handleTitleChange = useCallback( const handleTitleChange = useCallback(
(value: string) => { (value: string) => {
@@ -15,19 +35,37 @@ export default function NoteEditor() {
[setTitle], [setTitle],
); );
// handleRefreshTree is intentionally a no-op here: const handleTagsChange = useCallback(
// LexicalApp calls it after structural changes, but tree refresh (tags: string[]) => {
// is driven by the NoteStore and handled at the NotePage level. setTags(tags.join(','));
},
[setTags],
);
const handleRefreshTree = useCallback(() => { const handleRefreshTree = useCallback(() => {
// no-op: tree is refreshed by NotePage via store subscription // 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 ( return (
<> <>
<Breadcrumb <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '10px', marginBottom: '16px' }}>
style={{ marginBottom: '16px', paddingTop: '10px' }} <Breadcrumb routes={breadcrumbRoutes} />
routes={['CyyNote', '笔记标题', currentTitle]} {currentNoteId !== null && (
/> <Button
size="small"
theme="borderless"
icon={<IconHistory />}
onClick={() => setHistoryVisible(true)}
>
</Button>
)}
</div>
<NoteHistoryModal visible={historyVisible} onClose={() => setHistoryVisible(false)} />
<Skeleton <Skeleton
placeholder={<Skeleton.Paragraph rows={2} />} placeholder={<Skeleton.Paragraph rows={2} />}
loading={isNoteLoading} loading={isNoteLoading}
@@ -37,8 +75,19 @@ export default function NoteEditor() {
size="large" size="large"
value={currentTitle} value={currentTitle}
onChange={handleTitleChange} onChange={handleTitleChange}
style={{ marginBottom: '12px' }} 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 <div
style={{ style={{
borderRadius: '10px', borderRadius: '10px',
@@ -48,6 +97,7 @@ export default function NoteEditor() {
> >
{currentNoteId !== null && currentContent !== null ? ( {currentNoteId !== null && currentContent !== null ? (
<PlaygroundApp <PlaygroundApp
key={currentNoteId}
noteId={currentNoteId} noteId={currentNoteId}
noteTitle={currentTitle} noteTitle={currentTitle}
editData={currentContent} editData={currentContent}
@@ -1,5 +1,5 @@
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react'; import type { CSSProperties, ReactNode, KeyboardEvent } from 'react';
import { import {
Tree, Tree,
List, List,
@@ -9,11 +9,13 @@ import {
Dropdown, Dropdown,
Typography, Typography,
Toast, Toast,
Modal,
} from '@douyinfe/semi-ui'; } from '@douyinfe/semi-ui';
import { import {
IconCopyAdd, IconCopyAdd,
IconDelete, IconDelete,
IconSearch, IconSearch,
IconSetting,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { IconModal, IconToken as LabIconToken, IconTreeSelect } from '@douyinfe/semi-icons-lab'; import { IconModal, IconToken as LabIconToken, IconTreeSelect } from '@douyinfe/semi-icons-lab';
import { useNoteStore } from '../../stores/useNoteStore'; import { useNoteStore } from '../../stores/useNoteStore';
@@ -21,7 +23,7 @@ import { useUIStore } from '../../stores/useUIStore';
import noteService from '../../services/note.service'; import noteService from '../../services/note.service';
import type { INote, ITreeNode } from '../../types'; import type { INote, ITreeNode } from '../../types';
const NAV_ITEMS = ['首页', '设置', '已删除'] as const; const NAV_ITEMS = ['首页', '网盘', '设置', '已删除'] as const;
type NavItem = (typeof NAV_ITEMS)[number]; type NavItem = (typeof NAV_ITEMS)[number];
interface NoteTreeProps { interface NoteTreeProps {
@@ -31,23 +33,40 @@ interface NoteTreeProps {
} }
export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: NoteTreeProps) { export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: NoteTreeProps) {
const { treeData, viewData, currentNoteId, setCurrentNote } = useNoteStore(); const { treeData, viewData, currentNoteId, setCurrentNote, isDirty } = useNoteStore();
const { openDeleteConfirm, setSearchQuery, searchQuery, setActiveView } = useUIStore(); 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 toNoteId = (id: string | number) => Number(id);
const loadNote = useCallback( const loadNote = useCallback(
(id: number) => { (id: number) => {
const doLoad = () => {
noteService.get(id).then( noteService.get(id).then(
(res) => { (res) => {
const note = res.data; const note = res.data;
setCurrentNote(note.id, note.title, note.context); setCurrentNote(note.id, note.title, note.context, note.tags ?? '');
setActiveView('note'); setActiveView('note');
}, },
() => Toast.error('加载笔记失败'), () => Toast.error('加载笔记失败'),
); );
};
if (isDirty) {
Modal.confirm({
title: '未保存的内容',
content: '当前笔记有未保存的内容,确定要离开吗?',
okText: '确定',
cancelText: '取消',
onOk: doLoad,
});
} else {
doLoad();
}
}, },
[setCurrentNote, setActiveView], [setCurrentNote, setActiveView, isDirty],
); );
const addNote = useCallback( const addNote = useCallback(
@@ -63,6 +82,19 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
[onRefreshTree], [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 = { const treeStyle: CSSProperties = {
height: 550, height: 550,
border: '1px solid var(--semi-color-border)', border: '1px solid var(--semi-color-border)',
@@ -70,11 +102,46 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
top: '85px', top: '85px',
}; };
const renderLabel = (label: ReactNode, item: ITreeNode) => ( 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' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text <Typography.Text
ellipsis={{ showTooltip: true }} ellipsis={{ showTooltip: true }}
style={{ width: 'calc(100% - 68px)' }} style={{ width: 'calc(100% - 68px)', cursor: 'text' }}
onDoubleClick={(e) => {
e.stopPropagation();
setEditingKey(key);
setEditingValue(String(label));
}}
> >
{label} {label}
</Typography.Text> </Typography.Text>
@@ -83,7 +150,7 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
size="small" size="small"
icon={<IconCopyAdd />} icon={<IconCopyAdd />}
onClick={(e) => { onClick={(e) => {
addNote(toNoteId(item.key)); addNote(id);
e.stopPropagation(); e.stopPropagation();
}} }}
/> />
@@ -92,13 +159,14 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
type="warning" type="warning"
icon={<IconDelete />} icon={<IconDelete />}
onClick={(e) => { onClick={(e) => {
openDeleteConfirm(toNoteId(item.key), String(label)); openDeleteConfirm(id, String(label));
e.stopPropagation(); e.stopPropagation();
}} }}
/> />
</ButtonGroup> </ButtonGroup>
</div> </div>
); );
};
return ( return (
<> <>
@@ -140,8 +208,9 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
onClick={() => onNavItemClick(item)} onClick={() => onNavItemClick(item)}
> >
{item === '首页' && <Button type="danger" theme="borderless" icon={<LabIconToken />} style={{ marginRight: 4 }} />} {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={<IconTreeSelect />} style={{ marginRight: 4 }} />}
{item === '已删除' && <Button type="danger" theme="borderless" icon={<IconModal />} style={{ marginRight: 4 }} />} {item === '已删除' && <Button type="danger" theme="borderless" icon={<IconSetting />} style={{ marginRight: 4 }} />}
{item} {item}
</div> </div>
)} )}
@@ -189,12 +258,21 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
searchPlaceholder=" " searchPlaceholder=" "
showFilteredOnly showFilteredOnly
directory directory
draggable
virtualize={{ itemSize: 28 }} virtualize={{ itemSize: 28 }}
value={currentNoteId === null ? undefined : String(currentNoteId)} value={currentNoteId === null ? undefined : String(currentNoteId)}
treeData={treeData as unknown as Parameters<typeof Tree>[0]['treeData']} treeData={treeData as unknown as any}
renderLabel={renderLabel as Parameters<typeof Tree>[0]['renderLabel']} renderLabel={renderLabel as any}
style={treeStyle} style={treeStyle}
onSelect={(id) => loadNote(toNoteId(id as string | number))} onSelect={(id) => loadNote(toNoteId(id as string | number))}
onDrop={({ node, dragNode }: { node: any; dragNode: any }) => {
const id = toNoteId(dragNode.key);
const newPid = toNoteId(node.key);
noteService.move(id, newPid).then(
() => { Toast.success('移动成功'); onRefreshTree(); },
() => Toast.error('移动失败'),
);
}}
/> />
</> </>
); );
@@ -0,0 +1,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>
);
}
+38 -42
View File
@@ -9,7 +9,6 @@ import {
Avatar, Avatar,
Toast, Toast,
SideSheet, SideSheet,
Row,
List, List,
ButtonGroup, ButtonGroup,
Typography, Typography,
@@ -20,6 +19,8 @@ import {
IconArrowUp, IconArrowUp,
IconMenu, IconMenu,
IconCode, IconCode,
IconMoon,
IconSun,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { IconDescriptions } from '@douyinfe/semi-icons-lab'; import { IconDescriptions } from '@douyinfe/semi-icons-lab';
@@ -32,6 +33,8 @@ import authService from '../services/auth.service';
import NoteTree from '../components/sidebar/NoteTree'; import NoteTree from '../components/sidebar/NoteTree';
import NoteEditor from '../components/note/NoteEditor'; import NoteEditor from '../components/note/NoteEditor';
import HomePage from './home/HomePage'; import HomePage from './home/HomePage';
import SettingsPage from './settings/SettingsPage';
import DrivePage from './drive/DrivePage';
import DeleteModal from '../components/modals/DeleteModal'; import DeleteModal from '../components/modals/DeleteModal';
import PasswordModal from '../components/modals/PasswordModal'; import PasswordModal from '../components/modals/PasswordModal';
import TrashListModal from '../components/modals/TrashListModal'; import TrashListModal from '../components/modals/TrashListModal';
@@ -60,15 +63,25 @@ export default function NotePage() {
activeView, activeView,
siderFlag, siderFlag,
sideVisible, sideVisible,
isDarkMode,
setActiveView, setActiveView,
setSiderFlag, setSiderFlag,
setSideVisible, setSideVisible,
setDeleteModalVisible, setDeleteModalVisible,
setUpdatePasswordModalVisible, toggleDarkMode,
} = useUIStore(); } = useUIStore();
const [trashList, setTrashList] = useState<INote[]>([]); const [trashList, setTrashList] = useState<INote[]>([]);
// Restore dark mode on mount
useEffect(() => {
if (isDarkMode) {
document.body.setAttribute('theme-mode', 'dark');
} else {
document.body.removeAttribute('theme-mode');
}
}, [isDarkMode]);
const loadTree = useCallback(() => { const loadTree = useCallback(() => {
noteService.getAllContent().then( noteService.getAllContent().then(
(res) => setTreeData(res.data), (res) => setTreeData(res.data),
@@ -96,6 +109,8 @@ export default function NotePage() {
(item: string) => { (item: string) => {
if (item === '首页') { if (item === '首页') {
setActiveView('home'); setActiveView('home');
} else if (item === '网盘') {
setActiveView('drive');
} else if (item === '设置') { } else if (item === '设置') {
setActiveView('settings'); setActiveView('settings');
} else if (item === '已删除') { } else if (item === '已删除') {
@@ -163,6 +178,13 @@ export default function NotePage() {
</SideSheet> </SideSheet>
</> </>
)} )}
<Button
theme="borderless"
icon={isDarkMode ? <IconSun size="large" /> : <IconMoon size="large" />}
style={{ color: 'var(--semi-color-text-2)', marginRight: 12 }}
onClick={toggleDarkMode}
title={isDarkMode ? '切换浅色模式' : '切换深色模式'}
/>
<Button <Button
theme="borderless" theme="borderless"
icon={<IconBell size="large" />} icon={<IconBell size="large" />}
@@ -237,48 +259,22 @@ export default function NotePage() {
padding: 15, padding: 15,
}} }}
> >
Settings <SettingsPage />
<hr /> </div>
<Row> </Content>
<Button )}
theme="solid"
type="secondary" {activeView === 'drive' && (
style={{ marginRight: 8 }} <Content style={{ padding: '10px', backgroundColor: 'var(--semi-color-bg-0)', overflowY: 'auto' }}>
onClick={() => setUpdatePasswordModalVisible(true)} <Breadcrumb style={{ marginBottom: 24 }} routes={['网盘']} />
> <div
style={{
</Button> borderRadius: 10,
</Row> border: '1px solid var(--semi-color-border)',
<br /> padding: 15,
<Row>
<Button
theme="solid"
type="tertiary"
style={{ marginRight: 8 }}
onClick={() => {
void noteService
.historyQueryOrDelete(0)
.then((res) => Toast.info(`历史记录数:${String(res.data)}`));
}} }}
> >
Count <DrivePage />
</Button>
</Row>
<br />
<Row>
<Button
theme="solid"
type="danger"
style={{ marginRight: 8 }}
onClick={() => {
void noteService
.historyQueryOrDelete(1)
.then(() => Toast.success('历史记录已清空'));
}}
>
</Button>
</Row>
</div> </div>
</Content> </Content>
)} )}
@@ -0,0 +1,210 @@
import { useEffect, useState } from 'react';
import {
Tabs,
TabPane,
Card,
Input,
Button,
Toast,
Typography,
} from '@douyinfe/semi-ui';
import { useAuthStore } from '../../stores/useAuthStore';
import { useUIStore } from '../../stores/useUIStore';
import noteService from '../../services/note.service';
import settingsService from '../../services/settings.service';
const { Title, Text } = Typography;
export default function SettingsPage() {
const user = useAuthStore((s) => s.user);
const { isDarkMode, toggleDarkMode, setUpdatePasswordModalVisible } = useUIStore();
// 历史记录
const [historyCount, setHistoryCount] = useState<number | null>(null);
// R2 配置 — keys match backend snake_case
const [r2Config, setR2Config] = useState({
r2_endpoint: '',
r2_access_key_id: '',
r2_secret_access_key: '',
r2_bucket_name: '',
r2_public_url: '',
});
const [localStoragePath, setLocalStoragePath] = useState('');
useEffect(() => {
// Load history count
noteService
.historyQueryOrDelete(0)
.then((res) => setHistoryCount(res.data))
.catch(() => {});
// Load settings
settingsService
.getAll()
.then((res) => {
if (res.data) {
setR2Config({
r2_endpoint: res.data.r2_endpoint ?? '',
r2_access_key_id: res.data.r2_access_key_id ?? '',
r2_secret_access_key: res.data.r2_secret_access_key ?? '',
r2_bucket_name: res.data.r2_bucket_name ?? '',
r2_public_url: res.data.r2_public_url ?? '',
});
setLocalStoragePath(res.data.local_storage_path ?? '');
}
})
.catch(() => {});
}, []);
const handleClearHistory = () => {
noteService
.historyQueryOrDelete(1)
.then(() => {
Toast.success('历史记录已清空');
setHistoryCount(0);
})
.catch(() => Toast.error('清空失败'));
};
const handleSaveR2 = () => {
const payload: Record<string, string> = {};
for (const [k, v] of Object.entries(r2Config)) {
if (v) payload[k] = v;
}
settingsService
.set(payload)
.then(() => Toast.success('保存成功'))
.catch(() => Toast.error('保存失败'));
};
return (
<>
<Tabs type="line">
{/* 账户 */}
<TabPane tab="账户" itemKey="account">
<Card style={{ marginTop: 16 }}>
<Title heading={5} style={{ marginBottom: 16 }}>
</Title>
<Text>{user?.username ?? '—'}</Text>
<br />
<Text>{user?.email ?? '—'}</Text>
<br />
<br />
<Button
theme="solid"
type="secondary"
onClick={() => setUpdatePasswordModalVisible(true)}
>
</Button>
</Card>
</TabPane>
{/* 外观 */}
<TabPane tab="外观" itemKey="appearance">
<Card style={{ marginTop: 16 }}>
<Title heading={5} style={{ marginBottom: 16 }}>
</Title>
<Text>{isDarkMode ? '深色' : '浅色'}</Text>
<br />
<br />
<Button
theme="solid"
type={isDarkMode ? 'warning' : 'primary'}
onClick={toggleDarkMode}
>
{isDarkMode ? '浅色' : '深色'}
</Button>
</Card>
</TabPane>
{/* 历史记录 */}
<TabPane tab="历史记录" itemKey="history">
<Card style={{ marginTop: 16 }}>
<Title heading={5} style={{ marginBottom: 16 }}>
</Title>
<Text>{historyCount ?? '加载中…'}</Text>
<br />
<br />
<Button theme="solid" type="danger" onClick={handleClearHistory}>
</Button>
</Card>
</TabPane>
<TabPane tab="网盘配置 (R2)" itemKey="r2">
<Card style={{ marginTop: 16 }}>
<Title heading={5} style={{ marginBottom: 16 }}>
Cloudflare R2
</Title>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 500 }}>
{([
{ key: 'r2_endpoint' as const, label: 'R2 Endpoint', placeholder: 'https://xxxx.r2.cloudflarestorage.com' },
{ key: 'r2_access_key_id' as const, label: 'Access Key ID', placeholder: 'Access Key ID' },
{ key: 'r2_bucket_name' as const, label: 'Bucket Name', placeholder: 'my-bucket' },
{ key: 'r2_public_url' as const, label: 'Public URL(可选)', placeholder: 'https://pub-xxxx.r2.dev' },
]).map(({ key, label, placeholder }) => (
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{ width: 160, flexShrink: 0 }}>{label}</Text>
<Input
placeholder={placeholder}
value={r2Config[key]}
onChange={(v) => setR2Config((c) => ({ ...c, [key]: v }))}
style={{ flex: 1 }}
/>
</div>
))}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{ width: 160, flexShrink: 0 }}>Secret Access Key</Text>
<Input
type="password"
placeholder="Secret Access Key"
value={r2Config.r2_secret_access_key}
onChange={(v) => setR2Config((c) => ({ ...c, r2_secret_access_key: v }))}
style={{ flex: 1 }}
/>
</div>
<Button theme="solid" type="primary" onClick={handleSaveR2} style={{ alignSelf: 'flex-start' }}>
</Button>
</div>
</Card>
</TabPane>
{/* 本地存储配置 */}
<TabPane tab="本地存储配置" itemKey="local-storage">
<Card style={{ marginTop: 16 }}>
<Title heading={5} style={{ marginBottom: 8 }}></Title>
<Text type="tertiary" style={{ display: 'block', marginBottom: 16 }}>
使 <code>./local_drive</code>
</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, maxWidth: 500 }}>
<Input
placeholder="./local_drive"
value={localStoragePath}
onChange={setLocalStoragePath}
style={{ flex: 1 }}
/>
<Button
theme="solid"
type="primary"
onClick={() => {
settingsService
.set({ local_storage_path: localStoragePath })
.then(() => Toast.success('保存成功'))
.catch(() => Toast.error('保存失败'));
}}
>
</Button>
</div>
</Card>
</TabPane>
</Tabs>
</>
);
}
@@ -0,0 +1,47 @@
import axiosInstance from '../lib/axios';
import type { ApiResponse, IDriveFile } from '../types';
const driveService = {
list(): Promise<ApiResponse<IDriveFile[]>> {
return axiosInstance.get('drive/list').then((r) => r.data);
},
register(data: {
filename: string;
original_name: string;
size: number;
mime_type: string;
r2_key: string;
}): Promise<ApiResponse<void>> {
return axiosInstance.post('drive/register', data).then((r) => r.data);
},
uploadLocal(file: File, onProgress?: (percent: number) => void): Promise<ApiResponse<void>> {
const form = new FormData();
form.append('file', file);
return axiosInstance.post('drive/upload-local', form, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
if (onProgress && e.total) onProgress(Math.round((e.loaded * 100) / e.total));
},
}).then((r) => r.data);
},
getDownloadUrl(id: number): string {
return `/api/drive/download/${id}`;
},
share(id: number, days?: number): Promise<ApiResponse<{ share_token: string; share_expiry: string | null }>> {
return axiosInstance.post('drive/share', { id, days }).then((r) => r.data);
},
unshare(id: number): Promise<ApiResponse<void>> {
return axiosInstance.post('drive/unshare', { id }).then((r) => r.data);
},
delete(id: number): Promise<ApiResponse<void>> {
return axiosInstance.post('drive/delete', { id }).then((r) => r.data);
},
};
export default driveService;
+30 -3
View File
@@ -1,5 +1,5 @@
import axiosInstance from '../lib/axios'; import axiosInstance from '../lib/axios';
import type { ApiResponse, INote, ITreeNode } from '../types'; import type { ApiResponse, IHistory, INote, ITreeNode } from '../types';
const API_URL = 'note/'; const API_URL = 'note/';
@@ -14,9 +14,9 @@ const noteService = {
.then((r) => r.data); .then((r) => r.data);
}, },
update(id: number, title: string, context: string): Promise<ApiResponse<void>> { update(id: number, title: string, context: string, tags?: string): Promise<ApiResponse<void>> {
return axiosInstance return axiosInstance
.post(API_URL + 'update', { id, title, context }) .post(API_URL + 'update', { id, title, context, tags: tags ?? '' })
.then((r) => r.data); .then((r) => r.data);
}, },
@@ -52,6 +52,33 @@ const noteService = {
.then((r) => r.data); .then((r) => r.data);
}, },
/**
* Move a note to a new parent.
*/
move(id: number, pid: number): Promise<ApiResponse<void>> {
return axiosInstance
.post(API_URL + 'move', { id, pid })
.then((r) => r.data);
},
/**
* Get revision history list for a note (metadata only, no content).
*/
getHistory(nid: number): Promise<ApiResponse<IHistory[]>> {
return axiosInstance
.get(API_URL + 'history', { params: { nid } })
.then((r) => r.data);
},
/**
* Get full content of a specific history entry.
*/
getHistoryContent(id: number): Promise<ApiResponse<IHistory>> {
return axiosInstance
.get(API_URL + 'history/content', { params: { id } })
.then((r) => r.data);
},
/** /**
* Fetch home list by type and optional search query. * Fetch home list by type and optional search query.
* type: '0'=search, '1'=recently updated, '2'=newly added, * type: '0'=search, '1'=recently updated, '2'=newly added,
@@ -0,0 +1,13 @@
import axiosInstance from '../lib/axios';
import type { ApiResponse } from '../types';
const settingsService = {
getAll(): Promise<ApiResponse<Record<string, string | null>>> {
return axiosInstance.get('settings/all').then((r) => r.data);
},
set(data: Record<string, string>): Promise<ApiResponse<void>> {
return axiosInstance.post('settings/set', data).then((r) => r.data);
},
};
export default settingsService;
+13 -5
View File
@@ -5,30 +5,38 @@ interface NoteState {
currentNoteId: number | null; currentNoteId: number | null;
currentTitle: string; currentTitle: string;
currentContent: string | null; currentContent: string | null;
currentTags: string;
treeData: ITreeNode[]; treeData: ITreeNode[];
homeData: INote[]; homeData: INote[];
viewData: INote[]; viewData: INote[];
setCurrentNote: (id: number, title: string, content: string) => void; isDirty: boolean;
setCurrentNote: (id: number, title: string, content: string, tags?: string) => void;
setTreeData: (data: ITreeNode[]) => void; setTreeData: (data: ITreeNode[]) => void;
setTitle: (title: string) => void; setTitle: (title: string) => void;
setTags: (tags: string) => void;
setHomeData: (data: INote[]) => void; setHomeData: (data: INote[]) => void;
setViewData: (data: INote[]) => void; setViewData: (data: INote[]) => void;
clearCurrentNote: () => void; clearCurrentNote: () => void;
setDirty: (v: boolean) => void;
} }
export const useNoteStore = create<NoteState>()((set) => ({ export const useNoteStore = create<NoteState>()((set) => ({
currentNoteId: null, currentNoteId: null,
currentTitle: '', currentTitle: '',
currentContent: null, currentContent: null,
currentTags: '',
treeData: [], treeData: [],
homeData: [], homeData: [],
viewData: [], viewData: [],
setCurrentNote: (id, title, content) => isDirty: false,
set({ currentNoteId: id, currentTitle: title, currentContent: content }), setCurrentNote: (id, title, content, tags = '') =>
set({ currentNoteId: id, currentTitle: title, currentContent: content, currentTags: tags, isDirty: false }),
setTreeData: (treeData) => set({ treeData }), setTreeData: (treeData) => set({ treeData }),
setTitle: (title) => set({ currentTitle: title }), setTitle: (title) => set({ currentTitle: title, isDirty: true }),
setTags: (tags) => set({ currentTags: tags, isDirty: true }),
setHomeData: (homeData) => set({ homeData }), setHomeData: (homeData) => set({ homeData }),
setViewData: (viewData) => set({ viewData }), setViewData: (viewData) => set({ viewData }),
clearCurrentNote: () => clearCurrentNote: () =>
set({ currentNoteId: null, currentTitle: '', currentContent: null }), set({ currentNoteId: null, currentTitle: '', currentContent: null, currentTags: '', isDirty: false }),
setDirty: (isDirty) => set({ isDirty }),
})); }));
+15 -1
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
export type ActiveView = 'home' | 'note' | 'settings' | 'search'; export type ActiveView = 'home' | 'note' | 'settings' | 'search' | 'drive';
interface UIState { interface UIState {
activeView: ActiveView; activeView: ActiveView;
@@ -13,6 +13,7 @@ interface UIState {
deleteModalVisible: boolean; deleteModalVisible: boolean;
updatePasswordModalVisible: boolean; updatePasswordModalVisible: boolean;
searchQuery: string; searchQuery: string;
isDarkMode: boolean;
setActiveView: (view: ActiveView) => void; setActiveView: (view: ActiveView) => void;
setSideVisible: (visible: boolean) => void; setSideVisible: (visible: boolean) => void;
setSiderFlag: (flag: boolean) => void; setSiderFlag: (flag: boolean) => void;
@@ -22,6 +23,7 @@ interface UIState {
setDeleteModalVisible: (visible: boolean) => void; setDeleteModalVisible: (visible: boolean) => void;
setUpdatePasswordModalVisible: (visible: boolean) => void; setUpdatePasswordModalVisible: (visible: boolean) => void;
setSearchQuery: (query: string) => void; setSearchQuery: (query: string) => void;
toggleDarkMode: () => void;
} }
export const useUIStore = create<UIState>()((set) => ({ export const useUIStore = create<UIState>()((set) => ({
@@ -35,6 +37,7 @@ export const useUIStore = create<UIState>()((set) => ({
deleteModalVisible: false, deleteModalVisible: false,
updatePasswordModalVisible: false, updatePasswordModalVisible: false,
searchQuery: '', searchQuery: '',
isDarkMode: localStorage.getItem('theme') === 'dark',
setActiveView: (activeView) => set({ activeView }), setActiveView: (activeView) => set({ activeView }),
setSideVisible: (sideVisible) => set({ sideVisible }), setSideVisible: (sideVisible) => set({ sideVisible }),
setSiderFlag: (siderFlag) => set({ siderFlag }), setSiderFlag: (siderFlag) => set({ siderFlag }),
@@ -47,4 +50,15 @@ export const useUIStore = create<UIState>()((set) => ({
setUpdatePasswordModalVisible: (updatePasswordModalVisible) => setUpdatePasswordModalVisible: (updatePasswordModalVisible) =>
set({ updatePasswordModalVisible }), set({ updatePasswordModalVisible }),
setSearchQuery: (searchQuery) => set({ searchQuery }), setSearchQuery: (searchQuery) => set({ searchQuery }),
toggleDarkMode: () =>
set((state) => {
const next = !state.isDarkMode;
if (next) {
document.body.setAttribute('theme-mode', 'dark');
} else {
document.body.removeAttribute('theme-mode');
}
localStorage.setItem('theme', next ? 'dark' : 'light');
return { isDarkMode: next };
}),
})); }));
+27 -1
View File
@@ -21,17 +21,43 @@ export interface INote {
createtime: string; createtime: string;
updatetime: string; updatetime: string;
viewtime: string; viewtime: string;
flag: number;
tags?: string;
} }
export interface ITreeNode { export interface ITreeNode {
id?: number; id?: number;
pid?: number; pid?: number;
key: string; key: string | number;
value?: string; value?: string;
label: string; label: string;
children?: ITreeNode[]; children?: ITreeNode[];
} }
export interface IDriveFile {
id: number;
filename: string;
originalName: string;
size: number | null;
mimeType: string | null;
shareToken: string | null;
shareExpiry: string | null;
userid: number;
createtime: string;
flag: number;
storageMode: 'r2' | 'local' | null;
localPath: string | null;
}
export interface IHistory {
id: number;
nid: number;
title: string;
context: string;
createtime: string;
flag: number;
}
export interface ApiResponse<T> { export interface ApiResponse<T> {
code: number; code: number;
message: string; message: string;