Add storage
This commit is contained in:
Binary file not shown.
@@ -84,6 +84,13 @@
|
||||
<artifactId>solon-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- AWS S3 SDK v2 (Cloudflare R2 is S3-compatible) -->
|
||||
<dependency>
|
||||
<groupId>software.amazon.awssdk</groupId>
|
||||
<artifactId>s3</artifactId>
|
||||
<version>2.25.16</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.cyynote.controller;
|
||||
|
||||
import cn.hutool.core.date.DateUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.cyynote.model.UserModel;
|
||||
import com.cyynote.payload.request.LoginRequest;
|
||||
@@ -42,9 +43,8 @@ public class AuthController extends BaseController {
|
||||
}
|
||||
|
||||
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()))
|
||||
.andEq("password", loginRequest.getPassword()))
|
||||
.selectItem("*", UserModel.class);
|
||||
|
||||
if (model == null) {
|
||||
@@ -52,6 +52,29 @@ public class AuthController extends BaseController {
|
||||
return Result.failure(401, "用户名或密码错误");
|
||||
}
|
||||
|
||||
boolean passwordMatch;
|
||||
if (model.getPasswordHashed() == null || model.getPasswordHashed() == 0) {
|
||||
// Plain-text comparison; migrate to hash on success
|
||||
passwordMatch = loginRequest.getPassword().equals(model.getPassword());
|
||||
if (passwordMatch) {
|
||||
String hashed = DigestUtil.sha256Hex(loginRequest.getPassword());
|
||||
((DbTableQuery) this.db.table("user")
|
||||
.set("password", hashed)
|
||||
.set("password_hashed", 1)
|
||||
.whereEq("id", model.getId()))
|
||||
.update();
|
||||
model.setPassword(hashed);
|
||||
model.setPasswordHashed(1);
|
||||
}
|
||||
} else {
|
||||
passwordMatch = DigestUtil.sha256Hex(loginRequest.getPassword()).equals(model.getPassword());
|
||||
}
|
||||
|
||||
if (!passwordMatch) {
|
||||
ctx.status(401);
|
||||
return Result.failure(401, "用户名或密码错误");
|
||||
}
|
||||
|
||||
ctx.sessionSet("user_name", model.getUsername());
|
||||
ctx.sessionSet("user_id", model.getId());
|
||||
String token = ctx.sessionState().sessionToken();
|
||||
@@ -84,17 +107,30 @@ public class AuthController extends BaseController {
|
||||
}
|
||||
|
||||
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()))
|
||||
.andEq("password", loginRequest.getOldpassword()))
|
||||
.selectItem("*", UserModel.class);
|
||||
if (model == null) {
|
||||
ctx.status(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")
|
||||
.set("password", loginRequest.getNewpassword())
|
||||
.set("password", newHashed)
|
||||
.set("password_hashed", 1)
|
||||
.whereEq("id", model.getId()))
|
||||
.update();
|
||||
return Result.succeed(flag);
|
||||
@@ -120,7 +156,8 @@ public class AuthController extends BaseController {
|
||||
UserModel user = new UserModel();
|
||||
user.setUsername(signUpRequest.getUsername());
|
||||
user.setEmail(signUpRequest.getEmail());
|
||||
user.setPassword(signUpRequest.getPassword());
|
||||
user.setPassword(DigestUtil.sha256Hex(signUpRequest.getPassword()));
|
||||
user.setPasswordHashed(1);
|
||||
user.setRole("ROLE_USER");
|
||||
this.db.table("user").setEntityIf(user, (k, v) -> Boolean.valueOf(v != null)).insert();
|
||||
return Result.succeed("注册成功");
|
||||
@@ -145,4 +182,3 @@ public class AuthController extends BaseController {
|
||||
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
|
||||
DbContext db;
|
||||
|
||||
private static final String QUERY_F = "id,pid,title,createtime,updatetime,viewtime,flag";
|
||||
private static final String QUERY_F = "id,pid,title,createtime,updatetime,viewtime,flag,tags";
|
||||
|
||||
@Get
|
||||
@Mapping("get1")
|
||||
@@ -64,7 +64,10 @@ public class NoteController extends BaseController {
|
||||
@Get
|
||||
@Mapping("/all")
|
||||
public Result noteAccess(Context ctx) throws Exception {
|
||||
List<NoteModel> all = ((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
String userId = getUserInfoId(ctx);
|
||||
DbTableQuery query = this.db.table("note").whereEq("flag", 1);
|
||||
if (userId != null) query = query.andEq("userid", Integer.valueOf(userId));
|
||||
List<NoteModel> all = query.selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
List<TreeNode> treeNodeList = new ArrayList<>();
|
||||
for (NoteModel n : all) {
|
||||
TreeNode treeNode = new TreeNode(n.getId().intValue(), n.getPid().intValue(), n.getTitle(), String.valueOf(n.getId()), String.valueOf(n.getId()));
|
||||
@@ -78,17 +81,29 @@ public class NoteController extends BaseController {
|
||||
@Get
|
||||
@Mapping("/home")
|
||||
public Result noteHome(Context ctx, @Path int type, @Path String search) throws Exception {
|
||||
String userId = getUserInfoId(ctx);
|
||||
List<NoteModel> all = new ArrayList<>();
|
||||
if (type == 0) {
|
||||
all = ((DbTableQuery)((DbTableQuery)this.db.table("note").whereLk("context", "%" + search + "%")).andLk("title", "%" + search + "%")).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
DbTableQuery q = this.db.table("note")
|
||||
.where("flag = 1 AND (title LIKE ? OR context LIKE ?)", "%" + search + "%", "%" + search + "%");
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
all = q.selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
} else if (type == 1) {
|
||||
all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).orderByDesc("updatetime")).limit(20)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
DbTableQuery q = this.db.table("note").whereEq("flag", 1);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
all = q.orderByDesc("updatetime").limit(20).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
} else if (type == 2) {
|
||||
all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).orderByDesc("createtime")).limit(20)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
DbTableQuery q = this.db.table("note").whereEq("flag", 1);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
all = q.orderByDesc("createtime").limit(20).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
} else if (type == 3) {
|
||||
all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(1))).orderByDesc("viewtime")).limit(20)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
DbTableQuery q = this.db.table("note").whereEq("flag", 1);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
all = q.orderByDesc("viewtime").limit(20).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
} else if (type == 4) {
|
||||
all = ((DbTableQuery)((DbTableQuery)((DbTableQuery)this.db.table("note").whereEq("flag", Integer.valueOf(0))).orderByDesc("updatetime")).limit(10000)).selectList("id,pid,title,createtime,updatetime,viewtime,flag", NoteModel.class);
|
||||
DbTableQuery q = this.db.table("note").whereEq("flag", 0);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
all = q.orderByDesc("updatetime").limit(10000).selectList("id,pid,title,createtime,updatetime,viewtime,flag,tags", NoteModel.class);
|
||||
}
|
||||
return Result.succeed(JSONArray.parseArray(JSONArray.toJSONString(all)));
|
||||
}
|
||||
@@ -96,15 +111,17 @@ public class NoteController extends BaseController {
|
||||
@Get
|
||||
@Mapping("/get")
|
||||
public Result getNote(Context ctx, @Path int id) throws Exception {
|
||||
NoteModel model = (NoteModel)((DbTableQuery)this.db.table("note").whereEq("id", Integer.valueOf(id))).selectItem("*", NoteModel.class);
|
||||
String userId = getUserInfoId(ctx);
|
||||
DbTableQuery q = this.db.table("note").whereEq("id", id);
|
||||
if (userId != null) q = q.andEq("userid", Integer.valueOf(userId));
|
||||
NoteModel model = q.selectItem("*", NoteModel.class);
|
||||
if (model == null) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "笔记不存在");
|
||||
}
|
||||
String time = DateUtil.now();
|
||||
((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);
|
||||
log.info(JSONObject.toJSONString(model, new com.alibaba.fastjson2.JSONWriter.Feature[0]));
|
||||
return Result.succeed(model);
|
||||
}
|
||||
|
||||
@@ -121,9 +138,8 @@ public class NoteController extends BaseController {
|
||||
note.setCreatetime(DateUtil.now());
|
||||
note.setUpdatetime(note.getCreatetime());
|
||||
note.setViewtime(note.getCreatetime());
|
||||
note.setUserid(Integer.valueOf(userid));
|
||||
note.setContext("{\"root\":{\"children\":[{\"children\":[{\"detail\":0,\"format\":0,\"mode\":\"normal\",\"style\":\"\",\"text\":\"未命名Note\",\"type\":\"text\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"paragraph\",\"version\":1}],\"direction\":\"ltr\",\"format\":\"\",\"indent\":0,\"type\":\"root\",\"version\":1}}");
|
||||
|
||||
// note.setContext("{\"root\": {\"type\": \"root\", \"format\": \"\", \"indent\": 0, \"version\": 1, \"children\": [{\"type\": \"paragraph\", \"format\": \"\", \"indent\": 0, \"version\": 1, \"children\": [], \"direction\": null}], \"direction\": null}}");
|
||||
this.db.table("note").setEntityIf(note, (k, v) -> Boolean.valueOf((v != null))).insert();
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
@@ -135,26 +151,32 @@ public class NoteController extends BaseController {
|
||||
ctx.status(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) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "笔记不存在");
|
||||
}
|
||||
note.setTitle(noteRequest.getTitle());
|
||||
note.setUpdatetime(DateUtil.now());
|
||||
String cc = noteRequest.getContext();
|
||||
JSONObject ar = JSONObject.parseObject(cc);
|
||||
note.setContext(ar.toJSONString());
|
||||
int flag = ((DbTableQuery)this.db.table("note").set("title", noteRequest.getTitle()).set("updatetime", DateUtil.now()).set("context", ar.toJSONString()).whereEq("id", noteRequest.getId())).update();
|
||||
log.info("updateNote+ flag");
|
||||
DbTableQuery uq = this.db.table("note")
|
||||
.set("title", noteRequest.getTitle())
|
||||
.set("updatetime", DateUtil.now())
|
||||
.set("context", ar.toJSONString())
|
||||
.set("tags", noteRequest.getTags() != null ? noteRequest.getTags() : "")
|
||||
.whereEq("id", noteRequest.getId());
|
||||
if (userId != null) uq = uq.andEq("userid", Integer.valueOf(userId));
|
||||
uq.update();
|
||||
log.info("updateNote id=" + noteRequest.getId());
|
||||
HistoryModel history = new HistoryModel();
|
||||
history.setNid(Integer.valueOf(noteRequest.getId().intValue()));
|
||||
history.setTitle(noteRequest.getTitle());
|
||||
history.setFlag(Integer.valueOf(1));
|
||||
history.setCreatetime(DateUtil.now());
|
||||
history.setContext(ar.toJSONString());
|
||||
long his = this.db.table("history").setEntityIf(history, (k, v) -> Boolean.valueOf((v != null))).insert();
|
||||
log.info("history+ his");
|
||||
this.db.table("history").setEntityIf(history, (k, v) -> Boolean.valueOf((v != null))).insert();
|
||||
return Result.succeed("ok");
|
||||
}
|
||||
|
||||
@@ -165,7 +187,10 @@ public class NoteController extends BaseController {
|
||||
ctx.status(400);
|
||||
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) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "笔记不存在");
|
||||
@@ -176,7 +201,28 @@ public class NoteController extends BaseController {
|
||||
@Get
|
||||
@Mapping("/deleteBack")
|
||||
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) {
|
||||
ctx.status(404);
|
||||
return Result.failure(404, "笔记不存在");
|
||||
@@ -187,21 +233,52 @@ public class NoteController extends BaseController {
|
||||
@Get
|
||||
@Mapping("/historyQueryOrDelete")
|
||||
public Result historyQueryOrDelete(@Path int flag) throws Exception {
|
||||
|
||||
log.info("history+ flag" + flag);
|
||||
|
||||
if(flag==0){
|
||||
if (flag == 0) {
|
||||
long count = db.table("history").selectCount();
|
||||
log.info("count:" + count);
|
||||
return Result.succeed(count);
|
||||
}else if(flag==1){
|
||||
int flagdb = db.table("history").whereEq("flag",1).delete();
|
||||
} else if (flag == 1) {
|
||||
int flagdb = db.table("history").whereEq("flag", 1).delete();
|
||||
log.info("flagdb:" + flagdb);
|
||||
return Result.succeed(0);
|
||||
}
|
||||
return 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) {
|
||||
String userId = "";
|
||||
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;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.logging.Logger;
|
||||
import javax.sql.DataSource;
|
||||
|
||||
public class DsHelper {
|
||||
private static final Logger log = Logger.getLogger(DsHelper.class.getName());
|
||||
private static boolean inited = false;
|
||||
|
||||
public static void initData(DataSource ds) {
|
||||
if (inited)
|
||||
return;
|
||||
if (inited) return;
|
||||
inited = true;
|
||||
Connection conn = null;
|
||||
try {
|
||||
conn = ds.getConnection();
|
||||
PreparedStatement ps = conn.prepareStatement("SELECT name FROM sqlite_master WHERE type='table' AND name='appx';");
|
||||
ResultSet res = ps.executeQuery();
|
||||
if (!res.next()) {
|
||||
String[] sqls = getSqlFromFile();
|
||||
for (String sql : sqls)
|
||||
runSql(conn, sql);
|
||||
try (Connection conn = ds.getConnection()) {
|
||||
// 1. Initial schema: create all base tables if not present
|
||||
if (!tableExists(conn, "appx")) {
|
||||
log.info("[DB] Running initial schema...");
|
||||
for (String sql : loadSqlFile("/db/schema.sql")) {
|
||||
runSqlIgnoreError(conn, sql, null);
|
||||
}
|
||||
ps.close();
|
||||
} catch (SQLException sqlException) {
|
||||
throw new RuntimeException(sqlException);
|
||||
} finally {
|
||||
try {
|
||||
if (conn != null)
|
||||
conn.close();
|
||||
} catch (SQLException sQLException) {}
|
||||
}
|
||||
// 2. Versioned migrations
|
||||
runMigrations(conn);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("DB init failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String[] getSqlFromFile() {
|
||||
private static void runMigrations(Connection conn) throws SQLException {
|
||||
runSql(conn, "CREATE TABLE IF NOT EXISTS db_version (" +
|
||||
"version INTEGER PRIMARY KEY, applied_at TEXT)");
|
||||
|
||||
List<String> migrationFiles = listMigrationFiles();
|
||||
Collections.sort(migrationFiles);
|
||||
|
||||
for (String fileName : migrationFiles) {
|
||||
int version = parseMigrationVersion(fileName);
|
||||
if (version < 0) continue;
|
||||
if (isMigrationApplied(conn, version)) continue;
|
||||
|
||||
log.info("[DB] Running migration: " + fileName);
|
||||
String[] statements = loadSqlFile("/db/migrations/" + fileName);
|
||||
for (String sql : statements) {
|
||||
runSqlIgnoreError(conn, sql, "duplicate column");
|
||||
}
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"INSERT OR IGNORE INTO db_version(version, applied_at) VALUES(?, datetime('now'))");
|
||||
ps.setInt(1, version);
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
log.info("[DB] Migration V" + version + " applied.");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isMigrationApplied(Connection conn, int version) throws SQLException {
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT 1 FROM db_version WHERE version = ?");
|
||||
ps.setInt(1, version);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
boolean exists = rs.next();
|
||||
ps.close();
|
||||
return exists;
|
||||
}
|
||||
|
||||
private static List<String> listMigrationFiles() {
|
||||
List<String> files = new ArrayList<>();
|
||||
try {
|
||||
InputStream ins = com.cyynote.dso.DsHelper.class.getResourceAsStream("/db/schema.sql");
|
||||
int len = ins.available();
|
||||
byte[] bs = new byte[len];
|
||||
ins.read(bs);
|
||||
String str = new String(bs, "UTF-8");
|
||||
String[] sql = str.split(";");
|
||||
return sql;
|
||||
URL dirUrl = DsHelper.class.getResource("/db/migrations/");
|
||||
if (dirUrl == null) return files;
|
||||
String protocol = dirUrl.getProtocol();
|
||||
if ("file".equals(protocol)) {
|
||||
java.io.File dir = new java.io.File(dirUrl.toURI());
|
||||
if (dir.isDirectory()) {
|
||||
for (java.io.File f : dir.listFiles()) {
|
||||
if (f.getName().startsWith("V") && f.getName().endsWith(".sql")) {
|
||||
files.add(f.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ("jar".equals(protocol)) {
|
||||
String jarPath = dirUrl.getPath();
|
||||
String jarFile = jarPath.substring(5, jarPath.indexOf("!"));
|
||||
String prefix = jarPath.substring(jarPath.indexOf("!") + 2);
|
||||
try (java.util.jar.JarFile jar = new java.util.jar.JarFile(jarFile)) {
|
||||
java.util.Enumeration<java.util.jar.JarEntry> entries = jar.entries();
|
||||
while (entries.hasMoreElements()) {
|
||||
String name = entries.nextElement().getName();
|
||||
if (name.startsWith(prefix) && name.endsWith(".sql")) {
|
||||
String fileName = name.substring(name.lastIndexOf('/') + 1);
|
||||
if (fileName.startsWith("V")) {
|
||||
files.add(fileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warning("[DB] Could not list migration files: " + e.getMessage());
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
private static int parseMigrationVersion(String fileName) {
|
||||
try {
|
||||
String num = fileName.substring(1, fileName.indexOf("__"));
|
||||
return Integer.parseInt(num);
|
||||
} catch (Exception e) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean tableExists(Connection conn, String tableName) throws SQLException {
|
||||
PreparedStatement ps = conn.prepareStatement(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?");
|
||||
ps.setString(1, tableName);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
boolean exists = rs.next();
|
||||
ps.close();
|
||||
return exists;
|
||||
}
|
||||
|
||||
private static String[] loadSqlFile(String path) {
|
||||
try {
|
||||
InputStream ins = DsHelper.class.getResourceAsStream(path);
|
||||
if (ins == null) return new String[0];
|
||||
byte[] bs = ins.readAllBytes();
|
||||
String str = new String(bs, StandardCharsets.UTF_8);
|
||||
String[] parts = str.split(";");
|
||||
List<String> result = new ArrayList<>();
|
||||
for (String p : parts) {
|
||||
String trimmed = p.trim();
|
||||
if (!trimmed.isEmpty()) result.add(trimmed);
|
||||
}
|
||||
return result.toArray(new String[0]);
|
||||
} catch (Exception ex) {
|
||||
throw new RuntimeException(ex);
|
||||
throw new RuntimeException("Failed to load SQL file: " + path, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void runSql(Connection conn, String sql) throws SQLException {
|
||||
System.out.println(sql);
|
||||
PreparedStatement ps = conn.prepareStatement(sql);
|
||||
ps.executeUpdate();
|
||||
ps.close();
|
||||
Statement st = conn.createStatement();
|
||||
st.execute(sql);
|
||||
st.close();
|
||||
}
|
||||
|
||||
private static void runSqlIgnoreError(Connection conn, String sql, String ignoreContaining) {
|
||||
try {
|
||||
String trimmed = sql.trim();
|
||||
if (trimmed.isEmpty()) return;
|
||||
log.info("[DB SQL] " + trimmed.substring(0, Math.min(80, trimmed.length())));
|
||||
Statement st = conn.createStatement();
|
||||
st.execute(trimmed);
|
||||
st.close();
|
||||
} catch (SQLException e) {
|
||||
if (ignoreContaining != null && e.getMessage() != null &&
|
||||
e.getMessage().toLowerCase().contains(ignoreContaining.toLowerCase())) {
|
||||
log.info("[DB] Ignored: " + e.getMessage());
|
||||
} else {
|
||||
log.warning("[DB] SQL error (non-fatal): " + e.getMessage() + " | SQL: " + sql.trim().substring(0, Math.min(80, sql.trim().length())));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.cyynote.model;
|
||||
|
||||
import lombok.Data;
|
||||
import org.noear.wood.annotation.PrimaryKey;
|
||||
import org.noear.wood.annotation.Table;
|
||||
|
||||
@Data
|
||||
@Table("drive_file")
|
||||
public class DriveFileModel {
|
||||
@PrimaryKey
|
||||
public Integer id;
|
||||
public String filename;
|
||||
public String originalName;
|
||||
public Long size;
|
||||
public String mimeType;
|
||||
public String r2Key;
|
||||
public String shareToken;
|
||||
public String shareExpiry;
|
||||
public Integer userid;
|
||||
public String createtime;
|
||||
public Integer flag;
|
||||
public String storageMode;
|
||||
public String localPath;
|
||||
|
||||
public void setId(Integer id) { this.id = id; }
|
||||
public Integer getId() { return id; }
|
||||
|
||||
public void setFilename(String filename) { this.filename = filename; }
|
||||
public String getFilename() { return filename; }
|
||||
|
||||
public void setOriginalName(String originalName) { this.originalName = originalName; }
|
||||
public String getOriginalName() { return originalName; }
|
||||
|
||||
public void setSize(Long size) { this.size = size; }
|
||||
public Long getSize() { return size; }
|
||||
|
||||
public void setMimeType(String mimeType) { this.mimeType = mimeType; }
|
||||
public String getMimeType() { return mimeType; }
|
||||
|
||||
public void setR2Key(String r2Key) { this.r2Key = r2Key; }
|
||||
public String getR2Key() { return r2Key; }
|
||||
|
||||
public void setShareToken(String shareToken) { this.shareToken = shareToken; }
|
||||
public String getShareToken() { return shareToken; }
|
||||
|
||||
public void setShareExpiry(String shareExpiry) { this.shareExpiry = shareExpiry; }
|
||||
public String getShareExpiry() { return shareExpiry; }
|
||||
|
||||
public void setUserid(Integer userid) { this.userid = userid; }
|
||||
public Integer getUserid() { return userid; }
|
||||
|
||||
public void setCreatetime(String createtime) { this.createtime = createtime; }
|
||||
public String getCreatetime() { return createtime; }
|
||||
|
||||
public void setFlag(Integer flag) { this.flag = flag; }
|
||||
public Integer getFlag() { return flag; }
|
||||
|
||||
public void setStorageMode(String storageMode) { this.storageMode = storageMode; }
|
||||
public String getStorageMode() { return storageMode; }
|
||||
|
||||
public void setLocalPath(String localPath) { this.localPath = localPath; }
|
||||
public String getLocalPath() { return localPath; }
|
||||
}
|
||||
@@ -25,8 +25,12 @@ public class NoteModel {
|
||||
|
||||
public String viewtime;
|
||||
|
||||
public Integer userid;
|
||||
|
||||
public Integer flag;
|
||||
|
||||
public String tags;
|
||||
|
||||
public void setId(Integer id) {
|
||||
this.id = id;
|
||||
}
|
||||
@@ -63,6 +67,14 @@ public class NoteModel {
|
||||
this.flag = flag;
|
||||
}
|
||||
|
||||
public void setUserid(Integer userid) {
|
||||
this.userid = userid;
|
||||
}
|
||||
|
||||
public Integer getUserid() {
|
||||
return this.userid;
|
||||
}
|
||||
|
||||
|
||||
public String toString() {
|
||||
return "NoteModel(id=" + getId() + ", pid=" + getPid() + ", title=" + getTitle() + ", context=" + getContext() + ", conjson=" + getConjson() + ", createtime=" + getCreatetime() + ", updatetime=" + getUpdatetime() + ", viewtime=" + getViewtime() + ", flag=" + getFlag() + ")";
|
||||
@@ -103,5 +115,13 @@ public class NoteModel {
|
||||
public Integer getFlag() {
|
||||
return this.flag;
|
||||
}
|
||||
|
||||
public void setTags(String tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
public String getTags() {
|
||||
return this.tags;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ public class UserModel {
|
||||
|
||||
public String logintime;
|
||||
|
||||
public Integer passwordHashed;
|
||||
|
||||
public void setId(Integer id) {
|
||||
this.id = id;
|
||||
}
|
||||
@@ -51,6 +53,14 @@ public class UserModel {
|
||||
this.logintime = logintime;
|
||||
}
|
||||
|
||||
public Integer getPasswordHashed() {
|
||||
return this.passwordHashed;
|
||||
}
|
||||
|
||||
public void setPasswordHashed(Integer passwordHashed) {
|
||||
this.passwordHashed = passwordHashed;
|
||||
}
|
||||
|
||||
public String toString() {
|
||||
return "UserModel(id=" + getId() + ", email=" + getEmail() + ", password=" + getPassword() + ", username=" + getUsername() + ", role=" + getRole() + ", token=" + getToken() + ", logintime=" + getLogintime() + ")";
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ public class NoteRequest {
|
||||
|
||||
private String context;
|
||||
|
||||
private String tags;
|
||||
|
||||
public Long getId() {
|
||||
return this.id;
|
||||
}
|
||||
@@ -41,4 +43,12 @@ public class NoteRequest {
|
||||
public void setContext(String context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public String getTags() {
|
||||
return this.tags;
|
||||
}
|
||||
|
||||
public void setTags(String tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
#Thu May 14 15:10:41 CST 2026
|
||||
#Fri May 15 17:58:56 CST 2026
|
||||
groupId=com.cyynote
|
||||
artifactId=cyynote
|
||||
version=1.0.0
|
||||
|
||||
+3
@@ -1,5 +1,6 @@
|
||||
com/cyynote/payload/request/LoginRequest.class
|
||||
com/cyynote/dso/DsHelper.class
|
||||
com/cyynote/controller/SettingsController.class
|
||||
com/cyynote/controller/JwtInterceptor.class
|
||||
com/cyynote/util/TreeNode.class
|
||||
com/cyynote/model/Appx.class
|
||||
@@ -14,12 +15,14 @@ com/cyynote/Config.class
|
||||
com/cyynote/payload/request/UpdatePasswordRequest.class
|
||||
com/cyynote/payload/request/NoteRequest.class
|
||||
com/cyynote/WebApp.class
|
||||
com/cyynote/controller/DriveController.class
|
||||
com/cyynote/dso/AuthSqlAnnotation.class
|
||||
com/cyynote/dso/SqlMapper.class
|
||||
com/cyynote/payload/response/JwtResponse.class
|
||||
com/cyynote/controller/AuthController.class
|
||||
com/cyynote/dso/NoteSqlAnnotation.class
|
||||
com/cyynote/model/AppxModel.class
|
||||
com/cyynote/model/DriveFileModel.class
|
||||
com/cyynote/dso/SqlAnnotation.class
|
||||
com/cyynote/controller/BaseController.class
|
||||
com/cyynote/util/TreeBuild.class
|
||||
|
||||
+3
@@ -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/BaseController.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/DemoController.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/DriveController.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/JwtInterceptor.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/NoteController.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/SettingsController.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/controller/Test2Controller.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/AuthProcessorImpl.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/dso/AuthSqlAnnotation.java
|
||||
@@ -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/model/Appx.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/AppxModel.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/DriveFileModel.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/HistoryModel.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/NoteModel.java
|
||||
/opt/cyy/CyyNote/cyynote-backend/src/main/java/com/cyynote/model/UserModel.java
|
||||
|
||||
@@ -79,20 +79,19 @@ const skipCollaborationInit =
|
||||
window.parent != null && window.parent.frames.right === window;
|
||||
|
||||
|
||||
function OnChangePlugin({ onChange }) {
|
||||
function OnChangePlugin({ onChange }: { onChange: (editorState: unknown) => void }): null {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({editorState}) => {
|
||||
onChange(editorState);
|
||||
});
|
||||
}, [editor, onChange]);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Editor(props: { noteId: bigint; noteTitle: string; editData: any; handleLawClick_item2: any; }): JSX.Element {
|
||||
export default function Editor(props: { noteId: number; noteTitle: string; editData: string | null; handleLawClick_item2: any; }): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
console.log(props.noteId)
|
||||
|
||||
const {historyState} = useSharedHistoryContext();
|
||||
const {
|
||||
settings: {
|
||||
@@ -121,7 +120,7 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
|
||||
const [isSmallWidthViewport, setIsSmallWidthViewport] =
|
||||
useState<boolean>(false);
|
||||
const [isLinkEditMode, setIsLinkEditMode] = useState<boolean>(false);
|
||||
const [editorState, setEditorState] = useState();
|
||||
const [editorState, setEditorState] = useState<string | undefined>(undefined);
|
||||
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
|
||||
if (_floatingAnchorElem !== null) {
|
||||
setFloatingAnchorElem(_floatingAnchorElem);
|
||||
@@ -146,18 +145,11 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
|
||||
}, [isSmallWidthViewport]);
|
||||
|
||||
|
||||
function onChange(editorState) {
|
||||
// Call toJSON on the EditorState object, which produces a serialization safe string
|
||||
function onChange(editorState: { toJSON: () => unknown }) {
|
||||
const editorStateJSON = editorState.toJSON();
|
||||
|
||||
// However, we still have a JavaScript object, so we need to convert it to an actual string with JSON.stringify
|
||||
setEditorState(JSON.stringify(editorStateJSON));
|
||||
|
||||
}
|
||||
|
||||
console.log(props.noteId)
|
||||
// console.log(props.editData)
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRichText && <ToolbarPlugin setIsLinkEditMode={setIsLinkEditMode} />}
|
||||
@@ -270,7 +262,7 @@ export default function Editor(props: { noteId: bigint; noteTitle: string; editD
|
||||
{isAutocomplete && <AutocompletePlugin />}
|
||||
<div>{showTableOfContents && <TableOfContentsPlugin />}</div>
|
||||
{shouldUseLexicalContextMenu && <ContextMenuPlugin />}
|
||||
<ActionsPlugin isRichText={isRichText} noteId={props.noteId} noteTitle={props.noteTitle} editData ={props.editData} handleLawClick_item3={props.handleLawClick_item2}/>
|
||||
<ActionsPlugin noteId={props.noteId} noteTitle={props.noteTitle} editData={props.editData} handleLawClick_item3={props.handleLawClick_item2}/>
|
||||
</div>
|
||||
{showTreeView && <TreeViewPlugin />}
|
||||
</>
|
||||
|
||||
@@ -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 {
|
||||
settings: {isCollab, emptyEditor, measureTypingPerf},
|
||||
} = useSettings();
|
||||
|
||||
console.log(props.noteId)
|
||||
// console.log(props.editData)
|
||||
console.log(props.editData!==null)
|
||||
console.log(prepopulatedRichText)
|
||||
|
||||
const resolveEditorState = (): string | null | undefined => {
|
||||
if (props.editData === null) return undefined; // use prepopulatedRichText via function below
|
||||
const raw = props.editData as unknown as string;
|
||||
@@ -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 (
|
||||
// <Editor />
|
||||
|
||||
@@ -39,6 +39,32 @@ import NoteService from "../../../../services/note.service.ts";
|
||||
import {Toast} from "@douyinfe/semi-ui";
|
||||
import {SAVE_COMMAND} from "../GlobalEventsPlugin";
|
||||
|
||||
function exportMarkdown(editor: LexicalEditor, title: string) {
|
||||
let markdown = '';
|
||||
editor.getEditorState().read(() => {
|
||||
markdown = $convertToMarkdownString(PLAYGROUND_TRANSFORMERS);
|
||||
});
|
||||
const blob = new Blob([`# ${title}\n\n${markdown}`], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'note'}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function exportHtml(title: string) {
|
||||
const content = document.querySelector('.editor-scroller')?.innerHTML ?? '';
|
||||
const html = `<!DOCTYPE html><html lang="zh"><head><meta charset="UTF-8"><title>${title}</title></head><body><h1>${title}</h1>${content}</body></html>`;
|
||||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title || 'note'}.html`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function sendEditorState(editor: LexicalEditor): Promise<void> {
|
||||
const stringifiedEditorState = JSON.stringify(editor.getEditorState());
|
||||
try {
|
||||
@@ -77,7 +103,7 @@ async function validateEditorState(editor: LexicalEditor): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string;handleLawClick_item3:any; }): JSX.Element {
|
||||
export default function ActionsPlugin(props: { noteId: number; noteTitle: string; handleLawClick_item3: any; editData?: any }): JSX.Element {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [isEditable, setIsEditable] = useState(() => editor.isEditable());
|
||||
const [isSpeechToText, setIsSpeechToText] = useState(false);
|
||||
@@ -169,29 +195,22 @@ export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
const saveData = (editor) => {
|
||||
const x = document.getElementsByClassName("semi-input semi-input-large");
|
||||
console.log('================当前noteTitle x: ', x)
|
||||
console.log('当前ID: ', props.noteId)
|
||||
console.log('当前noteTitle: ', props.noteTitle)
|
||||
console.log('当前noteTitle2: ', x[0].value)
|
||||
// console.log('当前editor3: ', editor.getEditorState().toJSON())
|
||||
const saveData = (editor: LexicalEditor) => {
|
||||
const currentTitle = (document.querySelector('.semi-input.semi-input-large') as HTMLInputElement)?.value ?? props.noteTitle;
|
||||
const obj = editor.getEditorState().toJSON();
|
||||
// console.log('当前editor4: ', JSON.stringify(obj))
|
||||
|
||||
NoteService.update(props.noteId,x[0].value, JSON.stringify(obj)).then(
|
||||
response => {
|
||||
console.log(response.data)
|
||||
const data = response.data
|
||||
console.log(data)
|
||||
console.log('handleLawClick_item3')
|
||||
import('../../../../stores/useNoteStore').then(({ useNoteStore }) => {
|
||||
const tags = useNoteStore.getState().currentTags;
|
||||
NoteService.update(props.noteId, currentTitle, JSON.stringify(obj), tags).then(
|
||||
() => {
|
||||
props.handleLawClick_item3('1');
|
||||
Toast.info('保存成功' );
|
||||
Toast.info('保存成功');
|
||||
useNoteStore.getState().setDirty(false);
|
||||
},
|
||||
error => {
|
||||
console.log(error)
|
||||
() => {
|
||||
Toast.error('保存失败');
|
||||
}
|
||||
);
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -235,10 +254,24 @@ export default function ActionsPlugin(props: { noteId: bigint; noteTitle: string
|
||||
source: 'Playground',
|
||||
})
|
||||
}
|
||||
title="Export"
|
||||
title="Export JSON"
|
||||
aria-label="Export editor state to JSON">
|
||||
<i className="export" />
|
||||
</button>
|
||||
<button
|
||||
className="action-button export"
|
||||
onClick={() => exportMarkdown(editor, props.noteTitle)}
|
||||
title="导出 Markdown"
|
||||
aria-label="Export as Markdown">
|
||||
<i className="export" />
|
||||
</button>
|
||||
<button
|
||||
className="action-button export"
|
||||
onClick={() => exportHtml(props.noteTitle)}
|
||||
title="导出 HTML"
|
||||
aria-label="Export as HTML">
|
||||
<i className="export" />
|
||||
</button>
|
||||
<button
|
||||
className="action-button clear"
|
||||
disabled={isEditorEmpty}
|
||||
|
||||
@@ -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 { Input, Skeleton, Breadcrumb } from '@douyinfe/semi-ui';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Input, Skeleton, Breadcrumb, Button, Tag, TagInput } from '@douyinfe/semi-ui';
|
||||
import { IconHistory } from '@douyinfe/semi-icons';
|
||||
import { useNoteStore } from '../../stores/useNoteStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import PlaygroundApp from '../lexical/LexicalApp';
|
||||
import NoteHistoryModal from '../modals/NoteHistoryModal';
|
||||
import type { ITreeNode } from '../../types';
|
||||
|
||||
function getNotePath(
|
||||
nodes: ITreeNode[],
|
||||
targetKey: string | number,
|
||||
path: string[] = [],
|
||||
): string[] | null {
|
||||
for (const node of nodes) {
|
||||
const current = [...path, node.label];
|
||||
if (String(node.key) === String(targetKey)) return current;
|
||||
if (node.children) {
|
||||
const found = getNotePath(node.children, targetKey, current);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function NoteEditor() {
|
||||
const { currentNoteId, currentTitle, currentContent, setTitle } = useNoteStore();
|
||||
const { currentNoteId, currentTitle, currentContent, currentTags, setTitle, setTags, treeData } = useNoteStore();
|
||||
const { isNoteLoading } = useUIStore();
|
||||
const [historyVisible, setHistoryVisible] = useState(false);
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(value: string) => {
|
||||
@@ -15,19 +35,37 @@ export default function NoteEditor() {
|
||||
[setTitle],
|
||||
);
|
||||
|
||||
// handleRefreshTree is intentionally a no-op here:
|
||||
// LexicalApp calls it after structural changes, but tree refresh
|
||||
// is driven by the NoteStore and handled at the NotePage level.
|
||||
const handleTagsChange = useCallback(
|
||||
(tags: string[]) => {
|
||||
setTags(tags.join(','));
|
||||
},
|
||||
[setTags],
|
||||
);
|
||||
|
||||
const handleRefreshTree = useCallback(() => {
|
||||
// no-op: tree is refreshed by NotePage via store subscription
|
||||
}, []);
|
||||
|
||||
const notePath = currentNoteId != null ? getNotePath(treeData, currentNoteId) : null;
|
||||
const breadcrumbRoutes = ['CyyNote', ...(notePath ?? ['笔记标题', currentTitle])];
|
||||
const tagValues = currentTags ? currentTags.split(',').filter(Boolean) : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb
|
||||
style={{ marginBottom: '16px', paddingTop: '10px' }}
|
||||
routes={['CyyNote', '笔记标题', currentTitle]}
|
||||
/>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingTop: '10px', marginBottom: '16px' }}>
|
||||
<Breadcrumb routes={breadcrumbRoutes} />
|
||||
{currentNoteId !== null && (
|
||||
<Button
|
||||
size="small"
|
||||
theme="borderless"
|
||||
icon={<IconHistory />}
|
||||
onClick={() => setHistoryVisible(true)}
|
||||
>
|
||||
历史版本
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<NoteHistoryModal visible={historyVisible} onClose={() => setHistoryVisible(false)} />
|
||||
<Skeleton
|
||||
placeholder={<Skeleton.Paragraph rows={2} />}
|
||||
loading={isNoteLoading}
|
||||
@@ -37,8 +75,19 @@ export default function NoteEditor() {
|
||||
size="large"
|
||||
value={currentTitle}
|
||||
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
|
||||
style={{
|
||||
borderRadius: '10px',
|
||||
@@ -48,6 +97,7 @@ export default function NoteEditor() {
|
||||
>
|
||||
{currentNoteId !== null && currentContent !== null ? (
|
||||
<PlaygroundApp
|
||||
key={currentNoteId}
|
||||
noteId={currentNoteId}
|
||||
noteTitle={currentTitle}
|
||||
editData={currentContent}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { CSSProperties, ReactNode, KeyboardEvent } from 'react';
|
||||
import {
|
||||
Tree,
|
||||
List,
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
Dropdown,
|
||||
Typography,
|
||||
Toast,
|
||||
Modal,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconCopyAdd,
|
||||
IconDelete,
|
||||
IconSearch,
|
||||
IconSetting,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { IconModal, IconToken as LabIconToken, IconTreeSelect } from '@douyinfe/semi-icons-lab';
|
||||
import { useNoteStore } from '../../stores/useNoteStore';
|
||||
@@ -21,7 +23,7 @@ import { useUIStore } from '../../stores/useUIStore';
|
||||
import noteService from '../../services/note.service';
|
||||
import type { INote, ITreeNode } from '../../types';
|
||||
|
||||
const NAV_ITEMS = ['首页', '设置', '已删除'] as const;
|
||||
const NAV_ITEMS = ['首页', '网盘', '设置', '已删除'] as const;
|
||||
type NavItem = (typeof NAV_ITEMS)[number];
|
||||
|
||||
interface NoteTreeProps {
|
||||
@@ -31,23 +33,40 @@ interface 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 [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editingValue, setEditingValue] = useState('');
|
||||
|
||||
const toNoteId = (id: string | number) => Number(id);
|
||||
|
||||
const loadNote = useCallback(
|
||||
(id: number) => {
|
||||
const doLoad = () => {
|
||||
noteService.get(id).then(
|
||||
(res) => {
|
||||
const note = res.data;
|
||||
setCurrentNote(note.id, note.title, note.context);
|
||||
setCurrentNote(note.id, note.title, note.context, note.tags ?? '');
|
||||
setActiveView('note');
|
||||
},
|
||||
() => Toast.error('加载笔记失败'),
|
||||
);
|
||||
};
|
||||
|
||||
if (isDirty) {
|
||||
Modal.confirm({
|
||||
title: '未保存的内容',
|
||||
content: '当前笔记有未保存的内容,确定要离开吗?',
|
||||
okText: '确定',
|
||||
cancelText: '取消',
|
||||
onOk: doLoad,
|
||||
});
|
||||
} else {
|
||||
doLoad();
|
||||
}
|
||||
},
|
||||
[setCurrentNote, setActiveView],
|
||||
[setCurrentNote, setActiveView, isDirty],
|
||||
);
|
||||
|
||||
const addNote = useCallback(
|
||||
@@ -63,6 +82,19 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
|
||||
[onRefreshTree],
|
||||
);
|
||||
|
||||
const renameNote = useCallback(
|
||||
async (id: number, newTitle: string) => {
|
||||
try {
|
||||
const res = await noteService.get(id);
|
||||
await noteService.update(id, newTitle, res.data.context);
|
||||
onRefreshTree();
|
||||
} catch {
|
||||
Toast.error('重命名失败');
|
||||
}
|
||||
},
|
||||
[onRefreshTree],
|
||||
);
|
||||
|
||||
const treeStyle: CSSProperties = {
|
||||
height: 550,
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
@@ -70,11 +102,46 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
|
||||
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' }}>
|
||||
<Typography.Text
|
||||
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}
|
||||
</Typography.Text>
|
||||
@@ -83,7 +150,7 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
|
||||
size="small"
|
||||
icon={<IconCopyAdd />}
|
||||
onClick={(e) => {
|
||||
addNote(toNoteId(item.key));
|
||||
addNote(id);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
@@ -92,13 +159,14 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
|
||||
type="warning"
|
||||
icon={<IconDelete />}
|
||||
onClick={(e) => {
|
||||
openDeleteConfirm(toNoteId(item.key), String(label));
|
||||
openDeleteConfirm(id, String(label));
|
||||
e.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -140,8 +208,9 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
|
||||
onClick={() => onNavItemClick(item)}
|
||||
>
|
||||
{item === '首页' && <Button type="danger" theme="borderless" icon={<LabIconToken />} style={{ marginRight: 4 }} />}
|
||||
{item === '网盘' && <Button type="danger" theme="borderless" icon={<IconModal />} style={{ marginRight: 4 }} />}
|
||||
{item === '设置' && <Button type="danger" theme="borderless" icon={<IconTreeSelect />} style={{ marginRight: 4 }} />}
|
||||
{item === '已删除' && <Button type="danger" theme="borderless" icon={<IconModal />} style={{ marginRight: 4 }} />}
|
||||
{item === '已删除' && <Button type="danger" theme="borderless" icon={<IconSetting />} style={{ marginRight: 4 }} />}
|
||||
{item}
|
||||
</div>
|
||||
)}
|
||||
@@ -189,12 +258,21 @@ export default function NoteTree({ onRefreshTree, onNavItemClick, onSearch }: No
|
||||
searchPlaceholder=" "
|
||||
showFilteredOnly
|
||||
directory
|
||||
draggable
|
||||
virtualize={{ itemSize: 28 }}
|
||||
value={currentNoteId === null ? undefined : String(currentNoteId)}
|
||||
treeData={treeData as unknown as Parameters<typeof Tree>[0]['treeData']}
|
||||
renderLabel={renderLabel as Parameters<typeof Tree>[0]['renderLabel']}
|
||||
treeData={treeData as unknown as any}
|
||||
renderLabel={renderLabel as any}
|
||||
style={treeStyle}
|
||||
onSelect={(id) => loadNote(toNoteId(id as string | number))}
|
||||
onDrop={({ node, dragNode }: { node: any; dragNode: any }) => {
|
||||
const id = toNoteId(dragNode.key);
|
||||
const newPid = toNoteId(node.key);
|
||||
noteService.move(id, newPid).then(
|
||||
() => { Toast.success('移动成功'); onRefreshTree(); },
|
||||
() => Toast.error('移动失败'),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Avatar,
|
||||
Toast,
|
||||
SideSheet,
|
||||
Row,
|
||||
List,
|
||||
ButtonGroup,
|
||||
Typography,
|
||||
@@ -20,6 +19,8 @@ import {
|
||||
IconArrowUp,
|
||||
IconMenu,
|
||||
IconCode,
|
||||
IconMoon,
|
||||
IconSun,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { IconDescriptions } from '@douyinfe/semi-icons-lab';
|
||||
|
||||
@@ -32,6 +33,8 @@ import authService from '../services/auth.service';
|
||||
import NoteTree from '../components/sidebar/NoteTree';
|
||||
import NoteEditor from '../components/note/NoteEditor';
|
||||
import HomePage from './home/HomePage';
|
||||
import SettingsPage from './settings/SettingsPage';
|
||||
import DrivePage from './drive/DrivePage';
|
||||
import DeleteModal from '../components/modals/DeleteModal';
|
||||
import PasswordModal from '../components/modals/PasswordModal';
|
||||
import TrashListModal from '../components/modals/TrashListModal';
|
||||
@@ -60,15 +63,25 @@ export default function NotePage() {
|
||||
activeView,
|
||||
siderFlag,
|
||||
sideVisible,
|
||||
isDarkMode,
|
||||
setActiveView,
|
||||
setSiderFlag,
|
||||
setSideVisible,
|
||||
setDeleteModalVisible,
|
||||
setUpdatePasswordModalVisible,
|
||||
toggleDarkMode,
|
||||
} = useUIStore();
|
||||
|
||||
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(() => {
|
||||
noteService.getAllContent().then(
|
||||
(res) => setTreeData(res.data),
|
||||
@@ -96,6 +109,8 @@ export default function NotePage() {
|
||||
(item: string) => {
|
||||
if (item === '首页') {
|
||||
setActiveView('home');
|
||||
} else if (item === '网盘') {
|
||||
setActiveView('drive');
|
||||
} else if (item === '设置') {
|
||||
setActiveView('settings');
|
||||
} else if (item === '已删除') {
|
||||
@@ -163,6 +178,13 @@ export default function NotePage() {
|
||||
</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
|
||||
theme="borderless"
|
||||
icon={<IconBell size="large" />}
|
||||
@@ -237,48 +259,22 @@ export default function NotePage() {
|
||||
padding: 15,
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
<hr />
|
||||
<Row>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="secondary"
|
||||
style={{ marginRight: 8 }}
|
||||
onClick={() => setUpdatePasswordModalVisible(true)}
|
||||
>
|
||||
修改密码
|
||||
</Button>
|
||||
</Row>
|
||||
<br />
|
||||
<Row>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="tertiary"
|
||||
style={{ marginRight: 8 }}
|
||||
onClick={() => {
|
||||
void noteService
|
||||
.historyQueryOrDelete(0)
|
||||
.then((res) => Toast.info(`历史记录数:${String(res.data)}`));
|
||||
<SettingsPage />
|
||||
</div>
|
||||
</Content>
|
||||
)}
|
||||
|
||||
{activeView === 'drive' && (
|
||||
<Content style={{ padding: '10px', backgroundColor: 'var(--semi-color-bg-0)', overflowY: 'auto' }}>
|
||||
<Breadcrumb style={{ marginBottom: 24 }} routes={['网盘']} />
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
border: '1px solid var(--semi-color-border)',
|
||||
padding: 15,
|
||||
}}
|
||||
>
|
||||
查看历史记录Count
|
||||
</Button>
|
||||
</Row>
|
||||
<br />
|
||||
<Row>
|
||||
<Button
|
||||
theme="solid"
|
||||
type="danger"
|
||||
style={{ marginRight: 8 }}
|
||||
onClick={() => {
|
||||
void noteService
|
||||
.historyQueryOrDelete(1)
|
||||
.then(() => Toast.success('历史记录已清空'));
|
||||
}}
|
||||
>
|
||||
删除全部历史记录
|
||||
</Button>
|
||||
</Row>
|
||||
<DrivePage />
|
||||
</div>
|
||||
</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;
|
||||
@@ -1,5 +1,5 @@
|
||||
import axiosInstance from '../lib/axios';
|
||||
import type { ApiResponse, INote, ITreeNode } from '../types';
|
||||
import type { ApiResponse, IHistory, INote, ITreeNode } from '../types';
|
||||
|
||||
const API_URL = 'note/';
|
||||
|
||||
@@ -14,9 +14,9 @@ const noteService = {
|
||||
.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
|
||||
.post(API_URL + 'update', { id, title, context })
|
||||
.post(API_URL + 'update', { id, title, context, tags: tags ?? '' })
|
||||
.then((r) => r.data);
|
||||
},
|
||||
|
||||
@@ -52,6 +52,33 @@ const noteService = {
|
||||
.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.
|
||||
* 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;
|
||||
@@ -5,30 +5,38 @@ interface NoteState {
|
||||
currentNoteId: number | null;
|
||||
currentTitle: string;
|
||||
currentContent: string | null;
|
||||
currentTags: string;
|
||||
treeData: ITreeNode[];
|
||||
homeData: 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;
|
||||
setTitle: (title: string) => void;
|
||||
setTags: (tags: string) => void;
|
||||
setHomeData: (data: INote[]) => void;
|
||||
setViewData: (data: INote[]) => void;
|
||||
clearCurrentNote: () => void;
|
||||
setDirty: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useNoteStore = create<NoteState>()((set) => ({
|
||||
currentNoteId: null,
|
||||
currentTitle: '',
|
||||
currentContent: null,
|
||||
currentTags: '',
|
||||
treeData: [],
|
||||
homeData: [],
|
||||
viewData: [],
|
||||
setCurrentNote: (id, title, content) =>
|
||||
set({ currentNoteId: id, currentTitle: title, currentContent: content }),
|
||||
isDirty: false,
|
||||
setCurrentNote: (id, title, content, tags = '') =>
|
||||
set({ currentNoteId: id, currentTitle: title, currentContent: content, currentTags: tags, isDirty: false }),
|
||||
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 }),
|
||||
setViewData: (viewData) => set({ viewData }),
|
||||
clearCurrentNote: () =>
|
||||
set({ currentNoteId: null, currentTitle: '', currentContent: null }),
|
||||
set({ currentNoteId: null, currentTitle: '', currentContent: null, currentTags: '', isDirty: false }),
|
||||
setDirty: (isDirty) => set({ isDirty }),
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type ActiveView = 'home' | 'note' | 'settings' | 'search';
|
||||
export type ActiveView = 'home' | 'note' | 'settings' | 'search' | 'drive';
|
||||
|
||||
interface UIState {
|
||||
activeView: ActiveView;
|
||||
@@ -13,6 +13,7 @@ interface UIState {
|
||||
deleteModalVisible: boolean;
|
||||
updatePasswordModalVisible: boolean;
|
||||
searchQuery: string;
|
||||
isDarkMode: boolean;
|
||||
setActiveView: (view: ActiveView) => void;
|
||||
setSideVisible: (visible: boolean) => void;
|
||||
setSiderFlag: (flag: boolean) => void;
|
||||
@@ -22,6 +23,7 @@ interface UIState {
|
||||
setDeleteModalVisible: (visible: boolean) => void;
|
||||
setUpdatePasswordModalVisible: (visible: boolean) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
toggleDarkMode: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>()((set) => ({
|
||||
@@ -35,6 +37,7 @@ export const useUIStore = create<UIState>()((set) => ({
|
||||
deleteModalVisible: false,
|
||||
updatePasswordModalVisible: false,
|
||||
searchQuery: '',
|
||||
isDarkMode: localStorage.getItem('theme') === 'dark',
|
||||
setActiveView: (activeView) => set({ activeView }),
|
||||
setSideVisible: (sideVisible) => set({ sideVisible }),
|
||||
setSiderFlag: (siderFlag) => set({ siderFlag }),
|
||||
@@ -47,4 +50,15 @@ export const useUIStore = create<UIState>()((set) => ({
|
||||
setUpdatePasswordModalVisible: (updatePasswordModalVisible) =>
|
||||
set({ updatePasswordModalVisible }),
|
||||
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 };
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -21,17 +21,43 @@ export interface INote {
|
||||
createtime: string;
|
||||
updatetime: string;
|
||||
viewtime: string;
|
||||
flag: number;
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export interface ITreeNode {
|
||||
id?: number;
|
||||
pid?: number;
|
||||
key: string;
|
||||
key: string | number;
|
||||
value?: string;
|
||||
label: string;
|
||||
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> {
|
||||
code: number;
|
||||
message: string;
|
||||
|
||||
Reference in New Issue
Block a user