Compare commits

..

41 Commits

Author SHA1 Message Date
358203b11d feat(splash): 添加全局开屏图片功能并优化客户端启动流程
- 新增全局开屏图片上传、获取、删除接口
- 实现客户端全局开屏图片优先加载机制
- 集成七牛云存储配置从 pxdj 切换到 bydj
- 优化 splash 窗口显示逻辑确保至少显示 2 秒
- 添加全局开屏图片管理界面组件
- 更新应用配置和构建设置
- 修复多处图片缓存和加载问题
- 调整服务端 API 地址配置
- 修改微信客服联系方式
2026-01-19 17:33:43 +08:00
02858146b3 style(components): format CSS styles in Vue components
- Remove extra spaces in CSS property declarations
- Consolidate multi-line CSS rules into single lines
- Maintain consistent formatting across component styles
- Improve readability by removing unnecessary line breaks
- Ensure uniform styling structure in scoped CSS blocks
2025-11-28 17:14:00 +08:00
bff057c99b feat(auth):从JWT令牌中提取注册时间
- 在token工具函数中新增getRegisterTimeFromToken方法
- 修改客户端账户注册逻辑,将创建时间写入JWT令牌- 更新前后端代码以正确传递和解析registerTime字段
- 调整API调用逻辑,优先从令牌中获取注册时间
- 清理部分冗余代码和注释
2025-11-18 09:45:22 +08:00
d29d4d69da feat(erp):优化品牌商标缓存与方舟API调用逻辑
- 品牌商标缓存服务增加一天内去重保存逻辑- 方舟API调用支持TOKEN失效自动重新注册
- 增加证书验证失败重试机制
- 修复代理池API签名密钥
-优化商标检查面板完成状态计算逻辑- 更新应用配置注释格式
2025-11-17 14:34:08 +08:00
937a84bb81 fix(settings): 更新品牌 Banner 尺寸提示信息
- 将品牌 Banner 最佳显示尺寸从 1200*736 更新为 20*64
- 调整图片预览区域以适配新尺寸比例

---

test(api): 修改商标查询测试用例参数

- 更改测试品牌名称从 SummitFlare 为 MADCKDEDRT- 简化测试输出内容,仅显示注册状态结果
2025-11-14 17:54:04 +08:00
f9d1848280 feat(amazon):优化商标筛查重试机制和进度显示- 添加防抖控制,避免频繁点击重试按钮
- 优化重试逻辑,增加时间间隔限制和状态检查
- 移除表格上方冗余的进度条显示
- 更新取消状态下的提示文案和操作引导- 修复品牌统计数据显示逻辑,确保准确性- 调整用户界面元素间距和样式细节
- 完善后端接口调用,支持信号中断和错误处理
-优化SSE连接管理,防止连接泄漏
- 改进任务取消机制,提升用户体验
- 更新用户信息展示,增加注册时间显示
2025-11-14 17:22:25 +08:00
dd23d9fe90 feat(amazon):优化商标筛查面板状态指示器与进度逻辑
- 将 SVG 图标替换为 PNG 图片以提升渲染性能
- 调整状态图标动画效果及连接线样式- 修改任务进度初始值以即时反映进行中状态
- 动态计算配置步骤总数并更新显示逻辑- 移除开发模式下的调试快捷键与相关日志- 微调 CSS 样式以改善界面布局与视觉效果
2025-11-13 15:33:02 +08:00
007799fb2a feat(trademark):优化商标查询功能和Excel解析逻辑
- 重构品牌商标缓存服务,移除冗余的日志记录和存在检查- 简化Excel解析工具类,提取公共方法并优化列索引查找逻辑
- 增强Electron客户端开发模式下的后端启动控制能力
- 改进商标筛查面板的用户体验和数据处理流程-优化商标查询工具类,提高查询准确性和稳定性
- 调整商标控制器接口参数校验逻辑和资源清理机制
- 更新USPTO API测试用例以支持Spring容器环境运行
2025-11-13 14:20:12 +08:00
cfb9096788 feat(amazon): 更新商标筛查界面图标与状态展示- 将商标筛查状态图标从图片替换为 SVG 图标
- 添加了进行中、取消、完成/失败状态的 SVG 图标
-优化任务进度指示器,使用 SVG 并支持旋转动画
- 禁用跟卖许可筛查功能并更新提示文案
- 调整标签页样式和间距,适配不同屏幕尺寸
-修复状态横幅图标显示问题,并统一图标尺寸
- 更新全局样式以提升视觉一致性和用户体验
2025-11-12 15:55:06 +08:00
cce281497b feat(client): 添加品牌logo功能支持
- 在客户端账户实体中新增brandLogo字段用于存储品牌logo URL
- 实现品牌logo的上传、获取和删除接口
- 在Vue前端中集成品牌logo的展示和管理功能- 添加品牌logo的缓存机制提升访问性能
- 在设置对话框中增加品牌logo配置界面
- 实现品牌logo的预览、上传和删除操作
- 添加VIP权限控制确保只有VIP用户可使用该功能
- 增加品牌logo变更事件监听以实时更新界面显示- 更新数据库映射文件以支持brand_logo字段的读写- 在登录成功后异步加载品牌logo配置信息- 调整UI布局以适配品牌logo展示区域- 添加品牌logo相关的样式定义和响应式处理
- 实现品牌logo上传的文件类型和大小校验- 增加品牌logo删除确认提示增强用户体验
- 在App.vue中添加品牌logo的全局状态管理和展示逻辑- 优化品牌logo加载失败时的容错处理
- 完善品牌logo功能的相关错误处理和日志记录
2025-11-10 15:18:38 +08:00
92ab782943 refactor(utils):优化商标查询重试机制- 将初始化失败重试次数从3次增加到5次
- 改进批量查询逻辑,支持单个品牌独立重试- 添加针对HTTP 403、网络错误等异常的代理切换机制-优化查询脚本构造方式,提高执行稳定性
- 增强错误处理和日志输出信息
- 移除控制器中冗余的注释描述
2025-11-10 11:21:04 +08:00
c2e1617a99 feat(client): 实现自定义开屏图片功能
- 在 ClientAccount 实体中新增 splashImage 字段用于存储开屏图片URL
- 在 ClientAccountController 中添加上传、获取和删除开屏图片的接口
- 集成七牛云存储实现图片上传功能,支持图片格式和大小校验
- 使用 Redis 缓存开屏图片URL,提升访问性能
- 在客户端登录成功后异步加载并保存开屏图片配置
- 新增 splashApi 模块封装开屏图片相关HTTP请求- 在主进程中实现开屏图片配置的持久化存储和读取
- 在设置页面中增加开屏图片管理界面,支持上传、预览和删除操作
- 修改 splash.html 支持动态加载自定义开屏图片
- 调整 CSP 策略允许加载本地和HTTPS图片资源
2025-11-08 10:23:45 +08:00
7c7009ffed feat(electron):优化商标筛查面板与资源加载逻辑
- 将多个 v-if 条件渲染改为 v-show,提升组件切换性能
- 优化商标任务完成状态判断逻辑,确保准确显示采集完成图标- 调整任务统计数据显示条件,支持零数据展示- 更新 API 配置地址,切换至本地开发环境地址
- 降低 Spring Boot 线程池与数据库连接池配置,适应小规模并发- 禁用 devtools 热部署与 Swagger 接口文档,优化生产环境性能
- 配置 RestTemplate 使用 HttpClient 连接池,增强 HTTP 请求稳定性
- 改进静态资源拷贝脚本,确保 icon 与 image 文件夹正确复制
- 更新 electron-builder 配置,优化资源打包路径与应用图标
- 修改 HTTP 路由规则,明确区分客户端与管理端接口路径- 注册文件协议拦截器,解决生产环境下 icon/image 资源加载问题
- 调整商标 API 接口路径,指向 erp_client_sb服务
-重构 MarkController 控制器,专注 Token 管理功能
- 优化线程池参数,适配低并发业务场景- 强化商标筛查流程控制,完善任务取消与异常处理机制
- 新增方舟精选任务管理接口,实现 Excel 下载与数据解析功能
2025-11-07 11:30:24 +08:00
2f00fde3be feat(electron):优化商标筛查面板与资源加载逻辑
- 将多个 v-if 条件渲染改为 v-show,提升组件切换性能
- 优化商标任务完成状态判断逻辑,确保准确显示采集完成图标- 调整任务统计数据显示条件,支持零数据展示- 更新 API 配置地址,切换至本地开发环境地址
- 降低 Spring Boot 线程池与数据库连接池配置,适应小规模并发- 禁用 devtools 热部署与 Swagger 接口文档,优化生产环境性能
- 配置 RestTemplate 使用 HttpClient 连接池,增强 HTTP 请求稳定性
- 改进静态资源拷贝脚本,确保 icon 与 image 文件夹正确复制
- 更新 electron-builder 配置,优化资源打包路径与应用图标
- 修改 HTTP 路由规则,明确区分客户端与管理端接口路径- 注册文件协议拦截器,解决生产环境下 icon/image 资源加载问题
- 调整商标 API 接口路径,指向 erp_client_sb服务
-重构 MarkController 控制器,专注 Token 管理功能
- 优化线程池参数,适配低并发业务场景- 强化商标筛查流程控制,完善任务取消与异常处理机制
- 新增方舟精选任务管理接口,实现 Excel 下载与数据解析功能
2025-11-06 14:39:58 +08:00
cfb70d5830 feat(amazon): 实现商标筛查功能并优化用户体验
- 添加商标筛查面板和相关API接口- 实现Excel文件解析和数据过滤功能
- 添加文件上传进度跟踪和错误处理-优化空状态显示和操作引导- 实现tab状态持久化存储
- 添加订阅会员弹窗和付费入口
-优化文件选择和删除功能
- 改进UI样式和响应式布局
2025-11-06 11:07:05 +08:00
4e2ce48934 feat(trademark): 支持商标筛查任务取消状态及优化错误处理- 新增商标筛查取消状态的UI展示和处理逻辑
-优化错误提示信息,区分网络错误、超时和风控场景
- 改进任务进度计算逻辑,支持更准确的完成状态判断
- 调整品牌提取逻辑,从Excel中直接读取品牌列数据
- 增强后端403错误检测和代理自动切换机制
- 更新前端组件样式和交互逻辑以匹配新状态
-修复部分条件判断逻辑以提升稳定性- 调整文件上传大小限制至50MB以支持更大文件- 优化Excel解析工具类,支持自动识别表头行位置
2025-11-05 10:16:14 +08:00
a62d7b6147 feat(trademark): 实现商标筛查功能并优化相关配置
- 新增商标筛查进度展示界面与交互逻辑
- 实现产品、品牌及平台跟卖许可的分项任务进度追踪
- 添加商标数据导出与任务重试、取消功能
- 调整Redis连接池配置以提升并发性能
- 禁用ChromeDriver预加载,改为按需启动以节省资源- 支持品牌商标远程筛查接口调用与结果解析
- 增加Hutool工具库依赖用于简化IO与Excel处理- 更新USPTO商标查询脚本实现自动化检测
- 修改Ruoyi后台Redis依赖版本并添加集群心跳配置- 切换本地开发环境API地址指向内网测试服务器
2025-11-04 15:39:15 +08:00
c9874f1786 feat(amazon):重构亚马逊仪表板组件并新增商标筛查功能
- 将原有复杂逻辑拆分为独立组件:AsinQueryPanel、GenmaiSpiritPanel、TrademarkCheckPanel
- 新增商标批量筛查功能,支持商标状态、类别、权利人等信息查询
- 优化UI布局,改进标签页样式和响应式设计
- 重构数据处理逻辑,使用计算属性优化性能- 完善分页功能,支持不同tab的数据展示
- 移除冗余代码,提高组件可维护性
- 添加跟卖精灵功能说明和注意事项展示-优化空状态和加载状态的用户体验
2025-10-31 11:30:19 +08:00
87a4a2fed0 feat(system): 新增方舟账号管理与任务接口
- 新增 MarkController 控制器,提供方舟任务列表获取和新建任务接口
- 实现 IMarkService 接口及 MarkServiceImpl 实现类,支持注册、登录和MD5加密功能
- 在 CacheConstants 中添加方舟账号 Redis key 常量
- 集成 RestTemplate用于调用方舟API,支持自动注册与登录机制- 添加文件上传支持,用于新建任务时提交文件
- 实现 token 失效自动重新登录逻辑,确保接口调用稳定性
2025-10-30 13:55:07 +08:00
d0a930d4f2 feat(selenium):重构ChromeDriver预加载与防检测配置
- 移除旧的WebDriverManager配置逻辑
- 新增SeleniumStealthUtil工具类,集成防检测脚本
- 实现全局单例ChromeDriver Bean管理- 添加驱动生命周期自动清理机制
-优化驱动创建参数,增强浏览器伪装能力
- 移除无用的线程池销毁方法
- 调整配置类注解与加载顺序
2025-10-29 16:36:34 +08:00
6443cdc8d0 feat(electron):优化应用启动和健康检查逻辑
- 修改 Spring Boot 配置启用懒加载初始化
- 优化主进程窗口打开逻辑,增加销毁状态检查
- 简化数据迁移函数中的条件判断
- 添加 JVM 参数 UseSerialGC优化内存使用- 移除 Spring 进程的标准输出和错误流监听- 改进健康检查机制,使用版本接口确认服务就绪
- 调整启动超时时间并优化重试间隔
- 延迟更新检查时机以提升启动速度
2025-10-28 11:05:53 +08:00
1aceceb38f feat(electron):优化跟卖精灵启动流程和UI细节
- 移除启动时的固定延时,改为即时反馈启动状态- 更新跟卖精灵描述文案,移除初始化时间说明
- 统一spinner样式类名,优化加载动画显示逻辑
- 调整菜单激活背景色,增强视觉层次感- 引入平台图标图片替代文字标识- 修改VIP状态栏背景及文字颜色,提升可读性
- 配置ChromeDriver国内镜像源并实现后台预加载
- 添加WebDriverManager依赖以自动管理浏览器驱动
-优化electron-builder资源打包配置
2025-10-28 09:39:59 +08:00
84087ddf80 feat(client): 实现跟卖精灵异步启动和Chrome驱动预加载- 在GenmaiServiceImpl中添加@Async注解实现异步启动跟卖精灵- 增加ChromeDriverPreloader组件预加载Chrome驱动- 添加AsyncConfig配置类启用异步支持
- 优化跟卖精灵启动提示信息和加载状态显示
- 移除Java代码中关于刷新令牌的相关逻辑和依赖- 更新版本号从2.5.5到2.5.6
2025-10-27 16:49:37 +08:00
7e065c1a0b feat(erp): 实现按用户地区隔离的最新产品查询逻辑
- 修改AmazonController以支持按用户和区域筛选最新会话数据
- 更新AmazonProductRepository中的findLatestProducts方法,增加region参数实现数据隔离
-优化AmazonScrapingServiceImpl的数据处理流程,增强缓存清理机制
- 调整GenmaiServiceImpl的token验证逻辑并改进Chrome启动配置- 升级系统版本至2.5.5并完善相关依赖管理
- 改进前端设置对话框中关于缓存清理描述的信息准确性
-重构SystemController接口,移除不必要的用户名参数传递- 强化GenmaiAccountController和服务层的安全校验逻辑
2025-10-27 13:34:25 +08:00
0be60bc103 feat(genmai): 集成跟卖精灵账号管理系统跟
- 新增卖精灵账号管理功能,支持多账号切换
- 实现账号增删改查接口与前端交互逻辑
-优化打开跟卖精灵流程,增加账号选择界面
- 添加账号权限限制与订阅升级提醒
- 完善后端账号实体类及数据库映射
- 更新系统控制器以支持指定账号启动功能- 调整HTTP请求路径适配新工具模块路由- 升级客户端版本至2.5.3并优化代码结构
2025-10-27 09:13:00 +08:00
35c9fc205a featlectron): 实(e现自定义窗口控制和无边框窗口
- 添加窗口最小化、最大化、关闭和状态检测 API- 实现无边框窗口模式并添加窗口控制按钮
- 更新导航栏 UI,添加登录按钮和窗口控制区域
- 调整 Amazon、Rakuten 和 Zebra 仪表板样式
- 移除面包屑导航并调整布局结构
- 更新用户头像样式和 VIP 状态卡片背景渐变
- 添加窗口拖拽区域和用户选择禁用样式
2025-10-24 15:06:47 +08:00
3a76aaa3c0 feat(client): 实现用户数据隔离与设备绑定优化- 添加用户会话ID构建逻辑,确保数据按用户隔离- 优化设备绑定流程,支持设备状态更新和绑定时间同步- 实现用户缓存清理功能,仅清除当前用户的数据- 增强客户端账号删除逻辑,级联删除相关数据
- 调整设备在线查询逻辑,确保只返回活跃绑定的设备
- 优化试用期逻辑,精确计算过期时间和类型- 添加账号管理弹窗和相关状态注入
-修复跟卖精灵按钮加载状态显示问题
- 增强文件上传区域UI,显示选中文件名
- 调整分页组件样式,优化界面展示效果- 优化反馈日志存储路径逻辑,默认使用用户目录
- 移除冗余代码和无用导入,提升代码整洁度
2025-10-24 13:43:46 +08:00
e2a438c84e del 2025-10-22 14:19:10 +08:00
5468dc53fc feat(device): 更新设备管理功能并优化错误处理- 修改设备更新接口路径从 /updateExpire 到 /update- 添加设备注册时获取计算机名称功能
- 优化设备配额检查逻辑,增加账号存在性验证- 更新前端设备列表刷新逻辑,使用保存的用户名参数
- 修改账号编辑表单,禁用已存在账号的用户名和账号名编辑
-优化跟卖精灵打开功能的错误提示和异常处理- 添加页面刷新 IPC通信功能
- 限制用户名输入只能包含字母、数字和下划线
- 移除冗余的本地 IP 获取函数- 升级 erp_client_sb 模块版本至 2.4.9
2025-10-22 14:18:28 +08:00
17b6a7b9f9 feat(device): 实现设备与账号绑定管理机制
- 引入 ClientAccountDevice 表管理设备与账号绑定关系
- 重构设备注册逻辑,支持多账号绑定同一设备
- 新增设备配额检查,基于账号维度限制设备数量
-优化设备移除逻辑,仅解除绑定而非物理删除- 改进设备列表查询,通过账号ID关联获取设备信息
- 更新心跳任务,支持向设备绑定的所有账号发送心跳
- 调整设备API参数,增加username字段用于权限校验
-修复HTTP请求编码问题,统一使用UTF-8字符集
- 增强错误处理,携带错误码信息便于前端识别
- 移除设备表中的username字段,解耦设备与用户名关联
2025-10-22 09:51:55 +08:00
901d67d2dc feat(subscription): 添加订阅功能并优化过期处理逻辑
- 扩展 trialExpiredType 类型,新增 'subscribe' 状态以支持主动订阅场景
- 新增 openSubscriptionDialog 方法,用于处理 VIP 状态点击事件
- 优化 VIP 状态卡片 UI,添加悬停与点击效果,提升交互体验
- 调整过期状态样式,保持水平布局并移除冗余按钮样式
- 在 Rakuten 组件中引入请求中断机制,提升任务控制灵活性- 更新 TrialExpiredDialog 组件,支持订阅类型提示与微信复制反馈- 修复部分 API 调用未传递 signal 参数的问题,增强请求管理能力
- 切换 Ruoyi 服务地址至生产环境配置,确保接口通信正常
- 移除部分无用代码与样式,精简组件结构
2025-10-21 11:48:32 +08:00
1be22664c4 feat(subscription): 添加订阅功能并优化过期处理逻辑
- 扩展 trialExpiredType 类型,新增 'subscribe' 状态以支持主动订阅场景
- 新增 openSubscriptionDialog 方法,用于处理 VIP 状态点击事件
- 优化 VIP 状态卡片 UI,添加悬停与点击效果,提升交互体验
- 调整过期状态样式,保持水平布局并移除冗余按钮样式
- 在 Rakuten 组件中引入请求中断机制,提升任务控制灵活性- 更新 TrialExpiredDialog 组件,支持订阅类型提示与微信复制反馈- 修复部分 API 调用未传递 signal 参数的问题,增强请求管理能力
- 切换 Ruoyi 服务地址至生产环境配置,确保接口通信正常
- 移除部分无用代码与样式,精简组件结构
2025-10-21 11:33:41 +08:00
281ae6a846 refactor(api):重构API服务接口与实现
- 移除多余的接口定义文件,简化依赖关系- 更新控制器和服务实现类的注入方式-优化请求参数处理逻辑
- 统一响应数据结构格式- 调整方法签名以提高一致性
- 删除冗余注释和无用代码- 修改系统API调用路径引用位置
- 简化认证服务实现并移除不必要的抽象层
- 优化Excel文件解析相关功能
- 清理无用的工具类和配置项
- 调整错误上报机制的依赖注入方式
- 更新跟卖精灵服务的实现细节- 优化HTTP请求工具函数结构
- 移除废弃的缓存管理服务接口定义
- 调整设备配额检查逻辑复用性
- 优化订单服务的数据返回格式
- 更新产品服务中的数据处理方式
- 重构客户端账户控制器中的设备限制检查逻辑
2025-10-21 10:15:33 +08:00
17f03c3ade refactor(auth):重构认证服务并移除冗余代码
- 移除了 AuthServiceImpl 中的登录、注册、token 验证等方法,仅保留错误上报和客户端信息功能
- 删除了设备注册和离线通知相关逻辑
- 移除了 IAuthService 接口中的登录、注册、验证 token 等方法定义
- 清理了 AccountManager.vue 中的无关注释文字-优化了阿里巴巴1688 服务中的图片上传处理逻辑- 移除了 AmazonScrapingServiceImpl 中未使用的日志导入和空行
- 统一了 Vue 组件中的同步导入方式,替换异步组件定义
- 更新了应用配置文件中的服务器地址和懒加载设置
- 新增缓存管理服务用于统一清理各类缓存数据
- 优化了设备 IP 地址获取逻辑并在注册时传递给后端- 调整了构建配置以减小安装包体积并支持多语言
- 修改了主进程窗口加载逻辑以适配开发与生产环境- 添加了全局样式限制图片预览器尺寸
- 移除了设备 ID 测试类和部分无用的正则表达式导入
2025-10-20 18:01:40 +08:00
0c85aa5677 refactor(client):优化设备管理与登录逻辑
- 移除冗余的日志记录器声明
- 简化设备心跳接口,合并注册与更新逻辑
- 调整设备数量限制检查逻辑,提高代码可读性
- 修改默认设备数量限制从3台调整为1台- 更新客户端登出提示文案- 固定启动窗口尺寸并移除延迟启动逻辑
- 调整设备移除时的消息提示内容
2025-10-17 16:14:43 +08:00
d9f91b77e3 feat(electron): 实现系统托盘和关闭行为配置功能
- 添加系统托盘创建和销毁逻辑- 实现窗口关闭行为配置(退出/最小化/托盘)
- 添加配置文件读写功能
- 实现下载取消和清理功能
- 添加待更新文件检查机制
- 优化文件下载进度和错误处理
- 添加自动更新配置选项- 实现平滑滚动动画效果
- 添加试用期过期类型检查
-优化VIP状态刷新逻辑
2025-10-17 14:18:01 +08:00
07e34c35c8 feat(electron): 实现系统托盘和关闭行为配置功能
- 添加系统托盘创建和销毁逻辑- 实现窗口关闭行为配置(退出/最小化/托盘)
- 添加配置文件读写功能
- 实现下载取消和清理功能
- 添加待更新文件检查机制
- 优化文件下载进度和错误处理
- 添加自动更新配置选项- 实现平滑滚动动画效果
- 添加试用期过期类型检查
-优化VIP状态刷新逻辑
2025-10-17 14:17:47 +08:00
6e1b4d00de feat(client): 实现账号设备试用期管理功能
- 新增设备试用期过期时间字段及管理接口
- 实现试用期状态检查与过期提醒逻辑
- 支持账号类型区分试用与付费用户
- 添加设备注册时自动设置3天试用期- 实现VIP状态刷新与过期类型判断
-优化账号列表查询支持按客户端用户名过滤
- 更新客户端设备管理支持试用期控制- 完善登录流程支持试用期状态提示
-修复设备离线通知缺少用户名参数问题
- 调整账号默认设置清除逻辑关联客户端用户名
2025-10-17 14:17:02 +08:00
132299c4b7 feat(electron-vue-template):重构认证与设备管理模块
- 统一token存取逻辑,封装getToken/setToken/removeToken方法
-优化设备ID获取逻辑,调整API路径
- 完善设备管理接口类型定义,增强类型安全
- 调整SSE连接逻辑,使用统一配置管理- 重构HTTP客户端,集中管理后端服务配置
- 更新认证相关API接口,完善请求/响应类型
- 优化设备列表展示逻辑,移除冗余字段
- 调整图片代理路径,统一API前缀
- 完善用户反馈列表展示功能,增强交互体验
- 移除冗余的错误处理逻辑,简化代码结构
2025-10-16 10:37:00 +08:00
6f04658265 fix(client): 设备移除逻辑与认证流程优化
- 修改设备移除时的本地清理方法,统一调用 clearLocalAuth
- 优化设备数量限制校验逻辑,避免重复计算当前设备- 移除冗余的设备状态检查,简化设备移除流程- 调整 Redis 连接超时与等待时间,提升连接稳定性- 增强 MySQL 数据库连接配置,添加自动重连机制
-优化 Druid 连接池参数,提高数据库连接性能
- 简化客户端认证与数据上报逻辑,提升处理效率
- 移除过期设备状态更新逻辑,减少不必要的数据库操作- 调整慢 SQL 记录阈值,便于及时发现性能问题-优化版本分布与数据类型统计查询逻辑,提高响应速度
2025-10-15 18:32:48 +08:00
f614860eee feat(client): 实现稳定的设备ID生成与认证优化
- 重构设备ID生成逻辑,采用多重降级策略确保唯一性与稳定性- 移除客户端SQLite缓存依赖,改用localStorage存储token与设备ID
- 优化认证流程,简化token管理与会话恢复逻辑- 增加设备数量限制检查,防止超出配额
- 更新SSE连接逻辑,适配新的认证机制
- 调整Redis连接池配置,提升并发性能与稳定性
- 移除冗余的缓存接口与本地退出逻辑
- 修复设备移除时的状态处理问题,避免重复调用offline接口
- 引入OSHI库用于硬件信息采集(备用方案)- 更新开发环境API地址配置
2025-10-13 16:12:51 +08:00
220 changed files with 25719 additions and 6199 deletions

270
CLAUDE.md Normal file
View File

@@ -0,0 +1,270 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a **hybrid ERP system** consisting of:
1. **Backend**: RuoYi-Vue (Spring Boot 2.5.15 + MyBatis) - Java 17 management system
2. **Desktop Client**: Electron + Vue 3 + TypeScript application
3. **Embedded Spring Boot Service**: Java service that runs within the Electron app
The architecture uses a dual-service pattern where the Electron app communicates with both:
- Local embedded Spring Boot service (port 8081)
- Remote RuoYi admin backend (port 8085)
## Repository Structure
```
C:\wox\erp\
├── electron-vue-template/ # Electron + Vue 3 desktop client
│ ├── src/
│ │ ├── main/ # Electron main process (TypeScript)
│ │ └── renderer/ # Vue 3 renderer process
│ │ ├── api/ # API client modules
│ │ ├── components/ # Vue components
│ │ └── utils/ # Utility functions
│ ├── scripts/ # Build scripts
│ └── package.json
├── ruoyi-admin/ # Main Spring Boot application entry
├── ruoyi-system/ # System management module
├── ruoyi-framework/ # Framework core (Security, Redis, etc.)
├── ruoyi-common/ # Common utilities
├── ruoyi-generator/ # Code generator
├── ruoyi-quartz/ # Scheduled tasks
├── erp_client_sb/ # Embedded Spring Boot service for client
├── sql/ # Database migration scripts
└── pom.xml # Root Maven configuration
```
## Development Commands
### Backend (Spring Boot)
```bash
# Build the project (from root)
mvn clean package
# Run the RuoYi admin backend
cd ruoyi-admin
mvn spring-boot:run
# Runs on http://localhost:8085
# Build without tests
mvn clean package -DskipTests
```
### Frontend (Electron + Vue)
```bash
cd electron-vue-template
# Install dependencies
npm install
# Development mode with hot reload
npm run dev
# Build for distribution
npm run build # Cross-platform
npm run build:win # Windows
npm run build:mac # macOS
npm run build:linux # Linux
```
## Key Architecture Patterns
### 1. Dual-Backend Routing (http.ts)
The Electron client uses intelligent routing to determine which backend to call:
- Paths starting with `/monitor/`, `/system/`, `/tool/banma`, `/tool/genmai` → RuoYi backend (port 8085)
- All other paths → Embedded Spring Boot service (port 8081)
**Location**: `electron-vue-template/src/renderer/api/http.ts`
### 2. Account-Based Resource Isolation
User-specific resources (splash images, brand logos) are stored per account:
- Backend stores URLs in the `client_account` table (columns: `splash_image`, `brand_logo`)
- Files are uploaded to Qiniu Cloud (七牛云) configured in `application.yml`
- Each user sees only their own uploaded assets
**Key files**:
- Java entity: `ruoyi-system/src/main/java/com/ruoyi/system/domain/ClientAccount.java`
- MyBatis mapper: `ruoyi-system/src/main/resources/mapper/system/ClientAccountMapper.xml`
- API endpoints: `ruoyi-admin/src/main/java/com/ruoyi/web/controller/monitor/ClientAccountController.java`
### 3. Event-Driven UI Updates
The Vue application uses custom browser events to propagate state changes between components:
- `window.dispatchEvent(new CustomEvent('brandLogoChanged'))` - notifies when brand logo changes
- `window.addEventListener('brandLogoChanged', handler)` - listens for changes in App.vue
This pattern ensures immediate UI updates after upload/delete operations without requiring page refreshes.
### 4. VIP Feature Gating
Certain features (e.g., custom splash images, brand logos) are gated by VIP status:
- Check `accountType` field in `ClientAccount` (values: `trial`, `paid`)
- Trial accounts show `TrialExpiredDialog` when attempting VIP features
- VIP validation happens in `SettingsDialog.vue` before allowing uploads
## Important Configuration
### Backend Configuration
**File**: `ruoyi-admin/src/main/resources/application.yml`
Key settings:
- Server port: `8085`
- Upload path: `ruoyi.profile: D:/ruoyi/uploadPath`
- Redis: `8.138.23.49:6379` (password: `123123`)
- Qiniu Cloud credentials for file storage
- Token expiration: 30 minutes
### Database
The system uses MySQL with MyBatis. When adding new fields:
1. Write SQL migration script in `sql/` directory
2. Update Java entity in `ruoyi-system/src/main/java/com/ruoyi/system/domain/`
3. Update MyBatis mapper XML in `ruoyi-system/src/main/resources/mapper/system/`
4. Include field in `<resultMap>`, `<sql id="select...">`, `<insert>`, and `<update>` sections
### Electron Main Process
**File**: `electron-vue-template/src/main/main.ts`
- Manages embedded Spring Boot process lifecycle
- Handles splash screen display
- Configures tray icon
- Manages auto-updates
- Uses app data directory: `app.getPath('userData')`
## Development Workflow (from .cursor/rules/guize.mdc)
When making code changes, follow this three-phase approach:
### Phase 1: Analyze Problem (【分析问题】)
- Understand user intent and ask clarifying questions
- Search all related code
- Identify root cause
- Look for code smells: duplication, poor naming, outdated patterns, inconsistent types
- Ask questions if multiple solutions exist
### Phase 2: Plan Solution (【制定方案】)
- List files to be created/modified/deleted
- Describe changes briefly for each file
- Eliminate code duplication through reuse/abstraction
- Ensure DRY principles and good architecture
- Ask questions if key decisions are unclear
### Phase 3: Execute (【执行方案】)
- Implement according to the approved plan
- Run type checking after modifications
- **DO NOT** commit code unless explicitly requested
- **DO NOT** start dev servers automatically
## Common Patterns
### Adding a New API Endpoint
1. **Backend** (Spring Boot):
```java
// In appropriate Controller (e.g., ClientAccountController.java)
@PostMapping("/your-endpoint")
public AjaxResult yourMethod(@RequestBody YourDTO dto) {
// Implementation
return AjaxResult.success(result);
}
```
2. **Frontend** (Vue/TypeScript):
```typescript
// In electron-vue-template/src/renderer/api/your-module.ts
export const yourApi = {
async yourMethod(data: YourType) {
return http.post<ResponseType>('/your-endpoint', data)
}
}
```
3. **Component usage**:
```vue
<script setup lang="ts">
import { yourApi } from '@/api/your-module'
const handleAction = async () => {
try {
const res = await yourApi.yourMethod(data)
// Handle success, update local state immediately
localState.value = res.data
// Dispatch event if other components need to know
window.dispatchEvent(new CustomEvent('yourEventName'))
} catch (error) {
ElMessage.error(error.message)
}
}
</script>
```
### File Upload Pattern
```typescript
// Frontend
const handleUpload = async (file: File) => {
const formData = new FormData()
formData.append('file', file)
formData.append('username', currentUsername)
const res = await splashApi.uploadSomething(file, username)
if (res.url) {
localImageUrl.value = res.url // Update immediately
window.dispatchEvent(new CustomEvent('imageChanged'))
}
}
```
```java
// Backend Controller
@PostMapping("/upload")
public AjaxResult upload(@RequestParam("file") MultipartFile file) {
String url = qiniuService.uploadFile(file);
// Save URL to database
return AjaxResult.success(url);
}
```
## Technology Stack Details
### Backend
- **Framework**: Spring Boot 2.5.15
- **Security**: Spring Security 5.7.12 + JWT
- **ORM**: MyBatis with PageHelper
- **Database**: MySQL
- **Cache**: Redis (Lettuce client)
- **File Storage**: Qiniu Cloud (七牛云)
- **API Docs**: Swagger 3.0.0
- **Build**: Maven
### Frontend
- **Framework**: Vue 3.3.8 (Composition API with `<script setup>`)
- **Desktop**: Electron 32.1.2
- **Build**: Vite 4.5.0
- **UI Library**: Element Plus 2.11.3
- **Language**: TypeScript 5.2.2
- **Excel**: ExcelJS 4.4.0
## Testing
Currently, there is no explicit test framework configured. When adding tests:
- Backend: Use JUnit with Spring Boot Test
- Frontend: Consider Vitest (already compatible with Vite)
## Important Notes
- **Chinese Language**: All user-facing text should be in Chinese (simplified)
- **Code Style**: Follow existing patterns - keep code concise and avoid unnecessary abstractions
- **No Auto-commit**: Never commit changes unless explicitly requested by the user
- **Secrets**: Qiniu Cloud keys are in `application.yml` - never expose in client code
- **Token Management**: JWT tokens stored in Electron via `utils/token.ts`, sent in `Authorization` header
- **Image Proxy**: Custom protocol handler in Electron for loading images from backend

View File

@@ -1,77 +0,0 @@
<div align="center">
# Electron Vue Template
<img width="794" alt="image" src="https://user-images.githubusercontent.com/32544586/222748627-ee10c9a6-70d2-4e21-b23f-001dd8ec7238.png">
A simple starter template for a **Vue3** + **Electron** TypeScript based application, including **ViteJS** and **Electron Builder**.
</div>
## About
This template utilizes [ViteJS](https://vitejs.dev) for building and serving your (Vue powered) front-end process, it provides Hot Reloads (HMR) to make development fast and easy ⚡
Building the Electron (main) process is done with [Electron Builder](https://www.electron.build/), which makes your application easily distributable and supports cross-platform compilation 😎
## Getting started
Click the green **Use this template** button on top of the repository, and clone your own newly created repository.
**Or..**
Clone this repository: `git clone git@github.com:Deluze/electron-vue-template.git`
### Install dependencies ⏬
```bash
npm install
```
### Start developing ⚒️
```bash
npm run dev
```
## Additional Commands
```bash
npm run dev # starts application with hot reload
npm run build # builds application, distributable files can be found in "dist" folder
# OR
npm run build:win # uses windows as build target
npm run build:mac # uses mac as build target
npm run build:linux # uses linux as build target
```
Optional configuration options can be found in the [Electron Builder CLI docs](https://www.electron.build/cli.html).
## Project Structure
```bash
- scripts/ # all the scripts used to build or serve your application, change as you like.
- src/
- main/ # Main thread (Electron application source)
- renderer/ # Renderer thread (VueJS application source)
```
## Using static files
If you have any files that you want to copy over to the app directory after installation, you will need to add those files in your `src/main/static` directory.
Files in said directory are only accessible to the `main` process, similar to `src/renderer/assets` only being accessible to the `renderer` process. Besides that, the concept is the same as to what you're used to in your other front-end projects.
#### Referencing static files from your main process
```ts
/* Assumes src/main/static/myFile.txt exists */
import {app} from 'electron';
import {join} from 'path';
import {readFileSync} from 'fs';
const path = join(app.getAppPath(), 'static', 'myFile.txt');
const buffer = readFileSync(path);
```

View File

@@ -9,15 +9,13 @@
"public/jre/**/*", "public/jre/**/*",
"public/icon/**/*", "public/icon/**/*",
"public/image/**/*", "public/image/**/*",
"public/splash.html" "public/splash.html",
"public/config/**/*"
], ],
"directories": { "directories": {
"output": "dist" "output": "dist"
}, },
"publish": { "electronLanguages": ["zh-CN", "en-US"],
"provider": "generic",
"url": "http://192.168.1.89:8085/static/updates/"
},
"nsis": { "nsis": {
"oneClick": false, "oneClick": false,
"perMachine": false, "perMachine": false,
@@ -25,13 +23,11 @@
"shortcutName": "erpClient" "shortcutName": "erpClient"
}, },
"win": { "win": {
"target": "nsis", "target": "dir",
"icon": "public/icon/icon.png" "icon": "public/icon/icon1.png"
},
"linux": {
"target": ["snap"]
}, },
"files": [ "files": [
"package.json",
{ {
"from": "build/main", "from": "build/main",
"to": "main", "to": "main",
@@ -40,19 +36,14 @@
{ {
"from": "build/renderer", "from": "build/renderer",
"to": "renderer", "to": "renderer",
"filter": ["**/*"]
},
{
"from": "src/main/static",
"to": "static",
"filter": ["**/*"]
},
{
"from": "public",
"to": "assets",
"filter": [ "filter": [
"erp_client_sb-*.jar" "**/*",
"!icon/**/*",
"!image/**/*",
"!jre/**/*",
"!config/**/*",
"!*.jar",
"!splash.html"
] ]
}, },
{ {
@@ -63,58 +54,32 @@
"icon/**/*", "icon/**/*",
"image/**/*", "image/**/*",
"splash.html", "splash.html",
"config/**/*",
"!erp_client_sb-*.jar", "!erp_client_sb-*.jar",
"!data/**/*", "!data/**/*",
"!jre/bin/jabswitch.exe", "!jre/bin/*.exe",
"!jre/bin/jaccessinspector.exe", "jre/bin/java.exe",
"!jre/bin/jaccesswalker.exe", "jre/bin/javaw.exe",
"!jre/bin/jar.exe", "jre/bin/keytool.exe",
"!jre/bin/jarsigner.exe",
"!jre/bin/javac.exe",
"!jre/bin/javadoc.exe",
"!jre/bin/javap.exe",
"!jre/bin/jcmd.exe",
"!jre/bin/jconsole.exe",
"!jre/bin/jdb.exe",
"!jre/bin/jdeprscan.exe",
"!jre/bin/jdeps.exe",
"!jre/bin/jfr.exe",
"!jre/bin/jhsdb.exe",
"!jre/bin/jimage.exe",
"!jre/bin/jinfo.exe",
"!jre/bin/jlink.exe",
"!jre/bin/jmap.exe",
"!jre/bin/jmod.exe",
"!jre/bin/jpackage.exe",
"!jre/bin/jps.exe",
"!jre/bin/jrunscript.exe",
"!jre/bin/jshell.exe",
"!jre/bin/jstack.exe",
"!jre/bin/jstat.exe",
"!jre/bin/jstatd.exe",
"!jre/bin/keytool.exe",
"!jre/bin/kinit.exe",
"!jre/bin/klist.exe",
"!jre/bin/ktab.exe",
"!jre/bin/rmiregistry.exe",
"!jre/bin/serialver.exe",
"!jre/include/**", "!jre/include/**",
"!jre/lib/src.zip", "!jre/lib/src.zip",
"!jre/lib/ct.sym", "!jre/lib/ct.sym",
"!jre/lib/jvm.lib", "!jre/lib/jvm.lib"
"!icon/image.png",
"!icon/img.png"
] ]
}, },
"!build", "!build",
"!dist", "!dist",
"!scripts" "!scripts"
], ],
"electronLanguages": ["en", "zh-CN"],
"extraResources": [ "extraResources": [
{ {
"from": "update-helper.bat", "from": "update-helper.bat",
"to": "../update-helper.bat" "to": "../update-helper.bat"
},
{
"from": "public",
"to": "./",
"filter": ["erp_client_sb-*.jar"]
} }
] ]
} }

7281
electron-vue-template/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,14 @@
{ {
"name": "electron-vue-template", "name": "erpClient",
"version": "0.1.0", "version": "0.1.0",
"description": "A minimal Electron + Vue application", "description": "A minimal Electron + Vue application",
"main": "main/main.js", "main": "build/main/main.js",
"scripts": { "scripts": {
"dev": "node scripts/dev-server.js", "dev": "node scripts/dev-server.js",
"build": "node scripts/build.js && electron-builder", "build": "node scripts/build.js && electron-builder --dir",
"build:win": "node scripts/build.js && electron-builder --win", "build:win": "node scripts/build.js && electron-builder --win --dir",
"build:mac": "node scripts/build.js && electron-builder --mac", "build:mac": "node scripts/build.js && electron-builder --mac --dir",
"build:linux": "node scripts/build.js && electron-builder --linux" "build:linux": "node scripts/build.js && electron-builder --linux --dir"
}, },
"repository": "https://github.com/deluze/electron-vue-template", "repository": "https://github.com/deluze/electron-vue-template",
"author": { "author": {
@@ -17,9 +17,10 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^4.4.1", "@vitejs/plugin-vue": "^4.4.1",
"binary-extensions": "^3.1.0",
"chalk": "^4.1.2", "chalk": "^4.1.2",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"electron": "^38.2.2", "electron": "^32.1.2",
"electron-builder": "^25.1.6", "electron-builder": "^25.1.6",
"electron-rebuild": "^3.2.9", "electron-rebuild": "^3.2.9",
"express": "^5.1.0", "express": "^5.1.0",
@@ -32,5 +33,26 @@
"element-plus": "^2.11.3", "element-plus": "^2.11.3",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"vue": "^3.3.8" "vue": "^3.3.8"
},
"build": {
"appId": "com.tashow.erp",
"productName": "天骄智能电商",
"files": [
"build/**/*",
"node_modules/**/*",
"package.json"
],
"directories": {
"buildResources": "assets",
"output": "dist"
},
"win": {
"target": [
{
"target": "dir",
"arch": ["x64"]
}
]
}
} }
} }

4417
electron-vue-template/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 使用 Spring Boot 传递的日志路径 -->
<property name="LOG_HOME" value="${LOG_PATH:-logs}" />
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 文件输出 - 按天滚动 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/spring-boot.log</file>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n</pattern>
<charset>UTF-8</charset>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/spring-boot-%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>1GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- 设置根日志级别 - 同时输出到控制台和文件 -->
<root level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</root>
<!-- 设置特定包的日志级别 -->
<logger name="com.tashow.erp" level="INFO" additivity="false">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</logger>
<!-- 确保 Hibernate 日志也输出 -->
<logger name="org.hibernate" level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</logger>
<!-- 确保 Spring 日志也输出 -->
<logger name="org.springframework" level="INFO">
<appender-ref ref="CONSOLE" />
<appender-ref ref="FILE" />
</logger>
</configuration>

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 628 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 804 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1,31 +1,78 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>正在启动...</title> <title>正在启动...</title>
<style> <style>
html, body { height: 100%; margin: 0; } * { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
body { body {
background: #fff; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif; display: flex;
background-image: url('./image/splash_screen.png'); flex-direction: column;
background-repeat: no-repeat; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-position: center; }
background-size: cover; .image {
flex: 1;
background-image: __SPLASH_IMAGE__;
background-size: cover;
background-position: center;
}
.box {
height: 64px;
padding: 0 30px;
background: #fff;
display: flex;
align-items: center;
gap: 16px;
border-top: 1px solid #e8e8e8;
}
.text {
font-size: 14px;
color: rgba(0,0,0,0.85);
font-weight: 500;
white-space: nowrap;
}
.progress {
flex: 1;
height: 6px;
background: rgba(0,0,0,0.06);
border-radius: 10px;
overflow: hidden;
}
.bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #1677ff 0%, #4096ff 100%);
border-radius: 10px;
animation: load 3s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.btn {
padding: 4px 15px;
background: #fff;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
color: rgba(0,0,0,0.65);
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.btn:hover {
color: #1677ff;
border-color: #1677ff;
background: #f0f7ff;
}
@keyframes load {
to { width: 90%; }
} }
.box { position: fixed; left: 0; right: 0; bottom: 28px; padding: 0 0; }
.progress { position: relative; width: 100vw; height: 6px; background: rgba(0,0,0,0.08); }
.bar { position: absolute; left: 0; top: 0; height: 100%; width: 20vw; min-width: 120px; background: linear-gradient(90deg, #67C23A, #409EFF); animation: slide 1s ease-in-out infinite alternate; }
@keyframes slide { 0% { left: 0; } 100% { left: calc(100vw - 20vw); } }
</style> </style>
<link rel="icon" href="icon/icon.png">
<link rel="apple-touch-icon" href="icon/icon.png">
<meta name="theme-color" content="#ffffff">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline';">
</head> </head>
<body> <body>
<div class="image"></div>
<div class="box"> <div class="box">
<span class="text">正在启动</span>
<div class="progress"><div class="bar"></div></div> <div class="progress"><div class="bar"></div></div>
<button class="btn" onclick="require('electron').ipcRenderer.send('quit-app')">退出</button>
</div> </div>
</body> </body>
</html> </html>

View File

@@ -2,7 +2,27 @@ const Path = require('path');
const FileSystem = require('fs-extra'); const FileSystem = require('fs-extra');
async function copyAssets() { async function copyAssets() {
console.log('Static assets are now handled by Vite from src/renderer/public'); console.log('Copying static assets from public directory...');
// 注释icon 和 image 资源已统一由 public 目录管理
// electron-builder 会直接从 public 打包这些资源到 app.asar.unpacked
// 不需要复制到 build/renderer避免重复打包导致体积增大
// const publicDir = Path.join(__dirname, '..', 'public');
// const buildRendererDir = Path.join(__dirname, '..', 'build', 'renderer');
// await FileSystem.copy(
// Path.join(publicDir, 'icon'),
// Path.join(buildRendererDir, 'icon'),
// { overwrite: true }
// );
// await FileSystem.copy(
// Path.join(publicDir, 'image'),
// Path.join(buildRendererDir, 'image'),
// { overwrite: true }
// );
console.log('Static assets copy skipped (resources managed by public directory).');
} }
module.exports = copyAssets; module.exports = copyAssets;

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,8 @@ const electronAPI = {
installUpdate: () => ipcRenderer.invoke('install-update'), installUpdate: () => ipcRenderer.invoke('install-update'),
cancelDownload: () => ipcRenderer.invoke('cancel-download'), cancelDownload: () => ipcRenderer.invoke('cancel-download'),
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'), getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
checkPendingUpdate: () => ipcRenderer.invoke('check-pending-update'),
clearUpdateFiles: () => ipcRenderer.invoke('clear-update-files'),
// 添加文件保存对话框 API // 添加文件保存对话框 API
showSaveDialog: (options: any) => ipcRenderer.invoke('show-save-dialog', options), showSaveDialog: (options: any) => ipcRenderer.invoke('show-save-dialog', options),
@@ -17,8 +19,40 @@ const electronAPI = {
showOpenDialog: (options: any) => ipcRenderer.invoke('show-open-dialog', options), showOpenDialog: (options: any) => ipcRenderer.invoke('show-open-dialog', options),
// 添加文件写入 API // 添加文件写入 API
writeFile: (filePath: string, data: Uint8Array) => ipcRenderer.invoke('write-file', filePath, data), writeFile: (filePath: string, data: Uint8Array) => ipcRenderer.invoke('write-file', filePath, data),
// 添加日志相关 API
getLogDates: () => ipcRenderer.invoke('get-log-dates'),
readLogFile: (logDate: string) => ipcRenderer.invoke('read-log-file', logDate),
// 关闭行为配置 API
getCloseAction: () => ipcRenderer.invoke('get-close-action'),
setCloseAction: (action: 'quit' | 'minimize' | 'tray') => ipcRenderer.invoke('set-close-action', action),
// 缓存管理 API
clearCache: () => ipcRenderer.invoke('clear-cache'),
// 启动配置 API
getLaunchConfig: () => ipcRenderer.invoke('get-launch-config'),
setLaunchConfig: (config: { autoLaunch: boolean; launchMinimized: boolean }) => ipcRenderer.invoke('set-launch-config', config),
// 刷新页面 API
reload: () => ipcRenderer.invoke('reload'),
// 窗口控制 API
windowMinimize: () => ipcRenderer.invoke('window-minimize'),
windowMaximize: () => ipcRenderer.invoke('window-maximize'),
windowClose: () => ipcRenderer.invoke('window-close'),
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
// 开屏图片相关 API
saveSplashConfig: (username: string, imageUrl: string) => ipcRenderer.invoke('save-splash-config', username, imageUrl),
getSplashConfig: () => ipcRenderer.invoke('get-splash-config'),
// 品牌logo相关 API
saveBrandLogoConfig: (username: string, logoUrl: string) => ipcRenderer.invoke('save-brand-logo-config', username, logoUrl),
loadConfig: () => ipcRenderer.invoke('load-config'),
clearUserConfig: () => ipcRenderer.invoke('clear-user-config'),
onDownloadProgress: (callback: (progress: any) => void) => { onDownloadProgress: (callback: (progress: any) => void) => {
ipcRenderer.removeAllListeners('download-progress')
ipcRenderer.on('download-progress', (event, progress) => callback(progress)) ipcRenderer.on('download-progress', (event, progress) => callback(progress))
}, },
removeDownloadProgressListener: () => { removeDownloadProgressListener: () => {

View File

@@ -0,0 +1,71 @@
import { app, Tray, Menu, BrowserWindow, nativeImage } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
let tray: Tray | null = null
function getIconPath(): string {
const isDev = process.env.NODE_ENV === 'development'
if (isDev) {
return join(__dirname, '../../public/icon/icon1.png')
}
return join(process.resourcesPath, 'app.asar.unpacked', 'public/icon/icon1.png')
}
export function createTray(mainWindow: BrowserWindow | null) {
if (tray) return tray
const iconPath = getIconPath()
const icon = nativeImage.createFromPath(iconPath)
tray = new Tray(icon.resize({ width: 16, height: 16 }))
tray.setToolTip('ERP客户端 - 后台运行中')
// 左键点击显示窗口
tray.on('click', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
if (mainWindow.isVisible()) {
mainWindow.hide()
} else {
mainWindow.show()
mainWindow.focus()
}
}
})
// 右键菜单
updateTrayMenu(mainWindow)
return tray
}
export function updateTrayMenu(mainWindow: BrowserWindow | null) {
if (!tray) return
const contextMenu = Menu.buildFromTemplate([
{
label: '显示窗口',
click: () => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show()
mainWindow.focus()
}
}
},
{ type: 'separator' },
{
label: '退出应用',
click: () => {
app.quit()
}
}
])
tray.setContextMenu(contextMenu)
}
export function destroyTray() {
if (tray) {
tray.destroy()
tray = null
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,17 @@
import { http } from './http'; import { http } from './http';
export const amazonApi = { export const amazonApi = {
// 上传Excel文件解析ASIN列表
importAsinFromExcel(file: File) { importAsinFromExcel(file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
return http.upload<{ code: number, data: { asinList: string[], total: number }, msg: string | null }>('/api/amazon/import/asin', formData); return http.upload<{ code: number, data: { asinList: string[], total: number }, msg: string | null }>('/api/amazon/import/asin', formData);
}, },
getProductsBatch(asinList: string[], batchId: string, region: string) { getProductsBatch(asinList: string[], batchId: string, region: string, signal?: AbortSignal) {
return http.post<{ code: number, data: { products: any[] }, msg: string | null }>('/api/amazon/products/batch', { asinList, batchId, region }); return http.post<{ code: number, data: { products: any[] }, msg: string | null }>('/api/amazon/products/batch', { asinList, batchId, region }, signal);
}, },
getLatestProducts() { getLatestProducts() {
return http.get<{ code: number, data: { products: any[] }, msg: string | null }>('/api/amazon/products/latest'); return http.get<{ code: number, data: { products: any[] }, msg: string | null }>('/api/amazon/products/latest');
}, }
getProductsByBatch(batchId: string) {
return http.get<{ products: any[] }>(`/api/amazon/products/batch/${batchId}`);
},
updateProduct(productData: unknown) {
return http.post('/api/amazon/products/update', productData);
},
deleteProduct(productId: string) {
return http.post('/api/amazon/products/delete', { id: productId });
},
getProductStats() {
return http.get('/api/amazon/stats');
},
searchProducts(searchParams: Record<string, unknown>) {
return http.get('/api/amazon/products/search', searchParams);
},
openGenmaiSpirit() {
return http.post('/api/genmai/open');
},
}; };

View File

@@ -1,45 +1,32 @@
import { http } from './http' import { http } from './http'
export interface LoginParams {
username: string
password: string
clientId?: string
}
export interface AuthResponse {
token: string
permissions?: string
accountName?: string
expireTime?: string
}
export const authApi = { export const authApi = {
login(params: { username: string; password: string }) { login(params: LoginParams) {
// 直接调用 RuoYi 后端的登录接口 return http.post<{ data: AuthResponse }>('/monitor/account/login', params)
return http.post('/monitor/account/login', params)
}, },
register(params: { username: string; password: string }) { register(params: { username: string; password: string; deviceId?: string }) {
// 直接调用 RuoYi 后端的注册接口 return http.post<{ data: AuthResponse }>('/monitor/account/register', params)
return http.post('/monitor/account/register', params)
}, },
checkUsername(username: string) { checkUsername(username: string) {
// 直接调用 RuoYi 后端的用户名检查接口 return http.get<{ data: boolean }>('/monitor/account/check-username', { username })
return http.get('/monitor/account/check-username', { username })
}, },
verifyToken(token: string) { verifyToken(token: string) {
// 直接调用 RuoYi 后端的验证接口 return http.post<{ data: AuthResponse }>('/monitor/account/verify', { token })
return http.post('/monitor/account/verify', { token })
},
logout(token: string) {
// 保留客户端的 logout用于清理本地状态
return http.postVoid('/api/logout', { token })
},
// 以下缓存相关接口仍使用客户端服务(用于本地 SQLite 存储)
deleteTokenCache() {
return http.postVoid('/api/cache/delete?key=token')
},
saveToken(token: string) {
return http.postVoid('/api/cache/save', { key: 'token', value: token })
},
getToken() {
return http.get('/api/cache/get?key=token')
},
sessionBootstrap() {
return http.get('/api/session/bootstrap')
} }
} }

View File

@@ -1,33 +1,45 @@
import { http } from './http' import { http } from './http'
export interface DeviceItem {
deviceId: string
name?: string
os?: string
status: 'online' | 'offline'
lastActiveAt?: string
isCurrent?: boolean
}
export interface DeviceQuota {
limit: number
used: number
}
export const deviceApi = { export const deviceApi = {
getQuota(username: string) { getQuota(username: string) {
// 直接调用 RuoYi 后端的设备配额接口 return http.get<{ data: DeviceQuota }>('/monitor/device/quota', { username })
return http.get('/monitor/device/quota', { username })
}, },
list(username: string) { list(username: string) {
// 直接调用 RuoYi 后端的设备列表接口 return http.get<{ data: DeviceItem[] }>('/monitor/device/list', { username })
return http.get('/monitor/device/list', { username })
}, },
register(payload: { username: string }) { async register(payload: { username: string; deviceId: string; os?: string }) {
// 直接调用 RuoYi 后端的设备注册接口 const [ipRes, nameRes] = await Promise.all([
return http.post('/monitor/device/register', payload) http.get<{ data: string }>('/api/system/local-ip'),
http.get<{ data: string }>('/api/system/computer-name')
])
return http.post('/monitor/device/register', {
...payload,
ip: ipRes.data,
computerName: nameRes.data
})
}, },
remove(payload: { deviceId: string }) { remove(payload: { deviceId: string; username: string }) {
// 直接调用 RuoYi 后端的设备移除接口
return http.post('/monitor/device/remove', payload) return http.post('/monitor/device/remove', payload)
}, },
heartbeat(payload: { username: string; deviceId: string; version?: string }) {
// 直接调用 RuoYi 后端的心跳接口
return http.post('/monitor/device/heartbeat', payload)
},
offline(payload: { deviceId: string }) { offline(payload: { deviceId: string }) {
// 直接调用 RuoYi 后端的离线接口
return http.post('/monitor/device/offline', payload) return http.post('/monitor/device/offline', payload)
} }
} }

View File

@@ -0,0 +1,21 @@
import { http } from './http'
export interface FeedbackParams {
username: string
deviceId: string
feedbackContent: string
logDate?: string
logFile?: File
}
export const feedbackApi = {
submit(data: FeedbackParams) {
const formData = new FormData()
formData.append('username', data.username)
formData.append('deviceId', data.deviceId)
formData.append('feedbackContent', data.feedbackContent)
if (data.logDate) formData.append('logDate', data.logDate)
if (data.logFile) formData.append('logFile', data.logFile)
return http.upload('/monitor/feedback/submit', formData)
}
}

View File

@@ -0,0 +1,39 @@
import { http } from './http'
export interface GenmaiAccount {
id?: number
name?: string
username: string
password: string
clientUsername?: string
token?: string
tokenExpireAt?: string
status?: number
remark?: string
createTime?: string
updateTime?: string
}
export const genmaiApi = {
getAccounts(name?: string) {
return http.get('/tool/genmai/accounts', name ? { name } : undefined)
},
getAccountLimit(name?: string) {
return http.get('/tool/genmai/account-limit', name ? { name } : undefined)
},
saveAccount(body: GenmaiAccount, name?: string) {
const url = name ? `/tool/genmai/accounts?name=${encodeURIComponent(name)}` : '/tool/genmai/accounts'
return http.post(url, body)
},
removeAccount(id: number) {
return http.delete(`/tool/genmai/accounts/${id}`)
},
validateAndRefresh(id: number) {
return http.post(`/tool/genmai/accounts/${id}/validate`)
}
}

View File

@@ -1,97 +1,139 @@
// 极简 HTTP 工具:封装 GET/POST按路径选择后端服务 import { AppConfig, isRuoyiPath } from '../config'
export type HttpMethod = 'GET' | 'POST';
const BASE_CLIENT = 'http://localhost:8081'; // erp_client_sb export type HttpMethod = 'GET' | 'POST' | 'DELETE'
const BASE_RUOYI = 'http://192.168.1.89:8085'; export const CONFIG = AppConfig
function resolveBase(path: string): string { function resolveBase(path: string): string {
// 走 ruoyi-admin 的路径:鉴权、设备管理、版本、平台工具路由 return isRuoyiPath(path) ? CONFIG.RUOYI_BASE : CONFIG.CLIENT_BASE
if (path.startsWith('/monitor/account')) return BASE_RUOYI; // 账号认证相关
if (path.startsWith('/monitor/device')) return BASE_RUOYI; // 设备管理
if (path.startsWith('/system/')) return BASE_RUOYI; // 版本控制器 VersionController
if (path.startsWith('/tool/banma')) return BASE_RUOYI; // 既有规则保留
// 其他默认走客户端服务
return BASE_CLIENT;
} }
// 将对象转为查询字符串
function buildQuery(params?: Record<string, unknown>): string { function buildQuery(params?: Record<string, unknown>): string {
if (!params) return ''; if (!params) return '';
const usp = new URLSearchParams(); const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => { Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null) return; if (value != null) query.append(key, String(value));
usp.append(key, String(value));
}); });
const queryString = usp.toString(); return query.toString() ? `?${query}` : '';
return queryString ? `?${queryString}` : '';
} }
// 统一请求入口:自动加上 BASE_URL、JSON 头与错误处理 async function getToken(): Promise<string> {
async function request<T>(path: string, options: RequestInit): Promise<T> { try {
const res = await fetch(`${resolveBase(path)}${path}`, { const tokenModule = await import('../utils/token');
credentials: 'omit', return tokenModule.getToken() || '';
cache: 'no-store', } catch {
...options, return '';
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
} }
}
async function getUsername(): Promise<string> {
try {
const tokenModule = await import('../utils/token');
return tokenModule.getUsernameFromToken() || '';
} catch {
return '';
}
}
async function request<T>(path: string, options: RequestInit & { signal?: AbortSignal }): Promise<T> {
const token = await getToken();
const username = await getUsername();
let res: Response;
try {
res = await fetch(`${resolveBase(path)}${path}`, {
credentials: 'omit',
cache: 'no-store',
...options,
headers: {
'Content-Type': 'application/json;charset=UTF-8',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...(username ? { 'username': username } : {}),
...options.headers
}
});
} catch (e) {
throw new Error('无法连接服务器,请检查网络后重试');
}
if (!res.ok) {
if (res.status >= 500) {
throw new Error('无法连接服务器,请检查网络后重试');
}
const text = await res.text().catch(() => '');
throw new Error(text || '无法连接服务器,请检查网络后重试');
}
const contentType = res.headers.get('content-type') || ''; const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) { if (contentType.includes('application/json')) {
const json: any = await res.json(); const json: any = await res.json();
// 检查业务状态码
if (json.code !== undefined && json.code !== 0 && json.code !== 200) { if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
throw new Error(json.msg || json.message || '请求失败'); const error: any = new Error(json.msg || '请求失败');
error.code = json.code;
throw error;
} }
return json as T; return json as T;
} }
return (await res.text()) as unknown as T; return (await res.text()) as unknown as T;
} }
export const http = { export const http = {
get<T>(path: string, params?: Record<string, unknown>) { get<T>(path: string, params?: Record<string, unknown>, signal?: AbortSignal) {
return request<T>(`${path}${buildQuery(params)}`, { method: 'GET' }); return request<T>(`${path}${buildQuery(params)}`, { method: 'GET', signal });
}, },
post<T>(path: string, body?: unknown) { post<T>(path: string, body?: unknown, signal?: AbortSignal) {
return request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined }); return request<T>(path, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
signal
});
}, },
delete<T>(path: string) { delete<T>(path: string) {
return request<T>(path, { method: 'DELETE' }); return request<T>(path, { method: 'DELETE' });
}, },
// 用于无需读取响应体的 POST如删除/心跳等),从根源避免读取中断
postVoid(path: string, body?: unknown) { async upload<T>(path: string, form: FormData, signal?: AbortSignal) {
return fetch(`${resolveBase(path)}${path}`, { const token = await getToken();
method: 'POST', const username = await getUsername();
body: body ? JSON.stringify(body) : undefined, let res: Response;
credentials: 'omit',
cache: 'no-store', try {
headers: { 'Content-Type': 'application/json' }, res = await fetch(`${resolveBase(path)}${path}`, {
}).then(res => { method: 'POST',
if (!res.ok) return res.text().then(t => Promise.reject(new Error(t || `HTTP ${res.status}`))); body: form,
return undefined as unknown as void; credentials: 'omit',
}); cache: 'no-store',
}, headers: {
// 文件上传:透传 FormData不设置 Content-Type 让浏览器自动处理 ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
upload<T>(path: string, form: FormData) { ...(username ? { 'username': username } : {})
const res = fetch(`${resolveBase(path)}${path}`, { },
method: 'POST', signal
body: form, });
credentials: 'omit', } catch (e) {
cache: 'no-store', throw new Error('无法连接服务器,请检查网络后重试');
}); }
return res.then(async response => {
if (!response.ok) { if (!res.ok) {
const text = await response.text().catch(() => ''); if (res.status >= 500) {
throw new Error(text || `HTTP ${response.status}`); throw new Error('无法连接服务器,请检查网络后重试');
} }
return response.json() as Promise<T>; const text = await res.text().catch(() => '');
}); throw new Error(text || '无法连接服务器,请检查网络后重试');
}, }
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const json: any = await res.json();
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
const error: any = new Error(json.msg || '请求失败');
error.code = json.code;
throw error;
}
return json as T;
}
return (await res.text()) as unknown as T;
}
}; };

View File

@@ -0,0 +1,115 @@
import { http } from './http'
export const markApi = {
// 新建任务(调用 erp_client_sb
newTask(file: File, signal?: AbortSignal) {
const formData = new FormData()
formData.append('file', file)
return http.upload<{ code: number, data: any, msg: string }>('/api/trademark/newTask', formData, signal)
},
// 获取任务列表及筛选数据(调用 erp_client_sb
getTask(signal?: AbortSignal) {
return http.post<{
code: number,
data: {
original: any,
filtered: Record<string, any>[], // 完整的行数据Map格式
headers: string[] // 表头
},
msg: string
}>('/api/trademark/task', undefined, signal)
},
// 品牌商标筛查
brandCheck(brands: string[], taskId?: string, signal?: AbortSignal) {
return http.post<{ code: number, data: { total: number, checked: number, registered: number, unregistered: number, failed: number, data: any[], duration: string }, msg: string }>('/api/trademark/brandCheck', { brands, taskId }, signal)
},
// 查询品牌筛查进度
getBrandCheckProgress(taskId: string) {
return http.get<{ code: number, data: { current: number }, msg: string }>('/api/trademark/brandCheckProgress', { taskId })
},
// 取消品牌筛查任务
cancelBrandCheck(taskId: string) {
return http.post<{ code: number, data: string, msg: string }>('/api/trademark/cancelBrandCheck', { taskId })
},
// 验证Excel表头
validateHeaders(file: File, requiredHeaders?: string[]) {
const formData = new FormData()
formData.append('file', file)
if (requiredHeaders && requiredHeaders.length > 0) {
formData.append('requiredHeaders', JSON.stringify(requiredHeaders))
}
return http.upload<{
code: number,
data: {
headers: string[],
valid?: boolean,
missing?: string[]
},
msg: string
}>('/api/trademark/validateHeaders', formData)
},
// 从Excel提取品牌列表客户端本地接口返回完整Excel数据
extractBrands(file: File) {
const formData = new FormData()
formData.append('file', file)
return http.upload<{
code: number,
data: {
total: number,
brands: string[],
headers: string[],
allRows: Record<string, any>[]
},
msg: string
}>('/api/trademark/extractBrands', formData)
},
// 根据ASIN列表从Excel中过滤完整行数据客户端本地接口
filterByAsins(file: File, asins: string[]) {
const formData = new FormData()
formData.append('file', file)
formData.append('asins', JSON.stringify(asins))
return http.upload<{
code: number,
data: {
headers: string[],
filteredRows: Record<string, any>[],
total: number
},
msg: string
}>('/api/trademark/filterByAsins', formData)
},
// 根据品牌列表从Excel中过滤完整行数据客户端本地接口
filterByBrands(file: File, brands: string[]) {
const formData = new FormData()
formData.append('file', file)
formData.append('brands', JSON.stringify(brands))
return http.upload<{
code: number,
data: {
headers: string[],
filteredRows: Record<string, any>[],
total: number
},
msg: string
}>('/api/trademark/filterByBrands', formData)
},
// 保存查询会话
saveSession(sessionData: any) {
return http.post<{ code: number, data: { sessionId: string }, msg: string }>('/api/trademark/saveSession', sessionData)
},
// 恢复查询会话
getSession(sessionId: string) {
return http.get<{ code: number, data: any, msg: string }>('/api/trademark/getSession', { sessionId })
}
}

View File

@@ -1,18 +1,18 @@
import { http } from './http' import { http } from './http'
export const rakutenApi = { export const rakutenApi = {
getProducts(params: { file?: File; shopName?: string; batchId?: string }) { getProducts(params: { file?: File; shopName?: string; batchId?: string }, signal?: AbortSignal) {
const formData = new FormData() const formData = new FormData()
if (params.file) formData.append('file', params.file) if (params.file) formData.append('file', params.file)
if (params.batchId) formData.append('batchId', params.batchId) if (params.batchId) formData.append('batchId', params.batchId)
if (params.shopName) formData.append('shopName', params.shopName) if (params.shopName) formData.append('shopName', params.shopName)
return http.upload('/api/rakuten/products', formData) return http.upload('/api/rakuten/products', formData, signal)
}, },
search1688(imageUrl: string, sessionId?: string) { search1688(imageUrl: string, sessionId?: string, signal?: AbortSignal) {
const payload: Record<string, unknown> = { imageUrl } const payload: Record<string, unknown> = { imageUrl }
if (sessionId) payload.sessionId = sessionId if (sessionId) payload.sessionId = sessionId
return http.post('/api/rakuten/search1688', payload) return http.post('/api/rakuten/search1688', payload, signal)
}, },
getLatestProducts() { getLatestProducts() {

View File

@@ -0,0 +1,55 @@
import { http } from './http'
export interface SplashImageResponse {
splashImage: string
url: string
}
export const splashApi = {
// 上传开屏图片
async uploadSplashImage(file: File, username: string) {
const formData = new FormData()
formData.append('file', file)
formData.append('username', username)
return http.upload<{ data: { url: string; fileName: string } }>('/monitor/account/splash-image/upload', formData)
},
// 获取当前用户的开屏图片
async getSplashImage(username: string) {
return http.get<{ data: SplashImageResponse }>('/monitor/account/splash-image', { username })
},
// 根据用户名获取开屏图片(用于启动时)
async getSplashImageByUsername(username: string) {
return http.get<{ data: SplashImageResponse }>('/monitor/account/splash-image/by-username', { username })
},
// 删除自定义开屏图片(恢复默认)
async deleteSplashImage(username: string) {
return http.post<{ data: string }>(`/monitor/account/splash-image/delete?username=${username}`)
},
// 上传品牌logo
async uploadBrandLogo(file: File, username: string) {
const formData = new FormData()
formData.append('file', file)
formData.append('username', username)
return http.upload<{ data: { url: string; fileName: string } }>('/monitor/account/brand-logo/upload', formData)
},
// 获取当前用户的品牌logo
async getBrandLogo(username: string) {
return http.get<{ data: { url: string } }>('/monitor/account/brand-logo', { username })
},
// 删除品牌logo
async deleteBrandLogo(username: string) {
return http.post<{ data: string }>(`/monitor/account/brand-logo/delete?username=${username}`)
},
// 获取全局开屏图片
async getGlobalSplashImage() {
return http.get<{ data: { url: string } }>('/monitor/account/global-splash-image')
}
}

View File

@@ -0,0 +1,13 @@
import { http } from './http';
export const systemApi = {
openGenmaiSpirit(accountId?: number | null) {
const url = accountId ? `/api/system/genmai/open?accountId=${accountId}` : '/api/system/genmai/open';
return http.post(url);
},
clearCache() {
return http.post('/api/system/cache/clear');
}
};

View File

@@ -2,7 +2,7 @@ import { http } from './http'
export const updateApi = { export const updateApi = {
getVersion() { getVersion() {
return http.get('/api/update/version') return http.get('/api/system/version')
}, },
checkUpdate(currentVersion: string) { checkUpdate(currentVersion: string) {

View File

@@ -1,12 +1,17 @@
import { http } from './http' import { http } from './http'
export const zebraApi = { export const zebraApi = {
getAccounts() { getAccounts(name?: string) {
return http.get('/tool/banma/accounts') return http.get('/tool/banma/accounts', name ? { name } : undefined)
}, },
saveAccount(body: any) { getAccountLimit(name?: string) {
return http.post('/tool/banma/accounts', body) return http.get('/tool/banma/account-limit', name ? { name } : undefined)
},
saveAccount(body: any, name?: string) {
const url = name ? `/tool/banma/accounts?name=${encodeURIComponent(name)}` : '/tool/banma/accounts'
return http.post(url, body)
}, },
removeAccount(id: number) { removeAccount(id: number) {
@@ -17,23 +22,11 @@ export const zebraApi = {
return http.get('/api/banma/shops', params as Record<string, unknown>) return http.get('/api/banma/shops', params as Record<string, unknown>)
}, },
getOrders(params: any) { getOrders(params: any, signal?: AbortSignal) {
return http.get('/api/banma/orders', params as Record<string, unknown>) return http.get('/api/banma/orders', params as Record<string, unknown>, signal)
},
getOrdersByBatch(batchId: string) {
return http.get(`/api/banma/orders/batch/${batchId}`)
}, },
getLatestOrders() { getLatestOrders() {
return http.get('/api/banma/orders/latest') return http.get('/api/banma/orders/latest')
},
getOrderStats() {
return http.get('/api/banma/orders/stats')
},
searchOrders(searchParams: Record<string, unknown>) {
return http.get('/api/banma/orders/search', searchParams)
} }
} }

View File

@@ -0,0 +1,9 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
export {}
declare global {
}

View File

@@ -0,0 +1,31 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
declare module 'vue' {
export interface GlobalComponents {
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImage: typeof import('element-plus/es')['ElImage']
ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElProgress: typeof import('element-plus/es')['ElProgress']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
}
}

View File

@@ -0,0 +1,372 @@
<script setup lang="ts">
import { ref, inject, onMounted, defineAsyncComponent } from 'vue'
import { ElMessage } from 'element-plus'
import { amazonApi } from '../../api/amazon'
import { handlePlatformFileExport } from '../../utils/settings'
import { getUsernameFromToken } from '../../utils/token'
import { useFileDrop } from '../../composables/useFileDrop'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
const props = defineProps<{
isVip: boolean
}>()
const emit = defineEmits<{
updateData: [data: any[]]
}>()
const loading = ref(false)
const tableLoading = ref(false)
const progressPercentage = ref(0)
const progressVisible = ref(false)
const localProductData = ref<any[]>([])
const currentAsin = ref('')
let abortController: AbortController | null = null
const region = ref('JP')
const regionOptions = [
{ label: '日本 (Japan)', value: 'JP', flag: '🇯🇵' },
{ label: '美国 (USA)', value: 'US', flag: '🇺🇸' },
]
const pendingAsins = ref<string[]>([])
const selectedFileName = ref('')
const amazonUpload = ref<HTMLInputElement | null>(null)
const exportLoading = ref(false)
const amazonExampleVisible = ref(false)
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both' | 'subscribe'>('account')
const vipStatus = inject<any>('vipStatus')
function showMessage(message: string, type: 'success' | 'warning' | 'error' | 'info' = 'info') {
ElMessage({ message, type })
}
function removeSelectedFile() {
selectedFileName.value = ''
pendingAsins.value = []
if (amazonUpload.value) {
amazonUpload.value.value = ''
}
}
async function processExcelFile(file: File) {
try {
loading.value = true
progressPercentage.value = 0
progressVisible.value = false
const response = await amazonApi.importAsinFromExcel(file)
const asinList = response.data.asinList
if (!asinList || asinList.length === 0) {
showMessage('文件中未找到有效的ASIN数据', 'warning')
return
}
pendingAsins.value = asinList
selectedFileName.value = file.name
} catch (error: any) {
showMessage(error.message || '处理文件失败', 'error')
} finally {
loading.value = false
}
}
async function handleExcelUpload(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file) return
await processExcelFile(file)
input.value = ''
}
const { dragActive, onDragEnter, onDragOver, onDragLeave, onDrop } = useFileDrop({
accept: /\.xlsx?$/i,
onFile: processExcelFile,
onError: (msg) => showMessage(msg, 'warning')
})
async function batchGetProductInfo(asinList: string[]) {
if (refreshVipStatus) await refreshVipStatus()
if (!props.isVip) {
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
showTrialExpiredDialog.value = true
return
}
try {
currentAsin.value = '正在处理...'
progressPercentage.value = 0
const batchId = `BATCH_${Date.now()}`
const batchSize = 2
const totalBatches = Math.ceil(asinList.length / batchSize)
let processedCount = 0
for (let i = 0; i < totalBatches && loading.value; i++) {
const start = i * batchSize
const end = Math.min(start + batchSize, asinList.length)
const batchAsins = asinList.slice(start, end)
currentAsin.value = `正在处理第${i + 1}/${totalBatches}批 (${batchAsins.join(', ')})`
try {
const result = await amazonApi.getProductsBatch(batchAsins, batchId, region.value, abortController?.signal)
if (result?.data?.products?.length > 0) {
localProductData.value.push(...result.data.products)
// 立即更新父组件数据,实时显示
emit('updateData', [...localProductData.value])
if (tableLoading.value) tableLoading.value = false
}
} catch (error: any) {
if (error.name === 'AbortError') break
console.error(`批次${i + 1}失败:`, error)
}
processedCount += batchAsins.length
progressPercentage.value = Math.round((processedCount / asinList.length) * 100)
if (i < totalBatches - 1 && loading.value) {
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 1500))
}
}
progressPercentage.value = 100
currentAsin.value = '处理完成'
} catch (error: any) {
if (error.name !== 'AbortError') {
showMessage(error.message || '批量获取产品信息失败', 'error')
currentAsin.value = '处理失败'
}
} finally {
tableLoading.value = false
}
}
async function startQueuedFetch() {
if (!pendingAsins.value.length) {
showMessage('请先导入ASIN列表', 'warning')
return
}
// 开始采集前先清空数据
localProductData.value = []
emit('updateData', [])
abortController = new AbortController()
loading.value = true
progressVisible.value = true
tableLoading.value = true
try {
await batchGetProductInfo(pendingAsins.value)
} finally {
tableLoading.value = false
loading.value = false
abortController = null
}
}
async function exportToExcel() {
if (!localProductData.value.length) {
showMessage('没有数据可供导出', 'warning')
return
}
exportLoading.value = true
let html = `<table>
<tr><th>ASIN</th><th>卖家/配送方</th><th>当前售价</th></tr>`
localProductData.value.forEach(product => {
const sellerText = getSellerShipperText(product)
html += `<tr>
<td>${product.asin || ''}</td>
<td>${sellerText}</td>
<td>${product.price || '无货'}</td>
</tr>`
})
html += '</table>'
const blob = new Blob([html], { type: 'application/vnd.ms-excel' })
const fileName = `Amazon产品数据_${new Date().toISOString().slice(0, 10)}.xls`
const username = getUsernameFromToken()
const success = await handlePlatformFileExport('amazon', blob, fileName, username)
if (success) {
showMessage('Excel文件导出成功', 'success')
}
exportLoading.value = false
}
function getSellerShipperText(product: any) {
let text = product.seller || '无货'
if (product.shipper && product.shipper !== product.seller) {
text += (text && text !== '无货' ? ' / ' : '') + product.shipper
}
return text
}
function stopFetch() {
abortController?.abort()
abortController = null
loading.value = false
currentAsin.value = '已停止'
showMessage('已停止获取产品数据', 'info')
}
function openAmazonUpload() {
amazonUpload.value?.click()
}
function viewAmazonExample() {
amazonExampleVisible.value = true
}
function downloadAmazonTemplate() {
const html = '<table><tr><th>ASIN</th></tr><tr><td>B0XXXXXXX1</td></tr><tr><td>B0XXXXXXX2</td></tr></table>'
const blob = new Blob([html], { type: 'application/vnd.ms-excel' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'amazon_asin_template.xls'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// 组件挂载时加载缓存数据
onMounted(async () => {
try {
const resp = await amazonApi.getLatestProducts()
if (resp.data?.products && resp.data.products.length > 0) {
localProductData.value = resp.data.products
emit('updateData', resp.data.products)
}
} catch (error) {
console.error('加载缓存数据失败:', error)
}
})
defineExpose({
loading,
progressVisible,
progressPercentage,
localProductData
})
</script>
<template>
<div class="asin-panel">
<div class="steps-flow">
<!-- 1 -->
<div class="flow-item">
<div class="step-index">1</div>
<div class="step-card">
<div class="step-header"><div class="title">导入ASIN</div></div>
<div class="desc">仅支持包含 ASIN 列的 Excel 文档</div>
<div class="links">
<a class="link" @click.prevent="viewAmazonExample">点击查看示例</a>
<span class="sep">|</span>
<a class="link" @click.prevent="downloadAmazonTemplate">点击下载模板</a>
</div>
<div class="dropzone" :class="{ active: dragActive }" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openAmazonUpload">
<div class="dz-el-icon">📤</div>
<div class="dz-text">点击或将文件拖拽到这里上传</div>
<div class="dz-sub">支持 .xls .xlsx</div>
</div>
<input ref="amazonUpload" style="display:none" type="file" accept=".xls,.xlsx" @change="handleExcelUpload" :disabled="loading" />
<div v-if="selectedFileName" class="file-chip">
<span class="dot"></span>
<span class="name">{{ selectedFileName }}</span>
<span class="delete-btn" @click="removeSelectedFile" title="删除文件">🗑</span>
</div>
</div>
</div>
<!-- 2 网站地区 -->
<div class="flow-item">
<div class="step-index">2</div>
<div class="step-card">
<div class="step-header"><div class="title">网站地区</div></div>
<div class="desc">请选择目标网站地区日本区</div>
<el-select v-model="region" placeholder="选择地区" size="small" style="width: 100%">
<el-option v-for="opt in regionOptions" :key="opt.value" :label="opt.label" :value="opt.value">
<span style="margin-right:6px">{{ opt.flag }}</span>{{ opt.label }}
</el-option>
</el-select>
</div>
</div>
<!-- 3 获取数据 -->
<div class="flow-item">
<div class="step-index">3</div>
<div class="step-card">
<div class="step-header"><div class="title">获取数据</div></div>
<div class="desc">导入表格后点击下方按钮开始获取ASIN数据</div>
<div class="action-buttons column">
<el-button size="small" class="w100 btn-blue" :disabled="!pendingAsins.length || loading" @click="startQueuedFetch">{{ loading ? '处理中...' : '获取数据' }}</el-button>
<el-button size="small" class="w100" :disabled="!loading" @click="stopFetch">停止获取</el-button>
</div>
</div>
</div>
<!-- 4 -->
<div class="flow-item">
<div class="step-index">4</div>
<div class="step-card">
<div class="step-header"><div class="title">导出数据</div></div>
<div class="action-buttons column">
<el-button size="small" class="w100 btn-blue" :disabled="!localProductData.length || loading || exportLoading" :loading="exportLoading" @click="exportToExcel">{{ exportLoading ? '导出中...' : '导出Excel' }}</el-button>
</div>
</div>
</div>
</div>
<el-dialog v-model="amazonExampleVisible" title="示例 - ASIN文档格式" width="480px">
<div>
<div style="margin:8px 0;color:#606266;font-size:13px;">Excel 示例</div>
<el-table :data="[{asin:'B0XXXXXXX1'},{asin:'B0XXXXXXX2'}]" size="small" border>
<el-table-column prop="asin" label="ASIN" />
</el-table>
</div>
<template #footer>
<el-button type="primary" class="btn-blue" @click="amazonExampleVisible = false">我知道了</el-button>
</template>
</el-dialog>
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
</div>
</template>
<style scoped>
.asin-panel {flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden;}
.steps-flow {position: relative; flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: none;}
.asin-panel .steps-flow::-webkit-scrollbar {display: none;}
.steps-flow:before {content: ''; position: absolute; left: 13px; top: 26px; bottom: 0; width: 2px; background: rgba(229, 231, 235, 0.6);}
.flow-item {position: relative; display: grid; grid-template-columns: 28px 1fr; gap: 12px; padding: 10px 0;}
.flow-item .step-index {position: static; width: 28px; height: 28px; line-height: 28px; text-align: center; border-radius: 50%; background: #1677FF; color: #fff; font-size: 14px; font-weight: 600; margin-top: 2px;}
.step-card {border: none; border-radius: 0; padding: 0; background: transparent; min-width: 0;}
.step-header {display: flex; align-items: center; gap: 8px; margin-bottom: 8px;}
.title {font-size: 14px; font-weight: 600; color: #303133; text-align: left;}
.desc {font-size: 12px; color: #909399; margin-bottom: 10px; text-align: left; line-height: 1.5;}
.links {display: flex; align-items: center; gap: 2px; margin-bottom: 8px;}
.link {color: #409EFF; cursor: pointer; font-size: 12px;}
.sep {color: #dcdfe6;}
.dropzone {border: 1px dashed #c0c4cc; border-radius: 6px; padding: 16px; text-align: center; cursor: pointer; background: #fafafa;}
.dropzone:hover {background: #f6fbff; border-color: #409EFF;}
.dz-el-icon {font-size: 18px; margin-bottom: 4px; color: #909399;}
.dz-text {color: #303133; font-size: 13px;}
.dz-sub {color: #909399; font-size: 12px;}
.file-chip {display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; color: #606266; margin-top: 6px; width: 100%; box-sizing: border-box;}
.file-chip .dot {width: 6px; height: 6px; background: #409EFF; border-radius: 50%; flex-shrink: 0;}
.file-chip .name {flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
.file-chip .delete-btn {cursor: pointer; opacity: 0.6; flex-shrink: 0;}
.file-chip .delete-btn:hover {opacity: 1;}
.action-buttons.column {display: flex; flex-direction: column; gap: 8px;}
.btn-blue {background: #1677FF; border-color: #1677FF; color: #fff;}
.btn-blue:disabled {background: #a6c8ff; border-color: #a6c8ff; color: #fff;}
.w100 {width: 100%;}
</style>

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { ref, inject, onMounted, defineAsyncComponent } from 'vue'
import { ElMessage } from 'element-plus'
import { systemApi } from '../../api/system'
import { genmaiApi, type GenmaiAccount } from '../../api/genmai'
import { getUsernameFromToken } from '../../utils/token'
const AccountManager = defineAsyncComponent(() => import('../common/AccountManager.vue'))
const props = defineProps<{
isVip: boolean
}>()
const genmaiLoading = ref(false)
const genmaiAccounts = ref<GenmaiAccount[]>([])
const selectedGenmaiAccountId = ref<number | null>(null)
const showAccountManager = ref(false)
const accountManagerRef = ref<any>(null)
function showMessage(message: string, type: 'success' | 'warning' | 'error' | 'info' = 'info') {
ElMessage({ message, type })
}
async function openGenmaiSpirit() {
if (!genmaiAccounts.value.length) {
showAccountManager.value = true
return
}
genmaiLoading.value = true
try {
await systemApi.openGenmaiSpirit(selectedGenmaiAccountId.value)
showMessage('跟卖精灵已打开', 'success')
} finally {
genmaiLoading.value = false
}
}
async function loadGenmaiAccounts() {
try {
const res = await genmaiApi.getAccounts(getUsernameFromToken())
genmaiAccounts.value = (res as any)?.data ?? []
if (genmaiAccounts.value[0]) selectedGenmaiAccountId.value = genmaiAccounts.value[0].id
} catch {}
}
onMounted(async () => {
await loadGenmaiAccounts()
})
</script>
<template>
<div class="genmai-panel">
<div class="steps-flow">
<!-- 1. 选择账号 -->
<div class="flow-item">
<div class="step-index">1</div>
<div class="step-card">
<div class="step-header"><div class="title">需启动的跟卖精灵账号</div></div>
<div class="desc">请选择需启动的跟卖精灵账号</div>
<template v-if="genmaiAccounts.length">
<el-scrollbar :class="['account-list', { 'scroll-limit': genmaiAccounts.length > 3 }]">
<div>
<div
v-for="acc in genmaiAccounts"
:key="acc.id"
:class="['acct-item', { selected: selectedGenmaiAccountId === acc.id }]"
@click="selectedGenmaiAccountId = acc.id"
>
<span class="acct-row">
<span :class="['status-dot', acc.status === 1 ? 'on' : 'off']"></span>
<img class="avatar" src="/image/user.png" alt="avatar" />
<span class="acct-text">{{ acc.name || acc.username }}</span>
<span v-if="selectedGenmaiAccountId === acc.id" class="acct-check"></span>
</span>
</div>
</div>
</el-scrollbar>
</template>
<template v-else>
<div class="placeholder-box">
<img class="placeholder-img" src="/icon/image.png" alt="add-account" />
<div class="placeholder-tip">请添加跟卖精灵账号</div>
</div>
</template>
<div class="step-actions btn-row">
<el-button size="small" class="w50" @click="showAccountManager = true">添加账号</el-button>
<el-button size="small" class="w50 btn-blue" @click="showAccountManager = true">账号管理</el-button>
</div>
</div>
</div>
<!-- 2. 启动服务 -->
<div class="flow-item">
<div class="step-index">2</div>
<div class="step-card">
<div class="step-header"><div class="title">启动服务</div></div>
<div class="desc">请确保设备已安装Chrome浏览器否则服务将无法启动打开跟卖精灵将关闭Chrome浏览器进程</div>
<div class="action-buttons column">
<el-button
size="small"
class="w100 btn-blue"
:disabled="genmaiLoading || !genmaiAccounts.length"
@click="openGenmaiSpirit"
>
<span v-if="!genmaiLoading">启动服务</span>
<span v-else><span class="inline-spinner"></span> 启动中...</span>
</el-button>
</div>
</div>
</div>
</div>
<AccountManager
ref="accountManagerRef"
v-model="showAccountManager"
platform="genmai"
@refresh="loadGenmaiAccounts"
/>
</div>
</template>
<style scoped>
.genmai-panel {flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden;}
.steps-flow {position: relative; flex: 1; min-height: 0; overflow-y: auto; scrollbar-width: none;}
.genmai-panel .steps-flow::-webkit-scrollbar {display: none;}
.steps-flow:before {content: ''; position: absolute; left: 13px; top: 26px; bottom: 0; width: 2px; background: rgba(229, 231, 235, 0.6);}
.flow-item {position: relative; display: grid; grid-template-columns: 28px 1fr; gap: 12px; padding: 10px 0;}
.flow-item .step-index {position: static; width: 28px; height: 28px; line-height: 28px; text-align: center; border-radius: 50%; background: #1677FF; color: #fff; font-size: 14px; font-weight: 600; margin-top: 2px;}
.step-card {border: none; border-radius: 0; padding: 0; background: transparent;}
.step-header {display: flex; align-items: center; gap: 8px; margin-bottom: 8px;}
.title {font-size: 14px; font-weight: 600; color: #303133; text-align: left;}
.desc {font-size: 12px; color: #909399; margin-bottom: 10px; text-align: left; line-height: 1.5;}
.account-list {height: auto;}
.scroll-limit {max-height: 140px;}
.placeholder-box {display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100px; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; margin-bottom: 8px;}
.placeholder-img {width: 80px; opacity: 0.9;}
.placeholder-tip {margin-top: 6px; font-size: 12px; color: #a8abb2;}
.avatar {width: 18px; height: 18px; border-radius: 50%;}
.acct-row {display: grid; grid-template-columns: 6px 18px 1fr auto; align-items: center; gap: 6px; width: 100%;}
.acct-text {overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; font-size: 12px;}
.status-dot {width: 6px; height: 6px; border-radius: 50%; display: inline-block;}
.status-dot.on {background: #22c55e;}
.status-dot.off {background: #f87171;}
.acct-item {padding: 6px 8px; border-radius: 6px; cursor: pointer; margin-bottom: 4px;}
.acct-item.selected {background: #eef5ff; box-shadow: inset 0 0 0 1px #d6e4ff;}
.acct-check {display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; border-radius: 50%; background: transparent; color: #111; font-size: 12px;}
.account-list::-webkit-scrollbar {width: 0; height: 0;}
.step-actions {margin-top: 8px; display: flex; gap: 8px;}
.btn-row {display: grid; grid-template-columns: 1fr 1fr; gap: 8px;}
.w50 {width: 100%;}
.action-buttons.column {display: flex; flex-direction: column; gap: 8px;}
.btn-blue {background: #1677FF; border-color: #1677FF; color: #fff;}
.btn-blue:disabled {background: #a6c8ff; border-color: #a6c8ff; color: #fff;}
.w100 {width: 100%;}
.inline-spinner {display: inline-block; animation: spin 1s linear infinite;}
@keyframes spin {0% { transform: rotate(0deg);}
100% {transform: rotate(360deg);}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,8 @@ import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue' import { User } from '@element-plus/icons-vue'
import { authApi } from '../../api/auth' import { authApi } from '../../api/auth'
import { deviceApi } from '../../api/device'
import { getOrCreateDeviceId } from '../../utils/deviceId' import { getOrCreateDeviceId } from '../../utils/deviceId'
import { splashApi } from '../../api/splash'
interface Props { interface Props {
modelValue: boolean modelValue: boolean
@@ -12,8 +12,9 @@ interface Props {
interface Emits { interface Emits {
(e: 'update:modelValue', value: boolean): void (e: 'update:modelValue', value: boolean): void
(e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string }): void (e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }): void
(e: 'showRegister'): void (e: 'showRegister'): void
(e: 'deviceConflict', username: string): void
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -35,28 +36,33 @@ async function handleAuth() {
// 获取或生成设备ID // 获取或生成设备ID
const deviceId = await getOrCreateDeviceId() const deviceId = await getOrCreateDeviceId()
// 注册设备
await deviceApi.register({
username: authForm.value.username,
deviceId: deviceId,
os: navigator.platform
})
// 登录 // 登录
const loginRes: any = await authApi.login({ const loginRes: any = await authApi.login({
...authForm.value, ...authForm.value,
clientId: deviceId clientId: deviceId
}) })
// 保存开屏图片配置和品牌logo不阻塞登录
saveSplashConfigInBackground(authForm.value.username)
saveBrandLogoInBackground(authForm.value.username)
emit('loginSuccess', { emit('loginSuccess', {
token: loginRes.data.accessToken || loginRes.data.token, token: loginRes.data.accessToken || loginRes.data.token,
permissions: loginRes.data.permissions, permissions: loginRes.data.permissions,
expireTime: loginRes.data.expireTime expireTime: loginRes.data.expireTime,
accountType: loginRes.data.accountType,
deviceTrialExpired: loginRes.data.deviceTrialExpired || false
}) })
ElMessage.success('登录成功') ElMessage.success('登录成功')
resetForm() resetForm()
} catch (err) { } catch (err: any) {
ElMessage.error((err as Error).message) // 设备冲突/数量达上限:触发设备管理
if (err.code === 501 ) {
emit('deviceConflict', authForm.value.username)
resetForm()
} else {
ElMessage.error(err.message || '登录失败')
}
} finally { } finally {
authLoading.value = false authLoading.value = false
} }
@@ -74,6 +80,31 @@ function resetForm() {
function showRegister() { function showRegister() {
emit('showRegister') emit('showRegister')
} }
// 保存开屏图片配置
async function saveSplashConfigInBackground(username: string) {
try {
const res = await splashApi.getSplashImage(username)
const url = res?.data?.data?.url || res?.data?.url || ''
await (window as any).electronAPI.saveSplashConfig(username, url)
} catch (error) {
console.error('[开屏图片] 保存配置失败:', error)
}
}
// 保存品牌logo配置
async function saveBrandLogoInBackground(username: string) {
try {
const res = await splashApi.getBrandLogo(username)
const url = res?.data?.url || ''
// 保存到本地配置
await (window as any).electronAPI.saveBrandLogoConfig(username, url)
// 触发App.vue加载品牌logo
window.dispatchEvent(new CustomEvent('brandLogoChanged', { detail: url }))
} catch (error) {
console.error('[品牌logo] 加载配置失败:', error)
}
}
</script> </script>
<template> <template>
@@ -109,6 +140,7 @@ function showRegister() {
size="large" size="large"
style="margin-bottom: 20px;" style="margin-bottom: 20px;"
:disabled="authLoading" :disabled="authLoading"
show-password
@keyup.enter="handleAuth"> @keyup.enter="handleAuth">
</el-input> </el-input>
@@ -134,36 +166,10 @@ function showRegister() {
</template> </template>
<style scoped> <style scoped>
.auth-logo { .auth-logo {width: 160px; height: auto;}
width: 160px; .auth-dialog {--el-color-primary: #1677FF;}
height: auto; .auth-dialog :deep(.el-button--primary) {background-color: #1677FF; border-color: #1677FF;}
} .auth-title-wrap {margin-bottom: 12px;}
.auth-title {margin: 0; font-size: 18px; font-weight: 700; color: #1f1f1f; text-align: left;}
.auth-dialog { .auth-subtitle {margin: 6px 0 0; font-size: 12px; color: #8c8c8c; text-align: left;}
--el-color-primary: #1677FF;
}
.auth-dialog :deep(.el-button--primary) {
background-color: #1677FF;
border-color: #1677FF;
}
.auth-title-wrap {
margin-bottom: 12px;
}
.auth-title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #1f1f1f;
text-align: left;
}
.auth-subtitle {
margin: 6px 0 0;
font-size: 12px;
color: #8c8c8c;
text-align: left;
}
</style> </style>

View File

@@ -11,7 +11,7 @@ interface Props {
interface Emits { interface Emits {
(e: 'update:modelValue', value: boolean): void (e: 'update:modelValue', value: boolean): void
(e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string }): void (e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }): void
(e: 'backToLogin'): void (e: 'backToLogin'): void
} }
@@ -35,6 +35,10 @@ const canRegister = computed(() => {
usernameCheckResult.value === true usernameCheckResult.value === true
}) })
function filterUsername(value: string) {
registerForm.value.username = value.replace(/[^a-zA-Z0-9_]/g, '')
}
async function checkUsernameAvailability() { async function checkUsernameAvailability() {
if (!registerForm.value.username) { if (!registerForm.value.username) {
usernameCheckResult.value = null usernameCheckResult.value = null
@@ -65,24 +69,20 @@ async function handleRegister() {
deviceId: deviceId deviceId: deviceId
}) })
// 显示注册成功和VIP信息 // 显示注册成功提示
if (registerRes.data.expireTime) { if (registerRes.data.deviceTrialExpired) {
const expireDate = new Date(registerRes.data.expireTime) ElMessage.warning('注册成功您获得了3天VIP体验但该设备试用期已过请更换设备或联系管理员续费')
const now = new Date() } else {
const daysLeft = Math.ceil((expireDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) ElMessage.success('注册成功您获得了3天VIP体验')
if (daysLeft > 0) {
ElMessage.success(`注册成功!您获得了 ${daysLeft} 天VIP体验`)
} else {
ElMessage.warning('注册成功!该设备已使用过新人福利,请联系管理员续费')
}
} }
// 使用注册返回的token直接登录 // 使用注册返回的token直接登录
emit('loginSuccess', { emit('loginSuccess', {
token: registerRes.data.accessToken || registerRes.data.token, token: registerRes.data.accessToken || registerRes.data.token,
permissions: registerRes.data.permissions, permissions: registerRes.data.permissions,
expireTime: registerRes.data.expireTime expireTime: registerRes.data.expireTime,
accountType: registerRes.data.accountType,
deviceTrialExpired: registerRes.data.deviceTrialExpired || false
}) })
resetForm() resetForm()
} catch (err) { } catch (err) {
@@ -127,10 +127,11 @@ function backToLogin() {
<el-input <el-input
v-model="registerForm.username" v-model="registerForm.username"
placeholder="请输入用户名" placeholder="请输入用户名(字母、数字、下划线)"
size="large" size="large"
style="margin-bottom: 15px;" style="margin-bottom: 15px;"
:disabled="registerLoading" :disabled="registerLoading"
@input="filterUsername"
@blur="checkUsernameAvailability"> @blur="checkUsernameAvailability">
</el-input> </el-input>
@@ -149,7 +150,8 @@ function backToLogin() {
type="password" type="password"
size="large" size="large"
style="margin-bottom: 15px;" style="margin-bottom: 15px;"
:disabled="registerLoading"> :disabled="registerLoading"
show-password>
</el-input> </el-input>
<el-input <el-input
@@ -158,7 +160,8 @@ function backToLogin() {
type="password" type="password"
size="large" size="large"
style="margin-bottom: 20px;" style="margin-bottom: 20px;"
:disabled="registerLoading"> :disabled="registerLoading"
show-password>
</el-input> </el-input>
<div> <div>
@@ -182,36 +185,10 @@ function backToLogin() {
</el-dialog> </el-dialog>
</template> </template>
<style scoped> <style scoped>
.auth-logo { .auth-logo {width: 160px; height: auto;}
width: 160px; .auth-dialog {--el-color-primary: #1677FF;}
height: auto; .auth-dialog :deep(.el-button--primary) {background-color: #1677FF; border-color: #1677FF;}
} .auth-title-wrap {margin-bottom: 12px;}
.auth-title {margin: 0; font-size: 18px; font-weight: 700; color: #1f1f1f; text-align: left;}
.auth-dialog { .auth-subtitle {margin: 6px 0 0; font-size: 12px; color: #8c8c8c; text-align: left;}
--el-color-primary: #1677FF;
}
.auth-dialog :deep(.el-button--primary) {
background-color: #1677FF;
border-color: #1677FF;
}
.auth-title-wrap {
margin-bottom: 12px;
}
.auth-title {
margin: 0;
font-size: 18px;
font-weight: 700;
color: #1f1f1f;
text-align: left;
}
.auth-subtitle {
margin: 6px 0 0;
font-size: 12px;
color: #8c8c8c;
text-align: left;
}
</style> </style>

View File

@@ -1,26 +1,50 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, defineAsyncComponent, watch } from 'vue'
import { zebraApi, type BanmaAccount } from '../../api/zebra' import { zebraApi, type BanmaAccount } from '../../api/zebra'
import { genmaiApi, type GenmaiAccount } from '../../api/genmai'
import { ElMessageBox, ElMessage } from 'element-plus' import { ElMessageBox, ElMessage } from 'element-plus'
import { getUsernameFromToken } from '../../utils/token'
type PlatformKey = 'zebra' | 'shopee' | 'rakuten' | 'amazon' const TrialExpiredDialog = defineAsyncComponent(() => import('./TrialExpiredDialog.vue'))
type PlatformKey = 'zebra' | 'shopee' | 'rakuten' | 'amazon' | 'genmai'
const props = defineProps<{ modelValue: boolean; platform?: PlatformKey }>() const props = defineProps<{ modelValue: boolean; platform?: PlatformKey }>()
const emit = defineEmits(['update:modelValue', 'add', 'refresh']) const emit = defineEmits(['update:modelValue', 'refresh'])
const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) }) const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
const curPlatform = ref<PlatformKey>(props.platform || 'zebra') const curPlatform = ref<PlatformKey>(props.platform || 'zebra')
// 监听弹框打开,同步平台并加载数据
watch(() => props.modelValue, (newVal) => {
if (newVal && props.platform) {
curPlatform.value = props.platform
load()
}
})
// 升级订阅弹框
const showUpgradeDialog = ref(false)
const PLATFORM_LABEL: Record<PlatformKey, string> = { const PLATFORM_LABEL: Record<PlatformKey, string> = {
zebra: '斑马 ERP', zebra: '斑马 ERP',
shopee: 'Shopee 虾皮购物', shopee: 'Shopee 虾皮购物',
rakuten: 'Rakuten 乐天购物', rakuten: 'Rakuten 乐天购物',
amazon: 'Amazon 亚马逊' amazon: 'Amazon 亚马逊',
genmai: '跟卖精灵'
} }
const accounts = ref<BanmaAccount[]>([]) const accounts = ref<(BanmaAccount | GenmaiAccount)[]>([])
const accountLimit = ref({ limit: 1, count: 0 })
// 添加账号对话框
const accountDialogVisible = ref(false)
const formUsername = ref('')
const formPassword = ref('')
async function load() { async function load() {
const res = await zebraApi.getAccounts() const api = curPlatform.value === 'genmai' ? genmaiApi : zebraApi
const list = (res as any)?.data ?? res const username = getUsernameFromToken()
accounts.value = Array.isArray(list) ? list : [] const [res, limitRes] = await Promise.all([api.getAccounts(username), api.getAccountLimit(username)])
accounts.value = (res as any)?.data ?? res
accountLimit.value = (limitRes as any)?.data ?? limitRes
} }
// 暴露方法供父组件调用 // 暴露方法供父组件调用
@@ -42,11 +66,39 @@ async function onDelete(a: any) {
try { try {
await ElMessageBox.confirm(`确定删除账号 "${a?.name || a?.username || id}" 吗?`, '提示', { type: 'warning' }) await ElMessageBox.confirm(`确定删除账号 "${a?.name || a?.username || id}" 吗?`, '提示', { type: 'warning' })
} catch { return } } catch { return }
await zebraApi.removeAccount(id) const api = curPlatform.value === 'genmai' ? genmaiApi : zebraApi
await api.removeAccount(id)
ElMessage({ message: '删除成功', type: 'success' }) ElMessage({ message: '删除成功', type: 'success' })
await load() await load()
emit('refresh') // 通知外层组件刷新账号列表 emit('refresh') // 通知外层组件刷新账号列表
} }
async function handleAddAccount() {
if (accountLimit.value.count >= accountLimit.value.limit) {
ElMessage({ message: `账号数量已达上限`, type: 'warning' })
return
}
formUsername.value = ''
formPassword.value = ''
accountDialogVisible.value = true
}
async function submitAccount() {
const api = curPlatform.value === 'genmai' ? genmaiApi : zebraApi
try {
await api.saveAccount({
username: formUsername.value,
password: formPassword.value,
status: 1
}, getUsernameFromToken())
ElMessage({ message: '添加成功', type: 'success' })
accountDialogVisible.value = false
await load()
emit('refresh')
} catch (e: any) {
ElMessage({ message: e.message || '添加失败', type: 'error' })
}
}
</script> </script>
<script lang="ts"> <script lang="ts">
@@ -63,8 +115,9 @@ export default defineComponent({ name: 'AccountManager' })
<div class="layout"> <div class="layout">
<aside class="sider"> <aside class="sider">
<div class="sider-title">全账号管理</div> <div class="sider-title">全账号管理</div>
<div class="nav only-zebra"> <div class="nav">
<div :class="['nav-item', {active: curPlatform==='zebra'}]" @click="switchPlatform('zebra')">斑马 ERP</div> <div :class="['nav-item', {active: curPlatform==='zebra'}]" @click="switchPlatform('zebra')">斑马 ERP</div>
<div :class="['nav-item', {active: curPlatform==='genmai'}]" @click="switchPlatform('genmai')">跟卖精灵</div>
</div> </div>
</aside> </aside>
<section class="content"> <section class="content">
@@ -72,10 +125,10 @@ export default defineComponent({ name: 'AccountManager' })
<div class="top"> <div class="top">
<img src="/icon/image.png" class="hero" alt="logo" /> <img src="/icon/image.png" class="hero" alt="logo" />
<div class="head-main"> <div class="head-main">
<div class="main-title">在线账号管理3/3</div> <div class="main-title">在线账号管理{{ accountLimit.count }}/{{ accountLimit.limit }}</div>
<div class="main-sub"> <div class="main-sub">
您当前订阅可同时托管3家 Shopee 店铺<br> 您当前订阅可同时托管{{ accountLimit.limit }}{{ curPlatform === 'genmai' ? '跟卖精灵' : '斑马' }}账号<br>
如需扩增同时托管店铺数 <span class="upgrade">升级订阅</span> <span v-if="accountLimit.limit < 3">如需扩增账号数量,请 <span class="upgrade" @click="showUpgradeDialog = true">升级订阅</span></span>
</div> </div>
</div> </div>
</div> </div>
@@ -83,7 +136,7 @@ export default defineComponent({ name: 'AccountManager' })
<div v-for="a in accounts" :key="a.id" class="row"> <div v-for="a in accounts" :key="a.id" class="row">
<span :class="['dot', a.status === 1 ? 'on' : 'off']"></span> <span :class="['dot', a.status === 1 ? 'on' : 'off']"></span>
<div class="user-info"> <div class="user-info">
<img class="avatar" src="/image/img_v3_02qd_052605f0-4be3-44db-9691-35ee5ff6201g.jpg" /> <img class="avatar" src="/image/user.png" />
<span class="name">{{ a.name || a.username }}</span> <span class="name">{{ a.name || a.username }}</span>
</div> </div>
<span class="date">{{ formatDate(a) }}</span> <span class="date">{{ formatDate(a) }}</span>
@@ -91,44 +144,75 @@ export default defineComponent({ name: 'AccountManager' })
</div> </div>
</div> </div>
<div class="footer"> <div class="footer">
<el-button type="primary" class="btn" @click="$emit('add')">添加账号</el-button> <el-button type="primary" class="btn" @click="handleAddAccount">添加账号</el-button>
</div> </div>
</section> </section>
</div> </div>
<!-- 添加账号对话框 -->
<el-dialog v-model="accountDialogVisible" width="420px" class="add-account-dialog">
<template #header>
<div class="aad-header">
<img class="aad-icon" src="/icon/image.png" alt="logo" />
<div class="aad-title">添加{{ curPlatform === 'genmai' ? '跟卖精灵' : '斑马' }}账号</div>
</div>
</template>
<div class="aad-row">
<el-input v-model="formUsername" :placeholder="curPlatform === 'genmai' ? '请输入账号nickname' : '请输入账号'" />
</div>
<div class="aad-row">
<el-input v-model="formPassword" placeholder="请输入密码" type="password" show-password />
</div>
<template #footer>
<el-button type="primary" class="btn-blue" style="width: 100%" @click="submitAccount">添加</el-button>
</template>
</el-dialog>
<!-- 升级订阅弹框 -->
<TrialExpiredDialog v-model="showUpgradeDialog" expired-type="subscribe" />
</el-dialog> </el-dialog>
</template> </template>
<style scoped> <style scoped>
.acc-manager :deep(.el-dialog__header) { text-align:center; } .acc-manager :deep(.el-dialog__header) {text-align:center;}
.layout { display:grid; grid-template-columns: 160px 1fr; gap: 12px; min-height: 340px; } .layout {display:grid; grid-template-columns: 160px 1fr; gap: 12px; min-height: 340px;}
.sider { border-right: 1px solid #ebeef5; padding-right: 10px; } .sider {border-right: 1px solid #ebeef5; padding-right: 10px;}
.sider-title { color:#303133; font-size:13px; font-weight: 600; margin-bottom: 10px; text-align: left; } .sider-title {color:#303133; font-size:13px; font-weight: 600; margin-bottom: 10px; text-align: left;}
.nav { display:flex; flex-direction: column; gap: 4px; } .nav {display:flex; flex-direction: column; gap: 4px;}
.nav-item { padding: 6px 8px; border-radius: 4px; cursor: pointer; color:#606266; font-size: 12px; transition: all 0.2s; text-align: left; } .nav-item {padding: 6px 8px; border-radius: 4px; cursor: pointer; color:#606266; font-size: 12px; transition: all 0.2s; text-align: left;}
.nav-item:hover { background:#f0f2f5; } .nav-item:hover {background:#f0f2f5;}
.nav-item.active { background:#e6f4ff; color:#409EFF; font-weight: 600; } .nav-item.active {background:#e6f4ff; color:#409EFF; font-weight: 600;}
.platform-bar { font-weight: 600; color:#303133; margin: 0 0 12px 0; text-align: left; font-size: 14px; padding-bottom: 8px; border-bottom: 1px solid #ebeef5; } .platform-bar {font-weight: 600; color:#303133; margin: 0 0 12px 0; text-align: left; font-size: 14px; padding-bottom: 8px; border-bottom: 1px solid #ebeef5;}
.content { display:flex; flex-direction: column; min-width: 0; } .content {display:flex; flex-direction: column; min-width: 0;}
.top { display:flex; flex-direction: column; align-items:center; gap: 6px; margin-bottom: 12px; } .top {display:flex; flex-direction: column; align-items:center; gap: 6px; margin-bottom: 12px;}
.hero { width: 160px; height: auto; } .hero {width: 160px; height: auto;}
.head-main { text-align:center; } .head-main {text-align:center;}
.main-title { font-size: 16px; font-weight: 600; color:#303133; margin-bottom: 4px; } .main-title {font-size: 16px; font-weight: 600; color:#303133; margin-bottom: 4px;}
.main-sub { color:#909399; font-size: 11px; line-height: 1.4; } .main-sub {color:#909399; font-size: 11px; line-height: 1.4;}
.upgrade { color:#409EFF; cursor: pointer; } .upgrade {color:#409EFF; cursor: pointer; font-weight: 600; transition: all 0.2s ease;}
.list { border:1px solid #ebeef5; border-radius: 6px; background: #fff; flex: 0 0 auto; width: 100%; max-height: 160px; overflow-y: auto; } .upgrade:hover {color:#0d5ed6; text-decoration: underline;}
.list.compact { max-height: 48px; } .list {border:1px solid #ebeef5; border-radius: 6px; background: #fff; flex: 0 0 auto; width: 100%; max-height: 160px; overflow-y: auto;}
.row { display:grid; grid-template-columns: 8px 1fr 120px 60px; gap: 8px; align-items:center; padding: 4px 8px; border-bottom: 1px solid #f5f5f5; height: 28px; } .list.compact {max-height: 48px;}
.row:last-child { border-bottom:none; } /* 添加账号对话框样式 */
.row:hover { background:#fafafa; } .add-account-dialog .aad-header {display:flex; flex-direction: column; align-items:center; gap:8px; padding-top: 8px; width: 100%;}
.dot { width:6px; height:6px; border-radius:50%; justify-self: center; } .add-account-dialog .aad-icon {width: 120px; height: auto;}
.dot.on { background:#52c41a; } .add-account-dialog .aad-title {font-weight: 600; font-size: 18px; text-align: center;}
.dot.off { background:#ff4d4f; } .add-account-dialog .aad-row {margin-top: 12px;}
.user-info { display: flex; align-items: center; gap: 8px; min-width: 0; } :deep(.add-account-dialog .el-dialog__header) {text-align: center; padding-right: 0; display: block;}
.avatar { width:22px; height:22px; border-radius:50%; object-fit: cover; } .btn-blue {background: #1677FF; border-color: #1677FF; color: #fff;}
.name { font-weight:500; font-size: 13px; color:#303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .btn-blue:hover {background: #0d5ed6; border-color: #0d5ed6;}
.date { color:#999; font-size:11px; text-align: center; } .row {display:grid; grid-template-columns: 8px 1fr 120px 60px; gap: 8px; align-items:center; padding: 4px 8px; border-bottom: 1px solid #f5f5f5; height: 28px;}
.footer { display:flex; justify-content:center; padding-top: 10px; } .row:last-child {border-bottom:none;}
.btn { width: 180px; height: 32px; font-size: 13px; } .row:hover {background:#fafafa;}
.dot {width:6px; height:6px; border-radius:50%; justify-self: center;}
.dot.on {background:#52c41a;}
.dot.off {background:#ff4d4f;}
.user-info {display: flex; align-items: center; gap: 8px; min-width: 0;}
.avatar {width:22px; height:22px; border-radius:50%; object-fit: cover;}
.name {font-weight:500; font-size: 13px; color:#303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
.date {color:#999; font-size:11px; text-align: center;}
.footer {display:flex; justify-content:center; padding-top: 10px;}
.btn {width: 180px; height: 32px; font-size: 13px;}
</style> </style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
interface Props {
modelValue: boolean
expiredType: 'device' | 'account' | 'both' | 'subscribe' // 设备过期、账号过期、都过期、主动订阅
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const titleText = computed(() => {
if (props.expiredType === 'subscribe') return '订阅服务'
if (props.expiredType === 'both') return '试用已到期'
if (props.expiredType === 'account') return '账号试用已到期'
return '设备试用已到期'
})
const subtitleText = computed(() => {
if (props.expiredType === 'subscribe') return '联系客服订阅或续费,享受完整服务'
if (props.expiredType === 'both') return '试用已到期,请联系客服订阅以获取完整服务'
if (props.expiredType === 'account') return '账号试用已到期,请联系客服订阅'
return '当前设备试用已到期,请更换新设备体验或联系客服订阅'
})
function handleConfirm() {
visible.value = false
}
function copyWechat() {
navigator.clipboard.writeText('butaihaoba001').then(() => {
ElMessage.success('微信号已复制')
}).catch(() => {
ElMessage.error('复制失败,请手动复制')
})
}
</script>
<template>
<el-dialog
v-model="visible"
:close-on-click-modal="false"
:show-close="true"
width="380px"
center
class="trial-expired-dialog">
<div class="expired-content">
<!-- Logo -->
<div style="text-align: center; margin-bottom: 16px;">
<img src="/icon/image.png" alt="logo" class="expired-logo" />
</div>
<!-- 标题 -->
<h2 class="expired-title">{{ titleText }}</h2>
<!-- 副标题 -->
<p class="expired-subtitle">{{ subtitleText }}</p>
<!-- 客服微信 -->
<div class="wechat-card" @click="copyWechat">
<div class="wechat-icon">
<svg viewBox="0 0 1024 1024">
<path d="M664.250054 368.541681c10.015098 0 19.892049 0.732687 29.67281 1.795902-26.647917-122.810047-159.358451-214.077703-310.826188-214.077703-169.353083 0-308.085774 114.232694-308.085774 259.274068 0 83.708494 46.165436 152.460344 123.281791 205.78483l-30.80868 91.730191 107.688651-53.455469c38.558178 7.53665 69.459978 15.308661 107.924012 15.308661 9.66308 0 19.230993-0.470721 28.752858-1.225921-6.025227-20.36584-9.521864-41.723264-9.521864-63.94508C402.328693 476.632491 517.908058 368.541681 664.250054 368.541681zM498.62897 285.87389c23.200398 0 38.557154 15.120372 38.557154 38.061874 0 22.846334-15.356756 38.298144-38.557154 38.298144-23.107277 0-46.260603-15.45181-46.260603-38.298144C452.368366 300.994262 475.522716 285.87389 498.62897 285.87389zM283.016307 362.23394c-23.107277 0-46.402843-15.45181-46.402843-38.298144 0-22.941502 23.295566-38.061874 46.402843-38.061874 23.081695 0 38.46301 15.120372 38.46301 38.061874C321.479317 346.78213 306.098002 362.23394 283.016307 362.23394zM945.448458 606.151333c0-121.888048-123.258255-221.236753-261.683954-221.236753-146.57838 0-262.015505 99.348706-262.015505 221.236753 0 122.06508 115.437126 221.200938 262.015505 221.200938 30.66644 0 61.617359-7.609305 92.423993-15.262612l84.513836 45.786813-23.178909-76.17082C899.379213 735.776599 945.448458 674.90216 945.448458 606.151333zM598.803483 567.994292c-15.332197 0-30.807656-15.096836-30.807656-30.501688 0-15.190981 15.47546-30.477129 30.807656-30.477129 23.295566 0 38.558178 15.286148 38.558178 30.477129C637.361661 552.897456 622.099049 567.994292 598.803483 567.994292zM768.25071 567.994292c-15.213493 0-30.594809-15.096836-30.594809-30.501688 0-15.190981 15.381315-30.477129 30.594809-30.477129 23.107277 0 38.558178 15.286148 38.558178 30.477129C806.808888 552.897456 791.357987 567.994292 768.25071 567.994292z" fill="#09BB07"/>
</svg>
</div>
<div class="wechat-info">
<div class="wechat-label">客服微信</div>
<div class="wechat-id">butaihaoba001</div>
</div>
<div class="copy-icon">📋</div>
</div>
<!-- 按钮 -->
<el-button
type="primary"
class="confirm-btn"
@click="handleConfirm"
style="width: 100%;">
我知道了
</el-button>
</div>
</el-dialog>
</template>
<style scoped>
.trial-expired-dialog :deep(.el-dialog) {border-radius: 16px;}
.trial-expired-dialog :deep(.el-dialog__header) {padding: 0; margin: 0;}
.trial-expired-dialog :deep(.el-dialog__body) {padding: 20px;}
.expired-content {display: flex; flex-direction: column; align-items: center; padding: 10px 0;}
.expired-logo {width: 160px; height: auto;}
.expired-title {font-size: 18px; font-weight: 700; color: #1f1f1f; margin: 0 0 8px 0; text-align: center;}
.expired-subtitle {font-size: 12px; color: #8c8c8c; margin: 0 0 20px 0; text-align: center; line-height: 1.5;}
.wechat-card {display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #f5f5f5; border-radius: 6px; margin-bottom: 20px; width: 90%; cursor: pointer; transition: all 0.3s; position: relative;}
.wechat-card:hover {background: #e8f5e9; box-shadow: 0 2px 8px rgba(9, 187, 7, 0.15);}
.wechat-icon {flex-shrink: 0;}
.wechat-icon svg {width: 36px; height: 36px;}
.wechat-info {flex: 1; text-align: left;}
.wechat-label {font-size: 12px; color: #666; margin-bottom: 2px;}
.wechat-id {font-size: 15px; font-weight: 500; color: #1f1f1f;}
.copy-icon {margin-left: auto; font-size: 16px; opacity: 0.5; transition: all 0.3s;}
.wechat-card:hover .copy-icon {opacity: 1; transform: scale(1.1);}
.confirm-btn {height: 40px; font-size: 14px; font-weight: 500; background: #1677FF; border-color: #1677FF; border-radius: 6px;}
.confirm-btn:hover {background: #4096ff; border-color: #4096ff;}
</style>

View File

@@ -1,15 +1,12 @@
<template> <template>
<div> <div>
<div class="version-info" @click="handleVersionClick"> <el-dialog v-model="show" width="522px" :close-on-click-modal="false" align-center
v{{ version || '-' }} :class="['update-dialog', `stage-${stage}`]"
<span v-if="hasNewVersion" class="update-badge"></span>
</div>
<el-dialog v-model="show" width="522px" :close-on-click-modal="false" align-center class="update-dialog"
:title="stage === 'downloading' ? `正在更新 ${appName}` : '软件更新'"> :title="stage === 'downloading' ? `正在更新 ${appName}` : '软件更新'">
<div v-if="stage === 'check'" class="update-content"> <div v-if="stage === 'check'" class="update-content">
<div class="update-layout"> <div class="update-layout">
<div class="left-pane"> <div class="left-pane">
<img src="/icon/icon.png" class="app-icon app-icon-large" alt="App Icon"/> <img src="/icon/icon1.png" class="app-icon app-icon-large" alt="App Icon"/>
</div> </div>
<div class="right-pane"> <div class="right-pane">
<p class="announce">新版本的"{{ appName }}"已经发布</p> <p class="announce">新版本的"{{ appName }}"已经发布</p>
@@ -44,11 +41,11 @@
<div v-else-if="stage === 'downloading'" class="update-content"> <div v-else-if="stage === 'downloading'" class="update-content">
<div class="download-main"> <div class="download-main">
<div class="download-icon"> <div class="download-icon">
<img src="/icon/icon.png" class="app-icon" alt="App Icon"/> <img src="/icon/icon1.png" class="app-icon" alt="App Icon"/>
</div> </div>
<div class="download-content"> <div class="download-content">
<div class="download-info"> <div class="download-info">
<p>正在下载更新</p> <p>正在下载安装...</p>
</div> </div>
<div class="download-progress"> <div class="download-progress">
<el-progress <el-progress
@@ -65,26 +62,30 @@
</div> </div>
</div> </div>
<div v-else-if="stage === 'completed'" class="update-content"> <div v-else-if="stage === 'completed'" class="update-content">
<div class="update-header text-center"> <div class="download-main">
<img src="/icon/icon.png" class="app-icon" alt="App Icon"/> <div class="download-icon">
<h3>更新完成</h3> <img src="/icon/icon1.png" class="app-icon" alt="App Icon"/>
<p>更新文件已下载将在重启后自动应用</p> </div>
</div> <div class="download-content">
<div class="download-info">
<div class="download-progress"> <p>可以开始安装了</p>
<div class="progress-info"> </div>
<span>{{ prog.current }} / {{ prog.total }}</span> <div class="download-progress">
<el-progress
:percentage="100"
:show-text="false"
:stroke-width="6"
color="#67C23A"/>
<div class="progress-details">
<span style="font-weight: 500" v-if="prog.current !== '0 MB' && prog.total !== '0 MB'">{{ prog.current }} / {{ prog.total }}</span>
<span style="font-weight: 500" v-else>下载完成</span>
<div class="action-buttons">
<el-button size="small" @click="clearDownloadedFiles">清除下载</el-button>
<el-button size="small" type="primary" @click="installUpdate">立即重启</el-button>
</div>
</div>
</div>
</div> </div>
<el-progress
:percentage="100"
:show-text="false"
:stroke-width="6"
color="#67C23A"/>
</div>
<div class="update-buttons">
<el-button @click="cancelDownload">稍后更新</el-button>
<el-button type="primary" @click="installUpdate">重启应用新版本</el-button>
</div> </div>
</div> </div>
</el-dialog> </el-dialog>
@@ -92,13 +93,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {ref, computed, onMounted, onUnmounted} from 'vue' import {ref, computed, onMounted, onUnmounted, watch} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus' import {ElMessage, ElMessageBox} from 'element-plus'
import {updateApi} from '../../api/update' import {updateApi} from '../../api/update'
import {getSettings} from '../../utils/settings'
import {getUsernameFromToken} from '../../utils/token'
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
defineExpose({ checkForUpdatesNow })
const show = computed({ const show = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (value) => emit('update:modelValue', value) set: (value) => emit('update:modelValue', value)
@@ -108,153 +113,137 @@ type Stage = 'check' | 'downloading' | 'completed'
const stage = ref<Stage>('check') const stage = ref<Stage>('check')
const appName = ref('我了个电商') const appName = ref('我了个电商')
const version = ref('') const version = ref('')
const hasNewVersion = ref(false) // 控制小红点显示 const prog = ref({percentage: 0, current: '0 MB', total: '0 MB'})
const prog = ref({percentage: 0, current: '0 MB', total: '0 MB', speed: ''})
const info = ref({ const info = ref({
latestVersion: '2.4.8', latestVersion: '',
downloadUrl: '', asarUrl: '',
updateNotes: '• 优化了用户界面体验\n• 修复了已知问题\n• 提升了系统稳定性\n• 增加了新的功能模块\n• 优化了数据处理性能', jarUrl: '',
currentVersion: '', updateNotes: '',
hasUpdate: false currentVersion: ''
}) })
const SKIP_VERSION_KEY = 'skipped_version' async function checkUpdate(silent = false) {
const REMIND_LATER_KEY = 'remind_later_time'
async function autoCheck(silent = false) {
try { try {
version.value = await (window as any).electronAPI.getJarVersion() version.value = await (window as any).electronAPI.getJarVersion()
const checkRes: any = await updateApi.checkUpdate(version.value) const result = (await updateApi.checkUpdate(version.value))?.data
const result = checkRes?.data || checkRes
info.value = {
currentVersion: result.currentVersion || version.value,
latestVersion: result.latestVersion || version.value,
asarUrl: result.asarUrl || '',
jarUrl: result.jarUrl || '',
updateNotes: result.updateNotes || ''
}
if (!result.needUpdate) { if (!result.needUpdate) {
hasNewVersion.value = false if (!silent) ElMessage.info('当前已是最新版本')
if (!silent) {
ElMessage.info('当前已是最新版本')
}
return return
} }
// 发现新版本,更新信息并显示小红点 if (localStorage.getItem('skipped_version') === result.latestVersion) return
info.value = {
currentVersion: result.currentVersion,
latestVersion: result.latestVersion,
downloadUrl: result.downloadUrl || '',
updateNotes: '• 优化了用户界面体验\n• 修复了已知问题\n• 提升了系统稳定性\n• 轻量级更新仅替换app.asar',
hasUpdate: true
}
hasNewVersion.value = true
// 检查是否跳过此版本 const remindTime = localStorage.getItem('remind_later_time')
const skippedVersion = localStorage.getItem(SKIP_VERSION_KEY) if (remindTime && Date.now() < parseInt(remindTime)) return
if (skippedVersion === result.latestVersion) {
// 跳过的版本:显示小红点,但不弹框 if (getSettings(getUsernameFromToken()).autoUpdate) {
await downloadUpdate()
return return
} }
// 检查是否在稍后提醒时间内
const remindLater = localStorage.getItem(REMIND_LATER_KEY)
if (remindLater && Date.now() < parseInt(remindLater)) {
// 稍后提醒期间:显示小红点,但不弹框
return
}
// 首次发现新版本:显示小红点并弹框
show.value = true show.value = true
stage.value = 'check' stage.value = 'check'
if (!silent) { if (!silent) ElMessage.success('发现新版本')
ElMessage.success('发现新版本')
}
} catch (error) { } catch (error) {
console.error('检查更新失败:', error) if (!silent) ElMessage.error('检查更新失败')
if (!silent) {
ElMessage.error('检查更新失败')
}
} }
} }
function handleVersionClick() { async function checkForUpdatesNow() {
// 如果有新版本,直接显示更新对话框 if (stage.value === 'downloading' || stage.value === 'completed') {
if (hasNewVersion.value) {
show.value = true show.value = true
stage.value = 'check' return
} else {
// 没有新版本,执行检查更新
autoCheck(false)
} }
await checkUpdate(false)
} }
function skipVersion() { function skipVersion() {
localStorage.setItem(SKIP_VERSION_KEY, info.value.latestVersion) localStorage.setItem('skipped_version', info.value.latestVersion)
show.value = false show.value = false
} }
function remindLater() { function remindLater() {
// 24小时后再提醒 localStorage.setItem('remind_later_time', (Date.now() + 24 * 60 * 60 * 1000).toString())
localStorage.setItem(REMIND_LATER_KEY, (Date.now() + 24 * 60 * 60 * 1000).toString())
show.value = false show.value = false
} }
async function start() { async function start() {
if (!info.value.downloadUrl) { if (stage.value !== 'check') {
ElMessage.error('下载链接不可用') show.value = true
return
}
await downloadUpdate(true)
}
async function downloadUpdate(showDialog = false) {
if (!info.value.asarUrl && !info.value.jarUrl) {
if (showDialog) ElMessage.error('下载链接不可用')
return return
} }
stage.value = 'downloading' stage.value = 'downloading'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB', speed: ''} if (showDialog) show.value = true
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
;(window as any).electronAPI.onDownloadProgress((progress: any) => { ;(window as any).electronAPI.onDownloadProgress((progress: any) => {
prog.value = { prog.value = {
percentage: progress.percentage || 0, percentage: progress.percentage || 0,
current: progress.current || '0 MB', current: progress.current || '0 MB',
total: progress.total || '0 MB', total: progress.total || '0 MB'
speed: progress.speed || ''
} }
}) })
try { try {
const response = await (window as any).electronAPI.downloadUpdate(info.value.downloadUrl) const response = await (window as any).electronAPI.downloadUpdate({
asarUrl: info.value.asarUrl,
jarUrl: info.value.jarUrl,
latestVersion: info.value.latestVersion
})
if (response.success) { if (response.success) {
stage.value = 'completed' stage.value = 'completed'
prog.value.percentage = 100 prog.value.percentage = 100
ElMessage.success('下载完成') show.value = true
ElMessage.success(showDialog ? '下载完成' : '更新已下载完成,可以安装了')
} else { } else {
ElMessage.error('下载失败: ' + (response.error || '未知错误'))
stage.value = 'check' stage.value = 'check'
if (showDialog) ElMessage.error('下载失败: ' + (response.error || '未知错误'))
;(window as any).electronAPI.removeDownloadProgressListener()
} }
} catch (error) { } catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败')
stage.value = 'check' stage.value = 'check'
if (showDialog) ElMessage.error('下载失败')
;(window as any).electronAPI.removeDownloadProgressListener()
} }
} }
async function cancelDownload() { async function cancelDownload() {
try { ;(window as any).electronAPI.removeDownloadProgressListener()
(window as any).electronAPI.removeDownloadProgressListener() await (window as any).electronAPI.cancelDownload().catch(() => {})
await (window as any).electronAPI.cancelDownload()
show.value = false stage.value = 'check'
stage.value = 'check' prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
} catch (error) { show.value = false
console.error('取消下载失败:', error) ElMessage.info('取消下载')
show.value = false
stage.value = 'check'
}
} }
async function installUpdate() { async function installUpdate() {
try { try {
await ElMessageBox.confirm( await ElMessageBox.confirm('安装过程中程序将自动重启,请确保已保存所有工作。确定要立即安装更新吗?', '确认安装', {
'安装过程中程序将自动重启,请确保已保存所有工作。确定要立即安装更新吗?', confirmButtonText: '立即安装',
'确认安装', cancelButtonText: '取消',
{ type: 'warning'
confirmButtonText: '立即安装', })
cancelButtonText: '取消',
type: 'warning'
}
)
const response = await (window as any).electronAPI.installUpdate() const response = await (window as any).electronAPI.installUpdate()
if (response.success) { if (response.success) {
ElMessage.success('应用即将重启') ElMessage.success('应用即将重启')
@@ -265,9 +254,40 @@ async function installUpdate() {
} }
} }
async function clearDownloadedFiles() {
try {
await ElMessageBox.confirm('确定要清除已下载的更新文件吗?清除后需要重新下载。', '确认清除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const response = await (window as any).electronAPI.clearUpdateFiles()
if (response.success) {
stage.value = 'check'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
show.value = false
ElMessage.success('已清除下载文件')
} else {
ElMessage.error('清除失败: ' + (response.error || '未知错误'))
}
} catch (error) {
if (error !== 'cancel') ElMessage.error('清除失败')
}
}
onMounted(async () => { onMounted(async () => {
version.value = await (window as any).electronAPI.getJarVersion() version.value = await (window as any).electronAPI.getJarVersion()
await autoCheck(true) const pendingUpdate = await (window as any).electronAPI.checkPendingUpdate()
if (pendingUpdate?.hasPendingUpdate) {
stage.value = 'completed'
prog.value.percentage = 100
return
}
await checkUpdate(true)
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -278,296 +298,58 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
.version-info { :deep(.update-dialog .el-dialog) {border-radius: 16px; box-shadow: 0 24px 48px rgba(0, 0, 0, 0.15);}
position: fixed; /* 通用标题样式 */
right: 10px; :deep(.update-dialog .el-dialog__title) {font-size: 14px; font-weight: 500; margin-left: 8px;}
bottom: 10px; /* 默认标题样式(第一阶段 - 检查阶段) */
background: rgba(255, 255, 255, 0.9); :deep(.update-dialog.stage-check .el-dialog__header) {display: block; text-align: left;}
padding: 5px 10px; /* 第二阶段 - 下载中,标题居中 */
border-radius: 4px; :deep(.update-dialog.stage-downloading .el-dialog__header) {display: block; text-align: center;}
font-size: 12px; :deep(.update-dialog.stage-downloading .el-dialog__title) {margin-left: 20px;}
color: #909399; /* 第三阶段 - 下载完成,标题居中 */
z-index: 1000; :deep(.update-dialog.stage-completed .el-dialog__header) {display: block; text-align: center;}
cursor: pointer; :deep(.update-dialog.stage-completed .el-dialog__title) {margin-left: 20px;}
user-select: none; :deep(.update-dialog .el-dialog__body) {padding: 0;}
} .update-content {text-align: left;}
.update-layout {display: grid; grid-template-columns: 88px 1fr; align-items: start; margin-bottom: 5px;}
.update-badge { .left-pane {display: flex; flex-direction: column; align-items: flex-start;}
position: absolute; .app-icon-large {width: 70px; height: 70px; border-radius: 12px; margin: 4px 0 0 0;}
top: -2px; .right-pane {min-width: 0;}
right: -2px; .right-pane .announce {font-size: 16px; font-weight: 600; color: #1f2937; margin: 4px 0 6px; word-break: break-word;}
width: 8px; .right-pane .desc {font-size: 13px; color: #6b7280; line-height: 1.6; margin: 0; word-break: break-word;}
height: 8px; .update-header {display: flex; align-items: flex-start; margin-bottom: 24px;}
background: #f56c6c; .update-header.text-center {text-align: center; flex-direction: column; align-items: center;}
border-radius: 50%; .app-icon {width: 70px; height: 70px; border-radius: 12px; margin-right: 16px; flex-shrink: 0;}
border: 2px solid #fff; .update-header.text-center .app-icon {margin-right: 0; margin-bottom: 16px;}
animation: pulse 2s ease-in-out infinite; .update-header h3 {font-size: 20px; font-weight: 600; margin: 16px 0 8px 0; color: #1f2937;}
} .update-header p {font-size: 14px; color: #6b7280; margin: 0; line-height: 1.5;}
.update-details {border-radius: 8px; padding: 0; margin: 12px 0 8px 0;}
@keyframes pulse { .update-details.form {max-height: none;}
0%, 100% { .notes-box :deep(textarea.el-textarea__inner) {white-space: pre-wrap;}
transform: scale(1); .update-details h4 {font-size: 14px; font-weight: 600; color: #374151; margin: 0 0 8px 0;}
opacity: 1; .update-actions.row {display: flex; flex-direction: column; align-items: stretch; gap: 12px;}
} .update-buttons {display: flex; justify-content: space-between; gap: 12px;}
50% { .update-actions.row .update-buttons {justify-content: space-between;}
transform: scale(1.1); :deep(.update-actions.row .update-buttons .el-button) {flex: none; min-width: 100px;}
opacity: 0.8; .left-actions {display: flex; gap: 12px;}
} .right-actions {display: flex; gap: 8px;}
} :deep(.update-buttons .el-button) {flex: 1; height: 32px; font-size: 13px; border-radius: 8px;}
.download-header h3 {font-size: 14px; font-weight: 500; margin: 0; color: #1f2937;}
:deep(.update-dialog .el-dialog) { .download-main {display: grid; grid-template-columns: 80px 1fr; align-items: start;}
border-radius: 16px; .download-icon {display: flex; justify-content: center;}
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.15); .download-icon .app-icon {width: 64px; height: 64px; border-radius: 12px;}
} .download-content {min-width: 0;}
.download-info {margin-bottom: 12px;}
:deep(.update-dialog .el-dialog__header) { .download-info p {font-size: 14px; font-weight: 600; color: #6b7280; margin: 0;}
display: block; .download-progress {margin: 0;}
text-align: left; .progress-info {display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 14px; color: #6b7280;}
} .progress-details {margin-top: 12px; display: flex; justify-content: space-between; align-items: center;}
.progress-details span {font-size: 12px; color: #909399;}
:deep(.update-dialog .el-dialog__body) { .action-buttons {display: flex; gap: 8px;}
padding: 0; :deep(.el-progress-bar__outer) {border-radius: 4px; background-color: #e5e7eb;}
} :deep(.el-progress-bar__inner) {border-radius: 4px; transition: width 0.3s ease;}
:deep(.update-buttons .el-button--primary) {background-color: #2563eb; border-color: #2563eb; font-weight: 500;}
.update-content { :deep(.update-buttons .el-button--primary:hover) {background-color: #1d4ed8; border-color: #1d4ed8;}
text-align: left; :deep(.update-buttons .el-button:not(.el-button--primary)) {background-color: #f3f4f6; border-color: #d1d5db; color: #374151; font-weight: 500;}
} :deep(.update-buttons .el-button:not(.el-button--primary):hover) {background-color: #e5e7eb; border-color: #9ca3af;}
.update-layout {
display: grid;
grid-template-columns: 88px 1fr;
align-items: start;
margin-bottom: 5px;
}
.left-pane {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.app-icon-large {
width: 70px;
height: 70px;
border-radius: 12px;
margin: 4px 0 0 0;
}
.right-pane {
min-width: 0;
}
.right-pane .announce {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin: 4px 0 6px;
word-break: break-word;
}
.right-pane .desc {
font-size: 13px;
color: #6b7280;
line-height: 1.6;
margin: 0;
word-break: break-word;
}
.update-header {
display: flex;
align-items: flex-start;
margin-bottom: 24px;
}
.update-header.text-center {
text-align: center;
flex-direction: column;
align-items: center;
}
.app-icon {
width: 70px;
height: 70px;
border-radius: 12px;
margin-right: 16px;
flex-shrink: 0;
}
.update-header.text-center .app-icon {
margin-right: 0;
margin-bottom: 16px;
}
.update-header h3 {
font-size: 20px;
font-weight: 600;
margin: 16px 0 8px 0;
color: #1f2937;
}
.update-header p {
font-size: 14px;
color: #6b7280;
margin: 0;
line-height: 1.5;
}
.update-details {
border-radius: 8px;
padding: 0;
margin: 12px 0 8px 0;
}
.update-details.form {
max-height: none;
}
.notes-box :deep(textarea.el-textarea__inner) {
white-space: pre-wrap;
}
.update-details h4 {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 0 0 8px 0;
}
.update-actions.row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.update-buttons {
display: flex;
justify-content: space-between;
gap: 12px;
}
.update-actions.row .update-buttons {
justify-content: space-between;
}
:deep(.update-actions.row .update-buttons .el-button) {
flex: none;
min-width: 100px;
}
.left-actions {
display: flex;
gap: 12px;
}
.right-actions {
display: flex;
gap: 8px;
}
:deep(.update-buttons .el-button) {
flex: 1;
height: 32px;
font-size: 13px;
border-radius: 8px;
}
.download-header h3 {
font-size: 14px;
font-weight: 500;
margin: 0;
color: #1f2937;
}
.download-main {
display: grid;
grid-template-columns: 80px 1fr;
align-items: start;
}
.download-icon {
display: flex;
justify-content: center;
}
.download-icon .app-icon {
width: 64px;
height: 64px;
border-radius: 12px;
}
.download-content {
min-width: 0;
}
.download-info {
margin-bottom: 12px;
}
.download-info p {
font-size: 14px;
font-weight: 600;
color: #6b7280;
margin: 0;
}
.download-progress {
margin: 0;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
color: #6b7280;
}
.progress-details {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-details span {
font-size: 12px;
color: #909399;
}
:deep(.el-progress-bar__outer) {
border-radius: 4px;
background-color: #e5e7eb;
}
:deep(.el-progress-bar__inner) {
border-radius: 4px;
transition: width 0.3s ease;
}
:deep(.update-buttons .el-button--primary) {
background-color: #2563eb;
border-color: #2563eb;
font-weight: 500;
}
:deep(.update-buttons .el-button--primary:hover) {
background-color: #1d4ed8;
border-color: #1d4ed8;
}
:deep(.update-buttons .el-button:not(.el-button--primary)) {
background-color: #f3f4f6;
border-color: #d1d5db;
color: #374151;
font-weight: 500;
}
:deep(.update-buttons .el-button:not(.el-button--primary):hover) {
background-color: #e5e7eb;
border-color: #9ca3af;
}
</style> </style>

View File

@@ -1,23 +1,73 @@
<script setup lang="ts"> <script setup lang="ts">
import { ArrowLeft, ArrowRight, Refresh, Monitor, Setting, User } from '@element-plus/icons-vue' import { computed, ref, onMounted } from 'vue'
import { Refresh, Setting, Minus, CloseBold } from '@element-plus/icons-vue'
interface Props { interface Props {
canGoBack: boolean canGoBack: boolean
canGoForward: boolean canGoForward: boolean
activeMenu: string activeMenu: string
isAuthenticated: boolean
currentUsername: string
currentVersion: string
} }
interface Emits { interface Emits {
(e: 'go-back'): void (e: 'go-back'): void
(e: 'go-forward'): void (e: 'go-forward'): void
(e: 'reload'): void (e: 'reload'): void
(e: 'user-click'): void (e: 'logout'): void
(e: 'open-device'): void (e: 'open-device'): void
(e: 'open-settings'): void (e: 'open-settings'): void
(e: 'open-account-manager'): void
(e: 'check-update'): void
} }
defineProps<Props>() const props = defineProps<Props>()
defineEmits<Emits>() const emit = defineEmits<Emits>()
const isMaximized = ref(false)
const displayUsername = computed(() => {
return props.isAuthenticated ? props.currentUsername : '未登录'
})
const menuItems = [
{ command: 'check-update', label: computed(() => `检查更新 v${props.currentVersion}`), class: 'menu-item' },
{ command: 'account-manager', label: '我的电商账号', class: 'menu-item' },
{ command: 'device', label: '我的设备', class: 'menu-item' },
{ command: 'settings', label: '设置', class: 'menu-item' },
{ command: 'logout', label: '退出', class: 'menu-item logout-item', showIf: () => props.isAuthenticated }
]
async function handleMinimize() {
await (window as any).electronAPI.windowMinimize()
}
async function handleMaximize() {
await (window as any).electronAPI.windowMaximize()
isMaximized.value = await (window as any).electronAPI.windowIsMaximized()
}
async function handleClose() {
await (window as any).electronAPI.windowClose()
}
const commandMap: Record<string, keyof Emits> = {
logout: 'logout',
device: 'open-device',
settings: 'open-settings',
'account-manager': 'open-account-manager',
'check-update': 'check-update'
}
function handleCommand(command: string) {
const emitName = commandMap[command]
if (emitName) emit(emitName as any)
}
onMounted(async () => {
isMaximized.value = await (window as any).electronAPI.windowIsMaximized()
})
</script> </script>
<template> <template>
@@ -25,149 +75,109 @@ defineEmits<Emits>()
<div class="navbar-left"> <div class="navbar-left">
<div class="nav-controls"> <div class="nav-controls">
<button class="nav-btn" title="后退" @click="$emit('go-back')" :disabled="!canGoBack"> <button class="nav-btn" title="后退" @click="$emit('go-back')" :disabled="!canGoBack">
<el-icon><ArrowLeft /></el-icon> <svg viewBox="0 0 24 24" class="arrow-icon">
<path d="M15 18l-6-6 6-6" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button> </button>
<button class="nav-btn" title="前进" @click="$emit('go-forward')" :disabled="!canGoForward"> <button class="nav-btn" title="前进" @click="$emit('go-forward')" :disabled="!canGoForward">
<el-icon><ArrowRight /></el-icon> <svg viewBox="0 0 24 24" class="arrow-icon">
<path d="M9 18l6-6-6-6" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button> </button>
</div> </div>
<button class="nav-btn-round" title="刷新" @click="$emit('reload')">
<el-icon><Refresh /></el-icon>
</button>
<!-- 设置下拉菜单 -->
<el-dropdown trigger="click" @command="handleCommand" placement="bottom-end">
<button class="nav-btn-round" title="设置">
<el-icon><Setting /></el-icon>
</button>
<template #dropdown>
<el-dropdown-menu class="settings-dropdown">
<el-dropdown-item disabled class="username-item">
{{ displayUsername }}
</el-dropdown-item>
<el-dropdown-item
v-for="item in menuItems"
:key="item.command"
v-show="!item.showIf || item.showIf()"
:command="item.command"
:class="item.class">
{{ typeof item.label === 'string' ? item.label : item.label.value }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div> </div>
<div class="navbar-center"> <div class="navbar-center">
<div class="breadcrumbs"> <div class="breadcrumbs">
<span>首页</span> <span>首页</span>
<span class="separator">></span> <span class="separator">/</span>
<span>{{ activeMenu }}</span> <span>{{ activeMenu }}</span>
</div> </div>
</div> </div>
<div class="navbar-right"> <div class="navbar-right">
<button class="nav-btn-round" title="刷新" @click="$emit('reload')"> <!-- 窗口控制按钮 -->
<el-icon><Refresh /></el-icon> <div class="window-controls">
</button> <button class="window-btn window-btn-minimize" title="最小化" @click="handleMinimize">
<button class="nav-btn-round" title="设备管理" @click="$emit('open-device')"> <el-icon><Minus /></el-icon>
<el-icon><Monitor /></el-icon> </button>
</button> <button class="window-btn window-btn-maximize" title="最大化" @click="handleMaximize">
<button class="nav-btn-round" title="设置" @click="$emit('open-settings')"> <svg viewBox="0 0 12 12" class="maximize-icon">
<el-icon><Setting /></el-icon> <rect x="2" y="2" width="8" height="8" stroke="currentColor" fill="none" stroke-width="1"/>
</button> </svg>
<button class="nav-btn-round" title="用户" @click="$emit('user-click')"> </button>
<el-icon><User /></el-icon> <button class="window-btn window-btn-close" title="关闭" @click="handleClose">
</button> <el-icon><CloseBold /></el-icon>
</button>
</div>
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.top-navbar { .top-navbar {height: 40px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; background: #ffffff; border-bottom: 1px solid #e8eaec; box-shadow: 0 1px 3px rgba(0,0,0,0.03); -webkit-app-region: drag; user-select: none;}
height: 40px; .navbar-left {display: flex; align-items: center; gap: 8px; flex: 0 0 auto; -webkit-app-region: no-drag;}
display: flex; .navbar-center {display: flex; justify-content: center; flex: 1;}
align-items: center; .navbar-right {display: flex; align-items: center; gap: 8px; flex: 0 0 auto; -webkit-app-region: no-drag;}
justify-content: space-between; .nav-controls {display: flex; gap: 4px;}
padding: 0 16px; .nav-btn {width: 28px; height: 28px; border: none; background: transparent; cursor: pointer; font-size: 16px; color: #606266; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; outline: none; border-radius: 4px;}
background: #ffffff; .arrow-icon {width: 18px; height: 18px; flex-shrink: 0;}
border-bottom: 1px solid #e8eaec; .arrow-icon path {stroke: currentColor;}
box-shadow: 0 1px 3px rgba(0,0,0,0.03); .nav-btn:hover:not(:disabled) {background: rgba(0, 0, 0, 0.05); color: #409EFF;}
} .nav-btn:hover:not(:disabled) .arrow-icon path {stroke: #409EFF;}
.navbar-left {
display: flex;
align-items: center;
flex: 0 0 auto;
}
.navbar-center {
display: flex;
justify-content: center;
flex: 1;
}
.navbar-right {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.nav-controls {
display: flex;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.nav-btn {
width: 32px;
height: 28px;
border: none;
background: #fff;
cursor: pointer;
font-size: 14px;
color: #606266;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
outline: none;
}
.nav-btn:hover:not(:disabled) {
background: #f5f7fa;
color: #409EFF;
}
.nav-btn:focus, .nav-btn:focus,
.nav-btn:active { .nav-btn:active {outline: none; border: none;}
outline: none; .nav-btn:disabled {cursor: not-allowed; background: transparent; color: #d0d0d0;}
border: none; .nav-btn:disabled .arrow-icon path {stroke: #d0d0d0;}
} .nav-btn-round {width: 28px; height: 28px; border: none; border-radius: 4px; background: transparent; cursor: pointer; font-size: 16px; color: #606266; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; outline: none;}
.nav-btn-round:hover {background: rgba(0, 0, 0, 0.05); color: #409EFF;}
.nav-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
background: #f5f5f5;
color: #c0c4cc;
}
.nav-btn:not(:last-child) {
border-right: 1px solid #dcdfe6;
}
.nav-btn-round {
width: 28px;
height: 28px;
border: 1px solid #dcdfe6;
border-radius: 50%;
background: #fff;
cursor: pointer;
font-size: 12px;
color: #606266;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
outline: none;
}
.nav-btn-round:hover {
background: #f5f7fa;
color: #409EFF;
border-color: #c6e2ff;
}
.nav-btn-round:focus, .nav-btn-round:focus,
.nav-btn-round:active { .nav-btn-round:active {outline: none;}
outline: none; .breadcrumbs {display: flex; align-items: center; color: #606266; font-size: 14px;}
} .separator {margin: 0 6px; color: #c0c4cc; font-size: 14px;}
/* 窗口控制按钮 */
.breadcrumbs { .window-controls {display: flex; align-items: center; gap: 4px; margin-left: 8px;}
display: flex; .window-btn {width: 32px; height: 28px; border: none; background: transparent; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 14px; color: #606266; transition: all 0.2s ease; outline: none; padding: 0; margin: 0; border-radius: 4px;}
align-items: center; .window-btn:hover {background: rgba(0, 0, 0, 0.05);}
color: #606266; .window-btn:active {background: rgba(0, 0, 0, 0.1);}
font-size: 14px; .window-btn-close:hover {background: #e81123; color: #ffffff;}
} .window-btn-close:active {background: #f1707a;}
.maximize-icon {width: 12px; height: 12px;}
.separator { /* 登录/注册按钮 */
margin: 0 8px; </style>
color: #c0c4cc;
font-size: 12px; <style>
} /* 设置下拉菜单样式 */
.settings-dropdown {min-width: 180px !important; padding: 4px 0 !important; border-radius: 12px !important; margin-top: 4px !important;}
.settings-dropdown .username-item {font-weight: 600 !important; color: #000000 !important; cursor: default !important; padding: 8px 16px !important; font-size: 14px !important;}
.settings-dropdown .menu-item {padding: 8px 16px !important; font-size: 13px !important; color: #000000 !important; transition: all 0.2s ease !important;}
.settings-dropdown .menu-item:hover {background: #f5f7fa !important; color: #409EFF !important;}
.settings-dropdown .logout-item {color: #000000 !important;}
.settings-dropdown .logout-item:hover {background: #f5f7fa !important; color: #409EFF !important;}
.settings-dropdown .el-dropdown-menu__item.is-disabled {cursor: default !important; opacity: 1 !important;}
</style> </style>

View File

@@ -1,9 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import {ref, computed, onMounted} from 'vue' import {ref, computed, onMounted, defineAsyncComponent, inject} from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import {rakutenApi} from '../../api/rakuten' import {rakutenApi} from '../../api/rakuten'
import { batchConvertImages } from '../../utils/imageProxy' import { batchConvertImages } from '../../utils/imageProxy'
import { handlePlatformFileExport } from '../../utils/settings' import { handlePlatformFileExport } from '../../utils/settings'
import { getUsernameFromToken } from '../../utils/token'
import { useFileDrop } from '../../composables/useFileDrop'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
// 接收VIP状态 // 接收VIP状态
const props = defineProps<{ const props = defineProps<{
@@ -16,12 +22,12 @@ const tableLoading = ref(false)
const exportLoading = ref(false) const exportLoading = ref(false)
const statusMessage = ref('') const statusMessage = ref('')
const statusType = ref<'info' | 'success' | 'warning' | 'error'>('info') const statusType = ref<'info' | 'success' | 'warning' | 'error'>('info')
let abortController: AbortController | null = null
// 查询与上传 // 查询与上传
const singleShopName = ref('') const singleShopName = ref('')
const currentBatchId = ref('') const currentBatchId = ref('')
const uploadInputRef = ref<HTMLInputElement | null>(null) const uploadInputRef = ref<HTMLInputElement | null>(null)
const dragActive = ref(false)
// 数据与分页 // 数据与分页
const allProducts = ref<any[]>([]) const allProducts = ref<any[]>([])
@@ -49,6 +55,12 @@ const activeStep = computed(() => {
return 2 return 2
}) })
// 试用期过期弹框
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both' | 'subscribe'>('account')
const vipStatus = inject<any>('vipStatus')
// 左侧:上传文件名与地区 // 左侧:上传文件名与地区
const selectedFileName = ref('') const selectedFileName = ref('')
const pendingFile = ref<File | null>(null) const pendingFile = ref<File | null>(null)
@@ -124,16 +136,28 @@ async function loadLatest() {
allProducts.value = products.map((p: any) => ({...p, skuPrices: parseSkuPrices(p)})) allProducts.value = products.map((p: any) => ({...p, skuPrices: parseSkuPrices(p)}))
} }
async function searchProductInternal(product: any) { function hasValid1688Data(data: any) {
if (!product || !product.imgUrl) return if (!data) return false
if (!needsSearch(product)) return const skuJson = data.skuPriceJson || data.skuPrice
if (!props.isVip) { const prices = parseSkuPrices({ skuPriceJson: skuJson })
ElMessage.warning('VIP已过期1688识图功能受限') if (!data.mapRecognitionLink) return false
return if (!Array.isArray(prices) || !prices.length) return false
} if (!data.freight || data.freight <= 0) return false
if (!data.median || data.median <= 0) return false
return true
}
const res: any = await rakutenApi.search1688(product.imgUrl, currentBatchId.value) async function searchProductInternal(product: any) {
if (!product || !product.imgUrl) return false
if (!needsSearch(product)) return true
if (!props.isVip) {
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
showTrialExpiredDialog.value = true
return false
}
const res: any = await rakutenApi.search1688(product.imgUrl, currentBatchId.value, abortController?.signal)
const data = res.data const data = res.data
if (!hasValid1688Data(data)) return false
const skuJson = data.skuPriceJson || data.skuPrice const skuJson = data.skuPriceJson || data.skuPrice
Object.assign(product, { Object.assign(product, {
mapRecognitionLink: data.mapRecognitionLink, mapRecognitionLink: data.mapRecognitionLink,
@@ -146,6 +170,21 @@ async function searchProductInternal(product: any) {
image1688Url: data.mapRecognitionLink, image1688Url: data.mapRecognitionLink,
detailUrl1688: data.mapRecognitionLink, detailUrl1688: data.mapRecognitionLink,
}) })
return true
}
async function searchProductWithRetry(product: any, maxRetry = 2) {
for (let attempt = 1; attempt <= maxRetry; attempt++) {
try {
const ok = await searchProductInternal(product)
if (ok) return true
} catch (e: any) {
if (e.name === 'AbortError') return false
console.warn('search1688 failed', e)
}
if (attempt < maxRetry) await delay(600)
}
return false
} }
function beforeUpload(file: File) { function beforeUpload(file: File) {
@@ -185,35 +224,28 @@ async function handleExcelUpload(e: Event) {
input.value = '' input.value = ''
} }
function onDragOver(e: DragEvent) { e.preventDefault(); dragActive.value = true } // 拖拽上传
function onDragLeave() { dragActive.value = false } const { dragActive, onDragEnter, onDragOver, onDragLeave, onDrop } = useFileDrop({
async function onDrop(e: DragEvent) { accept: /\.xlsx?$/i,
e.preventDefault() onFile: processFile,
dragActive.value = false onError: (msg) => ElMessage({ message: msg, type: 'warning' })
const file = e.dataTransfer?.files?.[0] })
if (!file) return
await processFile(file)
}
// 点击"获取数据 // 点击"获取数据
async function handleStartSearch() { async function handleStartSearch() {
// 刷新VIP状态
if (refreshVipStatus) await refreshVipStatus()
// VIP检查 // VIP检查
if (!props.isVip) { if (!props.isVip) {
try { if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
await ElMessageBox.confirm( showTrialExpiredDialog.value = true
'VIP已过期数据采集功能受限。请联系管理员续费后继续使用。',
'VIP功能限制',
{
confirmButtonText: '我知道了',
showCancelButton: false,
type: 'warning'
}
)
} catch {}
return return
} }
abortController = new AbortController()
if (pendingFile.value) { if (pendingFile.value) {
try { try {
loading.value = true loading.value = true
@@ -225,7 +257,7 @@ async function handleStartSearch() {
progressPercentage.value = 0 progressPercentage.value = 0
totalProducts.value = 0 totalProducts.value = 0
processedProducts.value = 0 processedProducts.value = 0
const resp: any = await rakutenApi.getProducts({file: pendingFile.value, batchId: currentBatchId.value}) const resp: any = await rakutenApi.getProducts({file: pendingFile.value, batchId: currentBatchId.value}, abortController?.signal)
const products = (resp.data.products || []).map((p: any) => ({...p, skuPrices: parseSkuPrices(p)})) const products = (resp.data.products || []).map((p: any) => ({...p, skuPrices: parseSkuPrices(p)}))
if (products.length === 0) { if (products.length === 0) {
@@ -233,10 +265,11 @@ async function handleStartSearch() {
} }
allProducts.value = products allProducts.value = products
pendingFile.value = null } catch (e: any) {
} catch (e) { if (e.name !== 'AbortError') {
statusType.value = 'error' statusType.value = 'error'
statusMessage.value = '解析失败,请重试' statusMessage.value = '解析失败,请重试'
}
} finally { } finally {
loading.value = false loading.value = false
tableLoading.value = false tableLoading.value = false
@@ -250,17 +283,21 @@ async function handleStartSearch() {
progressPercentage.value = 100 progressPercentage.value = 100
statusType.value = 'success' statusType.value = 'success'
statusMessage.value = '' statusMessage.value = ''
abortController = null
return return
} }
if (items.length === 0) { if (items.length === 0) {
statusType.value = 'warning' statusType.value = 'warning'
statusMessage.value = '没有可处理的商品,请先导入或查询店铺' statusMessage.value = '没有可处理的商品,请先导入或查询店铺'
abortController = null
return return
} }
await startBatch1688Search(items) await startBatch1688Search(items)
} }
function stopTask() { function stopTask() {
abortController?.abort()
abortController = null
loading.value = false loading.value = false
tableLoading.value = false tableLoading.value = false
statusType.value = 'warning' statusType.value = 'warning'
@@ -275,6 +312,7 @@ async function startBatch1688Search(products: any[]) {
progressPercentage.value = 100 progressPercentage.value = 100
statusType.value = 'success' statusType.value = 'success'
statusMessage.value = '所有商品都已获取1688数据' statusMessage.value = '所有商品都已获取1688数据'
abortController = null
return return
} }
loading.value = true loading.value = true
@@ -293,6 +331,7 @@ async function startBatch1688Search(products: any[]) {
statusMessage.value = '' statusMessage.value = ''
} }
loading.value = false loading.value = false
abortController = null
} }
async function serialSearch1688(products: any[]) { async function serialSearch1688(products: any[]) {
@@ -300,7 +339,7 @@ async function serialSearch1688(products: any[]) {
const product = products[i] const product = products[i]
product.searching1688 = true product.searching1688 = true
await nextTickSafe() await nextTickSafe()
await searchProductInternal(product) await searchProductWithRetry(product)
product.searching1688 = false product.searching1688 = false
processedProducts.value++ processedProducts.value++
progressPercentage.value = Math.floor((processedProducts.value / Math.max(1, totalProducts.value)) * 100) progressPercentage.value = Math.floor((processedProducts.value / Math.max(1, totalProducts.value)) * 100)
@@ -316,7 +355,6 @@ function delay(ms: number) {
} }
function nextTickSafe() { function nextTickSafe() {
// 不额外引入 nextTick使用微任务刷新即可保持体积精简
return Promise.resolve() return Promise.resolve()
} }
@@ -325,6 +363,14 @@ function showMessage(message: string, type: 'info' | 'success' | 'warning' | 'er
ElMessage({ message, type }) ElMessage({ message, type })
} }
function removeSelectedFile() {
selectedFileName.value = ''
pendingFile.value = null
if (uploadInputRef.value) {
uploadInputRef.value.value = ''
}
}
async function exportToExcel() { async function exportToExcel() {
if (!allProducts.value.length) { if (!allProducts.value.length) {
showMessage('没有数据可供导出', 'warning') showMessage('没有数据可供导出', 'warning')
@@ -382,12 +428,10 @@ async function exportToExcel() {
base64: base64Data, base64: base64Data,
extension: 'jpeg', extension: 'jpeg',
}) })
worksheet.addImage(imageId, { worksheet.addImage(imageId, {
tl: { col: 1, row: row.number - 1 }, tl: { col: 1, row: row.number - 1 },
ext: { width: 60, height: 60 } ext: { width: 60, height: 60 }
}) })
row.height = 50 row.height = 50
} }
} }
@@ -398,9 +442,8 @@ async function exportToExcel() {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}) })
const fileName = `乐天商品数据_${new Date().toISOString().slice(0, 10)}.xlsx` const fileName = `乐天商品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
const username = getUsernameFromToken()
const success = await handlePlatformFileExport('rakuten', blob, fileName) const success = await handlePlatformFileExport('rakuten', blob, fileName, username)
if (success) { if (success) {
showMessage('Excel文件导出成功', 'success') showMessage('Excel文件导出成功', 'success')
} }
@@ -416,7 +459,6 @@ onMounted(loadLatest)
</script> </script>
<template> <template>
<div class="rakuten-root"> <div class="rakuten-root">
<div class="main-container"> <div class="main-container">
<div class="body-layout"> <div class="body-layout">
<!-- 左侧步骤栏 --> <!-- 左侧步骤栏 -->
@@ -438,7 +480,7 @@ onMounted(loadLatest)
<a class="link" @click.prevent="downloadRakutenTemplate">点击下载模板</a> <a class="link" @click.prevent="downloadRakutenTemplate">点击下载模板</a>
</div> </div>
<div class="dropzone" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openRakutenUpload" :class="{ disabled: loading }"> <div class="dropzone" :class="{ disabled: loading, active: dragActive }" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openRakutenUpload">
<div class="dz-el-icon">📤</div> <div class="dz-el-icon">📤</div>
<div class="dz-text">点击或将文件拖拽到这里上传</div> <div class="dz-text">点击或将文件拖拽到这里上传</div>
<div class="dz-sub">支持 .xls .xlsx</div> <div class="dz-sub">支持 .xls .xlsx</div>
@@ -447,6 +489,7 @@ onMounted(loadLatest)
<div v-if="selectedFileName" class="file-chip"> <div v-if="selectedFileName" class="file-chip">
<span class="dot"></span> <span class="dot"></span>
<span class="name">{{ selectedFileName }}</span> <span class="name">{{ selectedFileName }}</span>
<span class="delete-btn" @click="removeSelectedFile" title="删除文件">🗑</span>
</div> </div>
</div> </div>
</div> </div>
@@ -457,7 +500,7 @@ onMounted(loadLatest)
<div class="step-header"> <div class="step-header">
<div class="title">网站地区</div> <div class="title">网站地区</div>
</div> </div>
<div class="desc">请选择目标网站地区日本区</div> <div class="desc">仅支持乐天市场日本区商品查询后续将开放更多乐天网站地区敬请期待</div>
<el-select v-model="region" placeholder="选择地区" size="small" style="width: 100%" disabled> <el-select v-model="region" placeholder="选择地区" size="small" style="width: 100%" disabled>
<el-option v-for="opt in regionOptions" :key="opt.value" :label="opt.label" :value="opt.value"> <el-option v-for="opt in regionOptions" :key="opt.value" :label="opt.label" :value="opt.value">
<span style="margin-right:6px">{{ opt.flag }}</span>{{ opt.label }} <span style="margin-right:6px">{{ opt.flag }}</span>{{ opt.label }}
@@ -490,12 +533,8 @@ onMounted(loadLatest)
</div> </div>
<div class="desc">点击下方按钮导出所有商品数据到 Excel 文件</div> <div class="desc">点击下方按钮导出所有商品数据到 Excel 文件</div>
<el-button size="small" class="w100 btn-blue" :disabled="!allProducts.length || loading || exportLoading" :loading="exportLoading" @click="exportToExcel">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button> <el-button size="small" class="w100 btn-blue" :disabled="!allProducts.length || loading || exportLoading" :loading="exportLoading" @click="exportToExcel">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
<!-- 导出进度条 -->
</div> </div>
</div> </div>
</div> </div>
</aside> </aside>
@@ -509,6 +548,9 @@ onMounted(loadLatest)
<el-button type="primary" class="btn-blue" @click="rakutenExampleVisible = false">我知道了</el-button> <el-button type="primary" class="btn-blue" @click="rakutenExampleVisible = false">我知道了</el-button>
</template> </template>
</el-dialog> </el-dialog>
<!-- 试用期过期弹框 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
<!-- 数据显示区域 --> <!-- 数据显示区域 -->
<div class="table-container"> <div class="table-container">
<div class="table-section"> <div class="table-section">
@@ -552,7 +594,7 @@ onMounted(loadLatest)
</td> </td>
<td> <td>
<div class="image-container" v-if="row.imgUrl"> <div class="image-container" v-if="row.imgUrl">
<img :src="row.imgUrl" class="thumb" alt="thumb"/> <el-image :src="row.imgUrl" class="thumb" fit="contain" :preview-src-list="[row.imgUrl]" />
</div> </div>
<span v-else>无图片</span> <span v-else>无图片</span>
</td> </td>
@@ -588,9 +630,8 @@ onMounted(loadLatest)
</div> </div>
</div> </div>
<div class="pagination-fixed" > <div class="pagination-fixed">
<el-pagination <el-pagination
background
:current-page="currentPage" :current-page="currentPage"
:page-sizes="[15,30,50,100]" :page-sizes="[15,30,50,100]"
:page-size="pageSize" :page-size="pageSize"
@@ -608,227 +649,101 @@ onMounted(loadLatest)
</template> </template>
<style scoped> <style scoped>
.rakuten-root { .rakuten-root {position: absolute; inset: 0; background: #fff; box-sizing: border-box;}
position: absolute; .main-container {height: 100%; display: flex; flex-direction: column; padding: 12px; box-sizing: border-box;}
inset: 0; .body-layout {display: flex; gap: 12px; height: 100%;}
background: #f5f5f5; .steps-sidebar {width: 220px; background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 10px; height: 100%; flex-shrink: 0;}
padding: 12px; .steps-title {font-size: 14px; font-weight: 600; color: #303133; text-align: left;}
box-sizing: border-box;
}
.main-container {
background: #fff;
border-radius: 4px;
padding: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
height: 100%;
display: flex;
flex-direction: column;
}
.body-layout { display: flex; gap: 12px; height: 100%; }
.steps-sidebar { width: 220px; background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 10px; height: 100%; flex-shrink: 0; }
.steps-title { font-size: 14px; font-weight: 600; color: #303133; text-align: left; }
/* 卡片式步骤,与示例一致 */ /* 卡片式步骤,与示例一致 */
.steps-flow { position: relative; } .steps-flow {position: relative;}
.steps-flow:before { content: ''; position: absolute; left: 11px; top: 20px; bottom: 0; width: 1px; background: rgba(229, 231, 235, 0.6); } .steps-flow:before {content: ''; position: absolute; left: 11px; top: 20px; bottom: 0; width: 1px; background: rgba(229, 231, 235, 0.6);}
.flow-item { position: relative; display: grid; grid-template-columns: 22px 1fr; gap: 10px; padding: 8px 0; } .flow-item {position: relative; display: grid; grid-template-columns: 22px 1fr; gap: 10px; padding: 8px 0;}
.flow-item + .flow-item { border-top: 1px dashed #ebeef5; } .flow-item .step-index {position: static; width: 22px; height: 22px; line-height: 22px; text-align: center; border-radius: 50%; background: #1677FF; color: #fff; font-size: 12px; font-weight: 600; margin-top: 2px;}
.flow-item .step-index { position: static; width: 22px; height: 22px; line-height: 22px; text-align: center; border-radius: 50%; background: #1677FF; color: #fff; font-size: 12px; font-weight: 600; margin-top: 2px; } .flow-item:after {display: none;}
.flow-item:after { display: none; } .step-card {border: none; border-radius: 0; padding: 0; background: transparent; min-width: 0;}
.step-card { border: none; border-radius: 0; padding: 0; background: transparent; } .step-header {display: flex; align-items: center; gap: 8px; margin-bottom: 6px;}
.step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .title {font-size: 13px; font-weight: 600; color: #303133; text-align: left;}
.title { font-size: 13px; font-weight: 600; color: #303133; text-align: left; } .desc {font-size: 12px; color: #909399; margin-bottom: 8px; text-align: left;}
.desc { font-size: 12px; color: #909399; margin-bottom: 8px; text-align: left; } .mini-hint {font-size: 12px; color: #909399; margin-top: 8px; text-align: left;}
.mini-hint { font-size: 12px; color: #909399; margin-top: 8px; text-align: left; } .links {display: flex; align-items: center; gap: 6px; margin-bottom: 8px;}
.links { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; } .link {color: #409EFF; cursor: pointer; font-size: 12px;}
.link { color: #409EFF; cursor: pointer; font-size: 12px; } .sep {color: #dcdfe6;}
.sep { color: #dcdfe6; } .content-panel {flex: 1; display: flex; flex-direction: column; min-width: 0;}
.left-controls {margin-top: 10px; display: flex; flex-direction: column; gap: 10px;}
.content-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; } .dropzone {border: 1px dashed #c0c4cc; border-radius: 6px; padding: 12px; text-align: center; cursor: pointer; background: #fafafa;}
.dropzone:hover {background: #f6fbff; border-color: #409EFF;}
.left-controls { margin-top: 10px; display: flex; flex-direction: column; gap: 10px; } .dropzone.disabled {opacity: .6; cursor: not-allowed;}
.dropzone { border: 1px dashed #c0c4cc; border-radius: 6px; padding: 12px; text-align: center; cursor: pointer; background: #fafafa; } .dz-el-icon {font-size: 18px; margin-bottom: 4px; color: #909399;}
.dropzone:hover { background: #f6fbff; border-color: #409EFF; } .dz-text {color: #303133; font-size: 13px;}
.dropzone.disabled { opacity: .6; cursor: not-allowed; } .dz-sub {color: #909399; font-size: 12px;}
.dz-el-icon { font-size: 18px; margin-bottom: 4px; color: #909399; } .single-input.left {display: flex; gap: 8px;}
.dz-text { color: #303133; font-size: 13px; } .action-buttons.column {display: flex; flex-direction: column; gap: 8px;}
.dz-sub { color: #909399; font-size: 12px; } .file-chip {display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; color: #606266; margin-top: 6px; width: 100%; box-sizing: border-box;}
.single-input.left { display: flex; gap: 8px; } .file-chip .dot {width: 6px; height: 6px; background: #409EFF; border-radius: 50%; flex-shrink: 0;}
.action-buttons.column { display: flex; flex-direction: column; gap: 8px; } .file-chip .name {flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
.file-chip .delete-btn {cursor: pointer; opacity: 0.6; flex-shrink: 0;}
.file-chip { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; color: #606266; margin-top: 6px; } .file-chip .delete-btn:hover {opacity: 1;}
.file-chip .dot { width: 6px; height: 6px; background: #409EFF; border-radius: 50%; display: inline-block; } .progress-section.left {margin-top: 10px;}
.file-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .full {width: 100%;}
.form-row {margin-bottom: 10px;}
.progress-section.left { margin-top: 10px; } .label {display: block; font-size: 12px; color: #606266; margin-bottom: 6px;}
.full { width: 100%; }
.form-row { margin-bottom: 10px; }
.label { display: block; font-size: 12px; color: #606266; margin-bottom: 6px; }
/* 统一左侧控件宽度与主色 */ /* 统一左侧控件宽度与主色 */
.steps-sidebar :deep(.el-date-editor), .steps-sidebar :deep(.el-date-editor),
.steps-sidebar :deep(.el-range-editor.el-input__wrapper), .steps-sidebar :deep(.el-range-editor.el-input__wrapper),
.steps-sidebar :deep(.el-input), .steps-sidebar :deep(.el-input),
.steps-sidebar :deep(.el-input__wrapper), .steps-sidebar :deep(.el-input__wrapper),
.steps-sidebar :deep(.el-select) { width: 100%; box-sizing: border-box; } .steps-sidebar :deep(.el-select) {width: 100%; box-sizing: border-box;}
.btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; } .btn-blue {background: #1677FF; border-color: #1677FF; color: #fff;}
.btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; } .btn-blue:disabled {background: #a6c8ff; border-color: #a6c8ff; color: #fff;}
.w100 { width: 100%; } .w100 {width: 100%;}
.steps-sidebar :deep(.el-button + .el-button) { margin-left: 0; } .steps-sidebar :deep(.el-button + .el-button) {margin-left: 0;}
.progress-section { margin: 0px 12px 0px 12px; } .progress-section {margin: 0px 12px 0px 12px;}
.progress-box { padding: 4px 0; } .progress-box {padding: 4px 0;}
.progress-container { display: flex; align-items: center; gap: 8px; } .progress-container {display: flex; align-items: center; gap: 8px;}
.progress-bar { flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden; } .progress-bar {flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden;}
.progress-fill { height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease; } .progress-fill {height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease;}
.progress-text { font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right; } .progress-text {font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right;}
.current-status {font-size: 12px; color: #606266; padding-left: 2px;}
.current-status { .export-progress {display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px;}
font-size: 12px; .export-progress-bar {flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden;}
color: #606266; .export-progress-fill {height: 100%; background: #1677FF; border-radius: 2px; transition: width 0.3s ease;}
padding-left: 2px; .export-progress-text {font-size: 11px; color: #1677FF; font-weight: 500; min-width: 32px; text-align: right;}
} .table-container {display: flex; flex-direction: column; flex: 1; min-height: 400px; overflow: hidden;}
.export-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px; } .empty-section {flex: 1; display: flex; justify-content: center; align-items: center; background: #fff; border: 1px solid #ebeef5; border-radius: 6px;}
.export-progress-bar { flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden; } .empty-container {text-align: center;}
.export-progress-fill { height: 100%; background: #1677FF; border-radius: 2px; transition: width 0.3s ease; } .empty-icon {font-size: 48px; margin-bottom: 16px; opacity: 0.6;}
.export-progress-text { font-size: 11px; color: #1677FF; font-weight: 500; min-width: 32px; text-align: right; } .empty-text {font-size: 14px; color: #909399;}
.table-section {flex: 1; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column;}
.table-container { .table-wrapper {flex: 1; overflow: auto;}
display: flex; .table-wrapper {scrollbar-width: thin; scrollbar-color: #c0c4cc transparent;}
flex-direction: column; .table-wrapper::-webkit-scrollbar {width: 6px; height: 6px;}
flex: 1; .table-wrapper::-webkit-scrollbar-track {background: transparent;}
min-height: 400px; .table-wrapper::-webkit-scrollbar-thumb {background: #c0c4cc; border-radius: 3px;}
overflow: hidden; .table-wrapper:hover::-webkit-scrollbar-thumb {background: #a8abb2;}
} .table {width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px;}
.table th {background: #f5f7fa; color: #909399; font-weight: 600; padding: 8px 6px; border-bottom: 2px solid #ebeef5; text-align: left; font-size: 12px; white-space: nowrap;}
.empty-section { .table td {padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle;}
flex: 1; .table tbody tr:hover {background: #f9f9f9;}
display: flex; .truncate {max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}
justify-content: center; .shop-col {max-width: 160px;}
align-items: center; .url-col {max-width: 220px;}
background: #fff; .empty-tip {text-align: center; color: #909399; padding: 16px 0;}
border: 1px solid #ebeef5; .empty-container {text-align: center;}
border-radius: 6px; .empty-icon {font-size: 48px; margin-bottom: 12px; opacity: 0.6;}
} .empty-text {font-size: 14px; color: #909399;}
.import-section.drag-active {border: 1px dashed #409EFF; border-radius: 6px;}
.empty-container { .empty-abs {position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; pointer-events: none;}
text-align: center; .image-container {display: flex; justify-content: center; align-items: center; width: 40px; height: 40px; margin: 0 auto; background: #f8f9fa; border-radius: 2px;}
} .thumb {width: 32px; height: 32px; object-fit: contain; border-radius: 2px;}
.table-loading {position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95); display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 14px; color: #606266; pointer-events: none;}
.empty-icon { .spinner {font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px;}
font-size: 48px; @keyframes spin {0% {
margin-bottom: 16px; transform: rotate(0deg);}
opacity: 0.6; 100% {transform: rotate(360deg);}
}
.empty-text {
font-size: 14px;
color: #909399;
}
.table-section { flex: 1; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column; }
.table-wrapper { flex: 1; overflow: auto; }
.table-wrapper { scrollbar-width: thin; scrollbar-color: #c0c4cc transparent; }
.table-wrapper::-webkit-scrollbar { width: 6px; height: 6px; }
.table-wrapper::-webkit-scrollbar-track { background: transparent; }
.table-wrapper::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 3px; }
.table-wrapper:hover::-webkit-scrollbar-thumb { background: #a8abb2; }
.table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px; }
.table th {
background: #f5f7fa;
color: #909399;
font-weight: 600;
padding: 8px 6px;
border-bottom: 2px solid #ebeef5;
text-align: left;
font-size: 12px;
white-space: nowrap;
}
.table td {
padding: 10px 8px;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
}
.table tbody tr:hover {
background: #f9f9f9;
}
.truncate {
max-width: 260px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.shop-col { max-width: 160px; }
.url-col { max-width: 220px; }
.empty-tip { text-align: center; color: #909399; padding: 16px 0; }
.empty-container { text-align: center; }
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
.empty-text { font-size: 14px; color: #909399; }
.import-section.drag-active { border: 1px dashed #409EFF; border-radius: 6px; }
.empty-abs { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.image-container {
display: flex;
justify-content: center;
align-items: center;
width: 40px;
height: 40px;
margin: 0 auto;
background: #f8f9fa;
border-radius: 2px;
}
.thumb {
width: 32px;
height: 32px;
object-fit: contain;
border-radius: 2px;
}
.table-loading {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.95);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-size: 14px;
color: #606266;
pointer-events: none;
}
.spinner {
font-size: 24px;
animation: spin 1s linear infinite;
margin-bottom: 8px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.pagination-fixed {
flex-shrink: 0;
padding: 8px 12px;
background: #f9f9f9;
border-radius: 4px;
display: flex;
justify-content: center;
border-top: 1px solid #ebeef5;
margin-top: 8px;
} }
.pagination-fixed {flex-shrink: 0; padding: 8px 12px 0 12px; background: #fff; display: flex; justify-content: flex-end;}
.pagination-fixed :deep(.el-pager li.is-active) {border: 1px solid #1677FF; border-radius: 4px; color: #1677FF; background: #fff;}
</style> </style>
<script lang="ts"> <script lang="ts">

View File

@@ -1,26 +1,24 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, defineAsyncComponent, inject } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { zebraApi, type ZebraOrder, type BanmaAccount } from '../../api/zebra' import { zebraApi, type ZebraOrder, type BanmaAccount } from '../../api/zebra'
import AccountManager from '../common/AccountManager.vue' import AccountManager from '../common/AccountManager.vue'
import { batchConvertImages } from '../../utils/imageProxy' import { batchConvertImages } from '../../utils/imageProxy'
import { handlePlatformFileExport } from '../../utils/settings' import { handlePlatformFileExport } from '../../utils/settings'
import { getUsernameFromToken } from '../../utils/token'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
// 接收VIP状态 // 接收VIP状态
const props = defineProps<{ const props = defineProps<{
isVip: boolean isVip: boolean
}>() }>()
type Shop = { id: string; shopName: string } type Shop = { id: string; shopName: string }
const accounts = ref<BanmaAccount[]>([]) const accounts = ref<BanmaAccount[]>([])
const accountId = ref<number>() const accountId = ref<number>()
// 收起功能移除 // 收起功能移除
const shopList = ref<Shop[]>([]) const shopList = ref<Shop[]>([])
const selectedShops = ref<string[]>([]) const selectedShops = ref<string[]>([])
const dateRange = ref<string[]>([]) const dateRange = ref<string[]>([])
const loading = ref(false) const loading = ref(false)
const exportLoading = ref(false) const exportLoading = ref(false)
const progressPercentage = ref(0) const progressPercentage = ref(0)
@@ -35,6 +33,12 @@ const fetchCurrentPage = ref(1)
const fetchTotalPages = ref(0) const fetchTotalPages = ref(0)
const fetchTotalItems = ref(0) const fetchTotalItems = ref(0)
const isFetching = ref(false) const isFetching = ref(false)
let abortController: AbortController | null = null
// 试用期过期弹框
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both' | 'subscribe'>('account')
const vipStatus = inject<any>('vipStatus')
function selectAccount(id: number) { function selectAccount(id: number) {
accountId.value = id accountId.value = id
loadShops() loadShops()
@@ -68,7 +72,8 @@ async function loadShops() {
async function loadAccounts() { async function loadAccounts() {
try { try {
const res = await zebraApi.getAccounts() const username = getUsernameFromToken()
const res = await zebraApi.getAccounts(username)
const list = (res as any)?.data ?? res const list = (res as any)?.data ?? res
accounts.value = Array.isArray(list) ? list : [] accounts.value = Array.isArray(list) ? list : []
const def = accounts.value.find(a => a.isDefault === 1) || accounts.value[0] const def = accounts.value.find(a => a.isDefault === 1) || accounts.value[0]
@@ -91,22 +96,16 @@ function handleCurrentChange(page: number) {
async function fetchData() { async function fetchData() {
if (isFetching.value) return if (isFetching.value) return
// 刷新VIP状态
if (refreshVipStatus) await refreshVipStatus()
// VIP检查 // VIP检查
if (!props.isVip) { if (!props.isVip) {
try { if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
await ElMessageBox.confirm( showTrialExpiredDialog.value = true
'VIP已过期数据采集功能受限。请联系管理员续费后继续使用。',
'VIP功能限制',
{
confirmButtonText: '我知道了',
showCancelButton: false,
type: 'warning'
}
)
} catch {}
return return
} }
abortController = new AbortController()
loading.value = true loading.value = true
isFetching.value = true isFetching.value = true
showProgress.value = true showProgress.value = true
@@ -116,15 +115,16 @@ async function fetchData() {
fetchTotalItems.value = 0 fetchTotalItems.value = 0
currentBatchId.value = `ZEBRA_${Date.now()}` currentBatchId.value = `ZEBRA_${Date.now()}`
const [startDate = '', endDate = ''] = dateRange.value || [] const [start, end] = dateRange.value || []
const startDate = start ? `${new Date(start).toLocaleDateString('sv-SE')} 00:00:00` : ''
const endDate = end ? `${new Date(end).toLocaleDateString('sv-SE')} 23:59:59` : ''
await fetchPageData(startDate, endDate) await fetchPageData(startDate, endDate)
} }
async function fetchPageData(startDate: string, endDate: string) { async function fetchPageData(startDate: string, endDate: string) {
if (!isFetching.value) return if (!isFetching.value) return
try { try {
const data = await zebraApi.getOrders({ const response = await zebraApi.getOrders({
accountId: Number(accountId.value) || undefined, accountId: Number(accountId.value) || undefined,
startDate, startDate,
endDate, endDate,
@@ -132,14 +132,13 @@ async function fetchPageData(startDate: string, endDate: string) {
pageSize: 50, pageSize: 50,
shopIds: selectedShops.value.join(','), shopIds: selectedShops.value.join(','),
batchId: currentBatchId.value batchId: currentBatchId.value
}) }, abortController?.signal)
const data = (response as any)?.data || response
const orders = data.orders || [] const orders = data.orders || []
allOrderData.value = [...allOrderData.value, ...orders] allOrderData.value = [...allOrderData.value, ...orders]
fetchTotalPages.value = data.totalPages || 0 fetchTotalPages.value = data.totalPages || 0
fetchTotalItems.value = data.total || 0 fetchTotalItems.value = data.total || 0
if (fetchCurrentPage.value < fetchTotalPages.value && isFetching.value) { if (fetchCurrentPage.value < fetchTotalPages.value && isFetching.value) {
progressPercentage.value = Math.round((fetchCurrentPage.value / fetchTotalPages.value) * 100) progressPercentage.value = Math.round((fetchCurrentPage.value / fetchTotalPages.value) * 100)
fetchCurrentPage.value++ fetchCurrentPage.value++
@@ -148,8 +147,10 @@ async function fetchPageData(startDate: string, endDate: string) {
progressPercentage.value = 100 progressPercentage.value = 100
finishFetching() finishFetching()
} }
} catch (e) { } catch (e: any) {
console.error('获取订单数据失败:', e) if (e.name !== 'AbortError') {
console.error('获取订单数据失败:', e)
}
finishFetching() finishFetching()
} }
} }
@@ -157,6 +158,7 @@ async function fetchPageData(startDate: string, endDate: string) {
function finishFetching() { function finishFetching() {
isFetching.value = false isFetching.value = false
loading.value = false loading.value = false
abortController = null
// 确保进度条完全填满 // 确保进度条完全填满
progressPercentage.value = 100 progressPercentage.value = 100
currentPage.value = 1 currentPage.value = 1
@@ -164,6 +166,8 @@ function finishFetching() {
} }
function stopFetch() { function stopFetch() {
abortController?.abort()
abortController = null
isFetching.value = false isFetching.value = false
loading.value = false loading.value = false
// 进度条保留显示,不自动隐藏 // 进度条保留显示,不自动隐藏
@@ -258,7 +262,8 @@ async function exportToExcel() {
}) })
const fileName = `斑马订单数据_${new Date().toISOString().slice(0, 10)}.xlsx` const fileName = `斑马订单数据_${new Date().toISOString().slice(0, 10)}.xlsx`
const success = await handlePlatformFileExport('zebra', blob, fileName) const username = getUsernameFromToken()
const success = await handlePlatformFileExport('zebra', blob, fileName, username)
if (success) { if (success) {
showMessage('Excel文件导出成功', 'success') showMessage('Excel文件导出成功', 'success')
@@ -289,7 +294,19 @@ const rememberPwd = ref(true)
const managerVisible = ref(false) const managerVisible = ref(false)
const accountManagerRef = ref() const accountManagerRef = ref()
function openAddAccount() { async function openAddAccount() {
try {
const username = getUsernameFromToken()
const limitRes = await zebraApi.getAccountLimit(username)
const limitData = (limitRes as any)?.data ?? limitRes
const { limit = 1, count = 0 } = limitData
if (count >= limit) {
ElMessage({ message: `账号数量已达上限(${limit}个)${limit < 3 ? '请升级订阅或' : ''}请先删除其他账号`, type: 'warning' })
return
}
} catch (e) {
console.error('检查账号限制失败:', e)
}
isEditMode.value = false isEditMode.value = false
accountForm.value = { name: '', username: '', isDefault: 0, status: 1 } accountForm.value = { name: '', username: '', isDefault: 0, status: 1 }
formUsername.value = '' formUsername.value = ''
@@ -318,7 +335,8 @@ async function submitAccount() {
status: accountForm.value.status || 1, status: accountForm.value.status || 1,
} }
try { try {
const res = await zebraApi.saveAccount(payload) const username = getUsernameFromToken()
const res = await zebraApi.saveAccount(payload, username)
const id = (res as any)?.data?.id || (res as any)?.id const id = (res as any)?.data?.id || (res as any)?.id
if (!id) throw new Error((res as any)?.msg || '保存失败') if (!id) throw new Error((res as any)?.msg || '保存失败')
accountDialogVisible.value = false accountDialogVisible.value = false
@@ -366,9 +384,8 @@ async function removeCurrentAccount() {
> >
<span class="acct-row"> <span class="acct-row">
<span :class="['status-dot', a.status === 1 ? 'on' : 'off']"></span> <span :class="['status-dot', a.status === 1 ? 'on' : 'off']"></span>
<img class="avatar" src="/image/img_v3_02qd_052605f0-4be3-44db-9691-35ee5ff6201g.jpg" alt="avatar" /> <img class="avatar" src="/image/user.png" alt="avatar" />
<span class="acct-text">{{ a.name || a.username }}</span> <span class="acct-text">{{ a.name || a.username }}</span>
<span v-if="a.isDefault===1" class="tag">默认</span>
<span v-if="accountId === a.id" class="acct-check"></span> <span v-if="accountId === a.id" class="acct-check"></span>
</span> </span>
</div> </div>
@@ -391,8 +408,8 @@ async function removeCurrentAccount() {
<section class="step"> <section class="step">
<div class="step-index">2</div> <div class="step-index">2</div>
<div class="step-body"> <div class="step-body">
<div class="step-title">查询的日期</div> <div class="step-title">需查询的店铺与日期</div>
<div class="tip">请选择查询数据的日期范围</div> <div class="tip">请选择查询的店铺可多选与日期范围选项为空时默认获取全部数据</div>
<el-select v-model="selectedShops" multiple placeholder="选择店铺" :disabled="loading || !accounts.length" size="small" style="width: 100%"> <el-select v-model="selectedShops" multiple placeholder="选择店铺" :disabled="loading || !accounts.length" size="small" style="width: 100%">
<el-option v-for="shop in shopList" :key="shop.id" :label="shop.shopName" :value="shop.id" /> <el-option v-for="shop in shopList" :key="shop.id" :label="shop.shopName" :value="shop.id" />
</el-select> </el-select>
@@ -407,7 +424,7 @@ async function removeCurrentAccount() {
<div class="step-title">获取数据</div> <div class="step-title">获取数据</div>
<div class="tip">点击下方按钮开始查询订单数据</div> <div class="tip">点击下方按钮开始查询订单数据</div>
<div class="btn-col"> <div class="btn-col">
<el-button size="small" class="w100 btn-blue" :disabled="loading || !accounts.length" @click="fetchData">{{ loading ? '处理中...' : '获取数据' }}</el-button> <el-button size="small" class="w100 btn-blue" :disabled="loading || exportLoading || !accounts.length" @click="fetchData">{{ loading ? '处理中...' : '获取数据' }}</el-button>
<el-button size="small" :disabled="!loading" @click="stopFetch" class="w100">停止获取</el-button> <el-button size="small" :disabled="!loading" @click="stopFetch" class="w100">停止获取</el-button>
</div> </div>
</div> </div>
@@ -419,7 +436,7 @@ async function removeCurrentAccount() {
<div class="step-title">导出数据</div> <div class="step-title">导出数据</div>
<div class="tip">点击下方按钮导出所有订单数据到 Excel 文件</div> <div class="tip">点击下方按钮导出所有订单数据到 Excel 文件</div>
<div class="btn-col"> <div class="btn-col">
<el-button size="small" type="success" :disabled="exportLoading || !allOrderData.length" :loading="exportLoading" @click="exportToExcel" class="w100">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button> <el-button size="small" :disabled="exportLoading || loading || !allOrderData.length" :loading="exportLoading" @click="exportToExcel" class="w100 btn-blue">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
<!-- 导出进度条 --> <!-- 导出进度条 -->
</div> </div>
</div> </div>
@@ -468,7 +485,7 @@ async function removeCurrentAccount() {
<td>{{ row.orderedAt || '-' }}</td> <td>{{ row.orderedAt || '-' }}</td>
<td> <td>
<div class="image-container" v-if="row.productImage"> <div class="image-container" v-if="row.productImage">
<img :src="row.productImage" class="thumb" alt="thumb" /> <el-image :src="row.productImage" class="thumb" fit="contain" :preview-src-list="[row.productImage]" />
</div> </div>
<span v-else>无图片</span> <span v-else>无图片</span>
</td> </td>
@@ -510,7 +527,6 @@ async function removeCurrentAccount() {
<!-- 底部区域分页器 --> <!-- 底部区域分页器 -->
<div class="pagination-fixed"> <div class="pagination-fixed">
<el-pagination <el-pagination
background
:current-page="currentPage" :current-page="currentPage"
:page-sizes="[15,30,50,100]" :page-sizes="[15,30,50,100]"
:page-size="pageSize" :page-size="pageSize"
@@ -544,7 +560,11 @@ async function removeCurrentAccount() {
<el-button type="primary" class="btn-blue" style="width: 100%" @click="submitAccount">登录</el-button> <el-button type="primary" class="btn-blue" style="width: 100%" @click="submitAccount">登录</el-button>
</template> </template>
</el-dialog> </el-dialog>
<AccountManager ref="accountManagerRef" v-model="managerVisible" platform="zebra" @add="openAddAccount" @refresh="loadAccounts" />
<!-- 试用期过期弹框 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
<AccountManager ref="accountManagerRef" v-model="managerVisible" platform="zebra" @refresh="loadAccounts" />
</div> </div>
</template> </template>
@@ -555,93 +575,94 @@ export default {
</script> </script>
<style scoped> <style scoped>
.zebra-root { position: absolute; inset: 0; background: #f5f5f5; padding: 12px; box-sizing: border-box; } .zebra-root {position: absolute; inset: 0; background: #fff; box-sizing: border-box;}
.layout { background: #fff; border-radius: 4px; padding: 12px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); height: 100%; display: grid; grid-template-columns: 220px 1fr; gap: 12px; } .layout {height: 100%; display: grid; grid-template-columns: 220px 1fr; gap: 12px; padding: 12px; box-sizing: border-box;}
.aside { border: 1px solid #ebeef5; border-radius: 4px; padding: 10px; display: flex; flex-direction: column; transition: width 0.2s ease; } .aside {border: 1px solid #ebeef5; border-radius: 4px; padding: 10px; display: flex; flex-direction: column; transition: width 0.2s ease;}
.aside.collapsed { width: 56px; overflow: hidden; } .aside.collapsed {width: 56px; overflow: hidden;}
.aside-header { display: flex; justify-content: flex-start; align-items: center; font-weight: 600; color: #606266; margin-bottom: 8px; } .aside-header {display: flex; justify-content: flex-start; align-items: center; font-weight: 600; color: #606266; margin-bottom: 8px;}
.aside-steps { position: relative; } .aside-steps {position: relative;}
.step { display: grid; grid-template-columns: 22px 1fr; gap: 10px; position: relative; padding: 8px 0; } .step {display: grid; grid-template-columns: 22px 1fr; gap: 10px; position: relative; padding: 8px 0;}
.step + .step { border-top: 1px dashed #ebeef5; } .step-index {width: 22px; height: 22px; background: #1677FF; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; margin-top: 2px;}
.step-index { width: 22px; height: 22px; background: #1677FF; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; margin-top: 2px; } .step-body {min-width: 0; text-align: left;}
.step-body { min-width: 0; text-align: left; } .step-title {font-size: 13px; color: #606266; margin-bottom: 6px; font-weight: 600; text-align: left;}
.step-title { font-size: 13px; color: #606266; margin-bottom: 6px; font-weight: 600; text-align: left; } .aside-steps:before {content: ''; position: absolute; left: 11px; top: 20px; bottom: 0; width: 1px; background: rgba(229, 231, 235, 0.6);}
.aside-steps:before { content: ''; position: absolute; left: 11px; top: 20px; bottom: 0; width: 1px; background: rgba(229, 231, 235, 0.6); } .account-list {height: auto;}
.account-list {height: auto; } .step-actions {margin-top: 8px; display: flex; gap: 8px;}
.step-actions { margin-top: 8px; display: flex; gap: 8px; } .step-accounts {position: relative;}
.step-accounts { position: relative; } .sticky-actions {position: sticky; bottom: 0; background: #fafafa; padding-top: 8px;}
.sticky-actions { position: sticky; bottom: 0; background: #fafafa; padding-top: 8px; } .scroll-limit {max-height: 160px;}
.scroll-limit { max-height: 160px; } .btn-row {display: grid; grid-template-columns: 1fr 1fr; gap: 8px;}
.btn-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .btn-col {display: flex; flex-direction: column; gap: 6px;}
.btn-col { display: flex; flex-direction: column; gap: 6px; } .w50 {width: 48%;}
.w50 { width: 48%; } .w100 {width: 100%;}
.w100 { width: 100%; } .placeholder-box {display:flex; align-items:center; justify-content:center; flex-direction:column; height: 140px; background: #fff; border: 1px solid #ebeef5; border-radius: 4px;}
.placeholder-box { display:flex; align-items:center; justify-content:center; flex-direction:column; height: 140px; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; } .placeholder-img {width: 120px; opacity: 0.9;}
.placeholder-img { width: 120px; opacity: 0.9; } .placeholder-tip {margin-top: 6px; font-size: 12px; color: #a8abb2;}
.placeholder-tip { margin-top: 6px; font-size: 12px; color: #a8abb2; } .aside :deep(.el-date-editor) {width: 100%;}
.aside :deep(.el-date-editor) { width: 100%; } .aside :deep(.el-range-editor.el-input__wrapper) {width: 100%; box-sizing: border-box;}
.aside :deep(.el-range-editor.el-input__wrapper) { width: 100%; box-sizing: border-box; }
.aside :deep(.el-input), .aside :deep(.el-input),
.aside :deep(.el-input__wrapper), .aside :deep(.el-input__wrapper),
.aside :deep(.el-select) { width: 100%; box-sizing: border-box; } .aside :deep(.el-select) {width: 100%; box-sizing: border-box;}
.aside :deep(.el-button + .el-button) { margin-left: 0 !important; } .aside :deep(.el-button + .el-button) {margin-left: 0 !important;}
.btn-row :deep(.el-button) { width: 100%; } .btn-row :deep(.el-button) {width: 100%;}
.btn-col :deep(.el-button) { width: 100%; } .btn-col :deep(.el-button) {width: 100%;}
.btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; } .btn-blue {background: #1677FF; border-color: #1677FF; color: #fff;}
.btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; } .btn-blue:disabled {background: #a6c8ff; border-color: #a6c8ff; color: #fff;}
.tip { color: #909399; font-size: 12px; margin-bottom: 8px; text-align: left; } .tip {color: #909399; font-size: 12px; margin-bottom: 8px; text-align: left;}
.avatar { width: 22px; height: 22px; border-radius: 50%; margin-right: 6px; vertical-align: -2px; } .avatar {width: 22px; height: 22px; border-radius: 50%; margin-right: 6px; vertical-align: -2px;}
.acct-text { vertical-align: middle; } .acct-text {vertical-align: middle;}
.acct-row { display: grid; grid-template-columns: 8px 18px 1fr auto; align-items: center; gap: 6px; width: 100%; } .acct-row {display: grid; grid-template-columns: 8px 18px 1fr auto; align-items: center; gap: 6px; width: 100%;}
.acct-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; font-size: 12px; } .acct-text {overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; font-size: 12px;}
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; } .status-dot {width: 6px; height: 6px; border-radius: 50%; display: inline-block;}
.status-dot.on { background: #22c55e; } .status-dot.on {background: #22c55e;}
.status-dot.off { background: #f87171; } .status-dot.off {background: #f87171;}
.acct-item { padding: 6px 8px; border-radius: 8px; cursor: pointer; } .acct-item {padding: 6px 8px; border-radius: 8px; cursor: pointer;}
.acct-item.selected { background: #eef5ff; box-shadow: inset 0 0 0 1px #d6e4ff; } .acct-item.selected {background: #eef5ff; box-shadow: inset 0 0 0 1px #d6e4ff;}
.acct-check { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%; background: transparent; color: #111; font-size: 14px; } .acct-check {display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%; background: transparent; color: #111; font-size: 14px;}
.account-list::-webkit-scrollbar { width: 0; height: 0; } .account-list::-webkit-scrollbar {width: 0; height: 0;}
.add-account-dialog .aad-header { display:flex; flex-direction: column; align-items:center; gap:8px; padding-top: 8px; width: 100%; } .add-account-dialog .aad-header {display:flex; flex-direction: column; align-items:center; gap:8px; padding-top: 8px; width: 100%;}
.add-account-dialog .aad-icon { width: 120px; height: auto; } .add-account-dialog .aad-icon {width: 120px; height: auto;}
.add-account-dialog .aad-title { font-weight: 600; font-size: 18px; text-align: center; } .add-account-dialog .aad-title {font-weight: 600; font-size: 18px; text-align: center;}
.add-account-dialog .aad-row { margin-top: 12px; } .add-account-dialog .aad-row {margin-top: 12px;}
.add-account-dialog .aad-opts { display:flex; align-items:center; } .add-account-dialog .aad-opts {display:flex; align-items:center;}
/* 居中 header避免右上角关闭按钮影响视觉中心 */ /* 居中 header避免右上角关闭按钮影响视觉中心 */
:deep(.add-account-dialog .el-dialog__header) { text-align: center; padding-right: 0; display: block; } :deep(.add-account-dialog .el-dialog__header) {text-align: center; padding-right: 0; display: block;}
.content { display: grid; grid-template-rows: 1fr auto; min-height: 0; } .content {display: grid; grid-template-rows: 1fr auto; min-height: 0;}
.table-section { min-height: 0; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column; } .table-section {min-height: 0; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column;}
.table-wrapper { flex: 1; overflow: auto; overflow-x: auto; } .table-wrapper {flex: 1; overflow: auto; overflow-x: auto;}
.table-wrapper { scrollbar-width: thin; scrollbar-color: #c0c4cc transparent; } .table-wrapper {scrollbar-width: thin; scrollbar-color: #c0c4cc transparent;}
.table-wrapper::-webkit-scrollbar { width: 6px; height: 6px; } .table-wrapper::-webkit-scrollbar {width: 6px; height: 6px;}
.table-wrapper::-webkit-scrollbar-track { background: transparent; } .table-wrapper::-webkit-scrollbar-track {background: transparent;}
.table-wrapper::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 3px; } .table-wrapper::-webkit-scrollbar-thumb {background: #c0c4cc; border-radius: 3px;}
.table-wrapper:hover::-webkit-scrollbar-thumb { background: #a8abb2; } .table-wrapper:hover::-webkit-scrollbar-thumb {background: #a8abb2;}
.table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px; } .table {width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px;}
.table th { background: #f5f7fa; color: #909399; font-weight: 600; padding: 12px 8px; border-bottom: 2px solid #ebeef5; text-align: left; white-space: nowrap; } .table th {background: #f5f7fa; color: #909399; font-weight: 600; padding: 12px 8px; border-bottom: 2px solid #ebeef5; text-align: left; white-space: nowrap;}
.table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; } .table td {padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle;}
.table tbody tr:hover { background: #f9f9f9; } .table tbody tr:hover {background: #f9f9f9;}
.truncate { max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .truncate {max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}
.image-container { display: flex; justify-content: center; align-items: center; width: 28px; height: 24px; margin: 0 auto; background: #f8f9fa; border-radius: 2px; } .image-container {display: flex; justify-content: center; align-items: center; width: 28px; height: 24px; margin: 0 auto; background: #f8f9fa; border-radius: 2px;}
.thumb { width: 22px; height: 22px; object-fit: contain; border-radius: 2px; } .thumb {width: 22px; height: 22px; object-fit: contain; border-radius: 2px;}
.price-tag { color: #e6a23c; font-weight: bold; } .price-tag {color: #e6a23c; font-weight: bold;}
.fee-tag { color: #909399; font-weight: 500; } .fee-tag {color: #909399; font-weight: 500;}
.table-loading { position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95); display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 14px; color: #606266; } .table-loading {position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95); display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 14px; color: #606266;}
.spinner { font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px; } .spinner {font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px;}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } @keyframes spin {0% { transform: rotate(0deg);}
.pagination-fixed { position: sticky; bottom: 0; z-index: 2; padding: 8px 12px; background: #f9f9f9; border-radius: 4px; display: flex; justify-content: center; border-top: 1px solid #ebeef5; margin-top: 8px; } 100% {transform: rotate(360deg);}
.tag { display: inline-block; padding: 0 6px; margin-left: 6px; font-size: 12px; background: #ecf5ff; color: #409EFF; border-radius: 3px; } }
.empty-abs { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; } .pagination-fixed {position: sticky; bottom: 0; z-index: 2; padding: 8px 12px 0 12px; background: #fff; display: flex; justify-content: flex-end;}
.progress-section { margin: 0px 12px 0px 12px; } .pagination-fixed :deep(.el-pager li.is-active) {border: 1px solid #1677FF; border-radius: 4px; color: #1677FF; background: #fff;}
.progress-box { padding: 4px 0; } .tag {display: inline-block; padding: 0 6px; margin-left: 6px; font-size: 12px; background: #ecf5ff; color: #409EFF; border-radius: 3px;}
.progress-container { display: flex; align-items: center; gap: 8px; } .empty-abs {position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; pointer-events: none;}
.progress-bar { flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden; } .progress-section {margin: 0px 12px 0px 12px;}
.progress-fill { height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease; } .progress-box {padding: 4px 0;}
.progress-text { font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right; } .progress-container {display: flex; align-items: center; gap: 8px;}
.export-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px; } .progress-bar {flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden;}
.export-progress-bar { flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden; } .progress-fill {height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease;}
.export-progress-fill { height: 100%; background: #67c23a; border-radius: 2px; transition: width 0.3s ease; } .progress-text {font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right;}
.export-progress-text { font-size: 11px; color: #67c23a; font-weight: 500; min-width: 32px; text-align: right; } .export-progress {display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px;}
.export-progress-bar {flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden;}
.export-progress-fill {height: 100%; background: #67c23a; border-radius: 2px; transition: width 0.3s ease;}
.export-progress-text {font-size: 11px; color: #67c23a; font-weight: 500; min-width: 32px; text-align: right;}
</style> </style>

View File

@@ -0,0 +1,64 @@
import { ref } from 'vue'
export interface UseFileDropOptions {
accept?: RegExp
onFile: (file: File) => void | Promise<void>
onError?: (message: string) => void
}
export function useFileDrop(options: UseFileDropOptions) {
const { accept, onFile, onError } = options
const dragActive = ref(false)
let dragCounter = 0
const onDragEnter = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter++
dragActive.value = true
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'copy'
}
}
const onDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter--
if (dragCounter === 0) {
dragActive.value = false
}
}
const onDrop = async (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter = 0
dragActive.value = false
const file = e.dataTransfer?.files?.[0]
if (!file) return
if (accept && !accept.test(file.name)) {
const fileTypes = accept.source.includes('xlsx?') ? '.xls 或 .xlsx' : accept.source
onError?.(`仅支持 ${fileTypes} 文件`)
return
}
await onFile(file)
}
return {
dragActive,
onDragEnter,
onDragOver,
onDragLeave,
onDrop
}
}

View File

@@ -0,0 +1,20 @@
/**
* 应用配置
*/
export const AppConfig = {
CLIENT_BASE: 'http://localhost:8081',
RUOYI_BASE: 'http://8.138.23.49:8085',
get SSE_URL() {
return `${this.RUOYI_BASE}/monitor/account/events`
}
} as const
/**
* 判断路径是否路由到ruoyi-admin服务
*/
export function isRuoyiPath(path: string): boolean {
return path.startsWith('/monitor/') ||
path.startsWith('/system/') ||
path.startsWith('/tool/banma') ||
path.startsWith('/tool/genmai')
}

View File

@@ -5,6 +5,9 @@
<title>erpClient</title> <title>erpClient</title>
<link rel="icon" href="/icon/icon.png"> <link rel="icon" href="/icon/icon.png">
<meta name="theme-color" content="#ffffff"> <meta name="theme-color" content="#ffffff">
<style>
body { margin: 0; background-color: #f5f5f5; }
</style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1,33 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>正在启动...</title>
<style>
html, body { height: 100%; margin: 0; }
body {
background: #fff; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;
background-image: url('./image/splash_screen.png');
background-repeat: no-repeat;
background-position: center;
background-size: cover;
}
.box { position: fixed; left: 0; right: 0; bottom: 28px; padding: 0 0; }
.progress { position: relative; width: 100vw; height: 6px; background: rgba(0,0,0,0.08); }
.bar { position: absolute; left: 0; top: 0; height: 100%; width: 20vw; min-width: 120px; background: linear-gradient(90deg, #67C23A, #409EFF); animation: slide 1s ease-in-out infinite alternate; }
@keyframes slide { 0% { left: 0; } 100% { left: calc(100vw - 20vw); } }
</style>
<link rel="icon" href="icon/icon.png">
<link rel="apple-touch-icon" href="icon/icon.png">
<meta name="theme-color" content="#ffffff">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline';">
</head>
<body>
<div class="box">
<div class="progress"><div class="bar"></div></div>
</div>
</body>
</html>

View File

@@ -14,6 +14,10 @@ export default interface ElectronApi {
getUpdateStatus: () => Promise<{ downloadedFilePath: string | null; isDownloading: boolean; downloadProgress: any; isPackaged: boolean }> getUpdateStatus: () => Promise<{ downloadedFilePath: string | null; isDownloading: boolean; downloadProgress: any; isPackaged: boolean }>
onUpdateLog: (callback: (log: string) => void) => void onUpdateLog: (callback: (log: string) => void) => void
removeUpdateLogListener: () => void removeUpdateLogListener: () => void
windowMinimize: () => Promise<void>
windowMaximize: () => Promise<void>
windowClose: () => Promise<void>
windowIsMaximized: () => Promise<boolean>
} }
declare global { declare global {

View File

@@ -1,70 +1,24 @@
/** import { CONFIG } from '../api/http'
* 设备ID管理工具
* 从客户端服务获取硬件UUID通过 wmic 命令)
*/
const BASE_CLIENT = 'http://localhost:8081' const DEVICE_ID_KEY = 'device_id'
/**
* 从客户端服务获取硬件设备ID
* 客户端会使用 wmic 命令获取硬件UUID仅Windows
*/
async function fetchDeviceIdFromClient(): Promise<string> { async function fetchDeviceIdFromClient(): Promise<string> {
const response = await fetch(`${BASE_CLIENT}/api/device-id`, { const response = await fetch(`${CONFIG.CLIENT_BASE}/api/system/device-id`, {
method: 'GET', method: 'GET',
credentials: 'omit', credentials: 'omit',
cache: 'no-store' cache: 'no-store'
}) })
if (!response.ok) throw new Error('获取设备ID失败')
if (!response.ok) {
throw new Error('获取设备ID失败')
}
const result = await response.json() const result = await response.json()
const deviceId = result?.data if (!result?.data) throw new Error('设备ID为空')
return result.data
if (!deviceId) { }
throw new Error('设备ID为空')
}
export async function getOrCreateDeviceId(): Promise<string> {
const cached = localStorage.getItem(DEVICE_ID_KEY)
if (cached) return cached
const deviceId = await fetchDeviceIdFromClient()
localStorage.setItem(DEVICE_ID_KEY, deviceId)
return deviceId return deviceId
} }
/**
* 获取或创建设备ID
* 1. 优先从本地缓存读取
* 2. 如果没有缓存从客户端服务获取使用硬件UUID
* 3. 保存到本地缓存
*/
export async function getOrCreateDeviceId(): Promise<string> {
try {
// 尝试从本地缓存获取
const response = await fetch(`${BASE_CLIENT}/api/cache/get?key=deviceId`)
if (response.ok) {
const result = await response.json()
const cachedDeviceId = result?.data
if (cachedDeviceId) {
return cachedDeviceId
}
}
} catch (error) {
console.warn('从缓存读取设备ID失败:', error)
}
// 从客户端服务获取新的设备ID硬件UUID
const newDeviceId = await fetchDeviceIdFromClient()
// 保存到本地缓存
try {
await fetch(`${BASE_CLIENT}/api/cache/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: 'deviceId', value: newDeviceId })
})
} catch (error) {
console.warn('保存设备ID到缓存失败:', error)
}
return newDeviceId
}

View File

@@ -0,0 +1,177 @@
/**
* 图片压缩工具 - 在上传前压缩图片,减少传输和存储大小
* 保持视觉效果的同时显著减小文件体积
*/
export interface CompressOptions {
/** 目标质量 0-1默认0.8 */
quality?: number
/** 最大宽度超过则等比缩放默认1920 */
maxWidth?: number
/** 最大高度超过则等比缩放默认1080 */
maxHeight?: number
/** 输出格式,默认'image/jpeg' */
mimeType?: string
/** 是否转换为WebP格式更小体积默认false */
useWebP?: boolean
}
/**
* 压缩图片文件
* @param file 原始图片文件
* @param options 压缩选项
* @returns 压缩后的Blob和压缩信息
*/
export async function compressImage(
file: File,
options: CompressOptions = {}
): Promise<{
blob: Blob
file: File
originalSize: number
compressedSize: number
compressionRatio: number
}> {
const {
quality = 0.85,
maxWidth = 1920,
maxHeight = 1080,
mimeType = 'image/jpeg',
useWebP = false,
} = options
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
const img = new Image()
img.onload = () => {
try {
// 计算压缩后的尺寸(保持宽高比)
let { width, height } = img
const aspectRatio = width / height
if (width > maxWidth) {
width = maxWidth
height = width / aspectRatio
}
if (height > maxHeight) {
height = maxHeight
width = height * aspectRatio
}
// 创建canvas进行压缩
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('无法获取canvas上下文'))
return
}
// 绘制图片
ctx.drawImage(img, 0, 0, width, height)
// 转换为Blob - 如果原图是PNG,保持PNG格式以保留透明度
const isPNG = file.type === 'image/png'
const outputMimeType = useWebP ? 'image/webp' : (isPNG ? 'image/png' : mimeType)
canvas.toBlob(
(blob) => {
if (!blob) {
reject(new Error('图片压缩失败'))
return
}
const originalSize = file.size
const compressedSize = blob.size
const compressionRatio = ((1 - compressedSize / originalSize) * 100).toFixed(2)
// 转换为File对象
const isPNG = file.type === 'image/png'
let newFileName = file.name
if (useWebP) {
newFileName = file.name.replace(/\.[^.]+$/, '.webp')
} else if (!isPNG) {
newFileName = file.name.replace(/\.[^.]+$/, '.jpg')
}
// 如果是PNG,保持原文件名
const compressedFile = new File(
[blob],
newFileName,
{ type: outputMimeType }
)
resolve({
blob,
file: compressedFile,
originalSize,
compressedSize,
compressionRatio: parseFloat(compressionRatio),
})
},
outputMimeType,
isPNG ? 1.0 : quality // PNG使用无损压缩(quality=1.0)
)
} catch (error) {
reject(error)
}
}
img.onerror = () => {
reject(new Error('图片加载失败'))
}
img.src = e.target?.result as string
}
reader.onerror = () => {
reject(new Error('文件读取失败'))
}
reader.readAsDataURL(file)
})
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]
}
/**
* 预设压缩配置
*/
export const COMPRESS_PRESETS = {
/** 高质量适合开屏图片、品牌Logo- 1920x1080, 85%质量 */
HIGH: {
quality: 0.85,
maxWidth: 1920,
maxHeight: 1080,
mimeType: 'image/jpeg',
},
/** 中等质量(适合一般图片)- 1280x720, 80%质量 */
MEDIUM: {
quality: 0.8,
maxWidth: 1280,
maxHeight: 720,
mimeType: 'image/jpeg',
},
/** 缩略图(适合列表展示)- 400x400, 75%质量 */
THUMBNAIL: {
quality: 0.75,
maxWidth: 400,
maxHeight: 400,
mimeType: 'image/jpeg',
},
} as const

View File

@@ -8,7 +8,7 @@
export async function convertImageToBase64ViaProxy(imageUrl: string, maxSize: number = 80): Promise<string | null> { export async function convertImageToBase64ViaProxy(imageUrl: string, maxSize: number = 80): Promise<string | null> {
if (!imageUrl) return null if (!imageUrl) return null
try { try {
const proxyUrl = `http://127.0.0.1:8081/api/proxy/image-url?url=${encodeURIComponent(imageUrl)}` const proxyUrl = `http://127.0.0.1:8081/api/system/proxy/image?url=${encodeURIComponent(imageUrl)}`
const response = await fetch(proxyUrl) const response = await fetch(proxyUrl)
if (!response.ok) return null if (!response.ok) return null

View File

@@ -7,17 +7,30 @@ export interface PlatformExportSettings {
} }
export interface AppSettings { export interface AppSettings {
// 全局设置
global: PlatformExportSettings
// 平台特定设置 // 平台特定设置
platforms: { platforms: {
amazon: PlatformExportSettings amazon: PlatformExportSettings
rakuten: PlatformExportSettings rakuten: PlatformExportSettings
zebra: PlatformExportSettings zebra: PlatformExportSettings
} }
// 更新设置
autoUpdate?: boolean
// 关闭行为
closeAction?: 'quit' | 'minimize' | 'tray'
// 启动配置
autoLaunch?: boolean
launchMinimized?: boolean
} }
const SETTINGS_KEY = 'app-settings' const SETTINGS_KEY_PREFIX = 'app-settings'
// 获取带用户隔离的设置 key
function getSettingsKey(username?: string): string {
if (!username || username.trim() === '') {
return SETTINGS_KEY_PREFIX
}
return `${SETTINGS_KEY_PREFIX}-${username}`
}
// 默认平台设置 // 默认平台设置
const defaultPlatformSettings: PlatformExportSettings = { const defaultPlatformSettings: PlatformExportSettings = {
@@ -26,48 +39,59 @@ const defaultPlatformSettings: PlatformExportSettings = {
// 默认设置 // 默认设置
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
global: { ...defaultPlatformSettings },
platforms: { platforms: {
amazon: { ...defaultPlatformSettings }, amazon: { ...defaultPlatformSettings },
rakuten: { ...defaultPlatformSettings }, rakuten: { ...defaultPlatformSettings },
zebra: { ...defaultPlatformSettings } zebra: { ...defaultPlatformSettings }
} },
autoUpdate: false,
closeAction: 'quit',
autoLaunch: false,
launchMinimized: false
} }
// 获取设置 // 获取设置(按用户隔离)
export function getSettings(): AppSettings { export function getSettings(username?: string): AppSettings {
const saved = localStorage.getItem(SETTINGS_KEY) const settingsKey = getSettingsKey(username)
const saved = localStorage.getItem(settingsKey)
if (saved) { if (saved) {
const settings = JSON.parse(saved) const settings = JSON.parse(saved)
return { return {
global: { ...defaultSettings.global, ...settings.global },
platforms: { platforms: {
amazon: { ...defaultSettings.platforms.amazon, ...settings.platforms?.amazon }, amazon: { ...defaultSettings.platforms.amazon, ...settings.platforms?.amazon },
rakuten: { ...defaultSettings.platforms.rakuten, ...settings.platforms?.rakuten }, rakuten: { ...defaultSettings.platforms.rakuten, ...settings.platforms?.rakuten },
zebra: { ...defaultSettings.platforms.zebra, ...settings.platforms?.zebra } zebra: { ...defaultSettings.platforms.zebra, ...settings.platforms?.zebra }
} },
autoUpdate: settings.autoUpdate ?? defaultSettings.autoUpdate,
closeAction: settings.closeAction ?? defaultSettings.closeAction,
autoLaunch: settings.autoLaunch ?? defaultSettings.autoLaunch,
launchMinimized: settings.launchMinimized ?? defaultSettings.launchMinimized
} }
} }
return defaultSettings return defaultSettings
} }
// 保存设置 // 保存设置(按用户隔离)
export function saveSettings(settings: Partial<AppSettings>): void { export function saveSettings(settings: Partial<AppSettings>, username?: string): void {
const current = getSettings() const current = getSettings(username)
const updated = { const updated = {
global: { ...current.global, ...settings.global },
platforms: { platforms: {
amazon: { ...current.platforms.amazon, ...settings.platforms?.amazon }, amazon: { ...current.platforms.amazon, ...settings.platforms?.amazon },
rakuten: { ...current.platforms.rakuten, ...settings.platforms?.rakuten }, rakuten: { ...current.platforms.rakuten, ...settings.platforms?.rakuten },
zebra: { ...current.platforms.zebra, ...settings.platforms?.zebra } zebra: { ...current.platforms.zebra, ...settings.platforms?.zebra }
} },
autoUpdate: settings.autoUpdate ?? current.autoUpdate,
closeAction: settings.closeAction ?? current.closeAction,
autoLaunch: settings.autoLaunch ?? current.autoLaunch,
launchMinimized: settings.launchMinimized ?? current.launchMinimized
} }
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated)) const settingsKey = getSettingsKey(username)
localStorage.setItem(settingsKey, JSON.stringify(updated))
} }
// 保存平台特定设置 // 保存平台特定设置(按用户隔离)
export function savePlatformSettings(platform: Platform, settings: Partial<PlatformExportSettings>): void { export function savePlatformSettings(platform: Platform, settings: Partial<PlatformExportSettings>, username?: string): void {
const current = getSettings() const current = getSettings(username)
const updated = { const updated = {
...current, ...current,
platforms: { platforms: {
@@ -75,23 +99,25 @@ export function savePlatformSettings(platform: Platform, settings: Partial<Platf
[platform]: { ...current.platforms[platform], ...settings } [platform]: { ...current.platforms[platform], ...settings }
} }
} }
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated)) const settingsKey = getSettingsKey(username)
localStorage.setItem(settingsKey, JSON.stringify(updated))
} }
// 获取平台导出配置 // 获取平台导出配置(按用户隔离)
export function getPlatformExportConfig(platform: Platform): PlatformExportSettings { export function getPlatformExportConfig(platform: Platform, username?: string): PlatformExportSettings {
const settings = getSettings() const settings = getSettings(username)
return settings.platforms[platform] return settings.platforms[platform]
} }
// 处理平台特定文件导出 // 处理平台特定文件导出(按用户隔离)
export async function handlePlatformFileExport( export async function handlePlatformFileExport(
platform: Platform, platform: Platform,
blob: Blob, blob: Blob,
defaultFileName: string defaultFileName: string,
username?: string
): Promise<boolean> { ): Promise<boolean> {
const config = getPlatformExportConfig(platform) const config = getPlatformExportConfig(platform, username)
if (!config.exportPath) { if (!config.exportPath) {
const result = await (window as any).electronAPI.showSaveDialog({ const result = await (window as any).electronAPI.showSaveDialog({

View File

@@ -0,0 +1,49 @@
// Token 工具函数
export const TOKEN_KEY = 'auth_token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function removeToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export function getUsernameFromToken(token?: string): string {
try {
const t = token || getToken();
if (!t) return '';
const payload = JSON.parse(atob(t.split('.')[1]));
return payload.username || payload.sub || '';
} catch {
return '';
}
}
export function getClientIdFromToken(token?: string): string {
try {
const t = token || getToken();
if (!t) return '';
const payload = JSON.parse(atob(t.split('.')[1]));
return payload.clientId || '';
} catch {
return '';
}
}
export function getRegisterTimeFromToken(token?: string): string {
try {
const t = token || getToken();
const payload = JSON.parse(atob(t.split('.')[1]));
if (!payload.registerTime) return '';
const date = new Date(payload.registerTime);
return date.toISOString();
} catch {
return '';
}
}

View File

@@ -3,86 +3,82 @@ setlocal enabledelayedexpansion
set APP_ASAR=%~1 set APP_ASAR=%~1
set UPDATE_FILE=%~2 set UPDATE_FILE=%~2
set EXE_PATH=%~3 set JAR_UPDATE=%~3
set LOG_FILE=%TEMP%\electron-update.log set EXE_PATH=%~4
set UPDATE_DIR=%~5
echo ======================================== > "%LOG_FILE%" if not exist "%UPDATE_FILE%" if "%JAR_UPDATE%"=="" exit /b 1
echo Electron App Auto-Update Helper >> "%LOG_FILE%" if not exist "%UPDATE_FILE%" if not exist "%JAR_UPDATE%" exit /b 1
echo Started: %date% %time% >> "%LOG_FILE%"
echo ======================================== >> "%LOG_FILE%"
echo. >> "%LOG_FILE%"
if not exist "%UPDATE_FILE%" ( REM Wait for application to close
echo [ERROR] Update file not found: %UPDATE_FILE% >> "%LOG_FILE%"
goto :start_app
)
echo [INFO] Update file found: %UPDATE_FILE% >> "%LOG_FILE%"
echo [INFO] Target app.asar: %APP_ASAR% >> "%LOG_FILE%"
echo [INFO] Application exe: %EXE_PATH% >> "%LOG_FILE%"
echo [INFO] Waiting for application to close... >> "%LOG_FILE%"
REM 获取应用进程名
for /f "tokens=*" %%a in ("%EXE_PATH%") do set EXE_NAME=%%~nxa for /f "tokens=*" %%a in ("%EXE_PATH%") do set EXE_NAME=%%~nxa
echo [INFO] Waiting for %EXE_NAME% to close... >> "%LOG_FILE%"
REM 等待应用进程完全关闭最多等待10秒
set COUNT=0 set COUNT=0
:wait_loop :wait_loop
tasklist /FI "IMAGENAME eq %EXE_NAME%" 2>nul | find /I "%EXE_NAME%" >nul tasklist /FI "IMAGENAME eq %EXE_NAME%" 2>nul | find /I "%EXE_NAME%" >nul
if errorlevel 1 goto process_closed if errorlevel 1 goto process_closed
set /a COUNT+=1 set /a COUNT+=1
if %COUNT% GEQ 20 ( if %COUNT% GEQ 20 goto process_closed
echo [WARN] Application still running after 10 seconds >> "%LOG_FILE%"
goto process_closed
)
timeout /t 1 /nobreak >nul timeout /t 1 /nobreak >nul
goto wait_loop goto wait_loop
:process_closed :process_closed
echo [INFO] Application process closed >> "%LOG_FILE%"
timeout /t 1 /nobreak >nul timeout /t 1 /nobreak >nul
echo [INFO] Backing up current app.asar... >> "%LOG_FILE%" REM Update ASAR
if exist "%APP_ASAR%.backup" ( if exist "%UPDATE_FILE%" (
del /f /q "%APP_ASAR%.backup" >nul 2>&1 if exist "%APP_ASAR%.backup" del /f /q "%APP_ASAR%.backup" >nul 2>&1
if exist "%APP_ASAR%" move /y "%APP_ASAR%" "%APP_ASAR%.backup" >nul 2>&1
move /y "%UPDATE_FILE%" "%APP_ASAR%" >nul 2>&1
if errorlevel 1 if exist "%APP_ASAR%.backup" move /y "%APP_ASAR%.backup" "%APP_ASAR%" >nul 2>&1
if exist "%UPDATE_FILE%" del /f /q "%UPDATE_FILE%" >nul 2>&1
) )
if exist "%APP_ASAR%" ( REM Update JAR
move /y "%APP_ASAR%" "%APP_ASAR%.backup" >nul 2>&1 :update_jar
if "%JAR_UPDATE%"=="" goto :start_app
if not exist "%JAR_UPDATE%" goto :start_app
timeout /t 3 /nobreak >nul
for %%I in ("%APP_ASAR%") do set RESOURCES_DIR=%%~dpI
for %%F in ("%JAR_UPDATE%") do set JAR_NAME=%%~nF
echo %JAR_NAME% | findstr /B /C:"erp_client_sb-" >nul
if errorlevel 1 (
for /f "tokens=1-3 delims=/ " %%a in ("%date%") do set TODAY=%%a%%b%%c
for /f "tokens=1-3 delims=:." %%a in ("%time%") do set NOW=%%a%%b%%c
set JAR_NAME=erp_client_sb-2.4.7-!TODAY!!NOW!.jar
)
REM Delete old JAR files
for %%F in ("%RESOURCES_DIR%erp_client_sb-*.jar") do (
set RETRY_COUNT=0
:retry_delete
del /f /q "%%F" >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
echo [WARN] First move attempt failed, retrying... >> "%LOG_FILE%" set /a RETRY_COUNT+=1
timeout /t 2 /nobreak >nul if !RETRY_COUNT! LEQ 5 (
move /y "%APP_ASAR%" "%APP_ASAR%.backup" >nul 2>&1 timeout /t 2 /nobreak >nul
if errorlevel 1 ( goto :retry_delete
echo [ERROR] Failed to backup app.asar, file is locked >> "%LOG_FILE%"
goto :start_app
) )
) )
echo [SUCCESS] Backup completed >> "%LOG_FILE%"
) )
echo [INFO] Applying update... >> "%LOG_FILE%" REM Install new JAR file
move /y "%UPDATE_FILE%" "%APP_ASAR%" >nul 2>&1 set NEW_JAR_PATH=%RESOURCES_DIR%%JAR_NAME%
set INSTALL_RETRY=0
:retry_install
move /y "%JAR_UPDATE%" "%NEW_JAR_PATH%" >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
echo [ERROR] Failed to apply update >> "%LOG_FILE%" set /a INSTALL_RETRY+=1
if exist "%APP_ASAR%.backup" ( if %INSTALL_RETRY% LEQ 5 (
echo [INFO] Restoring backup... >> "%LOG_FILE%" timeout /t 2 /nobreak >nul
move /y "%APP_ASAR%.backup" "%APP_ASAR%" >nul 2>&1 goto :retry_install
) )
goto :start_app goto :start_app
) )
if exist "%JAR_UPDATE%" del /f /q "%JAR_UPDATE%" >nul 2>&1
echo [SUCCESS] Update applied successfully! >> "%LOG_FILE%"
if exist "%UPDATE_FILE%" (
del /f /q "%UPDATE_FILE%" >nul 2>&1
)
:start_app :start_app
echo [INFO] Restarting application... >> "%LOG_FILE%" REM Clean up update directory
echo ======================================== >> "%LOG_FILE%" if exist "%UPDATE_DIR%" (
for %%F in ("%UPDATE_DIR%\*") do del /f /q "%%F" >nul 2>&1
)
start "" "%EXE_PATH%" start "" "%EXE_PATH%"
exit /b 0 exit /b 0

View File

@@ -6,7 +6,7 @@ const { defineConfig } = require('vite');
*/ */
const config = defineConfig({ const config = defineConfig({
root: Path.join(__dirname, 'src', 'renderer'), root: Path.join(__dirname, 'src', 'renderer'),
publicDir: Path.join(__dirname, 'src', 'renderer', 'public'), publicDir: Path.join(__dirname, 'public'),
server: { server: {
port: 8083, port: 8083,
}, },

View File

@@ -10,7 +10,7 @@
</parent> </parent>
<groupId>com.tashow.erp</groupId> <groupId>com.tashow.erp</groupId>
<artifactId>erp_client_sb</artifactId> <artifactId>erp_client_sb</artifactId>
<version>2.4.7</version> <version>2.6.3</version>
<name>erp_client_sb</name> <name>erp_client_sb</name>
<description>erp客户端</description> <description>erp客户端</description>
<properties> <properties>
@@ -54,9 +54,7 @@
<artifactId>webmagic-extension</artifactId> <artifactId>webmagic-extension</artifactId>
<version>1.0.3</version> <version>1.0.3</version>
</dependency> </dependency>
<!-- JavaFX 相关依赖已移除 --> <!-- JavaFX 相关依赖已移除 -->
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
@@ -66,7 +64,12 @@
<dependency> <dependency>
<groupId>cn.hutool</groupId> <groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId> <artifactId>hutool-crypto</artifactId>
<version>5.8.36</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-poi</artifactId>
<version>5.8.36</version> <version>5.8.36</version>
</dependency> </dependency>
<!-- SQLite数据库支持 --> <!-- SQLite数据库支持 -->
@@ -94,12 +97,6 @@
<artifactId>webdrivermanager</artifactId> <artifactId>webdrivermanager</artifactId>
<version>5.9.2</version> <version>5.9.2</version>
</dependency> </dependency>
<!-- https://mvnrepository.com/artifact/org.python/jython-standalone -->
<dependency>
<groupId>org.python</groupId>
<artifactId>jython-standalone</artifactId>
<version>2.7.4</version>
</dependency>
<!-- JWT parsing for local RS256 verification --> <!-- JWT parsing for local RS256 verification -->
<dependency> <dependency>
@@ -119,6 +116,18 @@
<version>0.11.5</version> <version>0.11.5</version>
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- OSHI for hardware information -->
<dependency>
<groupId>com.github.oshi</groupId>
<artifactId>oshi-core</artifactId>
<version>6.4.6</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.36</version>
<scope>compile</scope>
</dependency>
</dependencies> </dependencies>
@@ -166,6 +175,8 @@
</exclude> </exclude>
</excludes> </excludes>
<mainClass>com.tashow.erp.ErpClientSbApplication</mainClass> <mainClass>com.tashow.erp.ErpClientSbApplication</mainClass>
<executable>false</executable>
<layout>ZIP</layout>
</configuration> </configuration>
</plugin> </plugin>
<plugin> <plugin>

View File

@@ -1,5 +1,4 @@
package com.tashow.erp; package com.tashow.erp;
import com.tashow.erp.utils.ErrorReporter; import com.tashow.erp.utils.ErrorReporter;
import com.tashow.erp.utils.ResourcePreloader; import com.tashow.erp.utils.ResourcePreloader;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -8,9 +7,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.ConfigurableApplicationContext;
@Slf4j @Slf4j
@SpringBootApplication @SpringBootApplication(
exclude = {
org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration.class,
org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration.class
}
)
public class ErpClientSbApplication { public class ErpClientSbApplication {
public static void main(String[] args) { public static void main(String[] args) {
ConfigurableApplicationContext applicationContext = SpringApplication.run(ErpClientSbApplication.class, args); ConfigurableApplicationContext applicationContext = SpringApplication.run(ErpClientSbApplication.class, args);
ErrorReporter errorReporter = applicationContext.getBean(ErrorReporter.class); ErrorReporter errorReporter = applicationContext.getBean(ErrorReporter.class);
@@ -18,10 +21,8 @@ public class ErpClientSbApplication {
log.error("捕获到未处理异常: " + ex.getMessage(), ex); log.error("捕获到未处理异常: " + ex.getMessage(), ex);
errorReporter.reportSystemError("未捕获异常: " + thread.getName(), (Exception) ex); errorReporter.reportSystemError("未捕获异常: " + thread.getName(), (Exception) ex);
}); });
log.info("Started Success");
ResourcePreloader.init(); ResourcePreloader.init();
ResourcePreloader.preloadErpDashboard(); ResourcePreloader.preloadErpDashboard();
ResourcePreloader.executePreloading(); ResourcePreloader.executePreloading();
} }
} }

View File

@@ -0,0 +1,21 @@
package com.tashow.erp.common;
/**
* 1688业务常量
*/
public class Alibaba1688Constants {
public static final String APP_ID = "32517";
public static final String INTERFACE_NAME = "imageOfferSearchService";
public static final String APP_NAME = "ios";
public static final String SEARCH_SCENE = "image";
public static final String SEO_SCENE = "seoSearch";
public static final int PAGE_SIZE = 40;
public static final String JSV_VERSION = "2.6.1";
public static final String API_VERSION = "2.0";
public static final String DATA_TYPE = "json";
public static final int TIMEOUT_MS = 10000;
public static final String API_BASE = "https://h5api.m.1688.com/h5";
public static final String API_METHOD = "mtop.relationrecommend.WirelessRecommend.recommend";
private Alibaba1688Constants() {}
}

View File

@@ -0,0 +1,36 @@
package com.tashow.erp.common;
/**
* 亚马逊业务常量
*/
public class AmazonConstants {
public static final String REGION_JP = "JP";
public static final String REGION_US = "US";
public static final String DOMAIN_JP = "https://www.amazon.co.jp";
public static final String DOMAIN_US = "https://www.amazon.com";
public static final String URL_PRODUCT_PATH = "/dp/";
public static final String SESSION_PREFIX = "SINGLE_";
public static final String DATA_TYPE = "AMAZON";
public static final int RETRY_TIMES = 3;
public static final int SLEEP_TIME_BASE = 2000;
public static final int SLEEP_TIME_RANDOM = 2000;
public static final int TIMEOUT_MS = 20000;
public static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36";
public static final String HEADER_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8";
public static final String HEADER_ACCEPT_ENCODING = "gzip, deflate, br";
public static final String HEADER_ACCEPT_LANGUAGE_US = "zh-CN,zh;q=0.9,en;q=0.8";
public static final String HEADER_ACCEPT_LANGUAGE_JP = "ja,en;q=0.9,zh-CN;q=0.8";
public static final String HEADER_CACHE_CONTROL = "max-age=0";
public static final String COOKIE_I18N_PREFS_US = "USD";
public static final String COOKIE_I18N_PREFS_JP = "JPY";
public static final String COOKIE_LC_US = "en_US";
public static final String COOKIE_LC_JP = "zh_CN";
public static final String COOKIE_SESSION_ID_US = "134-6097934-2082600";
public static final String COOKIE_SESSION_ID_JP = "358-1261309-0483141";
public static final String COOKIE_SESSION_ID_TIME = "2082787201l";
public static final String COOKIE_UBID_US = "132-7547587-3056927";
public static final String COOKIE_UBID_JP = "357-8224002-9668932";
public static final String COOKIE_SKIN = "noskin";
private AmazonConstants() {}
}

View File

@@ -0,0 +1,23 @@
package com.tashow.erp.common;
/**
* 斑马业务常量
*/
public class BanmaConstants {
public static final String API_BASE = "https://banma365.cn";
public static final String API_ORDER_LIST = API_BASE + "/api/order/list?%srecipientName=&page=%d&size=%d&markFlag=0&state=4&_t=%d";
public static final String API_ORDER_LIST_WITH_TIME = API_BASE + "/api/order/list?%srecipientName=&page=%d&size=%d&markFlag=0&state=4&orderedAtStart=%s&orderedAtEnd=%s&_t=%d";
public static final String API_TRACKING = API_BASE + "/zebraExpressHub/web/tracking/getByExpressNumber/%s";
public static final String API_SHOP_LIST = API_BASE + "/api/shop/list?_t=%d";
public static final int CONNECT_TIMEOUT_SECONDS = 5;
public static final int READ_TIMEOUT_SECONDS = 10;
public static final String DATA_TYPE = "BANMA";
public static final String DATA_TYPE_CACHE = "BANMA_CACHE";
public static final String TRACKING_PREFIX_ORDER = "ORDER_";
public static final String TRACKING_PREFIX_PRODUCT = "PRODUCT_";
public static final String TRACKING_PREFIX_UNKNOWN = "UNKNOWN_";
public static final String SESSION_PREFIX = "SESSION_";
public static final int CACHE_HOURS = 1;
private BanmaConstants() {}
}

View File

@@ -0,0 +1,13 @@
package com.tashow.erp.common;
/**
* 缓存相关常量
*/
public class CacheConstants {
public static final int DATA_RETENTION_HOURS = 1;
public static final int TRADEMARK_CACHE_DAYS = 1;
public static final int SESSION_LIMIT = 1;
public static final int RAKUTEN_CACHE_HOURS = 1;
private CacheConstants() {}
}

View File

@@ -1,169 +1,16 @@
package com.tashow.erp.common; package com.tashow.erp.common;
import java.util.Locale; import java.util.Locale;
/** /**
* 通用常量信息 * 通用常量(保留兼容性)
* * 新代码请使用具体业务常量类AmazonConstants、RakutenConstants、HttpConstants等
* @author ruoyi
*/ */
public class Constants @Deprecated
{ public class Constants {
/** public static final String HTTP = HttpConstants.HTTP;
* UTF-8 字符集 public static final String HTTPS = HttpConstants.HTTPS;
*/ public static final Locale DEFAULT_LOCALE = Locale.SIMPLIFIED_CHINESE;
public static final String UTF8 = "UTF-8";
/** private Constants() {}
* GBK 字符集
*/
public static final String GBK = "GBK";
/**
* 系统语言
*/
public static final Locale DEFAULT_LOCALE = Locale.SIMPLIFIED_CHINESE;
/**
* www主域
*/
public static final String WWW = "www.";
/**
* http请求
*/
public static final String HTTP = "http://";
/**
* https请求
*/
public static final String HTTPS = "https://";
/**
* 通用成功标识
*/
public static final String SUCCESS = "0";
/**
* 通用失败标识
*/
public static final String FAIL = "1";
/**
* 登录成功
*/
public static final String LOGIN_SUCCESS = "Success";
/**
* 注销
*/
public static final String LOGOUT = "Logout";
/**
* 注册
*/
public static final String REGISTER = "Register";
/**
* 登录失败
*/
public static final String LOGIN_FAIL = "Error";
/**
* 所有权限标识
*/
public static final String ALL_PERMISSION = "*:*:*";
/**
* 管理员角色权限标识
*/
public static final String SUPER_ADMIN = "admin";
/**
* 角色权限分隔符
*/
public static final String ROLE_DELIMETER = ",";
/**
* 权限标识分隔符
*/
public static final String PERMISSION_DELIMETER = ",";
/**
* 验证码有效期(分钟)
*/
public static final Integer CAPTCHA_EXPIRATION = 2;
/**
* 令牌
*/
public static final String TOKEN = "token";
/**
* 令牌前缀
*/
public static final String TOKEN_PREFIX = "Bearer ";
/**
* 令牌前缀
*/
public static final String LOGIN_USER_KEY = "login_user_key";
/**
* 用户ID
*/
public static final String JWT_USERID = "userid";
/**
* 用户头像
*/
public static final String JWT_AVATAR = "avatar";
/**
* 创建时间
*/
public static final String JWT_CREATED = "created";
/**
* 用户权限
*/
public static final String JWT_AUTHORITIES = "authorities";
/**
* 资源映射路径 前缀
*/
public static final String RESOURCE_PREFIX = "/profile";
/**
* RMI 远程方法调用
*/
public static final String LOOKUP_RMI = "rmi:";
/**
* LDAP 远程方法调用
*/
public static final String LOOKUP_LDAP = "ldap:";
/**
* LDAPS 远程方法调用
*/
public static final String LOOKUP_LDAPS = "ldaps:";
/**
* 自动识别json对象白名单配置仅允许解析的包名范围越小越安全
*/
public static final String[] JSON_WHITELIST_STR = { "org.springframework", "com.ruoyi" };
/**
* 定时任务白名单配置(仅允许访问的包名,如其他需要可以自行添加)
*/
public static final String[] JOB_WHITELIST_STR = { "com.ruoyi.quartz.task" };
/**
* 定时任务违规的字符
*/
public static final String[] JOB_ERROR_STR = { "java.net.URL", "javax.naming.InitialContext", "org.yaml.snakeyaml",
"org.springframework", "org.apache", "com.ruoyi.common.utils.file", "com.ruoyi.common.config", "com.ruoyi.generator" };
} }

View File

@@ -0,0 +1,14 @@
package com.tashow.erp.common;
/**
* 方舟精选业务常量
*/
public class FangzhouConstants {
public static final String API_URL = "https://api.fangzhoujingxuan.com/Task";
public static final String API_SECRET = "e10adc3949ba59abbe56e057f20f883e";
public static final int TOKEN_EXPIRED_CODE = -1006;
public static final String WEBSITE_CODE = "1";
public static final int SUCCESS_CODE = 1;
private FangzhouConstants() {}
}

View File

@@ -0,0 +1,13 @@
package com.tashow.erp.common;
/**
* HTTP协议常量
*/
public class HttpConstants {
public static final String HTTP = "http://";
public static final String HTTPS = "https://";
public static final String CHARSET_UTF8 = "UTF-8";
public static final String CHARSET_GBK = "GBK";
private HttpConstants() {}
}

View File

@@ -0,0 +1,16 @@
package com.tashow.erp.common;
/**
* 乐天业务常量
*/
public class RakutenConstants {
public static final String DATA_TYPE = "RAKUTEN";
public static final String DOMAIN = "https://item.rakuten.co.jp";
public static final int RETRY_TIMES = 3;
public static final int SLEEP_TIME_BASE = 2000;
public static final int SLEEP_TIME_RANDOM = 2000;
public static final int TIMEOUT_MS = 20000;
public static final String DATA_TYPE_CACHE = "RAKUTEN_CACHE";
private RakutenConstants() {}
}

View File

@@ -0,0 +1,47 @@
package com.tashow.erp.config;
import com.tashow.erp.utils.SeleniumUtil;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.openqa.selenium.chrome.ChromeDriver;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
/**
* ChromeDriver 配置类
* 已禁用预加载,由 TrademarkCheckUtil 按需创建
*/
@Slf4j
@Configuration
@Order(100)
public class ChromeDriverPreloader implements ApplicationRunner {
private ChromeDriver globalDriver;
@Override
public void run(ApplicationArguments args) {
// 不再预加载,节省资源
log.info("ChromeDriver 配置已加载(按需启动)");
}
@Bean
public ChromeDriver chromeDriver() {
// 为兼容性保留 Bean但不自动创建
if (globalDriver == null) globalDriver = SeleniumUtil.createDriver(true);
return globalDriver;
}
@PreDestroy
public void cleanup() {
if (globalDriver != null) {
try {
globalDriver.quit();
} catch (Exception e) {
log.error("关闭ChromeDriver失败", e);
}
}
}
}

View File

@@ -31,7 +31,6 @@ public class ErrorReportAspect {
// 检查返回值是否表示错误 // 检查返回值是否表示错误
if (result instanceof JsonData) { if (result instanceof JsonData) {
JsonData jsonData = (JsonData) result; JsonData jsonData = (JsonData) result;
// code != 0 表示失败根据JsonData注释0表示成功-1表示失败1表示处理中
if (jsonData.getCode() != null && jsonData.getCode() != 0) { if (jsonData.getCode() != null && jsonData.getCode() != 0) {
// 创建一个RuntimeException来包装错误信息 // 创建一个RuntimeException来包装错误信息
String errorMsg = jsonData.getMsg() != null ? jsonData.getMsg() : "未知错误"; String errorMsg = jsonData.getMsg() != null ? jsonData.getMsg() : "未知错误";

View File

@@ -1,13 +0,0 @@
package com.tashow.erp.config;
import com.tashow.erp.fx.controller.JavaBridge;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class JavaBridgeConfig {
@Bean
public JavaBridge javaBridge() {
return new JavaBridge();
}
}

View File

@@ -1,62 +1,85 @@
package com.tashow.erp.controller; package com.tashow.erp.controller;
import com.tashow.erp.entity.AmazonProductEntity; import com.tashow.erp.entity.AmazonProductEntity;
import com.tashow.erp.repository.AmazonProductRepository; import com.tashow.erp.repository.AmazonProductRepository;
import com.tashow.erp.service.IAmazonScrapingService; import com.tashow.erp.service.AmazonScrapingService;
import com.tashow.erp.utils.ExcelParseUtil; import com.tashow.erp.utils.ExcelParseUtil;
import com.tashow.erp.utils.ExcelExportUtil;
import com.tashow.erp.utils.JsonData; import com.tashow.erp.utils.JsonData;
import com.tashow.erp.utils.JwtUtil;
import com.tashow.erp.utils.LoggerUtil; import com.tashow.erp.utils.LoggerUtil;
import com.tashow.erp.fx.controller.JavaBridge;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.util.HashMap; import jakarta.servlet.http.HttpServletRequest;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.ArrayList;
import java.util.Optional; /**
* 亚马逊数据控制器
* 提供亚马逊商品采集、批量获取、Excel导入等功能
*
* @author 占子杰牛逼
*/
@RestController @RestController
@RequestMapping("/api/amazon") @RequestMapping("/api/amazon")
public class AmazonController { public class AmazonController {
private static final Logger logger = LoggerUtil.getLogger(AmazonController.class); private static final Logger logger = LoggerUtil.getLogger(AmazonController.class);
@Autowired @Autowired
private IAmazonScrapingService amazonScrapingService; private AmazonScrapingService amazonScrapingService;
@Autowired @Autowired
private AmazonProductRepository amazonProductRepository; private AmazonProductRepository amazonProductRepository;
/** /**
* 批量获取亚马逊品信息 * 批量获取亚马逊品信息
*
* @param request 包含asinList、batchId和region的请求参数
* @param httpRequest HTTP请求对象用于获取用户信息
* @return 商品列表和总数
*/ */
@PostMapping("/products/batch") @PostMapping("/products/batch")
public JsonData batchGetProducts(@RequestBody Object request) { public JsonData batchGetProducts(@RequestBody Map<String, Object> request, HttpServletRequest httpRequest) {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Object> requestMap = (Map<String, Object>) request; List<String> asinList = (List<String>) request.get("asinList");
List<String> asinList = (List<String>) requestMap.get("asinList"); String batchId = (String) request.get("batchId");
String batchId = (String) requestMap.get("batchId"); String region = (String) request.getOrDefault("region", "JP");
String region = (String) requestMap.getOrDefault("region", "JP");
List<AmazonProductEntity> products = amazonScrapingService.batchGetProductInfo(asinList, batchId, region); // 从 token 中获取 username
Map<String, Object> result = new HashMap<>(); String username = JwtUtil.getUsernameFromRequest(httpRequest);
result.put("products", products); // 构建带用户隔离的 sessionId
result.put("total", products.size()); String userSessionId = JwtUtil.buildUserSessionId(username, batchId);
return JsonData.buildSuccess(result);
List<AmazonProductEntity> products = amazonScrapingService.batchGetProductInfo(asinList, userSessionId, region);
return JsonData.buildSuccess(Map.of("products", products, "total", products.size()));
} }
/** /**
* 获取最新品数据 * 获取最新的亚马逊商品数据
*
* @param request HTTP请求对象用于获取用户信息
* @return 最新商品列表和总数
*/ */
@GetMapping("/products/latest") @GetMapping("/products/latest")
public JsonData getLatestProducts() { public JsonData getLatestProducts(HttpServletRequest request) {
List<AmazonProductEntity> products = amazonProductRepository.findLatestProducts(); String username = JwtUtil.getUsernameFromRequest(request);
Map<String, Object> result = new HashMap<>(); List<String> recentSessions = amazonProductRepository.findRecentSessionIds(org.springframework.data.domain.PageRequest.of(0, 1));
result.put("products", products); String latestSession = recentSessions.stream()
result.put("total", products.size()); .filter(sid -> sid != null && sid.startsWith(username + "#"))
return JsonData.buildSuccess(result); .findFirst()
.orElse("");
if (latestSession.isEmpty()) {
return JsonData.buildSuccess(Map.of("products", List.of(), "total", 0));
}
List<AmazonProductEntity> products = amazonProductRepository.findBySessionIdOrderByCreatedAtDesc(latestSession);
return JsonData.buildSuccess(Map.of("products", products, "total", products.size()));
} }
/** /**
* 解析Excel文件获取ASIN列表 * Excel文件导入ASIN列表
*
* @param file Excel文件
* @return ASIN列表和总数
*/ */
@PostMapping("/import/asin") @PostMapping("/import/asin")
public JsonData importAsinFromExcel(@RequestParam("file") MultipartFile file) { public JsonData importAsinFromExcel(@RequestParam("file") MultipartFile file) {
@@ -65,16 +88,10 @@ public class AmazonController {
if (asinList.isEmpty()) { if (asinList.isEmpty()) {
return JsonData.buildError("未从文件中解析到ASIN数据"); return JsonData.buildError("未从文件中解析到ASIN数据");
} }
return JsonData.buildSuccess(Map.of("asinList", asinList, "total", asinList.size()));
Map<String, Object> result = new HashMap<>();
result.put("asinList", asinList);
result.put("total", asinList.size());
return JsonData.buildSuccess(result);
} catch (Exception e) { } catch (Exception e) {
logger.error("解析文件失败: {}", e.getMessage(), e); logger.error("解析文件失败: {}", e.getMessage(), e);
return JsonData.buildError("解析失败: " + e.getMessage()); return JsonData.buildError("解析失败: " + e.getMessage());
} }
} }
} }

View File

@@ -1,140 +0,0 @@
package com.tashow.erp.controller;
import com.tashow.erp.entity.AuthTokenEntity;
import com.tashow.erp.entity.CacheDataEntity;
import com.tashow.erp.repository.AuthTokenRepository;
import com.tashow.erp.repository.CacheDataRepository;
import com.tashow.erp.service.IAuthService;
import com.tashow.erp.utils.JsonData;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
import java.util.Optional;
/**
* 客户端本地服务控制器
*/
@RestController
@RequestMapping("/api")
public class AuthController {
@Autowired
private AuthTokenRepository authTokenRepository;
@Autowired
private CacheDataRepository cacheDataRepository;
/**
* 退出登录(清理本地状态)
*/
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestBody Map<String, Object> data) {
// 清理本地缓存
try {
cacheDataRepository.deleteByCacheKey("token");
cacheDataRepository.deleteByCacheKey("deviceId");
} catch (Exception ignored) {}
return ResponseEntity.ok(Map.of("code", 0, "message", "退出成功"));
}
/**
* 保存认证密钥
*/
@PostMapping("/auth/save")
public JsonData saveAuth(@RequestBody Map<String, Object> data) {
String serviceName = (String) data.get("serviceName");
String authKey = (String) data.get("authKey");
if (serviceName == null || authKey == null) return JsonData.buildError("serviceName和authKey不能为空");
AuthTokenEntity entity = authTokenRepository.findByServiceName(serviceName).orElse(new AuthTokenEntity());
entity.setServiceName(serviceName);
entity.setToken(authKey);
authTokenRepository.save(entity);
return JsonData.buildSuccess("认证信息保存成功");
}
@GetMapping("/auth/get")
public JsonData getAuth(@RequestParam String serviceName) {
return JsonData.buildSuccess(authTokenRepository.findByServiceName(serviceName).map(AuthTokenEntity::getToken).orElse(null));
}
/**
* 删除认证密钥
*/
@DeleteMapping("/auth/remove")
public JsonData removeAuth(@RequestParam String serviceName) {
authTokenRepository.findByServiceName(serviceName).ifPresent(authTokenRepository::delete);
return JsonData.buildSuccess("认证信息删除成功");
}
/**
* 保存缓存数据
*/
@PostMapping("/cache/save")
public JsonData saveCache(@RequestBody Map<String, Object> data) {
String key = (String) data.get("key");
String value = (String) data.get("value");
if (key == null || value == null) return JsonData.buildError("key和value不能为空");
CacheDataEntity entity = cacheDataRepository.findByCacheKey(key).orElse(new CacheDataEntity());
entity.setCacheKey(key);
entity.setCacheValue(value);
cacheDataRepository.save(entity);
return JsonData.buildSuccess("缓存数据保存成功");
}
/**
* 获取缓存数据
*/
@GetMapping("/cache/get")
public JsonData getCache(@RequestParam String key) {
return JsonData.buildSuccess(cacheDataRepository.findByCacheKey(key)
.map(CacheDataEntity::getCacheValue).orElse(null));
}
/**
* 删除缓存数据
*/
@DeleteMapping("/cache/remove")
public JsonData removeCache(@RequestParam String key) {
cacheDataRepository.findByCacheKey(key).ifPresent(cacheDataRepository::delete);
return JsonData.buildSuccess("缓存数据删除成功");
}
/**
* 删除缓存数据
*/
@PostMapping("/cache/delete")
public JsonData deleteCacheByPost(@RequestParam String key) {
if (key == null || key.trim().isEmpty()) {
return JsonData.buildError("key不能为空");
}
System.out.println("key: " + key);
cacheDataRepository.deleteByCacheKey(key);
return JsonData.buildSuccess("缓存数据删除成功");
}
/**
* 会话引导检查SQLite中是否存在token
*/
@GetMapping("/session/bootstrap")
public JsonData sessionBootstrap() {
Optional<CacheDataEntity> tokenEntity = cacheDataRepository.findByCacheKey("token");
if (tokenEntity.isEmpty()) {
return JsonData.buildError("无可用会话,请重新登录");
}
String token = tokenEntity.get().getCacheValue();
if (token == null || token.isEmpty()) {
return JsonData.buildError("无可用会话,请重新登录");
}
return JsonData.buildSuccess("会话已恢复");
}
/**
* 获取设备ID
*/
@GetMapping("/device-id")
public JsonData getDeviceId() {
String deviceId = com.tashow.erp.utils.DeviceUtils.generateDeviceId();
return JsonData.buildSuccess(deviceId);
}
}

View File

@@ -1,61 +1,90 @@
package com.tashow.erp.controller; package com.tashow.erp.controller;
import com.tashow.erp.fx.controller.JavaBridge;
import com.tashow.erp.repository.BanmaOrderRepository; import com.tashow.erp.repository.BanmaOrderRepository;
import com.tashow.erp.service.IBanmaOrderService; import com.tashow.erp.service.BanmaOrderService;
import com.tashow.erp.utils.ExcelExportUtil;
import com.tashow.erp.utils.JsonData; import com.tashow.erp.utils.JsonData;
import com.tashow.erp.utils.JwtUtil;
import com.tashow.erp.utils.LoggerUtil; import com.tashow.erp.utils.LoggerUtil;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate; import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* 斑马订单控制器
* 提供斑马订单查询、店铺列表等功能
*
* @author 占子杰牛逼
*/
@RestController @RestController
@RequestMapping("/api/banma") @RequestMapping("/api/banma")
public class BanmaOrderController { public class BanmaOrderController {
private static final Logger logger = LoggerUtil.getLogger(BanmaOrderController.class); private static final Logger logger = LoggerUtil.getLogger(BanmaOrderController.class);
@Autowired @Autowired
IBanmaOrderService banmaOrderService; BanmaOrderService banmaOrderService;
@Autowired @Autowired
BanmaOrderRepository banmaOrderRepository; BanmaOrderRepository banmaOrderRepository;
/**
* 分页查询斑马订单
*
* @param accountId 账号ID可选
* @param startDate 开始日期(可选)
* @param endDate 结束日期(可选)
* @param page 页码默认为1
* @param pageSize 每页大小默认为10
* @param batchId 批次ID
* @param shopIds 店铺ID列表多个用逗号分隔可选
* @param request HTTP请求对象用于获取用户信息
* @return 订单列表和分页信息
*/
@GetMapping("/orders") @GetMapping("/orders")
public ResponseEntity<Map<String, Object>> getOrders( public JsonData getOrders(
@RequestParam(required = false, name = "accountId") Long accountId, @RequestParam(required = false, name = "accountId") Long accountId,
@RequestParam(required = false, name = "startDate") String startDate, @RequestParam(required = false, name = "startDate") String startDate,
@RequestParam(required = false, name = "endDate") String endDate, @RequestParam(required = false, name = "endDate") String endDate,
@RequestParam(defaultValue = "1", name = "page") int page, @RequestParam(defaultValue = "1", name = "page") int page,
@RequestParam(defaultValue = "10", name = "pageSize") int pageSize, @RequestParam(defaultValue = "10", name = "pageSize") int pageSize,
@RequestParam( "batchId") String batchId, @RequestParam("batchId") String batchId,
@RequestParam(required = false, name = "shopIds") String shopIds) { @RequestParam(required = false, name = "shopIds") String shopIds,
HttpServletRequest request) {
// 从 token 中获取 username
String username = JwtUtil.getUsernameFromRequest(request);
// 构建带用户隔离的 sessionId
String userSessionId = JwtUtil.buildUserSessionId(username, batchId);
List<String> shopIdList = shopIds != null ? java.util.Arrays.asList(shopIds.split(",")) : null; List<String> shopIdList = shopIds != null ? java.util.Arrays.asList(shopIds.split(",")) : null;
Map<String, Object> result = banmaOrderService.getOrdersByPage(accountId, startDate, endDate, page, pageSize, batchId, shopIdList); Map<String, Object> result = banmaOrderService.getOrdersByPage(accountId, startDate, endDate, page, pageSize, userSessionId, shopIdList);
return ResponseEntity.ok(result); return result.containsKey("success") && !(Boolean)result.get("success")
} ? JsonData.buildError((String)result.get("message"))
/** : JsonData.buildSuccess(result);
* 获取店铺列表
*/
@GetMapping("/shops")
public JsonData getShops(@RequestParam(required = false, name = "accountId") Long accountId) {
try {
Map<String, Object> response = banmaOrderService.getShops(accountId);
return JsonData.buildSuccess(response);
} catch (Exception e) {
logger.error("获取店铺列表失败: {}", e.getMessage(), e);
return JsonData.buildError("获取店铺列表失败: " + e.getMessage());
}
} }
/** /**
* 获取最新订单数据 * 获取店铺列表
*
* @param accountId 账号ID可选
* @return 店铺列表
*/
@GetMapping("/shops")
public JsonData getShops(@RequestParam(required = false, name = "accountId") Long accountId) {
Map<String, Object> response = banmaOrderService.getShops(accountId);
return JsonData.buildSuccess(response);
}
/**
* 获取最新的斑马订单数据
*
* @param request HTTP请求对象用于获取用户信息
* @return 最新订单列表和总数
*/ */
@GetMapping("/orders/latest") @GetMapping("/orders/latest")
public JsonData getLatestOrders() { public JsonData getLatestOrders(HttpServletRequest request) {
String username = JwtUtil.getUsernameFromRequest(request);
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper(); com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
List<Map<String, Object>> orders = banmaOrderRepository.findLatestOrders() List<Map<String, Object>> orders = banmaOrderRepository.findLatestOrders(username)
.parallelStream() .parallelStream()
.map(entity -> { .map(entity -> {
try { try {
@@ -68,8 +97,6 @@ public class BanmaOrderController {
}) })
.filter(order -> !order.isEmpty()) .filter(order -> !order.isEmpty())
.toList(); .toList();
return JsonData.buildSuccess(Map.of("orders", orders, "total", orders.size())); return JsonData.buildSuccess(Map.of("orders", orders, "total", orders.size()));
} }
} }

View File

@@ -1,30 +0,0 @@
package com.tashow.erp.controller;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 配置信息控制器
*/
@RestController
@RequestMapping("/api/config")
public class ConfigController {
@Value("${api.server.base-url}")
private String serverBaseUrl;
/**
* 获取服务器配置
*/
@GetMapping("/server")
public Map<String, Object> getServerConfig() {
return Map.of(
"baseUrl", serverBaseUrl,
"sseUrl", serverBaseUrl + "/monitor/account/events"
);
}
}

View File

@@ -1,25 +0,0 @@
package com.tashow.erp.controller;
import com.tashow.erp.service.IGenmaiService;
import com.tashow.erp.utils.JsonData;
import com.tashow.erp.utils.LoggerUtil;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/genmai")
public class GenmaiController {
private static final Logger logger = LoggerUtil.getLogger(GenmaiController.class);
@Autowired
private IGenmaiService genmaiService;
/**
* 打开跟卖精灵网页
*/
@PostMapping("/open")
public void openGenmaiWebsite() {
genmaiService.openGenmaiWebsite();
}
}

View File

@@ -1,18 +0,0 @@
package com.tashow.erp.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class HomeController {
@GetMapping("/")
public String home() {
return "redirect:/html/erp-dashboard.html";
}
@GetMapping("/erp")
public String erp() {
return "redirect:/html/erp-dashboard.html";
}
}

View File

@@ -1,116 +0,0 @@
package com.tashow.erp.controller;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
/**
* 代理控制器用于解决CORS跨域问题
*/
@RestController
@RequestMapping("/api/proxy")
public class ProxyController {
@Autowired
private RestTemplate restTemplate;
/**
* 代理获取图片
* @param requestBody 包含图片URL的请求体
* @return 图片字节数组
*/
@PostMapping("/image")
public ResponseEntity<byte[]> proxyImage(@RequestBody Map<String, String> requestBody) {
String imageUrl = requestBody.get("imageUrl");
if (imageUrl == null || imageUrl.isEmpty()) {
return ResponseEntity.badRequest().build();
}
try {
// 设置请求头
HttpHeaders headers = new HttpHeaders();
headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36");
headers.set("Accept", "image/jpeg, image/png, image/webp, image/*");
HttpEntity<String> entity = new HttpEntity<>(headers);
// 发送请求获取图片
ResponseEntity<byte[]> response = restTemplate.exchange(
imageUrl,
HttpMethod.GET,
entity,
byte[].class
);
// 设置响应头
HttpHeaders responseHeaders = new HttpHeaders();
responseHeaders.setContentType(MediaType.IMAGE_JPEG);
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
} catch (Exception e) {
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* 通过URL参数代理获取图片
* @param imageUrl 图片URL
* @return 图片字节数组
*/
@GetMapping("/image-url")
public ResponseEntity<byte[]> proxyImageByUrl(@RequestParam("url") String imageUrl) {
if (imageUrl == null || imageUrl.isEmpty()) {
System.err.println("图片代理请求失败: 图片URL为空");
return ResponseEntity.badRequest().build();
}
System.out.println("代理图片请求: " + imageUrl);
try {
// 设置请求头
HttpHeaders headers = new HttpHeaders();
headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36");
headers.set("Accept", "image/jpeg, image/png, image/webp, image/*");
headers.set("Referer", "https://item.rakuten.co.jp/");
HttpEntity<String> entity = new HttpEntity<>(headers);
// 发送请求获取图片
ResponseEntity<byte[]> response = restTemplate.exchange(
imageUrl,
HttpMethod.GET,
entity,
byte[].class
);
System.out.println("图片代理成功,响应大小: " + (response.getBody() != null ? response.getBody().length : 0) + " bytes");
// 设置响应头支持缓存以提升JavaFX WebView性能
HttpHeaders responseHeaders = new HttpHeaders();
// 尝试从原始响应中获取Content-Type
String contentType = response.getHeaders().getFirst("Content-Type");
if (contentType != null && contentType.startsWith("image/")) {
responseHeaders.setContentType(MediaType.parseMediaType(contentType));
} else {
responseHeaders.setContentType(MediaType.IMAGE_JPEG);
}
// 设置缓存头以提升性能
responseHeaders.setCacheControl("max-age=3600");
// 删除手动CORS设置使用WebConfig中的全局CORS配置
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
} catch (Exception e) {
System.err.println("图片代理失败: " + imageUrl + " - " + e.getMessage());
e.printStackTrace();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}

View File

@@ -1,33 +1,30 @@
package com.tashow.erp.controller; package com.tashow.erp.controller;
import com.tashow.erp.common.RakutenConstants;
import com.tashow.erp.model.RakutenProduct; import com.tashow.erp.model.RakutenProduct;
import com.tashow.erp.model.SearchResult; import com.tashow.erp.model.SearchResult;
import com.tashow.erp.repository.RakutenProductRepository;
import com.tashow.erp.service.Alibaba1688Service; import com.tashow.erp.service.Alibaba1688Service;
import com.tashow.erp.service.IRakutenCacheService; import com.tashow.erp.service.RakutenCacheService;
import com.tashow.erp.service.RakutenScrapingService; import com.tashow.erp.service.RakutenScrapingService;
import com.tashow.erp.service.impl.Alibaba1688ServiceImpl;
import com.tashow.erp.utils.DataReportUtil; import com.tashow.erp.utils.DataReportUtil;
import com.tashow.erp.utils.ExcelParseUtil; import com.tashow.erp.utils.ExcelParseUtil;
import com.tashow.erp.utils.JsonData; import com.tashow.erp.utils.JsonData;
import com.tashow.erp.utils.QiniuUtil; import com.tashow.erp.utils.JwtUtil;
import com.tashow.erp.fx.controller.JavaBridge;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.client.RestTemplate; import jakarta.servlet.http.HttpServletRequest;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
import java.util.Base64;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 乐天数据控制器
* 提供乐天商品采集、1688识图搜索等功能
*
* @author 占子杰牛逼
*/
@RestController @RestController
@RequestMapping("/api/rakuten") @RequestMapping("/api/rakuten")
@Slf4j @Slf4j
@@ -37,35 +34,38 @@ public class RakutenController {
@Autowired @Autowired
private Alibaba1688Service alibaba1688Service; private Alibaba1688Service alibaba1688Service;
@Autowired @Autowired
private IRakutenCacheService rakutenCacheService; private RakutenCacheService rakutenCacheService;
@Autowired
private JavaBridge javaBridge;
@Autowired @Autowired
private DataReportUtil dataReportUtil; private DataReportUtil dataReportUtil;
/** /**
* 获取乐天商品数据 * 从Excel获取乐天商品信息
* 支持缓存机制,避免重复采集
* *
* @param file Excel文件首列为店铺名 * @param file 包含店铺名称的Excel文件
* @param batchId 可选,批次号 * @param batchId 批次ID可选
* @return JsonData 响应 * @param request HTTP请求对象用于获取用户信息
* @return 商品列表、总数、跳过的店铺等信息
*/ */
@PostMapping(value = "/products") @PostMapping(value = "/products")
public JsonData getProducts(@RequestParam("file") MultipartFile file, @RequestParam(value = "batchId", required = false) String batchId) { public JsonData getProducts(@RequestParam("file") MultipartFile file, @RequestParam(value = "batchId", required = false) String batchId, HttpServletRequest request) {
try { try {
// 从 token 中获取 username
String username = JwtUtil.getUsernameFromRequest(request);
// 构建带用户隔离的 sessionId
String userSessionId = JwtUtil.buildUserSessionId(username, batchId);
List<String> shopNames = ExcelParseUtil.parseFirstColumn(file); List<String> shopNames = ExcelParseUtil.parseFirstColumn(file);
if (CollectionUtils.isEmpty(shopNames)) { if (CollectionUtils.isEmpty(shopNames)) {
return JsonData.buildError("Excel文件中未解析到店铺名"); return JsonData.buildError("Excel文件中未解析到店铺名");
} }
List<RakutenProduct> allProducts = new ArrayList<>(); List<RakutenProduct> allProducts = new ArrayList<>();
List<String> skippedShops = new ArrayList<>(); List<String> skippedShops = new ArrayList<>();
// 2. 遍历店铺,优先缓存,缺失则爬取
for (String currentShopName : shopNames) { for (String currentShopName : shopNames) {
if (rakutenCacheService.hasRecentData(currentShopName)) { if (rakutenCacheService.hasRecentData(currentShopName, username)) {
// 从缓存获取 // 从缓存获取
List<RakutenProduct> cached = rakutenCacheService.getProductsByShopName(currentShopName).stream().filter(p -> currentShopName.equals(p.getOriginalShopName())).toList(); List<RakutenProduct> cached = rakutenCacheService.getProductsByShopName(currentShopName, username).stream().filter(p -> currentShopName.equals(p.getOriginalShopName())).toList();
rakutenCacheService.updateSpecificProductsSessionId(cached, batchId); rakutenCacheService.updateSpecificProductsSessionId(cached, userSessionId);
allProducts.addAll(cached); allProducts.addAll(cached);
skippedShops.add(currentShopName); skippedShops.add(currentShopName);
log.info("使用缓存数据,店铺: {},数量: {}", currentShopName, cached.size()); log.info("使用缓存数据,店铺: {},数量: {}", currentShopName, cached.size());
@@ -80,15 +80,19 @@ public class RakutenController {
} }
List<RakutenProduct> newProducts = allProducts.stream().filter(p -> !skippedShops.contains(p.getOriginalShopName())).toList(); List<RakutenProduct> newProducts = allProducts.stream().filter(p -> !skippedShops.contains(p.getOriginalShopName())).toList();
if (!newProducts.isEmpty()) { if (!newProducts.isEmpty()) {
rakutenCacheService.saveProductsWithSessionId(newProducts, batchId); rakutenCacheService.saveProductsWithSessionId(newProducts, userSessionId);
} }
// 4. 上报缓存数据使用情况
int cachedCount = allProducts.size() - newProducts.size(); int cachedCount = allProducts.size() - newProducts.size();
if (cachedCount > 0) { if (cachedCount > 0) {
dataReportUtil.reportDataCollection("RAKUTEN_CACHE", cachedCount, "0"); dataReportUtil.reportDataCollection(RakutenConstants.DATA_TYPE_CACHE, cachedCount, "0");
} }
return JsonData.buildSuccess(Map.of(
return JsonData.buildSuccess(Map.of("products", allProducts, "total", allProducts.size(), "sessionId", batchId, "skippedShops", skippedShops, "newProductsCount", newProducts.size())); "products", allProducts,
"total", allProducts.size(),
"sessionId", batchId,
"skippedShops", skippedShops,
"newProductsCount", newProducts.size()
));
} catch (Exception e) { } catch (Exception e) {
log.error("获取乐天商品失败", e); log.error("获取乐天商品失败", e);
return JsonData.buildError("获取乐天商品失败: " + e.getMessage()); return JsonData.buildError("获取乐天商品失败: " + e.getMessage());
@@ -96,15 +100,25 @@ public class RakutenController {
} }
/** /**
* 1688识图搜索API - 自动保存1688搜索结果 * 1688识图搜索
* 根据图片URL在1688平台进行识图搜索
*
* @param params 包含imageUrl和sessionId的参数
* @param request HTTP请求对象用于获取用户信息
* @return 搜索结果
*/ */
@PostMapping("/search1688") @PostMapping("/search1688")
public JsonData search1688(@RequestBody Map<String, Object> params) { public JsonData search1688(@RequestBody Map<String, Object> params, HttpServletRequest request) {
String imageUrl = (String) params.get("imageUrl"); String imageUrl = (String) params.get("imageUrl");
String sessionId = (String) params.get("sessionId"); String sessionId = (String) params.get("sessionId");
try { try {
// 从 token 中获取 username
String username = JwtUtil.getUsernameFromRequest(request);
// 构建带用户隔离的 sessionId
String userSessionId = JwtUtil.buildUserSessionId(username, sessionId);
SearchResult result = alibaba1688Service.get1688Detail(imageUrl); SearchResult result = alibaba1688Service.get1688Detail(imageUrl);
rakutenScrapingService.update1688DataByImageUrl(result, sessionId, imageUrl); rakutenScrapingService.update1688DataByImageUrl(result, userSessionId, imageUrl);
return JsonData.buildSuccess(result); return JsonData.buildSuccess(result);
} catch (Exception e) { } catch (Exception e) {
log.error("1688识图搜索失败", e); log.error("1688识图搜索失败", e);
@@ -112,39 +126,21 @@ public class RakutenController {
} }
} }
/**
* 获取最新的乐天商品数据
*
* @param request HTTP请求对象用于获取用户信息
* @return 最新商品列表和总数
*/
@GetMapping("/products/latest") @GetMapping("/products/latest")
public JsonData getLatestProducts() { public JsonData getLatestProducts(HttpServletRequest request) {
try { try {
List<Map<String, Object>> products = rakutenScrapingService.getLatestProductsForDisplay(); String username = JwtUtil.getUsernameFromRequest(request);
List<Map<String, Object>> products = rakutenScrapingService.getLatestProductsForDisplay(username);
return JsonData.buildSuccess(Map.of("products", products, "total", products.size())); return JsonData.buildSuccess(Map.of("products", products, "total", products.size()));
} catch (Exception e) { } catch (Exception e) {
log.error("获取最新商品数据失败", e);
e.printStackTrace();
log.info("获取最新商品数据失败", e);
return JsonData.buildError("获取最新数据失败: " + e.getMessage()); return JsonData.buildError("获取最新数据失败: " + e.getMessage());
} }
} }
// 解析 skuPriceJson 或 skuPrice 字段中的价格键,返回从小到大排序的价格列表
private static List<Double> parseSkuPriceList(Object skuPriceJson, Object skuPrice) {
String src = skuPriceJson != null ? String.valueOf(skuPriceJson) : (skuPrice != null ? String.valueOf(skuPrice) : null);
if (src == null || src.isEmpty()) return Collections.emptyList();
try {
Pattern pattern = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*:");
Matcher m = pattern.matcher(src);
List<Double> prices = new ArrayList<>();
while (m.find()) {
String num = m.group(1);
try { prices.add(Double.parseDouble(num)); } catch (NumberFormatException ignored) {}
}
Collections.sort(prices);
return prices;
} catch (Exception ignored) {
return Collections.emptyList();
}
}
} }

View File

@@ -0,0 +1,211 @@
package com.tashow.erp.controller;
import com.tashow.erp.entity.AuthTokenEntity;
import com.tashow.erp.repository.AuthTokenRepository;
import com.tashow.erp.service.CacheService;
import com.tashow.erp.service.impl.GenmaiServiceImpl;
import com.tashow.erp.utils.JsonData;
import com.tashow.erp.utils.LoggerUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
/**
* 系统管理控制器
* 提供系统配置、认证管理、设备信息等功能
*
* @author 占子杰牛逼
*/
@RestController
@RequestMapping("/api/system")
public class SystemController {
private static final Logger logger = LoggerUtil.getLogger(SystemController.class);
@Autowired
private AuthTokenRepository authTokenRepository;
@Autowired
private GenmaiServiceImpl genmaiService;
@Autowired
private CacheService cacheService;
@Autowired
private RestTemplate restTemplate;
@Value("${project.version:2.3.6}")
private String currentVersion;
@Value("${project.build.time:}")
private String buildTime;
@Value("${api.server.base-url}")
private String serverBaseUrl;
/**
* 保存服务认证信息
*
* @param data 包含serviceName和authKey的认证信息
* @return 操作结果
*/
@PostMapping("/auth/save")
public JsonData saveAuth(@RequestBody Map<String, Object> data) {
String serviceName = (String) data.get("serviceName");
String authKey = (String) data.get("authKey");
if (serviceName == null || authKey == null) {
return JsonData.buildError("serviceName和authKey不能为空");
}
AuthTokenEntity entity = authTokenRepository.findByServiceName(serviceName).orElse(new AuthTokenEntity());
entity.setServiceName(serviceName);
entity.setToken(authKey);
authTokenRepository.save(entity);
return JsonData.buildSuccess("认证信息保存成功");
}
/**
* 获取指定服务的认证信息
*
* @param serviceName 服务名称
* @return 认证token信息
*/
@GetMapping("/auth/get")
public JsonData getAuth(@RequestParam String serviceName) {
return JsonData.buildSuccess(authTokenRepository.findByServiceName(serviceName)
.map(AuthTokenEntity::getToken)
.orElse(null));
}
/**
* 删除指定服务的认证信息
*
* @param serviceName 服务名称
* @return 操作结果
*/
@DeleteMapping("/auth/remove")
public JsonData removeAuth(@RequestParam String serviceName) {
authTokenRepository.findByServiceName(serviceName).ifPresent(authTokenRepository::delete);
return JsonData.buildSuccess("认证信息删除成功");
}
/**
* 获取设备唯一标识
*
* @return 设备ID
*/
@GetMapping("/device-id")
public JsonData getDeviceId() {
return JsonData.buildSuccess(com.tashow.erp.utils.DeviceUtils.generateDeviceId());
}
/**
* 获取本机IP地址
*
* @return 本机IP地址
* @throws Exception 获取IP失败时抛出异常
*/
@GetMapping("/local-ip")
public JsonData getLocalIp() throws Exception {
return JsonData.buildSuccess(java.net.InetAddress.getLocalHost().getHostAddress());
}
/**
* 获取计算机名称
*
* @return 计算机名称
*/
@GetMapping("/computer-name")
public JsonData getComputerName() {
return JsonData.buildSuccess(System.getenv("COMPUTERNAME"));
}
/**
* 获取系统版本信息
*
* @return 包含当前版本号和构建时间的信息
*/
@GetMapping("/version")
public Map<String, Object> getVersion() {
return Map.of("success", true, "currentVersion", currentVersion, "buildTime", buildTime);
}
/**
* 获取服务器配置信息
*
* @return 包含服务器基础URL和SSE URL的配置信息
*/
@GetMapping("/config/server")
public Map<String, Object> getServerConfig() {
return Map.of("baseUrl", serverBaseUrl, "sseUrl", serverBaseUrl + "/monitor/account/events");
}
/**
* 打开跟卖精灵网站
*
* @param accountId 账号ID
* @param request HTTP请求对象用于获取用户信息
* @return 操作结果
*/
@PostMapping("/genmai/open")
public JsonData openGenmaiWebsite(@RequestParam(required = false) Long accountId, HttpServletRequest request) {
try {
String username = com.tashow.erp.utils.JwtUtil.getUsernameFromRequest(request);
genmaiService.openGenmaiWebsite(accountId, username);
return JsonData.buildSuccess("跟卖精灵已打开");
} catch (Exception e) {
logger.error("打开跟卖精灵失败", e);
return JsonData.buildError(e.getMessage() != null ? e.getMessage() : "打开跟卖精灵失败");
}
}
/**
* 图片代理接口
* 用于代理获取外部图片资源,解决跨域问题
*
* @param imageUrl 图片URL
* @return 图片字节数组响应
*/
@GetMapping("/proxy/image")
public ResponseEntity<byte[]> proxyImage(@RequestParam("url") String imageUrl) {
if (imageUrl == null || imageUrl.isEmpty()) {
return ResponseEntity.badRequest().build();
}
try {
HttpHeaders headers = new HttpHeaders();
headers.set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
headers.set("Accept", "image/jpeg, image/png, image/webp, image/*");
headers.set("Referer", "https://item.rakuten.co.jp/");
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<byte[]> response = restTemplate.exchange(
imageUrl,
HttpMethod.GET,
entity,
byte[].class
);
HttpHeaders responseHeaders = new HttpHeaders();
String contentType = response.getHeaders().getFirst("Content-Type");
if (contentType != null && contentType.startsWith("image/")) {
responseHeaders.setContentType(MediaType.parseMediaType(contentType));
} else {
responseHeaders.setContentType(MediaType.IMAGE_JPEG);
}
responseHeaders.setCacheControl("max-age=3600");
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
} catch (Exception e) {
logger.error("代理图片失败: {}", imageUrl, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* 清理系统缓存
*
* @return 操作结果
*/
@PostMapping("/cache/clear")
public JsonData clearCache() {
cacheService.clearCache();
return JsonData.buildSuccess("缓存清理成功");
}
}

View File

@@ -0,0 +1,675 @@
package com.tashow.erp.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tashow.erp.entity.TrademarkSessionEntity;
import com.tashow.erp.repository.TrademarkSessionRepository;
import com.tashow.erp.service.BrandTrademarkCacheService;
import com.tashow.erp.service.IFangzhouApiService;
import com.tashow.erp.utils.ExcelParseUtil;
import com.tashow.erp.utils.JsonData;
import com.tashow.erp.utils.LoggerUtil;
import com.tashow.erp.utils.ProxyPool;
import com.tashow.erp.utils.TrademarkCheckUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.http.HttpUtil;
import cn.hutool.poi.excel.ExcelReader;
import cn.hutool.poi.excel.ExcelUtil;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.time.LocalDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* 商标检查控制器
*/
@RestController
@RequestMapping("/api/trademark")
@CrossOrigin
public class TrademarkController {
private static final Logger logger = LoggerUtil.getLogger(TrademarkController.class);
private static final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private ProxyPool proxyPool;
@Autowired
private BrandTrademarkCacheService cacheService;
@Autowired
private TrademarkSessionRepository sessionRepository;
@Autowired
private IFangzhouApiService fangzhouApi;
private static final Map<String, Integer> progressMap = new ConcurrentHashMap<>();
private static final Map<String, Boolean> cancelMap = new ConcurrentHashMap<>();
private static final Map<String, SseEmitter> sseEmitters = new ConcurrentHashMap<>();
private static final Map<String, java.util.concurrent.ExecutorService> taskExecutors = new ConcurrentHashMap<>();
private static volatile String currentTaskId = null;
private static final Object taskLock = new Object();
private static volatile boolean isUploadingFile = false;
private static final Object uploadLock = new Object();
@GetMapping("/progress/{taskId}")
public SseEmitter getProgress(@PathVariable String taskId) {
SseEmitter emitter = new SseEmitter(300000L);
sseEmitters.put(taskId, emitter);
emitter.onCompletion(() -> sseEmitters.remove(taskId));
emitter.onTimeout(() -> sseEmitters.remove(taskId));
emitter.onError((e) -> sseEmitters.remove(taskId));
return emitter;
}
@PostMapping("/brandCheck")
public JsonData brandCheck(@RequestBody Map<String, Object> request) {
@SuppressWarnings("unchecked")
List<String> brands = (List<String>) request.get("brands");
String taskId = (String) request.get("taskId");
synchronized (taskLock) {
if (currentTaskId != null && !currentTaskId.equals(taskId)) {
logger.info("检测到新任务 {},终止旧任务 {}", taskId, currentTaskId);
forceTerminateTask(currentTaskId);
}
currentTaskId = taskId;
cancelMap.remove(taskId);
}
try {
List<String> list = brands.stream()
.filter(b -> !b.trim().isEmpty())
.map(String::trim)
.collect(Collectors.toList());
long start = System.currentTimeMillis();
Map<String, Boolean> cached = cacheService.getCached(list);
List<String> toQuery = list.stream()
.filter(b -> !cached.containsKey(b))
.collect(Collectors.toList());
Map<String, Boolean> queried = new java.util.concurrent.ConcurrentHashMap<>();
if (!toQuery.isEmpty()) {
List<List<String>> chunks = new ArrayList<>();
int totalBrands = toQuery.size();
if (totalBrands <= 100) {
chunks.add(toQuery);
} else {
int chunkSize = 100;
int numChunks = (totalBrands + chunkSize - 1) / chunkSize;
int baseSize = totalBrands / numChunks;
int remainder = totalBrands % numChunks;
int startIndex = 0;
for (int i = 0; i < numChunks; i++) {
int currentChunkSize = baseSize + (i < remainder ? 1 : 0);
chunks.add(toQuery.subList(startIndex, startIndex + currentChunkSize));
startIndex += currentChunkSize;
}
}
// 根据实际线程数获取代理,不浪费
int proxyCount = chunks.size();
List<String> proxies = proxyPool.getProxies(proxyCount);
if (proxies.size() < chunks.size()) {
logger.warn("代理数量不足,需要{}个,实际获取{}个", chunks.size(), proxies.size());
}
logger.info("获取到{}个代理,分配给{}个线程", proxies.size(), chunks.size());
java.util.concurrent.ExecutorService executor = java.util.concurrent.Executors.newFixedThreadPool(chunks.size());
taskExecutors.put(taskId, executor);
List<java.util.concurrent.Future<Map<String, Boolean>>> futures = new ArrayList<>();
for (int i = 0; i < chunks.size(); i++) {
if (cancelMap.getOrDefault(taskId, false)) {
logger.info("任务 {} 已被取消", taskId);
break;
}
List<String> chunk = chunks.get(i);
String proxy = proxies.isEmpty() ? null : proxies.get(i % proxies.size());
final int chunkIndex = i;
futures.add(executor.submit(() -> {
if (cancelMap.getOrDefault(taskId, false)) {
return new HashMap<String, Boolean>();
}
logger.info("线程 {} 开始处理 {} 个品牌,使用代理: {}", chunkIndex, chunk.size(), proxy);
Map<String, Boolean> result = TrademarkCheckUtil.batchCheck(chunk, proxy, taskId, cancelMap, chunkIndex, sseEmitters);
if (cancelMap.getOrDefault(taskId, false)) {
return new HashMap<String, Boolean>();
}
return result;
}));
}
for (java.util.concurrent.Future<Map<String, Boolean>> future : futures) {
if (cancelMap.getOrDefault(taskId, false)) {
logger.info("任务 {} 已被取消,停止收集结果", taskId);
break;
}
try {
Map<String, Boolean> result = future.get();
if (!result.isEmpty()) {
queried.putAll(result);
}
} catch (java.util.concurrent.CancellationException e) {
logger.info("线程任务已被取消: {}", taskId);
} catch (InterruptedException e) {
logger.info("线程任务被中断: {}", taskId);
Thread.currentThread().interrupt();
} catch (Exception e) {
logger.error("获取线程结果失败", e);
}
}
taskExecutors.remove(taskId);
executor.shutdown();
try {
if (!executor.awaitTermination(60, java.util.concurrent.TimeUnit.SECONDS)) {
logger.warn("线程池未能在60秒内正常关闭强制关闭");
executor.shutdownNow();
if (!executor.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)) {
logger.error("线程池强制关闭失败");
}
}
} catch (InterruptedException e) {
logger.warn("等待线程池关闭时被中断,强制关闭");
executor.shutdownNow();
Thread.currentThread().interrupt();
}
if (!queried.isEmpty()) {
cacheService.saveResults(queried);
}
}
// 检查任务是否已被取消
if (cancelMap.getOrDefault(taskId, false)) {
logger.info("任务 {} 已被取消,停止处理结果", taskId);
synchronized (taskLock) {
if (taskId.equals(currentTaskId)) {
currentTaskId = null;
}
}
progressMap.remove(taskId);
cancelMap.remove(taskId);
return JsonData.buildError("任务已取消");
}
Map<String, Boolean> allResults = new HashMap<>(cached);
allResults.putAll(queried);
List<Map<String, Object>> unregistered = new ArrayList<>();
int registeredCount = 0;
for (Map.Entry<String, Boolean> entry : allResults.entrySet()) {
if (!entry.getValue()) {
Map<String, Object> m = new HashMap<>();
m.put("brand", entry.getKey());
m.put("status", "未注册");
unregistered.add(m);
} else {
registeredCount++;
}
}
long t = (System.currentTimeMillis() - start) / 1000;
Map<String, Object> res = new HashMap<>();
res.put("total", list.size());
res.put("checked", list.size());
res.put("registered", registeredCount);
res.put("unregistered", unregistered.size());
res.put("failed", 0);
res.put("data", unregistered);
res.put("duration", t + "");
logger.info("完成: 共{}个,已注册{}个,未注册{}个,耗时{}秒",
list.size(), registeredCount, unregistered.size(), t);
synchronized (taskLock) {
if (taskId.equals(currentTaskId)) {
currentTaskId = null;
}
}
progressMap.remove(taskId);
cancelMap.remove(taskId);
return JsonData.buildSuccess(res);
} catch (Exception e) {
logger.error("筛查失败", e);
return JsonData.buildError("筛查失败: " + e.getMessage());
} finally {
cacheService.cleanExpired();
}
}
@GetMapping("/brandCheckProgress")
public JsonData getBrandCheckProgress(@RequestParam("taskId") String taskId) {
Integer current = progressMap.get(taskId);
if (current == null) {
return JsonData.buildError("任务不存在或已完成");
}
Map<String, Integer> result = new HashMap<>();
result.put("current", current);
return JsonData.buildSuccess(result);
}
@PostMapping("/cancelBrandCheck")
public JsonData cancelBrandCheck(@RequestBody Map<String, String> request) {
String taskId = request.get("taskId");
if (taskId != null) {
forceTerminateTask(taskId);
}
return JsonData.buildSuccess("任务已取消");
}
private void forceTerminateTask(String taskId) {
logger.info("开始强制终止任务: {}", taskId);
cancelMap.put(taskId, true);
java.util.concurrent.ExecutorService executor = taskExecutors.remove(taskId);
if (executor != null && !executor.isShutdown()) {
logger.info("强制关闭任务 {} 的线程池", taskId);
executor.shutdownNow();
try {
if (!executor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)) {
logger.warn("任务 {} 的线程池未能在5秒内关闭", taskId);
} else {
logger.info("任务 {} 的线程池已成功关闭", taskId);
}
} catch (InterruptedException e) {
logger.warn("等待线程池关闭时被中断");
Thread.currentThread().interrupt();
}
}
SseEmitter emitter = sseEmitters.remove(taskId);
if (emitter != null) {
try {
emitter.send(SseEmitter.event().name("cancelled").data("任务已取消"));
emitter.complete();
} catch (Exception e) {
logger.warn("关闭SSE连接失败: {}", e.getMessage());
}
}
progressMap.remove(taskId);
synchronized (taskLock) {
if (taskId.equals(currentTaskId)) {
currentTaskId = null;
}
}
logger.info("任务 {} 强制终止完成", taskId);
}
@PostMapping("/validateHeaders")
public JsonData validateHeaders(@RequestParam("file") MultipartFile file,
@RequestParam(value = "requiredHeaders", required = false) String requiredHeadersJson) {
try {
Map<String, Object> fullData = ExcelParseUtil.parseFullExcel(file);
@SuppressWarnings("unchecked")
List<String> headers = (List<String>) fullData.get("headers");
if (headers == null || headers.isEmpty()) {
return JsonData.buildError("无法读取Excel表头");
}
Map<String, Object> result = new HashMap<>();
result.put("headers", headers);
if (requiredHeadersJson != null && !requiredHeadersJson.trim().isEmpty()) {
List<String> requiredHeaders = objectMapper.readValue(requiredHeadersJson,
objectMapper.getTypeFactory().constructCollectionType(List.class, String.class));
List<String> missing = new ArrayList<>();
for (String required : requiredHeaders) {
if (!headers.contains(required)) {
missing.add(required);
}
}
result.put("valid", missing.isEmpty());
result.put("missing", missing);
if (!missing.isEmpty()) {
return JsonData.buildError("缺少必需的列: " + String.join(", ", missing));
}
}
return JsonData.buildSuccess(result);
} catch (Exception e) {
logger.error("验证表头失败", e);
return JsonData.buildError("验证失败: " + e.getMessage());
}
}
/**
* 从Excel提取品牌列表同时返回完整Excel数据
*/
@PostMapping("/extractBrands")
public JsonData extractBrands(@RequestParam("file") MultipartFile file) {
try {
List<String> brands = ExcelParseUtil.parseColumnByName(file, "品牌");
if (brands.isEmpty()) return JsonData.buildError("未找到品牌列或品牌数据为空");
// 读取完整Excel数据
Map<String, Object> fullData = ExcelParseUtil.parseFullExcel(file);
Map<String, Object> result = new HashMap<>();
result.put("total", brands.size());
result.put("brands", brands);
result.put("headers", fullData.get("headers"));
result.put("allRows", fullData.get("rows"));
return JsonData.buildSuccess(result);
} catch (Exception e) {
return JsonData.buildError("提取失败: " + e.getMessage());
}
}
/**
* 根据ASIN列表从Excel中过滤完整行数据
*/
@PostMapping("/filterByAsins")
public JsonData filterByAsins(@RequestParam("file") MultipartFile file, @RequestParam("asins") String asinsJson) {
try {
if (asinsJson == null || asinsJson.trim().isEmpty()) {
return JsonData.buildError("ASIN列表不能为空");
}
// 使用Jackson解析JSON数组
List<String> asins;
try {
asins = objectMapper.readValue(asinsJson,
objectMapper.getTypeFactory().constructCollectionType(List.class, String.class));
} catch (Exception e) {
logger.error("解析ASIN列表JSON失败: {}", asinsJson, e);
return JsonData.buildError("ASIN列表格式错误: " + e.getMessage());
}
if (asins == null || asins.isEmpty()) {
return JsonData.buildError("ASIN列表不能为空");
}
logger.info("接收到ASIN过滤请求ASIN数量: {}", asins.size());
Map<String, Object> result = ExcelParseUtil.filterExcelByAsins(file, asins);
@SuppressWarnings("unchecked")
List<Map<String, Object>> filteredRows = (List<Map<String, Object>>) result.get("filteredRows");
Map<String, Object> response = new HashMap<>();
response.put("headers", result.get("headers"));
response.put("filteredRows", filteredRows);
response.put("total", filteredRows.size());
logger.info("ASIN过滤完成过滤出 {} 行数据", filteredRows.size());
return JsonData.buildSuccess(response);
} catch (Exception e) {
logger.error("根据ASIN过滤失败", e);
return JsonData.buildError("过滤失败: " + e.getMessage());
}
}
/**
* 根据品牌列表从Excel中过滤完整行数据
*/
@PostMapping("/filterByBrands")
public JsonData filterByBrands(@RequestParam("file") MultipartFile file, @RequestParam("brands") String brandsJson) {
try {
if (brandsJson == null || brandsJson.trim().isEmpty()) {
return JsonData.buildError("品牌列表不能为空");
}
// 使用Jackson解析JSON数组
List<String> brands;
try {
brands = objectMapper.readValue(brandsJson,
objectMapper.getTypeFactory().constructCollectionType(List.class, String.class));
} catch (Exception e) {
logger.error("解析品牌列表JSON失败: {}", brandsJson, e);
return JsonData.buildError("品牌列表格式错误: " + e.getMessage());
}
if (brands == null || brands.isEmpty()) {
return JsonData.buildError("品牌列表不能为空");
}
logger.info("接收到品牌过滤请求,品牌数量: {}", brands.size());
Map<String, Object> result = ExcelParseUtil.filterExcelByBrands(file, brands);
@SuppressWarnings("unchecked")
List<Map<String, Object>> filteredRows = (List<Map<String, Object>>) result.get("filteredRows");
Map<String, Object> response = new HashMap<>();
response.put("headers", result.get("headers"));
response.put("filteredRows", filteredRows);
response.put("total", filteredRows.size());
logger.info("品牌过滤完成,过滤出 {} 行数据", filteredRows.size());
return JsonData.buildSuccess(response);
} catch (Exception e) {
logger.error("根据品牌过滤失败", e);
return JsonData.buildError("过滤失败: " + e.getMessage());
}
}
/**
* 保存商标查询会话
*/
@PostMapping("/saveSession")
public JsonData saveSession(@RequestBody Map<String, Object> sessionData,
@RequestHeader(value = "username", required = false) String username) {
try {
if (username == null || username.trim().isEmpty()) {
username = "default";
}
String sessionId = UUID.randomUUID().toString();
TrademarkSessionEntity entity = new TrademarkSessionEntity();
entity.setSessionId(sessionId);
entity.setUsername(username);
entity.setFileName((String) sessionData.get("fileName"));
entity.setResultData(objectMapper.writeValueAsString(sessionData.get("resultData")));
entity.setFullData(objectMapper.writeValueAsString(sessionData.get("fullData")));
entity.setHeaders(objectMapper.writeValueAsString(sessionData.get("headers")));
entity.setTaskProgress(objectMapper.writeValueAsString(sessionData.get("taskProgress")));
entity.setQueryStatus((String) sessionData.get("queryStatus"));
sessionRepository.save(entity);
// 清理7天前的数据
sessionRepository.deleteByCreatedAtBefore(LocalDateTime.now().minusDays(7));
logger.info("保存商标查询会话: {} (用户: {})", sessionId, username);
Map<String, String> result = new HashMap<>();
result.put("sessionId", sessionId);
return JsonData.buildSuccess(result);
} catch (Exception e) {
logger.error("保存会话失败", e);
return JsonData.buildError("保存失败: " + e.getMessage());
}
}
/**
* 根据sessionId恢复查询会话
*/
@GetMapping("/getSession")
public JsonData getSession(@RequestParam("sessionId") String sessionId,
@RequestHeader(value = "username", required = false) String username) {
try {
if (username == null || username.trim().isEmpty()) {
username = "default";
}
Optional<TrademarkSessionEntity> opt = sessionRepository.findBySessionIdAndUsername(sessionId, username);
if (!opt.isPresent()) {
return JsonData.buildError("会话不存在或已过期");
}
TrademarkSessionEntity entity = opt.get();
Map<String, Object> result = new HashMap<>();
result.put("fileName", entity.getFileName());
result.put("resultData", objectMapper.readValue(entity.getResultData(), List.class));
result.put("fullData", objectMapper.readValue(entity.getFullData(), List.class));
result.put("headers", objectMapper.readValue(entity.getHeaders(), List.class));
result.put("taskProgress", objectMapper.readValue(entity.getTaskProgress(), Map.class));
result.put("queryStatus", entity.getQueryStatus());
logger.info("恢复商标查询会话: {} (用户: {})", sessionId, username);
return JsonData.buildSuccess(result);
} catch (Exception e) {
logger.error("恢复会话失败", e);
return JsonData.buildError("恢复失败: " + e.getMessage());
}
}
// ==================== 方舟精选任务管理接口 ====================
/**
* 获取方舟精选任务列表
* 从第三方 API 下载 Excel 并解析过滤数据
*/
@PostMapping("/task")
public JsonData getTask() {
try {
// 1. 获取 Token 并轮询等待下载链接
String token = fangzhouApi.getToken();
JsonNode dNode = fangzhouApi.pollTask(token, 6, 5000);
String downloadUrl = dNode.get("download_url").asText();
if (downloadUrl == null || downloadUrl.isEmpty()) {
return JsonData.buildError("下载链接生成超时");
}
// 2. 下载并解析 Excel
String tempFilePath = System.getProperty("java.io.tmpdir") + "/trademark_" + System.currentTimeMillis() + ".xlsx";
HttpUtil.downloadFile(downloadUrl, FileUtil.file(tempFilePath));
List<Map<String, Object>> filteredData = new ArrayList<>();
List<String> excelHeaders = new ArrayList<>();
ExcelReader reader = null;
try {
reader = ExcelUtil.getReader(FileUtil.file(tempFilePath));
List<List<Object>> rows = reader.read();
if (rows.isEmpty()) {
throw new RuntimeException("Excel文件为空");
}
// 读取表头
List<Object> headerRow = rows.get(0);
for (Object cell : headerRow) {
excelHeaders.add(cell != null ? cell.toString().trim() : "");
}
// 找到商标类型列的索引
int trademarkTypeIndex = -1;
for (int i = 0; i < excelHeaders.size(); i++) {
if ("商标类型".equals(excelHeaders.get(i))) {
trademarkTypeIndex = i;
break;
}
}
if (trademarkTypeIndex < 0) {
throw new RuntimeException("未找到'商标类型'列");
}
// 过滤TM和未注册数据
for (int i = 1; i < rows.size(); i++) {
List<Object> row = rows.get(i);
if (row.size() > trademarkTypeIndex) {
String trademarkType = row.get(trademarkTypeIndex).toString().trim();
if ("TM".equals(trademarkType) || "未注册".equals(trademarkType)) {
Map<String, Object> item = new HashMap<>();
for (int j = 0; j < excelHeaders.size() && j < row.size(); j++) {
item.put(excelHeaders.get(j), row.get(j));
}
filteredData.add(item);
}
}
}
} finally {
if (reader != null) {
reader.close();
}
FileUtil.del(tempFilePath);
}
// 6. 返回结果
Map<String, Object> combinedResult = new HashMap<>();
combinedResult.put("original", dNode);
combinedResult.put("filtered", filteredData);
combinedResult.put("headers", excelHeaders);
logger.info("任务获取成功,过滤出 {} 条数据", filteredData.size());
return JsonData.buildSuccess(combinedResult);
} catch (Exception e) {
logger.error("获取任务失败", e);
return JsonData.buildError("获取任务失败: " + e.getMessage());
}
}
/**
* 创建新任务
* 上传文件到方舟精选
*/
@PostMapping("/newTask")
public JsonData newTask(@RequestParam("file") MultipartFile file) {
// 防止重复上传:如果已有上传任务在进行,直接拒绝
synchronized (uploadLock) {
if (isUploadingFile) {
logger.warn("文件上传被拒绝:已有上传任务正在进行中");
return JsonData.buildError("请勿重复点击,上传任务进行中");
}
isUploadingFile = true;
}
try {
// 1. 获取 Token 并上传文件
String token = fangzhouApi.getToken();
JsonNode jsonNode = fangzhouApi.uploadFile(file, token);
// 2. 返回结果
if (jsonNode.get("S").asInt() == 1) {
logger.info("任务创建成功: {}", file.getOriginalFilename());
return JsonData.buildSuccess(jsonNode.toString());
}
return JsonData.buildError(jsonNode.get("M").asText());
} catch (Exception e) {
logger.error("创建任务失败", e);
return JsonData.buildError("创建任务失败: " + e.getMessage());
} finally {
// 释放上传锁
synchronized (uploadLock) {
isUploadingFile = false;
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More