笔记管理功能初始化
This commit is contained in:
@@ -37,6 +37,11 @@ public class SecurityConfig {
|
|||||||
.requestMatchers("/api/auth/**").permitAll()
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
.requestMatchers("/api/gold-price/**").permitAll()
|
.requestMatchers("/api/gold-price/**").permitAll()
|
||||||
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll()
|
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/webjars/**").permitAll()
|
||||||
|
// Web 后台管理页本身是静态页面,加载时不可能带 JWT;
|
||||||
|
// 页面里的数据请求仍然走 /api/notes/** 走正常鉴权
|
||||||
|
.requestMatchers("/admin/**").permitAll()
|
||||||
|
// 笔记附件(图片)。文件名是 UUID,不可枚举
|
||||||
|
.requestMatchers("/uploads/**").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.accounting.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把上传目录暴露成静态资源
|
||||||
|
*
|
||||||
|
* <p>原有的 FileUtil 只负责把文件写进 {@code ${file.upload.path}},
|
||||||
|
* 但项目里一直没有任何地方把它映射出去,所以历史上传的图片
|
||||||
|
* 没有任何 URL 能访问到(OCR 场景只把图片喂给阿里云,
|
||||||
|
* 不需要回显,所以一直没暴露这个问题)。</p>
|
||||||
|
*
|
||||||
|
* <p>笔记要在 Markdown 里引用图片,必须补上这层映射。</p>
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Value("${file.upload.path}")
|
||||||
|
private String uploadPath;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
|
// 用 toUri() 而不是直接拼 "file:" ,否则在 Windows 开发环境下
|
||||||
|
// 路径分隔符会出问题
|
||||||
|
String location = Paths.get(uploadPath).toUri().toString();
|
||||||
|
|
||||||
|
registry.addResourceHandler("/uploads/**")
|
||||||
|
.addResourceLocations(location);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package com.accounting.controller;
|
||||||
|
|
||||||
|
import com.accounting.dto.NoteAttachmentResponse;
|
||||||
|
import com.accounting.dto.NoteImportRequest;
|
||||||
|
import com.accounting.dto.NotePageResponse;
|
||||||
|
import com.accounting.dto.NoteRequest;
|
||||||
|
import com.accounting.dto.NoteResponse;
|
||||||
|
import com.accounting.entity.User;
|
||||||
|
import com.accounting.mapper.UserMapper;
|
||||||
|
import com.accounting.service.NoteService;
|
||||||
|
import com.accounting.util.FileUtil;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Tag(name = "笔记管理", description = "笔记的增删改查、批量导入、附件上传")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/notes")
|
||||||
|
public class NoteController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private NoteService noteService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserMapper userMapper;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private FileUtil fileUtil;
|
||||||
|
|
||||||
|
@Operation(summary = "分页查询笔记列表(不含正文)")
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<NotePageResponse> list(
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@RequestParam(required = false) String keyword,
|
||||||
|
@RequestParam(required = false) String tag,
|
||||||
|
Authentication authentication) {
|
||||||
|
|
||||||
|
Long userId = getUserId(authentication);
|
||||||
|
return ResponseEntity.ok(noteService.list(userId, keyword, tag, page, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "查询笔记详情(含正文)")
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<NoteResponse> detail(@PathVariable Long id, Authentication authentication) {
|
||||||
|
Long userId = getUserId(authentication);
|
||||||
|
return ResponseEntity.ok(noteService.detail(userId, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "新建笔记")
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<NoteResponse> create(@RequestBody NoteRequest request, Authentication authentication) {
|
||||||
|
Long userId = getUserId(authentication);
|
||||||
|
return ResponseEntity.ok(noteService.create(userId, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "更新笔记")
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<NoteResponse> update(
|
||||||
|
@PathVariable Long id,
|
||||||
|
@RequestBody NoteRequest request,
|
||||||
|
Authentication authentication) {
|
||||||
|
|
||||||
|
Long userId = getUserId(authentication);
|
||||||
|
return ResponseEntity.ok(noteService.update(userId, id, request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "删除笔记(逻辑删除)")
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id, Authentication authentication) {
|
||||||
|
Long userId = getUserId(authentication);
|
||||||
|
noteService.delete(userId, id);
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("success", true);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "批量导入笔记")
|
||||||
|
@PostMapping("/import")
|
||||||
|
public ResponseEntity<Map<String, Object>> importBatch(
|
||||||
|
@Valid @RequestBody NoteImportRequest request,
|
||||||
|
Authentication authentication) {
|
||||||
|
|
||||||
|
Long userId = getUserId(authentication);
|
||||||
|
int imported = noteService.importBatch(userId, request.getNotes());
|
||||||
|
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("imported", imported);
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "上传图片附件,返回可直接在 Markdown 中引用的地址")
|
||||||
|
@PostMapping("/attachments")
|
||||||
|
public ResponseEntity<NoteAttachmentResponse> uploadAttachment(
|
||||||
|
@RequestParam("file") MultipartFile file) throws Exception {
|
||||||
|
|
||||||
|
String relativePath = fileUtil.saveFile(file);
|
||||||
|
return ResponseEntity.ok(new NoteAttachmentResponse("/uploads/" + relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "获取当前用户的所有标签,按使用次数排序")
|
||||||
|
@GetMapping("/tags")
|
||||||
|
public ResponseEntity<List<String>> tags(Authentication authentication) {
|
||||||
|
Long userId = getUserId(authentication);
|
||||||
|
return ResponseEntity.ok(noteService.allTags(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long getUserId(Authentication authentication) {
|
||||||
|
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
|
||||||
|
String username = userDetails.getUsername();
|
||||||
|
User user = userMapper.selectOne(
|
||||||
|
new LambdaQueryWrapper<User>().eq(User::getUsername, username)
|
||||||
|
);
|
||||||
|
if (user == null) {
|
||||||
|
throw new IllegalArgumentException("用户不存在");
|
||||||
|
}
|
||||||
|
return user.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.accounting.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 附件上传结果
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class NoteAttachmentResponse {
|
||||||
|
|
||||||
|
/** 可直接用于 Markdown 引用的相对地址,如 /uploads/2026/09/22/xxx.png */
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
public NoteAttachmentResponse(String url) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.accounting.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入笔记的请求体
|
||||||
|
*
|
||||||
|
* <p>由前端把 .md 文件读成字符串后一次性提交,
|
||||||
|
* 而不是走后端 multipart 解析 —— 前端可以在提交前先提取标题、
|
||||||
|
* 去重、让用户改标签,比后端盲解析灵活得多。</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class NoteImportRequest {
|
||||||
|
|
||||||
|
@NotEmpty(message = "笔记列表不能为空")
|
||||||
|
@Valid
|
||||||
|
private List<Item> notes;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Item {
|
||||||
|
|
||||||
|
/** 标题,为空时由正文首行提取 */
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/** Markdown 正文 */
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/** 导入来源文件名 */
|
||||||
|
private String sourcePath;
|
||||||
|
|
||||||
|
/** 标签,逗号分隔 */
|
||||||
|
private String tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.accounting.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 笔记列表项 —— 刻意不含 content 字段
|
||||||
|
*
|
||||||
|
* <p>列表接口如果要带上正文,一页 20 条就可能传输几 MB,
|
||||||
|
* 而这个后端部署在公网服务器上,客户端跨网络拉取会明显变慢。</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class NoteListItem {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/** 摘要,由正文自动提取 */
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
/** 标签列表 */
|
||||||
|
private List<String> tagList;
|
||||||
|
|
||||||
|
private Integer isPinned;
|
||||||
|
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.accounting.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 笔记分页结果
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class NotePageResponse {
|
||||||
|
|
||||||
|
private List<NoteListItem> list;
|
||||||
|
|
||||||
|
/** 符合条件的总条数 */
|
||||||
|
private long total;
|
||||||
|
|
||||||
|
/** 当前页码,从 1 开始 */
|
||||||
|
private int page;
|
||||||
|
|
||||||
|
/** 每页条数 */
|
||||||
|
private int size;
|
||||||
|
|
||||||
|
/** 是否还有下一页,客户端做无限滚动时用 */
|
||||||
|
private boolean hasMore;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.accounting.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建 / 更新笔记的请求体
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class NoteRequest {
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/** Markdown 正文 */
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/** 标签,逗号分隔(中英文逗号都支持) */
|
||||||
|
private String tags;
|
||||||
|
|
||||||
|
/** 是否置顶:0否 1是,不传按 0 处理 */
|
||||||
|
private Integer isPinned;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.accounting.dto;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 笔记详情(含正文)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class NoteResponse {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/** 标签列表 */
|
||||||
|
private List<String> tagList;
|
||||||
|
|
||||||
|
private Integer isPinned;
|
||||||
|
|
||||||
|
private String sourcePath;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.accounting.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 笔记
|
||||||
|
*
|
||||||
|
* <p>deleted 字段与 application.yml 中的
|
||||||
|
* {@code mybatis-plus.global-config.db-config.logic-delete-field=deleted} 对应,
|
||||||
|
* 查询会自动追加 {@code deleted = 0},删除会自动变成 UPDATE。</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("note")
|
||||||
|
public class Note {
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
/** Markdown 正文 */
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/** 摘要,列表页展示用,避免查询 content */
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
/** 标签,逗号分隔 */
|
||||||
|
private String tags;
|
||||||
|
|
||||||
|
/** 是否置顶:0否 1是 */
|
||||||
|
private Integer isPinned;
|
||||||
|
|
||||||
|
/** 导入来源文件名 */
|
||||||
|
private String sourcePath;
|
||||||
|
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.accounting.mapper;
|
||||||
|
|
||||||
|
import com.accounting.entity.Note;
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface NoteMapper extends BaseMapper<Note> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
package com.accounting.service;
|
||||||
|
|
||||||
|
import com.accounting.dto.NoteImportRequest;
|
||||||
|
import com.accounting.dto.NoteListItem;
|
||||||
|
import com.accounting.dto.NotePageResponse;
|
||||||
|
import com.accounting.dto.NoteRequest;
|
||||||
|
import com.accounting.dto.NoteResponse;
|
||||||
|
import com.accounting.entity.Note;
|
||||||
|
import com.accounting.mapper.NoteMapper;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class NoteService {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private NoteMapper noteMapper;
|
||||||
|
|
||||||
|
/** 摘要最大长度 */
|
||||||
|
private static final int SUMMARY_MAX_LENGTH = 150;
|
||||||
|
|
||||||
|
/** 单页最大条数,防止客户端传个 size=99999 把数据库拖死 */
|
||||||
|
private static final int MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Markdown 清洗用的正则,顺序有讲究,见 buildSummary 注释
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
private static final Pattern RE_CODE_FENCE = Pattern.compile("(?s)```.*?```");
|
||||||
|
private static final Pattern RE_CODE_FENCE_OPEN = Pattern.compile("(?s)```.*$");
|
||||||
|
private static final Pattern RE_IMAGE = Pattern.compile("!\\[([^\\]]*)\\]\\([^)]*\\)");
|
||||||
|
private static final Pattern RE_LINK = Pattern.compile("\\[([^\\]]*)\\]\\([^)]*\\)");
|
||||||
|
private static final Pattern RE_HEADING = Pattern.compile("(?m)^#{1,6}\\s*");
|
||||||
|
private static final Pattern RE_QUOTE = Pattern.compile("(?m)^\\s*>\\s*");
|
||||||
|
private static final Pattern RE_LIST_MARK = Pattern.compile("(?m)^\\s*(?:[-*+]|\\d+\\.)\\s+");
|
||||||
|
private static final Pattern RE_HR = Pattern.compile("(?m)^\\s*(?:[-*_]\\s*){3,}$");
|
||||||
|
private static final Pattern RE_EMPHASIS = Pattern.compile("[*_~`]+");
|
||||||
|
private static final Pattern RE_BLANK = Pattern.compile("\\s+");
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// 查询
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询笔记列表(不含正文)
|
||||||
|
*
|
||||||
|
* @param keyword 关键词,同时匹配标题和正文,可为空
|
||||||
|
* @param tag 标签精确匹配,可为空
|
||||||
|
*/
|
||||||
|
public NotePageResponse list(Long userId, String keyword, String tag, int page, int size) {
|
||||||
|
int safePage = Math.max(page, 1);
|
||||||
|
int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);
|
||||||
|
|
||||||
|
long total = noteMapper.selectCount(buildListWrapper(userId, keyword, tag));
|
||||||
|
|
||||||
|
List<Note> rows;
|
||||||
|
if (total == 0) {
|
||||||
|
rows = new ArrayList<>();
|
||||||
|
} else {
|
||||||
|
int offset = (safePage - 1) * safeSize;
|
||||||
|
rows = noteMapper.selectList(
|
||||||
|
buildListWrapper(userId, keyword, tag)
|
||||||
|
.orderByDesc(Note::getIsPinned)
|
||||||
|
.orderByDesc(Note::getUpdateTime)
|
||||||
|
// 项目里没有配置 MyBatis-Plus 的 PaginationInnerInterceptor,
|
||||||
|
// selectPage 不会真正生成 LIMIT,所以这里直接拼。
|
||||||
|
// offset/size 都是上面校验过的 int,不存在注入问题。
|
||||||
|
.last("LIMIT " + offset + "," + safeSize)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NotePageResponse response = new NotePageResponse();
|
||||||
|
response.setList(rows.stream().map(this::toListItem).collect(Collectors.toList()));
|
||||||
|
response.setTotal(total);
|
||||||
|
response.setPage(safePage);
|
||||||
|
response.setSize(safeSize);
|
||||||
|
response.setHasMore((long) safePage * safeSize < total);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询笔记详情(含正文)
|
||||||
|
*/
|
||||||
|
public NoteResponse detail(Long userId, Long id) {
|
||||||
|
return toResponse(requireOwnedNote(userId, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 某个用户用过的全部标签,按使用次数从多到少排序
|
||||||
|
*/
|
||||||
|
public List<String> allTags(Long userId) {
|
||||||
|
List<Note> notes = noteMapper.selectList(
|
||||||
|
new LambdaQueryWrapper<Note>()
|
||||||
|
.select(Note::getTags)
|
||||||
|
.eq(Note::getUserId, userId)
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Integer> counter = new HashMap<>();
|
||||||
|
for (Note note : notes) {
|
||||||
|
for (String tag : parseTags(note.getTags())) {
|
||||||
|
counter.merge(tag, 1, Integer::sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counter.entrySet().stream()
|
||||||
|
.sorted(Map.Entry.<String, Integer>comparingByValue(Comparator.reverseOrder())
|
||||||
|
.thenComparing(Map.Entry.comparingByKey()))
|
||||||
|
.map(Map.Entry::getKey)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// 写入
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public NoteResponse create(Long userId, NoteRequest request) {
|
||||||
|
String content = request.getContent() == null ? "" : request.getContent();
|
||||||
|
|
||||||
|
Note note = new Note();
|
||||||
|
note.setUserId(userId);
|
||||||
|
note.setContent(content);
|
||||||
|
note.setTitle(resolveTitle(request.getTitle(), content, null));
|
||||||
|
note.setSummary(buildSummary(content));
|
||||||
|
note.setTags(normalizeTags(request.getTags()));
|
||||||
|
note.setIsPinned(toPinned(request.getIsPinned()));
|
||||||
|
noteMapper.insert(note);
|
||||||
|
return toResponse(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新笔记 —— 部分更新语义:字段为 null 表示"本次不改这一项"
|
||||||
|
*
|
||||||
|
* <p>这样客户端可以只发 {@code {"isPinned":1}} 来切换置顶,
|
||||||
|
* 而不用担心把正文覆盖成空。要清空某个字段请显式传空串。</p>
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public NoteResponse update(Long userId, Long id, NoteRequest request) {
|
||||||
|
Note note = requireOwnedNote(userId, id);
|
||||||
|
|
||||||
|
if (request.getContent() != null) {
|
||||||
|
note.setContent(request.getContent());
|
||||||
|
note.setSummary(buildSummary(request.getContent()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getTitle() != null) {
|
||||||
|
note.setTitle(resolveTitle(request.getTitle(), note.getContent(), note.getSourcePath()));
|
||||||
|
} else if (request.getContent() != null) {
|
||||||
|
// 正文变了但没显式给标题,且原标题当初是自动推导的,这里跟着刷新
|
||||||
|
note.setTitle(resolveTitle(null, request.getContent(), note.getSourcePath()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getTags() != null) {
|
||||||
|
note.setTags(normalizeTags(request.getTags()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getIsPinned() != null) {
|
||||||
|
note.setIsPinned(toPinned(request.getIsPinned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
noteMapper.updateById(note);
|
||||||
|
return toResponse(note);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void delete(Long userId, Long id) {
|
||||||
|
Note note = requireOwnedNote(userId, id);
|
||||||
|
// 逻辑删除:MyBatis-Plus 会把它变成 UPDATE note SET deleted = 1
|
||||||
|
noteMapper.deleteById(note.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量导入
|
||||||
|
*
|
||||||
|
* @return 实际写入的条数
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int importBatch(Long userId, List<NoteImportRequest.Item> items) {
|
||||||
|
if (items == null || items.isEmpty()) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int count = 0;
|
||||||
|
for (NoteImportRequest.Item item : items) {
|
||||||
|
String content = item.getContent() == null ? "" : item.getContent();
|
||||||
|
|
||||||
|
Note note = new Note();
|
||||||
|
note.setUserId(userId);
|
||||||
|
note.setTitle(resolveTitle(item.getTitle(), content, item.getSourcePath()));
|
||||||
|
note.setContent(content);
|
||||||
|
note.setSummary(buildSummary(content));
|
||||||
|
note.setTags(normalizeTags(item.getTags()));
|
||||||
|
note.setIsPinned(0);
|
||||||
|
note.setSourcePath(trimTo(item.getSourcePath(), 500));
|
||||||
|
noteMapper.insert(note);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================
|
||||||
|
// 内部工具
|
||||||
|
// ==================================================================
|
||||||
|
|
||||||
|
private LambdaQueryWrapper<Note> buildListWrapper(Long userId, String keyword, String tag) {
|
||||||
|
LambdaQueryWrapper<Note> wrapper = new LambdaQueryWrapper<Note>()
|
||||||
|
.eq(Note::getUserId, userId);
|
||||||
|
|
||||||
|
if (StringUtils.hasText(keyword)) {
|
||||||
|
String kw = keyword.trim();
|
||||||
|
wrapper.and(q -> q.like(Note::getTitle, kw).or().like(Note::getContent, kw));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StringUtils.hasText(tag)) {
|
||||||
|
// FIND_IN_SET 是精确匹配单个标签,不会出现搜 "java" 命中 "javascript" 的问题
|
||||||
|
wrapper.apply("FIND_IN_SET({0}, tags) > 0", tag.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出属于该用户的笔记,找不到或不属于该用户都抛异常
|
||||||
|
*/
|
||||||
|
private Note requireOwnedNote(Long userId, Long id) {
|
||||||
|
Note note = noteMapper.selectById(id);
|
||||||
|
if (note == null || !note.getUserId().equals(userId)) {
|
||||||
|
throw new IllegalArgumentException("笔记不存在");
|
||||||
|
}
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int toPinned(Integer value) {
|
||||||
|
return (value == null || value == 0) ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标题解析:优先用显式标题,没有就取正文第一个非空行,再没有就用来源文件名
|
||||||
|
*/
|
||||||
|
private String resolveTitle(String explicitTitle, String content, String fallback) {
|
||||||
|
if (StringUtils.hasText(explicitTitle)) {
|
||||||
|
return trimTo(explicitTitle.trim(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (String rawLine : content.split("\\r?\\n")) {
|
||||||
|
String line = rawLine.replaceAll("^\\s*#{1,6}\\s*", "").trim();
|
||||||
|
if (!line.isEmpty()) {
|
||||||
|
return trimTo(line, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (StringUtils.hasText(fallback)) {
|
||||||
|
String name = fallback.trim();
|
||||||
|
if (name.toLowerCase().endsWith(".md")) {
|
||||||
|
name = name.substring(0, name.length() - 3);
|
||||||
|
}
|
||||||
|
return trimTo(name, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return "无标题笔记";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Markdown 正文提取纯文本摘要
|
||||||
|
*
|
||||||
|
* <p>处理顺序很重要:先删代码块(里面可能有像 Markdown 的符号,
|
||||||
|
* 也最不该进摘要),再删图片(必须在链接之前,否则 
|
||||||
|
* 会先被链接规则吃掉一半留下一个感叹号),最后清理行首标记和强调符号。</p>
|
||||||
|
*/
|
||||||
|
private String buildSummary(String content) {
|
||||||
|
if (!StringUtils.hasText(content)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
String text = RE_CODE_FENCE.matcher(content).replaceAll(" ");
|
||||||
|
text = RE_CODE_FENCE_OPEN.matcher(text).replaceAll(" "); // 未闭合的代码块
|
||||||
|
text = RE_IMAGE.matcher(text).replaceAll("$1");
|
||||||
|
text = RE_LINK.matcher(text).replaceAll("$1");
|
||||||
|
text = RE_HR.matcher(text).replaceAll(" ");
|
||||||
|
text = RE_HEADING.matcher(text).replaceAll("");
|
||||||
|
text = RE_QUOTE.matcher(text).replaceAll("");
|
||||||
|
text = RE_LIST_MARK.matcher(text).replaceAll("");
|
||||||
|
text = RE_EMPHASIS.matcher(text).replaceAll("");
|
||||||
|
text = RE_BLANK.matcher(text).replaceAll(" ").trim();
|
||||||
|
|
||||||
|
if (text.length() <= SUMMARY_MAX_LENGTH) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
return text.substring(0, SUMMARY_MAX_LENGTH) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标签解析,同时支持中英文逗号,去重且保持原有顺序
|
||||||
|
*/
|
||||||
|
private static List<String> parseTags(String tags) {
|
||||||
|
if (!StringUtils.hasText(tags)) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
return Arrays.stream(tags.split("[,,]"))
|
||||||
|
.map(String::trim)
|
||||||
|
.filter(s -> !s.isEmpty())
|
||||||
|
.distinct()
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归一化成存储用的逗号分隔串 */
|
||||||
|
private static String normalizeTags(String tags) {
|
||||||
|
List<String> list = parseTags(tags);
|
||||||
|
return trimTo(String.join(",", list), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimTo(String value, int maxLength) {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.length() <= maxLength ? value : value.substring(0, maxLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
private NoteListItem toListItem(Note note) {
|
||||||
|
NoteListItem item = new NoteListItem();
|
||||||
|
item.setId(note.getId());
|
||||||
|
item.setTitle(note.getTitle());
|
||||||
|
item.setSummary(note.getSummary());
|
||||||
|
item.setTagList(parseTags(note.getTags()));
|
||||||
|
item.setIsPinned(note.getIsPinned());
|
||||||
|
item.setUpdateTime(note.getUpdateTime());
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private NoteResponse toResponse(Note note) {
|
||||||
|
NoteResponse response = new NoteResponse();
|
||||||
|
response.setId(note.getId());
|
||||||
|
response.setTitle(note.getTitle());
|
||||||
|
response.setContent(note.getContent());
|
||||||
|
response.setTagList(parseTags(note.getTags()));
|
||||||
|
response.setIsPinned(note.getIsPinned());
|
||||||
|
response.setSourcePath(note.getSourcePath());
|
||||||
|
response.setCreateTime(note.getCreateTime());
|
||||||
|
response.setUpdateTime(note.getUpdateTime());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
-- ===========================================================================
|
||||||
|
-- 笔记模块建表脚本
|
||||||
|
--
|
||||||
|
-- 执行方式:
|
||||||
|
-- mysql -uroot -p accounting_db < migration_add_note.sql
|
||||||
|
--
|
||||||
|
-- 说明:
|
||||||
|
-- - 本表沿用 schema.sql 的既有约定(utf8mb4_unicode_ci / create_time / user_id 外键)
|
||||||
|
-- - deleted 字段配合 application.yml 里的
|
||||||
|
-- mybatis-plus.global-config.db-config.logic-delete-field=deleted
|
||||||
|
-- 自动实现逻辑删除,业务代码里不需要写 where deleted = 0
|
||||||
|
-- ===========================================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `note` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '笔记ID',
|
||||||
|
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||||
|
`title` VARCHAR(200) NOT NULL COMMENT '标题',
|
||||||
|
`content` LONGTEXT COMMENT 'Markdown 正文',
|
||||||
|
`summary` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '摘要(列表页展示,避免查询正文)',
|
||||||
|
`tags` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标签,逗号分隔',
|
||||||
|
`is_pinned` TINYINT NOT NULL DEFAULT 0 COMMENT '是否置顶:0否 1是',
|
||||||
|
`source_path` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '导入来源文件名(便于追溯)',
|
||||||
|
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_user_updated` (`user_id`, `deleted`, `update_time`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='笔记表';
|
||||||
@@ -123,6 +123,24 @@ CREATE TABLE IF NOT EXISTS `gold_price` (
|
|||||||
INDEX `idx_gold_date` (`gold_id`, `price_date`) COMMENT '索引:用于按日期查询'
|
INDEX `idx_gold_date` (`gold_id`, `price_date`) COMMENT '索引:用于按日期查询'
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='黄金价格记录表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='黄金价格记录表';
|
||||||
|
|
||||||
|
-- 笔记表
|
||||||
|
CREATE TABLE IF NOT EXISTS `note` (
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '笔记ID',
|
||||||
|
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||||
|
`title` VARCHAR(200) NOT NULL COMMENT '标题',
|
||||||
|
`content` LONGTEXT COMMENT 'Markdown正文',
|
||||||
|
`summary` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '摘要(列表页展示,避免查询正文)',
|
||||||
|
`tags` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '标签,逗号分隔',
|
||||||
|
`is_pinned` TINYINT NOT NULL DEFAULT 0 COMMENT '是否置顶:0否 1是',
|
||||||
|
`source_path` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '导入来源文件名',
|
||||||
|
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
|
||||||
|
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
INDEX `idx_user_updated` (`user_id`, `deleted`, `update_time`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='笔记表';
|
||||||
|
|
||||||
-- 插入预设分类(支出)
|
-- 插入预设分类(支出)
|
||||||
INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES
|
INSERT INTO `category` (`user_id`, `name`, `icon`, `type`, `sort_order`) VALUES
|
||||||
(NULL, '餐饮', '🍔', 1, 1),
|
(NULL, '餐饮', '🍔', 1, 1),
|
||||||
|
|||||||
@@ -0,0 +1,650 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>笔记管理</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--brand: #C8956E;
|
||||||
|
--brand-light: #FFF6EC;
|
||||||
|
--text: #2B2B2B;
|
||||||
|
--text-dim: #8A8078;
|
||||||
|
--border: #EAE3DC;
|
||||||
|
--bg: #FAF8F5;
|
||||||
|
--danger: #D9534F;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; background: var(--bg); color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||||
|
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||||
|
font-size: 14px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font-family: inherit; font-size: 14px; cursor: pointer;
|
||||||
|
border: 1px solid var(--border); background: #fff; color: var(--text);
|
||||||
|
padding: 7px 14px; border-radius: 8px; transition: .15s;
|
||||||
|
}
|
||||||
|
button:hover { border-color: var(--brand); color: var(--brand); }
|
||||||
|
button.primary { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||||
|
button.primary:hover { opacity: .88; color: #fff; }
|
||||||
|
button.danger:hover { border-color: var(--danger); color: var(--danger); }
|
||||||
|
input, textarea {
|
||||||
|
font-family: inherit; font-size: 14px; color: var(--text);
|
||||||
|
border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 8px 12px; background: #fff; outline: none; width: 100%;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus { border-color: var(--brand); }
|
||||||
|
|
||||||
|
/* ---------- 登录 ---------- */
|
||||||
|
#login-view {
|
||||||
|
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.login-box {
|
||||||
|
width: 340px; background: #fff; padding: 36px 32px;
|
||||||
|
border-radius: 16px; border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,.05);
|
||||||
|
}
|
||||||
|
.login-box h1 { margin: 0 0 4px; font-size: 20px; }
|
||||||
|
.login-box p.sub { margin: 0 0 24px; color: var(--text-dim); font-size: 13px; }
|
||||||
|
.login-box .field { margin-bottom: 14px; }
|
||||||
|
.login-box .field label { display: block; margin-bottom: 6px; font-size: 13px; color: var(--text-dim); }
|
||||||
|
.login-box button { width: 100%; margin-top: 8px; padding: 10px; }
|
||||||
|
|
||||||
|
/* ---------- 主界面 ---------- */
|
||||||
|
header {
|
||||||
|
background: #fff; border-bottom: 1px solid var(--border);
|
||||||
|
padding: 14px 24px; display: flex; align-items: center; gap: 16px;
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
}
|
||||||
|
header h1 { margin: 0; font-size: 17px; font-weight: 600; white-space: nowrap; }
|
||||||
|
header .search { flex: 1; max-width: 380px; }
|
||||||
|
header .spacer { flex: 1; }
|
||||||
|
header .who { color: var(--text-dim); font-size: 13px; white-space: nowrap; }
|
||||||
|
|
||||||
|
.layout { display: flex; gap: 24px; padding: 24px; align-items: flex-start; max-width: 1200px; margin: 0 auto; }
|
||||||
|
aside.tags {
|
||||||
|
width: 180px; flex-shrink: 0; background: #fff; border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 16px; position: sticky; top: 84px;
|
||||||
|
}
|
||||||
|
aside.tags h3 { margin: 0 0 12px; font-size: 13px; color: var(--text-dim); font-weight: 500; }
|
||||||
|
.tag-chip {
|
||||||
|
display: block; width: 100%; text-align: left; border: none; background: transparent;
|
||||||
|
padding: 6px 10px; border-radius: 6px; margin-bottom: 2px; color: var(--text);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tag-chip:hover { background: var(--brand-light); color: var(--brand); }
|
||||||
|
.tag-chip.active { background: var(--brand); color: #fff; }
|
||||||
|
.tag-chip.active:hover { background: var(--brand); color: #fff; }
|
||||||
|
|
||||||
|
main { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
#dropzone {
|
||||||
|
border: 2px dashed var(--border); border-radius: 12px; padding: 20px;
|
||||||
|
text-align: center; color: var(--text-dim); margin-bottom: 20px;
|
||||||
|
background: #fff; transition: .15s;
|
||||||
|
}
|
||||||
|
#dropzone.over { border-color: var(--brand); background: var(--brand-light); color: var(--brand); }
|
||||||
|
|
||||||
|
.note-card {
|
||||||
|
background: #fff; border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
padding: 16px 18px; margin-bottom: 12px; transition: .15s;
|
||||||
|
}
|
||||||
|
.note-card:hover { border-color: var(--brand); box-shadow: 0 4px 16px rgba(0,0,0,.04); }
|
||||||
|
.note-card .head { display: flex; align-items: baseline; gap: 8px; }
|
||||||
|
.note-card h3 {
|
||||||
|
margin: 0; font-size: 15px; font-weight: 600; flex: 1;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.note-card .pin { color: var(--brand); font-size: 13px; }
|
||||||
|
.note-card .summary {
|
||||||
|
color: var(--text-dim); font-size: 13px; margin: 6px 0 10px;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
}
|
||||||
|
.note-card .meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.note-card .tag {
|
||||||
|
background: var(--brand-light); color: var(--brand);
|
||||||
|
padding: 2px 8px; border-radius: 4px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.note-card .time { color: var(--text-dim); font-size: 12px; margin-left: auto; }
|
||||||
|
.note-card .actions { display: flex; gap: 6px; margin-left: 12px; }
|
||||||
|
.note-card .actions button { padding: 3px 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
.empty { text-align: center; color: var(--text-dim); padding: 60px 0; }
|
||||||
|
#loadmore { width: 100%; margin-top: 4px; padding: 10px; }
|
||||||
|
|
||||||
|
/* ---------- 编辑器 ---------- */
|
||||||
|
#editor {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.4);
|
||||||
|
display: none; align-items: center; justify-content: center; z-index: 100; padding: 24px;
|
||||||
|
}
|
||||||
|
#editor.open { display: flex; }
|
||||||
|
.editor-box {
|
||||||
|
background: #fff; border-radius: 16px; width: 100%; max-width: 1000px;
|
||||||
|
height: 100%; max-height: 760px; display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
}
|
||||||
|
.editor-head {
|
||||||
|
padding: 14px 20px; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; gap: 12px; align-items: center;
|
||||||
|
}
|
||||||
|
.editor-head input.title { flex: 1; font-size: 15px; font-weight: 600; }
|
||||||
|
.editor-head input.tags { width: 220px; }
|
||||||
|
.editor-body { flex: 1; display: flex; min-height: 0; }
|
||||||
|
.editor-body textarea {
|
||||||
|
flex: 1; border: none; border-radius: 0; resize: none;
|
||||||
|
padding: 18px 20px; font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 13px; line-height: 1.7;
|
||||||
|
}
|
||||||
|
.editor-body textarea:focus { border: none; }
|
||||||
|
.editor-body .preview {
|
||||||
|
flex: 1; overflow-y: auto; padding: 18px 20px; border-left: 1px solid var(--border); background: #FCFBF9;
|
||||||
|
}
|
||||||
|
.editor-foot {
|
||||||
|
padding: 12px 20px; border-top: 1px solid var(--border);
|
||||||
|
display: flex; gap: 10px; align-items: center;
|
||||||
|
}
|
||||||
|
.editor-foot .hint { color: var(--text-dim); font-size: 12px; margin-right: auto; }
|
||||||
|
|
||||||
|
/* Markdown 预览样式 */
|
||||||
|
.preview h1, .preview h2, .preview h3 { margin: 18px 0 10px; line-height: 1.3; }
|
||||||
|
.preview h1 { font-size: 22px; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
||||||
|
.preview h2 { font-size: 18px; }
|
||||||
|
.preview h3 { font-size: 15px; }
|
||||||
|
.preview p { margin: 10px 0; }
|
||||||
|
.preview code {
|
||||||
|
background: #F2EEE9; padding: 2px 5px; border-radius: 4px;
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px;
|
||||||
|
}
|
||||||
|
.preview pre {
|
||||||
|
background: #2B2B2B; color: #F0EDEA; padding: 14px; border-radius: 8px; overflow-x: auto;
|
||||||
|
}
|
||||||
|
.preview pre code { background: none; color: inherit; padding: 0; }
|
||||||
|
.preview blockquote {
|
||||||
|
border-left: 3px solid var(--brand); margin: 10px 0; padding: 2px 14px; color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.preview table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 13px; }
|
||||||
|
.preview th, .preview td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; }
|
||||||
|
.preview th { background: var(--brand-light); }
|
||||||
|
.preview img { max-width: 100%; border-radius: 6px; }
|
||||||
|
.preview a { color: var(--brand); }
|
||||||
|
|
||||||
|
#toast {
|
||||||
|
position: fixed; bottom: 28px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: #2B2B2B; color: #fff; padding: 10px 20px; border-radius: 8px;
|
||||||
|
font-size: 13px; opacity: 0; pointer-events: none; transition: .25s; z-index: 999;
|
||||||
|
}
|
||||||
|
#toast.show { opacity: 1; }
|
||||||
|
#toast.err { background: var(--danger); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ==================== 登录 ==================== -->
|
||||||
|
<div id="login-view">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>笔记管理</h1>
|
||||||
|
<p class="sub">使用手机端同一个账号登录</p>
|
||||||
|
<div class="field">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input id="login-username" autocomplete="username" placeholder="用户名">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>密码</label>
|
||||||
|
<input id="login-password" type="password" autocomplete="current-password" placeholder="密码">
|
||||||
|
</div>
|
||||||
|
<button class="primary" id="login-btn">登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== 主界面 ==================== -->
|
||||||
|
<div id="app-view" style="display:none">
|
||||||
|
<header>
|
||||||
|
<h1>笔记管理</h1>
|
||||||
|
<div class="search"><input id="search" placeholder="搜索标题和正文…"></div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span class="who" id="who"></span>
|
||||||
|
<button id="new-btn" class="primary">+ 新建</button>
|
||||||
|
<button id="logout-btn">退出</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="layout">
|
||||||
|
<aside class="tags">
|
||||||
|
<h3>标签</h3>
|
||||||
|
<div id="tag-list"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="dropzone">
|
||||||
|
把 <b>.md</b> 文件拖到这里批量导入,或 <a href="#" id="pick-files">点击选择文件</a>
|
||||||
|
<input type="file" id="file-input" accept=".md,.markdown,.txt" multiple style="display:none">
|
||||||
|
</div>
|
||||||
|
<div id="note-list"></div>
|
||||||
|
<button id="loadmore" style="display:none">加载更多</button>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== 编辑器 ==================== -->
|
||||||
|
<div id="editor">
|
||||||
|
<div class="editor-box">
|
||||||
|
<div class="editor-head">
|
||||||
|
<input class="title" id="ed-title" placeholder="标题(留空则自动取正文首行)">
|
||||||
|
<input class="tags" id="ed-tags" placeholder="标签,逗号分隔">
|
||||||
|
</div>
|
||||||
|
<div class="editor-body">
|
||||||
|
<textarea id="ed-content" placeholder="在这里写 Markdown…"></textarea>
|
||||||
|
<div class="preview" id="ed-preview"></div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-foot">
|
||||||
|
<span class="hint" id="ed-hint"></span>
|
||||||
|
<button id="ed-upload">插入图片</button>
|
||||||
|
<input type="file" id="ed-image" accept="image/*" style="display:none">
|
||||||
|
<button id="ed-cancel">取消</button>
|
||||||
|
<button class="primary" id="ed-save">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var API = '/api';
|
||||||
|
var TOKEN_KEY = 'aq_note_admin_token';
|
||||||
|
var USER_KEY = 'aq_note_admin_user';
|
||||||
|
|
||||||
|
var state = {
|
||||||
|
token: localStorage.getItem(TOKEN_KEY) || '',
|
||||||
|
username: localStorage.getItem(USER_KEY) || '',
|
||||||
|
page: 1,
|
||||||
|
size: 20,
|
||||||
|
keyword: '',
|
||||||
|
tag: '',
|
||||||
|
total: 0,
|
||||||
|
editingId: null
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 工具
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s)
|
||||||
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toast(msg, isError) {
|
||||||
|
var el = $('toast');
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = (isError ? 'err ' : '') + 'show';
|
||||||
|
clearTimeout(el._t);
|
||||||
|
el._t = setTimeout(function () { el.className = ''; }, 2400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(s) {
|
||||||
|
if (!s) return '';
|
||||||
|
return String(s).replace('T', ' ').substring(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一的请求封装,带 token 和 401 处理 */
|
||||||
|
function request(path, options) {
|
||||||
|
options = options || {};
|
||||||
|
var headers = options.headers || {};
|
||||||
|
if (state.token) headers['Authorization'] = 'Bearer ' + state.token;
|
||||||
|
if (options.body && !(options.body instanceof FormData)) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(API + path, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: headers,
|
||||||
|
body: options.body
|
||||||
|
}).then(function (res) {
|
||||||
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
doLogout();
|
||||||
|
throw new Error('登录已过期,请重新登录');
|
||||||
|
}
|
||||||
|
var ct = res.headers.get('content-type') || '';
|
||||||
|
var parsed = ct.indexOf('application/json') >= 0 ? res.json() : res.text();
|
||||||
|
return parsed.then(function (data) {
|
||||||
|
if (!res.ok) {
|
||||||
|
var msg = (data && (data.message || data.error)) || ('请求失败 ' + res.status);
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 登录
|
||||||
|
function showApp() {
|
||||||
|
$('login-view').style.display = 'none';
|
||||||
|
$('app-view').style.display = '';
|
||||||
|
$('who').textContent = state.username ? ('当前:' + state.username) : '';
|
||||||
|
state.page = 1;
|
||||||
|
state.keyword = '';
|
||||||
|
state.tag = '';
|
||||||
|
$('search').value = '';
|
||||||
|
loadTags();
|
||||||
|
loadNotes(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLogout() {
|
||||||
|
state.token = '';
|
||||||
|
state.username = '';
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_KEY);
|
||||||
|
$('app-view').style.display = 'none';
|
||||||
|
$('login-view').style.display = 'flex';
|
||||||
|
$('note-list').innerHTML = '';
|
||||||
|
$('tag-list').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function doLogin() {
|
||||||
|
var username = $('login-username').value.trim();
|
||||||
|
var password = $('login-password').value;
|
||||||
|
if (!username || !password) { toast('请填写用户名和密码', true); return; }
|
||||||
|
|
||||||
|
fetch(API + '/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: username, password: password })
|
||||||
|
}).then(function (res) {
|
||||||
|
return res.json().then(function (data) {
|
||||||
|
if (!res.ok || !data.token) {
|
||||||
|
throw new Error(data.message || data.error || '用户名或密码错误');
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}).then(function (data) {
|
||||||
|
state.token = data.token;
|
||||||
|
state.username = data.username || username;
|
||||||
|
localStorage.setItem(TOKEN_KEY, state.token);
|
||||||
|
localStorage.setItem(USER_KEY, state.username);
|
||||||
|
$('login-password').value = '';
|
||||||
|
showApp();
|
||||||
|
}).catch(function (e) {
|
||||||
|
toast(e.message, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 列表
|
||||||
|
function loadNotes(reset) {
|
||||||
|
if (reset) { state.page = 1; }
|
||||||
|
|
||||||
|
var qs = '?page=' + state.page + '&size=' + state.size;
|
||||||
|
if (state.keyword) qs += '&keyword=' + encodeURIComponent(state.keyword);
|
||||||
|
if (state.tag) qs += '&tag=' + encodeURIComponent(state.tag);
|
||||||
|
|
||||||
|
request('/notes' + qs).then(function (data) {
|
||||||
|
state.total = data.total;
|
||||||
|
renderNotes(data.list, reset);
|
||||||
|
$('loadmore').style.display = data.hasMore ? 'block' : 'none';
|
||||||
|
if (data.list.length) state.page++;
|
||||||
|
}).catch(function (e) { toast(e.message, true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNotes(list, reset) {
|
||||||
|
var box = $('note-list');
|
||||||
|
if (reset) {
|
||||||
|
if (!list.length) {
|
||||||
|
box.innerHTML = '<div class="empty">' +
|
||||||
|
(state.keyword || state.tag ? '没有匹配的笔记' : '还没有笔记,拖个 .md 文件进来试试') +
|
||||||
|
'</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML = '';
|
||||||
|
}
|
||||||
|
list.forEach(function (n) { box.appendChild(noteCard(n)); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function noteCard(n) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'note-card';
|
||||||
|
|
||||||
|
var tags = (n.tagList || []).map(function (t) {
|
||||||
|
return '<span class="tag">' + esc(t) + '</span>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
el.innerHTML =
|
||||||
|
'<div class="head">' +
|
||||||
|
'<h3>' + esc(n.title) + '</h3>' +
|
||||||
|
(n.isPinned ? '<span class="pin">📌</span>' : '') +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="summary">' + esc(n.summary || '(空笔记)') + '</div>' +
|
||||||
|
'<div class="meta">' + tags +
|
||||||
|
'<span class="time">' + esc(formatTime(n.updateTime)) + '</span>' +
|
||||||
|
'<span class="actions">' +
|
||||||
|
'<button data-act="pin">' + (n.isPinned ? '取消置顶' : '置顶') + '</button>' +
|
||||||
|
'<button data-act="edit">编辑</button>' +
|
||||||
|
'<button class="danger" data-act="del">删除</button>' +
|
||||||
|
'</span>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
el.querySelector('[data-act="edit"]').onclick = function () { openEditor(n.id); };
|
||||||
|
el.querySelector('[data-act="del"]').onclick = function () { removeNote(n); };
|
||||||
|
el.querySelector('[data-act="pin"]').onclick = function () { togglePin(n); };
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeNote(n) {
|
||||||
|
if (!confirm('确定删除「' + n.title + '」?')) return;
|
||||||
|
request('/notes/' + n.id, { method: 'DELETE' })
|
||||||
|
.then(function () { toast('已删除'); loadTags(); loadNotes(true); })
|
||||||
|
.catch(function (e) { toast(e.message, true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePin(n) {
|
||||||
|
// 后端 update 是部分更新语义:只发 isPinned,正文不会被覆盖
|
||||||
|
request('/notes/' + n.id, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ isPinned: n.isPinned ? 0 : 1 })
|
||||||
|
}).then(function () { loadNotes(true); })
|
||||||
|
.catch(function (e) { toast(e.message, true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTags() {
|
||||||
|
request('/notes/tags').then(function (tags) {
|
||||||
|
var box = $('tag-list');
|
||||||
|
box.innerHTML = '';
|
||||||
|
var all = document.createElement('button');
|
||||||
|
all.className = 'tag-chip' + (state.tag === '' ? ' active' : '');
|
||||||
|
all.textContent = '全部';
|
||||||
|
all.onclick = function () { state.tag = ''; loadTags(); loadNotes(true); };
|
||||||
|
box.appendChild(all);
|
||||||
|
|
||||||
|
tags.forEach(function (t) {
|
||||||
|
var b = document.createElement('button');
|
||||||
|
b.className = 'tag-chip' + (state.tag === t ? ' active' : '');
|
||||||
|
b.textContent = t;
|
||||||
|
b.title = t;
|
||||||
|
b.onclick = function () { state.tag = t; loadTags(); loadNotes(true); };
|
||||||
|
box.appendChild(b);
|
||||||
|
});
|
||||||
|
}).catch(function () { /* 标签加载失败不影响主流程 */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 编辑器
|
||||||
|
function renderPreview() {
|
||||||
|
var md = $('ed-content').value;
|
||||||
|
var box = $('ed-preview');
|
||||||
|
if (typeof marked === 'undefined') {
|
||||||
|
// CDN 不可用时的降级:原样显示,至少不白屏
|
||||||
|
box.innerHTML = '<pre style="background:none;color:inherit;padding:0">' + esc(md) + '</pre>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = marked.parse(md);
|
||||||
|
box.innerHTML = (typeof DOMPurify !== 'undefined') ? DOMPurify.sanitize(html) : html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor(id) {
|
||||||
|
state.editingId = id || null;
|
||||||
|
$('ed-hint').textContent = id ? '编辑已有笔记' : '新建笔记';
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
$('ed-title').value = '';
|
||||||
|
$('ed-tags').value = '';
|
||||||
|
$('ed-content').value = '';
|
||||||
|
renderPreview();
|
||||||
|
$('editor').classList.add('open');
|
||||||
|
$('ed-title').focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
request('/notes/' + id).then(function (n) {
|
||||||
|
$('ed-title').value = n.title || '';
|
||||||
|
$('ed-tags').value = (n.tagList || []).join(',');
|
||||||
|
$('ed-content').value = n.content || '';
|
||||||
|
renderPreview();
|
||||||
|
$('editor').classList.add('open');
|
||||||
|
}).catch(function (e) { toast(e.message, true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor() {
|
||||||
|
$('editor').classList.remove('open');
|
||||||
|
state.editingId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveNote() {
|
||||||
|
var payload = {
|
||||||
|
title: $('ed-title').value,
|
||||||
|
content: $('ed-content').value,
|
||||||
|
tags: $('ed-tags').value,
|
||||||
|
isPinned: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
var req = state.editingId
|
||||||
|
? request('/notes/' + state.editingId, { method: 'PUT', body: JSON.stringify(payload) })
|
||||||
|
: request('/notes', { method: 'POST', body: JSON.stringify(payload) });
|
||||||
|
|
||||||
|
req.then(function () {
|
||||||
|
toast('已保存');
|
||||||
|
closeEditor();
|
||||||
|
loadTags();
|
||||||
|
loadNotes(true);
|
||||||
|
}).catch(function (e) { toast(e.message, true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 批量导入
|
||||||
|
function importFiles(files) {
|
||||||
|
var list = Array.prototype.slice.call(files).filter(function (f) {
|
||||||
|
return /\.(md|markdown|txt)$/i.test(f.name);
|
||||||
|
});
|
||||||
|
if (!list.length) { toast('没有找到 .md 文件', true); return; }
|
||||||
|
|
||||||
|
Promise.all(list.map(function (f) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function () {
|
||||||
|
var text = String(reader.result || '');
|
||||||
|
var firstLine = '';
|
||||||
|
text.split(/\r?\n/).some(function (line) {
|
||||||
|
var l = line.replace(/^\s*#{1,6}\s*/, '').trim();
|
||||||
|
if (l) { firstLine = l; return true; }
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
resolve({
|
||||||
|
title: firstLine,
|
||||||
|
content: text,
|
||||||
|
sourcePath: f.name,
|
||||||
|
tags: ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reader.readAsText(f, 'utf-8');
|
||||||
|
});
|
||||||
|
})).then(function (notes) {
|
||||||
|
return request('/notes/import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ notes: notes })
|
||||||
|
});
|
||||||
|
}).then(function (res) {
|
||||||
|
toast('成功导入 ' + (res.imported || list.length) + ' 篇笔记');
|
||||||
|
loadTags();
|
||||||
|
loadNotes(true);
|
||||||
|
}).catch(function (e) {
|
||||||
|
toast('导入失败:' + e.message, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 图片上传
|
||||||
|
function uploadImage(file) {
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
request('/notes/attachments', { method: 'POST', body: fd })
|
||||||
|
.then(function (res) {
|
||||||
|
var ta = $('ed-content');
|
||||||
|
var snippet = '';
|
||||||
|
var start = ta.selectionStart || 0;
|
||||||
|
ta.value = ta.value.slice(0, start) + snippet + ta.value.slice(ta.selectionEnd || start);
|
||||||
|
ta.selectionStart = ta.selectionEnd = start + snippet.length;
|
||||||
|
ta.focus();
|
||||||
|
renderPreview();
|
||||||
|
toast('图片已插入');
|
||||||
|
})
|
||||||
|
.catch(function (e) { toast('上传失败:' + e.message, true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 事件绑定
|
||||||
|
$('login-btn').onclick = doLogin;
|
||||||
|
$('login-password').onkeydown = function (e) { if (e.key === 'Enter') doLogin(); };
|
||||||
|
$('login-username').onkeydown = function (e) { if (e.key === 'Enter') $('login-password').focus(); };
|
||||||
|
$('logout-btn').onclick = doLogout;
|
||||||
|
$('new-btn').onclick = function () { openEditor(null); };
|
||||||
|
|
||||||
|
var searchTimer = null;
|
||||||
|
$('search').oninput = function () {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
var v = this.value.trim();
|
||||||
|
searchTimer = setTimeout(function () { state.keyword = v; loadNotes(true); }, 350);
|
||||||
|
};
|
||||||
|
|
||||||
|
$('loadmore').onclick = function () { loadNotes(false); };
|
||||||
|
|
||||||
|
$('pick-files').onclick = function (e) { e.preventDefault(); $('file-input').click(); };
|
||||||
|
$('file-input').onchange = function () { importFiles(this.files); this.value = ''; };
|
||||||
|
|
||||||
|
var dz = $('dropzone');
|
||||||
|
['dragenter', 'dragover'].forEach(function (ev) {
|
||||||
|
dz.addEventListener(ev, function (e) { e.preventDefault(); dz.classList.add('over'); });
|
||||||
|
});
|
||||||
|
['dragleave', 'drop'].forEach(function (ev) {
|
||||||
|
dz.addEventListener(ev, function (e) { e.preventDefault(); dz.classList.remove('over'); });
|
||||||
|
});
|
||||||
|
dz.addEventListener('drop', function (e) {
|
||||||
|
if (e.dataTransfer && e.dataTransfer.files) importFiles(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('ed-content').oninput = renderPreview;
|
||||||
|
$('ed-cancel').onclick = closeEditor;
|
||||||
|
$('ed-save').onclick = saveNote;
|
||||||
|
$('ed-upload').onclick = function () { $('ed-image').click(); };
|
||||||
|
$('ed-image').onchange = function () {
|
||||||
|
if (this.files && this.files[0]) uploadImage(this.files[0]);
|
||||||
|
this.value = '';
|
||||||
|
};
|
||||||
|
$('editor').onclick = function (e) { if (e.target === this) closeEditor(); };
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && $('editor').classList.contains('open')) closeEditor();
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 's' && $('editor').classList.contains('open')) {
|
||||||
|
e.preventDefault(); saveNote();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- 启动
|
||||||
|
if (state.token) {
|
||||||
|
// 用一次轻量请求验证本地 token 还有效
|
||||||
|
request('/notes?page=1&size=1')
|
||||||
|
.then(showApp)
|
||||||
|
.catch(function () { doLogout(); });
|
||||||
|
} else {
|
||||||
|
doLogout();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user