水印相机后端+建表语句
食物营养成分表数据
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
package com.accounting.controller;
|
||||
|
||||
import com.accounting.dto.PhotoResponse;
|
||||
import com.accounting.entity.User;
|
||||
import com.accounting.mapper.UserMapper;
|
||||
import com.accounting.service.PhotoService;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
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.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 照片存档接口(水印相机模块)
|
||||
*
|
||||
* <p>App 拍完照把**原图**传上来存档;水印在 App 本地合成,与这里无关。</p>
|
||||
*
|
||||
* <p>路径不在 SecurityConfig 的白名单里,所以自动走 {@code anyRequest().authenticated()}
|
||||
* —— 需要 JWT,防止变成一个公开的图床。</p>
|
||||
*/
|
||||
@Tag(name = "照片", description = "水印相机模块的照片存档接口")
|
||||
@RestController
|
||||
@RequestMapping("/api/photos")
|
||||
public class PhotoController {
|
||||
|
||||
@Autowired
|
||||
private PhotoService photoService;
|
||||
|
||||
@Autowired
|
||||
private UserMapper userMapper;
|
||||
|
||||
@Operation(summary = "上传照片原图")
|
||||
@PostMapping
|
||||
public ResponseEntity<PhotoResponse> upload(
|
||||
@RequestParam("file") MultipartFile file,
|
||||
@RequestParam(required = false) BigDecimal latitude,
|
||||
@RequestParam(required = false) BigDecimal longitude,
|
||||
@RequestParam(required = false) String address,
|
||||
@RequestParam(required = false) String weather,
|
||||
@RequestParam(required = false) String remark,
|
||||
@RequestParam(required = false) String takenTime,
|
||||
@RequestParam(required = false) String originalName,
|
||||
Authentication authentication) throws Exception {
|
||||
|
||||
Long userId = getUserId(authentication);
|
||||
PhotoResponse response = photoService.create(
|
||||
userId, file, latitude, longitude, address, weather, remark, takenTime, originalName);
|
||||
return ResponseEntity.ok(response);
|
||||
}
|
||||
|
||||
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,39 @@
|
||||
package com.accounting.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 照片响应
|
||||
*
|
||||
* <p>字段给得比较全(不只是上传时用得上的),是为了以后加「我的照片」列表接口时
|
||||
* 能直接复用这个 DTO,不用再建一个几乎一样的类。</p>
|
||||
*/
|
||||
@Data
|
||||
public class PhotoResponse {
|
||||
|
||||
private Long id;
|
||||
|
||||
/** 可直接访问的地址,如 /uploads/photo/2026/09/28/xxx.jpg */
|
||||
private String url;
|
||||
|
||||
private Long fileSize;
|
||||
|
||||
private String originalName;
|
||||
|
||||
private BigDecimal latitude;
|
||||
|
||||
private BigDecimal longitude;
|
||||
|
||||
private String address;
|
||||
|
||||
private String weather;
|
||||
|
||||
private String remark;
|
||||
|
||||
private LocalDateTime takenTime;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 照片(水印相机模块)
|
||||
*
|
||||
* <p>存的是 App 上传的**原图**(未加水印)。水印由 App 本地合成后直接存手机相册,
|
||||
* 服务器这份留作原始素材,以后想换水印样式还能重做。</p>
|
||||
*
|
||||
* <p>deleted 字段与 application.yml 中的
|
||||
* {@code mybatis-plus.global-config.db-config.logic-delete-field=deleted} 对应,
|
||||
* 查询会自动追加 {@code deleted = 0},删除会自动变成 UPDATE。</p>
|
||||
*/
|
||||
@Data
|
||||
@TableName("photo")
|
||||
public class Photo {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
private Long userId;
|
||||
|
||||
/** 相对路径,如 photo/2026/09/28/xxx.jpg(不含 /uploads 前缀) */
|
||||
private String filePath;
|
||||
|
||||
/** 文件大小(字节) */
|
||||
private Long fileSize;
|
||||
|
||||
/** 原始文件名 */
|
||||
private String originalName;
|
||||
|
||||
/** 纬度(WGS84),未授权定位时为空 */
|
||||
private BigDecimal latitude;
|
||||
|
||||
/** 经度(WGS84),未授权定位时为空 */
|
||||
private BigDecimal longitude;
|
||||
|
||||
/** 文字地址(逆地理编码结果) */
|
||||
private String address;
|
||||
|
||||
/** 天气+温度,如「晴 25℃」 */
|
||||
private String weather;
|
||||
|
||||
private String remark;
|
||||
|
||||
/** 拍摄时间(客户端传来的本地时间) */
|
||||
private LocalDateTime takenTime;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
|
||||
private LocalDateTime createTime;
|
||||
|
||||
private LocalDateTime updateTime;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.accounting.mapper;
|
||||
|
||||
import com.accounting.entity.Photo;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface PhotoMapper extends BaseMapper<Photo> {
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package com.accounting.service;
|
||||
|
||||
import com.accounting.dto.PhotoResponse;
|
||||
import com.accounting.entity.Photo;
|
||||
import com.accounting.mapper.PhotoMapper;
|
||||
import com.accounting.util.FileUtil;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
|
||||
/**
|
||||
* 照片服务
|
||||
*
|
||||
* <p>只负责「存原图 + 落一条记录」。水印是 App 本地合成的,服务器这份永远是原图。</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class PhotoService {
|
||||
|
||||
/**
|
||||
* 照片大小上限。
|
||||
*
|
||||
* <p>比笔记附件的 5MB 宽松得多 —— 手机原图 12MP 常见 3~8MB,
|
||||
* 用 5MB 会直接把它们挡在门外。</p>
|
||||
*/
|
||||
private static final long MAX_PHOTO_SIZE = 20 * 1024 * 1024;
|
||||
|
||||
/** 独立子目录,和笔记附件(直接落 yyyy/MM/dd/)分开,方便备份/清理 */
|
||||
private static final String PHOTO_SUB_DIR = "photo";
|
||||
|
||||
private static final int MAX_ADDRESS = 500;
|
||||
private static final int MAX_REMARK = 500;
|
||||
private static final int MAX_ORIGINAL_NAME = 255;
|
||||
|
||||
private static final DateTimeFormatter TAKEN_TIME_FORMAT =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final PhotoMapper photoMapper;
|
||||
private final FileUtil fileUtil;
|
||||
|
||||
/**
|
||||
* 保存一张照片。
|
||||
*
|
||||
* <p>先落盘、再写库。写库失败时把刚存的文件删掉 —— 否则会留下一个
|
||||
* 谁也引用不到的孤儿文件,时间长了把磁盘堆满。</p>
|
||||
*/
|
||||
@Transactional
|
||||
public PhotoResponse create(Long userId,
|
||||
MultipartFile file,
|
||||
BigDecimal latitude,
|
||||
BigDecimal longitude,
|
||||
String address,
|
||||
String weather,
|
||||
String remark,
|
||||
String takenTime,
|
||||
String originalName) throws IOException {
|
||||
|
||||
String relativePath = fileUtil.saveFile(file, MAX_PHOTO_SIZE, PHOTO_SUB_DIR);
|
||||
|
||||
Photo photo = new Photo();
|
||||
photo.setUserId(userId);
|
||||
photo.setFilePath(relativePath);
|
||||
photo.setFileSize(file.getSize());
|
||||
photo.setOriginalName(trimTo(
|
||||
originalName != null ? originalName : file.getOriginalFilename(), MAX_ORIGINAL_NAME));
|
||||
photo.setLatitude(latitude);
|
||||
photo.setLongitude(longitude);
|
||||
photo.setAddress(trimTo(address, MAX_ADDRESS));
|
||||
photo.setWeather(trimTo(weather, 50));
|
||||
photo.setRemark(trimTo(remark, MAX_REMARK));
|
||||
photo.setTakenTime(parseTakenTime(takenTime));
|
||||
|
||||
try {
|
||||
photoMapper.insert(photo);
|
||||
} catch (RuntimeException e) {
|
||||
// 写库失败 → 把刚落的文件删掉,别留孤儿
|
||||
if (!fileUtil.deleteFile(relativePath)) {
|
||||
log.warn("照片写库失败后清理文件也失败,残留: {}", relativePath);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
log.info("照片已保存: userId={}, id={}, size={}B, path={}",
|
||||
userId, photo.getId(), photo.getFileSize(), relativePath);
|
||||
return toResponse(photo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析客户端传来的拍摄时间。
|
||||
*
|
||||
* <p>宽容处理:格式不对就存 null,不让一个时间字段把整次上传搞失败 ——
|
||||
* 照片本身已经存好了,比时间重要得多。</p>
|
||||
*/
|
||||
private LocalDateTime parseTakenTime(String takenTime) {
|
||||
if (takenTime == null || takenTime.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDateTime.parse(takenTime.trim(), TAKEN_TIME_FORMAT);
|
||||
} catch (Exception e) {
|
||||
log.warn("拍摄时间格式无法解析,已忽略: {}", takenTime);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 按数据库列宽截断,避免超长直接把插入打回 */
|
||||
private String trimTo(String value, int max) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.length() <= max ? trimmed : trimmed.substring(0, max);
|
||||
}
|
||||
|
||||
private PhotoResponse toResponse(Photo photo) {
|
||||
PhotoResponse response = new PhotoResponse();
|
||||
response.setId(photo.getId());
|
||||
response.setUrl("/uploads/" + photo.getFilePath());
|
||||
response.setFileSize(photo.getFileSize());
|
||||
response.setOriginalName(photo.getOriginalName());
|
||||
response.setLatitude(photo.getLatitude());
|
||||
response.setLongitude(photo.getLongitude());
|
||||
response.setAddress(photo.getAddress());
|
||||
response.setWeather(photo.getWeather());
|
||||
response.setRemark(photo.getRemark());
|
||||
response.setTakenTime(photo.getTakenTime());
|
||||
response.setCreateTime(photo.getCreateTime());
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -15,28 +15,43 @@ import java.util.UUID;
|
||||
|
||||
@Component
|
||||
public class FileUtil {
|
||||
|
||||
|
||||
@Value("${file.upload.path}")
|
||||
private String uploadPath;
|
||||
|
||||
|
||||
private static final String[] ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp"};
|
||||
private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
|
||||
/**
|
||||
* 保存上传的文件
|
||||
* 保存上传的文件(笔记附件等通用场景)
|
||||
*
|
||||
* <p>上限 5MB,按 yyyy/MM/dd 分目录。行为与拆分前完全一致。</p>
|
||||
*/
|
||||
public String saveFile(MultipartFile file) throws IOException {
|
||||
// 验证文件
|
||||
validateFile(file);
|
||||
|
||||
return saveFile(file, MAX_FILE_SIZE, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存上传的文件(可指定大小上限与子目录)
|
||||
*
|
||||
* <p>拆出这个重载是为了「照片」这种大文件场景:手机原图动辄 3~8MB,用 5MB
|
||||
* 会被直接挡掉;而且照片和笔记附件分开放,方便单独备份/清理。</p>
|
||||
*
|
||||
* @param maxSize 单文件大小上限(字节)
|
||||
* @param subDir 子目录名;null 或空表示直接落在 yyyy/MM/dd 下
|
||||
* @return 相对路径,如 {@code photo/2026/09/28/xxx.jpg}
|
||||
*/
|
||||
public String saveFile(MultipartFile file, long maxSize, String subDir) throws IOException {
|
||||
validateFile(file, maxSize);
|
||||
|
||||
// 创建上传目录(按日期分类)
|
||||
String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||
String fullPath = uploadPath + dateDir;
|
||||
Path path = Paths.get(fullPath);
|
||||
if (!Files.exists(path)) {
|
||||
Files.createDirectories(path);
|
||||
String relativeDir = (subDir == null || subDir.isBlank()) ? dateDir : subDir + "/" + dateDir;
|
||||
Path dir = Paths.get(uploadPath + relativeDir);
|
||||
if (!Files.exists(dir)) {
|
||||
Files.createDirectories(dir);
|
||||
}
|
||||
|
||||
|
||||
// 生成文件名
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
String extension = "";
|
||||
@@ -44,32 +59,31 @@ public class FileUtil {
|
||||
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
|
||||
}
|
||||
String filename = UUID.randomUUID().toString() + extension;
|
||||
|
||||
|
||||
// 保存文件
|
||||
Path filePath = path.resolve(filename);
|
||||
file.transferTo(filePath.toFile());
|
||||
|
||||
file.transferTo(dir.resolve(filename).toFile());
|
||||
|
||||
// 返回相对路径(用于数据库存储)
|
||||
return dateDir + "/" + filename;
|
||||
return relativeDir + "/" + filename;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 验证文件
|
||||
*/
|
||||
private void validateFile(MultipartFile file) {
|
||||
private void validateFile(MultipartFile file, long maxSize) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("文件不能为空");
|
||||
}
|
||||
|
||||
if (file.getSize() > MAX_FILE_SIZE) {
|
||||
throw new IllegalArgumentException("文件大小不能超过5MB");
|
||||
|
||||
if (file.getSize() > maxSize) {
|
||||
throw new IllegalArgumentException("文件大小不能超过" + (maxSize / 1024 / 1024) + "MB");
|
||||
}
|
||||
|
||||
|
||||
String originalFilename = file.getOriginalFilename();
|
||||
if (originalFilename == null) {
|
||||
throw new IllegalArgumentException("文件名不能为空");
|
||||
}
|
||||
|
||||
|
||||
String extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase();
|
||||
boolean allowed = false;
|
||||
for (String allowedExt : ALLOWED_EXTENSIONS) {
|
||||
@@ -78,19 +92,19 @@ public class FileUtil {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!allowed) {
|
||||
throw new IllegalArgumentException("不支持的文件格式,仅支持: " + String.join(", ", ALLOWED_EXTENSIONS));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 获取文件的完整路径
|
||||
*/
|
||||
public String getFullPath(String relativePath) {
|
||||
return uploadPath + relativePath;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
@@ -103,12 +117,3 @@ public class FileUtil {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,10 @@ spring:
|
||||
password: mysql_jP65Fc
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 5MB
|
||||
max-request-size: 10MB
|
||||
# 20MB:手机原图(12MP 常见 3~8MB)要能传上来,5MB 会直接拒。
|
||||
# 各接口自己还有更细的上限校验(见 FileUtil):笔记附件 5MB、照片 20MB。
|
||||
max-file-size: 20MB
|
||||
max-request-size: 30MB
|
||||
jackson:
|
||||
date-format: yyyy-MM-dd HH:mm:ss
|
||||
time-zone: GMT+8
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
-- 食物营养成分表(2026-09-28)
|
||||
--
|
||||
-- 用途:把《中国食物成分表》的查询结果落库,供「饮食 / 营养」相关功能查询。
|
||||
-- 数据来源:中国疾病预防控制中心营养与健康所 · 中国营养学会
|
||||
-- 食物营养成分查询平台 https://nlc.chinanutri.cn/fq/
|
||||
-- 列表接口:POST /fq/FoodInfoQueryAction!queryFoodInfoList.do
|
||||
-- data: categoryOne=&categoryTwo=&foodName=&pageNum=&field=&flag=
|
||||
-- 返回:{ currentPage, totalPages, list: [ [ ...36 个元素... ], ... ] }
|
||||
--
|
||||
-- list 每行是一个数组,下标含义(= 列表页表头序号 + 2):
|
||||
-- [0] 食物ID [1] 图片文件名 [2] 食物名称
|
||||
-- [3] 别名或俗名 [4] 英文名称
|
||||
-- [5] 食部 % [6] 水分 g [7] 能量 kJ
|
||||
-- [8] 蛋白质 g [9] 脂肪 g [10] 胆固醇 mg
|
||||
-- [11] 灰分 g [12] 碳水化合物 g [13] 总膳食纤维 g
|
||||
-- [14] 胡萝卜素 μg [15] 视黄醇 μg [16] α-TE(维生素E) mg
|
||||
-- [17] 硫胺素 mg [18] 核黄素 mg [19] 烟酸 mg
|
||||
-- [20] 维生素C mg [21] 钙 mg [22] 磷 mg
|
||||
-- [23] 钾 mg [24] 钠 mg [25] 镁 mg
|
||||
-- [26] 铁 mg [27] 锌 mg [28] 硒 μg
|
||||
-- [29] 铜 mg [30] 锰 mg [31] 碘 μg
|
||||
-- [32] 饱和脂肪酸 % [33] 单不饱和脂肪酸 % [34] 多不饱和脂肪酸 %
|
||||
-- [35] 脂肪酸合计 %
|
||||
-- (字段名取自列表页 th 的 class:ysq_code_shibu / shuifen / nliang ... ,
|
||||
-- 其中 huifen=灰分、tshhw=碳水化合物、qianwei=总膳食纤维、afte=α-TE)
|
||||
--
|
||||
-- 单位:所有营养素均为「每 100g 可食部」的值,接口返回值自带单位后缀
|
||||
-- (如 "12.7g" / "5.40μg" / "1478kJ" / "100%"),入库时需剥离。
|
||||
-- 空值:接口用空串 "" 表示无数据、用 "—" 表示未检出/未测定,入库统一转 NULL。
|
||||
--
|
||||
-- 分类:站点为两级结构(categoryOne / categoryTwo),首页 20 个大类,
|
||||
-- ID 与名称见 https://nlc.chinanutri.cn/fq/ 的「食物分类」,如
|
||||
-- 1=谷类及制品、30=小麦;12=蔬菜类及制品、45=根菜类。
|
||||
-- 故按子类分页抓取时,把循环里的两个分类 ID / 名称一并写入。
|
||||
--
|
||||
-- 执行方式(在 231 上):
|
||||
-- docker exec -i mysql-8.0.30 mysql --default-character-set=utf8mb4 \
|
||||
-- -uroot -p密码 accounting_db < migration_add_food_nutrition.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `food_nutrition` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`food_id` INT NOT NULL COMMENT '来源食物ID(详情页 /fq/foodinfo/<food_id>.html)',
|
||||
`name` VARCHAR(200) NOT NULL COMMENT '食物名称',
|
||||
`alias` VARCHAR(200) NULL COMMENT '别名或俗名',
|
||||
`english_name` VARCHAR(200) NULL COMMENT '英文名称',
|
||||
`image_path` VARCHAR(300) NULL COMMENT '图片文件名(前缀 upload/images/small/)',
|
||||
|
||||
`category_id` INT NULL COMMENT '一级分类ID(接口 categoryOne),如 1=谷类及制品',
|
||||
`category_name` VARCHAR(50) NULL COMMENT '一级分类名称',
|
||||
`sub_category_id` INT NULL COMMENT '二级分类ID(接口 categoryTwo),如 30=小麦',
|
||||
`sub_category_name` VARCHAR(50) NULL COMMENT '二级分类名称',
|
||||
|
||||
-- ==== 一般营养成分(每 100g 可食部)====
|
||||
`edible_percent` DECIMAL(5,1) NULL COMMENT '食部 %',
|
||||
`water` DECIMAL(6,2) NULL COMMENT '水分 g',
|
||||
`energy_kj` DECIMAL(8,1) NULL COMMENT '能量 kJ',
|
||||
`protein` DECIMAL(6,2) NULL COMMENT '蛋白质 g',
|
||||
`fat` DECIMAL(6,2) NULL COMMENT '脂肪 g',
|
||||
`cholesterol` DECIMAL(8,1) NULL COMMENT '胆固醇 mg',
|
||||
`ash` DECIMAL(6,2) NULL COMMENT '灰分 g',
|
||||
`carbohydrate` DECIMAL(6,2) NULL COMMENT '碳水化合物 g',
|
||||
`dietary_fiber` DECIMAL(6,2) NULL COMMENT '总膳食纤维 g',
|
||||
`carotene` DECIMAL(10,1) NULL COMMENT '胡萝卜素 μg',
|
||||
`retinol` DECIMAL(9,1) NULL COMMENT '视黄醇 μg',
|
||||
`vitamin_e` DECIMAL(7,2) NULL COMMENT 'α-生育酚当量(维生素E)mg',
|
||||
`thiamin` DECIMAL(7,3) NULL COMMENT '硫胺素(维生素B1)mg',
|
||||
`riboflavin` DECIMAL(7,3) NULL COMMENT '核黄素(维生素B2)mg',
|
||||
`niacin` DECIMAL(7,2) NULL COMMENT '烟酸(尼克酸)mg',
|
||||
`vitamin_c` DECIMAL(8,2) NULL COMMENT '维生素C mg',
|
||||
`calcium` DECIMAL(8,1) NULL COMMENT '钙 mg',
|
||||
`phosphorus` DECIMAL(8,1) NULL COMMENT '磷 mg',
|
||||
`potassium` DECIMAL(8,1) NULL COMMENT '钾 mg',
|
||||
`sodium` DECIMAL(9,1) NULL COMMENT '钠 mg(食盐可达 39311)',
|
||||
`magnesium` DECIMAL(8,1) NULL COMMENT '镁 mg',
|
||||
`iron` DECIMAL(8,2) NULL COMMENT '铁 mg',
|
||||
`zinc` DECIMAL(8,2) NULL COMMENT '锌 mg',
|
||||
`selenium` DECIMAL(8,2) NULL COMMENT '硒 μg',
|
||||
`copper` DECIMAL(8,3) NULL COMMENT '铜 mg',
|
||||
`manganese` DECIMAL(8,3) NULL COMMENT '锰 mg',
|
||||
`iodine` DECIMAL(10,1) NULL COMMENT '碘 μg(干海带可达 36240)',
|
||||
|
||||
-- ==== 脂肪酸构成(占总脂肪酸的百分比,SFA+MUFA+PUFA ≈ 合计)====
|
||||
`sfa_percent` DECIMAL(5,1) NULL COMMENT '饱和脂肪酸 SFA %',
|
||||
`mufa_percent` DECIMAL(5,1) NULL COMMENT '单不饱和脂肪酸 MUFA %',
|
||||
`pufa_percent` DECIMAL(5,1) NULL COMMENT '多不饱和脂肪酸 PUFA %',
|
||||
`fa_total_percent` DECIMAL(5,1) NULL COMMENT '脂肪酸合计 %',
|
||||
|
||||
`raw_values` JSON NULL COMMENT '接口原始数组(保留 ""/"—" 等原始形态,便于回溯;不需要可删)',
|
||||
`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`),
|
||||
UNIQUE KEY `uk_food_id` (`food_id`) COMMENT '来源ID唯一,重复抓取用它幂等覆盖',
|
||||
INDEX `idx_category` (`category_id`, `sub_category_id`) COMMENT '按分类浏览',
|
||||
INDEX `idx_name` (`name`) COMMENT '按名称查询'
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='食物营养成分表(每100g可食部,数据来源:中国食物成分表)';
|
||||
|
||||
-- 核对:跑完应该能看到 food_nutrition 表
|
||||
-- SHOW TABLES LIKE 'food_nutrition';
|
||||
@@ -0,0 +1,37 @@
|
||||
-- 照片表(水印相机模块,2026-09-28)
|
||||
--
|
||||
-- 用途:App 拍完照后把**原图**上传到此存档,一条记录对应一个文件。
|
||||
-- 水印是 App 本地合成的,服务器存的始终是未加水印的原图 —— 这样以后
|
||||
-- 想重新生成水印(改了样式/颜色)也还有原始素材。
|
||||
--
|
||||
-- 上传接口:POST /api/photos
|
||||
-- 文件落盘位置:${file.upload.path}/photo/yyyy/MM/dd/<uuid>.<ext>
|
||||
-- 访问地址:/uploads/photo/yyyy/MM/dd/<uuid>.<ext>(走已有的 /uploads/** 静态映射)
|
||||
--
|
||||
-- 执行方式(在 231 上):
|
||||
-- docker exec -i mysql-8.0.30 mysql --default-character-set=utf8mb4 \
|
||||
-- -uroot -p密码 accounting_db < migration_add_photo.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `photo` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '照片ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||
`file_path` VARCHAR(500) NOT NULL COMMENT '相对路径,如 photo/2026/09/28/xxx.jpg',
|
||||
`file_size` BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
|
||||
`original_name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '原始文件名',
|
||||
`latitude` DECIMAL(10,7) NULL COMMENT '纬度(WGS84),未授权定位时为空',
|
||||
`longitude` DECIMAL(10,7) NULL COMMENT '经度(WGS84),未授权定位时为空',
|
||||
`address` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '文字地址(逆地理编码结果)',
|
||||
`weather` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '天气+温度,如「晴 25℃」',
|
||||
`remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
|
||||
`taken_time` DATETIME NULL 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_created` (`user_id`, `deleted`, `create_time`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='照片表';
|
||||
|
||||
-- 核对:跑完应该能看到 photo 表
|
||||
-- SHOW TABLES LIKE 'photo';
|
||||
@@ -208,3 +208,82 @@ CREATE TABLE IF NOT EXISTS `chat_message` (
|
||||
INDEX `idx_session_created` (`session_id`, `deleted`, `create_time`),
|
||||
FOREIGN KEY (`session_id`) REFERENCES `chat_session` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI消息表';
|
||||
|
||||
-- 照片表(水印相机模块)
|
||||
-- 存 App 上传的**原图**(未加水印);水印由 App 本地合成后直接存手机相册。
|
||||
-- 文件落在 ${file.upload.path}/photo/yyyy/MM/dd/,通过 /uploads/** 访问。
|
||||
CREATE TABLE IF NOT EXISTS `photo` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '照片ID',
|
||||
`user_id` BIGINT NOT NULL COMMENT '用户ID',
|
||||
`file_path` VARCHAR(500) NOT NULL COMMENT '相对路径,如 photo/2026/09/28/xxx.jpg',
|
||||
`file_size` BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
|
||||
`original_name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '原始文件名',
|
||||
`latitude` DECIMAL(10,7) NULL COMMENT '纬度(WGS84),未授权定位时为空',
|
||||
`longitude` DECIMAL(10,7) NULL COMMENT '经度(WGS84),未授权定位时为空',
|
||||
`address` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '文字地址(逆地理编码结果)',
|
||||
`weather` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '天气+温度,如「晴 25℃」',
|
||||
`remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
|
||||
`taken_time` DATETIME NULL 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_created` (`user_id`, `deleted`, `create_time`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='照片表';
|
||||
|
||||
-- 食物营养成分表(数据来源:中国食物成分表 · https://nlc.chinanutri.cn/fq/ )
|
||||
-- 与业务数据无关,是全局参考表,所以没有 user_id / 逻辑删除。
|
||||
-- 字段下标与接口数组的对应关系见 migration_add_food_nutrition.sql 头部注释。
|
||||
CREATE TABLE IF NOT EXISTS `food_nutrition` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`food_id` INT NOT NULL COMMENT '来源食物ID(详情页 /fq/foodinfo/<food_id>.html)',
|
||||
`name` VARCHAR(200) NOT NULL COMMENT '食物名称',
|
||||
`alias` VARCHAR(200) NULL COMMENT '别名或俗名',
|
||||
`english_name` VARCHAR(200) NULL COMMENT '英文名称',
|
||||
`image_path` VARCHAR(300) NULL COMMENT '图片文件名(前缀 upload/images/small/)',
|
||||
`category_id` INT NULL COMMENT '一级分类ID(接口 categoryOne),如 1=谷类及制品',
|
||||
`category_name` VARCHAR(50) NULL COMMENT '一级分类名称',
|
||||
`sub_category_id` INT NULL COMMENT '二级分类ID(接口 categoryTwo),如 30=小麦',
|
||||
`sub_category_name` VARCHAR(50) NULL COMMENT '二级分类名称',
|
||||
-- 一般营养成分(每 100g 可食部)
|
||||
`edible_percent` DECIMAL(5,1) NULL COMMENT '食部 %',
|
||||
`water` DECIMAL(6,2) NULL COMMENT '水分 g',
|
||||
`energy_kj` DECIMAL(8,1) NULL COMMENT '能量 kJ',
|
||||
`protein` DECIMAL(6,2) NULL COMMENT '蛋白质 g',
|
||||
`fat` DECIMAL(6,2) NULL COMMENT '脂肪 g',
|
||||
`cholesterol` DECIMAL(8,1) NULL COMMENT '胆固醇 mg',
|
||||
`ash` DECIMAL(6,2) NULL COMMENT '灰分 g',
|
||||
`carbohydrate` DECIMAL(6,2) NULL COMMENT '碳水化合物 g',
|
||||
`dietary_fiber` DECIMAL(6,2) NULL COMMENT '总膳食纤维 g',
|
||||
`carotene` DECIMAL(10,1) NULL COMMENT '胡萝卜素 μg',
|
||||
`retinol` DECIMAL(9,1) NULL COMMENT '视黄醇 μg',
|
||||
`vitamin_e` DECIMAL(7,2) NULL COMMENT 'α-生育酚当量(维生素E)mg',
|
||||
`thiamin` DECIMAL(7,3) NULL COMMENT '硫胺素(维生素B1)mg',
|
||||
`riboflavin` DECIMAL(7,3) NULL COMMENT '核黄素(维生素B2)mg',
|
||||
`niacin` DECIMAL(7,2) NULL COMMENT '烟酸(尼克酸)mg',
|
||||
`vitamin_c` DECIMAL(8,2) NULL COMMENT '维生素C mg',
|
||||
`calcium` DECIMAL(8,1) NULL COMMENT '钙 mg',
|
||||
`phosphorus` DECIMAL(8,1) NULL COMMENT '磷 mg',
|
||||
`potassium` DECIMAL(8,1) NULL COMMENT '钾 mg',
|
||||
`sodium` DECIMAL(9,1) NULL COMMENT '钠 mg(食盐可达 39311)',
|
||||
`magnesium` DECIMAL(8,1) NULL COMMENT '镁 mg',
|
||||
`iron` DECIMAL(8,2) NULL COMMENT '铁 mg',
|
||||
`zinc` DECIMAL(8,2) NULL COMMENT '锌 mg',
|
||||
`selenium` DECIMAL(8,2) NULL COMMENT '硒 μg',
|
||||
`copper` DECIMAL(8,3) NULL COMMENT '铜 mg',
|
||||
`manganese` DECIMAL(8,3) NULL COMMENT '锰 mg',
|
||||
`iodine` DECIMAL(10,1) NULL COMMENT '碘 μg(干海带可达 36240)',
|
||||
-- 脂肪酸构成(占总脂肪酸的百分比)
|
||||
`sfa_percent` DECIMAL(5,1) NULL COMMENT '饱和脂肪酸 SFA %',
|
||||
`mufa_percent` DECIMAL(5,1) NULL COMMENT '单不饱和脂肪酸 MUFA %',
|
||||
`pufa_percent` DECIMAL(5,1) NULL COMMENT '多不饱和脂肪酸 PUFA %',
|
||||
`fa_total_percent` DECIMAL(5,1) NULL COMMENT '脂肪酸合计 %',
|
||||
`raw_values` JSON NULL COMMENT '接口原始数组(保留 ""/"—" 等原始形态)',
|
||||
`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`),
|
||||
UNIQUE KEY `uk_food_id` (`food_id`),
|
||||
INDEX `idx_category` (`category_id`, `sub_category_id`),
|
||||
INDEX `idx_name` (`name`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='食物营养成分表(每100g可食部,数据来源:中国食物成分表)';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>强宝小助手</title>
|
||||
<link rel="stylesheet" href="/admin/vendor/vditor/dist/index.css">
|
||||
<link rel="stylesheet" href="/admin/admin.css">
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>强宝小助手</title>
|
||||
<link rel="stylesheet" href="/admin/vendor/vditor/dist/index.css">
|
||||
<link rel="stylesheet" href="/admin/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -15,41 +15,42 @@
|
||||
-->
|
||||
<div id="login-view">
|
||||
|
||||
<!-- 背景装饰圆球(手机端是带缓动的,这里用 CSS 动画近似) -->
|
||||
<div class="login-balls" aria-hidden="true">
|
||||
<span class="ball b1"></span>
|
||||
<span class="ball b2"></span>
|
||||
<span class="ball b3"></span>
|
||||
</div>
|
||||
|
||||
<div class="login-wrap">
|
||||
|
||||
<div class="login-header">
|
||||
<div class="login-logo">
|
||||
<img src="/admin/logo.png" alt="强宝小助手">
|
||||
</div>
|
||||
<h1 class="login-title">
|
||||
<i class="deco"></i><span>强宝小助手</span><i class="deco"></i>
|
||||
</h1>
|
||||
<p class="login-sub">智能记账,轻松管理</p>
|
||||
<!-- 背景装饰圆球(手机端是带缓动的,这里用 CSS 动画近似) -->
|
||||
<div class="login-balls" aria-hidden="true">
|
||||
<span class="ball b1"></span>
|
||||
<span class="ball b2"></span>
|
||||
<span class="ball b3"></span>
|
||||
</div>
|
||||
|
||||
<div class="login-box">
|
||||
<div class="login-field">
|
||||
<span class="ico"><img class="ci" src="/admin/icons/user.svg" width="18" height="18" alt=""></span>
|
||||
<input id="login-username" autocomplete="username" maxlength="20" placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="login-field">
|
||||
<span class="ico"><img class="ci" src="/admin/icons/lock.svg" width="18" height="18" alt=""></span>
|
||||
<input id="login-password" type="password" autocomplete="current-password" maxlength="20" placeholder="请输入密码">
|
||||
<button type="button" class="eye" id="login-eye" title="显示密码">
|
||||
<img class="ci" src="/admin/icons/eye.svg" width="18" height="18" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<button class="login-btn" id="login-btn">登 录</button>
|
||||
</div>
|
||||
<div class="login-wrap">
|
||||
|
||||
</div>
|
||||
<div class="login-header">
|
||||
<div class="login-logo">
|
||||
<img src="/admin/logo.png" alt="强宝小助手">
|
||||
</div>
|
||||
<h1 class="login-title">
|
||||
<i class="deco"></i><span>强宝小助手</span><i class="deco"></i>
|
||||
</h1>
|
||||
<p class="login-sub">智能记账,轻松管理</p>
|
||||
</div>
|
||||
|
||||
<div class="login-box">
|
||||
<div class="login-field">
|
||||
<span class="ico"><img class="ci" src="/admin/icons/user.svg" width="18" height="18" alt=""></span>
|
||||
<input id="login-username" autocomplete="username" maxlength="20" placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="login-field">
|
||||
<span class="ico"><img class="ci" src="/admin/icons/lock.svg" width="18" height="18" alt=""></span>
|
||||
<input id="login-password" type="password" autocomplete="current-password" maxlength="20"
|
||||
placeholder="请输入密码">
|
||||
<button type="button" class="eye" id="login-eye" title="显示密码">
|
||||
<img class="ci" src="/admin/icons/eye.svg" width="18" height="18" alt="">
|
||||
</button>
|
||||
</div>
|
||||
<button class="login-btn" id="login-btn">登 录</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 主界面 ====================
|
||||
@@ -60,178 +61,182 @@
|
||||
-->
|
||||
<div id="app-view" style="display:none">
|
||||
|
||||
<header class="topbar">
|
||||
<div class="brand"><img class="brand-logo" src="/admin/logo.png" alt="">强宝小助手</div>
|
||||
<div class="spacer"></div>
|
||||
<span class="who" id="who"></span>
|
||||
<button id="logout-btn">退出</button>
|
||||
</header>
|
||||
|
||||
<div class="console">
|
||||
|
||||
<nav class="module-nav">
|
||||
<button class="module-item active" data-module="notes">
|
||||
<span class="mi-icon"><img class="ci" src="/admin/icons/note.svg" width="18" height="18" alt=""></span>
|
||||
<span class="mi-text">笔记管理</span>
|
||||
</button>
|
||||
<button class="module-item" data-module="finance">
|
||||
<span class="mi-icon"><img class="ci" src="/admin/icons/bill.svg" width="18" height="18" alt=""></span>
|
||||
<span class="mi-text">财务管理</span>
|
||||
</button>
|
||||
<button class="module-item" data-module="ai">
|
||||
<span class="mi-icon"><img class="ci" src="/admin/icons/assistant.svg" width="18" height="18" alt=""></span>
|
||||
<span class="mi-text">AI 助手</span>
|
||||
</button>
|
||||
<!-- 以后的模块(数据看板…)往这里加:这里加一个 button,
|
||||
下面加一个 data-module-panel 的 section,再在 admin.js 的
|
||||
switchModule 里按需做初始化就行 -->
|
||||
</nav>
|
||||
|
||||
<section class="module-panel" data-module-panel="notes">
|
||||
|
||||
<div class="toolbar">
|
||||
<input id="search" placeholder="搜索标题和正文…">
|
||||
<select id="scope" title="搜索范围">
|
||||
<option value="current" selected>当前目录</option>
|
||||
<option value="all">全部笔记</option>
|
||||
</select>
|
||||
<select id="sort" title="排序方式">
|
||||
<option value="updated">最近更新</option>
|
||||
<option value="created">创建时间</option>
|
||||
<option value="title">标题</option>
|
||||
</select>
|
||||
<header class="topbar">
|
||||
<div class="brand"><img class="brand-logo" src="/admin/logo.png" alt="">强宝小助手</div>
|
||||
<div class="spacer"></div>
|
||||
<button id="new-folder-btn">+ 新建目录</button>
|
||||
<button id="new-btn" class="primary">+ 新建笔记</button>
|
||||
</div>
|
||||
<span class="who" id="who"></span>
|
||||
<button id="logout-btn">退出</button>
|
||||
</header>
|
||||
|
||||
<div class="module-body">
|
||||
<div class="console">
|
||||
|
||||
<aside class="sidebar">
|
||||
<div id="folder-tree"></div>
|
||||
<div class="side-divider"></div>
|
||||
<div class="side-title">标签</div>
|
||||
<div id="tag-list"></div>
|
||||
</aside>
|
||||
<nav class="module-nav">
|
||||
<button class="module-item active" data-module="notes">
|
||||
<span class="mi-icon"><img class="ci" src="/admin/icons/note.svg" width="18" height="18" alt=""></span>
|
||||
<span class="mi-text">笔记管理</span>
|
||||
</button>
|
||||
<button class="module-item" data-module="finance">
|
||||
<span class="mi-icon"><img class="ci" src="/admin/icons/bill.svg" width="18" height="18" alt=""></span>
|
||||
<span class="mi-text">财务管理</span>
|
||||
</button>
|
||||
<button class="module-item" data-module="ai">
|
||||
<span class="mi-icon"><img class="ci" src="/admin/icons/assistant.svg" width="18" height="18"
|
||||
alt=""></span>
|
||||
<span class="mi-text">AI 助手</span>
|
||||
</button>
|
||||
<!-- 以后的模块(数据看板…)往这里加:这里加一个 button,
|
||||
下面加一个 data-module-panel 的 section,再在 admin.js 的
|
||||
switchModule 里按需做初始化就行 -->
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
<div class="breadcrumb" id="breadcrumb"></div>
|
||||
<section class="module-panel" data-module-panel="notes">
|
||||
|
||||
<div id="dropzone">
|
||||
把 <b>.md</b> 文件拖到这里批量导入<span id="dropzone-hint"></span>,或 <a href="#" id="pick-files">点击选择文件</a>
|
||||
<input type="file" id="file-input" accept=".md,.markdown,.txt" multiple style="display:none">
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<input id="search" placeholder="搜索标题和正文…">
|
||||
<select id="scope" title="搜索范围">
|
||||
<option value="current" selected>当前目录</option>
|
||||
<option value="all">全部笔记</option>
|
||||
</select>
|
||||
<select id="sort" title="排序方式">
|
||||
<option value="updated">最近更新</option>
|
||||
<option value="created">创建时间</option>
|
||||
<option value="title">标题</option>
|
||||
</select>
|
||||
<div class="spacer"></div>
|
||||
<button id="new-folder-btn">+ 新建目录</button>
|
||||
<button id="new-btn" class="primary">+ 新建笔记</button>
|
||||
</div>
|
||||
|
||||
<div id="note-list"></div>
|
||||
<button id="loadmore" style="display:none">加载更多</button>
|
||||
</main>
|
||||
<div class="module-body">
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<aside class="sidebar">
|
||||
<div id="folder-tree"></div>
|
||||
<div class="side-divider"></div>
|
||||
<div class="side-title">标签</div>
|
||||
<div id="tag-list"></div>
|
||||
</aside>
|
||||
|
||||
<!-- ==================== 财务管理模块 ====================
|
||||
纯展示:月度汇总 / 预算进度 / 分类占比 / 账单明细。
|
||||
记账和改账在 App 上做,这里只读 —— 数据全部来自现成接口
|
||||
(/statistics/monthly、/bills、/budget、/account)。 -->
|
||||
<section class="module-panel" data-module-panel="finance" style="display:none">
|
||||
<main class="content">
|
||||
<div class="breadcrumb" id="breadcrumb"></div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="icon-btn" id="fin-prev" title="上一月">‹</button>
|
||||
<span class="fin-month" id="fin-month">—</span>
|
||||
<button class="icon-btn" id="fin-next" title="下一月">›</button>
|
||||
<div class="toolbar-sep"></div>
|
||||
<select id="fin-type" title="按类型筛选">
|
||||
<option value="">全部类型</option>
|
||||
<option value="1">只看支出</option>
|
||||
<option value="2">只看收入</option>
|
||||
</select>
|
||||
<select id="fin-cat" title="按分类筛选"><option value="">全部分类</option></select>
|
||||
<div class="spacer"></div>
|
||||
<span class="toolbar-note" id="fin-note"></span>
|
||||
</div>
|
||||
<div id="dropzone">
|
||||
把 <b>.md</b> 文件拖到这里批量导入<span id="dropzone-hint"></span>,或 <a href="#"
|
||||
id="pick-files">点击选择文件</a>
|
||||
<input type="file" id="file-input" accept=".md,.markdown,.txt" multiple style="display:none">
|
||||
</div>
|
||||
|
||||
<div class="module-body">
|
||||
<div id="note-list"></div>
|
||||
<button id="loadmore" style="display:none">加载更多</button>
|
||||
</main>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="side-title">本月概览</div>
|
||||
<div id="fin-summary"></div>
|
||||
<div class="side-divider"></div>
|
||||
<div class="side-title">分类(点一下筛选)</div>
|
||||
<div id="fin-cats"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="content">
|
||||
<div id="fin-bills"></div>
|
||||
</main>
|
||||
<!-- ==================== 财务管理模块 ====================
|
||||
纯展示:月度汇总 / 预算进度 / 分类占比 / 账单明细。
|
||||
记账和改账在 App 上做,这里只读 —— 数据全部来自现成接口
|
||||
(/statistics/monthly、/bills、/budget、/account)。 -->
|
||||
<section class="module-panel" data-module-panel="finance" style="display:none">
|
||||
|
||||
</div>
|
||||
</section>
|
||||
<div class="toolbar">
|
||||
<button class="icon-btn" id="fin-prev" title="上一月">‹</button>
|
||||
<span class="fin-month" id="fin-month">—</span>
|
||||
<button class="icon-btn" id="fin-next" title="下一月">›</button>
|
||||
<div class="toolbar-sep"></div>
|
||||
<select id="fin-type" title="按类型筛选">
|
||||
<option value="">全部类型</option>
|
||||
<option value="1">只看支出</option>
|
||||
<option value="2">只看收入</option>
|
||||
</select>
|
||||
<select id="fin-cat" title="按分类筛选">
|
||||
<option value="">全部分类</option>
|
||||
</select>
|
||||
<div class="spacer"></div>
|
||||
<span class="toolbar-note" id="fin-note"></span>
|
||||
</div>
|
||||
|
||||
<!-- ==================== AI 助手模块 ====================
|
||||
和 App 端共用同一套后端接口(/api/ai/**),历史存在服务端的
|
||||
chat_session / chat_message 里 —— 所以两边看到的会话是同一批。 -->
|
||||
<section class="module-panel" data-module-panel="ai" style="display:none">
|
||||
<div class="module-body">
|
||||
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-title">AI 助手</span>
|
||||
<span class="toolbar-note" id="ai-hint"></span>
|
||||
<div class="spacer"></div>
|
||||
<button id="ai-new">+ 新对话</button>
|
||||
</div>
|
||||
<aside class="sidebar">
|
||||
<div class="side-title">本月概览</div>
|
||||
<div id="fin-summary"></div>
|
||||
<div class="side-divider"></div>
|
||||
<div class="side-title">分类(点一下筛选)</div>
|
||||
<div id="fin-cats"></div>
|
||||
</aside>
|
||||
|
||||
<div class="module-body">
|
||||
<main class="content">
|
||||
<div id="fin-bills"></div>
|
||||
</main>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="side-title">历史对话</div>
|
||||
<div id="ai-session-list"></div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="ai-main">
|
||||
<div id="ai-messages" class="ai-messages"></div>
|
||||
<!-- 工具调用提示(「正在查账单…」),没有在查东西时整行隐藏 -->
|
||||
<div class="ai-status" id="ai-status" style="display:none"></div>
|
||||
<div class="ai-composer">
|
||||
<!-- ==================== AI 助手模块 ====================
|
||||
和 App 端共用同一套后端接口(/api/ai/**),历史存在服务端的
|
||||
chat_session / chat_message 里 —— 所以两边看到的会话是同一批。 -->
|
||||
<section class="module-panel" data-module-panel="ai" style="display:none">
|
||||
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-title">AI 助手</span>
|
||||
<span class="toolbar-note" id="ai-hint"></span>
|
||||
<div class="spacer"></div>
|
||||
<button id="ai-new">+ 新对话</button>
|
||||
</div>
|
||||
|
||||
<div class="module-body">
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="side-title">历史对话</div>
|
||||
<div id="ai-session-list"></div>
|
||||
</aside>
|
||||
|
||||
<main class="ai-main">
|
||||
<div id="ai-messages" class="ai-messages"></div>
|
||||
<!-- 工具调用提示(「正在查账单…」),没有在查东西时整行隐藏 -->
|
||||
<div class="ai-status" id="ai-status" style="display:none"></div>
|
||||
<div class="ai-composer">
|
||||
<textarea id="ai-input" rows="1"
|
||||
placeholder="问点什么,或者说一句…(Enter 发送,Shift+Enter 换行)"></textarea>
|
||||
<button class="primary" id="ai-send">发送</button>
|
||||
</div>
|
||||
</main>
|
||||
<button class="primary" id="ai-send">发送</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</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 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">
|
||||
<div id="vditor"></div>
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<span class="hint" id="ed-hint"></span>
|
||||
<button id="ed-cancel">取消</button>
|
||||
<button class="primary" id="ed-save">保存 (Ctrl+S)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<div id="vditor"></div>
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<span class="hint" id="ed-hint"></span>
|
||||
<button id="ed-cancel">取消</button>
|
||||
<button class="primary" id="ed-save">保存 (Ctrl+S)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 目录名输入 / 目录选择弹窗 ==================== -->
|
||||
<div id="dialog">
|
||||
<div class="dialog-box">
|
||||
<h3 id="dialog-title">新建目录</h3>
|
||||
<input id="dialog-input" placeholder="目录名">
|
||||
<select id="dialog-select" style="display:none"></select>
|
||||
<div class="dialog-foot">
|
||||
<button id="dialog-cancel">取消</button>
|
||||
<button class="primary" id="dialog-ok">确定</button>
|
||||
<div class="dialog-box">
|
||||
<h3 id="dialog-title">新建目录</h3>
|
||||
<input id="dialog-input" placeholder="目录名">
|
||||
<select id="dialog-select" style="display:none"></select>
|
||||
<div class="dialog-foot">
|
||||
<button id="dialog-cancel">取消</button>
|
||||
<button class="primary" id="dialog-ok">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
Reference in New Issue
Block a user