Compare commits

...

213 Commits

Author SHA1 Message Date
甲辰生产 a9a68de436 v1.2.98 - 同步所有文件版本号与VERSION.json一致 2026-04-19 21:58:28 +08:00
甲辰生产 36b467895c v1.2.98 - 完善版本管理体系:代码文件添加版本注释 2026-04-19 21:34:14 +08:00
甲辰生产 591db03ee1 v1.2.98 - 分离前后端版本文件,独立管理 2026-04-19 21:17:06 +08:00
甲辰生产 fb987dc5b9 v1.2.98 - 修复前端构建版本号不更新问题 2026-04-19 21:11:49 +08:00
甲辰生产 d578289c7c v1.2.98 - 更新版本号 2026-04-19 21:10:11 +08:00
甲辰生产 b0f28466c8 v1.2.98 - 添加前端版本文件 2026-04-19 21:09:50 +08:00
甲辰生产 57e51f6c28 v1.2.98 - 修复管理员用户列表行情数显示问题,改为显示实际发布的行情数量 2026-04-19 20:07:24 +08:00
甲辰生产 524601afc3 fix: 一尘看板按最新帖子发表时间排序 - 使用COALESCE(post_time, crawled_at) 2026-04-17 15:13:50 +08:00
甲辰生产 58c2291e83 fix: 修复统计页面无数据问题 - base_filter判断逻辑修正 2026-04-17 14:19:07 +08:00
甲辰生产 4ae29eb4bf v1.2.97 - 完整同步 2026-04-15 17:48:30 +08:00
甲辰生产 257ca40079 v1.2.97 - 修复行情录入接口、优化权限控制 2026-04-15 17:48:08 +08:00
甲辰生产 aec6ef7c79 feat: 表拆分seek_info和deal_info,优化显示和分页 2026-04-14 23:47:16 +08:00
甲辰生产 ea6528b7f3 v1.2.93 - 成交行情列表优化、行情编号格式调整 2026-04-14 16:56:29 +08:00
甲辰生产 e381427138 fix: 行情编号改为纯数字格式(如2404140001) 2026-04-14 16:34:39 +08:00
甲辰生产 0ffc10ad9a fix: 行情编号改为年份后两位+月日+4位序号(如DJ2404140001) 2026-04-14 16:19:56 +08:00
甲辰生产 c1f3b9bd3c fix: remove invalid category field check 2026-04-14 15:35:51 +08:00
甲辰生产 d2c4d5bf5f fix: handle Optional[User] in update_information 2026-04-14 15:31:02 +08:00
甲辰生产 38f1da12b5 fix: handle None f99_90_id in update_information 2026-04-14 15:28:32 +08:00
甲辰生产 2204e3a2af fix: InformationUpdate content field make optional 2026-04-14 15:24:10 +08:00
甲辰生产 e45899db0c v1.2.92 - 成交行情页面显示500条数据 2026-04-14 08:39:46 +08:00
甲辰生产 9fe3f77d45 v1.2.91 - 修复单条录入冠字号生成规则(7位→J0+0+数字,8位→J0+数字,9位→J0+后8位) 2026-04-14 08:02:45 +08:00
甲辰生产 2306e35dec v1.2.90 - 修复行情编辑保存、编号格式、默认值等问题 2026-04-13 09:08:19 +08:00
甲辰生产 5eb234443b 版本更新至v1.2.82 2026-04-11 22:39:38 +08:00
甲辰生产 6d7ba6e097 成交详情列表按价格排序 2026-04-11 22:32:38 +08:00
甲辰生产 161ff51b72 修改成交详情列表展示格式 2026-04-11 22:28:14 +08:00
甲辰生产 64eb4b921c 修复评级分数显示 2026-04-11 22:24:44 +08:00
甲辰生产 238383d3d8 统一分类排序和名称标准化 2026-04-11 22:21:22 +08:00
甲辰生产 2b1044ddb2 成交行情列表按分类排序 2026-04-11 22:18:07 +08:00
甲辰生产 e1bed251fa 删除统计卡片中的日期选择框 2026-04-11 22:12:50 +08:00
甲辰生产 9faa2d8069 修复日期过滤使用正确的date类型 2026-04-11 22:06:11 +08:00
甲辰生产 810a36d757 修复日期过滤 2026-04-11 22:00:02 +08:00
甲辰生产 4f6de4d3ac 成交行情按日期获取全部数据 2026-04-11 21:53:23 +08:00
甲辰生产 afa217e1b5 API支持deal_date参数过滤 2026-04-11 21:43:45 +08:00
甲辰生产 73de6a1f85 修改成交行情列表展示格式 2026-04-11 21:34:43 +08:00
甲辰生产 e8ac05decd 修复冠字号显示 2026-04-11 21:26:34 +08:00
甲辰生产 5484d12557 点击价格显示详情列表 2026-04-11 21:21:22 +08:00
甲辰生产 dced540eb8 修复分类名称映射 2026-04-11 21:11:19 +08:00
甲辰生产 926c12c787 修复包装分类匹配 2026-04-11 21:02:02 +08:00
甲辰生产 36527d033f 全局统计、按价格排序 2026-04-11 20:47:15 +08:00
甲辰生产 615be9805b 修复useState位置 2026-04-11 20:35:44 +08:00
甲辰生产 0093367452 修复日期选择器 2026-04-11 20:29:20 +08:00
甲辰生产 d5180d01b6 成交行情价格统计表格 2026-04-11 20:13:26 +08:00
甲辰生产 285929024b 成交行情添加统计区域 2026-04-11 19:50:41 +08:00
甲辰生产 55f0c9aa23 成交行情页面按维度展示 2026-04-11 19:47:22 +08:00
甲辰生产 39e5d44604 添加user_id参数支持我的行情权限过滤 2026-04-11 19:00:38 +08:00
甲辰生产 12c35e39db 恢复我的行情页面搜索框 2026-04-11 18:49:37 +08:00
甲辰生产 f038a397cc v1.2.84 - 行情录入完整版:号码分类名称修正、我的行情页面优化 2026-04-11 18:40:07 +08:00
甲辰生产 e2c0765623 fix: 修复后端号码分类名称(通货→带4号,钻石→钻石号,永恒→永恒号,无4→带7号) 2026-04-11 18:38:03 +08:00
甲辰生产 2b2b916f91 Merge commit '99d34f8' 2026-04-11 17:54:16 +08:00
甲辰生产 99d34f86c4 v1.2.83 - 我的行情页面搜索和排序功能修复 2026-04-11 17:54:09 +08:00
甲辰生产 74589883b6 v1.2.82 - 行情功能优化:批量录入增强、我的行情页面完善
- 批量录入增加默认日期、成交平台、默认包装类型选项
- 我的行情页面优化显示:冠字号金色、编号白色、大小号显示
- 编辑界面增加平台、出售者、购买者字段
- 导航栏优化:我的、行情
- 修复批量解析大小号计算问题
- 自动生成行情编号(日期+5位自然数)
2026-04-11 16:30:18 +08:00
甲辰生产 bd22c20108 v1.2.82 - 行情录入功能优化:添加号码分类、尾号、大小号、包装类型、出售者、购买者字段;提交时自动计算尾号和大小号 2026-04-11 01:47:55 +08:00
甲辰生产 acac7922af fix: 修复information.py缺少Response导入 2026-04-09 15:42:13 +08:00
甲辰生产 abc8c7bd08 v1.2.79 - 优化一尘看板 2026-04-09 15:36:14 +08:00
甲辰生产 550fc8f7da v1.2.79 - 邀请码注册功能 2026-04-09 08:29:40 +08:00
甲辰生产 b40f5c44a6 fix: 修复邀请码字段无法保存的bug 2026-04-09 08:22:27 +08:00
甲辰生产 448b3a1ba7 v1.2.77 - 添加邀请码注册功能(选填),用户模型增加邀请数量字段 2026-04-09 08:14:01 +08:00
甲辰生产 dacd023523 v1.2.78 - 支持用户编码登录 2026-04-09 00:45:19 +08:00
甲辰生产 0dee8a9d53 v1.2.77 - 用户编码从201开始 2026-04-09 00:41:21 +08:00
甲辰生产 7e19e2a55d v1.2.76 - 首页最新帖子+搜索分页修复 2026-04-08 23:29:15 +08:00
甲辰生产 6401fc57ea v1.2.75 - 搜索功能完善+分页修复 2026-04-08 23:12:30 +08:00
甲辰生产 49b68e802e v1.2.74 - 搜索返回总数+分页修复 2026-04-08 22:54:51 +08:00
甲辰生产 e5a6a5928e v1.2.73 - 全局搜索后端支持 2026-04-08 22:40:46 +08:00
甲辰生产 e5a5a2c48c v1.2.72 - 优化加载性能 2026-04-08 22:24:22 +08:00
甲辰生产 a4be63512c v1.2.72 - 修复一尘看板渲染逻辑问题 2026-04-08 22:17:55 +08:00
甲辰生产 36f617266d v1.2.70 2026-04-08 20:47:43 +08:00
甲辰生产 d6fd86bbfa 修复VERSION格式 2026-04-08 20:46:00 +08:00
甲辰生产 a46d63ab01 v1.2.70 - 修复编号并发锁 2026-04-08 20:45:24 +08:00
甲辰生产 022e81c800 v1.2.70 - 修复版本号统一+编号并发锁+Stats性能优化 2026-04-08 20:44:15 +08:00
甲辰生产 49795690af v1.2.69 - 修复版本号统一 2026-04-08 20:42:48 +08:00
甲辰生产 dd3e0d9fed 修复VERSION格式 2026-04-08 20:38:59 +08:00
甲辰生产 c96033167f v1.2.69 - 更新龙钞分类规则关键词 2026-04-08 20:14:35 +08:00
甲辰生产 e1ac548115 v1.2.63 - 统一版本号 2026-04-08 16:22:36 +08:00
甲辰生产 0a4d56f260 v1.2.63 - 首页龙钞数据模块+帖子跳转优化 2026-04-08 16:21:15 +08:00
甲辰生产 e158276101 v1.2.73 - 查看全部跳转一尘看板 2026-04-08 16:19:09 +08:00
甲辰生产 263890f43b v1.2.72 - 帖子点击跳转原帖 2026-04-08 16:18:24 +08:00
甲辰生产 8cfec31adf v1.2.71 - 添加合计列 2026-04-08 16:12:52 +08:00
甲辰生产 5ea8e9f4ab v1.2.70 - 龙钞数据改为单表格卡片布局 2026-04-08 16:09:32 +08:00
甲辰生产 041a5bc8bb v1.2.69 - 添加出售求购字样 2026-04-08 15:59:07 +08:00
甲辰生产 e635c36160 v1.2.68 - 美化龙钞数据UI 2026-04-08 15:56:39 +08:00
甲辰生产 bf2cc0848f v1.2.67 - 隐藏龙钞数据总数 2026-04-08 15:48:49 +08:00
甲辰生产 c9481ec385 v1.2.66 - 龙钞数据出售求购换行显示 2026-04-08 15:45:30 +08:00
甲辰生产 f7b5d6909b v1.2.65 - 优化龙钞数据展示:显示出售/求购数量 2026-04-08 15:37:55 +08:00
甲辰生产 90ce5a031f v1.2.64 - 修复龙钞统计数据API 2026-04-08 15:27:52 +08:00
甲辰生产 d207d6d329 v1.2.63 - 首页增加今日龙钞数据模块 2026-04-08 15:20:15 +08:00
甲辰生产 47e18948f1 fix: 添加images字段映射 2026-04-08 14:41:02 +08:00
甲辰生产 79ce79b92c fix: 恢复collections.py并添加用户认证检查 2026-04-08 14:39:10 +08:00
甲辰生产 9956ed3f61 fix: Stats返回值语法修复 2026-04-08 14:35:39 +08:00
甲辰生产 190df56118 fix: Stats返回值修复 2026-04-08 14:33:45 +08:00
甲辰生产 2a8dea6acf fix: Stats接口用户认证检查 2026-04-08 14:29:08 +08:00
甲辰生产 bb30f0e711 v1.2.62 - 更新版本号 2026-04-08 14:20:55 +08:00
甲辰生产 3cf8079b33 v1.2.62 - 性能优化:Stats数据库聚合+编号并发锁+images映射 2026-04-08 14:15:53 +08:00
甲辰生产 eb64ce3d8c v1.2.61 - 安全修复:CORS限制+SECRET_KEY强制环境变量 2026-04-08 13:35:18 +08:00
甲辰生产 309dd056c6 v1.2.60 - 用户功能开发基础稳定版 2026-04-08 13:28:18 +08:00
甲辰生产 893446ccd4 fix: 去掉寻配号数据箭头 2026-04-08 13:25:18 +08:00
甲辰生产 b8961a32e0 fix: 使用network_matched_count统计网络匹配数据 2026-04-08 13:22:29 +08:00
甲辰生产 e17024128f fix: 使用Information.id统计 2026-04-08 13:16:17 +08:00
甲辰生产 92bc6ab8d6 fix: 修正寻配号统计数据逻辑 2026-04-08 13:13:42 +08:00
甲辰生产 81ac7fd826 v1.2.54 - 寻配号数据增加我的匹配数 2026-04-08 13:07:55 +08:00
甲辰生产 b4b4a71640 fix: 寻配号数据调整到快捷操作下面 2026-04-08 13:05:07 +08:00
甲辰生产 a7dd51400c fix: 金额显示保留1位小数 2026-04-08 13:02:50 +08:00
甲辰生产 7a92c0d430 v1.2.53 - 首页优化:去掉图标+数字保留1位小数 2026-04-08 12:41:09 +08:00
甲辰生产 0bbaf87093 v1.2.52 - 首页优化:修改一尘标题+添加寻配号数据 2026-04-08 12:37:39 +08:00
甲辰生产 31cbe53c67 v1.2.51 - 修复用户信息返回缺少的字段 2026-04-08 12:30:38 +08:00
甲辰生产 723e5a2500 v1.2.51 - 修复用户信息返回缺少的字段 2026-04-08 11:22:32 +08:00
龙大 e8c3069bfd v1.2.50 阶段性完善版本:寻配号优化、网络数据匹配、分页功能、logo修复 2026-04-07 15:49:35 +08:00
龙大 693e13bbab v1.2.49 发布寻号优化版:支持输入D/E字符 2026-04-07 14:18:17 +08:00
龙大 195d779e79 v1.2.48 一尘看板阶段性稳定版:删除Info面板一尘看板tab 2026-04-07 14:11:35 +08:00
龙大 97cc727547 v1.2.47 修复logo 404问题:移除版本参数,修复static路径 2026-04-07 14:01:55 +08:00
龙大 3c3072159a v1.2.46 一尘数据首页优化版:首页布局调整、最新一尘发帖展示 2026-04-07 13:14:51 +08:00
龙大 f11c4a0be0 v1.2.45 一尘看板优化版:首页添加一尘数据模块、一尘看板搜索功能、分类统计优化 2026-04-07 13:07:20 +08:00
龙大 9d1b59a421 v1.2.43 一尘看板完善版 2026-04-06 23:22:57 +08:00
龙大 7ff0ae5c9e v1.2.42 一尘看板基础版 2026-04-06 21:17:15 +08:00
龙大 b2b30cc54e v1.2.41 修复一尘看板显示真实发布者名称 2026-04-06 16:21:47 +08:00
龙大 5da876a193 v1.2.41 添加一尘看板功能到资讯面板 2026-04-06 15:54:22 +08:00
龙大 5f6dffface v1.2.41 清理废弃代码,准备开发一尘数据功能 2026-04-06 15:06:11 +08:00
龙大 c0260785c2 v1.2.40 开始开发一尘数据展示功能 2026-04-06 14:42:06 +08:00
甲辰生产 9af4d5f9b1 修复用户信息返回缺少id和username字段 2026-04-02 10:35:15 +08:00
甲辰生产 35f576a38e 添加VERSION文件v1.2.38 2026-04-02 10:23:11 +08:00
甲辰生产 e3f4cdb876 强制更新VERSION为v1.2.38 2026-04-02 10:23:05 +08:00
甲辰生产 c423024622 添加config/VERSION文件 2026-04-02 10:21:50 +08:00
甲辰生产 5ddca92a40 版本更新为 v1.2.38 2026-04-02 10:21:05 +08:00
甲辰生产 3b6de8abcc 版本文件全部更新为 v1.2.38 2026-03-31 21:53:48 +08:00
甲辰生产 e9e0a2ae89 文档版本更新到 v1.2.38 2026-03-31 21:52:57 +08:00
甲辰生产 91464bcf21 版本更新到 v1.2.38 2026-03-31 21:50:50 +08:00
甲辰生产 62b3b4b20a v1.2.38: 最终版本,优化注册错误提示 2026-03-31 21:22:20 +08:00
甲辰生产 7760a52982 修复注册错误提示不明确的问题 2026-03-31 21:18:19 +08:00
甲辰生产 156137ee19 v1.2.36: 添加favicon图标,修复icons目录缺失问题 2026-03-31 21:09:57 +08:00
甲辰生产 c522b772f6 清理多余目录和文件 2026-03-31 21:04:48 +08:00
甲辰生产 83c8410627 v1.2.35: Admin页面显示28字段,登录/AI/配号次数统计,修复OCR/短信/资讯API等问题 2026-03-31 21:02:18 +08:00
甲辰生产 190d11347f v1.2.35: 增强News页面API错误处理 2026-03-31 20:02:24 +08:00
甲辰生产 49d2b3fccc v1.2.34: 修复资讯列表API未登录时返回401 2026-03-31 19:58:07 +08:00
甲辰生产 2878a0ca35 v1.2.33: 修复未登录时News页面崩溃问题 2026-03-31 19:56:09 +08:00
甲辰生产 bfa92b307b v1.2.32: 修复admin删除资讯权限问题 2026-03-31 18:24:46 +08:00
甲辰生产 6cacf7098e v1.2.31: 添加logo到public目录,修复构建时丢失问题 2026-03-31 18:08:18 +08:00
甲辰生产 0e489fccfe v1.2.31: 修复管理页面用户藏品数显示问题,修复资讯页面is_matched字段NULL问题 2026-03-31 17:42:04 +08:00
longda 72d8d3ef14 Merge remote changes and resolve conflicts 2026-03-31 01:23:15 +08:00
longda 4d3b467a5a v1.2.30 - 寻号行情功能基本完善稳定版
主要更新:
- 资讯页面配号逻辑修复(match_pattern重复去掉J0前缀问题)
- 管理面板用户角色显示优化(信息员正确显示)
- Info页面优化(显示所有帖子、用户名、折叠展开效果)
- B前端API代理配置修改(指向C后端)
- 修复前端部署时Logo丢失问题(使用Vite public目录)

涉及文件:
- frontend/src/pages/News.jsx (配号逻辑、折叠效果)
- frontend/src/pages/Admin.jsx (角色显示)
- frontend/src/pages/Info.jsx (用户名显示、获取所有帖子)
- frontend/public/images/ (Logo文件)
- backend/app/routers/information.py (配号逻辑修复)
- config/VERSION (版本更新)
2026-03-31 01:22:34 +08:00
甲辰生产 188458d2bc v1.2.26 - Info页面修复:寻配号发布改为独立页面,新增行情发布保持原有样式 2026-03-29 17:09:10 +08:00
甲辰生产 7009d1126c v1.2.25 - Info页面完善版:添加寻号/行情顶部Tab切换、列表筛选功能(全部/寻号/行情)、发布记录类型标签显示 2026-03-29 17:04:09 +08:00
甲辰生产 509e37cd98 同步C环境最新代码 - v1.2.24 (包含寻号和行情区分) 2026-03-29 16:20:51 +08:00
甲辰生产 27e14270a3 v1.2.24 - 登录状态优化(基于最新代码)+ 行情管理优化 2026-03-29 15:08:10 +08:00
甲辰生产 79f2f2e2f8 功能基本完善版本 v1.2.22 - 行情管理优化 2026-03-29 01:21:03 +08:00
甲辰生产 c7c2ad20f5 美化发布成功弹窗 v1.2.21 2026-03-28 23:31:32 +08:00
甲辰生产 8b6271bb84 弹窗美化版本 v1.2.20 2026-03-28 23:27:32 +08:00
甲辰生产 ad24947037 寻配号功能完整版 v1.2.24 - 修复部署问题 2026-03-28 15:31:42 +08:00
甲辰生产 9c913a8213 寻配号功能优化版 2026-03-28 15:21:07 +08:00
甲辰生产 840c8d3cfb 寻配号功能完整版 2026-03-28 14:30:02 +08:00
甲辰生产 4d3feeae45 寻号功能v1.2.21最终版:
1. 发布寻号面板:去掉类型匹配度字段,标题在正文前面
2. 联系方式默认***隐藏
3. 增加匹配状态、匹配并联系藏友、留言藏友按钮
4. 留言功能所有人可见
5. 我的寻号/我的配号筛选按钮(主页面筛选)
6. 记住用户浏览的标签页
7. 后端添加matched_contact字段
2026-03-28 11:10:23 +08:00
甲辰生产 2cd90a0604 寻号功能完善v1.2.20:
1. 发布寻号面板去掉类型、匹配度字段
2. 标题移到正文前面
3. 联系方式默认***隐藏
4. 增加匹配状态按钮
5. 增加匹配并联系藏友、留言藏友按钮
6. 留言功能完善,留言后自动刷新
7. 记住用户浏览的标签页
2026-03-28 10:15:54 +08:00
甲辰生产 ce08c68d04 寻号功能开发:
1. 发布寻号面板:去掉类型、匹配度字段,标题移到正文前面
2. 寻号列表:联系方式默认***隐藏,增加匹配状态按钮
3. 增加匹配并联系藏友、留言藏友按钮
4. 实现留言功能(所有人可见)
5. 后端:添加is_matched字段和新API
2026-03-28 08:24:08 +08:00
甲辰生产 f84a85d3c1 v1.2.18 寻号功能基础版:寻配号发布、自动配号匹配、我的寻号列表编辑 2026-03-27 16:39:36 +08:00
甲辰生产 18b6cfbc1d 版本更新: v1.2.18 - 寻号发布功能优化版 2026-03-27 01:20:03 +08:00
甲辰生产 f29629bfcc 版本更新: v1.2.17 - 寻号基本功能 2026-03-27 00:28:24 +08:00
甲辰生产 3b80d17010 版本更新: v1.2.16 - 修复信息发布输入问题 2026-03-26 23:11:20 +08:00
甲辰生产 2aae399ce4 版本更新: v1.2.16 - 用户信息完善版本 2026-03-26 23:06:47 +08:00
甲辰生产 a71697aed0 版本更新: v1.2.15 2026-03-26 22:17:11 +08:00
甲辰生产 bc23e95691 feat: 信息发布管理功能完善,界面优化 2026-03-26 20:26:51 +08:00
甲辰生产 66c086e35d feat: 资讯页面和发布页面分离,添加信息发布管理功能 2026-03-26 15:32:27 +08:00
甲辰生产 80effaaa29 feat: 添加信息员角色,支持管理员/信息员/用户三种权限 2026-03-26 14:50:26 +08:00
甲辰生产 0e7b5cbfdf v1.2.14: 新增资讯功能页面(寻配号/成交数据/发布中心) 2026-03-25 10:58:00 +08:00
longda fadb0e5b4f v1.2.13: 用户管理功能增强/登录优化/数据库连接池优化 2026-03-24 23:57:27 +08:00
longda 8662fbc625 新增: AI识别后自动更新用户识别次数 2026-03-24 23:32:53 +08:00
longda 2bad73e2de 修复: C环境短信模板改为苏州算力 2026-03-24 23:26:16 +08:00
longda 0db9215587 修复: 用户更新API支持新字段/登录次数更新 2026-03-24 23:20:12 +08:00
longda 1fe912ac76 优化: 登录有效期7天/数据库连接池增强 2026-03-24 23:16:35 +08:00
longda 84cd53143b 新增用户管理字段: 会员等级/积分/余额/登录次数等 2026-03-24 22:52:37 +08:00
longda 19e2ae6b0e Fix database.py syntax and update pool config 2026-03-24 22:31:59 +08:00
longda bb33d37087 Merge cangpin and resolve conflict 2026-03-24 22:31:29 +08:00
longda 3a2dd39df3 优化数据库连接池配置: pool_size=20, max_overflow=40 2026-03-24 22:30:53 +08:00
longda 1879444f7f Bump version to v1.2.13 2026-03-24 22:02:44 +08:00
longda 8c5c6a0b79 Merge cangpin v1.2.12 to main - resolve conflict 2026-03-24 21:57:37 +08:00
jiachenlong 9913530863 完整C环境部署版本 v1.2.12 2026-03-23 11:08:52 +08:00
酷博特 fcb8108734 Merge pull request 'fix: 数据库连接池添加超时配置,优化用户列表查询性能' (#6) from fix/db-pool into main
Reviewed-on: http://47.253.189.47:3000/coolbot/jiachenlong/pulls/6
2026-03-23 02:56:48 +00:00
jiachenlong 0143a06a2a Clean up directory structure for v1.2.12 2026-03-23 10:56:43 +08:00
jiachenlong 77c47b89f9 Full release v1.2.12 - all files updated 2026-03-23 10:56:23 +08:00
酷博特 975fc0b89a Merge pull request 'Update user agreement to full version v1.2.12' (#5) from release/v1.2.12-ua into main
Reviewed-on: http://47.253.189.47:3000/coolbot/jiachenlong/pulls/5
2026-03-23 02:55:28 +00:00
jiachenlong 903be993d5 Update user agreement to full version v1.2.12 2026-03-23 10:50:55 +08:00
酷博特 ce2677124f Merge pull request 'release/v1.2.12' (#4) from release/v1.2.12 into main
Reviewed-on: http://47.253.189.47:3000/coolbot/jiachenlong/pulls/4
2026-03-23 02:30:56 +00:00
jiachenlong 00b2c45a27 Release v1.2.12 2026-03-23 10:23:06 +08:00
jiachenlong 9a6653c492 Update index.html build version 2026-03-23 08:35:58 +08:00
jiachenlong 0388602da2 Add user agreement page v1.2.11 2026-03-23 08:34:31 +08:00
jiachenlong 9c2ca70fc0 Bump version to v1.2.11 2026-03-23 08:30:06 +08:00
酷博特 d045e436de Merge pull request 'v1.2.10 - 统计页筛选优化,全量数据筛选' (#3) from release/v1.2.10 into main
Reviewed-on: http://47.253.189.47:3000/coolbot/jiachenlong/pulls/3
2026-03-22 15:41:22 +00:00
Admin ad1adfb811 v1.2.10 - 统计页筛选优化,全量数据筛选 2026-03-22 23:39:41 +08:00
龙大 58ffe488e1 fix: 数据库连接池添加超时配置,优化用户列表查询性能 2026-03-22 19:40:18 +08:00
酷博特 51834f4498 Merge pull request 'Release v1.2.7 - 列表样式优化' (#2) from feature/v1.2.7 into main
Reviewed-on: http://47.253.189.47:3000/coolbot/jiachenlong/pulls/2
2026-03-21 17:08:30 +00:00
甲辰生产 5863134805 Release v1.2.7 - 列表样式优化
- 优化藏品列表样式,紧凑布局
- 珍惜度移至第二行备注前
- 冠字号取消加粗
2026-03-22 01:05:48 +08:00
甲辰生产 7b38db1b6a docs: 更新部署检查清单 - 添加用户协议检查项 2026-03-22 01:05:48 +08:00
甲辰生产 8f31591a92 Release v1.2.6 - 号码分类功能稳定版
- 添加号码分类字段(无2347/无347/无247/无47/无4/带4/其他)
- 录入/编辑时自动根据冠字号分类
- 统计页面显示号码分类分布
- 列表页显示号码分类标签
- 编号自动生成(4位→5位)
- 用户协议页面修复
- 修复重复编号错误处理
- 藏品名称默认设置为藏品
2026-03-22 01:05:48 +08:00
酷博特 766f16bf68 Merge pull request 'Release v1.2.6 - 号码分类功能稳定版' (#1) from feature/font-style into main
Reviewed-on: http://47.253.189.47:3000/coolbot/jiachenlong/pulls/1
2026-03-21 16:03:37 +00:00
甲辰生产 231c705c36 feat: 优化藏品列表字体样式,冠字号14px加粗,编号12px 2026-03-21 21:15:45 +08:00
甲辰生产 b5dd42da2f Release v1.2.5 2026-03-21 11:47:57 +08:00
龙大 6344f18fb0 feat: 添加.env.example模板和部署检查清单 2026-03-21 01:41:08 +08:00
龙大 1c8e773046 docs: 添加标准部署流程手册v1.0 2026-03-21 00:28:16 +08:00
龙大 fdb16c151a docs: 添加v1.2.4问题修复说明文档 2026-03-20 23:26:50 +08:00
龙大 bdbee1d020 fix: 首页logo添加版本号防止缓存 2026-03-20 23:17:32 +08:00
龙大 fb8f021a0c fix: 修复版本号重复v和logo缺失问题 2026-03-20 23:14:00 +08:00
龙大 63223908c3 docs: 更新部署手册v1.2.4 2026-03-20 22:59:53 +08:00
龙大 e2dbc016ac v1.2.4 - 用户协议修复版本 2026-03-20 22:59:11 +08:00
龙大 64f507c1a8 fix: OCR临时图片支持大小写扩展名 2026-03-20 22:02:12 +08:00
龙大 012ff63d3a v1.2.3 完整部署 - 字体/OSS/短信/用户协议修复 2026-03-20 21:41:55 +08:00
龙大 1aa1ae8a26 v1.2.3 字体优化版本 2026-03-20 20:53:55 +08:00
龙大 b093c44619 fix: 移除last_login字段映射以适配A环境数据库 2026-03-20 20:34:03 +08:00
龙大 3d8a5dd142 docs: 更新团队分工描述 2026-03-20 20:28:13 +08:00
龙大 074fddc687 docs: 更新团队分工 2026-03-20 20:25:39 +08:00
龙大 73dbc63e71 docs: 更新团队成员名称 2026-03-20 20:17:44 +08:00
龙大 1b0a6b06c3 docs: 更新README.md - 开发团队信息 2026-03-20 20:14:47 +08:00
龙大 221eeef464 docs: 更新README.md - v1.2.2版本说明 2026-03-20 20:10:23 +08:00
longda ef8b004a79 v1.2.2 列表优化版本 - 3行布局,颜色配置完善 2026-03-20 18:26:21 +08:00
longda 617f1401e7 v1.2.1 用户协议版本 - 添加用户协议勾选功能 2026-03-20 17:29:26 +08:00
甲辰生产 d92559a3e3 v1.2.1 阶段性稳定版本 2026-03-20 15:03:26 +08:00
甲辰生产 4965bf68d3 docs: 添加测试环境部署指南
- 测试环境架构说明
- 详细部署步骤
- 常见问题及解决方案
- 短信配置说明
2026-03-19 09:29:33 +08:00
76 changed files with 13507 additions and 2914 deletions

163
README.md
View File

@ -1,115 +1,100 @@
# 甲辰藏品管理系统
**版本**: v1.0.0
**代号**: 新生
**发布日期**: 2026-03-16
> 生肖纪念钞收藏管理系统
生肖纪念钞收藏管理系统 - 支持藏品管理、OCR 识别、统计分析等功能。
## 版本信息
---
| 项目 | 内容 |
|------|------|
| **版本** | v1.2.38 |
| **代号** | 最终优化版本 |
| **发布日期** | 2026-03-31 |
## 📁 项目结构
## 技术栈
- **后端**: FastAPI + PostgreSQL + 阿里云OSS + 阿里云短信
- **前端**: React + Vite + Tailwind CSS
- **部署**: Nginx
## 核心功能
- 用户管理(管理员/普通用户)
- 藏品管理CRUD、多条件筛选
- OCR识别阿里云视觉智能
- 统计分析
- 图片上传阿里云OSS
- 短信验证码
- 用户协议
- 密码强度验证
## 项目结构
```
jiachenlong/
├── backend/ # FastAPI 后端服务
├── frontend/ # React 移动端前端
├── static/ # 静态资源(图片、图标、字体)
├── config/ # 配置文件
├── docs/ # 文档
├── scripts/ # 部署脚本
└── README.md # 本文件
├── backend/ # FastAPI后端
│ ├── app/
│ │ ├── routers/ # API路由
│ │ ├── services/ # 业务服务
│ │ └── modules/ # 业务模块
│ └── config/
├── frontend/ # React前端
│ ├── src/pages/ # 页面组件
│ └── dist/ # 构建产物
└── config/ # 版本配置
```
---
## 版本历史
## 🚀 快速开始
| 版本 | 日期 | 说明 |
|------|------|------|
| v1.2.38 | 2026-03-31 | 最终优化版本 - Admin页面显示28字段、登录AI配号统计、修复OCR/短信/注册提示 |
| v1.2.35 | 2026-03-31 | Admin页面显示28字段登录/AI/配号次数统计 |
| v1.2.30 | 2026-03-25 | 资讯功能页面 + 管理后台V2 |
| v1.2.2 | 2026-03-20 | 列表布局优化 |
| v1.2.1 | 2026-03-20 | 密码强度验证、用户协议 |
| v1.2.0 | 2026-03-20 | 管理员查看用户藏品 |
### 后端启动
## 快速开始
```bash
cd backend
# 后端
cd backend && pip install -r requirements.txt
python -m uvicorn app.main:app --reload
# 安装依赖
pip install -r requirements.txt
# 配置环境变量(修改数据库密码等)
vi .env
# 启动服务
python -m uvicorn app.main:app --port 3000 --host 0.0.0.0
# 前端
cd frontend && npm install && npm run dev
```
### 前端启动
## API接口
```bash
cd frontend
### 认证
- POST /api/auth/register - 注册
- POST /api/auth/login - 登录
- GET /api/auth/me - 当前用户
# 安装依赖
npm install
### 藏品
- GET /api/collections - 列表
- POST /api/collections - 创建
- PUT /api/collections/{id} - 更新
- DELETE /api/collections/{id} - 删除
# 开发模式
npm run dev
## 开发团队
# 生产构建
npm run build
```
| 角色 | 姓名 | 职责 |
|------|------|------|
| 产品负责人 | 酷博特 | 项目总负责 |
| 项目经理 | 龙大 | 项目整体管理 |
| 开发 | 龙二 | 本地开发 |
| 测试 | 龙A | 生产A环境负责人 |
| 测试 | 龙B | 生产B环境负责人 |
| 运维 | 龙运 | 运维负责人 |
| 运维 | 龙镜 | 镜像版本管理负责人 |
### 配置文件
## 文档
所有配置文件在 `config/` 目录:
- `config/VERSION` - 版本号配置
- `config/docker-compose.yml` - Docker 部署配置
- `.gitignore` - Git 忽略配置(根目录)
### 静态资源
所有静态资源在 `static/` 目录:
- `static/images/` - 图片资源Logo、背景图等
- `static/icons/` - 图标资源favicon、应用图标等
- `static/fonts/` - 字体文件
- [部署手册](https://qcn9r1i8r51d.feishu.cn/docx/ZvEadXZ7XoxUtYxGLoIcMCfgn7g)
- [Git仓库](http://47.253.189.47:3000/coolbot/jiachenlong)
---
## 📋 文档
所有文档都在 `docs/` 目录下:
- `docs/DEPLOYMENT_v1.0.0.md` - 部署指南
- `docs/RELEASE_v1.0.0.md` - 发布说明
- `docs/ERROR_CODES.md` - 错误码
- `docs/部署手册.md` - 完整部署手册
- `docs/BACKEND_SERVICE_GUIDE.md` - 后端服务指南
- `docs/TEST_REPORT.md` - 测试报告
---
## 🛠️ 技术栈
**后端**:
- FastAPI + SQLAlchemy
- PostgreSQL
- JWT 认证
- DashScope OCR
**前端**:
- React + Vite
- 移动端适配
- 暗色主题
---
## 📊 核心功能
- ✅ 用户管理(管理员/普通用户)
- ✅ 藏品管理CRUD
- ✅ OCR 识别
- ✅ 统计分析
- ✅ 图片上传
- ✅ 移动端适配
---
**开发团队**: 酷博特 + 菜鸟小 D 🤖
© 2026 甲辰收藏

26
backend/.env.example Normal file
View File

@ -0,0 +1,26 @@
# 数据库配置
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zodiac
# JWT配置
SECRET_KEY=your-production-secret-key-change-this
ACCESS_TOKEN_EXPIRE_MINUTES=60
# 阿里云 DashScope OCR API必须配置
DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f
# 阿里云 OSS配置
OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
OSS_BUCKET_NAME=jiachenlong-oss
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_PUBLIC_URL=https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com
# 阿里云短信配置(必须配置!)
SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
SMS_SIGN_NAME=苏州算力
SMS_TEMPLATE_CODE=SMS_501590956
# 服务配置
PORT=3000
HOST=0.0.0.0

1
backend/VERSION Normal file
View File

@ -0,0 +1 @@
1.2.98

View File

@ -11,9 +11,11 @@ from app.core.database import SessionLocal
from app.models.models import User
# 配置
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
SECRET_KEY = os.getenv("SECRET_KEY")
if not SECRET_KEY:
raise ValueError("SECRET_KEY environment variable is not set. Please configure it in production!")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7天
# HTTP Bearer 认证
security = HTTPBearer(auto_error=False)
@ -57,14 +59,10 @@ def decode_access_token(token: str) -> Optional[dict]:
def get_current_user(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(lambda: SessionLocal())
) -> User:
"""获取当前用户"""
) -> Optional[User]:
"""获取当前用户可返回None"""
if not credentials:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="未提供认证信息",
headers={"WWW-Authenticate": "Bearer"},
)
return None
token = credentials.credentials
payload = decode_access_token(token)
@ -86,10 +84,6 @@ def get_current_user(
user = db.query(User).filter(User.f99_90_id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在",
headers={"WWW-Authenticate": "Bearer"},
)
return None
return user

View File

@ -0,0 +1,37 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool
COOLBOT_DB_URL = os.getenv(
"COOLBOT_DB_URL",
"postgresql://coolbot:Coolbot123@pgm-bp1t1008h019ez6cno.pg.rds.aliyuncs.com:5432/coolbot_data"
)
coolbot_engine = create_engine(
COOLBOT_DB_URL,
poolclass=QueuePool,
pool_size=10,
max_overflow=20,
pool_timeout=30,
pool_recycle=1800,
pool_pre_ping=True,
echo=False,
connect_args={
"connect_timeout": 10,
"application_name": "zodiac-coolbot"
}
)
CoolbotSession = sessionmaker(autocommit=False, autoflush=False, bind=coolbot_engine)
def get_coolbot_db():
"""获取coolbot数据库会话"""
db = CoolbotSession()
try:
yield db
except Exception:
db.rollback()
raise
finally:
db.close()

View File

@ -1,32 +1,69 @@
import os
from sqlalchemy import create_engine
import time
from sqlalchemy import create_engine, event
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool
from typing import Generator
import logging
logger = logging.getLogger(__name__)
# 支持 MySQL, PostgreSQL
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://postgres:postgres@localhost:5432/zodiac"
"postgresql://postgres:postgres@127.0.0.1:5432/zodiac"
)
# 数据库引擎配置
# 增强版数据库引擎配置
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_size=10,
max_overflow=20,
echo=False
# 连接池配置
poolclass=QueuePool,
pool_size=20, # 常规连接数
max_overflow=40, # 允许超出的连接数(高并发时)
pool_timeout=30, # 获取连接超时时间(秒)
pool_recycle=1800, # 连接回收时间30分钟避免连接过期
pool_pre_ping=True, # 每次获取连接前检查连接是否有效
echo=False,
# 连接参数优化
connect_args={
"connect_timeout": 10,
"application_name": "zodiac-api",
"options": "-c statement_timeout=30000" # 查询超时30秒
}
)
# 添加连接事件监听器
@event.listens_for(engine, "connect")
def set_connect_timeout(dbapi_conn, connection_record):
"""设置连接参数"""
cursor = dbapi_conn.cursor()
cursor.execute("SET statement_timeout = 30000")
cursor.close()
@event.listens_for(engine, "checkout")
def check_connection(dbapi_conn, connection_record, connection_proxy):
"""检出连接时检查"""
try:
cursor = dbapi_conn.cursor()
cursor.execute("SELECT 1")
cursor.close()
except Exception as e:
logger.warning(f"连接检查失败: {e}")
raise Exception("数据库连接无效")
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""获取数据库会话"""
def get_db() -> Generator:
"""获取数据库会话,带错误处理"""
db = SessionLocal(expire_on_commit=False)
try:
yield db
except Exception as e:
logger.error(f"数据库会话错误: {e}")
db.rollback()
raise
finally:
db.close()

View File

@ -15,6 +15,10 @@ from app.middleware.logging import logging_middleware
from app.routers import auth, collections, operations
from app.routers import ocr as ocr_router
from app.routers import users as users_router
from app.routers import information as information_router
from app.routers import yichens as yichens_router
from app.routers import seek as seek_router
from app.routers import deal as deal_router
# 版本信息 - 从 config/VERSION 文件读取
def get_version():
@ -50,10 +54,11 @@ app = FastAPI(
# 设置全局错误处理器
setup_error_handlers(app)
# CORS 配置
# CORS 配置 - 生产环境限制域名
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://47.103.29.111,http://120.55.81.21,https://socoolbot.com").split(",")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 生产环境应该限制域名
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@ -64,10 +69,10 @@ uploads_dir = "uploads"
os.makedirs(uploads_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
# 挂载项目静态资源目录(可选,生产环境建议用 Nginx
# static_dir = Path(__file__).parent.parent.parent / "static"
# if static_dir.exists():
# app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# 挂载项目静态资源目录
static_dir = Path(__file__).parent.parent / "static"
if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# 添加日志中间件
app.middleware("http")(logging_middleware)
@ -79,6 +84,10 @@ app.include_router(operations.router)
app.include_router(ocr_router.router) # OCR 识别
app.include_router(users_router.router) # 当前用户接口
app.include_router(users_router.admin_router) # 管理员用户管理
app.include_router(information_router.router)
app.include_router(yichens_router.router) # 一尘看板
app.include_router(seek_router.router) # 寻配号
app.include_router(deal_router.router) # 成交行情
@app.get("/")
@ -101,3 +110,6 @@ if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", "3000"))
uvicorn.run(app, host="0.0.0.0", port=port)
from app.routers import seek as seek_router
from app.routers import deal as deal_router

View File

@ -0,0 +1,54 @@
from sqlalchemy import Column, String, Text, Float, Date, Boolean, Integer, DateTime
from sqlalchemy.sql import func
from app.core.database import Base
import uuid
def generate_uuid():
return str(uuid.uuid4())
class DealInfo(Base):
__tablename__ = "deal_info"
id = Column(String(36), primary_key=True, default=generate_uuid)
user_id = Column(String(36), nullable=True, index=True)
# 标题和内容
title = Column(String(255), nullable=False)
content = Column(Text, nullable=True)
# 成交信息
deal_price = Column(Float, nullable=True) # 成交价格
deal_date = Column(Date, nullable=True) # 成交日期
deal_no = Column(String(20), nullable=True, index=True) # 行情编号从A000001开始递增
# 包装和分类
packaging = Column(String(50), nullable=True) # 包装(标百/标十/单张)
category = Column(String(100), nullable=True) # 分类
# 评级相关
is_graded = Column(Boolean, default=False) # 是否评级
grading_company = Column(String(100), nullable=True) # 评级机构
grading_score = Column(String(50), nullable=True) # 评级分数
# 号码特征
tail_number = Column(String(10), nullable=True) # 尾号
size_type = Column(String(20), nullable=True) # 大小号
# 版别
version = Column(String(50), nullable=True) # 版别
# 交易信息
platform = Column(String(50), nullable=True) # 成交平台
seller = Column(String(100), nullable=True) # 出售者
buyer = Column(String(100), nullable=True) # 购买者
# 状态
status = Column(String(20), default="active")
# 统计
view_count = Column(Integer, default=0)
contact_count = Column(Integer, default=0)
# 时间
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -1,5 +1,5 @@
# 数据库模型 - 使用字段编码
from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey
from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey, Date, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
@ -16,6 +16,7 @@ class User(Base):
# f99 系统字段
f99_90_id = Column(String(36), primary_key=True, default=generate_uuid)
user_code = Column(String(10), unique=True, nullable=True, index=True) # 用户编码
f99_91_user_id = Column(String(36), unique=True, nullable=False, index=True)
f01_01_name = Column(String(255), unique=True, nullable=False, index=True) # username
email = Column(String(255), unique=True, nullable=True, index=True)
@ -25,9 +26,26 @@ class User(Base):
bio = Column(Text, nullable=True)
password = Column(String(255), nullable=False)
role = Column(String(50), default="user")
last_login = Column(DateTime(timezone=True), nullable=True) # 最后登录时间
# 时间字段
f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now())
f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 新增字段
f99_94_level = Column(String(20), default="青铜") # 会员等级
f99_95_ai_count = Column(Integer, default=0) # ai识别次数
f99_96_search_count = Column(Integer, default=0) # 寻号使用次数
f99_97_collection_count = Column(Integer, default=0) # 藏品数量
f01_06_phone_verified = Column(Boolean, default=False) # 手机号已核验
f99_98_login_count = Column(Integer, default=0) # 登录次数
f99_99_last_login = Column(DateTime(timezone=True), nullable=True) # 最后登录时间
f01_07_gender = Column(String(10), nullable=True) # 性别
f01_08_birthday = Column(Date, nullable=True) # 生日
f01_09_region = Column(String(100), nullable=True) # 地区
f01_10_realname_verified = Column(Boolean, default=False) # 实名认证
f99_100_points = Column(Integer, default=0) # 积分
f01_11_balance = Column(Float, default=0) # 余额
f01_12_total_amount = Column(Float, default=0) # 累计金额
f01_13_invite_code = Column(String(20), nullable=True) # 邀请码(自己的邀请码)
f99_101_invited_count = Column(Integer, default=0) # 通过自己邀请码注册的用户数量
collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan")
operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan")
@ -55,6 +73,7 @@ class Collection(Base):
f02_11_version = Column(String(100), nullable=True, index=True)
f02_12_packaging = Column(String(100), nullable=True, index=True)
f02_13_rarity = Column(String(50), nullable=True, index=True) # 珍惜度
f02_14_number_category = Column(String(20), nullable=True, index=True) # 号码分类
# f03 评级信息
f03_20_is_graded = Column(Boolean, default=False, index=True)
@ -128,3 +147,122 @@ class CustomField(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", back_populates="custom_fields")
# 资讯模型
class Information(Base):
__tablename__ = "information"
id = Column(String(36), primary_key=True, default=generate_uuid)
user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
# 信息类型: seek-寻配号, deal-成交数据, publish-发布
info_type = Column(String(20), nullable=False, index=True)
# 标题
title = Column(String(255), nullable=False)
# 内容描述
content = Column(Text, nullable=True)
# 关联藏品ID
collection_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="SET NULL"), nullable=True)
# 期望条件 (寻配号用)
expect_category = Column(String(100), nullable=True) # 期望类别
expect_version = Column(String(100), nullable=True) # 期望版别
expect_packaging = Column(String(100), nullable=True) # 期望包装
expect_number = Column(String(50), nullable=True) # 期望号码
expect_price_min = Column(Float, nullable=True) # 期望价格区间
expect_price_max = Column(Float, nullable=True)
# 成交价格 (成交数据用)
deal_price = Column(Float, nullable=True)
deal_date = Column(Date, nullable=True)
# 评级相关字段
packaging = Column(String(50), nullable=True)
is_graded = Column(Boolean, default=False)
grading_company = Column(String(100), nullable=True)
grading_score = Column(String(50), nullable=True)
category = Column(String(100), nullable=True)
# 行情编号
deal_no = Column(String(50), nullable=True, index=True)
# 状态: active-有效, closed-已关闭, expired-已过期
status = Column(String(20), default="active", index=True)
# 匹配状态: pending-尚未匹配, matched-已经匹配
is_matched = Column(String(20), default="pending", index=True)
# 匹配的用户ID当用户愿意交换联系方式时
matched_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="SET NULL"), nullable=True)
# 匹配者的联系方式(只有发布者和匹配者可见)
matched_contact = Column(String(100), nullable=True)
# 浏览/联系次数
view_count = Column(Integer, default=0)
contact_count = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
user = relationship("User", foreign_keys=[user_id])
collection = relationship("Collection")
# 资讯评论/留言
class InformationComment(Base):
__tablename__ = "information_comments"
id = Column(String(36), primary_key=True, default=generate_uuid)
information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
content = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
user = relationship("User", foreign_keys=[user_id])
# 资讯联系方式查看记录
class InformationContactView(Base):
__tablename__ = "information_contact_views"
id = Column(String(36), primary_key=True, default=generate_uuid)
information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True)
viewer_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
viewer = relationship("User", foreign_keys=[viewer_id])
# 资讯关联用户 (收藏/点赞)
class InformationLike(Base):
__tablename__ = "information_likes"
id = Column(String(36), primary_key=True, default=generate_uuid)
information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True)
user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
__table_args__ = (
UniqueConstraint('information_id', 'user_id', name='uq_information_user'),
)
# 角色常量
class UserRole:
ADMIN = "admin" # 管理员:全部权限
EDITOR = "editor" # 信息员:可发布信息、管理资讯
USER = "user" # 普通用户:基本功能
@classmethod
def get_role_name(cls, role):
names = {
cls.ADMIN: "管理员",
cls.EDITOR: "信息员",
cls.USER: "用户"
}
return names.get(role, "用户")

View File

@ -0,0 +1,39 @@
from sqlalchemy import Column, String, Text, Float, Date, Boolean, Integer, DateTime
from sqlalchemy.sql import func
from app.core.database import Base
import uuid
def generate_uuid():
return str(uuid.uuid4())
class SeekInfo(Base):
__tablename__ = "seek_info"
id = Column(String(36), primary_key=True, default=generate_uuid)
user_id = Column(String(36), nullable=False, index=True)
# 标题和内容
title = Column(String(255), nullable=False)
content = Column(Text, nullable=True)
# 期望条件(求购条件)
expect_category = Column(String(100), nullable=True) # 期望类别
expect_version = Column(String(100), nullable=True) # 期望版别
expect_packaging = Column(String(100), nullable=True) # 期望包装
expect_number = Column(String(50), nullable=True) # 期望号码
expect_price_min = Column(Float, nullable=True) # 期望最低价
expect_price_max = Column(Float, nullable=True) # 期望最高价
# 匹配状态
status = Column(String(20), default="active") # active/closed/expired
is_matched = Column(String(10), default="false") # 是否已匹配
matched_user_id = Column(String(36), nullable=True) # 匹配的用户ID
matched_contact = Column(String(100), nullable=True) # 匹配的联系方式
# 统计
view_count = Column(Integer, default=0)
contact_count = Column(Integer, default=0)
# 时间
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -1,167 +1,489 @@
# 认证路由 - 使用字段编码
# auth - 认证路由
# Version: 1.2.90
# 更新:
from fastapi import APIRouter
# Version: 1.2.x
# 更新:
from fastapi import APIRouter, Depends, HTTPException, status, Body
# 更新:
from fastapi.security import OAuth2PasswordRequestForm
# 更新:
from sqlalchemy.orm import Session
# 更新:
from app.core.database import get_db
# 更新:
from app.core.auth import verify_password, create_access_token, get_password_hash, get_current_user
# 更新:
from app.models.models import User
# 更新:
from app.schemas.schemas import Token, UserCreate, UserResponse
# 更新:
# 更新:
router = APIRouter(prefix="/api/auth", tags=["认证"])
# 更新:
# 更新:
# 更新:
# 更新:
# 更新:
def generate_user_code(db):
# 更新:
"""生成用户编码从201开始按自然数顺序递增跳过已存在的"""
# 更新:
# 查找最大的user_code
# 更新:
max_code = db.query(User.user_code).filter(User.user_code != None).order_by(User.user_code.desc()).first()
# 更新:
if max_code and max_code[0]:
# 更新:
try:
# 更新:
num = int(max_code[0]) + 1
# 更新:
if num < 201:
# 更新:
num = 201
# 更新:
# 检查是否已存在,如果存在则继续递增
# 更新:
while db.query(User).filter(User.user_code == str(num)).first():
# 更新:
num += 1
# 更新:
return str(num)
# 更新:
except:
# 更新:
pass
# 更新:
return "201"
# 更新:
# 更新:
@router.post("/register", response_model=UserResponse)
# 更新:
def register(user_data: UserCreate, db: Session = Depends(get_db)):
# 更新:
"""用户注册"""
# 更新:
# 检查用户名是否已存在
# 更新:
existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first()
# 更新:
if existing_user:
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="f01_01_name: 用户名已存在"
# 更新:
)
# 更新:
# 更新:
# 检查邮箱是否已存在
if user_data.email:
existing_email = db.query(User).filter(User.email == user_data.email).first()
if existing_email:
# 更新:
# 更新:
# 检查手机号是否已存在
# 更新:
if user_data.phone:
# 更新:
existing_phone = db.query(User).filter(User.phone == user_data.phone).first()
# 更新:
if existing_phone:
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST,
detail="邮箱已被注册"
# 更新:
detail="E00040:该手机号已被注册,请更换手机号"
# 更新:
)
# 更新:
if user_data.email:
# 更新:
existing_email = db.query(User).filter(User.email == user_data.email).first()
# 更新:
if existing_email:
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="E00041:该邮箱已被注册,请更换邮箱"
# 更新:
)
# 更新:
# 更新:
# 处理邀请码
# 更新:
invited_by_user = None
# 更新:
if user_data.invite_code:
# 更新:
# 查找邀请人
# 更新:
invited_by_user = db.query(User).filter(User.user_code == user_data.invite_code).first()
# 更新:
if not invited_by_user:
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="E00042:邀请码无效"
# 更新:
)
# 更新:
# 更新:
# 创建用户
# 更新:
import uuid
# 更新:
hashed_password = get_password_hash(user_data.password)
# 更新:
generated_code = generate_user_code(db)
# 更新:
user = User(
# 更新:
f99_90_id=str(uuid.uuid4()),
f99_91_user_id=str(uuid.uuid4()), # 生成唯一 user_id
# 更新:
f99_91_user_id=str(uuid.uuid4()),
# 更新:
user_code=generated_code,
# 更新:
f01_01_name=user_data.f01_01_name,
# 更新:
email=user_data.email,
# 更新:
phone=user_data.phone,
# 更新:
avatar=user_data.avatar,
# 更新:
address=user_data.address,
# 更新:
bio=user_data.bio,
# 更新:
password=hashed_password,
# 更新:
role="user"
# 更新:
)
# 更新:
# 更新:
db.add(user)
# 更新:
db.flush() # 确保获取user ID
# 更新:
# 更新:
# 更新邀请人、被邀请人的关联关系
# 更新:
if invited_by_user:
# 更新:
# 记录是被谁邀请的
# 更新:
user.f01_13_invite_code = invited_by_user.user_code
# 更新:
# 增加邀请人的邀请计数
# 更新:
invited_by_user.f99_101_invited_count = (invited_by_user.f99_101_invited_count or 0) + 1
# 更新:
# 更新:
# 生成自己的邀请码用自己的user_code
# 更新:
user.f01_13_invite_code = generated_code
# 更新:
# 更新:
db.commit()
# 更新:
db.refresh(user)
# 更新:
return user
@router.post("/login", response_model=Token)
def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""用户登录"""
# 查找用户
user = db.query(User).filter(User.f01_01_name == form_data.username).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="E00011: 用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
# 验证密码
if not verify_password(form_data.password, user.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="E00011: 用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
# 生成 token
access_token = create_access_token(data={"sub": user.f99_90_id})
# 更新:
# 返回用户信息避免Pydantic序列化问题
# 更新:
return {
"access_token": access_token,
"token_type": "bearer"
# 更新:
"id": user.f99_90_id,
# 更新:
"username": user.f01_01_name,
# 更新:
"user_code": user.user_code,
# 更新:
"email": user.email,
# 更新:
"phone": user.phone,
# 更新:
"avatar": user.avatar,
# 更新:
"role": user.role,
# 更新:
"level": user.f99_94_level,
# 更新:
"aiCount": user.f99_95_ai_count or 0,
# 更新:
"searchCount": user.f99_96_search_count or 0,
# 更新:
"collectionCount": user.f99_97_collection_count or 0
# 更新:
}
# 更新:
# 更新:
@router.get("/me", response_model=UserResponse)
def get_current_user_info(
current_user: User = Depends(lambda: None)
):
"""获取当前用户信息"""
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="请使用正确的依赖注入"
)
@router.post("/change-password")
def change_password(
old_password: str = Body(...),
new_password: str = Body(...),
current_user: User = Depends(get_current_user),
# 更新:
@router.post("/login", response_model=Token)
# 更新:
def login(
# 更新:
form_data: OAuth2PasswordRequestForm = Depends(),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
"""修改当前用户密码"""
from app.core.auth import verify_password, get_password_hash
# 在当前session中重新查询用户
user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first()
# 更新:
"""用户登录 - 支持用户名或用户编码登录"""
# 更新:
# 先尝试用户名登录
# 更新:
user = db.query(User).filter(User.f01_01_name == form_data.username).first()
# 更新:
# 如果用户名不存在,尝试用户编码登录
# 更新:
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 验证旧密码
if not verify_password(old_password, user.password):
# 更新:
user = db.query(User).filter(User.user_code == form_data.username).first()
# 更新:
if not user:
# 更新:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="当前密码错误"
# 更新:
status_code=status.HTTP_401_UNAUTHORIZED,
# 更新:
detail="E00011: 用户名或密码错误",
# 更新:
headers={"WWW-Authenticate": "Bearer"},
# 更新:
)
# 更新:
# 更新密码
user.password = get_password_hash(new_password)
# 更新:
# 验证密码
# 更新:
if not verify_password(form_data.password, user.password):
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_401_UNAUTHORIZED,
# 更新:
detail="E00011: 用户名或密码错误",
# 更新:
headers={"WWW-Authenticate": "Bearer"},
# 更新:
)
# 更新:
# 更新:
# 更新登录次数和最后登录时间
# 更新:
from datetime import datetime
# 更新:
user.f99_98_login_count = (user.f99_98_login_count or 0) + 1
# 更新:
user.f99_99_last_login = datetime.now()
# 更新:
db.commit()
# 更新:
# 更新:
# 生成 token
# 更新:
access_token = create_access_token(data={"sub": user.f99_90_id})
# 更新:
# 更新:
return {
# 更新:
"access_token": access_token,
# 更新:
"token_type": "bearer"
# 更新:
}
# 更新:
# 更新:
# 更新:
@router.get("/me", response_model=UserResponse)
# 更新:
def get_current_user_info(
# 更新:
current_user: User = Depends(lambda: None)
# 更新:
):
# 更新:
"""获取当前用户信息"""
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_501_NOT_IMPLEMENTED,
# 更新:
detail="请使用正确的依赖注入"
# 更新:
)
# 更新:
# 更新:
# 更新:
@router.post("/change-password")
# 更新:
def change_password(
# 更新:
old_password: str = Body(...),
# 更新:
new_password: str = Body(...),
# 更新:
current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""修改当前用户密码"""
# 更新:
from app.core.auth import verify_password, get_password_hash
# 更新:
# 更新:
# 在当前session中重新查询用户
# 更新:
user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first()
# 更新:
if not user:
# 更新:
raise HTTPException(status_code=404, detail="用户不存在")
# 更新:
# 更新:
# 验证旧密码
# 更新:
if not verify_password(old_password, user.password):
# 更新:
raise HTTPException(
# 更新:
status_code=status.HTTP_400_BAD_REQUEST,
# 更新:
detail="当前密码错误"
# 更新:
)
# 更新:
# 更新:
# 更新密码
# 更新:
user.password = get_password_hash(new_password)
# 更新:
db.commit()
# 更新:
# 更新:
return {"message": "密码修改成功"}
# 更新:
# 更新:
# 更新:
# ============ 短信验证码接口 ============
# 更新:
# 更新:
@router.post("/send-verification-code")
# 更新:
def send_verification_code(
# 更新:
phone: str = Body(..., min_length=11, max_length=11),
# 更新:
purpose: str = Body("register") # register | login | reset_password
# 更新:
):
# 更新:
"""发送短信验证码"""
# 更新:
from app.services.sms import send_verification_code as send_sms
# 更新:
# 更新:
# 验证手机号格式
# 更新:
if not phone.startswith("1") or len(phone) != 11:
# 更新:
return {"success": False, "message": "手机号格式不正确"}
# 更新:
# 更新:
result = send_sms(phone)
# 更新:
# 更新:
if result["success"]:
# 更新:
return {
# 更新:
"success": True,
# 更新:
"message": f"验证码已发送到 {phone[:3]}****{phone[7:]}",
# 更新:
"expire": result.get("expire", 300)
# 更新:
}
# 更新:
else:
# 更新:
return result
# 更新:
# 更新:
# 更新:
@router.post("/verify-code")
# 更新:
def verify_code(
# 更新:
phone: str = Body(...),
# 更新:
code: str = Body(..., min_length=6, max_length=6)
# 更新:
):
# 更新:
"""验证短信验证码(仅验证,不执行后续操作)"""
# 更新:
from app.services.sms import verify_code as check_code
# 更新:
# 更新:
is_valid = check_code(phone, code)
# 更新:
# 更新:
if is_valid:
# 更新:
return {"success": True, "message": "验证成功"}
# 更新:
else:
# 更新:
return {"success": False, "message": "验证码错误或已过期"}
# 更新:

File diff suppressed because it is too large Load Diff

502
backend/app/routers/deal.py Normal file
View File

@ -0,0 +1,502 @@
# deal - 成交行情路由
# Version: 1.2.85
# 更新:
from fastapi import APIRouter, Depends, Query, HTTPException
# 更新:
# Version: 1.2.x
# 更新:
from sqlalchemy.orm import Session
# 更新:
from pydantic import BaseModel
# 更新:
from typing import Optional
# 更新:
from datetime import datetime, date
# 更新:
from app.core.database import get_db
# 更新:
from app.core.auth import get_current_user
# 更新:
from app.models.deal_info import DealInfo
# 更新:
# 更新:
router = APIRouter(prefix="/api/deal", tags=["成交行情"])
# 更新:
# 更新:
# ============ Schema ============
# 更新:
class DealInfoCreate(BaseModel):
# 更新:
title: str
# 更新:
content: Optional[str] = None
# 更新:
deal_price: Optional[float] = None
# 更新:
deal_date: Optional[str] = None # YYYY-MM-DD
# 更新:
packaging: Optional[str] = None
# 更新:
category: Optional[str] = None
# 更新:
is_graded: Optional[bool] = False
# 更新:
grading_company: Optional[str] = None
# 更新:
grading_score: Optional[str] = None
# 更新:
tail_number: Optional[str] = None
# 更新:
size_type: Optional[str] = None
# 更新:
version: Optional[str] = None
# 更新:
platform: Optional[str] = None
# 更新:
seller: Optional[str] = None
# 更新:
buyer: Optional[str] = None
# 更新:
# 更新:
class DealInfoUpdate(BaseModel):
# 更新:
title: Optional[str] = None
# 更新:
content: Optional[str] = None
# 更新:
deal_price: Optional[float] = None
# 更新:
deal_date: Optional[str] = None
# 更新:
packaging: Optional[str] = None
# 更新:
category: Optional[str] = None
# 更新:
is_graded: Optional[bool] = None
# 更新:
grading_company: Optional[str] = None
# 更新:
grading_score: Optional[str] = None
# 更新:
tail_number: Optional[str] = None
# 更新:
size_type: Optional[str] = None
# 更新:
version: Optional[str] = None
# 更新:
platform: Optional[str] = None
# 更新:
seller: Optional[str] = None
# 更新:
buyer: Optional[str] = None
# 更新:
status: Optional[str] = None
# 更新:
# 更新:
class DealInfoResponse(BaseModel):
# 更新:
id: str
# 更新:
user_id: Optional[str]
# 更新:
title: str
# 更新:
content: Optional[str]
# 更新:
deal_price: Optional[float]
# 更新:
deal_date: Optional[date]
# 更新:
deal_no: Optional[str]
# 更新:
packaging: Optional[str]
# 更新:
category: Optional[str]
# 更新:
is_graded: Optional[bool]
# 更新:
grading_company: Optional[str]
# 更新:
grading_score: Optional[str]
# 更新:
tail_number: Optional[str]
# 更新:
size_type: Optional[str]
# 更新:
version: Optional[str]
# 更新:
platform: Optional[str]
# 更新:
seller: Optional[str]
# 更新:
buyer: Optional[str]
# 更新:
status: str
# 更新:
view_count: int
# 更新:
contact_count: int
# 更新:
created_at: Optional[datetime]
# 更新:
updated_at: Optional[datetime]
# 更新:
# 更新:
class Config:
# 更新:
from_attributes = True
# 更新:
# 更新:
# 生成行情编号
# 更新:
def generate_deal_no(db: Session):
# 更新:
"""生成行情编号从A000001开始递增"""
# 更新:
last = db.query(DealInfo).order_by(DealInfo.deal_no.desc()).first()
# 更新:
if last and last.deal_no:
# 更新:
# 例如 A000001 -> 2 -> A000002
# 更新:
num = int(last.deal_no[1:]) + 1
# 更新:
return f"A{num:06d}"
# 更新:
return "A000001"
# 更新:
# 更新:
# ============ API ============
# 更新:
@router.get("/list", response_model=list[DealInfoResponse])
# 更新:
def get_deal_list(
# 更新:
status: str = Query("active"),
# 更新:
deal_date: Optional[str] = Query(None),
# 更新:
page: int = Query(1, ge=1),
# 更新:
page_size: int = Query(20, ge=1, le=1000),
# 更新:
user_only: bool = Query(False), # 是否只查看自己的
# 更新:
current_user: Optional = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取成交行情列表"""
# 更新:
query = db.query(DealInfo).filter(DealInfo.status == status)
# 更新:
# 更新:
# 我的行情:只查看自己的(管理员也只看自己的)
# 更新:
if user_only and current_user:
# 更新:
query = query.filter(DealInfo.user_id == current_user.f99_90_id)
# 更新:
# 更新:
# 成交日期过滤
# 更新:
if deal_date:
# 更新:
query = query.filter(DealInfo.deal_date == deal_date)
# 更新:
# 更新:
# 排序:优先成交日期倒序,同日按编号倒序
# 更新:
query = query.order_by(DealInfo.deal_date.desc().nullslast(), DealInfo.deal_no.desc().nullslast())
# 更新:
# 更新:
# 分页
# 更新:
offset = (page - 1) * page_size
# 更新:
items = query.offset(offset).limit(page_size).all()
# 更新:
# 更新:
return items
# 更新:
# 更新:
@router.get("/stats")
# 更新:
def get_deal_stats(
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取成交行情统计"""
# 更新:
total = db.query(DealInfo).filter(DealInfo.status == "active").count()
# 更新:
# 更新:
# 按日期统计
# 更新:
from sqlalchemy import func
# 更新:
date_stats = db.query(
# 更新:
DealInfo.deal_date,
# 更新:
func.count(DealInfo.id).label('count')
# 更新:
).filter(
# 更新:
DealInfo.status == "active",
# 更新:
DealInfo.deal_date.isnot(None)
# 更新:
).group_by(DealInfo.deal_date).order_by(DealInfo.deal_date.desc()).limit(10).all()
# 更新:
# 更新:
return {
# 更新:
"total": total,
# 更新:
"by_date": [{"date": str(d.deal_date), "count": d.count} for d in date_stats]
# 更新:
}
# 更新:
# 更新:
@router.post("", response_model=DealInfoResponse)
# 更新:
def create_deal(
# 更新:
data: DealInfoCreate,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""创建成交行情"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
# 生成行情编号
# 更新:
deal_no = generate_deal_no(db)
# 更新:
# 更新:
# 解析日期
# 更新:
deal_date = None
# 更新:
if data.deal_date:
# 更新:
try:
# 更新:
deal_date = datetime.strptime(data.deal_date, "%Y-%m-%d").date()
# 更新:
except:
# 更新:
pass
# 更新:
# 更新:
deal = DealInfo(
# 更新:
user_id=current_user.f99_90_id if current_user else None,
# 更新:
title=data.title,
# 更新:
content=data.content,
# 更新:
deal_price=data.deal_price,
# 更新:
deal_date=deal_date,
# 更新:
deal_no=deal_no,
# 更新:
packaging=data.packaging,
# 更新:
category=data.category,
# 更新:
is_graded=data.is_graded or False,
# 更新:
grading_company=data.grading_company,
# 更新:
grading_score=data.grading_score,
# 更新:
tail_number=data.tail_number,
# 更新:
size_type=data.size_type,
# 更新:
version=data.version,
# 更新:
platform=data.platform,
# 更新:
seller=data.seller,
# 更新:
buyer=data.buyer,
# 更新:
status="active"
# 更新:
)
# 更新:
db.add(deal)
# 更新:
db.commit()
# 更新:
db.refresh(deal)
# 更新:
return deal
# 更新:
# 更新:
@router.get("/{deal_id}", response_model=DealInfoResponse)
# 更新:
def get_deal(
# 更新:
deal_id: str,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取成交行情详情"""
# 更新:
deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first()
# 更新:
if not deal:
# 更新:
raise HTTPException(status_code=404, detail="成交行情不存在")
# 更新:
# 更新:
# 增加浏览数
# 更新:
deal.view_count += 1
# 更新:
db.commit()
# 更新:
# 更新:
return deal
# 更新:
# 更新:
@router.put("/{deal_id}", response_model=DealInfoResponse)
# 更新:
def update_deal(
# 更新:
deal_id: str,
# 更新:
data: DealInfoUpdate,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""更新成交行情"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first()
# 更新:
if not deal:
# 更新:
raise HTTPException(status_code=404, detail="成交行情不存在")
# 更新:
# 更新:
# 处理日期
# 更新:
if data.deal_date:
# 更新:
try:
# 更新:
data.deal_date = datetime.strptime(data.deal_date, "%Y-%m-%d").date()
# 更新:
except:
# 更新:
data.deal_date = None
# 更新:
# 更新:
for key, value in data.model_dump(exclude_unset=True).items():
# 更新:
setattr(deal, key, value)
# 更新:
# 更新:
db.commit()
# 更新:
db.refresh(deal)
# 更新:
return deal
# 更新:
# 更新:
@router.delete("/{deal_id}")
# 更新:
def delete_deal(
# 更新:
deal_id: str,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""删除成交行情"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first()
# 更新:
if not deal:
# 更新:
raise HTTPException(status_code=404, detail="成交行情不存在")
# 更新:
# 更新:
deal.status = "deleted"
# 更新:
db.commit()
# 更新:
# 更新:
return {"message": "删除成功"}
# 更新:

File diff suppressed because it is too large Load Diff

262
backend/app/routers/news.py Normal file
View File

@ -0,0 +1,262 @@
# news - 新闻路由
# Version: 1.2.80
# 更新:
from fastapi import APIRouter, Depends, HTTPException, Query
# 更新:
# Version: 1.2.x
# 更新:
from sqlalchemy import Table, MetaData
# 更新:
from sqlalchemy.orm import Session
# 更新:
from pydantic import BaseModel
# 更新:
from typing import Optional, List
# 更新:
from datetime import datetime, date
# 更新:
from app.core.database import get_db, engine
# 更新:
from app.models.models import User
# 更新:
from app.routers.auth import get_current_user
# 更新:
# 更新:
router = APIRouter(prefix="/api/news", tags=["资讯"])
# 更新:
metadata = MetaData()
# 更新:
# 更新:
# 分类表
# 更新:
categories_table = Table('news_categories', metadata, autoload_with=engine)
# 更新:
news_table = Table('news', metadata, autoload_with=engine)
# 更新:
user_posts_table = Table('user_posts', metadata, autoload_with=engine)
# 更新:
users_table = Table('users', metadata, autoload_with=engine)
# 更新:
deals_table = Table('deals', metadata, autoload_with=engine)
# 更新:
notifications_table = Table('notifications', metadata, autoload_with=engine)
# 更新:
# 更新:
# ============ 获取分类 ============
# 更新:
@router.get("/categories")
# 更新:
def get_categories(db: Session = Depends(get_db)):
# 更新:
results = db.query(categories_table).order_by(categories_table.c.sort_order).all()
# 更新:
return [dict(r._mapping) for r in results]
# 更新:
# 更新:
# ============ 获取资讯 ============
# 更新:
@router.get("")
# 更新:
def get_news(
# 更新:
category_id: Optional[int] = None,
# 更新:
page: int = 1,
# 更新:
limit: int = 20,
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
query = db.query(news_table)
# 更新:
if category_id:
# 更新:
query = query.filter(news_table.c.category_id == category_id)
# 更新:
offset = (page - 1) * limit
# 更新:
results = query.order_by(news_table.c.created_at.desc()).offset(offset).limit(limit).all()
# 更新:
return [dict(r._mapping) for r in results]
# 更新:
# 更新:
# ============ 获取用户发布 ============
# 更新:
@router.get("/posts")
# 更新:
def get_posts(
# 更新:
post_type: Optional[str] = None,
# 更新:
status: str = "active",
# 更新:
page: int = 1,
# 更新:
limit: int = 20,
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
query = db.query(user_posts_table).filter(user_posts_table.c.status == status)
# 更新:
if post_type:
# 更新:
query = query.filter(user_posts_table.c.post_type == post_type)
# 更新:
offset = (page - 1) * limit
# 更新:
results = query.order_by(user_posts_table.c.created_at.desc()).offset(offset).limit(limit).all()
# 更新:
return [dict(r._mapping) for r in results]
# 更新:
# 更新:
# ============ 创建发布 ============
# 更新:
class PostCreate(BaseModel):
# 更新:
post_type: str
# 更新:
title: str
# 更新:
content: Optional[str] = None
# 更新:
zodiac_type: Optional[str] = None
# 更新:
packaging: Optional[str] = None
# 更新:
# 更新:
@router.post("/posts")
# 更新:
def create_post(
# 更新:
post: PostCreate,
# 更新:
current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
result = db.execute(user_posts_table.insert().values(
# 更新:
user_id=current_user.f99_90_id,
# 更新:
post_type=post.post_type,
# 更新:
title=post.title,
# 更新:
content=post.content,
# 更新:
zodiac_type=post.zodiac_type,
# 更新:
packaging=post.packaging,
# 更新:
status="pending"
# 更新:
))
# 更新:
db.commit()
# 更新:
return {"success": True, "id": result.inserted_primary_key[0]}
# 更新:
# 更新:
# ============ 成交数据 ============
# 更新:
@router.get("/deals")
# 更新:
def get_deals(
# 更新:
zodiac_type: Optional[str] = None,
# 更新:
limit: int = 20,
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
query = db.query(deals_table)
# 更新:
if zodiac_type:
# 更新:
query = query.filter(deals_table.c.zodiac_type == zodiac_type)
# 更新:
results = query.order_by(deals_table.c.deal_date.desc()).limit(limit).all()
# 更新:
return [dict(r._mapping) for r in results]
# 更新:
# 更新:
# ============ 通知 ============
# 更新:
@router.get("/notifications")
# 更新:
def get_notifications(limit: int = 10, db: Session = Depends(get_db)):
# 更新:
results = db.query(notifications_table).filter(
# 更新:
notifications_table.c.is_published == True
# 更新:
).order_by(notifications_table.c.created_at.desc()).limit(limit).all()
# 更新:
return [dict(r._mapping) for r in results]
# 更新:
# 更新:
# ============ 首页数据 ============
# 更新:
@router.get("/home")
# 更新:
def get_home(db: Session = Depends(get_db)):
# 更新:
# 推荐发布
# 更新:
posts = db.query(user_posts_table).filter(
# 更新:
user_posts_table.c.status == "active"
# 更新:
).order_by(user_posts_table.c.created_at.desc()).limit(10).all()
# 更新:
# 更新:
# 成交
# 更新:
deals = db.query(deals_table).order_by(
# 更新:
deals_table.c.deal_date.desc()
# 更新:
).limit(10).all()
# 更新:
# 更新:
# 通知
# 更新:
notices = db.query(notifications_table).filter(
# 更新:
notifications_table.c.is_published == True
# 更新:
).order_by(notifications_table.c.created_at.desc()).limit(5).all()
# 更新:
# 更新:
return {
# 更新:
"posts": [dict(p._mapping) for p in posts],
# 更新:
"deals": [dict(d._mapping) for d in deals],
# 更新:
"notices": [dict(n._mapping) for n in notices]
# 更新:
}
# 更新:

File diff suppressed because it is too large Load Diff

View File

@ -1,104 +1,210 @@
# 操作路由
# operations - 运营操作路由
# Version: 1.2.70
# 更新:
from typing import List, Optional
# 更新:
from fastapi import APIRouter, Depends, HTTPException, status, Query
# 更新:
from sqlalchemy.orm import Session
# 更新:
from app.core.database import get_db
# 更新:
from app.core.auth import get_current_user
# 更新:
from app.models.models import User, Collection, Operation
# 更新:
from app.schemas.schemas import OperationCreate, OperationResponse
# 更新:
# 更新:
router = APIRouter(prefix="/api", tags=["操作"])
# 更新:
# 更新:
# 更新:
@router.get("/operations", response_model=List[OperationResponse])
# 更新:
def get_operations(
# 更新:
collection_id: Optional[str] = None,
# 更新:
page: int = Query(1, ge=1),
# 更新:
limit: int = Query(50, ge=1, le=100),
# 更新:
current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取操作历史"""
# 更新:
query = db.query(Operation).filter(Operation.user_id == current_user.id)
# 更新:
# 更新:
if collection_id:
# 更新:
query = query.filter(Operation.collection_id == collection_id)
# 更新:
# 更新:
operations = query.order_by(Operation.created_at.desc()) \
# 更新:
.offset((page - 1) * limit) \
# 更新:
.limit(limit) \
# 更新:
.all()
# 更新:
# 更新:
return operations
# 更新:
# 更新:
# 更新:
@router.get("/operations/history")
# 更新:
def get_operation_history(
# 更新:
collection_id: Optional[str] = None,
# 更新:
type: Optional[str] = None,
# 更新:
start_date: Optional[str] = None,
# 更新:
end_date: Optional[str] = None,
# 更新:
page: int = Query(1, ge=1),
# 更新:
limit: int = Query(50, ge=1, le=100),
# 更新:
current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取操作历史(带统计)"""
# 更新:
query = db.query(Operation).filter(Operation.user_id == current_user.id)
# 更新:
# 更新:
if collection_id:
# 更新:
query = query.filter(Operation.collection_id == collection_id)
# 更新:
if type:
# 更新:
query = query.filter(Operation.type == type)
# 更新:
if start_date:
# 更新:
query = query.filter(Operation.created_at >= start_date)
# 更新:
if end_date:
# 更新:
query = query.filter(Operation.created_at <= end_date)
# 更新:
# 更新:
total = query.count()
# 更新:
# 更新:
data = query.order_by(Operation.created_at.desc()) \
# 更新:
.offset((page - 1) * limit) \
# 更新:
.limit(limit) \
# 更新:
.all()
# 更新:
# 更新:
return {
# 更新:
"data": data,
# 更新:
"pagination": {
# 更新:
"page": page,
# 更新:
"limit": limit,
# 更新:
"total": total,
# 更新:
"pages": (total + limit - 1) // limit
# 更新:
}
# 更新:
}
# 更新:
# 更新:
# 更新:
@router.post("/operations", response_model=OperationResponse)
# 更新:
def create_operation(
# 更新:
operation_data: OperationCreate,
# 更新:
current_user: User = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""创建操作记录"""
# 更新:
# 验证藏品存在
# 更新:
collection = db.query(Collection).filter(
# 更新:
Collection.id == operation_data.collection_id,
# 更新:
Collection.user_id == current_user.id
# 更新:
).first()
# 更新:
# 更新:
if not collection:
# 更新:
raise HTTPException(status_code=404, detail="藏品不存在")
# 更新:
# 更新:
operation = Operation(
# 更新:
collection_id=operation_data.collection_id,
# 更新:
user_id=current_user.id,
# 更新:
type=operation_data.type,
# 更新:
price=operation_data.price,
# 更新:
note=operation_data.note
# 更新:
)
# 更新:
# 更新:
db.add(operation)
# 更新:
db.commit()
# 更新:
db.refresh(operation)
# 更新:
# 更新:
return operation
# 更新:

384
backend/app/routers/seek.py Normal file
View File

@ -0,0 +1,384 @@
# seek - 寻号匹配路由
# Version: 1.2.70
# 更新:
from fastapi import APIRouter, Depends, Query, HTTPException
# 更新:
# Version: 1.2.x
# 更新:
from sqlalchemy.orm import Session
# 更新:
from pydantic import BaseModel
# 更新:
from typing import Optional
# 更新:
from datetime import datetime
# 更新:
from app.core.database import get_db
# 更新:
from app.core.auth import get_current_user
# 更新:
from app.models.seek_info import SeekInfo
# 更新:
# 更新:
router = APIRouter(prefix="/api/seek", tags=["寻配号"])
# 更新:
# 更新:
# ============ Schema ============
# 更新:
class SeekInfoCreate(BaseModel):
# 更新:
title: str
# 更新:
content: Optional[str] = None
# 更新:
expect_category: Optional[str] = None
# 更新:
expect_version: Optional[str] = None
# 更新:
expect_packaging: Optional[str] = None
# 更新:
expect_number: Optional[str] = None
# 更新:
expect_price_min: Optional[float] = None
# 更新:
expect_price_max: Optional[float] = None
# 更新:
# 更新:
class SeekInfoUpdate(BaseModel):
# 更新:
title: Optional[str] = None
# 更新:
content: Optional[str] = None
# 更新:
expect_category: Optional[str] = None
# 更新:
expect_version: Optional[str] = None
# 更新:
expect_packaging: Optional[str] = None
# 更新:
expect_number: Optional[str] = None
# 更新:
expect_price_min: Optional[float] = None
# 更新:
expect_price_max: Optional[float] = None
# 更新:
status: Optional[str] = None
# 更新:
# 更新:
class SeekInfoResponse(BaseModel):
# 更新:
id: str
# 更新:
user_id: str
# 更新:
title: str
# 更新:
content: Optional[str]
# 更新:
expect_category: Optional[str]
# 更新:
expect_version: Optional[str]
# 更新:
expect_packaging: Optional[str]
# 更新:
expect_number: Optional[str]
# 更新:
expect_price_min: Optional[float]
# 更新:
expect_price_max: Optional[float]
# 更新:
status: str
# 更新:
is_matched: Optional[str]
# 更新:
matched_user_id: Optional[str]
# 更新:
matched_contact: Optional[str]
# 更新:
view_count: int
# 更新:
contact_count: int
# 更新:
created_at: Optional[datetime]
# 更新:
updated_at: Optional[datetime]
# 更新:
# 更新:
class Config:
# 更新:
from_attributes = True
# 更新:
# 更新:
# ============ API ============
# 更新:
@router.get("/list", response_model=list[SeekInfoResponse])
# 更新:
def get_seek_list(
# 更新:
status: str = Query("active"),
# 更新:
page: int = Query(1, ge=1),
# 更新:
page_size: int = Query(20, ge=1, le=1000),
# 更新:
user_only: bool = Query(False),
# 更新:
current_user: Optional = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取寻配号列表"""
# 更新:
query = db.query(SeekInfo).filter(SeekInfo.status == status)
# 更新:
# 更新:
# 我的寻配号:只查看自己的
# 更新:
if user_only and current_user:
# 更新:
query = query.filter(SeekInfo.user_id == current_user.f99_90_id)
# 更新:
# 更新:
# 排序
# 更新:
query = query.order_by(SeekInfo.created_at.desc())
# 更新:
# 更新:
# 分页
# 更新:
offset = (page - 1) * page_size
# 更新:
items = query.offset(offset).limit(page_size).all()
# 更新:
# 更新:
return items
# 更新:
# 更新:
@router.get("/stats")
# 更新:
def get_seek_stats(
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取寻配号统计"""
# 更新:
total = db.query(SeekInfo).filter(SeekInfo.status == "active").count()
# 更新:
matched = db.query(SeekInfo).filter(SeekInfo.is_matched == "true").count()
# 更新:
# 更新:
return {
# 更新:
"total": total,
# 更新:
"matched": matched,
# 更新:
"unmatched": total - matched
# 更新:
}
# 更新:
# 更新:
@router.post("", response_model=SeekInfoResponse)
# 更新:
def create_seek(
# 更新:
data: SeekInfoCreate,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""创建寻配号"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
seek = SeekInfo(
# 更新:
user_id=current_user.f99_90_id,
# 更新:
title=data.title,
# 更新:
content=data.content,
# 更新:
expect_category=data.expect_category,
# 更新:
expect_version=data.expect_version,
# 更新:
expect_packaging=data.expect_packaging,
# 更新:
expect_number=data.expect_number,
# 更新:
expect_price_min=data.expect_price_min,
# 更新:
expect_price_max=data.expect_price_max,
# 更新:
status="active"
# 更新:
)
# 更新:
db.add(seek)
# 更新:
db.commit()
# 更新:
db.refresh(seek)
# 更新:
return seek
# 更新:
# 更新:
@router.get("/{seek_id}", response_model=SeekInfoResponse)
# 更新:
def get_seek(
# 更新:
seek_id: str,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""获取寻配号详情"""
# 更新:
seek = db.query(SeekInfo).filter(SeekInfo.id == seek_id).first()
# 更新:
if not seek:
# 更新:
raise HTTPException(status_code=404, detail="寻配号不存在")
# 更新:
# 更新:
# 增加浏览数
# 更新:
seek.view_count += 1
# 更新:
db.commit()
# 更新:
# 更新:
return seek
# 更新:
# 更新:
@router.put("/{seek_id}", response_model=SeekInfoResponse)
# 更新:
def update_seek(
# 更新:
seek_id: str,
# 更新:
data: SeekInfoUpdate,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""更新寻配号"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
seek = db.query(SeekInfo).filter(
# 更新:
SeekInfo.id == seek_id,
# 更新:
SeekInfo.user_id == current_user.f99_90_id
# 更新:
).first()
# 更新:
# 更新:
if not seek:
# 更新:
raise HTTPException(status_code=404, detail="寻配号不存在")
# 更新:
# 更新:
for key, value in data.model_dump(exclude_unset=True).items():
# 更新:
setattr(seek, key, value)
# 更新:
# 更新:
db.commit()
# 更新:
db.refresh(seek)
# 更新:
return seek
# 更新:
# 更新:
@router.delete("/{seek_id}")
# 更新:
def delete_seek(
# 更新:
seek_id: str,
# 更新:
current_user = Depends(get_current_user),
# 更新:
db: Session = Depends(get_db)
# 更新:
):
# 更新:
"""删除寻配号"""
# 更新:
if not current_user:
# 更新:
raise HTTPException(status_code=401, detail="请先登录")
# 更新:
# 更新:
seek = db.query(SeekInfo).filter(
# 更新:
SeekInfo.id == seek_id,
# 更新:
SeekInfo.user_id == current_user.f99_90_id
# 更新:
).first()
# 更新:
# 更新:
if not seek:
# 更新:
raise HTTPException(status_code=404, detail="寻配号不存在")
# 更新:
# 更新:
seek.status = "deleted"
# 更新:
db.commit()
# 更新:
# 更新:
return {"message": "删除成功"}
# 更新:

View File

@ -1,4 +1,7 @@
# 用户管理路由
# users.py - 用户管理路由
# Version: 1.2.98 (2026-04-19)
# 更新:新增 dealCount 字段,从 Information 表统计用户发布的行情数量
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
from sqlalchemy.orm import Session
@ -11,12 +14,14 @@ router = APIRouter(prefix="/api", tags=["用户"])
# ============ 当前用户接口 ============
@router.get("/users/me", response_model=UserResponse)
@router.get("/users/me") # 无 response_model避免 Pydantic 序列化问题
def get_current_user_info(
current_user: User = Depends(get_current_user)
):
"""获取当前登录用户信息"""
return {
"id": current_user.f99_90_id,
"username": current_user.f01_01_name,
"f99_90_id": current_user.f99_90_id,
"f01_01_name": current_user.f01_01_name,
"email": current_user.email,
@ -25,11 +30,27 @@ def get_current_user_info(
"address": current_user.address,
"bio": current_user.bio,
"role": current_user.role,
"level": current_user.f99_94_level,
"aiCount": current_user.f99_95_ai_count or 0,
"searchCount": current_user.f99_96_search_count or 0,
"collectionCount": current_user.f99_97_collection_count or 0,
"phoneVerified": current_user.f01_06_phone_verified or False,
"loginCount": current_user.f99_98_login_count or 0,
"lastLogin": current_user.f99_99_last_login.isoformat() if current_user.f99_99_last_login else None,
"gender": current_user.f01_07_gender,
"birthday": current_user.f01_08_birthday.isoformat() if current_user.f01_08_birthday else None,
"region": current_user.f01_09_region,
"realnameVerified": current_user.f01_10_realname_verified or False,
"points": current_user.f99_100_points or 0,
"balance": float(current_user.f01_11_balance) if current_user.f01_11_balance else 0,
"totalAmount": float(current_user.f01_12_total_amount) if current_user.f01_12_total_amount else 0,
"inviteCode": current_user.f01_13_invite_code,
"user_code": current_user.user_code,
"f99_92_created_at": current_user.f99_92_created_at.isoformat() if current_user.f99_92_created_at else None,
"f99_93_updated_at": current_user.f99_93_updated_at.isoformat() if current_user.f99_93_updated_at else None
}
@router.put("/users/me", response_model=UserResponse)
@router.put("/users/me") # 无 response_model避免 Pydantic 序列化问题
def update_current_user(
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
@ -79,24 +100,46 @@ def get_users(
db: Session = Depends(get_db)
):
"""获取用户列表(仅管理员)"""
if current_user.role != "admin":
if current_user.role not in ["admin", "editor"]:
raise HTTPException(status_code=403, detail="无权访问")
total = db.query(User).count()
users = db.query(User).offset((page-1)*limit).limit(limit).all()
from sqlalchemy import case
users = db.query(User).order_by(
case(
(User.user_code == None, 1),
else_=0
),
User.user_code.asc()
).offset((page-1)*limit).limit(limit).all()
user_list = []
for u in users:
# 统计每个用户的藏品数量
count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count()
# 统计每个用户发布的行情数量info_type='deal'
deal_count = db.query(Information).filter(
Information.user_id == u.f99_90_id,
Information.info_type == "deal"
).count()
user_list.append({
"id": u.f99_90_id,
"username": u.f01_01_name,
"email": u.email,
"phone": u.phone,
"role": u.role,
"user_code": u.user_code,
"created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None,
"collection_count": count
"collectionCount": count,
"dealCount": deal_count,
"level": u.f99_94_level,
"aiCount": u.f99_95_ai_count,
"searchCount": u.f99_96_search_count,
"loginCount": u.f99_98_login_count,
"points": u.f99_100_points,
"balance": float(u.f01_11_balance) if u.f01_11_balance else 0,
"totalAmount": float(u.f01_12_total_amount) if u.f01_12_total_amount else 0,
"phoneVerified": u.f01_06_phone_verified,
})
return user_list
@ -109,7 +152,7 @@ def get_user(
db: Session = Depends(get_db)
):
"""获取单个用户信息"""
if current_user.role != "admin":
if current_user.role not in ["admin", "editor"]:
raise HTTPException(status_code=403, detail="无权访问")
user = db.query(User).filter(User.id == user_id).first()
@ -134,7 +177,7 @@ def get_user_collections(
db: Session = Depends(get_db)
):
"""获取指定用户的藏品列表"""
if current_user.role != "admin":
if current_user.role not in ["admin", "editor"]:
raise HTTPException(status_code=403, detail="无权访问")
collections = db.query(Collection).filter(
@ -151,7 +194,7 @@ def get_user_collection_count(
db: Session = Depends(get_db)
):
"""获取指定用户的藏品数量"""
if current_user.role != "admin":
if current_user.role not in ["admin", "editor"]:
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
count = db.query(Collection).filter(Collection.user_id == user_id).count()
@ -165,11 +208,18 @@ def update_user(
email: Optional[str] = Body(None),
role: Optional[str] = Body(None),
password: Optional[str] = Body(None),
user_code: Optional[str] = Body(None),
level: Optional[str] = Body(None),
points: Optional[int] = Body(None),
balance: Optional[float] = Body(None),
totalAmount: Optional[float] = Body(None),
aiCount: Optional[int] = Body(None),
searchCount: Optional[int] = Body(None),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""更新用户信息(仅管理员)"""
if current_user.role != "admin":
if current_user.role not in ["admin", "editor"]:
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
user = db.query(User).filter(User.f99_90_id == user_id).first()
@ -184,6 +234,45 @@ def update_user(
if role is not None:
user.role = role
# 更新会员等级
if level is not None:
user.f99_94_level = level
# 更新积分
if points is not None:
user.f99_100_points = points
# 更新余额
if balance is not None:
user.f01_11_balance = balance
# 更新累计金额
if totalAmount is not None:
user.f01_12_total_amount = totalAmount
# 更新AI识别次数
if aiCount is not None:
user.f99_95_ai_count = aiCount
# 更新寻号次数
if searchCount is not None:
user.f99_96_search_count = searchCount
# 更新用户编码
if user_code is not None:
# 只有非空字符串才检查唯一性
user_code_str = user_code.strip() if user_code else ''
if user_code_str:
existing = db.query(User).filter(
User.user_code == user_code_str,
User.f99_90_id != user_id
).first()
if existing:
raise HTTPException(status_code=400, detail="E00052: 该用户编码已被其他用户使用")
user.user_code = user_code_str
else:
user.user_code = None
# 更新密码
if password and password.strip():
from app.core.auth import get_password_hash
@ -208,7 +297,7 @@ def delete_user(
db: Session = Depends(get_db)
):
"""删除用户(仅管理员)"""
if current_user.role != "admin":
if current_user.role not in ["admin", "editor"]:
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
# 不能删除自己

View File

@ -0,0 +1,760 @@
# yichens - 一尘数据路由
# Version: 1.2.70
# 更新:
from fastapi import APIRouter, Depends, Query
# 更新:
# Version: 1.2.x
# 更新:
from sqlalchemy import func, text
# 更新:
from sqlalchemy.orm import Session
# 更新:
from pydantic import BaseModel
# 更新:
from typing import Optional, List
# 更新:
from datetime import datetime, date
# 更新:
from app.core.coolbot_db import get_coolbot_db
# 更新:
# 更新:
router = APIRouter(prefix="/api/yichens", tags=["一尘看板"])
# 更新:
# 更新:
# ============ 数据模型 ============
# 更新:
class YichensPostStats(BaseModel):
# 更新:
total_posts: int
# 更新:
total_deals: int # 出售
# 更新:
total_wants: int # 求购
# 更新:
total_replies: int
# 更新:
total_views: int
# 更新:
avg_price: Optional[float]
# 更新:
# 更新:
class CategoryStat(BaseModel):
# 更新:
category: str
# 更新:
count: int
# 更新:
# 更新:
class PostItem(BaseModel):
# 更新:
post_id: str
# 更新:
title: str
# 更新:
category: Optional[str]
# 更新:
post_type: str
# 更新:
price: Optional[float]
# 更新:
author_username: str
# 更新:
post_time: str
# 更新:
reply_count: int
# 更新:
view_count: int
# 更新:
url: Optional[str]
# 更新:
content: Optional[str]
# 更新:
# 更新:
class UserStat(BaseModel):
# 更新:
total_users: int
# 更新:
new_users_today: int
# 更新:
sellers: int
# 更新:
# 更新:
class UserItem(BaseModel):
# 更新:
user_id: str
# 更新:
username: str
# 更新:
avatar_url: Optional[str]
# 更新:
content: Optional[str]
# 更新:
credit_level: Optional[str]
# 更新:
credit_score: Optional[int]
# 更新:
post_count: int
# 更新:
is_seller: bool
# 更新:
registration_date: Optional[str]
# 更新:
# 更新:
# ============ 统计接口 ============
# 更新:
@router.get("/stats/posts", response_model=YichensPostStats)
# 更新:
def get_post_stats(days: int = Query(30, ge=1, le=365), db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取帖子统计"""
# 更新:
result = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total_posts,
# 更新:
COUNT(*) FILTER (WHERE post_type = 'deal') as total_deals,
# 更新:
COUNT(*) FILTER (WHERE post_type = 'want') as total_wants,
# 更新:
COALESCE(SUM(reply_count), 0) as total_replies,
# 更新:
COALESCE(SUM(view_count), 0) as total_views,
# 更新:
AVG(price) as avg_price
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= NOW() - INTERVAL '1 day' * :days
# 更新:
"""), {"days": days}).fetchone()
# 更新:
# 更新:
return YichensPostStats(
# 更新:
total_posts=result[0] or 0,
# 更新:
total_deals=result[1] or 0,
# 更新:
total_wants=result[2] or 0,
# 更新:
total_replies=result[3] or 0,
# 更新:
total_views=result[4] or 0,
# 更新:
avg_price=float(result[5]) if result[5] else None
# 更新:
)
# 更新:
# 更新:
@router.get("/stats/categories", response_model=List[CategoryStat])
# 更新:
def get_category_stats(days: int = Query(30, ge=1, le=365), db: Session = Depends(get_coolbot_db)):
# 更新:
"""按分类统计帖子数量"""
# 更新:
results = db.execute(text("""
# 更新:
SELECT category, COUNT(*) as count
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= NOW() - INTERVAL '1 day' * :days
# 更新:
GROUP BY category
# 更新:
ORDER BY count DESC
# 更新:
"""), {"days": days}).fetchall()
# 更新:
# 更新:
return [CategoryStat(category=r[0] or "未知", count=r[1]) for r in results]
# 更新:
# 更新:
@router.get("/stats/users", response_model=UserStat)
# 更新:
def get_user_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取用户统计"""
# 更新:
result = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total_users,
# 更新:
COUNT(*) FILTER (WHERE created_at::date = CURRENT_DATE) as new_users_today,
# 更新:
COUNT(*) FILTER (WHERE is_seller = true) as sellers
# 更新:
FROM yichens_users
# 更新:
""")).fetchone()
# 更新:
# 更新:
return UserStat(
# 更新:
total_users=result[0] or 0,
# 更新:
new_users_today=result[1] or 0,
# 更新:
sellers=result[2] or 0
# 更新:
)
# 更新:
# 更新:
@router.get("/posts")
# 更新:
def get_posts(
# 更新:
limit: int = Query(20, ge=1, le=500),
# 更新:
offset: int = Query(0, ge=0),
# 更新:
category: Optional[str] = None,
# 更新:
post_type: Optional[str] = None,
# 更新:
keyword: Optional[str] = None,
# 更新:
db: Session = Depends(get_coolbot_db)
# 更新:
):
# 更新:
"""获取帖子列表 - 支持全局搜索,返回总数和分页信息"""
# 更新:
# 构建WHERE条件
# 更新:
where_clauses = ["1=1"]
# 更新:
params = {"limit": limit, "offset": offset}
# 更新:
# 更新:
if category:
# 更新:
where_clauses.append("category = :category")
# 更新:
params["category"] = category
# 更新:
# 更新:
if post_type:
# 更新:
where_clauses.append("post_type = :post_type")
# 更新:
params["post_type"] = post_type
# 更新:
# 更新:
# 全局搜索
# 更新:
if keyword:
# 更新:
where_clauses.append("(title ILIKE :keyword OR content ILIKE :keyword OR category ILIKE :keyword OR author_username ILIKE :keyword)")
# 更新:
params["keyword"] = f"%{keyword}%"
# 更新:
# 更新:
where_sql = " AND ".join(where_clauses)
# 更新:
# 更新:
# 查询总数
# 更新:
count_query = f"SELECT COUNT(*) FROM yichens_posts WHERE {where_sql}"
# 更新:
total_count = db.execute(text(count_query), {k: v for k, v in params.items() if k not in ['limit', 'offset']}).scalar() or 0
# 更新:
# 更新:
# 查询数据 - 有post_time时按post_time排序没有时按crawled_at排序
# 更新:
data_query = f"""
# 更新:
SELECT post_id, title, content, category, post_type, price,
# 更新:
author_username, post_time, reply_count, view_count, url
# 更新:
FROM yichens_posts
# 更新:
WHERE {where_sql}
# 更新:
ORDER BY COALESCE(post_time, crawled_at) DESC LIMIT :limit OFFSET :offset
# 更新:
"""
# 更新:
results = db.execute(text(data_query), params).fetchall()
# 更新:
# 更新:
posts = [PostItem(
# 更新:
post_id=r[0],
# 更新:
title=r[1] or "",
# 更新:
content=r[2] or "",
# 更新:
category=r[3],
# 更新:
post_type=r[4] or "",
# 更新:
price=float(r[5]) if r[5] else None,
# 更新:
author_username=r[6] or "",
# 更新:
post_time=str(r[7]) if r[7] else "",
# 更新:
reply_count=r[8] or 0,
# 更新:
view_count=r[9] or 0,
# 更新:
url=r[10]
# 更新:
) for r in results]
# 更新:
# 更新:
return {
# 更新:
"posts": posts,
# 更新:
"total": total_count,
# 更新:
"page": offset // limit + 1,
# 更新:
"page_size": limit
# 更新:
}
# 更新:
# 更新:
@router.get("/users", response_model=List[UserItem])
# 更新:
def get_users(
# 更新:
limit: int = Query(20, ge=1, le=500),
# 更新:
offset: int = Query(0, ge=0),
# 更新:
is_seller: Optional[bool] = None,
# 更新:
db: Session = Depends(get_coolbot_db)
# 更新:
):
# 更新:
"""获取用户列表"""
# 更新:
query = """
# 更新:
SELECT user_id, username, avatar_url, credit_level, credit_score,
# 更新:
post_count, is_seller, registration_date
# 更新:
FROM yichens_users
# 更新:
WHERE 1=1
# 更新:
"""
# 更新:
params = {"limit": limit, "offset": offset}
# 更新:
# 更新:
if is_seller is not None:
# 更新:
query += " AND is_seller = :is_seller"
# 更新:
params["is_seller"] = is_seller
# 更新:
# 更新:
query += " ORDER BY post_count DESC LIMIT :limit OFFSET :offset"
# 更新:
# 更新:
results = db.execute(text(query), params).fetchall()
# 更新:
# 更新:
return [UserItem(
# 更新:
user_id=r[0],
# 更新:
username=r[1] or "",
# 更新:
avatar_url=r[2],
# 更新:
credit_level=r[3],
# 更新:
credit_score=r[4],
# 更新:
post_count=r[5] or 0,
# 更新:
is_seller=r[6] or False,
# 更新:
registration_date=str(r[7]) if r[7] else None
# 更新:
) for r in results]
# 更新:
# 更新:
# 更新:
@router.get("/stats/today")
# 更新:
async def get_today_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取今日新增帖子统计"""
# 更新:
query = """
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants,
# 更新:
SUM(CASE WHEN post_type = 'normal' THEN 1 ELSE 0 END) as others,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as dragons,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as horses,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as snakes,
# 更新:
SUM(CASE WHEN title LIKE '%天马%' OR content LIKE '%天马%' THEN 1 ELSE 0 END) as tianma
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
"""
# 更新:
result = db.execute(text(query)).fetchone()
# 更新:
return {
# 更新:
"total": result[0] or 0,
# 更新:
"deals": result[1] or 0,
# 更新:
"wants": result[2] or 0,
# 更新:
"others": result[3] or 0,
# 更新:
"dragons": result[4] or 0,
# 更新:
"horses": result[5] or 0,
# 更新:
"snakes": result[6] or 0,
# 更新:
"tianma": result[7] or 0
# 更新:
}
# 更新:
# 更新:
@router.get("/stats/hour")
# 更新:
async def get_hour_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取近一个小时新增帖子统计"""
# 更新:
query = """
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants,
# 更新:
SUM(CASE WHEN post_type = 'normal' THEN 1 ELSE 0 END) as others,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as dragons,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as horses,
# 更新:
SUM(CASE WHEN category LIKE '%%' THEN 1 ELSE 0 END) as snakes
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= NOW() - INTERVAL '1 hour'
# 更新:
"""
# 更新:
result = db.execute(text(query)).fetchone()
# 更新:
return {
# 更新:
"total": result[0] or 0,
# 更新:
"deals": result[1] or 0,
# 更新:
"wants": result[2] or 0,
# 更新:
"others": result[3] or 0,
# 更新:
"dragons": result[3] or 0,
# 更新:
"horses": result[4] or 0,
# 更新:
"snakes": result[5] or 0
# 更新:
}
# 更新:
# 更新:
@router.get("/stats/today-category")
# 更新:
async def get_today_category_stats(db: Session = Depends(get_coolbot_db)):
# 更新:
"""获取今日帖子分类统计"""
# 更新:
query = """
# 更新:
SELECT category, COUNT(*) as count
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
GROUP BY category
# 更新:
ORDER BY count DESC
# 更新:
"""
# 更新:
results = db.execute(text(query)).fetchall()
# 更新:
return [{"category": r[0] or "未分类", "count": r[1]} for r in results]
# 更新:
# 更新:
# 更新:
@router.get("/stats/dragons-today")
# 更新:
def get_dragons_stats_today(
# 更新:
db: Session = Depends(get_coolbot_db)
# 更新:
):
# 更新:
"""获取今日龙钞详细统计数据(按号码分类)- 就高不就低"""
# 更新:
from sqlalchemy import text
# 更新:
# 更新:
# 1. 带4包含"带4"、"带四"、"通货"
# 更新:
dai4 = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
AND category LIKE '%%'
# 更新:
AND (
# 更新:
content LIKE '%带4%' OR title LIKE '%带4%'
# 更新:
OR content LIKE '%带四%' OR title LIKE '%带四%'
# 更新:
OR content LIKE '%通货%' OR title LIKE '%通货%'
# 更新:
)
# 更新:
""")).fetchone()
# 更新:
# 更新:
# 2. 无4包含"无4"、"无四",排除"无47"、"无247"、"无四七"、"永恒"
# 更新:
wu4 = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
AND category LIKE '%%'
# 更新:
AND (
# 更新:
content LIKE '%无4%' OR title LIKE '%无4%'
# 更新:
OR content LIKE '%无四%' OR title LIKE '%无四%'
# 更新:
)
# 更新:
AND content NOT LIKE '%无47%' AND title NOT LIKE '%无47%'
# 更新:
AND content NOT LIKE '%无247%' AND title NOT LIKE '%无247%'
# 更新:
AND content NOT LIKE '%永恒%' AND title NOT LIKE '%永恒%'
# 更新:
AND content NOT LIKE '%无四七%' AND title NOT LIKE '%无四七%'
# 更新:
""")).fetchone()
# 更新:
# 更新:
# 3. 无47包含"无47"、"永恒"、"无四七",排除"无247"
# 更新:
wu47 = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
AND category LIKE '%%'
# 更新:
AND (
# 更新:
content LIKE '%无47%' OR title LIKE '%无47%'
# 更新:
OR content LIKE '%永恒%' OR title LIKE '%永恒%'
# 更新:
OR content LIKE '%无四七%' OR title LIKE '%无四七%'
# 更新:
)
# 更新:
AND content NOT LIKE '%无247%' AND title NOT LIKE '%无247%'
# 更新:
""")).fetchone()
# 更新:
# 更新:
# 4. 无247包含"无247"、"天马"、"金山",排除"无347"
# 更新:
wu247 = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
AND category LIKE '%%'
# 更新:
AND (
# 更新:
content LIKE '%无247%' OR title LIKE '%无247%'
# 更新:
OR content LIKE '%天马%' OR title LIKE '%天马%'
# 更新:
OR content LIKE '%金山%' OR title LIKE '%金山%'
# 更新:
)
# 更新:
AND content NOT LIKE '%无347%' AND title NOT LIKE '%无347%'
# 更新:
""")).fetchone()
# 更新:
# 更新:
# 5. 无347包含"无347"、"钻石"、"金马"、"魅力"、"朦胧"
# 更新:
wu347 = db.execute(text("""
# 更新:
SELECT
# 更新:
COUNT(*) as total,
# 更新:
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
# 更新:
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
# 更新:
FROM yichens_posts
# 更新:
WHERE post_time >= CURRENT_DATE
# 更新:
AND category LIKE '%%'
# 更新:
AND (
# 更新:
content LIKE '%无347%' OR title LIKE '%无347%'
# 更新:
OR content LIKE '%钻石%' OR title LIKE '%钻石%'
# 更新:
OR content LIKE '%金马%' OR title LIKE '%金马%'
# 更新:
OR content LIKE '%魅力%' OR title LIKE '%魅力%'
# 更新:
OR content LIKE '%朦胧%' OR title LIKE '%朦胧%'
# 更新:
)
# 更新:
""")).fetchone()
# 更新:
# 更新:
return {
# 更新:
"dai4": {"total": dai4[0] or 0, "deals": dai4[1] or 0, "wants": dai4[2] or 0},
# 更新:
"wu4": {"total": wu4[0] or 0, "deals": wu4[1] or 0, "wants": wu4[2] or 0},
# 更新:
"wu47": {"total": wu47[0] or 0, "deals": wu47[1] or 0, "wants": wu47[2] or 0},
# 更新:
"wu247": {"total": wu247[0] or 0, "deals": wu247[1] or 0, "wants": wu247[2] or 0},
# 更新:
"wu347": {"total": wu347[0] or 0, "deals": wu347[1] or 0, "wants": wu347[2] or 0}
# 更新:
}
# 更新:
# 更新:

View File

@ -19,6 +19,7 @@ class UserBase(BaseModel):
class UserCreate(UserBase):
password: str = Field(..., min_length=6)
invite_code: Optional[str] = Field(None, alias="inviteCode") # 填写的邀请码(选填)
class UserUpdate(BaseModel):
@ -34,9 +35,27 @@ class UserUpdate(BaseModel):
class UserResponse(UserBase):
# 新增字段
f99_94_level: Optional[str] = Field(None, alias="level")
f99_95_ai_count: Optional[int] = Field(0, alias="aiCount")
f99_96_search_count: Optional[int] = Field(0, alias="searchCount")
f99_97_collection_count: Optional[int] = Field(0, alias="collectionCount")
f01_06_phone_verified: Optional[bool] = Field(False, alias="phoneVerified")
f99_98_login_count: Optional[int] = Field(0, alias="loginCount")
f99_99_last_login: Optional[datetime] = Field(None, alias="lastLogin")
f01_07_gender: Optional[str] = Field(None, alias="gender")
f01_08_birthday: Optional[datetime] = Field(None, alias="birthday")
f01_09_region: Optional[str] = Field(None, alias="region")
f01_10_realname_verified: Optional[bool] = Field(False, alias="realnameVerified")
f99_100_points: Optional[int] = Field(0, alias="points")
f01_11_balance: Optional[float] = Field(0, alias="balance")
f01_12_total_amount: Optional[float] = Field(0, alias="totalAmount")
f01_13_invite_code: Optional[str] = Field(None, alias="inviteCode")
f99_101_invited_count: Optional[int] = Field(0, alias="invitedCount")
f99_90_id: str = Field(..., alias="id")
f01_01_name: str = Field(..., alias="username")
role: str
user_code: Optional[str] = None
f99_92_created_at: Optional[datetime] = Field(None, alias="created_at")
f99_93_updated_at: Optional[datetime] = Field(None, alias="updated_at")
@ -58,6 +77,7 @@ class CollectionBase(BaseModel):
f02_11_version: Optional[str] = Field(None, alias="version")
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
f02_14_number_category: Optional[str] = Field(None, alias="numberCategory")
# f03 评级信息
f03_20_is_graded: Optional[bool] = Field(False, alias="isGraded")
@ -104,6 +124,7 @@ class CollectionUpdate(BaseModel):
f02_11_version: Optional[str] = Field(None, alias="version")
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
f02_14_number_category: Optional[str] = Field(None, alias="numberCategory")
# f03 评级信息
f03_20_is_graded: Optional[bool] = Field(None, alias="isGraded")

View File

@ -8,10 +8,10 @@ from typing import Optional
# 阿里云短信配置
SMS_CONFIG = {
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
"sign_name": "阿里云",
"template_code": "100001",
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5tQAx5niD7JQVqGE5acE"),
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "QsQFAEKBkaNynIoKyvdIi3BUyWVZu1"),
"sign_name": os.getenv("SMS_SIGN_NAME", "苏州算力"),
"template_code": os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956"),
}
# 验证码缓存生产环境建议用Redis

View File

@ -0,0 +1,132 @@
# 号码分类工具
# 按MEMORY.md最新规则 (2026-04-02)
# 优先级:数字越小越有价值
CATEGORIES = [
# 1. 圆圆号:无.123457只能用0689
{"name": "圆圆号", "cannot_use": ".123457", "must_have": ""},
# 2. 倒置号无23457可用01689必须有1
{"name": "倒置号", "cannot_use": "23457", "must_have": "1"},
# 3. 金马王无12347可用05689必须有5
{"name": "金马王", "cannot_use": "12347", "must_have": "5"},
# 4. 金马号无2347可用015689必须有1和5
{"name": "金马号", "cannot_use": "2347", "must_have": "15"},
# 5. 金山王无12457可用03689必须有3
{"name": "金山王", "cannot_use": "12457", "must_have": "3"},
# 6. 天马王无1247可用035689必须有3和5
{"name": "天马王", "cannot_use": "1247", "must_have": "35"},
# 7. 金山号无2457可用013689必须有1和3
{"name": "金山号", "cannot_use": "2457", "must_have": "13"},
# 8. 天马号无247可用0135689必须有1、3和5
{"name": "天马号", "cannot_use": "247", "must_have": "135"},
# 9. 朦胧王无13457
{"name": "朦胧王", "cannot_use": "13457", "must_have": ""},
# 10. 朦胧号无3457
{"name": "朦胧号", "cannot_use": "3457", "must_have": ""},
# 11. 如意号无1347
{"name": "如意号", "cannot_use": "1347", "must_have": ""},
# 12. 钻石号无347
{"name": "钻石号", "cannot_use": "347", "must_have": ""},
# 13. 永恒号无47
{"name": "永恒号", "cannot_use": "47", "must_have": ""},
# 14. 无4号不包含4
{"name": "无4号", "cannot_use": "4", "must_have": ""},
# 15. 通货含4
{"name": "通货", "cannot_use": "", "must_have": "4"},
]
def extract_digits(serial: str) -> dict:
"""提取冠字号中的数字部分"""
if not serial:
return {"digits": "", "type": "single"}
nums = serial.replace("J", "").replace(",", "").replace(".", "").strip()
nums = "".join(c for c in nums if c.isdigit())
if nums.endswith("01"):
return {"digits": nums[:-2], "type": "hundred"}
elif nums.endswith("1"):
return {"digits": nums[:-1], "type": "ten"}
else:
return {"digits": nums, "type": "single"}
def matches_category(digits: str, category: dict) -> bool:
cannot_use = category.get("cannot_use", "")
must_have = category.get("must_have", "")
# 检查不能用的数字
for n in cannot_use:
if n in digits:
return False
# 检查必须有的数字
if must_have:
for n in must_have:
if n not in digits:
return False
return True
def get_number_category(serial: str) -> str:
"""号码分类函数"""
if not serial:
return ""
# 提取数字
info = extract_digits(serial)
digits = info["digits"]
if not digits:
return ""
# 根据类型取对应位数
digits_type = info["type"]
if digits_type == "hundred":
# 标百看后6位
check_digits = digits[-6:] if len(digits) >= 6 else digits
elif digits_type == "ten":
# 标十看后7位
check_digits = digits[-7:] if len(digits) >= 7 else digits
else:
# 散钞看全部
check_digits = digits
# 按优先级匹配分类
for category in CATEGORIES:
if matches_category(check_digits, category):
return category["name"]
return "通货"
def get_number_category_color(category: str) -> str:
"""获取分类颜色"""
colors = {
"圆圆号": "#ef4444", # 红
"倒置号": "#f97316", # 橙
"金马王": "#eab308", # 黄
"金马号": "#84cc16", # 绿
"金山王": "#22c55e", # 深绿
"天马王": "#14b8a6", # 青
"金山号": "#06b6d4", # 蓝
"天马号": "#0ea5e9", # 浅蓝
"朦胧王": "#6366f1", # 靛蓝
"朦胧号": "#8b5cf6", # 紫
"如意号": "#a855f7", # 深紫
"钻石号": "#d946ef", # 品红
"永恒号": "#ec4899", # 粉红
"无4号": "#64748b", # 灰
"通货": "#9ca3af", # 浅灰
}
return colors.get(category, "#9ca3af")
def get_category_priority(category: str) -> int:
"""获取分类优先级(数字越小越高级)"""
for i, cat in enumerate(CATEGORIES, 1):
if cat["name"] == category:
return i
return 999

35
config/CHANGELOG.md Normal file
View File

@ -0,0 +1,35 @@
# 版本更新记录
## v1.2.98 (2026-04-19)
### 前端更新
- **Admin.jsx**
- 功能:修复管理员用户列表行情数显示问题
- 改进:新增 dealCount 字段显示用户发布的成交行情数量
- 代码:将"配号"改为"📈 行情: X条"
### 后端更新
- **users.py**
- 功能修复管理员用户列表行情数API
- 改进:新增 dealCount 字段,从 Information 表统计用户发布的 deal 数量
- 代码:新增 Information 模型导入,统计查询
### Vite配置更新
- **vite.config.js**
- 功能:修复前端构建版本号不更新问题
- 改进:将版本更新改为 closeBundle hook 执行
- 代码:重构 updateHtmlTitle 函数
---
## v1.2.97 (2026-04-XX)
### 更新内容
- (待记录)
---
## v1.2.96 (2026-04-XX)
### 更新内容
- (待记录)

View File

@ -1,14 +1 @@
# 甲辰藏品管理系统 - 版本配置
# Version Configuration for Zodiac Collection Management System
# 当前版本号 (语义化版本:主版本。次版本.修订版)
VERSION=1.1.21
# 版本代号 (可选)
VERSION_CODENAME="新生"
# 发布日期
RELEASE_DATE=2026-03-18
# 版本说明
VERSION_NOTES="性能优化 - Nginx反向代理、图片OSS直连、自动压缩、上传限制20MB"
1.2.98

48
config/VERSION.json Normal file
View File

@ -0,0 +1,48 @@
{
"version": "1.2.98",
"updated": "2026-04-19",
"modules": {
"frontend": {
"version": "1.2.98",
"pages": {
"Add": "1.2.90",
"Admin": "1.2.98",
"Detail": "1.2.90",
"Edit": "1.2.90",
"Home": "1.2.95",
"List": "1.2.93",
"Login": "1.2.85",
"News": "1.2.80",
"News_YichensBoard": "1.2.75",
"Settings": "1.2.75",
"Stats": "1.2.70",
"YichensBoard": "1.2.70"
},
"config": {
"version": "1.2.98"
}
},
"backend": {
"version": "1.2.98",
"routers": {
"auth": "1.2.90",
"collections": "1.2.95",
"deal": "1.2.85",
"information": "1.2.92",
"news": "1.2.80",
"ocr": "1.2.75",
"operations": "1.2.70",
"seek": "1.2.70",
"users": "1.2.98",
"yichens": "1.2.70"
},
"app": {
"core": "1.2.0",
"models": "1.2.0",
"schemas": "1.2.0",
"services": "1.2.0",
"utils": "1.2.0"
}
}
}
}

View File

@ -0,0 +1,50 @@
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: jiachenlong-db-test
restart: unless-stopped
environment:
POSTGRES_DB: zodiac
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
backend:
image: python:3.11-slim
container_name: jiachenlong-backend-test
restart: unless-stopped
working_dir: /app
command: >
bash -c "pip install fastapi uvicorn sqlalchemy psycopg2-binary pydantic python-jose bcrypt python-multipart pillow dashscope alibabacloud-dysmsapi20170525 -q && uvicorn app.main:app --host 0.0.0.0 --port 3000"
ports:
- "3000:3000"
volumes:
- /root/jiachenlong/backend:/app
environment:
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/zodiac
- SECRET_KEY=test-secret-key-for-sms
- ACCESS_TOKEN_EXPIRE_MINUTES=60
- OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
- OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
- OSS_BUCKET=jiachenlong-oss
- OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
- SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
- SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
- SMS_SIGN_NAME=苏州算力
- SMS_TEMPLATE_CODE=SMS_501590956
depends_on:
postgres:
condition: service_healthy
volumes:
postgres_data:

View File

@ -0,0 +1,145 @@
# v1.2.4 版本问题修复说明
**版本**: v1.2.4-stable
**发布日期**: 2026-03-20
**维护人**: 甲辰生产
---
## 问题列表及解决方案
### 问题1: OCR识别后保存藏品失败
**现象**: OCR识别成功后点击保存藏品返回404错误
**原因**:
- OCR代码只尝试小写扩展名(jpg/jpeg/png/gif)
- 用户上传的图片文件扩展名是大写的.JPG
**解决方案**:
修改 `backend/app/routers/ocr.py` 第299行
```python
# 修改前
temp_extensions = [jpg, jpeg, png, gif]
# 修改后
temp_extensions = [jpg, jpeg, png, gif, JPG, JPEG, PNG, GIF]
```
---
### 问题2: 用户协议页面乱码
**现象**: 点击用户协议显示乱码,只有一个标题
**原因**:
- 协议文件只有一个空的HTML骨架
- 没有实际内容
**解决方案**:
创建完整的用户协议HTML文件 `/var/www/frontend/user_agreement.html`,包含完整的中文协议内容(服务条款、用户责任、数据安全、免责声明等)
---
### 问题3: 用户协议页面无返回按钮
**现象**: 打开用户协议后无法返回
**解决方案**:
在用户协议页面添加返回按钮:
```html
<button class="back-btn" onclick="window.close()">← 返回</button>
```
---
### 问题4: 首页Logo显示不出来
**现象**: 登录页Logo正常登录后首页Logo显示404
**原因**:
- 部署时static目录没有复制到正确位置
- 浏览器缓存了旧的JS文件
**解决方案**:
1. 确保部署时复制static目录
```bash
cp -r /root/jiachenlong/static /var/www/frontend/
cp -r /root/jiachenlong/static /var/www/mobile/
```
2. 首页Logo添加版本号防止缓存
```jsx
// 修改Home.jsx
<img src="/static/images/jiachenlong-logo.png?v=1.2.4" ... />
```
---
### 问题5: 版本号显示重复
**现象**: 标题显示"vv1.2.4"v重复
**原因**:
- VERSION文件中版本号为"v1.2.4"带v前缀
- vite.config.js中又自动添加了"v"前缀
**解决方案**:
修改VERSION文件去掉v前缀
```
# 修改前
VERSION=v1.2.4
# 修改后
VERSION=1.2.4
```
---
## 部署检查清单
### 前端部署
```bash
# 1. 确保VERSION文件格式正确不带v前缀
cat config/VERSION
# 输出: VERSION=1.2.4
# 2. 构建
cd frontend
npm run build
# 3. 部署(两个目录都要部署)
rm -rf /var/www/mobile/*
rm -rf /var/www/frontend/*
cp -r dist/* /var/www/mobile/
cp -r dist/* /var/www/frontend/
cp -r ../static /var/www/mobile/
cp -r ../static /var/www/frontend/
# 4. 重载Nginx
nginx -s reload
```
### 后端部署
```bash
# 1. 拉取最新代码
cd /root/jiachenlong
git pull
# 2. 重启后端服务
pkill -f uvicorn
cd backend
python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 &
```
---
## 标签信息
- **当前稳定版本**: v1.2.4-stable
- **Gitea标签地址**: http://47.253.189.47:3000/coolbot/jiachenlong/tags
- **发布说明**: v1.2.4稳定版本 - 用户协议修复版
---
**文档结束**

297
docs/标准部署流程.md Normal file
View File

@ -0,0 +1,297 @@
# 甲辰藏品管理系统 - 标准部署流程
**版本**: v1.0
**创建时间**: 2026-03-21
**维护人**: 甲辰生产
---
## 📋 部署前检查清单
### 1. 获取信息
| 项目 | 内容 | 获取方式 |
|------|------|---------|
| 目标服务器IP | 如 8.149.137.26 | MEMORY.md |
| SSH密码 | 如 Jiachen123 | 询问酷博特 |
| 目标版本 | 如 v1.2.4 | Gitea tags |
| 数据库配置 | IP/密码/端口 | MEMORY.md |
### 2. 环境确认
```bash
# 登录目标服务器
ssh root@<目标IP>
# 检查已有配置(不要覆盖!)
cat /root/jiachenlong/config/VERSION
cat /etc/nginx/conf.d/*.conf
```
---
## 🚀 标准部署流程
### 前端部署(所有环境)
```bash
# 1. 登录服务器
ssh root@<前端IP>
# 2. 拉取代码(重要:不要覆盖已有目录)
cd /root
rm -rf jiachenlong_bak
mv jiachenlong jiachenlong_bak # 备份旧代码
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git jiachenlong
# 3. 检查并修改VERSION文件重要用sed保留原内容
# 先查看原内容
cat jiachenlong/config/VERSION
# 修改VERSION行保留其他行
sed -i 's/^VERSION=.*/VERSION=1.2.4/' jiachenlong/config/VERSION
# 4. 构建前端
cd jiachenlong/frontend
npm install
npm run build
# 5. 部署(两个目录都要部署!)
rm -rf /var/www/mobile/*
rm -rf /var/www/frontend/*
cp -r dist/* /var/www/mobile/
cp -r dist/* /var/www/frontend/
cp -r ../static /var/www/mobile/
cp -r ../static /var/www/frontend/
# 6. 部署用户协议(如有)
cp user_agreement.html /var/www/mobile/
cp user_agreement.html /var/www/frontend/
# 7. 重载Nginx
nginx -s reload
```
### 后端部署(所有环境)
```bash
# 1. 登录服务器
ssh root@<后端IP>
# 2. 拉取代码
cd /root
rm -rf jiachenlong_bak
mv jiachenlong jiachenlong_bak
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git jiachenlong
# 3. 检查OCR扩展名修复如无则手动修复
grep -n 'temp_extensions' jiachenlong/backend/app/routers/ocr.py
# 如只有小写,修复:
sed -i "s/temp_extensions = \['jpg', 'jpeg', 'png', 'gif'\]/temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF']/" jiachenlong/backend/app/routers/ocr.py
# 4. 检查数据库配置
cat jiachenlong/backend/.env | grep DATABASE_URL
# 5. 停止旧服务
pkill -f uvicorn
# 6. 启动新服务
cd jiachenlong/backend
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > /tmp/uvicorn.log 2>&1 &
# 7. 等待启动
sleep 5
# 8. 验证
curl -s http://localhost:3000/ | head -c 100
```
---
## ✅ 部署后验证清单
### 必须验证的项目
| # | 验证项 | 命令 | 期望结果 |
|---|--------|------|---------|
| 1 | 前端页面 | curl http://<前端IP>/ | 200 + HTML |
| 2 | 前端版本 | curl http://<前端IP>/ \| grep title | v1.2.4 |
| 3 | 前端端口 | curl http://<前端IP>:3001/ | 200 |
| 4 | 后端健康 | curl http://<后端IP>:3000/ | 200 |
| 5 | 登录功能 | curl -X POST http://<后端IP>:3000/api/auth/login -d "username=admin&password=admin123" | 返回token |
| 6 | Logo图片 | curl -I http://<前端IP>/static/images/jiachenlong-logo.png | 200 |
| 7 | 用户协议 | curl http://<前端IP>/user_agreement.html | 200 + 内容 |
| 8 | 80端口 | curl -o /dev/null -w "%{http_code}" http://<前端IP>/ | 200 |
| 9 | API代理 | curl http://<前端IP>/api/collections | JSON响应 |
### 与基准环境对比
```bash
# 以C环境为基准对比关键文件
# C环境
curl -s http://47.103.29.111/ | grep title
# B环境
curl -s http://8.149.137.26/ | grep title
# 期望:版本号一致
```
---
## ⚠️ 常见错误及解决方案
### 1. git clone失败目录已存在
**错误**
```
fatal: destination path . already exists and is not an empty directory.
```
**解决**
```bash
# 方法1先备份再删除
mv jiachenlong jiachenlong_backup
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git
# 方法2删除后克隆
rm -rf jiachenlong
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git
```
### 2. VERSION文件被覆盖
**错误**
```
VERSION=1.2.4
# 原有内容丢失
```
**解决**使用sed修改而非echo覆盖
```bash
# 错误方法
echo "VERSION=1.2.4" > VERSION # ❌ 会覆盖整个文件
# 正确方法
sed -i s/^VERSION=.*/VERSION=1.2.4/ VERSION # ✅ 只修改VERSION行
```
### 3. 版本号显示vv1.2.4
**原因**VERSION文件带v前缀 + vite.config.js又加v
**解决**
```bash
# VERSION文件不要带v
VERSION=1.2.4 # ✅
# 不是 VERSION=v1.2.4
# 源index.html如有vv先修复
sed -i s/vv/v/g index.html
```
### 4. 首页Logo显示404
**原因**static目录未部署
**解决**
```bash
# 部署时必须复制static目录
cp -r ../static /var/www/mobile/
cp -r ../static /var/www/frontend/
```
### 5. 浏览器缓存旧JS
**原因**JS文件名hash未变
**解决**首页Logo添加版本号
```jsx
// Home.jsx
<img src="/static/images/jiachenlong-logo.png?v=1.2.4" ... />
```
### 6. Nginx 80端口返回403
**原因**root目录为空或不存在
**解决**
```bash
# 检查目录
ls -la /var/www/frontend/
# 部署到正确目录
cp -r dist/* /var/www/frontend/
# 重载Nginx
nginx -s reload
```
---
## 📊 环境配置参考
### A环境生产
| 服务 | IP | 端口 |
|------|-----|------|
| 前端 | 8.154.46.3 | 80, 3001 |
| 后端 | 42.121.116.25 | 3000 |
| 数据库 | 47.98.171.101 | 5432 |
### B环境灰度
| 服务 | IP | 端口 |
|------|-----|------|
| 前端 | 8.149.137.26 | 80, 3001 |
| 后端 | 47.110.37.129 | 3000 |
| 数据库 | 47.96.181.36 | 5432 |
### C环境测试
| 服务 | IP | 端口 |
|------|-----|------|
| 前端 | 47.103.29.111 | 80 |
| 后端 | 47.103.9.192 | 3000 |
| 数据库 | 47.103.9.192 | 5432 |
---
## 📝 部署记录模板
每次部署后填写:
```markdown
## 部署记录
### 2026-03-21 v1.2.4
| 环境 | 部署时间 | 操作人 | 结果 |
|------|---------|--------|------|
| B环境 | 00:27 | 甲辰生产 | ✅ 成功 |
### 部署命令
```bash
# 前端
ssh root@8.149.137.26
cd /root/jiachenlong/frontend
npm run build
cp -r dist/* /var/www/mobile/
cp -r dist/* /var/www/frontend/
# 后端
ssh root@47.110.37.129
pkill -f uvicorn
cd /root/jiachenlong/backend
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 &
```
### 验证结果
- 前端版本v1.2.4 ✅
- 后端健康200 ✅
- Logo显示200 ✅
### 问题记录
```
---
**文档结束**

View File

@ -0,0 +1,186 @@
# 测试环境部署指南
## 测试环境架构
| 服务器 | IP | 服务 |
|--------|-----|------|
| 测试机1 | 47.103.29.111 | 前端 (Nginx) |
| 测试机2 | 47.103.9.192 | 后端 (FastAPI) + PostgreSQL |
## 部署步骤
### 1. 测试机1 - 前端部署
```bash
# 拉取代码
cd /root/jiachenlong
git fetch --all
git checkout v1.1.21
# 安装依赖并构建
cd frontend
npm install
npm run build
# 复制静态文件到可访问目录
mkdir -p /var/www/html
cp -r dist/* /var/www/html/
cp -r ../static /var/www/html/
chmod -R 755 /var/www/html
# 配置Nginx
cat > /etc/nginx/conf.d/jiachenlong-test.conf << 'EOF'
server {
listen 80;
server_name _;
root /var/www/html;
index index.html;
client_max_body_size 20M;
# 静态资源logo、图片等
location /static {
alias /var/www/html/static;
expires 30d;
add_header Cache-Control "public, immutable";
}
# SPA路由
location / {
try_files $uri $uri/ /index.html;
}
# API代理到后端
location /api {
proxy_pass http://47.103.9.192:3000/api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
EOF
nginx -t && systemctl enable nginx && systemctl restart nginx
```
### 2. 测试机2 - 后端部署
```bash
# 拉取代码
cd /root/jiachenlong
git fetch --all
git checkout v1.1.21
# 安装Python依赖
pip3 install fastapi uvicorn sqlalchemy psycopg2-binary pydantic python-jose bcrypt python-multipart pillow dashscope alibabacloud-dysmsapi20170525 oss2 email-validator httpx python-dotenv
# 创建环境变量文件
cat > backend/.env << 'EOF'
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zodiac
SECRET_KEY=test-secret-key-for-sms
ACCESS_TOKEN_EXPIRE_MINUTES=60
OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
OSS_BUCKET=jiachenlong-oss
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
SMS_SIGN_NAME=苏州算力
SMS_TEMPLATE_CODE=SMS_501590956
EOF
# 启动后端
cd backend
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > /tmp/backend.log 2>&1 &
# 复制静态文件
mkdir -p /var/www/html/static
cp -r ../static/* /var/www/html/static/
chmod -R 755 /var/www/html/static
# 配置Nginx
cat > /etc/nginx/conf.d/jiachenlong-test.conf << 'EOF'
server {
listen 80;
server_name _;
root /var/www/html;
index index.html;
client_max_body_size 20M;
location /static {
alias /var/www/html/static;
}
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://127.0.0.1:3000/api;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
EOF
nginx -t && systemctl enable nginx && systemctl restart nginx
```
## 常见问题
### 问题1: Logo不显示
**原因:**
1. Nginx未配置`/static`路径SPA路由捕获了请求
2. 静态文件在`/root`目录下nginx无权限读取
**解决:**
1. 添加`location /static`配置
2. 将静态文件复制到`/var/www/html/static`
### 问题2: 短信发送失败 "找不到模板"
**原因:** sms.py中的默认配置未更新
**解决:** 修改`backend/app/services/sms.py`:
```python
SMS_CONFIG = {
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5tQAx5niD7JQVqGE5acE"),
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "QsQFAEKBkaNynIoKyvdIi3BUyWVZu1"),
"sign_name": os.getenv("SMS_SIGN_NAME", "苏州算力"),
"template_code": os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956"),
}
```
### 问题3: 数据库连接失败 "Ident authentication failed"
**原因:** PostgreSQL默认使用ident认证
**解决:**
```bash
sed -i 's/ident/trust/g' /var/lib/pgsql/data/pg_hba.conf
systemctl restart postgresql
```
## 验证命令
```bash
# 测试机1验证
curl -s -o /dev/null -w "%{http_code}" http://47.103.29.111/
curl -s -o /dev/null -w "%{http_code}" http://47.103.29.111/static/images/jiachenlong-logo.png
# 测试机2验证
curl -s http://47.103.9.192:3000/
curl -s -o /dev/null -w "%{http_code}" http://47.103.9.192/static/images/jiachenlong-logo.png
```
## 短信配置
| 项目 | 值 |
|------|-----|
| 模板Code | SMS_501590956 |
| 签名 | 苏州算力 |
| AccessKey ID | LTAI5tQAx5niD7JQVqGE5acE |
| AccessKey Secret | QsQFAEKBkaNynIoKyvdIi3BUyWVZu1 |

View File

@ -66,6 +66,7 @@ tailwindcss@3
| 版本 | 日期 | 更新人 | 更新内容 |
|------|------|--------|---------|
| v1.2 | 2026-03-15 | 菜鸟小 D | 添加详细版本要求和关键注意事项 |
| v1.2.4 | 2026-03-20 | 甲辰生产 | 修复OCR扩展名大小写问题、修复用户协议乱码、添加返回按钮 |
| v1.1 | 2026-03-15 | 菜鸟小 D | 根据实地检查更新实际配置 |
| v1.0 | 2026-03-15 | 菜鸟小 D | 初始版本 |

View File

@ -0,0 +1,72 @@
# 部署检查清单
## 部署后必须检查
### 1. 环境变量检查
```bash
cat backend/.env
```
必须包含:
- ✅ DASHSCOPE_API_KEY不是sk-xxx
- ✅ SMS_ACCESS_KEY_ID
- ✅ SMS_SIGN_NAME
- ✅ SMS_TEMPLATE_CODE
### 2. 代码版本检查
```bash
git log --oneline -1
```
确认是目标版本
### 3. OCR扩展名检查
```bash
grep temp_extensions backend/app/routers/ocr.py
```
确认包含大小写扩展名 JPG JPEG PNG GIF
### 4. Python版本检查
```bash
python3 --version
```
必须是 3.11+
### 5. 服务启动检查
```bash
curl http://localhost:3000/
```
返回 200
### 6. API测试
```bash
# 登录
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=admin123" | python3 -c "import sys,json; print(json.load(sys.stdin).get(access_token,))")
# 测试验证码
curl -X POST http://localhost:3000/api/auth/send-verification-code \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"phone":"13800138000"}'
```
## 常见问题快速修复
| 问题 | 修复命令 |
|------|---------|
| OCR慢 | 检查DASHSCOPE_API_KEY是否正确 |
| 验证码失败 | 检查SMS_*配置是否完整 |
| 导入错误 | 使用python3.11启动 |
| 404错误 | 重启后端服务 |
### 7. 用户协议页面检查
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
确认返回HTML内容
### 8. 前端静态文件检查
确认用户协议文件存在

8
env.conf Normal file
View File

@ -0,0 +1,8 @@
DATABASE_URL=postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong
SECRET_KEY=production-secret-key-b-env
OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
SMS_SIGN_NAME=苏州算力
SMS_TEMPLATE_CODE=SMS_501590956

1
frontend/VERSION Normal file
View File

@ -0,0 +1 @@
VERSION=1.2.97

1
frontend/config/VERSION Normal file
View File

@ -0,0 +1 @@
1.2.98

View File

@ -4,10 +4,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>甲辰收藏 v1.1.19</title>
<title>甲辰收藏 v=1.2.97</title>
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/static/icons/favicon.ico" />
<link rel="icon" type="image/png" href="/static/icons/favicon.png" />
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }

View File

@ -1,12 +1,12 @@
{
"name": "jiachenlong-frontend",
"version": "1.0.1",
"version": "1.2.82",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jiachenlong-frontend",
"version": "1.0.1",
"version": "1.2.82",
"dependencies": {
"axios": "^1.7.9",
"react": "^18.3.1",
@ -15,7 +15,7 @@
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
"vite": "^6.4.2"
}
},
"node_modules/@babel/code-frame": {
@ -2047,9 +2047,9 @@
}
},
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"version": "6.4.2",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz",
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "jiachenlong-frontend",
"version": "1.0.1",
"version": "1.2.82",
"private": true,
"description": "甲辰藏品管理系统 - 移动端前端",
"scripts": {
@ -9,13 +9,13 @@
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.7.9",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.1.0",
"axios": "^1.7.9"
"react-router-dom": "^7.1.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.7"
"vite": "^6.4.2"
}
}

1
frontend/package.txt Normal file
View File

@ -0,0 +1 @@
v=1.2.80

20
frontend/postbuild.js Normal file
View File

@ -0,0 +1,20 @@
const fs = require('fs');
const path = require('path');
const src = path.join(__dirname, 'static', 'images');
const dst = path.join(__dirname, 'dist', 'static', 'images');
if (!fs.existsSync(dst)) {
fs.mkdirSync(dst, { recursive: true });
}
if (fs.existsSync(src)) {
fs.readdirSync(src).forEach(f => {
const srcFile = path.join(src, f);
const dstFile = path.join(dst, f);
fs.copyFileSync(srcFile, dstFile);
console.log('Copied:', f);
});
}
console.log('Logo复制完成');

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 80">
<defs>
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFE4B5"/>
<stop offset="25%" stop-color="#FFD700"/>
<stop offset="50%" stop-color="#FFA500"/>
<stop offset="75%" stop-color="#DAA520"/>
<stop offset="100%" stop-color="#B8860B"/>
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feFlood flood-color="#FFD700" flood-opacity="0.6"/>
<feComposite in2="blur" operator="in"/>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="shadow">
<feDropShadow dx="2" dy="3" stdDeviation="2" flood-color="#000" flood-opacity="0.5"/>
</filter>
</defs>
<!-- Main title -->
<text x="0" y="45" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="52" font-weight="bold" fill="url(#goldGrad)" filter="url(#shadow)">甲辰收藏</text>
<!-- Subtitle -->
<text x="0" y="72" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="20" fill="#DAA520" letter-spacing="4">生肖纪念钞管理系统</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>用户协议 - 甲辰收藏</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; line-height: 1.8; background: #f5f5f5; }
h1 { text-align: center; color: #1a1a1a; font-size: 24px; margin-bottom: 30px; }
h2 { color: #333; font-size: 18px; margin-top: 30px; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 8px; }
h3 { color: #555; font-size: 15px; margin-top: 20px; margin-bottom: 10px; }
.content { color: #444; font-size: 14px; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
.content p { margin: 10px 0; }
.highlight { font-weight: bold; }
.version { text-align: center; color: #999; font-size: 12px; margin-bottom: 20px; }
ul { padding-left: 20px; }
li { margin: 5px 0; }
</style>
</head>
<body>
<div class="content">
<h1>甲辰收藏平台用户协议</h1>
<div class="version">版本生效日期:自注册之日起</div>
<p><span class="highlight">特别提示:</span>本协议涉及您的重大权利义务,加粗条款请您重点阅读。您完成注册、登录或继续使用本平台服务,即视为已充分阅读、理解并同意本协议全部内容。本协议适用《中华人民共和国民法典》《网络安全法》《个人信息保护法》《电子商务法》等法律法规。</p>
<h2>一、协议主体与适用范围</h2>
<ol>
<li>本协议由您与甲辰收藏平台运营主体(以下简称"平台")签订,约束您使用平台藏品管理,信息展示,数据存储,社区互动等全部服务。</li>
<li>平台发布的隐私政策、藏品发布规则、交易规范等均为本协议组成部分,与本协议具有同等法律效力。</li>
<li>您承诺为具备完全民事行为能力的自然人/法人;未成年人使用需监护人同意并陪同。</li>
</ol>
<h2>二、个人信息收集与使用</h2>
<h3>(一)收集原则</h3>
<p>平台遵循合法、正当、必要、诚信、最小必要原则收集信息,仅为实现服务功能所必需,不强制收集非必要信息。</p>
<h3>(二)收集范围与目的</h3>
<ol>
<li><strong>注册与身份信息:</strong>用户名、手机号、邮箱(用于账号创建,安全验证、客服联系)。</li>
<li><strong>藏品与行为信息:</strong>藏品上传内容、收藏记录、浏览操作、发布评论、交易数据(用于藏品管理,服务优化、风控核验)。</li>
<li><strong>设备与网络信息:</strong>设备型号、系统版本、IP 地址、日志信息(用于安全防护、故障排查,防作弊)。</li>
<li><strong>位置信息:</strong>仅在您主动开启定位权限时收集,用于同城展示等可选功能,关闭不影响基础服务。</li>
</ol>
<h3>(三)信息使用规则</h3>
<ol>
<li>平台仅在您授权范围内使用信息,不超出约定目的、范围与期限处理。</li>
<li>向第三方共享信息时,将单独告知并取得您明确同意,法律法规要求除外。</li>
<li>您有权查阅、更正、删除个人信息,申请注销账号,平台在核验身份后依法处理。</li>
<li>平台采取加密、去标识化、权限管控等措施保护信息安全,制定安全事件应急预案。</li>
</ol>
<h2>三、公开信息使用与免责条款</h2>
<ol>
<li><strong>公开信息定义:</strong>您通过平台主动发布、设置为公开可见的藏品资料、图文、评论、动态等内容,均属于您自行公开的信息。</li>
<li><strong>授权使用:</strong>您同意平台可在合理范围内使用您的公开信息,用于藏品展示、平台运营、合规审核、服务推广等,不侵犯您合法权益。</li>
<li><strong>法定免责依据:</strong>根据《个人信息保护法》第二十七条,平台处理您自行合法公开的信息,在无明确拒绝且不对您权益造成重大影响的情形下,无需另行取得单独同意。</li>
<li><strong>第三方行为免责:</strong>
<ul>
<li>公开信息可被其他用户浏览、复制、存储、转发,平台无法完全控制第三方使用行为。</li>
<li>因第三方擅自使用、转载、篡改您公开信息引发的纠纷、损失,平台不承担责任。</li>
</ul>
</li>
<li><strong>内容责任自负:</strong>
<ul>
<li>您对公开信息的真实性、合法性、原创性承担全部责任,不得侵犯第三方知识产权、肖像权、名誉权等。</li>
<li>如因您发布违法、侵权内容导致平台受损,您应承担全部赔偿责任。</li>
</ul>
</li>
<li><strong>平台管理边界:</strong>平台仅依据法律法规与平台规则进行内容审核,对用户自主发布的公开信息不做修改、篡改,不承担真实性担保责任。</li>
</ol>
<h2>四、用户权利与义务</h2>
<ol>
<li>您有权使用平台基础服务,对账号与密码安全负责,不得转借、出售账号。</li>
<li>不得发布违法违规、侵权、虚假、低俗等违反法律法规与公序良俗的内容。</li>
<li>不得利用平台从事洗钱、诈骗、非法交易等违法活动。</li>
<li>您对自行上传的藏品素材、文字等享有知识产权,授权平台在服务范围内非独占使用。</li>
</ol>
<h2>五、知识产权条款</h2>
<ol>
<li>平台所有文字、图标、界面设计、软件代码等知识产权归平台所有,受法律保护。</li>
<li>您上传的原创内容知识产权归您所有;您授权平台为提供服务之目的,使用、存储、展示、传播该内容。</li>
<li>未经权利人书面许可,任何主体不得复制、改编、传播、商用平台内容或用户原创内容。</li>
</ol>
<h2>六、服务变更、中断与终止</h2>
<ol>
<li>平台因维护、升级、政策调整需变更或暂停服务的,将提前公示;因不可抗力、监管要求、第三方故障导致服务中断的,平台不承担违约责任。</li>
<li>平台有权对违规账号采取警示、限流、删帖、封禁等处理措施。</li>
<li>您可随时停止使用服务;账号注销后,平台按法律法规留存相关数据,逾期依法删除。</li>
</ol>
<h2>七、免责声明(法律允许范围内)</h2>
<ol>
<li>平台按"现状"提供服务,对服务及时性、安全性、稳定性不作绝对担保。</li>
<li>平台不对藏品真伪、价值、权属作明示或暗示保证,藏品鉴定与价值判断由您自行负责。</li>
<li>法律允许的最大范围内,平台不对间接损失、利润损失、数据丢失等承担赔偿责任;因平台故意或重大过失导致的损失除外。</li>
<li>因您自身操作不当、账号保管不善、第三方侵权等导致的损失,由您自行承担。</li>
</ol>
<h2>八、协议修改与争议解决</h2>
<ol>
<li>平台修改协议将提前7日公示您继续使用视为接受修订如不同意可停止使用并注销账号。</li>
<li>因本协议产生争议,双方协商解决;协商不成,提交平台运营主体所在地有管辖权的人民法院诉讼解决。</li>
<li>本协议适用中华人民共和国大陆地区法律(不含港澳台法律)。</li>
</ol>
<h2>九、联系与通知</h2>
<p>平台联系方式aicoolbot@163.com您可通过客服渠道咨询协议、隐私、投诉等相关事宜。</p>
</div>
</body>
</html>

View File

@ -4,6 +4,7 @@ import Settings from './pages/Settings'
import List from './pages/List'
import Add from './pages/Add'
import Stats from './pages/Stats'
import News from './pages/News'
import Login from './pages/Login'
import Detail from './pages/Detail'
import Edit from './pages/Edit'
@ -28,6 +29,7 @@ export default function App() {
const getComponent = () => {
const basePath = path.split('?')[0]
if (basePath === '/') return <Home />
if (basePath === '/news') return <News />
if (basePath === '/stats') return <Stats />
if (basePath === '/list') return <List />
if (basePath === '/add') return <Add />
@ -48,6 +50,7 @@ export default function App() {
console.error('Parse user error:', e)
}
const isAdmin = user && user.role === 'admin'
const isEditor = user && user.role === 'editor'
//
if (!token) {
@ -82,9 +85,10 @@ export default function App() {
}}>
{[
{ path: '/', icon: '🏠', label: '首页' },
{ path: '/news', icon: '📰', label: '行情' },
{ path: '/stats', icon: '📊', label: '统计' },
{ path: '/list', icon: '📚', label: '藏品' },
{ path: '/add', icon: '🎯', label: '添加' },
{ path: '/list', icon: '📚', label: '我的' },
{ path: '/add', icon: '🎯', label: '录入' },
...(isAdmin ? [{ path: '/admin', icon: '⚙️', label: '管理' }] : [])
].map(tab => (
<div
@ -108,3 +112,4 @@ export default function App() {
</div>
)
}
// Fri Apr 10 03:44:24 PM CST 2026

View File

@ -3,7 +3,7 @@
// 修改版本号只需修改 ../../VERSION 文件中的 VERSION=xxx
// 从环境变量读取vite.config.js 注入)
export const APP_VERSION = import.meta.env.APP_VERSION || '1.0.0'
export const APP_VERSION = import.meta.env.APP_VERSION || '1.2.82'
// 版本信息
export const VERSION_INFO = {

View File

@ -1,6 +1,13 @@
// - AI //
/**
* Add - 添加藏品页面
* Version: 1.2.90x
* 更新
*/
import React, { useState, useRef } from 'react'
import { APP_VERSION } from '../config/version'
const API_BASE = localStorage.getItem('API_BASE') || ''
const numberCategoryOptions = [{value:'无2347',label:'无2347'},{value:'无347',label:'无347'},{value:'无247',label:'无247'},{value:'无47',label:'无47'},{value:'带7号',label:'带7号'},{value:'带4号',label:'带4号'},{value:'其他',label:'其他'}];
//
const convertField = (obj) => {
@ -12,7 +19,7 @@ const convertField = (obj) => {
packaging: 'f02_12_packaging', rarity: 'f02_13_rarity',
isGraded: 'f03_20_is_graded', gradingCompany: 'f03_21_grading_company',
gradingScore: 'f03_22_grading_score', threeStar: 'f03_23_three_star',
specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature',
specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature', numberCategory: 'f02_14_number_category',
issuer: 'f04_32_issuer', issueYear: 'f04_33_issue_year',
material: 'f04_34_material', denomination: 'f04_35_denomination',
issueQuantity: 'f04_36_issue_quantity',
@ -61,21 +68,66 @@ const Input = ({ form, handleChange, label, field, type = 'text', options = null
}
const getDefaultForm = () => ({
name: '龙钞', code: '', category: '自持', rarity: '通货', prefixSerial: '',
name: '藏品', code: '', category: '自持', rarity: '通货', prefixSerial: '',
version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张',
material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false,
gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '',
serialFeature: '', issuer: '中国人民银行', issueYear: '2024',
gradingCompany: '爱藏', gradingScore: '67+', threeStar: false, specialMark: '',
serialFeature: '', numberCategory: '', issuer: '中国人民银行', issueYear: '2024',
costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: ''
})
export default function Add() {
//
const [dealForm, setDealForm] = useState({
serial: '',
category: '',
packaging: '标十',
price: '',
platform: '抖音',
seller: '',
buyer: '',
date: new Date().toISOString().split('T')[0],
isGraded: false,
gradingCompany: '爱藏',
gradingScore: '67+'
})
const [batchDefaultPackaging, setBatchDefaultPackaging] = useState('')
const [batchDefaultDate, setBatchDefaultDate] = useState('')
const [batchDefaultPlatform, setBatchDefaultPlatform] = useState('抖音')
const [dealMode, setDealMode] = useState('single') // single-, batch-
const [batchText, setBatchText] = useState('')
const [batchResult, setBatchResult] = useState([])
const [parsing, setParsing] = useState(false)
const [savingDeal, setSavingDeal] = useState(false)
//
const autoCategory = (serial) => {
const digits = serial.replace('J', '').replace(/[^0-9]/g, '').slice(0, 9)
if (!digits) return ''
const d = digits
if (!d.includes('1') && !d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '圆圆号'
if (!d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '倒置号'
if (!d.includes('1') && !d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '金马王'
if (!d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '金马号'
if (!d.includes('1') && !d.includes('2') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '金山王'
if (!d.includes('1') && !d.includes('2') && !d.includes('4') && !d.includes('7')) return '天马王'
if (!d.includes('2') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '金山号'
if (!d.includes('2') && !d.includes('4') && !d.includes('7')) return '天马号'
if (!d.includes('1') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '朦胧王'
if (!d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '朦胧号'
if (!d.includes('1') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '如意号'
if (!d.includes('3') && !d.includes('4') && !d.includes('7')) return '钻石号'
if (!d.includes('4') && !d.includes('7')) return '永恒号'
if (!d.includes('4')) return '带7号'
return '带4号'
}
// URL
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const modeParam = params.get('mode')
const defaultTab = modeParam === 'manual' ? 'manual' : 'ai'
const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, batch
const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, deal
const [form, setForm] = useState(getDefaultForm())
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
@ -146,6 +198,23 @@ export default function Add() {
if (key === 'goalPrice' && value) {
setForm(prev => ({ ...prev, status: 'sold' }))
}
//
if (key === 'prefixSerial') {
const serial = value
let cat = ''
const match = serial.match(/J(\d{9})/)
const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9)
if (digits) {
if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347'
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47'
else if (digits.includes('7') && !digits.includes('4')) cat = '带7号'
else if (digits.includes('4')) cat = '带4号'
else cat = '其他'
}
setForm(prev => ({ ...prev, numberCategory: cat }))
}
//
if (key === 'version') {
const yearMatch = value.match(/(20\d{2})/)
@ -244,7 +313,26 @@ export default function Add() {
setSaving(true)
setError('')
const token = localStorage.getItem('token')
const formData = convertField(form)
//
const formWithCategory = { ...form }
if (formWithCategory.prefixSerial && !formWithCategory.numberCategory) {
const serial = formWithCategory.prefixSerial
const match = serial.match(/J(\d{9})/)
const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9)
if (digits) {
let cat = ''
if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347'
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47'
else if (digits.includes('7') && !digits.includes('4')) cat = '带7号'
else if (digits.includes('4')) cat = '带4号'
else cat = '其他'
formWithCategory.numberCategory = cat
}
}
const formData = convertField(formWithCategory)
try {
// 1.
const res = await fetch('/api/collections', {
@ -259,6 +347,11 @@ export default function Add() {
const result = await res.json()
let collectionId = result.f99_90_id || result.id
//
if (result.error) {
throw new Error(result.error.message || '保存失败')
}
//
if (result.warning && result.warning.code === 'DUPLICATE_SERIAL') {
const { warning } = result
@ -363,15 +456,15 @@ export default function Add() {
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => setActiveTab('ai')}
style={{ flex: 1, padding: '10px', background: activeTab === 'ai' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'ai' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
🤖 AI 识别
AI 识别
</button>
<button onClick={() => setActiveTab('manual')}
style={{ flex: 1, padding: '10px', background: activeTab === 'manual' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'manual' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
手工录入
手工录入
</button>
<button onClick={() => setActiveTab('batch')} disabled
style={{ flex: 1, padding: '10px', background: 'rgba(255,255,255,0.02)', color: '#64748b', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'not-allowed' }}>
📦 批量录入
<button onClick={() => setActiveTab('deal')}
style={{ flex: 1, padding: '10px', background: activeTab === 'deal' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'deal' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
行情录入
</button>
</div>
</div>
@ -393,7 +486,7 @@ export default function Add() {
style={{ padding: '12px 24px', background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: '1px solid #ef4444', borderRadius: '8px', fontSize: '14px', cursor: 'pointer' }}>🗑 重新选择</button>
<button onClick={handleRecognize} disabled={recognizing}
style={{ padding: '12px 24px', background: recognizing ? '#64748b' : '#fbbf24', color: recognizing ? '#94a3b8' : '#1e293b', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: recognizing ? 'not-allowed' : 'pointer' }}>
{recognizing ? '🔍 识别中...' : '🤖 开始识别'}
{recognizing ? '🔍 识别中...' : '开始识别'}
</button>
</div>
</div>
@ -536,6 +629,7 @@ export default function Add() {
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
<Input form={form} handleChange={handleChange} label="编号(可选)" field="code" />
<Input form={form} handleChange={handleChange} label="持仓类型" field="category" options={categoryOptions} />
<Input form={form} handleChange={handleChange} label="珍惜度" field="rarity" options={rarityOptions} />
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
@ -573,6 +667,7 @@ export default function Add() {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
<Input form={form} handleChange={handleChange} label="号码分类" field="numberCategory" options={numberCategoryOptions} />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />
@ -613,16 +708,380 @@ export default function Add() {
</div>
)}
{/* 批量录入模式 */}
{activeTab === 'batch' && (
<div style={{ padding: '48px 16px', textAlign: 'center', color: '#94a3b8' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚧</div>
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '8px' }}>批量录入开发中</div>
<div style={{ fontSize: '13px' }}>敬请期待后续版本</div>
{/* 版本号 */}
{/* 行情录入模式 */}
{activeTab === 'deal' && (
<div style={{ padding: '16px' }}>
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '20px', marginBottom: '16px' }}>
<div style={{ color: '#fbbf24', fontSize: '16px', fontWeight: 'bold', marginBottom: '16px' }}>成交行情录入</div>
{/* 单条/批量切换 */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<button onClick={() => setDealMode('single')}
style={{ flex: 1, padding: '10px', background: dealMode === 'single' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: dealMode === 'single' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
单条录入
</button>
<button onClick={() => setDealMode('batch')}
style={{ flex: 1, padding: '10px', background: dealMode === 'batch' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: dealMode === 'batch' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
批量录入
</button>
</div>
{/* 单条录入 */}
{dealMode === 'single' && (
<div>
<div style={{ marginBottom: '12px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>冠字号 *</div>
<input value={dealForm.serial} onChange={(e) => setDealForm({...dealForm, serial: e.target.value.toUpperCase(), category: autoCategory(e.target.value)})}
placeholder="J0xxxxxxxx"
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
</div>
{dealForm.category && (
<div style={{ background: 'rgba(251,191,36,0.2)', padding: '10px', borderRadius: '8px', textAlign: 'center', marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8' }}>号码分类</div>
<div style={{ fontSize: '14px', color: '#fbbf24', fontWeight: 'bold' }}>{dealForm.category}</div>
</div>
)}
<div style={{ marginBottom: '12px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>包装类型</div>
<div style={{ display: 'flex', gap: '8px' }}>
{['单张', '标十', '标百'].map(p => (
<button key={p} onClick={() => setDealForm({...dealForm, packaging: p})}
style={{ flex: 1, padding: '10px', background: dealForm.packaging === p ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: dealForm.packaging === p ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
{p}
</button>
))}
</div>
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>成交价格 *</div>
<input type="number" value={dealForm.price} onChange={(e) => setDealForm({...dealForm, price: e.target.value})}
placeholder="请输入成交价格"
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
</div>
<div style={{ marginBottom: '12px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>成交平台 *</div>
<select value={dealForm.platform} onChange={(e) => setDealForm({...dealForm, platform: e.target.value})}
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }}>
<option value="淘宝">淘宝</option>
<option value="咸鱼">咸鱼</option>
<option value="抖音">抖音</option>
<option value="快手">快手</option>
<option value="微拍堂">微拍堂</option>
<option value="拼多多">拼多多</option>
<option value="一尘">一尘</option>
<option value="爱藏">爱藏</option>
<option value="其他">其他</option>
</select>
</div>
<div style={{ display: 'flex', gap: '12px', marginBottom: '12px' }}>
<div style={{ flex: 1 }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>出售者</div>
<input value={dealForm.seller} onChange={(e) => setDealForm({...dealForm, seller: e.target.value})}
placeholder="请输入出售者"
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>购买者</div>
<input value={dealForm.buyer} onChange={(e) => setDealForm({...dealForm, buyer: e.target.value})}
placeholder="请输入购买者"
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>成交日期 *</div>
<input type="date" value={dealForm.date} onChange={(e) => setDealForm({...dealForm, date: e.target.value})}
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
</div>
{/* 评级字段 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>评级可选</div>
<div style={{ display: 'flex', gap: '8px' }}>
<select value={dealForm.gradingCompany || ''} onChange={(e) => setDealForm({...dealForm, gradingCompany: e.target.value})}
style={{ flex: 1, padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '14px' }}>
<option value="">评级机构</option>
<option value="PCGS">PCGS</option>
<option value="PMG">PMG</option>
<option value="ACG">ACG</option>
<option value="爱藏">爱藏</option>
</select>
<input value={dealForm.gradingScore || ''} onChange={(e) => setDealForm({...dealForm, gradingScore: e.target.value})}
placeholder="评级分数"
style={{ flex: 1, padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '14px' }} />
</div>
</div>
<button onClick={async () => {
if (!dealForm.serial || !dealForm.price || !dealForm.platform || !dealForm.date) {
alert('请填写所有必填项')
return
}
setSavingDeal(true)
try {
const token = localStorage.getItem('token')
//
// 7 J0 + 0 + ( J00123456)
// 8 J0 + ( J01234567)
// 9 J0 + 8 ( J02345678)
const rawSerial = dealForm.serial.replace('J', '').replace(/\D/g, '')
let normalizedSerial = ''
if (rawSerial.length === 7) {
normalizedSerial = 'J0' + '0' + rawSerial
} else if (rawSerial.length === 8) {
normalizedSerial = 'J0' + rawSerial
} else if (rawSerial.length >= 9) {
normalizedSerial = 'J0' + rawSerial.slice(1, 9) // 8
} else {
normalizedSerial = 'J0' + rawSerial
}
const tailNumber = rawSerial.slice(-2)
const sizeType = (dealForm.packaging === '标十' && ['01','11','21','31','41','51','61','71','81','91'].includes(tailNumber)) ? '小号' :
(dealForm.packaging === '标百' && ['001','011','021','031','041','051','061','071','081','091'].includes(rawSerial.slice(-3))) ? '小号' : '大号'
const response = await fetch(`${API_BASE}/api/deal`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `${normalizedSerial}${dealForm.price}`,
content: `分类: ${dealForm.category || '未分类'}\n尾号: ${tailNumber || '-'}\n大小号: ${sizeType || '-'}\n包装: ${dealForm.packaging}\n平台: ${dealForm.platform}\n价格: ¥${dealForm.price}\n出售者: ${dealForm.seller || '-'}\n购买者: ${dealForm.buyer || '-'}\n日期: ${dealForm.date}\n评级: ${dealForm.gradingCompany ? dealForm.gradingCompany + ' ' + dealForm.gradingScore : '未评级'}`,
deal_price: parseFloat(dealForm.price),
deal_date: dealForm.date,
packaging: dealForm.packaging,
category: dealForm.category,
tail_number: tailNumber,
size_type: sizeType,
platform: dealForm.platform,
seller: dealForm.seller || '',
buyer: dealForm.buyer || ''
})
})
if (response.ok) {
alert('行情录入成功!')
setDealForm({ serial: '', category: '', packaging: '标十', price: '', platform: '抖音', seller: '', buyer: '', date: new Date().toISOString().split('T')[0] })
} else {
const data = await response.json()
alert('录入失败: ' + (data.detail || '未知错误'))
}
} catch (e) {
alert('提交失败: ' + e.message)
} finally {
setSavingDeal(false)
}
}}
disabled={savingDeal}
style={{ width: '100%', padding: '14px', background: savingDeal ? '#64748b' : '#fbbf24', color: savingDeal ? '#94a3b8' : '#1e293b', border: 'none', borderRadius: '10px', fontSize: '16px', fontWeight: '600', cursor: savingDeal ? 'not-allowed' : 'pointer' }}>
{savingDeal ? '提交中...' : '提交行情'}
</button>
</div>
)}
{/* 批量录入 */}
{dealMode === 'batch' && (
<div>
{/* 日期和平台 */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '10px', color: '#64748b', marginBottom: '4px' }}>默认日期可选</div>
<input type="date" value={batchDefaultDate || ''} onChange={(e) => setBatchDefaultDate(e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '12px' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '10px', color: '#64748b', marginBottom: '4px' }}>成交平台可选</div>
<select value={batchDefaultPlatform || ''} onChange={(e) => setBatchDefaultPlatform(e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '12px' }}>
<option value="">请选择</option>
<option value="淘宝">淘宝</option>
<option value="咸鱼">咸鱼</option>
<option value="抖音">抖音</option>
<option value="快手">快手</option>
<option value="微拍堂">微拍堂</option>
<option value="拼多多">拼多多</option>
<option value="一尘">一尘</option>
<option value="爱藏">爱藏</option>
<option value="其他">其他</option>
</select>
</div>
</div>
{/* 包装类型选择 */}
<div style={{ marginBottom: '12px' }}>
<div style={{ fontSize: '10px', color: '#64748b', marginBottom: '6px' }}>批量默认包装类型可选</div>
<div style={{ display: 'flex', gap: '6px' }}>
<button onClick={() => setBatchDefaultPackaging(batchDefaultPackaging === '单张' ? '' : '单张')}
style={{ flex: 1, padding: '8px', background: batchDefaultPackaging === '单张' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: batchDefaultPackaging === '单张' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '12px', cursor: 'pointer' }}>
单张
</button>
<button onClick={() => setBatchDefaultPackaging(batchDefaultPackaging === '标十' ? '' : '标十')}
style={{ flex: 1, padding: '8px', background: batchDefaultPackaging === '标十' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: batchDefaultPackaging === '标十' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '12px', cursor: 'pointer' }}>
标十
</button>
<button onClick={() => setBatchDefaultPackaging(batchDefaultPackaging === '标百' ? '' : '标百')}
style={{ flex: 1, padding: '8px', background: batchDefaultPackaging === '标百' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: batchDefaultPackaging === '标百' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '12px', cursor: 'pointer' }}>
标百
</button>
</div>
</div>
<textarea value={batchText} onChange={(e) => setBatchText(e.target.value)}
placeholder="粘贴批量行情文本..." style={{ width: '100%', minHeight: '120px', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '14px' }} />
<button onClick={async () => {
if (!batchText.trim()) { alert('请先粘贴行情文本'); return }
setParsing(true)
try {
const response = await fetch(`${API_BASE}/api/information/batch-parse-local`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: batchText, defaultPackaging: batchDefaultPackaging, defaultDate: batchDefaultDate, defaultPlatform: batchDefaultPlatform })
})
const data = await response.json()
if (data.success) { setBatchResult(data.data || []); alert(`解析成功${data.data.length}`) }
else { alert('解析失败: ' + (data.error || '未知错误')) }
} catch (e) { alert('请求失败: ' + e.message) }
finally { setParsing(false) }
}}
disabled={parsing} style={{ width: '100%', marginTop: '8px', padding: '10px', background: parsing ? '#64748b' : '#22c55e', color: '#fff', border: 'none', borderRadius: '8px' }}>
{parsing ? '解析中...' : '解析文本'}
</button>
{batchResult.length > 0 && (
<div style={{ marginTop: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<div style={{ color: '#94a3b8', fontSize: '12px' }}>解析结果 ({batchResult.length})</div>
<button onClick={() => { setBatchResult([]); setBatchText('') }} style={{ padding: '4px 8px', background: 'rgba(239,68,68,0.2)', color: '#ef4444', border: 'none', borderRadius: '4px', fontSize: '11px', cursor: 'pointer' }}>清空</button>
</div>
<div style={{ maxHeight: '350px', overflowY: 'auto' }}>
{batchResult.map((item, idx) => (
<div key={idx} style={{ padding: '8px', background: 'rgba(255,255,255,0.05)', marginBottom: '6px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.1)' }}>
{/* 第一行:冠字号 | 价格 | 分类 | 包装 */}
<div style={{ display: 'flex', gap: '6px', marginBottom: '4px' }}>
<div style={{ flex: '0 0 35%' }}>
<input value={batchResult[idx].serial} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].serial = e.target.value
setBatchResult(newResult)
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fbbf24', fontSize: '12px' }} />
</div>
<div style={{ flex: '0 0 25%' }}>
<input type="number" value={batchResult[idx].price} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].price = parseFloat(e.target.value) || 0
setBatchResult(newResult)
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#22c55e', fontSize: '12px' }} />
</div>
<div style={{ flex: '0 0 20%' }}>
<select value={batchResult[idx].category} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].category = e.target.value
setBatchResult(newResult)
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '10px' }}>
{['圆圆号','倒置号','金马王','金马号','金山王','天马王','金山号','天马号','朦胧王','朦胧号','如意号','钻石号','永恒号','带7号','带4号'].map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div style={{ flex: '0 0 20%' }}>
<select value={batchResult[idx].packaging || '单张'} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].packaging = e.target.value
setBatchResult(newResult)
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '10px' }}>
<option value="单张">单张</option>
<option value="标十">标十</option>
<option value="标百">标百</option>
</select>
</div>
</div>
{/* 第二行:评级机构 | 分数 | 出售者 | 删除 */}
<div style={{ display: 'flex', gap: '6px' }}>
<div style={{ flex: '0 0 22%' }}>
<select value={batchResult[idx].grading_company || ''} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].grading_company = e.target.value
newResult[idx].is_graded = !!e.target.value
setBatchResult(newResult)
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '10px' }}>
<option value="">未评级</option>
<option value="PCGS">PCGS</option>
<option value="PMG">PMG</option>
<option value="ACG">ACG</option>
<option value="爱藏">爱藏</option>
</select>
</div>
<div style={{ flex: '0 0 18%' }}>
<input value={batchResult[idx].grade || ''} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].grade = e.target.value
setBatchResult(newResult)
}} placeholder="分数" style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#06b6d4', fontSize: '11px' }} />
</div>
<div style={{ flex: '0 0 30%' }}>
<input value={batchResult[idx].seller || ''} onChange={(e) => {
const newResult = [...batchResult]
newResult[idx].seller = e.target.value
setBatchResult(newResult)
}} placeholder="出售者" style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '11px' }} />
</div>
<div style={{ flex: '0 0 30%', textAlign: 'right' }}>
<button onClick={() => {
const newResult = batchResult.filter((_, i) => i !== idx)
setBatchResult(newResult)
}} style={{ padding: '4px 8px', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}>删除</button>
</div>
</div>
</div>
))}
</div>
<button onClick={async () => {
setSavingDeal(true)
let count = 0; const token = localStorage.getItem('token')
for (const item of batchResult) {
try {
//
const digits = (item.serial || '').replace('J', '').replace(/[^0-9]/g, '')
let tail_number = '', size_type = ''
if ((item.packaging === '标十' || item.packaging === '标百') && digits.length >= 2) {
tail_number = item.packaging === '标十' ? digits.slice(-2) : digits.slice(-3)
size_type = (item.packaging === '标十' && ['01','11','21','31','41','51'].includes(tail_number)) || (item.packaging === '标百' && ['101','201','301','401','501'].includes(tail_number)) ? '小号' : '大号'
}
await fetch(`${API_BASE}/api/deal`, {
method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `${item.serial}${item.price}`,
content: `分类: ${item.category || '-'}\n尾号: ${tail_number || '-'}\n大小号: ${size_type || '-'}\n包装: ${item.packaging || '单张'}\n平台: ${item.platform || '-'}\n价格: ¥${item.price}\n出售者: ${item.seller || '-'}\n购买者: ${item.buyer || '-'}\n日期: ${item.deal_date || new Date().toISOString().split('T')[0]}`,
deal_price: parseFloat(item.price),
deal_date: item.deal_date || new Date().toISOString().split('T')[0],
packaging: item.packaging || '单张',
category: item.category || '',
tail_number: tail_number,
size_type: size_type,
platform: item.platform || '-',
seller: item.seller || '',
buyer: item.buyer || ''
})
})
count++
} catch(e) { console.error(e) }
}
alert(`完成${count}`); setBatchResult([]); setBatchText(''); setSavingDeal(false)
}} disabled={savingDeal} style={{ width: '100%', marginTop: '8px', padding: '10px', background: savingDeal?'#64748b':'#fbbf24', color: savingDeal?'#94a3b8':'#1e293b', border:'none', borderRadius:'8px' }}>
{savingDeal ? '提交中...' : `批量录入${batchResult.length}`}
</button>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* 版本号 */}
<div style={{ position: 'fixed', bottom: '16px', right: '16px', color: 'rgba(255,255,255,0.2)', fontSize: '11px', zIndex: 100 }}>v{APP_VERSION}</div>
</div>
)

View File

@ -1,3 +1,9 @@
/**
* Admin - 管理员用户管理页面
* Version: 1.2.98 (2026-04-19)
* 更新新增 dealCount 字段显示用户发布的行情数量
*/
import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version'
@ -126,7 +132,18 @@ export default function Admin() {
const updateData = {
username: editingUser.username,
email: editingUser.email,
role: editingUser.role
phone: editingUser.phone,
phoneVerified: editingUser.phoneVerified,
role: editingUser.role,
user_code: editingUser.user_code,
level: editingUser.level,
loginCount: editingUser.loginCount,
aiCount: editingUser.aiCount,
searchCount: editingUser.searchCount,
points: editingUser.points,
balance: editingUser.balance,
totalAmount: editingUser.totalAmount,
phoneVerified: editingUser.phoneVerified
}
//
@ -186,26 +203,56 @@ export default function Admin() {
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{users.map((user) => (
<div key={user.id} style={{ background: 'rgba(30, 41, 59, 0.8)', borderRadius: '12px', padding: '16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<div style={{ color: '#fbbf24', fontSize: '16px', fontWeight: 'bold' }}>{user.username}</div>
<div style={{
padding: '2px 8px', borderRadius: '4px',
background: user.role === 'admin' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(148, 163, 184, 0.2)',
color: user.role === 'admin' ? '#10b981' : '#94a3b8',
background: user.role === 'admin' ? 'rgba(16, 185, 129, 0.2)' : (user.role === 'editor' ? 'rgba(245, 158, 11, 0.2)' : 'rgba(148, 163, 184, 0.2)'),
color: user.role === 'admin' ? '#10b981' : (user.role === 'editor' ? '#f59e0b' : '#94a3b8'),
fontSize: '12px'
}}>
{user.role === 'admin' ? '👑 管理员' : '👤 用户'}
{user.role === 'admin' ? '👑 管理员' : (user.role === 'editor' ? '📝 信息员' : '👤 用户')}
</div>
<div style={{ background: 'rgba(59, 130, 246, 0.2)', padding: '2px 8px', borderRadius: '4px', color: '#60a5fa', fontSize: '12px' }}>
编码: {user.user_code || '-'}
</div>
<div style={{ background: 'rgba(168, 85, 247, 0.2)', padding: '2px 8px', borderRadius: '4px', color: '#a855f7', fontSize: '12px' }}>
等级: {user.level || '青铜'}
</div>
</div>
<div style={{ color: '#64748b', fontSize: '13px', marginTop: '4px' }}>
📧 {user.email || '未设置'} | 📱 {user.phone || '未设置'}
<div style={{ color: '#64748b', fontSize: '13px', marginTop: '8px' }}>
📧 {user.email || '未设置'} | 📱 {user.phone || '未设置'} | 📱已认证: {user.phoneVerified ? '✓' : '✗'}
</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ color: '#94a3b8', fontSize: '12px' }}>藏品数</div>
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: 'bold' }}>{user.collection_count || 0}</div>
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: 'bold', cursor: 'pointer' }} onClick={() => window.location.hash = '#/list?userId=' + user.id} title='点击查看'>{user.collectionCount || 0}</div>
</div>
</div>
{/* 更多字段 */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ background: 'rgba(16, 185, 129, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#10b981' }}>
🔑 登录: {user.loginCount || 0}
</div>
<div style={{ background: 'rgba(59, 130, 246, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#3b82f6' }}>
🤖 AI识别: {user.aiCount || 0}
</div>
<div style={{ background: 'rgba(20, 184, 166, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#14b8a6' }}>
📈 行情: {user.dealCount || 0}
</div>
<div style={{ background: 'rgba(245, 158, 11, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#f59e0b' }}>
🔍 寻号: {user.searchCount || 0}
</div>
<div style={{ background: 'rgba(168, 85, 247, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#a855f7' }}>
💎 积分: {user.points || 0}
</div>
<div style={{ background: 'rgba(236, 72, 153, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#ec4899' }}>
💰 余额: {user.balance || 0}
</div>
<div style={{ background: 'rgba(34, 197, 94, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#22c55e' }}>
📊 累计: {user.totalAmount || 0}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
@ -214,7 +261,7 @@ export default function Admin() {
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setEditingUser({ ...user })}
onClick={() => setEditingUser({ ...user, email: user.email || '', phone: user.phone || '', phoneVerified: user.phoneVerified !== false, newPassword: '', confirmPassword: '' })}
style={{
padding: '6px 12px',
borderRadius: '6px',
@ -300,6 +347,7 @@ export default function Admin() {
>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
<option value="editor">信息员</option>
</select>
</div>
@ -351,6 +399,28 @@ export default function Admin() {
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>手机号</label>
<input
type="text"
value={editingUser.phone || ''}
onChange={(e) => setEditingUser({ ...editingUser, phone: e.target.value })}
style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', color: '#94a3b8', fontSize: '13px', marginBottom: '6px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={editingUser.phoneVerified === true}
onChange={(e) => setEditingUser({ ...editingUser, phoneVerified: e.target.checked })}
style={{ marginRight: '8px' }}
/>
手机号已验证
</label>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label>
<select
@ -360,6 +430,7 @@ export default function Admin() {
>
<option value="user">普通用户</option>
<option value="admin">管理员</option>
<option value="editor">信息员</option>
</select>
</div>
</div>
@ -391,6 +462,113 @@ export default function Admin() {
</div>
</div>
{/* 第三部分:扩展信息 */}
<div style={{ marginBottom: '24px' }}>
<h3 style={{ color: '#a855f7', fontSize: '15px', fontWeight: 'bold', marginBottom: '16px', paddingBottom: '8px', borderBottom: '1px solid rgba(168, 85, 247, 0.3)' }}>📊 扩展信息</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>用户编码</label>
<input
type="text"
value={editingUser.user_code || ''}
onChange={(e) => setEditingUser({ ...editingUser, user_code: e.target.value })}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>会员等级</label>
<select
value={editingUser.level || '青铜'}
onChange={(e) => setEditingUser({ ...editingUser, level: e.target.value })}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
>
<option value="青铜">青铜</option>
<option value="白银">白银</option>
<option value="黄金">黄金</option>
<option value="铂金">铂金</option>
<option value="钻石号">钻石号</option>
<option value="永恒号">永恒号</option>
<option value="带7号">带7号</option>
<option value="带4号">带4号</option>
</select>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>登录次数</label>
<input
type="number"
value={editingUser.loginCount || 0}
onChange={(e) => setEditingUser({ ...editingUser, loginCount: parseInt(e.target.value) || 0 })}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>AI识别次数</label>
<input
type="number"
value={editingUser.aiCount || 0}
onChange={(e) => setEditingUser({ ...editingUser, aiCount: parseInt(e.target.value) || 0 })}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>配号次数</label>
<input
type="number"
value={editingUser.searchCount || 0}
onChange={(e) => setEditingUser({ ...editingUser, searchCount: parseInt(e.target.value) || 0 })}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>寻号次数</label>
<input
type="number"
value={editingUser.searchCount || 0}
onChange={(e) => setEditingUser({ ...editingUser, searchCount: parseInt(e.target.value) || 0 })}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>积分</label>
<input
type="number"
value={editingUser.points || 0}
onChange={(e) => setEditingUser({ ...editingUser, points: parseInt(e.target.value) || 0 })}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>余额</label>
<input
type="number"
step="0.01"
value={editingUser.balance || 0}
onChange={(e) => setEditingUser({ ...editingUser, balance: parseFloat(e.target.value) || 0 })}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
/>
</div>
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>累计金额</label>
<input
type="number"
step="0.01"
value={editingUser.totalAmount || 0}
onChange={(e) => setEditingUser({ ...editingUser, totalAmount: parseFloat(e.target.value) || 0 })}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
/>
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => setEditingUser(null)}

View File

@ -1,513 +0,0 @@
import React, { useState, useRef } from 'react'
// 线
const convertField = (obj) => {
const map = {
prefixSerial: 'prefix_serial',
isGraded: 'is_graded',
gradingCompany: 'grading_company',
gradingScore: 'grading_score',
threeStar: 'three_star',
specialMark: 'special_mark',
serialFeature: 'serial_feature',
issueYear: 'issue_year',
issueQuantity: 'issue_quantity',
costPrice: 'cost_price',
targetPrice: 'target_price',
goalPrice: 'goal_price',
repairFee: 'repair_fee',
gradingFee: 'grading_fee'
}
const result = {}
for (const key in obj) {
result[map[key] || key] = obj[key]
}
return result
}
export default function BatchMode() {
const [images, setImages] = useState([])
const [currentIndex, setCurrentIndex] = useState(0)
const [result, setResult] = useState(null)
const [loading, setLoading] = useState(false)
const [savedCount, setSavedCount] = useState(0)
const fileInputRef = useRef(null)
const statusOptions = [
{ value: 'in_collection', label: '收藏中' },
{ value: 'selling', label: '在售' },
{ value: 'sold', label: '已售' },
{ value: 'grading', label: '送评' },
{ value: 'transit', label: '在途' },
{ value: 'other', label: '其他' }
]
const packagingOptions = [
{ value: '单张', label: '单张' },
{ value: '标十', label: '标十' },
{ value: '标百', label: '标百' },
{ value: '裸钞', label: '裸钞' }
]
const categoryOptions = [
{ value: '自持', label: '自持' },
{ value: '寄存', label: '寄存' },
{ value: '寄售', label: '寄售' },
{ value: '共有', label: '共有' },
{ value: '寻号', label: '寻号' },
{ value: '其他', label: '其他' }
]
const rarityOptions = [
{ value: '通货', label: '通货' },
{ value: '特色', label: '特色' },
{ value: '少见', label: '少见' },
{ value: '稀有', label: '稀有' },
{ value: '珍品', label: '珍品' },
{ value: '孤品', label: '孤品' }
]
const handleChooseImages = (e) => {
const files = Array.from(e.target.files || [])
if (files.length > 0) {
setImages(files.slice(0, 10))
setCurrentIndex(0)
setResult(null)
setSavedCount(0)
// Auto OCR first image
processOCR(files[0])
}
}
const processOCR = async (file) => {
setLoading(true)
try {
const formData = new FormData()
formData.append('image', file)
const res = await fetch('/api/ocr/recognize', { method: 'POST', body: formData })
const data = await res.json()
const text = data.result || ''
const parsed = {
name: '生肖纪念钞',
ownership_type: '自持',
rarity: '通货',
status: 'in_collection',
version: '',
prefixSerial: '',
packaging: '单张',
isGraded: false,
gradingCompany: '',
gradingScore: '',
threeStar: false,
specialMark: '',
serialFeature: '',
denomination: '贰拾圆',
issuer: '中国人民银行',
issueYear: '2024',
material: '塑料',
issueQuantity: '一亿',
costPrice: '',
goalPrice: '',
targetPrice: '',
remark: ''
}
const v = text.match(/发行版别[:]\s*([^\n]+)/)
if (v) {
parsed.version = v[1].trim()
const yearMatch = v[1].match(/20\d{2}/)
if (yearMatch) parsed.issueYear = yearMatch[0]
}
//
const issuerMatch = text.match(/发行机构[:]\s*([^\n]+)/)
if (issuerMatch) parsed.issuer = issuerMatch[1].trim()
if (text.includes('三星') || text.includes('3星') || text.includes('★★★')) parsed.threeStar = true
const d = text.match(/面额[:]\s*([^\n]+)/)
if (d) parsed.denomination = d[1].trim()
const p = text.match(/冠字序号[:]\s*([^\n]+)/)
if (p) parsed.prefixSerial = p[1].trim()
//
const codeMatch = text.match(/编号[:]\s*([^\n]+)/)
if (codeMatch) parsed.code = codeMatch[1].trim()
const pk = text.match(/封装类型[:]\s*([^\n]+)/)
if (pk) {
const pv = pk[1].trim()
if (pv.includes('百')) parsed.packaging = '标百'
else if (pv.includes('十')) parsed.packaging = '标十'
else if (pv.includes('单')) parsed.packaging = '单张'
else parsed.packaging = '裸钞'
}
const g = text.match(/是否评级[:]\s*([^\n]+)/)
if (g) parsed.isGraded = g[1].trim() === '是'
const gc = text.match(/评级机构[:]\s*([^\n]+)/)
if (gc) parsed.gradingCompany = gc[1].trim()
const gs = text.match(/评级分数[:]\s*([^\n]+)/)
if (gs) parsed.gradingScore = gs[1].trim()
const sm = text.match(/特殊标识[:]\s*([^\n]+)/)
if (sm) parsed.specialMark = sm[1].trim()
const sf = text.match(/号码特征[:]\s*([^\n]+)/)
if (sf) parsed.serialFeature = sf[1].trim()
setResult(parsed)
} catch (e) {
console.error(e)
alert('识别失败,请重试')
}
setLoading(false)
}
const updateResult = (field, value) => {
setResult(prev => ({ ...prev, [field]: value }))
}
const handleSave = async (force = false) => {
const token = localStorage.getItem('token')
if (!token) {
alert('请先登录')
return
}
try {
const saveData = convertField({ ...result, _forceSave: force })
const res = await fetch('/api/collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify(saveData)
})
if (res.ok) {
setSavedCount(prev => prev + 1)
alert('保存成功!')
} else {
const data = await res.json()
const errMsg = data.error || data.message || data.detail || '保存失败'
//
if (errMsg.includes('E002') || errMsg.includes('已存在') || errMsg.includes('禁止重复') || errMsg.includes('重复')) {
const confirmed = confirm('发现重复:冠字号 ' + (result.prefixSerial || '') + ' 已存在,是否继续保存?')
if (confirmed) {
await handleSave(true) //
return
}
}
alert(errMsg)
}
} catch (e) {
alert('保存失败: ' + e.message)
}
}
const handleNext = () => {
if (currentIndex < images.length - 1) {
setCurrentIndex(currentIndex + 1)
setResult(null)
processOCR(images[currentIndex + 1])
}
}
const handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1)
setResult(null)
processOCR(images[currentIndex - 1])
}
}
const handleReOCR = () => {
processOCR(images[currentIndex])
}
const handleReUpload = () => {
setImages([])
setCurrentIndex(0)
setResult(null)
setSavedCount(0)
fileInputRef.current?.click()
}
// No images selected
if (images.length === 0) {
return (
<div style={{ padding: '20px' }}>
<div
onClick={() => fileInputRef.current?.click()}
style={{
minHeight: '50vh',
background: 'rgba(255,255,255,0.03)',
border: '2px dashed rgba(255,255,255,0.15)',
borderRadius: '16px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer'
}}
>
<div style={{ fontSize: '60px', opacity: 0.5 }}>📦</div>
<div style={{ color: '#fff', fontSize: '16px', marginTop: '16px' }}>点击选择多张图片</div>
<div style={{ color: '#94a3b8', fontSize: '13px', marginTop: '8px' }}>最多10张</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleChooseImages}
style={{ display: 'none' }}
/>
</div>
)
}
const isSaved = savedCount > currentIndex
return (
<div style={{ padding: '16px', paddingBottom: '120px' }}>
{/* Sticky Header */}
<div style={{ position: 'sticky', top: 0, background: '#0f172a', padding: '12px 0', marginBottom: '12px', zIndex: 10, borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ color: '#fff', fontSize: '14px', marginBottom: '8px' }}>
已保存: {savedCount} / {images.length} | 当前第 {currentIndex + 1}
</div>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{images.map((_, i) => (
<div key={i} style={{
width: '24px', height: '24px', borderRadius: '4px',
background: i < savedCount ? '#22c55e' : (i === currentIndex ? '#fbbf24' : '#333'),
color: '#fff', fontSize: '10px', display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
{i + 1}
</div>
))}
</div>
{currentIndex === images.length - 1 && savedCount > 0 && (
<button onClick={handleReUpload} style={{ marginTop: '12px', padding: '8px 16px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '6px', fontSize: '13px', cursor: 'pointer' }}>
重新上传
</button>
)}
</div>
{/* Image */}
<div style={{ marginBottom: '16px' }}>
<img
src={URL.createObjectURL(images[currentIndex])}
alt="preview"
style={{ width: '100%', maxHeight: '200px', objectFit: 'contain', borderRadius: '8px' }}
/>
</div>
{/* Buttons - moved here */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
<button
onClick={handlePrev}
disabled={currentIndex === 0}
style={{ flex: 1, padding: '12px', background: currentIndex === 0 ? '#333' : 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', cursor: currentIndex === 0 ? 'not-allowed' : 'pointer' }}
>
上一张
</button>
{currentIndex < images.length - 1 && (
<button
onClick={handleNext}
style={{ flex: 1, padding: '12px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer' }}
>
下一张
</button>
)}
<button
onClick={handleReOCR}
disabled={loading}
style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '8px', cursor: loading ? 'not-allowed' : 'pointer' }}
>
重新识别
</button>
<button
onClick={handleSave}
disabled={!result || loading}
style={{ flex: 1, padding: '12px', background: result && !loading ? '#22c55e' : '#333', color: '#fff', border: 'none', borderRadius: '8px', fontWeight: 'bold', cursor: result && !loading ? 'pointer' : 'not-allowed' }}
>
保存结果
</button>
</div>
{loading && (
<div style={{ textAlign: 'center', padding: '20px', color: '#fbbf24' }}>🤖 AI识别中...</div>
)}
{result && !loading && (
<>
{/* 基本信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>名称</div>
<input type="text" value={result.name || ''} onChange={(e) => updateResult('name', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>编号</div>
<input type="text" value={result.code || ''} onChange={(e) => updateResult('code', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>持仓类型</div>
<select value={result.ownership_type || '自持'} onChange={(e) => updateResult('ownership_type', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
{categoryOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>珍惜度</div>
<select value={result.rarity || '通货'} onChange={(e) => updateResult('rarity', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
{rarityOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>冠字序号</div>
<input type="text" value={result.prefixSerial || ''} onChange={(e) => updateResult('prefixSerial', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px', fontFamily: 'monospace' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>版别</div>
<input type="text" value={result.version || ''} onChange={(e) => updateResult('version', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>状态</div>
<select value={result.status || 'in_collection'} onChange={(e) => updateResult('status', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
{statusOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>封装</div>
<select value={result.packaging || '单张'} onChange={(e) => updateResult('packaging', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
{packagingOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
</div>
</div>
{/* 评级信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '10px', padding: '8px', background: result.isGraded ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255,255,255,0.03)', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => updateResult('isGraded', !result.isGraded)}>
<div style={{ width: '22px', height: '22px', borderRadius: '4px', border: '2px solid', borderColor: result.isGraded ? '#22c55e' : '#64748b', backgroundColor: result.isGraded ? '#22c55e' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{result.isGraded && <span style={{color:'#fff',fontSize:'14px'}}></span>}
</div>
<span style={{color:'#fff',fontSize:'13px'}}>已评级</span>
</div>
{result.isGraded && (
<>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>评级公司</div>
<input type="text" value={result.gradingCompany || ''} onChange={(e) => updateResult('gradingCompany', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>评级分数</div>
<input type="text" value={result.gradingScore || ''} onChange={(e) => updateResult('gradingScore', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px' }} />
</div>
</>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px', background: result.threeStar ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255,255,255,0.03)', borderRadius: '6px', cursor: 'pointer' }}
onClick={() => updateResult('threeStar', !result.threeStar)}>
<div style={{ width: '22px', height: '22px', borderRadius: '4px', border: '2px solid', borderColor: result.threeStar ? '#22c55e' : '#64748b', backgroundColor: result.threeStar ? '#22c55e' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{result.threeStar && <span style={{color:'#fff',fontSize:'14px'}}></span>}
</div>
<span style={{color:'#fff',fontSize:'13px'}}>三星</span>
</div>
</div>
{/* 特殊信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>特殊信息</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>发行机构</div>
<input type="text" value={result.issuer || ''} onChange={(e) => updateResult('issuer', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>年份</div>
<input type="text" value={result.issueYear || ''} onChange={(e) => updateResult('issueYear', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>特殊标识</div>
<input type="text" value={result.specialMark || ''} onChange={(e) => updateResult('specialMark', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>号码特征</div>
<input type="text" value={result.serialFeature || ''} onChange={(e) => updateResult('serialFeature', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>面额</div>
<input type="text" value={result.denomination || ''} onChange={(e) => updateResult('denomination', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
</div>
{/* 价格信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '16px' }}>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>成本价</div>
<input type="number" value={result.costPrice || ''} onChange={(e) => updateResult('costPrice', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
<div style={{ marginBottom: '10px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>出售价</div>
<input type="number" value={result.targetPrice || ''} onChange={(e) => updateResult('targetPrice', e.target.value)}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px' }} />
</div>
</div>
{/* 备注 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '16px' }}>
<div style={{ marginBottom: '8px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>备注</div>
<textarea value={result.remark || ''} onChange={(e) => updateResult('remark', e.target.value)} rows={2}
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px', resize: 'none' }} />
</div>
</div>
</>
)}
{/* Footer */}
{savedCount > 0 && currentIndex === images.length - 1 && (
<button onClick={handleReUpload} style={{ width: '100%', padding: '14px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '15px', marginTop: '16px', cursor: 'pointer' }}>
重新上传
</button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
onChange={handleChooseImages}
style={{ display: 'none' }}
/>
</div>
)
}

View File

@ -1,3 +1,9 @@
/**
* Detail - 藏品详情页面
* Version: 1.2.90x
* 更新
*/
import React, { useState, useEffect } from 'react'
export default function Detail() {
@ -230,6 +236,7 @@ export default function Detail() {
<Section title="特殊信息">
<Field label="特殊标识" value={collection.specialMark} />
<Field label="号码特征" value={collection.serialFeature} />
<Field label="号码分类" value={collection.numberCategory} />
<Field label="发行方" value={collection.issuer} />
<Field label="发行年份" value={collection.issueYear} />
<Field label="材质" value={collection.material} />

View File

@ -1,3 +1,9 @@
/**
* Edit - 编辑藏品页面
* Version: 1.2.90x
* 更新
*/
import React, { useState, useRef, useEffect } from 'react'
// Input Add.jsx
@ -46,6 +52,7 @@ const categoryOptions = [
{ value: '其他', label: '其他' }
]
const numberCategoryOptions = [{value:'无2347',label:'无2347'},{value:'无347',label:'无347'},{value:'无247',label:'无247'},{value:'无47',label:'无47'},{value:'带7号',label:'带7号'},{value:'带4号',label:'带4号'},{value:'其他',label:'其他'}];
const rarityOptions = [
{ value: '通货', label: '通货' },
{ value: '特色', label: '特色' },
@ -68,7 +75,7 @@ export default function Edit() {
const editId = params.get('id')
const [form, setForm] = useState({
name: '', code: '', category: '自持', rarity: '通货', prefixSerial: '',
name: '', code: '', category: '自持', rarity: '通货', numberCategory: '', prefixSerial: '',
version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张',
material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false,
gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '',
@ -96,6 +103,7 @@ export default function Edit() {
code: data.code || '',
category: data.category || '自持',
rarity: data.rarity || '通货',
numberCategory: data.numberCategory || '',
prefixSerial: data.prefixSerial || '',
version: data.version || '2024 龙',
denomination: data.denomination || '',
@ -146,6 +154,23 @@ export default function Edit() {
const handleChange = (key, value) => {
setForm({ ...form, [key]: value })
if (key === 'targetPrice' && value) setForm(prev => ({ ...prev, status: 'sold' }))
//
if (key === 'prefixSerial') {
const serial = value
let cat = ''
const match = serial.match(/J(\d{9})/)
const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9)
if (digits) {
if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347'
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
else if (digits.includes('4')) cat = '带4号'
else if (digits.includes('7')) cat = '带7号'
else if (!digits.includes('4') && !digits.includes('7')) cat = '无47'
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
else cat = '其他'
}
setForm(prev => ({ ...prev, numberCategory: cat }))
}
//
if (key === 'version') {
const yearMatch = value.match(/(20\d{2})/)
@ -238,6 +263,7 @@ export default function Edit() {
status: 'f01_04_status', remark: 'f01_05_remark',
prefixSerial: 'f02_10_prefix_serial', version: 'f02_11_version',
packaging: 'f02_12_packaging', rarity: 'f02_13_rarity',
numberCategory: 'f02_14_number_category',
isGraded: 'f03_20_is_graded', gradingCompany: 'f03_21_grading_company',
gradingScore: 'f03_22_grading_score', threeStar: 'f03_23_three_star',
specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature',
@ -398,6 +424,7 @@ export default function Edit() {
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
<Input form={form} handleChange={handleChange} label="号码分类" field="numberCategory" options={numberCategoryOptions} />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />

View File

@ -1,575 +0,0 @@
import React, { useState, useEffect, useRef } from 'react'
// 通用 Input 组件
const Input = ({ form, handleChange, label, field, type = 'text', options = null }) => {
const onChange = (e) => {
const value = e.target.value
if (type === 'number' && value !== '') {
const num = parseFloat(value)
handleChange(field, isNaN(num) ? '' : num)
} else handleChange(field, value)
}
return (
<div style={{ flex: 1, minWidth: '45%', marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{label}</div>
{options ? (
<select value={form[field] || ''} onChange={onChange}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }}>
<option value="">请选择</option>
{options.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
) : (
<input type={type} value={form[field] ?? ''} onChange={onChange}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }} />
)}
</div>
)
}
export default function Edit() {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const id = params.get('id')
const [collection, setCollection] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({})
const [showDelete, setShowDelete] = useState(false)
const [collectionImages, setCollectionImages] = useState([])
const fileInputRef = useRef(null)
const [editingImageIndex, setEditingImageIndex] = useState(-1)
useEffect(() => {
if (id) {
fetchDetail()
}
}, [id])
// 图片上传处理
const handleImageUpload = async (e) => {
const files = Array.from(e.target.files)
if (files.length === 0) return
const token = localStorage.getItem('token')
const maxImages = 3
// 检查是否超过限制
const currentCount = collection.images ? collection.images.length : 0
if (editingImageIndex === -1 && currentCount + files.length > maxImages) {
alert(`最多只能上传${maxImages}张图片`)
return
}
// 更换图片(点击已有图片)
if (editingImageIndex >= 0) {
const file = files[0]
const oldImage = collection.images[editingImageIndex]
const imgFormData = new FormData()
imgFormData.append('file', file)
try {
// 先上传新图片
const uploadRes = await fetch(`/api/collections/upload-image?collection_id=${id}`, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: imgFormData
})
if (uploadRes.ok) {
// 删除旧图片
await fetch(`/api/collections/images/${oldImage.id}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
})
// 重新加载详情
await fetchDetail()
alert('图片已更换')
}
} catch (err) {
console.error('图片更换失败:', err)
alert('更换失败,请重试')
}
setEditingImageIndex(-1)
return
}
// 添加新图片
for (const file of files) {
const imgFormData = new FormData()
imgFormData.append('file', file)
try {
const res = await fetch(`/api/collections/upload-image?collection_id=${id}`, {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token },
body: imgFormData
})
if (res.ok) {
// 重新加载藏品详情
await fetchDetail()
}
} catch (err) {
console.error('图片上传失败:', err)
}
}
}
const handleDeleteImage = async (imageId) => {
if (!confirm('确定删除这张图片吗?')) return
const token = localStorage.getItem('token')
try {
const res = await fetch(`/api/collections/images/${imageId}`, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token }
})
if (res.ok) {
await fetchDetail()
}
} catch (err) {
console.error('图片删除失败:', err)
}
}
const fetchDetail = async () => {
setLoading(true)
const token = localStorage.getItem('token')
try {
const res = await fetch(`/api/collections/${id}`, {
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
})
const data = await res.json()
const item = (data.code === 200 ? data.data : data)
if (item && item.id) {
setCollection(item)
// 设置特殊信息默认值
const yearMatch = item.version ? item.version.match(/(20\d{2})/) : null
const defaultForm = {
...item,
issuer: item.issuer || '中国人民银行',
issueYear: item.issueYear || (yearMatch ? yearMatch[1] : '2024'),
material: item.material || '塑料钞',
issueQuantity: item.issueQuantity || '1 亿'
}
setForm(defaultForm)
// 加载图片
if (item.images && Array.isArray(item.images)) {
setCollectionImages(item.images)
}
}
} catch (e) {
console.error('加载详情失败:', e)
}
setLoading(false)
}
const handleChange = (key, value) => {
setForm({ ...form, [key]: value })
// 填入出售价时自动把状态改为"已售"
if (key === 'targetPrice' && value) {
setForm(prev => ({ ...prev, status: 'sold' }))
}
// 版别变化时同步发行年份
if (key === 'version') {
const yearMatch = value.match(/(20\d{2})/)
if (yearMatch) {
setForm(prev => ({ ...prev, issueYear: yearMatch[1] }))
}
}
}
const handleSave = async () => {
setSaving(true)
setError('')
const token = localStorage.getItem('token')
// 1. 先检查是否重复(同版别、同冠字号)
if (form.prefixSerial) {
const checkRes = await fetch(`/api/collections?search=${encodeURIComponent(form.prefixSerial)}`, {
headers: { 'Authorization': 'Bearer ' + token }
})
const checkData = await checkRes.json()
const collections = checkData.data || checkData || []
const duplicate = collections.find(c =>
c.version === form.version &&
c.prefixSerial === form.prefixSerial &&
c.id !== id // 排除自己
)
if (duplicate) {
setError('已经录入过同版同号藏品!')
setSaving(false)
return
}
}
// 转换字段名camelCase 转字段编码
const convertField = (obj) => {
const map = {
// f99 系统字段
id: 'f99_90_id',
userId: 'f99_91_user_id',
createdAt: 'f99_92_created_at',
updatedAt: 'f99_93_updated_at',
// f01 基本信息
name: 'f01_01_name',
code: 'f01_02_code',
category: 'f01_03_category',
status: 'f01_04_status',
remark: 'f01_05_remark',
// f02 详细字段
prefixSerial: 'f02_10_prefix_serial',
version: 'f02_11_version',
packaging: 'f02_12_packaging',
rarity: 'f02_13_rarity',
// f03 评级信息
isGraded: 'f03_20_is_graded',
gradingCompany: 'f03_21_grading_company',
gradingScore: 'f03_22_grading_score',
threeStar: 'f03_23_three_star',
// f04 特殊信息
specialMark: 'f04_30_special_mark',
serialFeature: 'f04_31_serial_feature',
issuer: 'f04_32_issuer',
issueYear: 'f04_33_issue_year',
material: 'f04_34_material',
denomination: 'f04_35_denomination',
issueQuantity: 'f04_36_issue_quantity',
// f05 价格信息
costPrice: 'f05_40_cost_price',
targetPrice: 'f05_41_target_price',
goalPrice: 'f05_42_goal_price',
repairFee: 'f05_43_repair_fee',
gradingFee: 'f05_44_grading_fee',
// f06 其他信息
purpose: 'f06_50_purpose'
}
const result = {}
// 数字字段列表
const numberFields = ['costPrice', 'targetPrice', 'goalPrice', 'repairFee', 'gradingFee']
for (const key in obj) {
let value = obj[key]
// 过滤空字符串为 null
if (value === '' || value === null) {
value = null
}
// 数字字段转换为浮点数
else if (numberFields.includes(key)) {
value = parseFloat(value)
if (isNaN(value)) value = null
}
result[map[key] || key] = value
}
return result
}
try {
const res = await fetch(`/api/collections/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify(convertField(form))
})
if (res.ok) {
alert('保存成功!')
// 刷新首页统计数据
if (window.refreshHome) {
window.refreshHome()
}
window.location.hash = '#/detail?id=' + id
} else {
const data = await res.json()
alert('保存失败: ' + (data.error || '未知错误'))
}
} catch (e) {
alert('保存失败: ' + e.message)
}
setSaving(false)
}
const handleDelete = () => {
setShowDelete(true)
}
const doDelete = async () => {
const token = localStorage.getItem('token')
try {
const res = await fetch(`/api/collections/${id}`, {
method: 'DELETE',
headers: {
'Authorization': 'Bearer ' + token
}
})
if (res.ok) {
alert('删除成功!')
if (window.refreshList) window.refreshList()
window.location.hash = '#/list'
} else {
alert('删除失败')
}
} catch (e) {
alert('删除失败')
}
setShowDelete(false)
}
const goBack = () => {
window.location.hash = '#/detail?id=' + id
}
const statusOptions = [
{ value: 'in_collection', label: '收藏中' },
{ value: 'selling', label: '出售中' },
{ value: 'sold', label: '已售' },
{ value: 'grading', label: '送评中' },
{ value: 'repairing', label: '修复中' },
{ value: 'transit', label: '在途中' },
{ value: 'other', label: '其他' }
]
const categoryOptions = [
{ value: '自持', label: '自持' },
{ value: '寄存', label: '寄存' },
{ value: '寄售', label: '寄售' },
{ value: '共有', label: '共有' },
{ value: '寻号', label: '寻号' },
{ value: '其他', label: '其他' }
]
const rarityOptions = [
{ value: '通货', label: '通货' },
{ value: '特色', label: '特色' },
{ value: '少见', label: '少见' },
{ value: '稀有', label: '稀有' },
{ value: '珍品', label: '珍品' },
{ value: '孤品', label: '孤品' }
]
if (loading) {
return <div style={{ padding: '40px', textAlign: 'center', color: '#94a3b8' }}>加载中...</div>
}
return (
<div style={{ background: '#0f172a', minHeight: '100vh', overflowY: 'auto', paddingBottom: '120px' }}>
{/* 顶部 */}
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', position: 'sticky', top: 0, zIndex: 100 }}>
<button onClick={goBack} style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer', padding: '8px', color: '#fff' }}>←</button>
<span style={{ fontSize: '18px', fontWeight: 'bold', marginLeft: '8px', color: '#fff' }}>编辑藏品</span>
</div>
<div style={{ padding: '16px' }}>
{/* 图片预览区域 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold' }}>藏品图片</div>
{collectionImages.length > 0 && (
<button
onClick={() => fileInputRef.current?.click()}
style={{
padding: '6px 12px',
background: 'rgba(34, 197, 94, 0.2)',
color: '#22c55e',
border: '1px solid #22c55e',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
>
📷 更换图片
</button>
)}
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageUpload}
style={{ display: 'none' }}
/>
{collectionImages.length > 0 ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '12px' }}>
{collectionImages.map((img, index) => (
<div key={img.id || index} style={{ position: 'relative' }}>
<img
src={`http://120.26.133.10:3000/${img.path || `uploads/collections/${img.filename}`}`}
alt={img.original_name || img.filename || '藏品图片'}
style={{
width: '100%',
aspectRatio: '1.5',
objectFit: 'contain',
borderRadius: '12px',
border: '2px solid #10b981',
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
background: 'rgba(0,0,0,0.3)'
}}
/>
<div style={{
position: 'absolute',
top: '8px',
left: '8px',
background: 'rgba(0,0,0,0.6)',
color: '#fff',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px'
}}>{index + 1}/{collectionImages.length}</div>
</div>
))}
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
style={{
aspectRatio: '1.5',
background: 'rgba(255,255,255,0.05)',
border: '2px dashed #fbbf24',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: '#fbbf24',
fontSize: '12px',
transition: 'all 0.2s'
}}
>
<div style={{ fontSize: '48px', marginBottom: '8px' }}>📷</div>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>点击上传图片</div>
<div style={{ fontSize: '11px', color: '#94a3b8' }}>
最多可上传 1 张
</div>
</div>
)}
</div>
{/* 基本信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
<Input form={form} handleChange={handleChange} label="持仓类型" field="category" options={categoryOptions} />
<Input form={form} handleChange={handleChange} label="珍惜度" field="rarity" options={rarityOptions} />
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
<Input form={form} handleChange={handleChange} label="版别" field="version" />
<Input form={form} handleChange={handleChange} label="面值" field="denomination" />
<Input form={form} handleChange={handleChange} label="状态" field="status" options={statusOptions} />
<Input form={form} handleChange={handleChange} label="包装" field="packaging" options={packagingOptions} />
</div>
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', gap: '24px', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: '45%' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>编号</div>
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', color: '#fbbf24', padding: '8px', borderRadius: '6px', fontSize: '13px', fontFamily: 'monospace', fontWeight: 'bold' }}>
{form.code || '(保存后自动生成)'}
</div>
</div>
<div style={{ flex: 1, minWidth: '45%' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>创建时间</div>
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', color: '#94a3b8', padding: '8px', borderRadius: '6px', fontSize: '13px' }}>
{form.createdAt ? new Date(form.createdAt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-') : '(保存后自动生成)'}
</div>
</div>
</div>
</div>
</div>
{/* 评级信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>评级信息</div>
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="checkbox"
id="isGraded"
checked={form.isGraded || false}
onChange={(e) => handleChange('isGraded', e.target.checked)}
style={{ width: '22px', height: '22px', cursor: 'pointer', backgroundColor: form.isGraded ? '#22c55e' : 'rgba(255,255,255,0.1)', border: '2px solid #22c55e', borderRadius: '4px', appearance: 'none', WebkitAppearance: 'none' }}
/>
<label htmlFor="isGraded" style={{ color: '#fff', cursor: 'pointer', fontSize: '15px' }}>是否评级</label>
</div>
<Input form={form} handleChange={handleChange} label="评级公司" field="gradingCompany" />
<Input form={form} handleChange={handleChange} label="评级分数" field="gradingScore" />
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="checkbox"
id="threeStar"
checked={form.threeStar || false}
onChange={(e) => handleChange('threeStar', e.target.checked)}
style={{ width: '22px', height: '22px', cursor: 'pointer', backgroundColor: form.threeStar ? '#22c55e' : 'rgba(255,255,255,0.1)', border: '2px solid #22c55e', borderRadius: '4px', appearance: 'none', WebkitAppearance: 'none' }}
/>
<label htmlFor="threeStar" style={{ color: '#fff', cursor: 'pointer', fontSize: '15px' }}>三星</label>
</div>
</div>
{/* 特殊信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>特殊信息</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />
<Input form={form} handleChange={handleChange} label="发行量" field="issueQuantity" />
</div>
</div>
{/* 价格信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>价格信息</div>
<Input form={form} handleChange={handleChange} label="成本价" field="costPrice" type="number" />
<Input form={form} handleChange={handleChange} label="目标价" field="targetPrice" type="number" />
<Input form={form} handleChange={handleChange} label="出售价" field="goalPrice" type="number" />
<Input form={form} handleChange={handleChange} label="修复费" field="repairFee" type="number" />
<Input form={form} handleChange={handleChange} label="评级费" field="gradingFee" type="number" />
<Input form={form} handleChange={handleChange} label="用途" field="purpose" />
</div>
{/* 备注 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>备注</div>
<textarea
value={form.remark || ''}
onChange={(e) => handleChange('remark', e.target.value)}
rows={3}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '8px', width: '100%', resize: 'none' }}
/>
</div>
{/* 按钮 */}
<button onClick={saving ? null : handleSave} disabled={saving} style={{ width: '100%', padding: '14px', background: saving ? '#64748b' : '#fbbf24', color: '#1e293b', border: 'none', borderRadius: '10px', fontSize: '16px', fontWeight: '600', marginBottom: '12px', cursor: saving ? 'not-allowed' : 'pointer' }}>
{saving ? '保存中...' : '保存'}
</button>
<button onClick={handleDelete} style={{ width: '100%', padding: '14px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid #ef4444', borderRadius: '10px', fontSize: '16px', cursor: 'pointer' }}>
删除藏品
</button>
</div>
{/* 删除确认弹窗 */}
{showDelete && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', width: '80%', maxWidth: '300px' }}>
<div style={{ color: '#fff', fontSize: '16px', fontWeight: 'bold', marginBottom: '16px', textAlign: 'center' }}>确定删除此藏品?</div>
<div style={{ display: 'flex', gap: '12px' }}>
<button onClick={() => setShowDelete(false)} style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px' }}>取消</button>
<button onClick={doDelete} style={{ flex: 1, padding: '12px', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px' }}>删除</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -1,3 +1,9 @@
/**
* Home - 首页
* Version: 1.2.95x
* 更新
*/
import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version'
@ -5,6 +11,10 @@ export default function Home() {
const [user, setUser] = useState(null)
const [stats, setStats] = useState({ totalCount: 0, totalCost: 0, totalRevenue: 0, expectedProfit: 0, totalProfit: 0, gradedCount: 0 })
const [recentCollections, setRecentCollections] = useState([])
const [yichensStats, setYichensStats] = useState({ total: 0, deals: 0, wants: 0, dragons: 0, horses: 0, tianma: 0 })
const [seekStats, setSeekStats] = useState({ seekCount: 0, matchedCount: 0 })
const [recentPosts, setRecentPosts] = useState([])
const [dragonStats, setDragonStats] = useState({})
const currentPath = window.location.hash.slice(1) || '/'
useEffect(() => {
@ -54,6 +64,31 @@ export default function Home() {
}
}, [])
//
useEffect(() => {
fetch('/api/yichens/stats/dragons-today').then(res => res.json()).then(data => {
setDragonStats(data || {})
}).catch(() => {})
fetch('/api/yichens/stats/today').then(res => res.json()).then(data => {
setYichensStats(data || {})
}).catch(() => {})
//
fetch('/api/yichens/stats/dragons-today').then(res => res.json()).then(data => {
setDragonStats(data || {})
}).catch(() => {})
fetch('/api/yichens/posts?limit=20&offset=0').then(res => res.json()).then(data => {
setRecentPosts(data.posts || data || [])
}).catch(() => {})
//
fetch('/api/information/seek/stats').then(res => res.json()).then(data => {
setSeekStats(data || {})
}).catch(() => {})
}, [])
//
const isAdmin = user && user.role === 'admin'
@ -67,16 +102,16 @@ export default function Home() {
const formatMoney = (val) => {
if (!val || val === 0) return '0'
const v = val / 10000
return v.toFixed(4)
return v.toFixed(1)
}
const statCards = [
{ label: '藏品数', value: stats.totalCount, color: '#3b82f6', icon: '📦' },
{ label: '总成本', value: formatMoney(stats.totalCost) + '万', color: '#22c55e', icon: '💰' },
{ label: '总收入', value: formatMoney(stats.totalRevenue) + '万', color: '#06b6d4', icon: '📈' },
{ label: '评级数', value: stats.gradedCount, color: '#8b5cf6', icon: '⭐' },
{ label: '预期利润', value: formatMoney(stats.expectedProfit) + '万', color: stats.expectedProfit >= 0 ? '#f59e0b' : '#ef4444', icon: '🎯' },
{ label: '总利润', value: formatMoney(stats.totalProfit) + '万', color: stats.totalProfit >= 0 ? '#10b981' : '#f43f5e', icon: '💵' }
{ label: '藏品数', value: stats.totalCount, color: '#3b82f6' },
{ label: '总成本', value: formatMoney(stats.totalCost) + '万', color: '#22c55e' },
{ label: '总收入', value: formatMoney(stats.totalRevenue) + '万', color: '#06b6d4' },
{ label: '评级数', value: stats.gradedCount, color: '#8b5cf6' },
{ label: '预期利润', value: formatMoney(stats.expectedProfit) + '万', color: stats.expectedProfit >= 0 ? '#f59e0b' : '#ef4444' },
{ label: '总利润', value: formatMoney(stats.totalProfit) + '万', color: stats.totalProfit >= 0 ? '#10b981' : '#f43f5e' }
]
return (
@ -98,7 +133,7 @@ export default function Home() {
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<img src="/static/images/jiachenlong-logo.png" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} />
<div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '600' }}>{user?.username || '用户'}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '600' }}>{user?.username || '用户'} <span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '11px', fontWeight: 'normal' }}>ID:{user?.user_code || '-'}</span></div>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '12px' }}>欢迎回来 👋</div>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '12px' }}>
@ -131,7 +166,7 @@ export default function Home() {
onMouseOut={e => { e.currentTarget.style.transform = 'translateY(0)' }}
onClick={() => window.location.hash = '#/stats'}
>
<div style={{ fontSize: '24px', marginBottom: '4px' }}>{card.icon}</div>
<div style={{ color: card.color, fontSize: '18px', fontWeight: '700', marginBottom: '4px' }}>{card.value}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>{card.label}</div>
</div>
@ -145,41 +180,171 @@ export default function Home() {
<div onClick={() => window.location.hash = '#/add?mode=ocr'} style={{
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
borderRadius: '12px',
padding: '16px',
padding: '12px 16px',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '12px'
justifyContent: 'center',
textAlign: 'center'
}}>
<div style={{ fontSize: '24px' }}>📷</div>
<div>
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>AI识别</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>拍照识别藏品</div>
</div>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>藏品录入</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>AI识别添加</div>
</div>
<div onClick={() => window.location.hash = '#/add?mode=deal'} style={{
background: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)',
borderRadius: '12px',
padding: '12px 16px',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center'
}}>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>行情录入</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>录入成交行情</div>
</div>
<div onClick={() => window.location.hash = '#/news?type=seek'} style={{
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
borderRadius: '12px',
padding: '12px 16px',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center'
}}>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>发布寻号</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>发布寻配需求</div>
</div>
<div onClick={() => window.location.hash = '#/add?mode=manual'} style={{
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
borderRadius: '12px',
padding: '16px',
padding: '12px 16px',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '12px'
justifyContent: 'center',
textAlign: 'center'
}}>
<div style={{ fontSize: '24px' }}></div>
<div>
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>手动录入</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>添加新藏品</div>
</div>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>发布藏品</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>手动发布</div>
</div>
</div>
</div>
{/* 最近藏品 */}
{/* 寻配号数据 */}
<div style={{ marginBottom: '20px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>🔍 寻配号数据</div>
<div onClick={() => window.location.hash = '#/news?type=seek'} style={{
background: 'linear-gradient(135deg, rgba(245,158,11,0.15) 0%, rgba(245,158,11,0.05) 100%)',
borderRadius: '12px',
padding: '16px',
border: '1px solid rgba(245,158,11,0.2)',
cursor: 'pointer'
}}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px', textAlign: 'center' }}>
<div>
<div style={{ color: '#fbbf24', fontSize: '18px', fontWeight: '700' }}>{seekStats.seekCount || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>寻号需求</div>
</div>
<div>
<div style={{ color: '#34d399', fontSize: '18px', fontWeight: '700' }}>{seekStats.userMatchedCount || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>我的匹配</div>
</div>
<div>
<div style={{ color: '#a78bfa', fontSize: '18px', fontWeight: '700' }}>{seekStats.totalMatchedCount || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>总共匹配</div>
</div>
</div>
</div>
</div>
{/* 一尘今日数据 */}
<div style={{ marginBottom: '20px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>📊 一尘今日连体纪念钞数据</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px' }}>
<div style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15) 0%, rgba(59,130,246,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(59,130,246,0.2)', textAlign: 'center' }}>
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: '700' }}>{yichensStats.total || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>总帖子</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(16,185,129,0.15) 0%, rgba(16,185,129,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(16,185,129,0.2)', textAlign: 'center' }}>
<div style={{ color: '#34d399', fontSize: '20px', fontWeight: '700' }}>{yichensStats.deals || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>出售</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(245,158,11,0.15) 0%, rgba(245,158,11,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(245,158,11,0.2)', textAlign: 'center' }}>
<div style={{ color: '#fbbf24', fontSize: '20px', fontWeight: '700' }}>{yichensStats.wants || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>求购</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(139,92,246,0.15) 0%, rgba(139,92,246,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(139,92,246,0.2)', textAlign: 'center' }}>
<div style={{ color: '#a78bfa', fontSize: '20px', fontWeight: '700' }}>{yichensStats.dragons || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>龙钞</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(236,72,153,0.15) 0%, rgba(236,72,153,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(236,72,153,0.2)', textAlign: 'center' }}>
<div style={{ color: '#f472b6', fontSize: '20px', fontWeight: '700' }}>{yichensStats.horses || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>马钞</div>
</div>
<div style={{ background: 'linear-gradient(135deg, rgba(20,184,166,0.15) 0%, rgba(20,184,166,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(20,184,166,0.2)', textAlign: 'center' }}>
<div style={{ color: '#2dd4bf', fontSize: '20px', fontWeight: '700' }}>{yichensStats.tianma || 0}</div>
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>天马</div>
</div>
</div>
</div>
{/* 今日龙钞帖子数据统计 */}
<div style={{ marginBottom: '20px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>🐉 今日龙钞帖子数据统计</div>
<div style={{ background: 'linear-gradient(180deg, rgba(99,102,241,0.15) 0%, rgba(99,102,241,0.05) 100%)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(99,102,241,0.2)' }}>
{/* 表头 */}
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '8px' }}>
<div></div>
<div style={{ color: '#22c55e', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>出售</div>
<div style={{ color: '#f97316', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>求购</div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>合计</div>
</div>
{/* 数据行 */}
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
<div style={{ color: '#ef4444', fontSize: '14px', fontWeight: '500' }}>带4</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.dai4?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.dai4?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.dai4?.deals || 0) + (dragonStats.dai4?.wants || 0)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
<div style={{ color: '#06b6d4', fontSize: '14px', fontWeight: '500' }}>带7号</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu4?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu4?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu4?.deals || 0) + (dragonStats.wu4?.wants || 0)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
<div style={{ color: '#3b82f6', fontSize: '14px', fontWeight: '500' }}>无47</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu47?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu47?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu47?.deals || 0) + (dragonStats.wu47?.wants || 0)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
<div style={{ color: '#a855f7', fontSize: '14px', fontWeight: '500' }}>无247</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu247?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu247?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu247?.deals || 0) + (dragonStats.wu247?.wants || 0)}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px' }}>
<div style={{ color: '#ec4899', fontSize: '14px', fontWeight: '500' }}>无347</div>
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu347?.deals || 0}</div>
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu347?.wants || 0}</div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu347?.deals || 0) + (dragonStats.wu347?.wants || 0)}</div>
</div>
</div>
</div>
{/* 最新一尘发帖 */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', paddingLeft: '4px' }}>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>最近藏品</div>
<div onClick={() => window.location.hash = '#/list'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 </div>
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>📝 最新一尘发帖</div>
<div onClick={() => window.location.hash = '#/news'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 </div>
</div>
<div style={{
background: 'rgba(255,255,255,0.03)',
@ -187,24 +352,30 @@ export default function Home() {
border: '1px solid rgba(255,255,255,0.05)',
overflow: 'hidden'
}}>
{recentCollections.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无藏品</div>
{recentPosts.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无帖子</div>
) : (
recentCollections.map((item, idx) => (
<div key={item.id || idx} onClick={() => window.location.hash = '#/detail?id=' + item.id} style={{
recentPosts.map((item, idx) => (
<div key={item.post_id || idx} onClick={() => window.open(item.url, '_blank')} style={{
padding: '12px 16px',
borderBottom: idx < recentCollections.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
borderBottom: idx < recentPosts.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div>
<div style={{ color: '#fff', fontSize: '14px' }}>{item.code || '-'} <span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '12px' }}>{item.prefixSerial || ''}</span></div>
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: '12px', marginTop: '2px' }}>{item.version || '-'} · {item.status === 'sold' ? '已售' : item.status === 'in_collection' ? '收藏中' : item.status}</div>
<div style={{ flex: 1 }}>
<div style={{ color: '#fff', fontSize: '14px' }}>{item.title || '无标题'}</div>
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: '12px', marginTop: '2px' }}>{item.category || '-'} · {item.author_username || '未知'} · {item.post_time?.substring(0, 16) || ''}</div>
</div>
<div style={{ color: item.costPrice ? '#22c55e' : 'rgba(255,255,255,0.3)', fontSize: '13px' }}>
{item.costPrice ? '¥' + item.costPrice : '-'}
<div style={{
color: item.post_type === 'deal' ? '#10b981' : item.post_type === 'want' ? '#f59e0b' : '#8b5cf6',
fontSize: '12px',
padding: '2px 8px',
borderRadius: '4px',
background: item.post_type === 'deal' ? 'rgba(16,185,129,0.2)' : item.post_type === 'want' ? 'rgba(245,158,11,0.2)' : 'rgba(139,92,246,0.2)'
}}>
{item.post_type === 'deal' ? '出售' : item.post_type === 'want' ? '求购' : '其他'}
</div>
</div>
))
@ -228,13 +399,13 @@ export default function Home() {
{(isAdmin ? [
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 },
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
{ icon: '', label: '添加', hash: '#/ocr', idx: 2 },
{ icon: '', label: '录入', hash: '#/ocr', idx: 2 },
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 },
{ icon: '⚙️', label: '管理', hash: '#/admin', idx: 4 }
] : [
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 },
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
{ icon: '', label: '添加', hash: '#/ocr', idx: 2 },
{ icon: '', label: '录入', hash: '#/ocr', idx: 2 },
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 }
]).map((item) => (
<div key={item.idx} onClick={() => window.location.hash = item.hash} style={{

View File

@ -1,17 +1,35 @@
/**
* List - 藏品列表页面
* Version: 1.2.93x
* 更新
*/
import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version'
const API_BASE = localStorage.getItem('API_BASE') || ''
export default function List() {
const [collections, setCollections] = useState([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('')
const [filterType, setFilterType] = useState('')
const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || '')
const urlUserId = urlParams.get('userId') || ''
const [userIdFilter, setUserIdFilter] = useState(urlUserId)
const [sortField, setSortField] = useState('createdAt')
const [sortOrder, setSortOrder] = useState('desc')
const [viewMode, setViewMode] = useState('list')
const [key, setKey] = useState(0)
const [search, setSearch] = useState('')
const [isAdmin, setIsAdmin] = useState(false)
const [page, setPage] = useState(1)
const [pagination, setPagination] = useState({ total: 0, pages: 1 })
const [activeTab, setActiveTab] = useState('collections') // collections-, deals-
const [dealSearch, setDealSearch] = useState('')
const [dealSortField, setDealSortField] = useState('created_at')
const [dealSortOrder, setDealSortOrder] = useState('desc')
const [myDeals, setMyDeals] = useState([])
const [dealsLoading, setDealsLoading] = useState(false)
useEffect(() => {
//
@ -56,11 +74,70 @@ export default function List() {
}
}, [])
//
const fetchMyDeals = async () => {
setDealsLoading(true)
const token = localStorage.getItem('token')
const userStr = localStorage.getItem('user')
if (!token || !userStr) {
setDealsLoading(false)
return
}
try {
const user = JSON.parse(userStr)
const res = await fetch(`${API_BASE}/api/deal/list?user_only=true&page_size=500`, {
headers: { 'Authorization': 'Bearer ' + token }
})
const data = await res.json()
const list = data.data || data || []
setMyDeals(Array.isArray(list) ? list : [])
} catch (e) {
console.error('获取行情失败:', e)
}
setDealsLoading(false)
}
// tab
useEffect(() => {
if (activeTab === 'deals') {
fetchMyDeals()
}
}, [activeTab])
//
const filteredDeals = myDeals.filter(deal => {
if (!dealSearch) return true
const s = dealSearch.toLowerCase().trim()
const fields = [deal.title, deal.content, deal.category, deal.packaging, deal.grading_company, deal.grading_score, deal.deal_no, deal.seller, deal.platform, deal.buyer, deal.deal_price?.toString(), deal.deal_date].filter(v => v).map(v => v.toString().toLowerCase())
return fields.some(f => f.includes(s))
}).sort((a, b) => {
let aVal = a[dealSortField] || ''
let bVal = b[dealSortField] || ''
if (dealSortField === 'created_at') {
aVal = new Date(a.created_at).getTime()
bVal = new Date(b.created_at).getTime()
} else if (dealSortField === 'deal_price') {
aVal = a.deal_price || 0
bVal = b.deal_price || 0
}
if (dealSortOrder === 'asc') return aVal > bVal ? 1 : -1
return aVal < bVal ? 1 : -1
})
const fetchCollections = async () => {
setLoading(true)
const token = localStorage.getItem('token')
try {
const res = await fetch('/api/collections?limit=100', {
// URL
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
const urlFilterType = params.get('filter') || ''
const urlFilterValue = params.get('value') ? decodeURIComponent(params.get('value')) : ''
let api = '/api/collections?page=' + page + '&limit=100&sortBy=' + sortField + '&sortOrder=' + sortOrder
if (urlFilterType && urlFilterValue) {
api += '&' + urlFilterType + '=' + encodeURIComponent(urlFilterValue)
}
const res = await fetch(api, {
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
})
@ -80,12 +157,18 @@ export default function List() {
}
//
// IDURL
if (userIdFilter) {
list = list.filter(item => item.userId === userIdFilter || item.userId === userIdFilter.replace(/-/g, ''))
}
if (filterType && filter) {
const fieldMap = {
status: 'status',
category: 'category',
packaging: 'packaging',
rarity: 'rarity',
numberCategory: 'numberCategory',
version: 'version',
gradingCompany: 'gradingCompany',
gradingScore: 'gradingScore',
@ -112,6 +195,14 @@ export default function List() {
}
setCollections(list || [])
//
if (data.pagination) {
setPagination({
total: data.pagination.total || 0,
pages: data.pagination.pages || 1
})
}
} catch (e) {
console.error('Fetch collections error:', e)
//
@ -122,6 +213,14 @@ export default function List() {
setLoading(false)
}
//
useEffect(() => {
if (page > 1) {
fetchCollections()
}
}, [page])
const refresh = () => {
setKey(k => k + 1)
}
@ -270,6 +369,11 @@ export default function List() {
const rarityOrder = { '通货': 1, '特色': 2, '少见': 3, '稀有': 4, '珍品': 5, '孤品': 6 }
aVal = rarityOrder[aVal] || 0
bVal = rarityOrder[bVal] || 0
} else if (sortField === 'numberCategory') {
//
const numberCategoryOrder = { '无2347': 1, '无347': 2, '无247': 3, '无47': 4, '带7号': 5, '带4号': 6, '其他': 7 }
aVal = numberCategoryOrder[aVal] || 99
bVal = numberCategoryOrder[bVal] || 99
}
if (aVal == null) return 1
if (bVal == null) return -1
@ -309,6 +413,30 @@ export default function List() {
return colors[category] || '#64748b'
}
const getNumberCategoryColor = (cat) => {
const colors = { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '带7号': '#06b6d4', '带4号': '#22c55e', '其他': '#64748b' };
return colors[cat] || '#64748b';
};
const getRarityColor = (rarity) => {
const colors = { '通货': '#22c55e', '特色': '#06b6d4', '少见': '#3b82f6', '稀有': '#ec4899', '珍品': '#ef4444', '孤品': '#8b5cf6' }
return colors[rarity] || '#64748b'
}
const formatPrefixSerial = (serial) => {
if (!serial) return '-'
// J101J + 9
const match = serial.match(/J(\d{9})/)
if (match) return 'J' + match[1]
// J10
return serial.substring(0, 10)
}
const getPackagingColor = (packaging) => {
const colors = { '裸钞': '#22c55e', '单张': '#3b82f6', '标十': '#fbbf24', '标百': '#8b5cf6' }
return colors[packaging] || '#64748b'
}
const getStatusColor = (status) => {
const colors = {
'in_collection': '#22c55e',
@ -342,37 +470,46 @@ export default function List() {
}
const ListItem = ({ item }) => (
<div onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '14px', marginBottom: '10px', cursor: 'pointer' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '500' }}>{item.code || '-'}</div>
{isAdmin && item.owner_name && <div style={{ color: '#60a5fa', fontSize: '11px', padding: '2px 6px', background: 'rgba(96, 165, 250, 0.2)', borderRadius: '4px' }}>👤 {item.owner_name}</div>}
</div>
<div style={{ color: '#fbbf24', fontSize: '13px', fontFamily: 'monospace', marginTop: '4px' }}>{item.prefixSerial || '-'}</div>
<div onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '10px', marginBottom: '8px', cursor: 'pointer' }}>
{/* 第1行编号 + 冠字号 + 包装(蓝色) | 状态 + 持仓类型 + 珍惜度(紫色) */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
<span style={{ color: '#fff', fontSize: '11px', fontWeight: 'normal' }}>{item.code || '-'}</span>
<span style={{ color: '#fbbf24', fontSize: '14px', fontFamily: 'monospace', }}>{formatPrefixSerial(item.prefixSerial)}</span>
{item.packaging && <span style={{ background: getPackagingColor(item.packaging) + '20', color: getPackagingColor(item.packaging), fontSize: '10px', padding: '1px 5px', borderRadius: '3px' }}>{item.packaging}</span>}
{item.numberCategory && <span style={{ color: getNumberCategoryColor(item.numberCategory), fontSize: '9px', padding: '1px 4px', background: getNumberCategoryColor(item.numberCategory) + '20', borderRadius: '2px', marginLeft: '4px' }}>{item.numberCategory}</span>}
</div>
<div style={{ textAlign: 'right' }}>
{item.category && <div style={{ color: getCategoryColor(item.category), fontSize: '12px', padding: '3px 10px', background: getCategoryColor(item.category) + '20', borderRadius: '4px', display: 'inline-block', marginBottom: '4px' }}>{getCategoryText(item.category)}</div>}
{item.rarity && <div style={{ color: '#8b5cf6', fontSize: '12px', padding: '3px 10px', background: 'rgba(139, 92, 246, 0.2)', borderRadius: '4px', display: 'inline-block', marginBottom: '4px' }}>{item.rarity}</div>}
<div style={{ color: getStatusColor(item.status), fontSize: '12px', padding: '3px 10px', background: getStatusColor(item.status) + '20', borderRadius: '4px', display: 'inline-block' }}>{getStatusText(item.status)}</div>
{item.gradingScore && <div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginTop: '6px' }}>{item.gradingScore}</div>}
<div style={{ display: 'flex', gap: '3px' }}>
{item.status && <span style={{ color: getStatusColor(item.status), fontSize: '9px', padding: '1px 4px', background: getStatusColor(item.status) + '25', borderRadius: '2px' }}>{getStatusText(item.status)}</span>}
{item.category && <span style={{ color: getCategoryColor(item.category), fontSize: '9px', padding: '1px 4px', background: getCategoryColor(item.category) + '25', borderRadius: '2px' }}>{getCategoryText(item.category)}</span>}
</div>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' }}>
{item.version && <span style={{ background: getVersionColor(item.version).bg, color: getVersionColor(item.version).color, fontSize: '12px', fontWeight: 'bold', padding: '4px 10px', borderRadius: '4px' }}>{item.version}</span>}
{item.packaging && <span style={{ background: 'rgba(255,255,255,0.08)', color: '#94a3b8', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.packaging}</span>}
{item.isGraded ? <span style={{ background: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>已评级</span> : <span style={{ background: 'rgba(255,255,255,0.08)', color: '#64748b', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>未评级</span>}
{item.gradingCompany && <span style={{ background: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.gradingCompany}</span>}
{item.threeStar && <span style={{ background: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>三星</span>}
{item.specialMark && <span style={{ background: 'rgba(139, 92, 246, 0.15)', color: '#8b5cf6', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.specialMark}</span>}
{item.remark && <span style={{ background: 'rgba(100,116,139,0.2)', color: '#94a3b8', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.remark}</span>}
{/* 第2行版本(彩色) + 已评级 + 评级公司 + 评级分数 + 三星 + 特殊标识 | 备注 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '2px', flexWrap: 'wrap', marginBottom: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', flexWrap: 'wrap' }}>
{item.version && <span style={{ background: getVersionColor(item.version).bg, color: getVersionColor(item.version).color, fontSize: '10px', padding: '1px 5px', borderRadius: '3px' }}>{item.version}</span>}
{item.isGraded && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>已评级</span>}
{item.gradingCompany && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>{item.gradingCompany.substring(0,4)}</span>}
{item.gradingScore && <span style={{ background: 'rgba(249, 115, 22, 0.2)', color: '#f97316', fontSize: '9px', padding: '1px 4px', borderRadius: '2px', }}>{item.gradingScore}</span>}
{item.threeStar && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>三星</span>}
{item.specialMark && <span style={{ background: 'rgba(139, 92, 246, 0.15)', color: '#a78bfa', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>{item.specialMark}</span>}
</div>
<div style={{ display: 'flex', gap: '2px', marginLeft: 'auto' }}>
{item.rarity && <span style={{ color: getRarityColor(item.rarity), fontSize: '9px', padding: '1px 4px', background: getRarityColor(item.rarity) + '20', borderRadius: '2px' }}>{item.rarity}</span>}
{item.remark && <span style={{ background: 'rgba(100,116,139,0.2)', color: '#94a3b8', fontSize: '9px', padding: '1px 4px', borderRadius: '2px', maxWidth: '80px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.remark}</span>}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '10px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
{item.costPrice && <span style={{ color: '#94a3b8', fontSize: '12px' }}>成本: <span style={{ color: '#fff' }}>¥{item.costPrice}</span></span>}
{item.targetPrice && <span style={{ color: '#94a3b8', fontSize: '12px' }}>目标: <span style={{ color: '#f59e0b' }}>¥{item.targetPrice}</span></span>}
{item.goalPrice && <span style={{ color: '#94a3b8', fontSize: '12px' }}>出售: <span style={{ color: '#22c55e' }}>¥{item.goalPrice}</span></span>}
{item.repairFee && <span style={{ color: '#94a3b8', fontSize: '12px' }}>修复: <span style={{ color: '#fff' }}>¥{item.repairFee}</span></span>}
{item.gradingFee && <span style={{ color: '#94a3b8', fontSize: '12px' }}>评级: <span style={{ color: '#fff' }}>¥{item.gradingFee}</span></span>}
{/* 第3行成本 + 修复 + 评级 | 目标 + 出售 */}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: '#94a3b8', paddingTop: '4px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', gap: '8px' }}>
{item.costPrice && <span>成本: <span style={{ color: '#e2e8f0' }}>¥{item.costPrice}</span></span>}
{item.repairFee && <span>修复: <span style={{ color: '#e2e8f0' }}>¥{item.repairFee}</span></span>}
{item.gradingFee && <span>评级: <span style={{ color: '#e2e8f0' }}>¥{item.gradingFee}</span></span>}
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{item.targetPrice && <span>目标: <span style={{ color: '#fbbf24' }}>¥{item.targetPrice}</span></span>}
{item.goalPrice && <span>出售: <span style={{ color: '#4ade80' }}>¥{item.goalPrice}</span></span>}
</div>
</div>
</div>
)
@ -381,7 +518,18 @@ export default function List() {
<div style={{ minHeight: '100vh', overflowY: 'auto', background: '#0f172a', paddingBottom: '70px' }}>
<div style={{ padding: '20px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div style={{ color: '#fff', fontSize: '20px', fontWeight: 'bold' }}>我的藏品 <span style={{ fontSize: '14px', color: '#fbbf24' }}>({filteredCollections.length})</span></div>
<div style={{ color: '#fff', fontSize: '20px' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => setActiveTab('collections')}
style={{ padding: '6px 16px', background: activeTab === 'collections' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'collections' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
我的藏品 ({filteredCollections.length})
</button>
<button onClick={() => setActiveTab('deals')}
style={{ padding: '6px 16px', background: activeTab === 'deals' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'deals' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
我的行情 ({filteredDeals.length})
</button>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
{filter && filterType && (
<button
@ -390,7 +538,7 @@ export default function List() {
setFilterType('')
window.location.hash = '#/list'
}}
style={{ background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: 'none', borderRadius: '6px', padding: '6px 12px', fontSize: '13px', cursor: 'pointer' }}
style={{ background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: 'none', borderRadius: '6px', padding: '4px 10px', fontSize: '12px', cursor: 'pointer' }}
>
清除筛选
</button>
@ -401,13 +549,15 @@ export default function List() {
{filter && filterType && (
<div style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', borderRadius: '8px', padding: '12px', marginBottom: '16px' }}>
<div style={{ color: '#60a5fa', fontSize: '13px', fontWeight: 'bold' }}>当前筛选</div>
<div style={{ color: '#60a5fa', fontSize: '12px', }}>当前筛选</div>
<div style={{ color: '#fff', fontSize: '14px', marginTop: '4px' }}>
{getFilterLabel(filterType)} = <span style={{ color: '#fbbf24', fontWeight: 'bold' }}>{getFilterValueLabel(filterType, filter)}</span>
{getFilterLabel(filterType)} = <span style={{ color: '#fbbf24', }}>{getFilterValueLabel(filterType, filter)}</span>
</div>
</div>
)}
{activeTab === 'collections' && (
<>
{/* 搜索框 - 全字段搜索 */}
<div style={{ position: 'relative', marginBottom: '16px' }}>
<input type="text"
@ -420,7 +570,7 @@ export default function List() {
color: '#fff',
width: '100%',
boxSizing: 'border-box',
padding: '12px 40px 12px 12px',
padding: '4px 40px 4px 8px',
borderRadius: '12px',
fontSize: '14px',
outline: 'none',
@ -445,7 +595,7 @@ export default function List() {
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'flex-start'
}}
>
@ -455,15 +605,12 @@ export default function List() {
{/* 排序表头按钮 */}
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap', marginBottom: '12px' }}>
<span style={{ color: '#94a3b8', fontSize: '13px', marginRight: '4px' }}>排序:</span>
<span style={{ color: '#94a3b8', fontSize: '12px', marginRight: '2px' }}>排序:</span>
{[
{ key: 'code', label: '编号' },
{ key: 'prefixSerial', label: '冠字号' },
{ key: 'costPrice', label: '成本' },
{ key: 'goalPrice', label: '售价' },
{ key: 'category', label: '持仓类型' },
{ key: 'rarity', label: '珍惜度' },
{ key: 'gradingScore', label: '评级分数' }
{ key: 'numberCategory', label: '号码分类' },
{ key: 'packaging', label: '包装类型' },
].map(item => (
<div key={item.key} onClick={() => {
if (sortField === item.key) {
@ -473,9 +620,9 @@ export default function List() {
setSortOrder('desc')
}
}} style={{
padding: '8px 14px',
padding: '4px 8px',
borderRadius: '8px',
fontSize: '13px',
fontSize: '12px',
cursor: 'pointer',
background: sortField === item.key ? (sortOrder === 'asc' ? '#22c55e' : '#fbbf24') : 'rgba(255,255,255,0.08)',
color: sortField === item.key ? '#fff' : '#94a3b8',
@ -486,9 +633,70 @@ export default function List() {
</div>
))}
</div>
</>
)}
{/* 分页组件 - 仅在藏品tab下显示 */}
{activeTab === 'collections' && pagination.pages > 1 && (
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', justifyContent: 'flex-start', marginBottom: '12px', padding: '8px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px' }}>
<button onClick={() => { setPage(Math.max(1, page - 1)) }} disabled={page === 1} style={{ padding: '4px 10px', borderRadius: '6px', border: 'none', background: page === 1 ? 'rgba(255,255,255,0.1)' : '#3b82f6', color: '#fff', cursor: page === 1 ? 'not-allowed' : 'pointer', opacity: page === 1 ? 0.5 : 1 }}>上一页</button>
{Array.from({ length: Math.min(5, pagination.pages) }, (_, i) => {
let startPage = Math.max(1, page - 2)
return <button key={i} onClick={() => { setPage(startPage + i) }} style={{ padding: '4px 8px', borderRadius: '6px', border: 'none', background: page === startPage + i ? '#3b82f6' : 'rgba(255,255,255,0.08)', color: '#fff', cursor: 'pointer' }}>{startPage + i}</button>
})}
<button onClick={() => { setPage(Math.min(pagination.pages, page + 1)) }} disabled={page === pagination.pages} style={{ padding: '4px 10px', borderRadius: '6px', border: 'none', background: page === pagination.pages ? 'rgba(255,255,255,0.1)' : '#3b82f6', color: '#fff', cursor: page === pagination.pages ? 'not-allowed' : 'pointer', opacity: page === pagination.pages ? 0.5 : 1 }}>下一页</button>
<span style={{ color: '#94a3b8', fontSize: '11px', marginLeft: '8px' }}>{pagination.total}</span>
</div>
)}
</div>
<div style={{ padding: '16px' }}>
{/* 行情tab内容 */}
{activeTab === 'deals' && (
<div>
{/* 行情搜索框 */}
<div style={{ marginBottom: '12px' }}>
<input type="text"
placeholder="🔍 搜索行情..."
value={dealSearch}
onChange={e => setDealSearch(e.target.value)}
style={{
width: '100%',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#fff',
padding: '10px 12px',
borderRadius: '8px',
fontSize: '14px',
outline: 'none',
boxSizing: 'border-box'
}}
/>
</div>
{dealsLoading ? (
<div style={{ textAlign: 'center', padding: '60px', color: '#64748b' }}>加载中...</div>
) : filteredDeals.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 0' }}>
<div style={{ fontSize: '50px', opacity: 0.3 }}>📊</div>
<div style={{ color: '#64748b', marginTop: '16px' }}>{dealSearch ? '没有匹配的行情' : '暂无行情记录'}</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{filteredDeals.map(deal => (
<DealListItem key={deal.id} deal={deal} onRefresh={fetchMyDeals} />
))}
</div>
)}
</div>
)}
{/* 藏品tab内容 */}
{activeTab === 'collections' && (
<>
{loading ? (
<div style={{ textAlign: 'center', padding: '60px', color: '#64748b' }}>加载中...</div>
) : filteredCollections.length === 0 ? (
@ -497,11 +705,11 @@ export default function List() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
{filteredCollections.map(item => (
<div key={item.id} onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '12px', cursor: 'pointer' }}>
<div style={{ height: '80px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '32px', marginBottom: '10px' }}>🐉</div>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '500' }}>{item.code || '-'}</div>
<div style={{ color: '#fbbf24', fontSize: '12px', fontFamily: 'monospace', marginTop: '2px' }}>{item.prefixSerial || '-'}</div>
<div style={{ display: 'flex', gap: '4px', marginTop: '6px', flexWrap: 'wrap' }}>
{item.gradingScore && <span style={{ color: '#fbbf24', fontSize: '12px', fontWeight: 'bold' }}>{item.gradingScore}</span>}
<div style={{ height: '80px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', fontSize: '32px', marginBottom: '10px' }}>🐉</div>
<div style={{ color: '#fff', fontSize: '11px', fontWeight: 'normal' }}>{item.code || '-'}</div>
<div style={{ color: '#fbbf24', fontSize: '11px', fontFamily: 'monospace', marginTop: '2px' }}>{formatPrefixSerial(item.prefixSerial)}</div>
<div style={{ display: 'flex', gap: '2px', marginTop: '6px', flexWrap: 'wrap' }}>
{item.gradingScore && <span style={{ color: '#fbbf24', fontSize: '11px', }}>{item.gradingScore}</span>}
{item.threeStar && <span style={{ color: '#fbbf24', fontSize: '10px' }}></span>}
</div>
</div>
@ -510,7 +718,237 @@ export default function List() {
) : (
filteredCollections.map(item => <ListItem key={item.id} item={item} />)
)}
</>
)}
</div>
</div>
)
}
//
function DealListItem({ deal, onRefresh }) {
const [expanded, setExpanded] = useState(false)
const [editing, setEditing] = useState(false)
const [editForm, setEditForm] = useState({})
const API_BASE = localStorage.getItem('API_BASE') || ''
const openEdit = () => {
// content
let platform = '', seller = '', buyer = ''
if (deal.content) {
const platformMatch = deal.content.match(/平台:\s*([^\n]+)/)
const sellerMatch = deal.content.match(/出售者:\s*([^\n]+)/)
const buyerMatch = deal.content.match(/购买者:\s*([^\n]+)/)
if (platformMatch) platform = platformMatch[1].trim()
if (sellerMatch) seller = sellerMatch[1].trim()
if (buyerMatch) buyer = buyerMatch[1].trim()
}
setEditForm({
title: deal.title,
content: deal.content,
deal_price: deal.deal_price,
deal_date: deal.deal_date ? (typeof deal.deal_date === 'string' ? deal.deal_date.split('T')[0] : '') : '',
packaging: deal.packaging || '单张',
is_graded: deal.is_graded || false,
grading_company: deal.grading_company || '',
grading_score: deal.grading_score || '',
category: deal.category || '',
deal_no: deal.deal_no || '',
platform: platform,
seller: seller,
buyer: buyer
})
setEditing(true)
}
const saveEdit = async () => {
const token = localStorage.getItem('token')
try {
await fetch(`${API_BASE}/api/deal/${deal.id}`, {
method: 'PUT',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify(editForm)
})
setEditing(false)
onRefresh()
} catch(e) { alert('保存失败') }
}
const deleteDeal = async () => {
if (!confirm('确定删除这条行情?')) return
const token = localStorage.getItem('token')
try {
await fetch(`${API_BASE}/api/deal/${deal.id}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
})
onRefresh()
} catch(e) { alert('删除失败') }
}
return (
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: '10px', padding: '12px', border: '1px solid rgba(255,255,255,0.1)' }}>
{/* 简要展示 - 两行显示关键信息 */}
<div onClick={() => setExpanded(!expanded)} style={{ cursor: 'pointer', padding: '10px', background: 'rgba(0,0,0,0.2)', borderRadius: '8px' }}>
{/* 第一行:成交日期(月-日)+ 冠字号 + 包装 + 评级分数 + 价格 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px', flexWrap: 'wrap', gap: '4px' }}>
{deal.deal_date && <span style={{ color: '#64748b', fontSize: '11px' }}>{deal.deal_date.slice(5)}</span>}
<span style={{ color: '#fbbf24', fontSize: '13px', fontFamily: 'monospace' }}>{deal.title?.split('-')[0] || '-'}</span>
{deal.packaging && <span style={{ background: 'rgba(139,92,246,0.15)', color: '#a78bfa', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{deal.packaging}</span>}
{deal.grading_company && <span style={{ background: 'rgba(6,182,212,0.15)', color: '#06b6d4', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{deal.grading_score}</span>}
<span style={{ color: '#22c55e', fontSize: '14px', fontWeight: 'bold' }}>¥{deal.deal_price?.toLocaleString()}</span>
</div>
{/* 第二行:编号 + 分类 + 大小号 + 展开箭头 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
{deal.deal_no && <span style={{ color: '#fff', fontSize: '10px' }}>{deal.deal_no}</span>}
{deal.category && <span style={{ background: 'rgba(59,130,246,0.15)', color: '#60a5fa', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{deal.category}</span>}
{deal.content && (() => {
const sizeMatch = deal.content.match(/大小号:\s*([^\n]+)/)
return sizeMatch && <span style={{ background: 'rgba(34,197,94,0.15)', color: '#22c55e', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{sizeMatch[1]}</span>
})()}
</div>
<span style={{ color: '#64748b', fontSize: '12px' }}>{expanded ? '▲ 收起' : '▼展开'}</span>
</div>
</div>
{/* 展开详情 */}
{expanded && (
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
{editing ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* 冠字号 */}
<div>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>冠字号</div>
<input value={editForm.title?.split('-')[0] || ''} onChange={e => setEditForm({...editForm, title: `${e.target.value}${editForm.deal_price}`})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '14px', fontFamily: 'monospace' }} />
</div>
{/* 价格和日期 */}
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>价格</div>
<input type="number" value={editForm.deal_price} onChange={e => {
const val = parseFloat(e.target.value) || 0
const serial = editForm.title?.split('-')[0] || ''
setEditForm({...editForm, deal_price: val, title: `${serial}${val}`})
}} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#22c55e', fontSize: '14px' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>日期</div>
<input type="date" value={editForm.deal_date} onChange={e => setEditForm({...editForm, deal_date: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '14px' }} />
</div>
</div>
{/* 包装和分类 */}
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>包装</div>
<select value={editForm.packaging} onChange={e => setEditForm({...editForm, packaging: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
<option value="单张">单张</option>
<option value="标十">标十</option>
<option value="标百">标百</option>
</select>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>分类</div>
<input value={editForm.category || ''} onChange={e => setEditForm({...editForm, category: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
</div>
{/* 评级 */}
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>评级机构</div>
<select value={editForm.grading_company || ''} onChange={e => setEditForm({...editForm, grading_company: e.target.value, is_graded: !!e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
<option value="">未评级</option>
<option value="PCGS">PCGS</option>
<option value="PMG">PMG</option>
<option value="ACG">ACG</option>
<option value="爱藏">爱藏</option>
</select>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>评级分数</div>
<input value={editForm.grading_score || ''} onChange={e => setEditForm({...editForm, grading_score: e.target.value})} placeholder="如: PC69, 67+" style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#06b6d4', fontSize: '13px' }} />
</div>
</div>
{/* 平台和出售者购买者 */}
<div>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>平台</div>
<select value={editForm.platform || ''} onChange={e => setEditForm({...editForm, platform: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
<option value="">请选择</option>
<option value="淘宝">淘宝</option>
<option value="咸鱼">咸鱼</option>
<option value="抖音">抖音</option>
<option value="快手">快手</option>
<option value="微拍堂">微拍堂</option>
<option value="拼多多">拼多多</option>
<option value="一尘">一尘</option>
<option value="爱藏">爱藏</option>
<option value="其他">其他</option>
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>出售者</div>
<input value={editForm.seller || ''} onChange={e => setEditForm({...editForm, seller: e.target.value})} placeholder="请输入出售者" style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>购买者</div>
<input value={editForm.buyer || ''} onChange={e => setEditForm({...editForm, buyer: e.target.value})} placeholder="请输入购买者" style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
</div>
</div>
{/* 备注 */}
<div>
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>备注</div>
<textarea value={editForm.content || ''} onChange={e => setEditForm({...editForm, content: e.target.value})} rows={2} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px', boxSizing: 'border-box' }} />
</div>
{/* 保存取消按钮 */}
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={saveEdit} style={{ flex: 1, padding: '12px', background: '#22c55e', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>保存</button>
<button onClick={() => setEditing(false)} style={{ flex: 1, padding: '12px', background: '#64748b', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>取消</button>
</div>
</div>
) : (
<div>
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '8px' }}>
{deal.deal_no && <div>编号: <span style={{ color: '#fbbf24' }}>{deal.deal_no}</span></div>}
<div>冠字号: <span style={{ color: '#fbbf24' }}>{deal.title?.split('-')[0] || '-'}</span></div>
<div>价格: <span style={{ color: '#22c55e' }}>¥{deal.deal_price?.toLocaleString()}</span></div>
<div>日期: {deal.deal_date}</div>
<div>包装: {deal.packaging || '单张'}</div>
<div>分类: {deal.category || '-'}</div>
<div>评级: {deal.grading_company ? `${deal.grading_company} ${deal.grading_score || ''}` : '未评级'}</div>
{deal.content && (() => {
const platformMatch = deal.content.match(/平台:\s*([^\n]+)/)
const sellerMatch = deal.content.match(/出售者:\s*([^\n]+)/)
const buyerMatch = deal.content.match(/购买者:\s*([^\n]+)/)
const sizeMatch = deal.content.match(/大小号:\s*([^\n]+)/)
const tailMatch = deal.content.match(/尾号:\s*([^\n]+)/)
return (
<div style={{ marginTop: '4px' }}>
{platformMatch && <div>平台: {platformMatch[1]}</div>}
{sellerMatch && <div>出售者: {sellerMatch[1]}</div>}
{buyerMatch && buyerMatch[1].trim() !== '-' && <div>购买者: {buyerMatch[1]}</div>}
{tailMatch && <div>尾号: {tailMatch[1]}</div>}
{sizeMatch && <div>大小号: <span style={{ color: '#60a5fa' }}>{sizeMatch[1]}</span></div>}
</div>
)
})()}
{deal.content && <div style={{ marginTop: '8px', color: '#64748b', fontSize: '11px' }}>{deal.content}</div>}
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button onClick={openEdit} style={{ flex: 1, padding: '8px', background: 'rgba(59,130,246,0.2)', color: '#60a5fa', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '12px' }}>编辑</button>
<button onClick={deleteDeal} style={{ flex: 1, padding: '8px', background: 'rgba(239,68,68,0.2)', color: '#ef4444', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '12px' }}>删除</button>
</div>
</div>
)}
</div>
)}
</div>
)
}

View File

@ -1,3 +1,9 @@
/**
* Login - 登录页面
* Version: 1.2.85x
* 更新
*/
import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version'
import { api } from '../utils/api'
@ -12,21 +18,84 @@ export default function Login() {
//
const [showRegister, setShowRegister] = useState(false)
const [registerData, setRegisterData] = useState({ username: '', password: '', confirmPassword: '', email: '' })
const [registerData, setRegisterData] = useState({ agreeTerms: false,
username: '',
password: '',
confirmPassword: '',
email: '',
phone: '',
verifyCode: '',
inviteCode: ''
})
const [registerLoading, setRegisterLoading] = useState(false)
const [registerError, setRegisterError] = useState('')
//
//
const [sendingCode, setSendingCode] = useState(false)
const [codeCountdown, setCodeCountdown] = useState(0)
const [codeSent, setCodeSent] = useState(false)
//
useEffect(() => {
generateCaptcha()
}, [])
//
useEffect(() => {
if (codeCountdown > 0) {
const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
return () => clearTimeout(timer)
}
}, [codeCountdown])
const generateCaptcha = () => {
const num1 = Math.floor(Math.random() * 10)
const num2 = Math.floor(Math.random() * 10)
setCaptcha({ num1, num2, answer: '' })
}
//
const handleSendCode = async () => {
if (!registerData.phone) {
setRegisterError('请先输入手机号')
return
}
//
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(registerData.phone)) {
setRegisterError('请输入正确的手机号')
return
}
setSendingCode(true)
setRegisterError('')
try {
const res = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone: registerData.phone })
})
const data = await res.json()
if (data.success) {
setCodeSent(true)
setCodeCountdown(60)
setRegisterError('')
} else {
setRegisterError(data.message || '发送失败')
}
} catch (err) {
setRegisterError('发送失败,请稍后重试')
} finally {
setSendingCode(false)
}
}
//
const handleRegister = async () => {
//
@ -35,13 +104,18 @@ export default function Login() {
return
}
if (registerData.username.length < 3) {
setRegisterError('用户名至少 3 个字符')
if (!registerData.agreeTerms) {
setRegisterError('请先同意用户协议')
return
}
if (registerData.password.length < 6) {
setRegisterError('密码至少 6 个字符')
if (registerData.username.length < 2) {
setRegisterError('用户名至少 2 个字符')
return
}
if (registerData.password.length < 8 || !/[A-Z]/.test(registerData.password) || !/[a-z]/.test(registerData.password) || !/[0-9]/.test(registerData.password)) {
setRegisterError('密码至少8位需包含大写、小写、数字')
return
}
@ -50,17 +124,40 @@ export default function Login() {
return
}
//
if (!registerData.phone) {
setRegisterError('手机号为必填项')
return
}
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(registerData.phone)) {
setRegisterError('请输入正确的手机号')
return
}
//
if (!registerData.verifyCode) {
setRegisterError('请输入短信获取')
return
}
setRegisterLoading(true)
setRegisterError('')
try {
const payload = {
username: registerData.username,
password: registerData.password
password: registerData.password,
phone: registerData.phone,
verifyCode: registerData.verifyCode
}
if (registerData.email) {
payload.email = registerData.email
}
if (registerData.inviteCode) {
payload.inviteCode = registerData.inviteCode
}
const res = await fetch('/api/auth/register', {
method: 'POST',
@ -73,12 +170,14 @@ export default function Login() {
const data = await res.json()
if (!res.ok) {
throw new Error(data.error?.message || '注册失败')
throw new Error(data.detail || data.error?.message || '注册失败')
}
alert('注册成功!请登录')
setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '' })
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '', inviteCode: '' })
setCodeSent(false)
setCodeCountdown(0)
generateCaptcha()
} catch (err) {
console.error('注册错误:', err)
@ -95,17 +194,13 @@ export default function Login() {
return
}
if (username.length < 3) {
if (username.length < 2) {
setError(ErrorCodes.E00021.message)
return
}
if (password.length < 6) {
setError(ErrorCodes.E00022.message)
return
}
//
//
const expectedAnswer = captcha.num1 + captcha.num2
if (!captcha.answer || Number(captcha.answer) !== expectedAnswer) {
setError(ErrorCodes.E00012.message)
@ -170,7 +265,6 @@ export default function Login() {
background: '#fff'
}}
/>
<h1 style={{ color: '#fbbf24', fontSize: '28px', fontWeight: '700', margin: '16px 0 8px' }}>甲辰收藏</h1>
<p style={{ color: 'rgba(255,255,255,0.6)', fontSize: '14px', margin: 0 }}>生肖纪念钞管理系统</p>
</div>
@ -241,11 +335,11 @@ export default function Login() {
/>
</div>
{/* 验证码 */}
{/* 获取 */}
<div style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<div onClick={generateCaptcha} style={{
flex: 1,
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
@ -355,7 +449,9 @@ export default function Login() {
background: 'rgba(30, 41, 59, 0.95)',
borderRadius: '16px',
padding: '24px',
border: '1px solid rgba(255,255,255,0.1)'
border: '1px solid rgba(255,255,255,0.1)',
maxHeight: '90vh',
overflowY: 'auto'
}}>
<h2 style={{ color: '#fbbf24', fontSize: '20px', fontWeight: '600', marginBottom: '20px', textAlign: 'center' }}>
注册新账号 🎉
@ -381,7 +477,7 @@ export default function Login() {
type="text"
value={registerData.username}
onChange={(e) => setRegisterData(prev => ({ ...prev, username: e.target.value }))}
placeholder="用户名(至少 3 个字符)"
placeholder="用户名(至少 2 个字符)"
style={{
width: '100%',
padding: '14px',
@ -395,6 +491,68 @@ export default function Login() {
/>
</div>
{/* 手机号 */}
<div style={{ marginBottom: '16px' }}>
<input
type="tel"
value={registerData.phone}
onChange={(e) => setRegisterData(prev => ({ ...prev, phone: e.target.value }))}
placeholder="手机号11位"
maxLength={11}
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 短信获取 */}
<div style={{ marginBottom: '16px' }}>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="text"
value={registerData.verifyCode}
onChange={(e) => setRegisterData(prev => ({ ...prev, verifyCode: e.target.value }))}
placeholder="短信验证码"
maxLength={6}
style={{
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
<button
onClick={handleSendCode}
disabled={sendingCode || codeCountdown > 0}
style={{
minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
background: codeCountdown > 0 ? 'rgba(148, 163, 184, 0.3)' : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
color: '#fff',
fontSize: '14px',
fontWeight: '600',
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap'
}}
>
{codeCountdown > 0 ? `${codeCountdown}` : sendingCode ? '发送中...' : '获取验证码'}
</button>
</div>
</div>
{/* 邮箱(可选) */}
<div style={{ marginBottom: '16px' }}>
<input
@ -421,7 +579,7 @@ export default function Login() {
type="password"
value={registerData.password}
onChange={(e) => setRegisterData(prev => ({ ...prev, password: e.target.value }))}
placeholder="密码(至少 6 个字符"
placeholder="密码(至少8位需大写+小写+数字"
style={{
width: '100%',
padding: '14px',
@ -436,7 +594,7 @@ export default function Login() {
</div>
{/* 确认密码 */}
<div style={{ marginBottom: '24px' }}>
<div style={{ marginBottom: '16px' }}>
<input
type="password"
value={registerData.confirmPassword}
@ -455,16 +613,56 @@ export default function Login() {
/>
</div>
{/* 邀请码(选填) */}
<div style={{ marginBottom: '24px' }}>
<input
type="text"
value={registerData.inviteCode}
onChange={(e) => setRegisterData(prev => ({ ...prev, inviteCode: e.target.value }))}
placeholder="邀请码(选填)"
style={{
width: '100%',
padding: '14px',
borderRadius: '8px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: '#fff',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
{/* 用户协议勾选 */}
<div style={{ marginBottom: '20px', display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
<input
type="checkbox"
checked={registerData.agreeTerms}
onChange={(e) => setRegisterData(prev => ({ ...prev, agreeTerms: e.target.checked }))}
style={{ width: '18px', height: '18px', marginTop: '2px', cursor: 'pointer' }}
/>
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.7)', lineHeight: '1.5' }}>
我已阅读并同意
<span
onClick={() => window.open('/user_agreement.html', '_blank')}
style={{ color: '#fbbf24', cursor: 'pointer', textDecoration: 'underline' }}
>用户协议</span>
</div>
</div>
{/* 按钮 */}
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={() => {
setShowRegister(false)
setRegisterData({ username: '', password: '', confirmPassword: '', email: '' })
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '', inviteCode: '' })
setRegisterError('')
setCodeSent(false)
setCodeCountdown(0)
}}
style={{
flex: 1,
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',
@ -481,7 +679,7 @@ export default function Login() {
onClick={handleRegister}
disabled={registerLoading}
style={{
flex: 1,
flex: 1, minWidth: "90px", width: "auto",
padding: '14px',
borderRadius: '8px',
border: 'none',

1250
frontend/src/pages/News.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,224 @@
/**
* News_YichensBoard - 一尘帖子页面
* Version: 1.2.75x
* 更新
*/
import React, { useState, useEffect } from 'react'
export default function YichensBoard() {
const [todayStats, setTodayStats] = useState({ total: 0, deals: 0, wants: 0, others: 0 })
const [todayCategory, setTodayCategory] = useState([])
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(false)
const [expandedPosts, setExpandedPosts] = useState({})
const [postTypeFilter, setPostTypeFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('')
const [page, setPage] = useState(1)
const [totalPosts, setTotalPosts] = useState(0)
const [searchKeyword, setSearchKeyword] = useState('')
const API_BASE = localStorage.getItem('API_BASE') || ''
const today = new Date()
const dateStr = today.getFullYear() + '/' + (today.getMonth() + 1) + '/' + today.getDate()
useEffect(() => { fetchTodayStats(); fetchPosts(1, '') }, [])
const fetchTodayStats = async () => {
try {
const res = await fetch(API_BASE + '/api/yichens/stats/today')
setTodayStats(await res.json())
} catch(e) { console.error(e) }
}
const fetchTodayCategory = async () => {
try {
const res = await fetch(API_BASE + '/api/yichens/stats/today-category')
setTodayCategory(await res.json())
} catch(e) { console.error(e) }
}
const fetchPosts = async (p, cat) => {
setLoading(true)
const currentPage = p !== undefined ? p : page
const currentCat = cat !== undefined ? cat : categoryFilter
let url = API_BASE + '/api/yichens/posts?limit=390&offset=' + ((currentPage - 1) * 390)
if (postTypeFilter === 'deal') url += '&post_type=deal'
else if (postTypeFilter === 'want') url += '&post_type=want'
else if (postTypeFilter === 'other') url += '&post_type=normal'
try {
const res = await fetch(url)
let data = await res.json() || []
if (currentCat) {
if (currentCat === '龙') {
data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞')))
} else if (currentCat === '蛇') {
data = data.filter(p => p.category && p.category.includes('蛇'))
} else if (currentCat === '马') {
data = data.filter(p => p.category && p.category.includes('马'))
} else if (currentCat === '其他') {
data = data.filter(p => p.category && !p.category.includes('龙') && !p.category.includes('纪念钞') && !p.category.includes('蛇') && !p.category.includes('马'))
}
}
// -
if (searchKeyword) {
const kw = searchKeyword.trim()
if (kw) {
data = data.filter(p =>
(p.title && p.title.includes(kw)) ||
(p.content && p.content.includes(kw)) ||
(p.category && p.category.includes(kw)) ||
(p.contact && p.contact.includes(kw))
)
}
}
setPosts(data)
setTotalPosts(todayStats.total || 0)
} catch { setPosts([]) }
setLoading(false)
}
useEffect(() => { if (todayStats.total > 0) fetchTodayCategory() }, [todayStats])
//
useEffect(() => {
setPage(1)
fetchPosts(1, '')
}, [searchKeyword])
const togglePost = id => setExpandedPosts(p => ({ ...p, [id]: !p[id] }))
const StatCard = ({ label, value, color, onClick }) => (
<div onClick={onClick} style={{
background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)',
borderRadius: 12, padding: '12px 8px', border: '1px solid #374151',
cursor: onClick ? 'pointer' : 'default',
textAlign: 'center'
}}>
<div style={{ color: '#9ca3af', fontSize: 11, marginBottom: 4 }}>{label}</div>
<div style={{ color: color || '#fff', fontSize: 20, fontWeight: 'bold' }}>{value}</div>
</div>
)
return (
<div style={{ padding: '0' }}>
<div style={{ marginBottom: 16 }}>
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 10 }}>
📈 连体钞/纪念钞 <span style={{ color: '#9ca3af', fontWeight: 400 }}>{dateStr}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
<StatCard label='全部' value={todayStats.total} color='#3b82f6' onClick={() => { setPostTypeFilter('all'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
<StatCard label='出售' value={todayStats.deals} color='#10b981' onClick={() => { setPostTypeFilter('deal'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
<StatCard label='求购' value={todayStats.wants} color='#f59e0b' onClick={() => { setPostTypeFilter('want'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
<StatCard label='其他' value={todayStats.others || 0} color='#8b5cf6' onClick={() => { setPostTypeFilter('other'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
</div>
</div>
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 12, marginBottom: 20, border: '1px solid #374151' }}>
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 8 }}>📊 今日分类统计</div>
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
{todayCategory.map(cat => (
<span key={cat.category} style={{ padding: '4px 10px', background: 'rgba(59,130,246,0.2)', borderRadius: 16, color: '#93c5fd', fontSize: 11, whiteSpace: 'nowrap' }}>
{cat.category} ({cat.count})
</span>
))}
</div>
</div>
{/* 搜索框 */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
placeholder="搜索标题、内容、分类..."
value={searchKeyword}
onChange={(e) => { setSearchKeyword(e.target.value); setPage(1); fetchPosts(1, '') }}
style={{ flex: 1, padding: '10px 14px', borderRadius: 8, border: '1px solid #374151', background: '#1f2937', color: '#fff', fontSize: 13, outline: 'none' }}
/>
<button
onClick={() => { setSearchKeyword(''); setPage(1); fetchPosts(1, '') }}
style={{ padding: '10px 16px', borderRadius: 8, border: 'none', background: '#4b5563', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
清空
</button>
</div>
{searchKeyword && <div style={{ color: '#9ca3af', fontSize: 12, marginTop: 6 }}>搜索: "{searchKeyword}"找到 {posts.length} 条结果</div>}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
{[{key:'all',label:'全部'},{key:'deal',label:'出售'},{key:'want',label:'求购'},{key:'other',label:'其他'}].map(k => (
<button key={k.key} onClick={() => { setPostTypeFilter(k.key==='other'?'other':k.key); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }}
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
background: postTypeFilter===k.key ? 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
{k.label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{[{key:'',label:'全部'},{key:'龙',label:'龙钞'},{key:'蛇',label:'蛇钞'},{key:'马',label:'马钞'},{key:'其他',label:'其他'}].map(k => (
<button key={k.key} onClick={() => { setCategoryFilter(k.key); setPage(1); fetchPosts(1, k.key) }}
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
background: categoryFilter===k.key ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
{k.label}
</button>
))}
</div>
{loading ? <div style={{textAlign:'center',color:'#9ca3af',padding:40}}>加载中...</div> : (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{posts.map(post => (
<div key={post.post_id} style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, border: '1px solid #374151' }}>
<div onClick={() => togglePost(post.post_id)} style={{ cursor: 'pointer' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ color: '#fff', fontSize: 15, fontWeight: 500, flex: 1 }}>{post.title||'无标题'}</span>
<div style={{ display: 'flex', gap: 4 }}>
<span style={{ color: post.post_type==='deal'?'#10b981':post.post_type==='want'?'#f59e0b':post.post_type==='normal'?'#8b5cf6':'#9ca3af', fontSize: 12 }}>
{post.post_type==='deal'?'出售':post.post_type==='want'?'求购':post.post_type==='normal'?'其他':'普通'}
</span>
<span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 11, background: post.category?.includes('龙') ? 'rgba(251,191,36,0.4)' : post.category?.includes('马') ? 'rgba(180,83,9,0.4)' : post.category?.includes('蛇') ? 'rgba(249,168,212,0.4)' : 'rgba(139,92,246,0.4)', color: post.category?.includes('龙') ? '#fde047' : post.category?.includes('马') ? '#d97706' : post.category?.includes('蛇') ? '#fbcfe8' : '#c4b5fd' }}>
{post.category || '-'}
</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#9ca3af', fontSize: 12 }}>
<span>{post.author_username||'未知'}</span>
<span>{post.post_time?.substring(0,16)||''}</span>
</div>
</div>
{expandedPosts[post.post_id] && post.content && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #374151' }}>
<div style={{ color: '#e2e8f0', fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 300, overflowY: 'auto' }}>
{post.content}
</div>
{post.url && <a href={post.url} target='_blank' rel='noopener noreferrer' style={{ display:'inline-block', marginTop:12, padding:'8px 16px', background:'rgba(59,130,246,0.2)', borderRadius:8, color:'#60a5fa', textDecoration:'none', fontSize:13 }}>查看原帖</a>}
</div>
)}
</div>
))}
</div>
{/* 分页按钮移到页面底部 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#1e293b', borderRadius: 12, marginTop: 16 }}>
<div style={{ color: '#9ca3af', fontSize: 12 }}>
{totalPosts} 条帖子{Math.ceil(totalPosts / 390)} 当前第 {page}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{page > 1 ? (
<button onClick={() => { const newPage = page - 1; setPage(newPage); fetchPosts(newPage, categoryFilter) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>上一页</button>
) : (
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>上一页</span>
)}
{posts.length >= 390 ? (
<button onClick={() => { const newPage = page + 1; setPage(newPage); fetchPosts(newPage, categoryFilter) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>下一页</button>
) : (
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>下一页</span>
)}
</div>
</div>
</div>
)}
</div>
)
}

View File

@ -1,654 +0,0 @@
// AI OCR -
import React, { useState, useEffect, useRef } from 'react'
// camelCase
const convertField = (obj) => {
const map = {
// f99
id: 'f99_90_id',
userId: 'f99_91_user_id',
// f01
name: 'f01_01_name',
code: 'f01_02_code',
category: 'f01_03_category',
status: 'f01_04_status',
remark: 'f01_05_remark',
// f02
prefixSerial: 'f02_10_prefix_serial',
version: 'f02_11_version',
packaging: 'f02_12_packaging',
rarity: 'f02_13_rarity',
// f03
isGraded: 'f03_20_is_graded',
gradingCompany: 'f03_21_grading_company',
gradingScore: 'f03_22_grading_score',
threeStar: 'f03_23_three_star',
// f04
specialMark: 'f04_30_special_mark',
serialFeature: 'f04_31_serial_feature',
issuer: 'f04_32_issuer',
issueYear: 'f04_33_issue_year',
material: 'f04_34_material',
denomination: 'f04_35_denomination',
issueQuantity: 'f04_36_issue_quantity',
// f05
costPrice: 'f05_40_cost_price',
targetPrice: 'f05_41_target_price',
goalPrice: 'f05_42_goal_price',
repairFee: 'f05_43_repair_fee',
gradingFee: 'f05_44_grading_fee',
// f06
purpose: 'f06_50_purpose'
}
const result = {}
const numberFields = ['costPrice', 'targetPrice', 'goalPrice', 'repairFee', 'gradingFee']
for (const key in obj) {
let value = obj[key]
if (value === '' || value === null) {
value = null
} else if (numberFields.includes(key)) {
value = parseFloat(value)
if (isNaN(value)) value = null
}
result[map[key] || key] = value
}
return result
}
// Input
const Input = ({ form, handleChange, label, field, type = 'text', options = null, disabled = false }) => {
const inputRef = useRef(null)
const handleFocus = () => {
setTimeout(() => {
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 100)
}
const onChange = (e) => {
const value = e.target.value
if (type === 'number' && value !== '') {
const num = parseFloat(value)
handleChange(field, isNaN(num) ? '' : num)
} else {
handleChange(field, value)
}
}
return (
<div style={{ marginBottom: '12px' }} ref={inputRef}>
<div style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>{label}</div>
{options ? (
<select
value={form[field] || ''}
onChange={onChange}
onFocus={handleFocus}
disabled={disabled}
style={{
background: disabled ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: disabled ? '#64748b' : '#fff',
padding: '10px',
borderRadius: '8px',
width: '100%'
}}
>
<option value="">请选择</option>
{options.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
</select>
) : (
<input
type={type}
value={form[field] ?? ''}
onChange={onChange}
onFocus={handleFocus}
disabled={disabled}
style={{
background: disabled ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
color: disabled ? '#64748b' : '#fff',
padding: '10px',
borderRadius: '8px',
width: '100%'
}}
/>
)}
</div>
)
}
//
const getDefaultForm = () => ({
name: '龙钞',
code: '',
category: '自持',
rarity: '通货',
prefixSerial: '',
version: '2024 龙',
denomination: '',
status: 'in_collection',
packaging: '单张',
material: '',
issueQuantity: '',
purpose: '收藏',
isGraded: false,
gradingCompany: '',
gradingScore: '',
threeStar: false,
specialMark: '',
serialFeature: '',
issuer: '中国人民银行',
issueYear: '',
costPrice: '',
targetPrice: '',
goalPrice: '',
repairFee: '',
gradingFee: '',
remark: ''
})
export default function OCR() {
const [activeTab, setActiveTab] = useState('ai') // ai, manual, batch
const [mode, setMode] = useState('camera') // camera, form
const [form, setForm] = useState(getDefaultForm())
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [recognizing, setRecognizing] = useState(false)
const [selectedImage, setSelectedImage] = useState(null)
const [imagePreview, setImagePreview] = useState(null)
const fileInputRef = useRef(null)
const videoRef = useRef(null)
const canvasRef = useRef(null)
//
const statusOptions = [
{ value: 'in_collection', label: '收藏中' },
{ value: 'selling', label: '出售中' },
{ value: 'sold', label: '已售' },
{ value: 'grading', label: '送评中' },
{ value: 'repairing', label: '修复中' },
{ value: 'transit', label: '在途中' },
{ value: 'other', label: '其他' }
]
const categoryOptions = [
{ value: '自持', label: '自持' },
{ value: '寄存', label: '寄存' },
{ value: '寄售', label: '寄售' },
{ value: '共有', label: '共有' },
{ value: '寻号', label: '寻号' },
{ value: '其他', label: '其他' }
]
const rarityOptions = [
{ value: '通货', label: '通货' },
{ value: '特色', label: '特色' },
{ value: '少见', label: '少见' },
{ value: '稀有', label: '稀有' },
{ value: '珍品', label: '珍品' },
{ value: '孤品', label: '孤品' }
]
const packagingOptions = [
{ value: '标十', label: '标十' },
{ value: '标百', label: '标百' },
{ value: '单张', label: '单张' },
{ value: '裸钞', label: '裸钞' }
]
const handleChange = (key, value) => {
setForm({ ...form, [key]: value })
if (key === 'targetPrice' && value) {
setForm(prev => ({ ...prev, status: 'sold' }))
}
}
//
const handleSelectImage = (e) => {
const file = e.target.files[0]
if (!file) return
setSelectedImage(file)
const reader = new FileReader()
reader.onload = (e) => {
setImagePreview(e.target.result)
}
reader.readAsDataURL(file)
}
// OCR
const handleRecognize = async () => {
if (!selectedImage) {
setError('请先选择图片')
return
}
setRecognizing(true)
setError('')
const token = localStorage.getItem('token')
const formData = new FormData()
formData.append('image', selectedImage)
// 使 XMLHttpRequest fetch
const data = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/ocr/recognize')
xhr.setRequestHeader('Authorization', 'Bearer ' + token)
xhr.onload = function() {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText)
resolve(data)
} catch (e) {
reject(new Error('JSON解析失败: ' + xhr.responseText.substring(0, 100)))
}
} else {
try {
const data = JSON.parse(xhr.responseText)
reject(new Error(data.error?.message || data.detail || '识别失败'))
} catch (e) {
reject(new Error('请求失败: ' + xhr.status))
}
}
}
xhr.onerror = function() {
reject(new Error('网络错误'))
}
xhr.send(formData)
})
try {
//
if (data.fields) {
const recognizedForm = { ...getDefaultForm() }
//
if (data.fields.name) recognizedForm.name = data.fields.name
if (data.fields.code) recognizedForm.code = data.fields.code
if (data.fields.version) recognizedForm.version = data.fields.version
if (data.fields.prefix_serial) recognizedForm.prefixSerial = data.fields.prefix_serial
if (data.fields.denomination) recognizedForm.denomination = data.fields.denomination
if (data.fields.material) recognizedForm.material = data.fields.material
if (data.fields.issue_year) recognizedForm.issueYear = data.fields.issue_year
if (data.fields.issuer) recognizedForm.issuer = data.fields.issuer
if (data.fields.cost_price) recognizedForm.costPrice = data.fields.cost_price
if (data.fields.remark) recognizedForm.remark = data.fields.remark
setForm(recognizedForm)
setMode('form')
alert('识别成功!请检查并完善信息')
} else {
setError('识别结果为空')
}
} catch (e) {
console.error('OCR 识别失败:', e)
setError('识别失败:' + e.message)
} finally {
setRecognizing(false)
}
}
//
const handleSave = async () => {
if (!form.name || !form.version) {
setError('名称和版别为必填项')
return
}
setSaving(true)
setError('')
const token = localStorage.getItem('token')
const formData = convertField(form)
try {
const res = await fetch('/api/collections', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
},
body: JSON.stringify(formData)
})
if (!res.ok) {
const data = await res.json()
throw new Error(data.error?.message || data.detail || '保存失败')
}
alert('保存成功!')
window.location.hash = '#/list'
window.refreshList?.()
window.refreshHome?.()
} catch (e) {
alert('保存失败:' + e.message)
} finally {
setSaving(false)
}
}
//
if (mode === 'camera') {
return (
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
{/* 顶部标签切换 */}
<div style={{ padding: '12px 16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={() => setActiveTab('ai')}
style={{
flex: 1,
padding: '10px',
background: activeTab === 'ai' ? '#fbbf24' : 'rgba(255,255,255,0.05)',
color: activeTab === 'ai' ? '#1e293b' : '#fff',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer'
}}
>
🤖 AI 识别
</button>
<button
onClick={() => {
setActiveTab('manual');
setMode('form');
}}
style={{
flex: 1,
padding: '10px',
background: activeTab === 'manual' ? '#fbbf24' : 'rgba(255,255,255,0.05)',
color: activeTab === 'manual' ? '#1e293b' : '#fff',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer'
}}
>
手工录入
</button>
<button
onClick={() => setActiveTab('batch')}
disabled
style={{
flex: 1,
padding: '10px',
background: 'rgba(255,255,255,0.02)',
color: '#64748b',
border: '1px solid rgba(255,255,255,0.05)',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: 'not-allowed'
}}
>
📦 批量录入
</button>
</div>
</div>
{/* 内容区域 */}
{activeTab === 'ai' && (
<div style={{ padding: '16px' }}>
{/* 图片选择区域 */}
<div style={{
background: 'rgba(255,255,255,0.03)',
borderRadius: '12px',
padding: '24px',
textAlign: 'center',
marginBottom: '12px'
}}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
capture="environment"
onChange={handleSelectImage}
style={{ display: 'none' }}
/>
{imagePreview ? (
<div>
<img
src={imagePreview}
alt="已选择图片"
style={{ maxWidth: '100%', borderRadius: '8px', marginBottom: '16px' }}
/>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
<button
onClick={() => { setSelectedImage(null); setImagePreview(null); }}
style={{
padding: '12px 24px',
background: 'rgba(239, 68, 68, 0.2)',
color: '#ef4444',
border: '1px solid #ef4444',
borderRadius: '8px',
fontSize: '14px',
cursor: 'pointer'
}}
>
🗑 重新选择
</button>
<button
onClick={handleRecognize}
disabled={recognizing}
style={{
padding: '12px 24px',
background: recognizing ? '#64748b' : '#fbbf24',
color: recognizing ? '#94a3b8' : '#1e293b',
border: 'none',
borderRadius: '8px',
fontSize: '14px',
fontWeight: '600',
cursor: recognizing ? 'not-allowed' : 'pointer'
}}
>
{recognizing ? '🔍 识别中...' : '🤖 开始识别'}
</button>
</div>
</div>
) : (
<div>
<div
onClick={() => {
fileInputRef.current.setAttribute('capture', '');
fileInputRef.current.setAttribute('accept', 'image/*');
fileInputRef.current.click();
}}
style={{
padding: '24px',
background: 'rgba(59, 130, 246, 0.1)',
border: '2px dashed rgba(59, 130, 246, 0.5)',
borderRadius: '12px',
cursor: 'pointer',
marginBottom: '16px',
textAlign: 'center'
}}
>
<div style={{ fontSize: '40px', marginBottom: '12px' }}>📷</div>
<div style={{ color: '#60a5fa', fontSize: '15px', fontWeight: 'bold', marginBottom: '4px' }}>
拍照识别
</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
使用相机拍照并识别
</div>
</div>
<div
onClick={() => {
fileInputRef.current.removeAttribute('capture');
fileInputRef.current.setAttribute('accept', 'image/*');
fileInputRef.current.click();
}}
style={{
padding: '24px',
background: 'rgba(34, 197, 94, 0.1)',
border: '2px dashed rgba(34, 197, 94, 0.5)',
borderRadius: '12px',
cursor: 'pointer',
textAlign: 'center'
}}
>
<div style={{ fontSize: '40px', marginBottom: '12px' }}>🖼</div>
<div style={{ color: '#22c55e', fontSize: '15px', fontWeight: 'bold', marginBottom: '4px' }}>
从相册选择
</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
从相册选择已有图片
</div>
</div>
</div>
)}
</div>
{/* 错误提示 */}
{error && (
<div style={{
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid #ef4444',
color: '#ef4444',
padding: '12px',
borderRadius: '8px',
marginBottom: '12px'
}}>
{error}
</div>
)}
{/* 提示信息 */}
<div style={{
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
padding: '16px',
marginTop: '12px'
}}>
<div style={{ color: '#60a5fa', fontSize: '14px', fontWeight: 'bold', marginBottom: '8px' }}>💡 识别说明</div>
<ul style={{ color: '#94a3b8', fontSize: '13px', paddingLeft: '20px', margin: 0 }}>
<li>支持拍照或从相册选择图片</li>
<li>自动识别名称版别冠字序号等字段</li>
<li>识别结果可手动修改完善</li>
<li>建议拍摄清晰光线充足的正面照片</li>
</ul>
</div>
</div>
</div>
)
}
//
return (
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
{/* 顶部 */}
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '12px' }}>
<button
onClick={() => setMode('camera')}
style={{
background: 'rgba(255,255,255,0.1)',
color: '#fff',
border: 'none',
borderRadius: '6px',
width: '36px',
height: '36px',
fontSize: '20px',
cursor: 'pointer'
}}
>
</button>
<div>
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>确认信息</div>
<div style={{ color: '#94a3b8', fontSize: '13px' }}>请检查并完善识别结果</div>
</div>
</div>
{error && (
<div style={{
margin: '16px',
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid #ef4444',
color: '#ef4444',
padding: '12px',
borderRadius: '8px'
}}>
{error}
</div>
)}
<div style={{ padding: '16px' }}>
{/* 基本信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
<Input form={form} handleChange={handleChange} label="版别 *" field="version" />
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
<Input form={form} handleChange={handleChange} label="面值" field="denomination" />
<Input form={form} handleChange={handleChange} label="材质" field="material" />
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
</div>
{/* 价格信息 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>价格信息</div>
<Input form={form} handleChange={handleChange} label="成本价" field="costPrice" type="number" />
<Input form={form} handleChange={handleChange} label="目标价" field="targetPrice" type="number" />
<Input form={form} handleChange={handleChange} label="出售价" field="goalPrice" type="number" />
</div>
{/* 备注 */}
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>备注</div>
<textarea
value={form.remark || ''}
onChange={(e) => handleChange('remark', e.target.value)}
rows={3}
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '10px', borderRadius: '8px', width: '100%', resize: 'none' }}
/>
</div>
{/* 保存按钮 */}
<button
onClick={saving ? null : handleSave}
disabled={saving}
style={{
width: '100%',
padding: '14px',
background: saving ? '#64748b' : '#fbbf24',
color: saving ? '#94a3b8' : '#1e293b',
border: 'none',
borderRadius: '10px',
fontSize: '16px',
fontWeight: '600',
cursor: saving ? 'not-allowed' : 'pointer',
marginBottom: '12px'
}}
>
{saving ? '保存中...' : '✅ 保存藏品'}
</button>
<button
onClick={() => setMode('camera')}
style={{
width: '100%',
padding: '14px',
background: 'rgba(255,255,255,0.05)',
color: '#94a3b8',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '10px',
fontSize: '14px',
cursor: 'pointer'
}}
>
🔄 重新识别
</button>
</div>
</div>
)
}

View File

@ -1,3 +1,9 @@
/**
* Settings - 设置页面
* Version: 1.2.75x
* 更新
*/
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
@ -16,6 +22,13 @@ export default function Settings() {
bio: ''
})
//
const [phoneChanged, setPhoneChanged] = useState(false)
const [newPhone, setNewPhone] = useState('')
const [verifyCode, setVerifyCode] = useState('')
const [sendingCode, setSendingCode] = useState(false)
const [codeCountdown, setCodeCountdown] = useState(0)
const [passwordForm, setPasswordForm] = useState({
oldPassword: '',
newPassword: '',
@ -29,6 +42,14 @@ export default function Settings() {
fetchUserInfo()
}, [])
//
useEffect(() => {
if (codeCountdown > 0) {
const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
return () => clearTimeout(timer)
}
}, [codeCountdown])
const fetchUserInfo = async () => {
try {
const res = await fetch('/api/users/me', {
@ -45,12 +66,10 @@ export default function Settings() {
address: data.address || '',
bio: data.bio || ''
})
} else {
console.error('获取用户信息失败:', res.status)
setNewPhone(data.phone || '')
}
} catch (e) {
console.error('获取用户信息异常:', e)
//
const userStr = localStorage.getItem('user')
if (userStr) {
try {
@ -63,6 +82,7 @@ export default function Settings() {
address: user.address || '',
bio: user.bio || ''
})
setNewPhone(user.phone || '')
} catch (e2) {
setError('获取用户信息失败')
}
@ -72,26 +92,92 @@ export default function Settings() {
}
}
//
const handleSendCode = async () => {
if (!newPhone) {
setError('请输入手机号')
return
}
const phoneRegex = /^1[3-9]\d{9}$/
if (!phoneRegex.test(newPhone)) {
setError('请输入正确的手机号')
return
}
setSendingCode(true)
setError('')
try {
const res = await fetch('/api/auth/send-verification-code', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone: newPhone })
})
const data = await res.json()
if (data.success) {
setPhoneChanged(true)
setCodeCountdown(60)
setSuccess('验证码已发送到 ' + newPhone.substring(0,3) + '****' + newPhone.substring(7))
} else {
setError(data.message || '发送失败')
}
} catch (err) {
setError('发送失败,请稍后重试')
} finally {
setSendingCode(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSuccess('')
//
if (newPhone !== form.phone) {
if (!verifyCode) {
setError('请输入手机验证码')
return
}
}
setSaving(true)
try {
const updateData = {
username: form.username,
email: form.email || null,
phone: newPhone,
address: form.address,
bio: form.bio
}
//
if (newPhone !== form.phone) {
updateData.verifyCode = verifyCode
}
const res = await fetch('/api/users/me', {
method: 'PUT',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify(form)
body: JSON.stringify(updateData)
})
if (res.ok) {
const data = await res.json()
setSuccess('保存成功!')
//
setForm({...form, phone: newPhone})
setVerifyCode('')
setPhoneChanged(false)
//
const userStr = localStorage.getItem('user')
if (userStr) {
const user = JSON.parse(userStr)
@ -102,7 +188,7 @@ export default function Settings() {
}
} else {
const data = await res.json()
setError(data.error?.message || '保存失败')
setError(data.error?.message || data.detail || '保存失败')
}
} catch (e) {
setError('保存失败,请重试')
@ -126,16 +212,14 @@ export default function Settings() {
return
}
if (passwordForm.newPassword.length < 6) {
setError('密码长度至少6位')
if (passwordForm.newPassword.length < 8) {
setError('密码至少8位需包含大写+小写+数字')
return
}
setSaving(true)
console.log('开始修改密码...')
try {
console.log('发送请求到 /api/auth/change-password')
const res = await fetch('/api/auth/change-password', {
method: 'POST',
headers: {
@ -148,9 +232,7 @@ export default function Settings() {
})
})
console.log('响应状态:', res.status)
const data = await res.json()
console.log('响应数据:', data)
if (res.ok) {
setSuccess('密码修改成功!')
@ -200,11 +282,12 @@ export default function Settings() {
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>邮箱</label>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>邮箱选填</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="选填"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
@ -213,27 +296,69 @@ export default function Settings() {
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>手机号</label>
<input
type="tel"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
value={newPhone}
onChange={(e) => {
setNewPhone(e.target.value)
setPhoneChanged(true)
setVerifyCode('')
}}
placeholder="11位手机号"
maxLength={11}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
{/* 手机验证码 - 仅在手机号变更时显示 */}
{phoneChanged && (
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>手机验证码</label>
<div style={{ display: 'flex', gap: '12px' }}>
<input
type="text"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value)}
placeholder="6位验证码"
maxLength={6}
style={{ flex: 1, padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
<button
type="button"
onClick={handleSendCode}
disabled={sendingCode || codeCountdown > 0}
style={{
width: '120px',
padding: '12px',
borderRadius: '8px',
border: 'none',
background: codeCountdown > 0 ? '#4b5563' : '#22c55e',
color: '#fff',
fontSize: '14px',
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer'
}}
>
{codeCountdown > 0 ? codeCountdown + '秒' : sendingCode ? '发送中...' : '获取验证码'}
</button>
</div>
</div>
)}
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>地址</label>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>地址选填</label>
<input
type="text"
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
placeholder="选填"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>简介</label>
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>简介选填</label>
<textarea
value={form.bio}
onChange={(e) => setForm({ ...form, bio: e.target.value })}
placeholder="选填"
rows={3}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff', resize: 'none' }}
/>
@ -260,8 +385,8 @@ export default function Settings() {
type="password"
value={passwordForm.oldPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
placeholder="请输入当前密码"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>
@ -271,8 +396,8 @@ export default function Settings() {
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
placeholder="至少8位需包含大写+小写+数字"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
placeholder="至少6位"
/>
</div>
@ -282,8 +407,8 @@ export default function Settings() {
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
placeholder="再次输入新密码"
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
/>
</div>

View File

@ -1,4 +1,9 @@
// -
/**
* Stats - 统计页面
* Version: 1.2.70x
* 更新
*/
import React, { useState, useEffect } from 'react'
import { APP_VERSION } from '../config/version'
@ -10,6 +15,7 @@ export default function Stats() {
byGrading: [],
byPackaging: [],
byRarity: [],
byNumberCategory: [],
byVersion: [],
byGradingCompany: [],
byGradingScore: [],
@ -49,6 +55,7 @@ export default function Stats() {
byGrading: data.byGrading || [],
byPackaging: data.byPackaging || [],
byRarity: data.byRarity || [],
byNumberCategory: data.byNumberCategory || [],
byVersion: data.byVersion || [],
byGradingCompany: data.byGradingCompany || [],
byGradingScore: data.byGradingScore || [],
@ -86,6 +93,9 @@ export default function Stats() {
gradingCompany: 'gradingCompany',
gradingScore: 'gradingScore',
specialMark: 'specialMark',
numberCategory: 'numberCategory',
gradingCompany: 'gradingCompany',
gradingScore: 'gradingScore',
profitLoss: 'profitLoss'
}
return map[type] || type
@ -94,10 +104,15 @@ export default function Stats() {
//
const colors = {
packaging: { '标十': '#22c55e', '标百': '#3b82f6', '单张': '#f59e0b', '裸钞': '#64748b' },
rarity: { '通货': '#64748b', '特色': '#22c55e', '少见': '#3b82f6', '稀有': '#8b5cf6', '珍品': '#f59e0b', '孤品': '#ef4444' },
rarity: { '通货': '#64748b', '特色': '#22c55e', '少见': '#3b82f6', '稀有': '#8b5cf6', '珍品': '#ef4444', '孤品': '#fbbf24' },
status: { 'in_collection': '#22c55e', 'selling': '#f59e0b', 'sold': '#ef4444', 'grading': '#8b5cf6', 'repairing': '#f97316', 'transit': '#06b6d4', 'seeking': '#ec4899' },
category: { '自持': '#22c55e', '寄存': '#3b82f6', '寄售': '#f59e0b', '共有': '#8b5cf6', '寻号': '#06b6d4', '生肖钞': '#22c55e', '其他': '#64748b' },
profitLoss: { 'profit': '#22c55e', 'loss': '#ef4444' }
profitLoss: { 'profit': '#22c55e', 'loss': '#ef4444' },
numberCategory: { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '带7号': '#06b6d4', '带4号': '#22c55e', '其他': '#64748b' },
version: {},
gradingCompany: {},
gradingScore: {},
specialMark: {}
}
//
@ -117,8 +132,14 @@ export default function Stats() {
}
}
const colorPalette = ['#22c55e', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#f97316', '#14b8a6', '#a855f7']
const getColor = (type, value) => {
return colors[type]?.[value] || '#64748b'
if (colors[type]?.[value]) return colors[type][value]
//
const key = String(value)
let hash = 0
for (let i = 0; i < key.length; i++) hash = key.charCodeAt(i) + ((hash << 5) - hash)
return colorPalette[Math.abs(hash) % colorPalette.length]
}
const getLabel = (type, value) => {
@ -130,11 +151,23 @@ export default function Stats() {
return Number(val).toLocaleString('zh-CN')
}
const numberCategoryOrder = ['无2347', '无347', '无247', '无47', '带7号', '带4号', '其他']
const getSortedData = (data, type) => {
if (type === 'numberCategory') {
return [...data].sort((a, b) => {
const order = numberCategoryOrder.indexOf(a.numberCategory)
const order2 = numberCategoryOrder.indexOf(b.numberCategory)
return order - order2
})
}
return data
}
const DistributionCard = ({ title, data, type, valueKey, labelKey }) => (
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>{title}</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
{data.slice(0, 6).map((item, index) => {
{getSortedData(data, type).map((item, index) => {
const value = item[valueKey]
const label = getLabel(type, item[labelKey] || value)
const color = getColor(type, value)
@ -159,13 +192,17 @@ export default function Stats() {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.05)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: color }} />
<div style={{ color: '#fff', fontSize: '13px', fontWeight: '500', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: 1, overflow: 'hidden' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: color, flexShrink: 0 }} />
<div style={{ color: color, fontSize: '12px', fontWeight: '500', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label}
</div>
</div>
<div style={{ color: '#fff', fontSize: '14px', fontWeight: 'bold', marginLeft: '8px', flexShrink: 0 }}>
{item.count}
</div>
</div>
<div style={{ color: '#fff', fontSize: '16px', fontWeight: 'bold' }}>{item.count}</div>
</div>
)
})}
@ -201,6 +238,25 @@ export default function Stats() {
</div>
<div style={{ padding: '16px' }}>
{/* 总统计 */}
<div style={{ background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.2) 0%, rgba(251, 191, 36, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(251, 191, 36, 0.3)' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>📊 总统计</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>总数量</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{stats.totalCount}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>已评级</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{gradedCount}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>未评级</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{ungradedCount}</div>
</div>
</div>
</div>
{/* 财务统计 */}
<div style={{ background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(34, 197, 94, 0.3)' }}>
<div style={{ color: '#22c55e', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>💰 财务统计</div>
@ -227,28 +283,9 @@ export default function Stats() {
{/* 盈亏统计移到顶部 */}
<DistributionCard title="💰 盈亏统计" data={stats.byProfitLoss} type="profitLoss" valueKey="type" labelKey="label" />
{/* 总统计 */}
<div style={{ background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.2) 0%, rgba(251, 191, 36, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(251, 191, 36, 0.3)' }}>
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>📊 总统计</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>总数量</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{stats.totalCount}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>已评级</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{gradedCount}</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>未评级</div>
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{ungradedCount}</div>
</div>
</div>
</div>
<DistributionCard title="📋 状态分布" data={stats.byStatus} type="status" valueKey="status" />
<DistributionCard title="💼 持仓类型分布" data={stats.byCategory} type="category" valueKey="category" />
<DistributionCard title="📦 包装分布" data={stats.byPackaging} type="packaging" valueKey="packaging" />
<DistributionCard title="🔢 号码分类分布" data={stats.byNumberCategory} type="numberCategory" valueKey="numberCategory" />
<DistributionCard title="📋 状态分布" data={stats.byStatus} type="status" valueKey="status" />
<DistributionCard title="⭐ 珍惜度分布" data={stats.byRarity} type="rarity" valueKey="rarity" />
<DistributionCard title="🏷️ 版别分布" data={stats.byVersion} type="version" valueKey="version" />
<DistributionCard title="🏅 评级机构分布" data={stats.byGradingCompany} type="gradingCompany" valueKey="company" />

View File

@ -0,0 +1,261 @@
/**
* YichensBoard - 一尘看板页面
* Version: 1.2.70x
* 更新
*/
import React, { useState, useEffect } from 'react'
export default function YichensBoard() {
const [todayStats, setTodayStats] = useState({ total: 0, deals: 0, wants: 0, others: 0 })
const [todayCategory, setTodayCategory] = useState([])
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [expandedPosts, setExpandedPosts] = useState({})
const [postTypeFilter, setPostTypeFilter] = useState('all')
const [categoryFilter, setCategoryFilter] = useState('')
const [page, setPage] = useState(1)
const [totalPosts, setTotalPosts] = useState(0)
const [searchKeyword, setSearchKeyword] = useState('')
const today = new Date()
const dateStr = today.getFullYear() + '/' + (today.getMonth() + 1) + '/' + today.getDate()
useEffect(() => {
console.log('YichensBoard: 开始加载数据')
fetchTodayStats()
}, [])
const fetchTodayStats = async () => {
try {
console.log('YichensBoard: 请求 /api/yichens/stats/today')
const res = await fetch('/api/yichens/stats/today')
if (!res.ok) throw new Error('stats API error: ' + res.status)
const data = await res.json()
console.log('YichensBoard: stats data', data)
setTodayStats(data)
} catch(e) {
console.error('YichensBoard: fetchTodayStats error', e)
setError(e.message)
}
}
const fetchPosts = async (p, cat, kw) => {
setLoading(true)
setError(null)
const currentPage = p !== undefined ? p : page
const currentCat = cat !== undefined ? cat : categoryFilter
const searchKw = kw !== undefined ? kw : searchKeyword
let url = '/api/yichens/posts?limit=390&offset=' + ((currentPage - 1) * 390)
if (postTypeFilter === 'deal') url += '&post_type=deal'
else if (postTypeFilter === 'want') url += '&post_type=want'
else if (postTypeFilter === 'other') url += '&post_type=normal'
if (searchKw && searchKw.trim()) url += '&keyword=' + encodeURIComponent(searchKw.trim())
try {
console.log('YichensBoard: 请求 posts', url)
const res = await fetch(url)
if (!res.ok) throw new Error('posts API error: ' + res.status)
let data = await res.json() || []
// data
if (!Array.isArray(data)) {
data = data.posts || data.data || []
}
console.log('YichensBoard: posts data count', data.length)
if (currentCat && Array.isArray(data)) {
if (currentCat === '龙') {
data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞')))
} else if (currentCat === '蛇') {
data = data.filter(p => p.category && p.category.includes('蛇'))
} else if (currentCat === '马') {
data = data.filter(p => p.category && p.category.includes('马'))
} else if (currentCat === '其他') {
data = data.filter(p => p.category && !p.category.includes('龙') && !p.category.includes('纪念钞') && !p.category.includes('蛇') && !p.category.includes('马'))
}
}
// data
if (!Array.isArray(data)) {
data = data.posts || data.data || []
}
console.log('YichensBoard: posts data count', data.length)
if (currentCat && Array.isArray(data)) {
if (currentCat === '龙') {
data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞')))
} else if (currentCat === '蛇') {
data = data.filter(p => p.category && p.category.includes('蛇'))
} else if (currentCat === '马') {
data = data.filter(p => p.category && p.category.includes('马'))
} else if (currentCat === '其他') {
data = data.filter(p => p.category && !p.category.includes('龙') && !p.category.includes('纪念钞') && !p.category.includes('蛇') && !p.category.includes('马'))
}
}
// {posts:[], total:xxx} [{},{}]
const postsArray = Array.isArray(data) ? data : (data.posts || [])
const totalCount = data.total || todayStats.total || postsArray.length
setPosts(postsArray)
setTotalPosts(totalCount)
} catch(e) {
console.error('YichensBoard: fetchPosts error', e)
setError(e.message)
setPosts([])
} finally {
setLoading(false)
}
}
useEffect(() => {
if (todayStats.total > 0) {
fetchPosts(1, categoryFilter, searchKeyword)
}
}, [todayStats, categoryFilter, postTypeFilter])
useEffect(() => {
setPage(1)
fetchPosts(1, categoryFilter, searchKeyword)
}, [searchKeyword])
const togglePost = id => setExpandedPosts(p => ({ ...p, [id]: !p[id] }))
const StatCard = ({ label, value, color, onClick }) => (
<div onClick={() => { setLoading(true); if (onClick) onClick(); }}
style={{
background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)',
borderRadius: 12, padding: '12px 8px', border: '1px solid #374151',
cursor: onClick ? 'pointer' : 'default',
textAlign: 'center'
}}>
<div style={{ color: '#9ca3af', fontSize: 11, marginBottom: 4 }}>{label}</div>
<div style={{ color: color || '#fff', fontSize: 20, fontWeight: 'bold' }}>{value}</div>
</div>
)
if (error) {
return (
<div style={{ padding: 20, textAlign: 'center', color: '#ef4444' }}>
<div>加载失败: {error}</div>
<button onClick={() => { setError(null); fetchTodayStats(); }} style={{ marginTop: 10, padding: '8px 16px', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>
重试
</button>
</div>
)
}
return (
<div style={{ padding: '0' }}>
<div style={{ marginBottom: 16 }}>
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 10 }}>
📈 连体钞/纪念钞 <span style={{ color: '#9ca3af', fontWeight: 400 }}>{dateStr}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
<StatCard label='全部' value={todayStats.total} color='#3b82f6' onClick={() => { setPostTypeFilter('all'); setCategoryFilter(''); setPage(1); }} />
<StatCard label='出售' value={todayStats.deals} color='#10b981' onClick={() => { setPostTypeFilter('deal'); setCategoryFilter(''); setPage(1); }} />
<StatCard label='求购' value={todayStats.wants} color='#f59e0b' onClick={() => { setPostTypeFilter('want'); setCategoryFilter(''); setPage(1); }} />
<StatCard label='其他' value={todayStats.others || 0} color='#8b5cf6' onClick={() => { setPostTypeFilter('other'); setCategoryFilter(''); setPage(1); }} />
</div>
</div>
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 12, marginBottom: 20, border: '1px solid #374151' }}>
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 8 }}>📊 今日分类统计</div>
<div style={{ color: '#9ca3af', fontSize: 12 }}>: {todayStats.dragons || 0} | : {todayStats.horses || 0} | : {todayStats.snakes || 0} | 天马: {todayStats.tianma || 0}</div>
</div>
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
placeholder="搜索标题、内容、分类..."
value={searchKeyword}
onChange={(e) => { const kw = e.target.value; setSearchKeyword(kw); setPage(1); fetchPosts(1, '', kw) }}
style={{ flex: 1, padding: '10px 14px', borderRadius: 8, border: '1px solid #374151', background: '#1f2937', color: '#fff', fontSize: 13, outline: 'none' }}
/>
<button
onClick={() => { setSearchKeyword(''); setPage(1); fetchPosts(1, '') }}
style={{ padding: '10px 16px', borderRadius: 8, border: 'none', background: '#4b5563', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
清空
</button>
</div>
{searchKeyword && <div style={{ color: '#9ca3af', fontSize: 12, marginTop: 6 }}>搜索: "{searchKeyword}"共找到 {totalPosts} 条结果</div>}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
{[{key:'all',label:'全部'},{key:'deal',label:'出售'},{key:'want',label:'求购'},{key:'other',label:'其他'}].map(k => (
<button key={k.key} onClick={() => { setPostTypeFilter(k.key==='other'?'other':k.key); setCategoryFilter(''); setPage(1); }}
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
background: postTypeFilter===k.key ? 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
{k.label}
</button>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
{[{key:'',label:'全部'},{key:'龙',label:'龙钞'},{key:'蛇',label:'蛇钞'},{key:'马',label:'马钞'},{key:'其他',label:'其他'}].map(k => (
<button key={k.key} onClick={() => { setCategoryFilter(k.key); setPage(1); }}
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
background: categoryFilter===k.key ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
{k.label}
</button>
))}
</div>
{loading ? <div style={{textAlign:'center',color:'#9ca3af',padding:40}}>加载中...</div> : (
<div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{posts.length === 0 ? (
<div style={{ textAlign: 'center', color: '#9ca3af', padding: 40 }}>暂无帖子数据</div>
) : (
posts.map(post => (
<div key={post.post_id} style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, border: '1px solid #374151' }}>
<div onClick={() => togglePost(post.post_id)} style={{ cursor: 'pointer' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ color: '#fff', fontSize: 15, fontWeight: 500, flex: 1 }}>{post.title||'无标题'}</span>
<div style={{ display: 'flex', gap: 4 }}>
<span style={{ color: post.post_type==='deal'?'#10b981':post.post_type==='want'?'#f59e0b':post.post_type==='normal'?'#8b5cf6':'#9ca3af', fontSize: 12 }}>
{post.post_type==='deal'?'出售':post.post_type==='want'?'求购':post.post_type==='normal'?'其他':'普通'}
</span>
<span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 11, background: post.category?.includes('龙') ? 'rgba(251,191,36,0.4)' : post.category?.includes('马') ? 'rgba(180,83,9,0.4)' : post.category?.includes('蛇') ? 'rgba(249,168,212,0.4)' : 'rgba(139,92,246,0.4)', color: post.category?.includes('龙') ? '#fde047' : post.category?.includes('马') ? '#d97706' : post.category?.includes('蛇') ? '#fbcfe8' : '#c4b5fd' }}>
{post.category || '-'}
</span>
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#9ca3af', fontSize: 12 }}>
<span>{post.author_username||'未知'}</span>
<span>{post.post_time?.substring(0,16)||''}</span>
</div>
</div>
{expandedPosts[post.post_id] && post.content && (
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #374151' }}>
<div style={{ color: '#e2e8f0', fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 300, overflowY: 'auto' }}>
{post.content}
</div>
{post.url && <a href={post.url} target='_blank' rel='noopener noreferrer' style={{ display:'inline-block', marginTop:12, padding:'8px 16px', background:'rgba(59,130,246,0.2)', borderRadius:8, color:'#60a5fa', textDecoration:'none', fontSize:13 }}>查看原帖</a>}
</div>
)}
</div>
))
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#1e293b', borderRadius: 12, marginTop: 16 }}>
<div style={{ color: '#9ca3af', fontSize: 12 }}>
{totalPosts} 条结果{Math.ceil(totalPosts / 390)} 当前第 {page}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{page > 1 ? (
<button onClick={() => { const newPage = page - 1; setPage(newPage); fetchPosts(newPage, categoryFilter, searchKeyword) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>上一页</button>
) : (
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>上一页</span>
)}
{posts.length >= 390 && totalPosts > page * 390 ? (
<button onClick={() => { const newPage = page + 1; setPage(newPage); fetchPosts(newPage, categoryFilter, searchKeyword) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>下一页</button>
) : (
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>下一页</span>
)}
</div>
</div>
</div>
)}
</div>
)
}

128
frontend/src/pages/news.py Normal file
View File

@ -0,0 +1,128 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import Table, MetaData
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime, date
from app.core.database import get_db, engine
from app.models.models import User
from app.routers.auth import get_current_user
router = APIRouter(prefix="/api/news", tags=["资讯"])
metadata = MetaData()
# 分类表
categories_table = Table('news_categories', metadata, autoload_with=engine)
news_table = Table('news', metadata, autoload_with=engine)
user_posts_table = Table('user_posts', metadata, autoload_with=engine)
users_table = Table('users', metadata, autoload_with=engine)
deals_table = Table('deals', metadata, autoload_with=engine)
notifications_table = Table('notifications', metadata, autoload_with=engine)
# ============ 获取分类 ============
@router.get("/categories")
def get_categories(db: Session = Depends(get_db)):
results = db.query(categories_table).order_by(categories_table.c.sort_order).all()
return [dict(r._mapping) for r in results]
# ============ 获取资讯 ============
@router.get("")
def get_news(
category_id: Optional[int] = None,
page: int = 1,
limit: int = 20,
db: Session = Depends(get_db)
):
query = db.query(news_table)
if category_id:
query = query.filter(news_table.c.category_id == category_id)
offset = (page - 1) * limit
results = query.order_by(news_table.c.created_at.desc()).offset(offset).limit(limit).all()
return [dict(r._mapping) for r in results]
# ============ 获取用户发布 ============
@router.get("/posts")
def get_posts(
post_type: Optional[str] = None,
status: str = "active",
page: int = 1,
limit: int = 20,
db: Session = Depends(get_db)
):
query = db.query(user_posts_table).filter(user_posts_table.c.status == status)
if post_type:
query = query.filter(user_posts_table.c.post_type == post_type)
offset = (page - 1) * limit
results = query.order_by(user_posts_table.c.created_at.desc()).offset(offset).limit(limit).all()
return [dict(r._mapping) for r in results]
# ============ 创建发布 ============
class PostCreate(BaseModel):
post_type: str
title: str
content: Optional[str] = None
zodiac_type: Optional[str] = None
packaging: Optional[str] = None
@router.post("/posts")
def create_post(
post: PostCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
result = db.execute(user_posts_table.insert().values(
user_id=current_user.f99_90_id,
post_type=post.post_type,
title=post.title,
content=post.content,
zodiac_type=post.zodiac_type,
packaging=post.packaging,
status="pending"
))
db.commit()
return {"success": True, "id": result.inserted_primary_key[0]}
# ============ 成交数据 ============
@router.get("/deals")
def get_deals(
zodiac_type: Optional[str] = None,
limit: int = 20,
db: Session = Depends(get_db)
):
query = db.query(deals_table)
if zodiac_type:
query = query.filter(deals_table.c.zodiac_type == zodiac_type)
results = query.order_by(deals_table.c.deal_date.desc()).limit(limit).all()
return [dict(r._mapping) for r in results]
# ============ 通知 ============
@router.get("/notifications")
def get_notifications(limit: int = 10, db: Session = Depends(get_db)):
results = db.query(notifications_table).filter(
notifications_table.c.is_published == True
).order_by(notifications_table.c.created_at.desc()).limit(limit).all()
return [dict(r._mapping) for r in results]
# ============ 首页数据 ============
@router.get("/home")
def get_home(db: Session = Depends(get_db)):
# 推荐发布
posts = db.query(user_posts_table).filter(
user_posts_table.c.status == "active"
).order_by(user_posts_table.c.created_at.desc()).limit(10).all()
# 成交
deals = db.query(deals_table).order_by(
deals_table.c.deal_date.desc()
).limit(10).all()
# 通知
notices = db.query(notifications_table).filter(
notifications_table.c.is_published == True
).order_by(notifications_table.c.created_at.desc()).limit(5).all()
return {
"posts": [dict(p._mapping) for p in posts],
"deals": [dict(d._mapping) for d in deals],
"notices": [dict(n._mapping) for n in notices]
}

View File

@ -0,0 +1,129 @@
/**
* 号码分类工具
* 根据冠字号自动分类为圆圆号倒置号金马王金马号金山王天马王金山号天马号朦胧号如意号钻石号永恒号带7号带4号
*/
// 分类定义(按优先级排序)
const CATEGORIES = [
{ name: '圆圆号', mustNot: ['1','2','3','4','5','7'], mustHave: [] },
{ name: '倒置号', mustNot: ['2','3','4','5','7'], mustHave: ['1'] },
{ name: '金马王', mustNot: ['1','2','3','4','7'], mustHave: ['5'] },
{ name: '金马号', mustNot: ['2','3','4','7'], mustHave: ['1','5'] },
{ name: '金山王', mustNot: ['1','2','4','5','7'], mustHave: ['3'] },
{ name: '天马王', mustNot: ['1','2','4','7'], mustHave: ['3','5'] },
{ name: '金山号', mustNot: ['2','4','5','7'], mustHave: ['1','3'] },
{ name: '天马号', mustNot: ['2','4','7'], mustHave: ['1','3','5'] },
{ name: '朦胧号', mustNot: ['3','4','5','7'], mustHave: [] },
{ name: '如意号', mustNot: ['1','3','4','7'], mustHave: [] },
{ name: '钻石号', mustNot: ['3','4','7'], mustHave: [] },
{ name: '永恒号', mustNot: ['4','7'], mustHave: [] },
{ name: '带7号', mustNot: ['4'], mustHave: ['7'] },
{ name: '带4号', mustNot: [], mustHave: ['4'] },
]
/**
* 提取冠字号中的数字部分
* @param {string} serial - 冠字号 J0123456789 J0123456781 J0123456701
* @returns {object} - { digits: 数字串, type: 'single'|'ten'|'hundred' }
*/
export function extractDigits(serial) {
if (!serial) return { digits: '', type: 'single' }
// 去掉J取数字部分
const nums = serial.replace(/J/g, '').replace(/\D/g, '')
// 判断类型
if (nums.endsWith('01')) {
// 标百去掉最后2位
return { digits: nums.slice(0, -2), type: 'hundred' }
} else if (nums.endsWith('1')) {
// 标十去掉最后1位
return { digits: nums.slice(0, -1), type: 'ten' }
} else {
// 单张:全部数字
return { digits: nums, type: 'single' }
}
}
/**
* 号码分类函数
* @param {string} serial - 冠字号
* @returns {string} - 分类名称
*/
export function getNumberCategory(serial) {
const { digits } = extractDigits(serial)
if (!digits || digits.length < 7) {
return '其他'
}
// 按优先级匹配
for (const cat of CATEGORIES) {
if (matchesCategory(digits, cat)) {
return cat.name
}
}
return '其他'
}
/**
* 检查数字是否匹配分类条件
* @param {string} digits - 数字串
* @param {object} category - 分类定义
* @returns {boolean}
*/
function matchesCategory(digits, category) {
const mustNot = category.mustNot
const mustHave = category.mustHave
// 1. 检查必须不含的数字
for (const n of mustNot) {
if (digits.includes(n)) {
return false
}
}
// 2. 检查必须含有的数字
for (const n of mustHave) {
if (!digits.includes(n)) {
return false
}
}
return true
}
/**
* 获取分类颜色
* @param {string} category - 分类名称
* @returns {string} - 颜色 hex
*/
export function getNumberCategoryColor(category) {
const colors = {
'圆圆号': '#8b5cf6', // 紫
'倒置号': '#ec4899', // 粉
'金马王': '#f59e0b', // 金
'金马号': '#ef4444', // 红
'金山王': '#14b8a6', // 青
'天马王': '#06b6d4', // 蓝
'金山号': '#0d9488', // 绿松石
'天马号': '#22c55e', // 绿
'朦胧号': '#6366f1', // 靛蓝
'如意号': '#a855f7', // 紫红
'钻石号': '#eab308', // 黄
'永恒号': '#3b82f6', // <20><><EFBFBD>
'带7号': '#f97316', // 橙
'带4号': '#64748b', // 灰
'其他': '#94a3b8',
}
return colors[category] || colors['其他']
}
/**
* 分类优先级用于排序
*/
export const NUMBER_CATEGORY_ORDER = CATEGORIES.map(c => c.name)
// 可选值列表(用于下拉框)
export const NUMBER_CATEGORY_OPTIONS = CATEGORIES.map(c => ({ value: c.name, label: c.name }))

View File

View File

@ -0,0 +1,240 @@
# Logo 使用规范
**版本**: v1.0.0
**更新日期**: 2026-03-16
**状态**: ✅ 官方指定 Logo
---
## 🐉 官方 Logo
### 主 Logo
**文件**: `jiachenlong-logo.png`
**位置**:
- 本地:`/static/images/jiachenlong-logo.png`
- 前端服务器:`/var/www/html/static/images/jiachenlong-logo.png`
**规格**:
- 格式PNG
- 大小606KB
- 尺寸:正方形(适合圆形裁剪)
- 颜色:橙色(中国传统色)
- 设计:龙型环绕 + "甲辰收藏"文字
---
## 📋 使用场景
### 1. 登录页面
**文件**: `frontend/src/pages/Login.jsx`
```jsx
<img
src="/static/images/jiachenlong-logo.png"
alt="甲辰收藏"
style={{
width: '200px',
height: '200px',
borderRadius: '50%',
boxShadow: '0 0 40px rgba(251, 191, 36, 0.4)',
background: '#fff'
}}
/>
```
### 2. 首页
**文件**: `frontend/src/pages/Home.jsx`
```jsx
<img
src="/static/images/jiachenlong-logo.png"
alt="甲辰收藏"
style={{
width: '48px',
height: '48px',
borderRadius: '50%',
objectFit: 'cover'
}}
/>
```
### 3. 藏品详情页
**文件**: `frontend/src/pages/Detail.jsx`
```jsx
<img
src="/static/images/jiachenlong-logo.png"
alt="甲辰收藏"
onError={(e) => {
e.target.src = '/static/images/jiachenlong-logo.png';
}}
/>
```
---
## 🎨 样式规范
### 圆形样式(推荐)
```css
.logo {
width: 200px;
height: 200px;
border-radius: 50%;
object-fit: cover;
box-shadow: 0 0 40px rgba(251, 191, 36, 0.4);
background: #fff;
}
```
### 小尺寸(导航栏等)
```css
.logo-small {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
}
```
### 中等尺寸
```css
.logo-medium {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
}
```
---
## 📦 部署规范
### 部署脚本
**文件**: `scripts/deploy.sh`
部署脚本会自动:
1. ✅ 检查 Logo 文件是否存在
2. ✅ 部署前端构建文件
3. ✅ 部署 Logo 到服务器
4. ✅ 重启 Nginx
### 部署命令
```bash
# 测试环境
./scripts/deploy.sh 1.0.0 test
# 生产环境
./scripts/deploy.sh 1.0.0 production
```
### 手动部署
```bash
# 1. 构建前端
cd frontend
npm run build
# 2. 部署到服务器
scp -r dist/* root@8.149.137.26:/var/www/html/
scp static/images/jiachenlong-logo.png root@8.149.137.26:/var/www/html/static/images/
# 3. 重启 Nginx
ssh root@8.149.137.26 "nginx -s reload"
```
---
## ⚠️ 注意事项
### 必须遵守
1. ✅ **统一使用** `jiachenlong-logo.png`
2. ✅ **禁止使用** 旧版 `logo.jpg`、`dragon-logo.jpg`、`title_logo.svg`
3. ✅ **保持比例** - 始终使用正方形容器
4. ✅ **圆形裁剪** - 使用 `border-radius: 50%`
5. ✅ **白色背景** - Logo 需要白色背景衬托
### 禁止行为
- ❌ 不要修改 Logo 颜色
- ❌ 不要拉伸变形
- ❌ 不要添加其他效果
- ❌ 不要使用其他 Logo 文件
---
## 📁 文件位置
### 本地开发
```
jiachenlong/
└── static/
└── images/
└── jiachenlong-logo.png # ✅ 官方 Logo
```
### 前端服务器
```
/var/www/html/
└── static/
└── images/
└── jiachenlong-logo.png # ✅ 官方 Logo
```
---
## 🔄 更新流程
如需更新 Logo
1. **替换文件**
```bash
cp new-logo.png /static/images/jiachenlong-logo.png
```
2. **重新构建**
```bash
cd frontend
npm run build
```
3. **部署到服务器**
```bash
./scripts/deploy.sh 1.0.1 production
```
4. **验证部署**
```bash
curl http://8.149.137.26/static/images/jiachenlong-logo.png -o /tmp/logo-check.png
```
---
## 📊 Logo 对比
| 文件 | 状态 | 说明 |
|------|------|------|
| `jiachenlong-logo.png` | ✅ **官方指定** | 橙色圆形龙型 Logo |
| `logo.jpg` | ❌ 废弃 | 旧版 Logo |
| `dragon-logo.jpg` | ❌ 废弃 | 旧版龙型 Logo |
| `title_logo.svg` | ❌ 废弃 | 旧版 SVG Logo |
---
**所有部署必须使用 `jiachenlong-logo.png`**
**最后更新**: 2026-03-16

View File

@ -0,0 +1,36 @@
# 图片资源
本目录存放项目的所有图片资源。
## 📁 文件列表
- `logo.jpg` - 系统主 Logo106KB, 512x512
## 🎨 使用方式
### 前端访问
```jsx
<img src="/static/images/logo.jpg" alt="logo" />
```
### 后端访问FastAPI
```python
from fastapi.staticfiles import StaticFiles
app.mount("/static", StaticFiles(directory="static"), name="static")
```
## 📐 建议尺寸
- **Logo**: 512x512 或更大(用于缩放)
- **背景图**: 1920x1080全屏背景
- **头像**: 200x200用户头像
## 📦 格式建议
- **Logo**: PNG透明背景或 JPG
- **照片**: JPG压缩比好
- **图标**: SVG矢量可缩放或 PNG
---
**最后更新**: 2026-03-16

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 80">
<defs>
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#FFE4B5"/>
<stop offset="25%" stop-color="#FFD700"/>
<stop offset="50%" stop-color="#FFA500"/>
<stop offset="75%" stop-color="#DAA520"/>
<stop offset="100%" stop-color="#B8860B"/>
</linearGradient>
<filter id="glow">
<feGaussianBlur stdDeviation="1.5" result="blur"/>
<feFlood flood-color="#FFD700" flood-opacity="0.6"/>
<feComposite in2="blur" operator="in"/>
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="shadow">
<feDropShadow dx="2" dy="3" stdDeviation="2" flood-color="#000" flood-opacity="0.5"/>
</filter>
</defs>
<!-- Main title -->
<text x="0" y="45" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="52" font-weight="bold" fill="url(#goldGrad)" filter="url(#shadow)">甲辰收藏</text>
<!-- Subtitle -->
<text x="0" y="72" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="20" fill="#DAA520" letter-spacing="4">生肖纪念钞管理系统</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -6,10 +6,13 @@ import { join } from 'path'
// 从 config/VERSION 文件读取版本号
function getVersion() {
try {
const versionFile = join(__dirname, '..', 'config', 'VERSION')
const content = readFileSync(versionFile, 'utf-8')
const match = content.match(/^VERSION=(.*)$/m)
return match ? match[1].trim() : '0.0.0'
const versionFile = join(__dirname, 'config', 'VERSION')
const content = readFileSync(versionFile, 'utf-8').trim()
// 移除 VERSION= 前缀
if (content.startsWith('VERSION=')) {
return content.substring(7).trim()
}
return content || '0.0.0'
} catch (e) {
console.error('读取 VERSION 文件失败:', e.message)
return '0.0.0'
@ -17,42 +20,42 @@ function getVersion() {
}
const APP_VERSION = getVersion()
console.log(`📦 构建版本v${APP_VERSION}`)
console.log('📦 构建版本v' + APP_VERSION)
// 构建时自动更新 index.html 的 title
// 构建后执行 - 更新 dist/index.html 的 title
function updateHtmlTitle() {
try {
const htmlPath = join(__dirname, 'index.html')
let htmlContent = readFileSync(htmlPath, 'utf-8')
// 替换 <title>甲辰收藏 vXXX</title>
htmlContent = htmlContent.replace(
/<title>甲辰收藏 v[\d.]+<\/title>/,
`<title>甲辰收藏 v${APP_VERSION}</title>`
)
writeFileSync(htmlPath, htmlContent, 'utf-8')
console.log(`✅ 已更新 index.html title: 甲辰收藏 v${APP_VERSION}`)
} catch (e) {
console.error('更新 index.html 失败:', e.message)
return {
name: 'update-html-title',
closeBundle() {
try {
const htmlPath = join(__dirname, 'dist', 'index.html')
let htmlContent = readFileSync(htmlPath, 'utf-8')
// 替换 <title>甲辰收藏 vXXX</title>
htmlContent = htmlContent.replace(
/<title>甲辰收藏 v[\d.]+<\/title>/,
'<title>甲辰收藏 v' + APP_VERSION + '</title>'
)
writeFileSync(htmlPath, htmlContent, 'utf-8')
console.log('✅ 已更新 dist/index.html title: 甲辰收藏 v' + APP_VERSION)
} catch (e) {
console.error('更新 dist/index.html 失败:', e.message)
}
}
}
}
// 构建前执行
updateHtmlTitle()
export default defineConfig({
plugins: [react()],
plugins: [react(), updateHtmlTitle()],
define: {
'import.meta.env.APP_VERSION': JSON.stringify(APP_VERSION)
},
build: {
rollupOptions: {
output: {
entryFileNames: `assets/[name]-[hash]-[name].js`,
chunkFileNames: `assets/[name]-[hash].js`,
assetFileNames: `assets/[name]-[hash].[ext]`
entryFileNames: 'assets/[name]-[hash]-[name].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]'
}
},
// 禁用缓存
manifest: true
}
}
})

39
scripts/check_version.sh Executable file
View File

@ -0,0 +1,39 @@
#!/bin/bash
# 版本一致性检查脚本
# 用法: ./scripts/check_version.sh
echo "========================================="
echo " 版本一致性检查"
echo "========================================="
CONFIG_FILE="/root/.openclaw/workspace/jiachenlong/config/VERSION.json"
VERSION=$(grep -o '"version": "[^"]*"' $CONFIG_FILE | cut -d'"' -f4)
UPDATED=$(grep -o '"updated": "[^"]*"' $CONFIG_FILE | cut -d'"' -f4)
echo ""
echo "当前版本: $VERSION"
echo "更新时间: $UPDATED"
echo ""
# 检查前端页面版本
echo "--- 前端页面 (最后修改时间) ---"
for file in /root/.openclaw/workspace/jiachenlong/frontend/src/pages/*.jsx; do
name=$(basename $file .jsx)
mtime=$(date -r "$file" "+%Y-%m-%d %H:%M")
echo "$name: $mtime"
done
echo ""
echo "--- 后端路由 (最后修改时间) ---"
for file in /root/.openclaw/workspace/jiachenlong/backend/app/routers/*.py; do
name=$(basename $file .py)
if [ "$name" != "__init__" ]; then
mtime=$(date -r "$file" "+%Y-%m-%d %H:%M")
echo "$name: $mtime"
fi
done
echo ""
echo "========================================="
echo " 检查完成"
echo "========================================="

13
start.sh Executable file
View File

@ -0,0 +1,13 @@
#!/bin/bash
cd /root/jiachenlong/backend
export DATABASE_URL='postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong'
export SECRET_KEY=production-secret-key-b-env-20260401
export OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
export OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
export SMS_ACCESS_KEY_ID=LTAI5t86bc1nNKVNyYv4Af6x
export SMS_ACCESS_KEY_SECRET=92EVAIE3GECr214c9UaSF6TSYJvDLY
export SMS_SIGN_NAME=苏州双人旁
export SMS_TEMPLATE_CODE=SMS_505015231
export DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 --workers 1 > /tmp/uvicorn.log 2>&1 &
echo 'B后端服务已启动'

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

BIN
static/icons/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB