Compare commits

...

51 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
4c2546733e 1 2025-10-10 15:37:57 +08:00
a0d94b2c70 feat(update): 添加版本更新提示红点功能
- 新增小红点标识新版本可用状态
- 实现点击版本号直接显示更新对话框- 优化更新逻辑,区分首次发现、跳过版本和稍后提醒状态- 添加脉冲动画效果增强视觉提示- 完善本地存储判断避免重复提示
2025-10-10 14:00:31 +08:00
d77ebab153 1 2025-10-10 13:39:16 +08:00
a28d846638 1 2025-10-10 11:28:04 +08:00
1c4dd2dd6d 1 2025-10-10 10:07:57 +08:00
6f22c9bffd 1 2025-10-10 10:06:56 +08:00
4fbe51d625 1 2025-10-09 11:18:26 +08:00
db67a99288 1 2025-10-09 10:02:37 +08:00
4065da3766 1 2025-10-09 09:27:21 +08:00
61235c5610 1 2025-09-30 17:19:34 +08:00
228 changed files with 20479 additions and 7133 deletions

View File

@@ -1,48 +0,0 @@
---
description:
globs:
alwaysApply: false
---
---
description:
globs:
alwaysApply: false
---
# Your rule content
#角色
你是一名精通开发的高级工程师拥有10年以上的应用开发经验熟悉*等开发工具和技术栈。
你的任务是帮助用户设计和开发易用且易于推护的 *** 应用。始终遵循最佳实践,并坚持干净代码和健壮架构的原则。
#目标
你的目标是以用户容易理解的方式帮助他们完成“应用的设计和开发工作,确保应用功能完善、性能优异、用户体验良好。
#要求
在理解用户需求、设计UI、编写代码、解决问题和项目选代优化时你应该始终遵循以下原则:
##需求理解
-充分理解用户需求,站在用户角度思考,分析需求是否存在缺漏,并与用户讨论完善需求;
-选择最简单的解决方案来满足用户需求,避免过度设计。
##UI和样式设计
-使用现代UI框架进行样式设计(例如***这里可以根据不同开发项目仔纽展开比如使用哪些视觉规范或者UI框架没有的话也可以不用过多展开);
-在不同平台上实现一致的设计和响应式模式
##代码编写
技术选型:根据项目需求选择合适的技术栈(例如***,这里需要仔细展开,比如介招某个技术栈用在什么地方,以及要遵循什么最佳实践)
代码结构:强调代码的清晰性、模块化、可维护性,遵循最佳实践(如DRY原则、最小权限原则、响应式设计等)
-代码安全性:在编写代码时,始终考虑安全性,避免引入漏洞,确保用户输入的安全处理
-性能优化:优化代码的性能,减少资源占用,提升加载速度,确保项目的高效运行
-测试与文档:编写单元测试,确保代码的健壮性,并提供清晰的中文注释和文档。方便后续阅读和维护
##问题解决
-全面阅读相关代码,理解***应用的工作原理
-根据用户的反馈分析问题的原因,提出解决问题的思路
-确保每次代码变更不会破坏现有功能,且尽可能保持最小的改动
##迭代优化
与用户保持密切沟通,根据反读调整功能和设计,确保应用符合用户需求
在不确定需求时,主动询问用户以澄清需求或技术细节
##方法论
-系统2思维:以分析严谨的方式解决问题。将需求分解为更小、可管理的部分,并在实施前仔细考虑每一步
思维树:评估多种可能的解决方案及其后果。使用结构化的方法探索不同的路径。并选择最优的解决方案
-选代改进:在最终确定代码之前,考虑改进、边缘情况和优化。通过潜在增强的迭代,确保最终解决方案是健壮的

View File

@@ -1,222 +0,0 @@
{
"permissions": {
"allow": [
"Bash(mvn clean:*)",
"Bash(where mysql)",
"Bash(dir:*)",
"Bash(mvn:*)",
"Bash(npm run dev:*)",
"Bash(xmllint:*)",
"Bash(sed:*)",
"Bash(rmdir:*)",
"Bash(rm:*)",
"mcp__talktofigma__get_selection",
"mcp__talktofigma__join_channel",
"mcp__talktofigma__get_document_info",
"mcp__talktofigma__read_my_design",
"mcp__talktofigma__get_node_info",
"mcp__talktofigma__scan_text_nodes",
"mcp__talktofigma__scan_nodes_by_types",
"mcp__talktofigma__get_nodes_info",
"Bash(mvn compile:*)",
"Bash(mkdir:*)",
"Bash(copy:*)",
"Bash(cp:*)",
"Bash(mvn:*)",
"Bash(del \"C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\module-info.java\")",
"Bash(jar tf:*)",
"Bash(./build-simple.bat)",
"Bash(java:*)",
"Bash(./build-custom-installer.bat)",
"Bash(echo $JAVA_HOME)",
"Bash(where java)",
"Bash(./build-installer-fixed.bat)",
"Bash(fix-npm-install.bat)",
"Bash(chmod:*)",
"Bash(where mvn)",
"Bash(./install-nsis.bat)",
"Bash(./test-final.bat)",
"Bash(./build-final-installer.bat)",
"Bash(npm install)",
"Bash(./快速启动-ERP客户端.bat)",
"Bash(npm install:*)",
"Bash(curl:*)",
"Bash(rg:*)",
"Bash(timeout:*)",
"Bash(del \"C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js\\table-scroll-fix.js\")",
"mcp__context7-mcp__resolve-library-id",
"WebSearch",
"Bash(findstr:*)",
"Bash(move:*)",
"Bash(jar:*)",
"Bash(cat:*)",
"Bash(taskkill:*)",
"Bash(del \"C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\test\\SeleniumBrowserTest.java\")",
"Bash(del \"C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\service\\RakutenCacheService.java\")",
"Bash(del \"C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\service\\CacheService.java\")",
"Bash(git checkout:*)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-ui\\src\\views\\monitor\\key/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\controller\\monitor/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-system\\src\\main\\java\\com\\ruoyi\\system\\domain/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-system\\src\\main\\resources\\mapper\\system/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\controller/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\service/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\controller\\monitor/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-ui/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-common\\src\\main\\java\\com\\ruoyi\\common\\core\\redis/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-ui\\src\\views/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\controller\\monitor/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-ui\\src\\views/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\html/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-ui\\src\\views/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\controller\\monitor/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-system\\src\\main\\java\\com\\ruoyi\\system\\domain/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\controller/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-system\\src\\main\\resources\\mapper\\system/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\util/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\controller\\monitor/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\utils/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-ui\\src\\views/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-ui\\src\\views/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\utils/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\java\\com\\tashow\\erp\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb\\src\\main\\resources\\static\\js/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\erp_client_sb/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\controller\\monitor/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(/C:\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue\\ruoyi-admin\\src\\main\\java\\com\\ruoyi\\web\\service\\impl/**)",
"Read(//c/Users/ZiJIe/Desktop/MongooCrawler-feature-monitor/**)",
"Bash(wmic process where:*)",
"Bash(del:*)",
"Bash(ren:*)",
"Bash(find:*)",
"Bash(netstat:*)",
"Bash(mysql:*)",
"Bash(rd:*)",
"Bash(git clean:*)",
"Bash(tasklist:*)",
"Bash(./fix-electron.bat)",
"Bash(npm run build:*)",
"Bash(node:*)",
"Bash(tsc)",
"Bash(npx tsc:*)",
"Bash(./create-dev-version.bat)",
"Bash(./prepare-backend.bat)",
"Bash(./start-erp.bat)",
"Bash(npm config set:*)",
"Bash(set ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/)",
"Bash(npm cache clean:*)",
"Bash(npx vite:*)",
"Bash(./dev-ruoyi-erp.bat)",
"Bash(./start-desktop-app.bat)",
"Bash(cnpm install:*)",
"Bash(cnpm uninstall:*)",
"WebFetch(domain:www.electronjs.org)",
"Bash(test:*)",
"Bash(sqlite3:*)",
"Bash(npx electron:*)",
"Bash(.erpClient.exe)",
"Bash(start erpClient.exe)",
"Bash(grep:*)",
"Bash(npx asar list:*)",
"Bash(npx @electron/asar extract:*)"
],
"deny": [],
"ask": [],
"additionalDirectories": [
"C:\\c\\Users\\ZiJIe\\Desktop\\wox\\RuoYi-Vue",
"C:\\Users\\ZiJIe\\Desktop\\wox",
"C:\\c\\Users\\ZiJIe"
]
},
"dangerouslySkipPermissions": true
}

72
.gitignore vendored
View File

@@ -4,6 +4,7 @@
*.temp
.DS_Store
Thumbs.db
nul
# IDE 文件
## IntelliJ IDEA
@@ -14,6 +15,25 @@ Thumbs.db
*.ipr
out/
.idea_modules/
## VSCode
.vscode/
.history/
## Eclipse
.classpath
.project
.settings/
## AI 助手配置
.claude/
.cursor/
.starFactory/
.windsurf/
.aider/
.copilot/
# Maven 构建目录
/ruoyi-common/target/
/ruoyi-system/target/
/ruoyi-quartz/target/
@@ -21,4 +41,54 @@ out/
/ruoyi-framework/target/
/ruoyi-admin/target/
/erp_client_sb/target/
target /
target/
*.class
# Node.js 前端项目
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.npm
.yarn/
dist/
dist-ssr/
*.local
# Vite 构建缓存
.vite/
.vite-inspect/
# Electron 构建产物
build/
release/
out/
*.exe
*.dmg
*.AppImage
# Java 打包文件
*.jar
*.war
*.ear
*.zip
*.tar.gz
*.rar
# JRE 运行时环境
jre/
jdk/
# 数据库文件
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
# 缓存和临时文件
.cache/
*.swp
*.swo
*~

25
.idea/compiler.xml generated
View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="ruoyi-framework" />
<module name="ruoyi-system" />
<module name="ruoyi-common" />
<module name="ruoyi-generator" />
<module name="erp_client_sb" />
<module name="ruoyi-quartz" />
<module name="ruoyi-admin" />
</profile>
</annotationProcessing>
</component>
<component name="JavacSettings">
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
<module name="erp_client_sb" options="-parameters" />
</option>
</component>
</project>

20
.idea/encodings.xml generated
View File

@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/erp_client_sb/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-admin/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-admin/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-common/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-common/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-framework/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-framework/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-generator/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-generator/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-quartz/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-quartz/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-system/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/ruoyi-system/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>

View File

@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="public" />
<option name="name" value="aliyun nexus" />
<option name="url" value="https://maven.aliyun.com/repository/public" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="http://maven.aliyun.com/nexus/content/groups/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="aliyun-repository" />
<option name="name" value="aliyun repository" />
<option name="url" value="http://maven.aliyun.com/nexus/content/groups/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss-repository" />
<option name="name" value="jboss repository" />
<option name="url" value="http://repository.jboss.org/nexus/content/groups/public-jboss/" />
</remote-repository>
</component>
</project>

13
.idea/misc.xml generated
View File

@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
<option value="$PROJECT_DIR$/erp_client_sb/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK" />
</project>

7
.idea/vcs.xml generated
View File

@@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

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,21 +0,0 @@
MIT License
Copyright (c) 2021 Deluze
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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/icon/**/*",
"public/image/**/*",
"public/splash.html"
"public/splash.html",
"public/config/**/*"
],
"directories": {
"output": "dist"
},
"publish": {
"provider": "generic",
"url": "http://192.168.1.89:8080/static/updates/"
},
"electronLanguages": ["zh-CN", "en-US"],
"nsis": {
"oneClick": false,
"perMachine": false,
@@ -25,13 +23,11 @@
"shortcutName": "erpClient"
},
"win": {
"target": "nsis",
"icon": "public/icon/icon.png"
},
"linux": {
"target": ["snap"]
"target": "dir",
"icon": "public/icon/icon1.png"
},
"files": [
"package.json",
{
"from": "build/main",
"to": "main",
@@ -40,19 +36,14 @@
{
"from": "build/renderer",
"to": "renderer",
"filter": ["**/*"]
},
{
"from": "src/main/static",
"to": "static",
"filter": ["**/*"]
},
{
"from": "public",
"to": "assets",
"filter": [
"erp_client_sb-*.jar"
"**/*",
"!icon/**/*",
"!image/**/*",
"!jre/**/*",
"!config/**/*",
"!*.jar",
"!splash.html"
]
},
{
@@ -63,58 +54,32 @@
"icon/**/*",
"image/**/*",
"splash.html",
"config/**/*",
"!erp_client_sb-*.jar",
"!data/**/*",
"!jre/bin/jabswitch.exe",
"!jre/bin/jaccessinspector.exe",
"!jre/bin/jaccesswalker.exe",
"!jre/bin/jar.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/bin/*.exe",
"jre/bin/java.exe",
"jre/bin/javaw.exe",
"jre/bin/keytool.exe",
"!jre/include/**",
"!jre/lib/src.zip",
"!jre/lib/ct.sym",
"!jre/lib/jvm.lib",
"!icon/image.png",
"!icon/img.png"
"!jre/lib/jvm.lib"
]
},
"!build",
"!dist",
"!scripts"
],
"electronLanguages": ["en", "zh-CN"],
"extraResources": [
{
"from": "update-helper.bat",
"to": "../update-helper.bat"
},
{
"from": "public",
"to": "./",
"filter": ["erp_client_sb-*.jar"]
}
]
}

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",
"description": "A minimal Electron + Vue application",
"main": "main/main.js",
"main": "build/main/main.js",
"scripts": {
"dev": "node scripts/dev-server.js",
"build": "node scripts/build.js && electron-builder",
"build:win": "node scripts/build.js && electron-builder --win",
"build:mac": "node scripts/build.js && electron-builder --mac",
"build:linux": "node scripts/build.js && electron-builder --linux"
"build": "node scripts/build.js && electron-builder --dir",
"build:win": "node scripts/build.js && electron-builder --win --dir",
"build:mac": "node scripts/build.js && electron-builder --mac --dir",
"build:linux": "node scripts/build.js && electron-builder --linux --dir"
},
"repository": "https://github.com/deluze/electron-vue-template",
"author": {
@@ -17,10 +17,12 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.4.1",
"binary-extensions": "^3.1.0",
"chalk": "^4.1.2",
"chokidar": "^3.5.3",
"electron": "^38.1.2",
"electron": "^32.1.2",
"electron-builder": "^25.1.6",
"electron-rebuild": "^3.2.9",
"express": "^5.1.0",
"fs-extra": "^11.3.2",
"typescript": "^5.2.2",
@@ -31,5 +33,26 @@
"element-plus": "^2.11.3",
"exceljs": "^4.4.0",
"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>
<html lang="zh-CN">
<html>
<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; }
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; overflow: hidden; }
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;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.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>
<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="image"></div>
<div class="box">
<span class="text">正在启动</span>
<div class="progress"><div class="bar"></div></div>
<button class="btn" onclick="require('electron').ipcRenderer.send('quit-app')">退出</button>
</div>
</body>
</html>

View File

@@ -0,0 +1,28 @@
const Path = require('path');
const FileSystem = require('fs-extra');
async function copyAssets() {
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;

View File

@@ -25,7 +25,7 @@ async function startRenderer() {
}
async function startElectron() {
if (electronProcess) { // single instance lock
if (electronProcess) {
return;
}
@@ -52,7 +52,7 @@ async function startElectron() {
process.stdout.write(Chalk.blueBright(`[electron] `) + Chalk.white(data.toString()))
});
electronProcess.stderr.on('data', data =>
electronProcess.stderr.on('data', data =>
process.stderr.write(Chalk.blueBright(`[electron] `) + Chalk.white(data.toString()))
);

View File

@@ -3,14 +3,18 @@ const Chalk = require('chalk');
function compile(directory) {
return new Promise((resolve, reject) => {
const tscProcess = ChildProcess.exec('tsc', {
const tscProcess = ChildProcess.exec('npx tsc', {
cwd: directory,
});
tscProcess.stdout.on('data', data =>
tscProcess.stdout.on('data', data =>
process.stdout.write(Chalk.yellowBright(`[tsc] `) + Chalk.white(data.toString()))
);
tscProcess.stderr.on('data', data =>
process.stderr.write(Chalk.yellowBright(`[tsc] `) + Chalk.white(data.toString()))
);
tscProcess.on('exit', exitCode => {
if (exitCode > 0) {
reject(exitCode);

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,8 @@ const electronAPI = {
installUpdate: () => ipcRenderer.invoke('install-update'),
cancelDownload: () => ipcRenderer.invoke('cancel-download'),
getUpdateStatus: () => ipcRenderer.invoke('get-update-status'),
checkPendingUpdate: () => ipcRenderer.invoke('check-pending-update'),
clearUpdateFiles: () => ipcRenderer.invoke('clear-update-files'),
// 添加文件保存对话框 API
showSaveDialog: (options: any) => ipcRenderer.invoke('show-save-dialog', options),
@@ -17,8 +19,40 @@ const electronAPI = {
showOpenDialog: (options: any) => ipcRenderer.invoke('show-open-dialog', options),
// 添加文件写入 API
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) => {
ipcRenderer.removeAllListeners('download-progress')
ipcRenderer.on('download-progress', (event, progress) => callback(progress))
},
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';
export const amazonApi = {
// 上传Excel文件解析ASIN列表
importAsinFromExcel(file: File) {
const formData = new FormData();
formData.append('file', file);
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) {
return http.post<{ code: number, data: { products: any[] }, msg: string | null }>('/api/amazon/products/batch', { asinList, batchId, region });
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 }, signal);
},
getLatestProducts() {
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,39 +1,32 @@
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 = {
login(params: { username: string; password: string }) {
return http.post('/api/login', params)
login(params: LoginParams) {
return http.post<{ data: AuthResponse }>('/monitor/account/login', params)
},
register(params: { username: string; password: string }) {
return http.post('/api/register', params)
register(params: { username: string; password: string; deviceId?: string }) {
return http.post<{ data: AuthResponse }>('/monitor/account/register', params)
},
checkUsername(username: string) {
return http.get('/api/check-username', { username })
return http.get<{ data: boolean }>('/monitor/account/check-username', { username })
},
verifyToken(token: string) {
return http.post('/api/verify', { token })
},
logout(token: string) {
return http.postVoid('/api/logout', { token })
},
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')
return http.post<{ data: AuthResponse }>('/monitor/account/verify', { token })
}
}

View File

@@ -1,27 +1,45 @@
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 = {
getQuota(username: string) {
return http.get('/api/device/quota', { username })
return http.get<{ data: DeviceQuota }>('/monitor/device/quota', { username })
},
list(username: string) {
return http.get('/api/device/list', { username })
return http.get<{ data: DeviceItem[] }>('/monitor/device/list', { username })
},
register(payload: { username: string }) {
return http.post('/api/device/register', payload)
async register(payload: { username: string; deviceId: string; os?: string }) {
const [ipRes, nameRes] = await Promise.all([
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 }) {
return http.post('/api/device/remove', payload)
},
heartbeat(payload: { username: string; deviceId: string; version?: string }) {
return http.post('/api/device/heartbeat', payload)
remove(payload: { deviceId: string; username: string }) {
return http.post('/monitor/device/remove', payload)
},
offline(payload: { deviceId: string }) {
return http.post('/api/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,90 +1,139 @@
// 极简 HTTP 工具:封装 GET/POST按路径选择后端服务
export type HttpMethod = 'GET' | 'POST';
import { AppConfig, isRuoyiPath } from '../config'
const BASE_CLIENT = 'http://localhost:8081'; // erp_client_sb
const BASE_RUOYI = 'http://192.168.1.89:8080';
export type HttpMethod = 'GET' | 'POST' | 'DELETE'
export const CONFIG = AppConfig
function resolveBase(path: string): string {
// 走 ruoyi-admin 的路径:鉴权与版本、平台工具路由
if (path.startsWith('/system/')) return BASE_RUOYI; // 版本控制器 VersionController
if (path.startsWith('/tool/banma')) return BASE_RUOYI; // 既有规则保留
// 其他默认走客户端服务
return BASE_CLIENT;
return isRuoyiPath(path) ? CONFIG.RUOYI_BASE : CONFIG.CLIENT_BASE
}
// 将对象转为查询字符串
function buildQuery(params?: Record<string, unknown>): string {
if (!params) return '';
const usp = new URLSearchParams();
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null) return;
usp.append(key, String(value));
if (value != null) query.append(key, String(value));
});
const queryString = usp.toString();
return queryString ? `?${queryString}` : '';
return query.toString() ? `?${query}` : '';
}
// 统一请求入口:自动加上 BASE_URL、JSON 头与错误处理
async function request<T>(path: string, options: RequestInit): Promise<T> {
const res = await fetch(`${resolveBase(path)}${path}`, {
credentials: 'omit',
cache: 'no-store',
...options,
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 getToken(): Promise<string> {
try {
const tokenModule = await import('../utils/token');
return tokenModule.getToken() || '';
} catch {
return '';
}
}
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') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
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;
}
export const http = {
get<T>(path: string, params?: Record<string, unknown>) {
return request<T>(`${path}${buildQuery(params)}`, { method: 'GET' });
get<T>(path: string, params?: Record<string, unknown>, signal?: AbortSignal) {
return request<T>(`${path}${buildQuery(params)}`, { method: 'GET', signal });
},
post<T>(path: string, body?: unknown) {
return request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined });
post<T>(path: string, body?: unknown, signal?: AbortSignal) {
return request<T>(path, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
signal
});
},
delete<T>(path: string) {
return request<T>(path, { method: 'DELETE' });
},
// 用于无需读取响应体的 POST如删除/心跳等),从根源避免读取中断
postVoid(path: string, body?: unknown) {
return fetch(`${resolveBase(path)}${path}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
credentials: 'omit',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
}).then(res => {
if (!res.ok) return res.text().then(t => Promise.reject(new Error(t || `HTTP ${res.status}`)));
return undefined as unknown as void;
});
},
// 文件上传:透传 FormData不设置 Content-Type 让浏览器自动处理
upload<T>(path: string, form: FormData) {
const res = fetch(`${resolveBase(path)}${path}`, {
method: 'POST',
body: form,
credentials: 'omit',
cache: 'no-store',
});
return res.then(async response => {
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `HTTP ${response.status}`);
async upload<T>(path: string, form: FormData, signal?: AbortSignal) {
const token = await getToken();
const username = await getUsername();
let res: Response;
try {
res = await fetch(`${resolveBase(path)}${path}`, {
method: 'POST',
body: form,
credentials: 'omit',
cache: 'no-store',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...(username ? { 'username': username } : {})
},
signal
});
} catch (e) {
throw new Error('无法连接服务器,请检查网络后重试');
}
if (!res.ok) {
if (res.status >= 500) {
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'
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()
if (params.file) formData.append('file', params.file)
if (params.batchId) formData.append('batchId', params.batchId)
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 }
if (sessionId) payload.sessionId = sessionId
return http.post('/api/rakuten/search1688', payload)
return http.post('/api/rakuten/search1688', payload, signal)
},
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 = {
getVersion() {
return http.get('/api/update/version')
return http.get('/api/system/version')
},
checkUpdate(currentVersion: string) {

View File

@@ -1,12 +1,17 @@
import { http } from './http'
export const zebraApi = {
getAccounts() {
return http.get('/tool/banma/accounts')
getAccounts(name?: string) {
return http.get('/tool/banma/accounts', name ? { name } : undefined)
},
saveAccount(body: any) {
return http.post('/tool/banma/accounts', body)
getAccountLimit(name?: string) {
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) {
@@ -17,23 +22,11 @@ export const zebraApi = {
return http.get('/api/banma/shops', params as Record<string, unknown>)
},
getOrders(params: any) {
return http.get('/api/banma/orders', params as Record<string, unknown>)
},
getOrdersByBatch(batchId: string) {
return http.get(`/api/banma/orders/batch/${batchId}`)
getOrders(params: any, signal?: AbortSignal) {
return http.get('/api/banma/orders', params as Record<string, unknown>, signal)
},
getLatestOrders() {
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,7 +3,8 @@ import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import { authApi } from '../../api/auth'
import { deviceApi } from '../../api/device'
import { getOrCreateDeviceId } from '../../utils/deviceId'
import { splashApi } from '../../api/splash'
interface Props {
modelValue: boolean
@@ -11,8 +12,9 @@ interface Props {
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'loginSuccess', data: { token: string; user: any }): void
(e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }): void
(e: 'showRegister'): void
(e: 'deviceConflict', username: string): void
}
const props = defineProps<Props>()
@@ -31,21 +33,36 @@ async function handleAuth() {
authLoading.value = true
try {
await deviceApi.register({ username: authForm.value.username })
const loginRes: any = await authApi.login(authForm.value)
const data = loginRes?.data || loginRes
// 获取或生成设备ID
const deviceId = await getOrCreateDeviceId()
// 登录
const loginRes: any = await authApi.login({
...authForm.value,
clientId: deviceId
})
// 保存开屏图片配置和品牌logo不阻塞登录
saveSplashConfigInBackground(authForm.value.username)
saveBrandLogoInBackground(authForm.value.username)
emit('loginSuccess', {
token: data.token,
user: {
username: data.username,
permissions: data.permissions
}
token: loginRes.data.accessToken || loginRes.data.token,
permissions: loginRes.data.permissions,
expireTime: loginRes.data.expireTime,
accountType: loginRes.data.accountType,
deviceTrialExpired: loginRes.data.deviceTrialExpired || false
})
ElMessage.success('登录成功')
resetForm()
} catch (err) {
ElMessage.error((err as Error).message)
} catch (err: any) {
// 设备冲突/数量达上限:触发设备管理
if (err.code === 501 ) {
emit('deviceConflict', authForm.value.username)
resetForm()
} else {
ElMessage.error(err.message || '登录失败')
}
} finally {
authLoading.value = false
}
@@ -63,6 +80,31 @@ function resetForm() {
function 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>
<template>
@@ -98,6 +140,7 @@ function showRegister() {
size="large"
style="margin-bottom: 20px;"
:disabled="authLoading"
show-password
@keyup.enter="handleAuth">
</el-input>
@@ -123,36 +166,10 @@ function showRegister() {
</template>
<style scoped>
.auth-logo {
width: 160px;
height: auto;
}
.auth-dialog {
--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;
}
.auth-logo {width: 160px; height: auto;}
.auth-dialog {--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>

View File

@@ -3,6 +3,7 @@ import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import { authApi } from '../../api/auth'
import { getOrCreateDeviceId } from '../../utils/deviceId'
interface Props {
modelValue: boolean
@@ -10,7 +11,7 @@ interface Props {
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'loginSuccess', data: { token: string; user: any }): void
(e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }): void
(e: 'backToLogin'): void
}
@@ -34,6 +35,10 @@ const canRegister = computed(() => {
usernameCheckResult.value === true
})
function filterUsername(value: string) {
registerForm.value.username = value.replace(/[^a-zA-Z0-9_]/g, '')
}
async function checkUsernameAvailability() {
if (!registerForm.value.username) {
usernameCheckResult.value = null
@@ -42,8 +47,8 @@ async function checkUsernameAvailability() {
try {
const res: any = await authApi.checkUsername(registerForm.value.username)
const data = res?.data || res
usernameCheckResult.value = data?.available || false
// 后端返回 {code: 200, data: true/false}data 直接是布尔值
usernameCheckResult.value = res.data
} catch {
usernameCheckResult.value = null
}
@@ -54,23 +59,30 @@ async function handleRegister() {
registerLoading.value = true
try {
await authApi.register({
// 获取设备ID
const deviceId = await getOrCreateDeviceId()
// 注册账号传递设备ID用于判断是否赠送VIP
const registerRes: any = await authApi.register({
username: registerForm.value.username,
password: registerForm.value.password
password: registerForm.value.password,
deviceId: deviceId
})
// 显示注册成功提示
if (registerRes.data.deviceTrialExpired) {
ElMessage.warning('注册成功您获得了3天VIP体验但该设备试用期已过请更换设备或联系管理员续费')
} else {
ElMessage.success('注册成功您获得了3天VIP体验')
}
const loginRes: any = await authApi.login({
username: registerForm.value.username,
password: registerForm.value.password
})
const loginData = loginRes?.data || loginRes
// 使用注册返回的token直接登录
emit('loginSuccess', {
token: loginData.token,
user: {
username: loginData.username,
permissions: loginData.permissions
}
token: registerRes.data.accessToken || registerRes.data.token,
permissions: registerRes.data.permissions,
expireTime: registerRes.data.expireTime,
accountType: registerRes.data.accountType,
deviceTrialExpired: registerRes.data.deviceTrialExpired || false
})
resetForm()
} catch (err) {
@@ -115,10 +127,11 @@ function backToLogin() {
<el-input
v-model="registerForm.username"
placeholder="请输入用户名"
placeholder="请输入用户名(字母、数字、下划线)"
size="large"
style="margin-bottom: 15px;"
:disabled="registerLoading"
@input="filterUsername"
@blur="checkUsernameAvailability">
</el-input>
@@ -137,7 +150,8 @@ function backToLogin() {
type="password"
size="large"
style="margin-bottom: 15px;"
:disabled="registerLoading">
:disabled="registerLoading"
show-password>
</el-input>
<el-input
@@ -146,7 +160,8 @@ function backToLogin() {
type="password"
size="large"
style="margin-bottom: 20px;"
:disabled="registerLoading">
:disabled="registerLoading"
show-password>
</el-input>
<div>
@@ -170,36 +185,10 @@ function backToLogin() {
</el-dialog>
</template>
<style scoped>
.auth-logo {
width: 160px;
height: auto;
}
.auth-dialog {
--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;
}
.auth-logo {width: 160px; height: auto;}
.auth-dialog {--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>

View File

@@ -1,27 +1,50 @@
<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 { genmaiApi, type GenmaiAccount } from '../../api/genmai'
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 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 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> = {
zebra: '斑马 ERP',
shopee: 'Shopee 虾皮购物',
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() {
// 目前后端只有斑马接口,其它平台先共用此接口占位
const res = await zebraApi.getAccounts()
const list = (res as any)?.data ?? res
accounts.value = Array.isArray(list) ? list : []
const api = curPlatform.value === 'genmai' ? genmaiApi : zebraApi
const username = getUsernameFromToken()
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
}
// 暴露方法供父组件调用
@@ -43,11 +66,39 @@ async function onDelete(a: any) {
try {
await ElMessageBox.confirm(`确定删除账号 "${a?.name || a?.username || id}" 吗?`, '提示', { type: 'warning' })
} catch { return }
await zebraApi.removeAccount(id)
const api = curPlatform.value === 'genmai' ? genmaiApi : zebraApi
await api.removeAccount(id)
ElMessage({ message: '删除成功', type: 'success' })
await load()
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 lang="ts">
@@ -64,8 +115,9 @@ export default defineComponent({ name: 'AccountManager' })
<div class="layout">
<aside class="sider">
<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==='genmai'}]" @click="switchPlatform('genmai')">跟卖精灵</div>
</div>
</aside>
<section class="content">
@@ -73,10 +125,10 @@ export default defineComponent({ name: 'AccountManager' })
<div class="top">
<img src="/icon/image.png" class="hero" alt="logo" />
<div class="head-main">
<div class="main-title">在线账号管理3/3</div>
<div class="main-title">在线账号管理{{ accountLimit.count }}/{{ accountLimit.limit }}</div>
<div class="main-sub">
您当前订阅可同时托管3家 Shopee 店铺<br>
如需扩增同时托管店铺数 <span class="upgrade">升级订阅</span>
您当前订阅可同时托管{{ accountLimit.limit }}{{ curPlatform === 'genmai' ? '跟卖精灵' : '斑马' }}账号<br>
<span v-if="accountLimit.limit < 3">如需扩增账号数量,请 <span class="upgrade" @click="showUpgradeDialog = true">升级订阅</span></span>
</div>
</div>
</div>
@@ -84,7 +136,7 @@ export default defineComponent({ name: 'AccountManager' })
<div v-for="a in accounts" :key="a.id" class="row">
<span :class="['dot', a.status === 1 ? 'on' : 'off']"></span>
<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>
</div>
<span class="date">{{ formatDate(a) }}</span>
@@ -92,44 +144,75 @@ export default defineComponent({ name: 'AccountManager' })
</div>
</div>
<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>
</section>
</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>
</template>
<style scoped>
.acc-manager :deep(.el-dialog__header) { text-align:center; }
.layout { display:grid; grid-template-columns: 160px 1fr; gap: 12px; min-height: 340px; }
.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; }
.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:hover { background:#f0f2f5; }
.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; }
.content { display:flex; flex-direction: column; min-width: 0; }
.top { display:flex; flex-direction: column; align-items:center; gap: 6px; margin-bottom: 12px; }
.hero { width: 160px; height: auto; }
.head-main { text-align:center; }
.main-title { font-size: 16px; font-weight: 600; color:#303133; margin-bottom: 4px; }
.main-sub { color:#909399; font-size: 11px; line-height: 1.4; }
.upgrade { color:#409EFF; cursor: pointer; }
.list { border:1px solid #ebeef5; border-radius: 6px; background: #fff; flex: 0 0 auto; width: 100%; max-height: 160px; overflow-y: auto; }
.list.compact { max-height: 48px; }
.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; }
.row:last-child { border-bottom:none; }
.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; }
.acc-manager :deep(.el-dialog__header) {text-align:center;}
.layout {display:grid; grid-template-columns: 160px 1fr; gap: 12px; min-height: 340px;}
.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;}
.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:hover {background:#f0f2f5;}
.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;}
.content {display:flex; flex-direction: column; min-width: 0;}
.top {display:flex; flex-direction: column; align-items:center; gap: 6px; margin-bottom: 12px;}
.hero {width: 160px; height: auto;}
.head-main {text-align:center;}
.main-title {font-size: 16px; font-weight: 600; color:#303133; margin-bottom: 4px;}
.main-sub {color:#909399; font-size: 11px; line-height: 1.4;}
.upgrade {color:#409EFF; cursor: pointer; font-weight: 600; transition: all 0.2s ease;}
.upgrade:hover {color:#0d5ed6; text-decoration: underline;}
.list {border:1px solid #ebeef5; border-radius: 6px; background: #fff; flex: 0 0 auto; width: 100%; max-height: 160px; overflow-y: auto;}
.list.compact {max-height: 48px;}
/* 添加账号对话框样式 */
.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-title {font-weight: 600; font-size: 18px; text-align: center;}
.add-account-dialog .aad-row {margin-top: 12px;}
:deep(.add-account-dialog .el-dialog__header) {text-align: center; padding-right: 0; display: block;}
.btn-blue {background: #1677FF; border-color: #1677FF; color: #fff;}
.btn-blue:hover {background: #0d5ed6; border-color: #0d5ed6;}
.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;}
.row:last-child {border-bottom:none;}
.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>

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,20 +1,18 @@
<template>
<div>
<div class="version-info" @click="autoCheck">v{{ version || '-' }}</div>
<el-dialog v-model="show" width="522px" :close-on-click-modal="false" align-center class="update-dialog"
<el-dialog v-model="show" width="522px" :close-on-click-modal="false" align-center
:class="['update-dialog', `stage-${stage}`]"
:title="stage === 'downloading' ? `正在更新 ${appName}` : '软件更新'">
<div v-if="stage === 'check'" class="update-content">
<div class="update-layout">
<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 class="right-pane">
<p class="announce">新版本的"{{ appName }}"已经发布</p>
<p class="desc">{{ appName }} {{ info.latestVersion }} 可供安装您现在的版本是 {{
version
}}要现在安装吗</p>
<div class="update-details form">
<h4>更新信息</h4>
<el-input
@@ -25,14 +23,13 @@
readonly
resize="none"/>
</div>
<div class="update-actions row">
<div class="update-buttons">
<div class="left-actions">
<el-button size="small" @click="show=false">跳过这个版本</el-button>
<el-button size="small" @click="skipVersion">跳过这个版本</el-button>
</div>
<div class="right-actions">
<el-button size="small" @click="show=false">稍后提醒</el-button>
<el-button size="small" @click="remindLater">稍后提醒</el-button>
<el-button size="small" type="primary" @click="start">下载更新</el-button>
</div>
</div>
@@ -44,11 +41,11 @@
<div v-else-if="stage === 'downloading'" class="update-content">
<div class="download-main">
<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 class="download-content">
<div class="download-info">
<p>正在下载更新</p>
<p>正在下载安装...</p>
</div>
<div class="download-progress">
<el-progress
@@ -64,28 +61,31 @@
</div>
</div>
</div>
<div v-else-if="stage === 'completed'" class="update-content">
<div class="update-header text-center">
<img src="/icon/icon.png" class="app-icon" alt="App Icon"/>
<h3>更新完成</h3>
<p>更新文件已下载将在重启后自动应用</p>
</div>
<div class="download-progress">
<div class="progress-info">
<span>{{ prog.current }} / {{ prog.total }}</span>
<div class="download-main">
<div class="download-icon">
<img src="/icon/icon1.png" class="app-icon" alt="App Icon"/>
</div>
<div class="download-content">
<div class="download-info">
<p>可以开始安装了</p>
</div>
<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>
<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>
</el-dialog>
@@ -93,13 +93,17 @@
</template>
<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 {updateApi} from '../../api/update'
import {getSettings} from '../../utils/settings'
import {getUsernameFromToken} from '../../utils/token'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
defineExpose({ checkForUpdatesNow })
const show = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
@@ -109,102 +113,137 @@ type Stage = 'check' | 'downloading' | 'completed'
const stage = ref<Stage>('check')
const appName = ref('我了个电商')
const version = ref('')
const prog = ref({percentage: 0, current: '0 MB', total: '0 MB', speed: ''})
const prog = ref({percentage: 0, current: '0 MB', total: '0 MB'})
const info = ref({
latestVersion: '2.4.8',
downloadUrl: '',
updateNotes: '• 优化了用户界面体验\n• 修复了已知问题\n• 提升了系统稳定性\n• 增加了新的功能模块\n• 优化了数据处理性能',
currentVersion: '',
hasUpdate: false
latestVersion: '',
asarUrl: '',
jarUrl: '',
updateNotes: '',
currentVersion: ''
})
async function autoCheck() {
async function checkUpdate(silent = false) {
try {
version.value = await (window as any).electronAPI.getJarVersion()
const checkRes: any = await updateApi.checkUpdate(version.value)
const result = checkRes?.data || checkRes
const result = (await updateApi.checkUpdate(version.value))?.data
info.value = {
currentVersion: result.currentVersion || version.value,
latestVersion: result.latestVersion || version.value,
asarUrl: result.asarUrl || '',
jarUrl: result.jarUrl || '',
updateNotes: result.updateNotes || ''
}
if (!result.needUpdate) {
ElMessage.info('当前已是最新版本')
if (!silent) ElMessage.info('当前已是最新版本')
return
}
info.value = {
currentVersion: result.currentVersion,
latestVersion: result.latestVersion,
downloadUrl: result.downloadUrl || '',
updateNotes: '• 优化了用户界面体验\n• 修复了已知问题\n• 提升了系统稳定性\n• 轻量级更新仅替换app.asar',
hasUpdate: true
if (localStorage.getItem('skipped_version') === result.latestVersion) return
const remindTime = localStorage.getItem('remind_later_time')
if (remindTime && Date.now() < parseInt(remindTime)) return
if (getSettings(getUsernameFromToken()).autoUpdate) {
await downloadUpdate()
return
}
show.value = true
stage.value = 'check'
ElMessage.success('发现新版本')
if (!silent) ElMessage.success('发现新版本')
} catch (error) {
console.error('检查更新失败:', error)
ElMessage.error('检查更新失败')
if (!silent) ElMessage.error('检查更新失败')
}
}
async function checkForUpdatesNow() {
if (stage.value === 'downloading' || stage.value === 'completed') {
show.value = true
return
}
await checkUpdate(false)
}
function skipVersion() {
localStorage.setItem('skipped_version', info.value.latestVersion)
show.value = false
}
function remindLater() {
localStorage.setItem('remind_later_time', (Date.now() + 24 * 60 * 60 * 1000).toString())
show.value = false
}
async function start() {
if (!info.value.downloadUrl) {
ElMessage.error('下载链接不可用')
if (stage.value !== 'check') {
show.value = true
return
}
await downloadUpdate(true)
}
async function downloadUpdate(showDialog = false) {
if (!info.value.asarUrl && !info.value.jarUrl) {
if (showDialog) ElMessage.error('下载链接不可用')
return
}
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) => {
prog.value = {
percentage: progress.percentage || 0,
current: progress.current || '0 MB',
total: progress.total || '0 MB',
speed: progress.speed || ''
total: progress.total || '0 MB'
}
})
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) {
stage.value = 'completed'
prog.value.percentage = 100
ElMessage.success('下载完成')
show.value = true
ElMessage.success(showDialog ? '下载完成' : '更新已下载完成,可以安装了')
} else {
ElMessage.error('下载失败: ' + (response.error || '未知错误'))
stage.value = 'check'
if (showDialog) ElMessage.error('下载失败: ' + (response.error || '未知错误'))
;(window as any).electronAPI.removeDownloadProgressListener()
}
} catch (error) {
console.error('下载失败:', error)
ElMessage.error('下载失败')
stage.value = 'check'
if (showDialog) ElMessage.error('下载失败')
;(window as any).electronAPI.removeDownloadProgressListener()
}
}
async function cancelDownload() {
try {
(window as any).electronAPI.removeDownloadProgressListener()
await (window as any).electronAPI.cancelDownload()
show.value = false
stage.value = 'check'
} catch (error) {
console.error('取消下载失败:', error)
show.value = false
stage.value = 'check'
}
;(window as any).electronAPI.removeDownloadProgressListener()
await (window as any).electronAPI.cancelDownload().catch(() => {})
stage.value = 'check'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
show.value = false
ElMessage.info('取消下载')
}
async function installUpdate() {
try {
await ElMessageBox.confirm(
'安装过程中程序将自动重启,请确保已保存所有工作。确定要立即安装更新吗?',
'确认安装',
{
confirmButtonText: '立即安装',
cancelButtonText: '取消',
type: 'warning'
}
)
await ElMessageBox.confirm('安装过程中程序将自动重启,请确保已保存所有工作。确定要立即安装更新吗?', '确认安装', {
confirmButtonText: '立即安装',
cancelButtonText: '取消',
type: 'warning'
})
const response = await (window as any).electronAPI.installUpdate()
if (response.success) {
ElMessage.success('应用即将重启')
@@ -215,287 +254,102 @@ 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 () => {
version.value = await (window as any).electronAPI.getJarVersion()
const pendingUpdate = await (window as any).electronAPI.checkPendingUpdate()
if (pendingUpdate?.hasPendingUpdate) {
stage.value = 'completed'
prog.value.percentage = 100
return
}
await checkUpdate(true)
})
onUnmounted(() => {
(window as any).electronAPI.removeDownloadProgressListener()
})
</script>
<style scoped>
.version-info {
position: fixed;
right: 10px;
bottom: 10px;
background: rgba(255, 255, 255, 0.9);
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
color: #909399;
z-index: 1000;
cursor: pointer;
user-select: none;
}
:deep(.update-dialog .el-dialog) {
border-radius: 16px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.15);
}
:deep(.update-dialog .el-dialog__header) {
display: block;
text-align: left;
}
: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;
}
.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 {
text-align: center;
margin-bottom: 20px;
}
.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;
}
:deep(.update-dialog .el-dialog) {border-radius: 16px; box-shadow: 0 24px 48px rgba(0, 0, 0, 0.15);}
/* 通用标题样式 */
:deep(.update-dialog .el-dialog__title) {font-size: 14px; font-weight: 500; margin-left: 8px;}
/* 默认标题样式(第一阶段 - 检查阶段) */
:deep(.update-dialog.stage-check .el-dialog__header) {display: block; text-align: left;}
/* 第二阶段 - 下载中,标题居中 */
:deep(.update-dialog.stage-downloading .el-dialog__header) {display: block; text-align: center;}
:deep(.update-dialog.stage-downloading .el-dialog__title) {margin-left: 20px;}
/* 第三阶段 - 下载完成,标题居中 */
:deep(.update-dialog.stage-completed .el-dialog__header) {display: block; text-align: center;}
:deep(.update-dialog.stage-completed .el-dialog__title) {margin-left: 20px;}
: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;}
.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;}
.action-buttons {display: flex; gap: 8px;}
: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>

View File

@@ -1,23 +1,73 @@
<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 {
canGoBack: boolean
canGoForward: boolean
activeMenu: string
isAuthenticated: boolean
currentUsername: string
currentVersion: string
}
interface Emits {
(e: 'go-back'): void
(e: 'go-forward'): void
(e: 'reload'): void
(e: 'user-click'): void
(e: 'logout'): void
(e: 'open-device'): void
(e: 'open-settings'): void
(e: 'open-account-manager'): void
(e: 'check-update'): void
}
defineProps<Props>()
defineEmits<Emits>()
const props = defineProps<Props>()
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>
<template>
@@ -25,149 +75,109 @@ defineEmits<Emits>()
<div class="navbar-left">
<div class="nav-controls">
<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 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>
</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 class="navbar-center">
<div class="breadcrumbs">
<span>首页</span>
<span class="separator">></span>
<span class="separator">/</span>
<span>{{ activeMenu }}</span>
</div>
</div>
<div class="navbar-right">
<button class="nav-btn-round" title="刷新" @click="$emit('reload')">
<el-icon><Refresh /></el-icon>
</button>
<button class="nav-btn-round" title="设备管理" @click="$emit('open-device')">
<el-icon><Monitor /></el-icon>
</button>
<button class="nav-btn-round" title="设置" @click="$emit('open-settings')">
<el-icon><Setting /></el-icon>
</button>
<button class="nav-btn-round" title="用户" @click="$emit('user-click')">
<el-icon><User /></el-icon>
</button>
<!-- 窗口控制按钮 -->
<div class="window-controls">
<button class="window-btn window-btn-minimize" title="最小化" @click="handleMinimize">
<el-icon><Minus /></el-icon>
</button>
<button class="window-btn window-btn-maximize" title="最大化" @click="handleMaximize">
<svg viewBox="0 0 12 12" class="maximize-icon">
<rect x="2" y="2" width="8" height="8" stroke="currentColor" fill="none" stroke-width="1"/>
</svg>
</button>
<button class="window-btn window-btn-close" title="关闭" @click="handleClose">
<el-icon><CloseBold /></el-icon>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.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);
}
.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;
}
.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;}
.navbar-left {display: flex; align-items: center; gap: 8px; flex: 0 0 auto; -webkit-app-region: no-drag;}
.navbar-center {display: flex; justify-content: center; flex: 1;}
.navbar-right {display: flex; align-items: center; gap: 8px; flex: 0 0 auto; -webkit-app-region: no-drag;}
.nav-controls {display: flex; gap: 4px;}
.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;}
.arrow-icon {width: 18px; height: 18px; flex-shrink: 0;}
.arrow-icon path {stroke: currentColor;}
.nav-btn:hover:not(:disabled) {background: rgba(0, 0, 0, 0.05); color: #409EFF;}
.nav-btn:hover:not(:disabled) .arrow-icon path {stroke: #409EFF;}
.nav-btn:focus,
.nav-btn:active {
outline: none;
border: none;
}
.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:active {outline: none; border: none;}
.nav-btn:disabled {cursor: not-allowed; background: transparent; color: #d0d0d0;}
.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-round:focus,
.nav-btn-round:active {
outline: none;
}
.nav-btn-round:active {outline: none;}
.breadcrumbs {display: flex; align-items: center; color: #606266; font-size: 14px;}
.separator {margin: 0 6px; color: #c0c4cc; font-size: 14px;}
/* 窗口控制按钮 */
.window-controls {display: flex; align-items: center; gap: 4px; margin-left: 8px;}
.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;}
.window-btn:hover {background: rgba(0, 0, 0, 0.05);}
.window-btn:active {background: rgba(0, 0, 0, 0.1);}
.window-btn-close:hover {background: #e81123; color: #ffffff;}
.window-btn-close:active {background: #f1707a;}
.maximize-icon {width: 12px; height: 12px;}
/* 登录/注册按钮 */
</style>
.breadcrumbs {
display: flex;
align-items: center;
color: #606266;
font-size: 14px;
}
.separator {
margin: 0 8px;
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>

View File

@@ -1,9 +1,20 @@
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue'
import { ElMessage } from 'element-plus'
import {ref, computed, onMounted, defineAsyncComponent, inject} from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {rakutenApi} from '../../api/rakuten'
import { batchConvertImages } from '../../utils/imageProxy'
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状态
const props = defineProps<{
isVip: boolean
}>()
// UI 与加载状态
const loading = ref(false)
@@ -11,12 +22,12 @@ const tableLoading = ref(false)
const exportLoading = ref(false)
const statusMessage = ref('')
const statusType = ref<'info' | 'success' | 'warning' | 'error'>('info')
let abortController: AbortController | null = null
// 查询与上传
const singleShopName = ref('')
const currentBatchId = ref('')
const uploadInputRef = ref<HTMLInputElement | null>(null)
const dragActive = ref(false)
// 数据与分页
const allProducts = ref<any[]>([])
@@ -44,6 +55,12 @@ const activeStep = computed(() => {
return 2
})
// 试用期过期弹框
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both' | 'subscribe'>('account')
const vipStatus = inject<any>('vipStatus')
// 左侧:上传文件名与地区
const selectedFileName = ref('')
const pendingFile = ref<File | null>(null)
@@ -114,17 +131,34 @@ function needsSearch(product: any) {
}
async function loadLatest() {
const resp = await rakutenApi.getLatestProducts()
allProducts.value = (resp.products || []).map(p => ({...p, skuPrices: parseSkuPrices(p)}))
const resp: any = await rakutenApi.getLatestProducts()
const products = resp.data.products || []
allProducts.value = products.map((p: any) => ({...p, skuPrices: parseSkuPrices(p)}))
}
function hasValid1688Data(data: any) {
if (!data) return false
const skuJson = data.skuPriceJson || data.skuPrice
const prices = parseSkuPrices({ skuPriceJson: skuJson })
if (!data.mapRecognitionLink) return false
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
}
async function searchProductInternal(product: any) {
if (!product || !product.imgUrl) return
if (!needsSearch(product)) return
const res = await rakutenApi.search1688(product.imgUrl, currentBatchId.value)
const data = res
const skuJson = (data as any)?.skuPriceJson ?? (data as any)?.skuPrice
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
if (!hasValid1688Data(data)) return false
const skuJson = data.skuPriceJson || data.skuPrice
Object.assign(product, {
mapRecognitionLink: data.mapRecognitionLink,
freight: data.freight,
@@ -136,6 +170,21 @@ async function searchProductInternal(product: any) {
image1688Url: 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) {
@@ -175,19 +224,28 @@ async function handleExcelUpload(e: Event) {
input.value = ''
}
function onDragOver(e: DragEvent) { e.preventDefault(); dragActive.value = true }
function onDragLeave() { dragActive.value = false }
async function onDrop(e: DragEvent) {
e.preventDefault()
dragActive.value = false
const file = e.dataTransfer?.files?.[0]
if (!file) return
await processFile(file)
}
// 拖拽上传
const { dragActive, onDragEnter, onDragOver, onDragLeave, onDrop } = useFileDrop({
accept: /\.xlsx?$/i,
onFile: processFile,
onError: (msg) => ElMessage({ message: msg, type: 'warning' })
})
// 点击获取数据
// 点击"获取数据
async function handleStartSearch() {
// 刷新VIP状态
if (refreshVipStatus) await refreshVipStatus()
// VIP检查
if (!props.isVip) {
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
showTrialExpiredDialog.value = true
return
}
abortController = new AbortController()
if (pendingFile.value) {
try {
loading.value = true
@@ -199,18 +257,19 @@ async function handleStartSearch() {
progressPercentage.value = 0
totalProducts.value = 0
processedProducts.value = 0
const resp = await rakutenApi.getProducts({file: pendingFile.value, batchId: currentBatchId.value})
const products = (resp.products || []).map(p => ({...p, skuPrices: parseSkuPrices(p)}))
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)}))
if (products.length === 0) {
showMessage('未采集到数据,请检查代理或店铺是否存在', 'warning')
}
allProducts.value = products
pendingFile.value = null
} catch (e) {
statusType.value = 'error'
statusMessage.value = '解析失败,请重试'
} catch (e: any) {
if (e.name !== 'AbortError') {
statusType.value = 'error'
statusMessage.value = '解析失败,请重试'
}
} finally {
loading.value = false
tableLoading.value = false
@@ -224,17 +283,21 @@ async function handleStartSearch() {
progressPercentage.value = 100
statusType.value = 'success'
statusMessage.value = ''
abortController = null
return
}
if (items.length === 0) {
statusType.value = 'warning'
statusMessage.value = '没有可处理的商品,请先导入或查询店铺'
abortController = null
return
}
await startBatch1688Search(items)
}
function stopTask() {
abortController?.abort()
abortController = null
loading.value = false
tableLoading.value = false
statusType.value = 'warning'
@@ -249,6 +312,7 @@ async function startBatch1688Search(products: any[]) {
progressPercentage.value = 100
statusType.value = 'success'
statusMessage.value = '所有商品都已获取1688数据'
abortController = null
return
}
loading.value = true
@@ -267,6 +331,7 @@ async function startBatch1688Search(products: any[]) {
statusMessage.value = ''
}
loading.value = false
abortController = null
}
async function serialSearch1688(products: any[]) {
@@ -274,7 +339,7 @@ async function serialSearch1688(products: any[]) {
const product = products[i]
product.searching1688 = true
await nextTickSafe()
await searchProductInternal(product)
await searchProductWithRetry(product)
product.searching1688 = false
processedProducts.value++
progressPercentage.value = Math.floor((processedProducts.value / Math.max(1, totalProducts.value)) * 100)
@@ -290,7 +355,6 @@ function delay(ms: number) {
}
function nextTickSafe() {
// 不额外引入 nextTick使用微任务刷新即可保持体积精简
return Promise.resolve()
}
@@ -299,6 +363,14 @@ function showMessage(message: string, type: 'info' | 'success' | 'warning' | 'er
ElMessage({ message, type })
}
function removeSelectedFile() {
selectedFileName.value = ''
pendingFile.value = null
if (uploadInputRef.value) {
uploadInputRef.value.value = ''
}
}
async function exportToExcel() {
if (!allProducts.value.length) {
showMessage('没有数据可供导出', 'warning')
@@ -356,12 +428,10 @@ async function exportToExcel() {
base64: base64Data,
extension: 'jpeg',
})
worksheet.addImage(imageId, {
tl: { col: 1, row: row.number - 1 },
ext: { width: 60, height: 60 }
})
row.height = 50
}
}
@@ -372,10 +442,11 @@ async function exportToExcel() {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const fileName = `乐天商品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
await handlePlatformFileExport('rakuten', blob, fileName)
showMessage('Excel文件导出成功', 'success')
const username = getUsernameFromToken()
const success = await handlePlatformFileExport('rakuten', blob, fileName, username)
if (success) {
showMessage('Excel文件导出成功', 'success')
}
} catch (error) {
showMessage('导出失败', 'error')
} finally {
@@ -388,7 +459,6 @@ onMounted(loadLatest)
</script>
<template>
<div class="rakuten-root">
<div class="main-container">
<div class="body-layout">
<!-- 左侧步骤栏 -->
@@ -410,7 +480,7 @@ onMounted(loadLatest)
<a class="link" @click.prevent="downloadRakutenTemplate">点击下载模板</a>
</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-text">点击或将文件拖拽到这里上传</div>
<div class="dz-sub">支持 .xls .xlsx</div>
@@ -419,6 +489,7 @@ onMounted(loadLatest)
<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>
@@ -429,7 +500,7 @@ onMounted(loadLatest)
<div class="step-header">
<div class="title">网站地区</div>
</div>
<div class="desc">请选择目标网站地区日本区</div>
<div class="desc">仅支持乐天市场日本区商品查询后续将开放更多乐天网站地区敬请期待</div>
<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">
<span style="margin-right:6px">{{ opt.flag }}</span>{{ opt.label }}
@@ -462,12 +533,8 @@ onMounted(loadLatest)
</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>
<!-- 导出进度条 -->
</div>
</div>
</div>
</aside>
@@ -481,6 +548,9 @@ onMounted(loadLatest)
<el-button type="primary" class="btn-blue" @click="rakutenExampleVisible = false">我知道了</el-button>
</template>
</el-dialog>
<!-- 试用期过期弹框 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
<!-- 数据显示区域 -->
<div class="table-container">
<div class="table-section">
@@ -524,7 +594,7 @@ onMounted(loadLatest)
</td>
<td>
<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>
<span v-else>无图片</span>
</td>
@@ -560,9 +630,8 @@ onMounted(loadLatest)
</div>
</div>
<div class="pagination-fixed" >
<div class="pagination-fixed">
<el-pagination
background
:current-page="currentPage"
:page-sizes="[15,30,50,100]"
:page-size="pageSize"
@@ -580,227 +649,101 @@ onMounted(loadLatest)
</template>
<style scoped>
.rakuten-root {
position: absolute;
inset: 0;
background: #f5f5f5;
padding: 12px;
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; }
.rakuten-root {position: absolute; inset: 0; background: #fff; box-sizing: border-box;}
.main-container {height: 100%; display: flex; flex-direction: column; padding: 12px; box-sizing: border-box;}
.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: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 + .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:after { display: none; }
.step-card { border: none; border-radius: 0; padding: 0; background: transparent; }
.step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.title { font-size: 13px; font-weight: 600; color: #303133; 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; }
.links { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
.link { color: #409EFF; cursor: pointer; font-size: 12px; }
.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; }
.dropzone { border: 1px dashed #c0c4cc; border-radius: 6px; padding: 12px; text-align: center; cursor: pointer; background: #fafafa; }
.dropzone:hover { background: #f6fbff; border-color: #409EFF; }
.dropzone.disabled { opacity: .6; cursor: not-allowed; }
.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; }
.single-input.left { display: flex; gap: 8px; }
.action-buttons.column { display: flex; flex-direction: column; gap: 8px; }
.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 .dot { width: 6px; height: 6px; background: #409EFF; border-radius: 50%; display: inline-block; }
.file-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.progress-section.left { margin-top: 10px; }
.full { width: 100%; }
.form-row { margin-bottom: 10px; }
.label { display: block; font-size: 12px; color: #606266; margin-bottom: 6px; }
.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);}
.flow-item {position: relative; display: grid; grid-template-columns: 22px 1fr; gap: 10px; padding: 8px 0;}
.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;}
.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: 6px;}
.title {font-size: 13px; font-weight: 600; color: #303133; 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;}
.links {display: flex; align-items: center; gap: 6px; margin-bottom: 8px;}
.link {color: #409EFF; cursor: pointer; font-size: 12px;}
.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;}
.dropzone {border: 1px dashed #c0c4cc; border-radius: 6px; padding: 12px; text-align: center; cursor: pointer; background: #fafafa;}
.dropzone:hover {background: #f6fbff; border-color: #409EFF;}
.dropzone.disabled {opacity: .6; cursor: not-allowed;}
.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;}
.single-input.left {display: flex; gap: 8px;}
.action-buttons.column {display: flex; flex-direction: column; gap: 8px;}
.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;}
.progress-section.left {margin-top: 10px;}
.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-range-editor.el-input__wrapper),
.steps-sidebar :deep(.el-input),
.steps-sidebar :deep(.el-input__wrapper),
.steps-sidebar :deep(.el-select) { width: 100%; box-sizing: border-box; }
.btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; }
.btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; }
.w100 { width: 100%; }
.steps-sidebar :deep(.el-button + .el-button) { margin-left: 0; }
.progress-section { margin: 0px 12px 0px 12px; }
.progress-box { padding: 4px 0; }
.progress-container { display: flex; align-items: center; gap: 8px; }
.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-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;
}
.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: #1677FF; border-radius: 2px; transition: width 0.3s ease; }
.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;
}
.empty-section {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
}
.empty-container {
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.6;
}
.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;
.steps-sidebar :deep(.el-select) {width: 100%; box-sizing: border-box;}
.btn-blue {background: #1677FF; border-color: #1677FF; color: #fff;}
.btn-blue:disabled {background: #a6c8ff; border-color: #a6c8ff; color: #fff;}
.w100 {width: 100%;}
.steps-sidebar :deep(.el-button + .el-button) {margin-left: 0;}
.progress-section {margin: 0px 12px 0px 12px;}
.progress-box {padding: 4px 0;}
.progress-container {display: flex; align-items: center; gap: 8px;}
.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-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;}
.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: #1677FF; border-radius: 2px; transition: width 0.3s ease;}
.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;}
.empty-section {flex: 1; display: flex; justify-content: center; align-items: center; background: #fff; border: 1px solid #ebeef5; border-radius: 6px;}
.empty-container {text-align: center;}
.empty-icon {font-size: 48px; margin-bottom: 16px; opacity: 0.6;}
.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 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>
<script lang="ts">

View File

@@ -1,21 +1,24 @@
<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 { zebraApi, type ZebraOrder, type BanmaAccount } from '../../api/zebra'
import AccountManager from '../common/AccountManager.vue'
import { batchConvertImages } from '../../utils/imageProxy'
import { handlePlatformFileExport } from '../../utils/settings'
import { getUsernameFromToken } from '../../utils/token'
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
// 接收VIP状态
const props = defineProps<{
isVip: boolean
}>()
type Shop = { id: string; shopName: string }
const accounts = ref<BanmaAccount[]>([])
const accountId = ref<number>()
// 收起功能移除
const shopList = ref<Shop[]>([])
const selectedShops = ref<string[]>([])
const dateRange = ref<string[]>([])
const loading = ref(false)
const exportLoading = ref(false)
const progressPercentage = ref(0)
@@ -30,6 +33,12 @@ const fetchCurrentPage = ref(1)
const fetchTotalPages = ref(0)
const fetchTotalItems = ref(0)
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) {
accountId.value = id
loadShops()
@@ -63,7 +72,8 @@ async function loadShops() {
async function loadAccounts() {
try {
const res = await zebraApi.getAccounts()
const username = getUsernameFromToken()
const res = await zebraApi.getAccounts(username)
const list = (res as any)?.data ?? res
accounts.value = Array.isArray(list) ? list : []
const def = accounts.value.find(a => a.isDefault === 1) || accounts.value[0]
@@ -86,6 +96,16 @@ function handleCurrentChange(page: number) {
async function fetchData() {
if (isFetching.value) return
// 刷新VIP状态
if (refreshVipStatus) await refreshVipStatus()
// VIP检查
if (!props.isVip) {
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
showTrialExpiredDialog.value = true
return
}
abortController = new AbortController()
loading.value = true
isFetching.value = true
showProgress.value = true
@@ -94,16 +114,17 @@ async function fetchData() {
fetchCurrentPage.value = 1
fetchTotalItems.value = 0
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)
}
async function fetchPageData(startDate: string, endDate: string) {
if (!isFetching.value) return
try {
const data = await zebraApi.getOrders({
const response = await zebraApi.getOrders({
accountId: Number(accountId.value) || undefined,
startDate,
endDate,
@@ -111,14 +132,13 @@ async function fetchPageData(startDate: string, endDate: string) {
pageSize: 50,
shopIds: selectedShops.value.join(','),
batchId: currentBatchId.value
})
}, abortController?.signal)
const data = (response as any)?.data || response
const orders = data.orders || []
allOrderData.value = [...allOrderData.value, ...orders]
fetchTotalPages.value = data.totalPages || 0
fetchTotalItems.value = data.total || 0
if (fetchCurrentPage.value < fetchTotalPages.value && isFetching.value) {
progressPercentage.value = Math.round((fetchCurrentPage.value / fetchTotalPages.value) * 100)
fetchCurrentPage.value++
@@ -127,8 +147,10 @@ async function fetchPageData(startDate: string, endDate: string) {
progressPercentage.value = 100
finishFetching()
}
} catch (e) {
console.error('获取订单数据失败:', e)
} catch (e: any) {
if (e.name !== 'AbortError') {
console.error('获取订单数据失败:', e)
}
finishFetching()
}
}
@@ -136,6 +158,7 @@ async function fetchPageData(startDate: string, endDate: string) {
function finishFetching() {
isFetching.value = false
loading.value = false
abortController = null
// 确保进度条完全填满
progressPercentage.value = 100
currentPage.value = 1
@@ -143,6 +166,8 @@ function finishFetching() {
}
function stopFetch() {
abortController?.abort()
abortController = null
isFetching.value = false
loading.value = false
// 进度条保留显示,不自动隐藏
@@ -237,9 +262,12 @@ async function exportToExcel() {
})
const fileName = `斑马订单数据_${new Date().toISOString().slice(0, 10)}.xlsx`
await handlePlatformFileExport('zebra', blob, fileName)
const username = getUsernameFromToken()
const success = await handlePlatformFileExport('zebra', blob, fileName, username)
showMessage('Excel文件导出成功', 'success')
if (success) {
showMessage('Excel文件导出成功', 'success')
}
} catch (error) {
showMessage('导出失败', 'error')
} finally {
@@ -266,7 +294,19 @@ const rememberPwd = ref(true)
const managerVisible = ref(false)
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
accountForm.value = { name: '', username: '', isDefault: 0, status: 1 }
formUsername.value = ''
@@ -295,7 +335,8 @@ async function submitAccount() {
status: accountForm.value.status || 1,
}
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
if (!id) throw new Error((res as any)?.msg || '保存失败')
accountDialogVisible.value = false
@@ -343,9 +384,8 @@ async function removeCurrentAccount() {
>
<span class="acct-row">
<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 v-if="a.isDefault===1" class="tag">默认</span>
<span v-if="accountId === a.id" class="acct-check"></span>
</span>
</div>
@@ -368,8 +408,8 @@ async function removeCurrentAccount() {
<section class="step">
<div class="step-index">2</div>
<div class="step-body">
<div class="step-title">查询的日期</div>
<div class="tip">请选择查询数据的日期范围</div>
<div class="step-title">需查询的店铺与日期</div>
<div class="tip">请选择查询的店铺可多选与日期范围选项为空时默认获取全部数据</div>
<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-select>
@@ -384,7 +424,7 @@ async function removeCurrentAccount() {
<div class="step-title">获取数据</div>
<div class="tip">点击下方按钮开始查询订单数据</div>
<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>
</div>
</div>
@@ -396,7 +436,7 @@ async function removeCurrentAccount() {
<div class="step-title">导出数据</div>
<div class="tip">点击下方按钮导出所有订单数据到 Excel 文件</div>
<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>
@@ -445,7 +485,7 @@ async function removeCurrentAccount() {
<td>{{ row.orderedAt || '-' }}</td>
<td>
<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>
<span v-else>无图片</span>
</td>
@@ -487,7 +527,6 @@ async function removeCurrentAccount() {
<!-- 底部区域分页器 -->
<div class="pagination-fixed">
<el-pagination
background
:current-page="currentPage"
:page-sizes="[15,30,50,100]"
:page-size="pageSize"
@@ -521,7 +560,11 @@ async function removeCurrentAccount() {
<el-button type="primary" class="btn-blue" style="width: 100%" @click="submitAccount">登录</el-button>
</template>
</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>
</template>
@@ -532,93 +575,94 @@ export default {
</script>
<style scoped>
.zebra-root { position: absolute; inset: 0; background: #f5f5f5; padding: 12px; 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; }
.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-header { display: flex; justify-content: flex-start; align-items: center; font-weight: 600; color: #606266; margin-bottom: 8px; }
.aside-steps { position: relative; }
.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-body { min-width: 0; 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); }
.account-list {height: auto; }
.step-actions { margin-top: 8px; display: flex; gap: 8px; }
.step-accounts { position: relative; }
.sticky-actions { position: sticky; bottom: 0; background: #fafafa; padding-top: 8px; }
.scroll-limit { max-height: 160px; }
.btn-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.btn-col { display: flex; flex-direction: column; gap: 6px; }
.w50 { width: 48%; }
.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-img { width: 120px; opacity: 0.9; }
.placeholder-tip { margin-top: 6px; font-size: 12px; color: #a8abb2; }
.aside :deep(.el-date-editor) { width: 100%; }
.aside :deep(.el-range-editor.el-input__wrapper) { width: 100%; box-sizing: border-box; }
.zebra-root {position: absolute; inset: 0; background: #fff; box-sizing: border-box;}
.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.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-steps {position: relative;}
.step {display: grid; grid-template-columns: 22px 1fr; gap: 10px; position: relative; padding: 8px 0;}
.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-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);}
.account-list {height: auto;}
.step-actions {margin-top: 8px; display: flex; gap: 8px;}
.step-accounts {position: relative;}
.sticky-actions {position: sticky; bottom: 0; background: #fafafa; padding-top: 8px;}
.scroll-limit {max-height: 160px;}
.btn-row {display: grid; grid-template-columns: 1fr 1fr; gap: 8px;}
.btn-col {display: flex; flex-direction: column; gap: 6px;}
.w50 {width: 48%;}
.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-img {width: 120px; opacity: 0.9;}
.placeholder-tip {margin-top: 6px; font-size: 12px; color: #a8abb2;}
.aside :deep(.el-date-editor) {width: 100%;}
.aside :deep(.el-range-editor.el-input__wrapper) {width: 100%; box-sizing: border-box;}
.aside :deep(.el-input),
.aside :deep(.el-input__wrapper),
.aside :deep(.el-select) { width: 100%; box-sizing: border-box; }
.aside :deep(.el-button + .el-button) { margin-left: 0 !important; }
.btn-row :deep(.el-button) { width: 100%; }
.btn-col :deep(.el-button) { width: 100%; }
.btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; }
.btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; }
.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; }
.acct-text { vertical-align: middle; }
.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; }
.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: 8px; cursor: pointer; }
.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; }
.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-icon { width: 120px; height: auto; }
.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-opts { display:flex; align-items:center; }
.aside :deep(.el-select) {width: 100%; box-sizing: border-box;}
.aside :deep(.el-button + .el-button) {margin-left: 0 !important;}
.btn-row :deep(.el-button) {width: 100%;}
.btn-col :deep(.el-button) {width: 100%;}
.btn-blue {background: #1677FF; border-color: #1677FF; color: #fff;}
.btn-blue:disabled {background: #a6c8ff; border-color: #a6c8ff; color: #fff;}
.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;}
.acct-text {vertical-align: middle;}
.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;}
.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: 8px; cursor: pointer;}
.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;}
.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-icon {width: 120px; height: auto;}
.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-opts {display:flex; align-items:center;}
/* 居中 header避免右上角关闭按钮影响视觉中心 */
: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; }
.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 { 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: 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 tbody tr:hover { background: #f9f9f9; }
.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; }
.thumb { width: 22px; height: 22px; object-fit: contain; border-radius: 2px; }
.price-tag { color: #e6a23c; font-weight: bold; }
.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; }
.spinner { font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.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; }
.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; }
.progress-section { margin: 0px 12px 0px 12px; }
.progress-box { padding: 4px 0; }
.progress-container { display: flex; align-items: center; gap: 8px; }
.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-text { font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; 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; }
: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;}
.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 {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: 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 tbody tr:hover {background: #f9f9f9;}
.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;}
.thumb {width: 22px; height: 22px; object-fit: contain; border-radius: 2px;}
.price-tag {color: #e6a23c; font-weight: bold;}
.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;}
.spinner {font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px;}
@keyframes spin {0% { transform: rotate(0deg);}
100% {transform: rotate(360deg);}
}
.pagination-fixed {position: sticky; bottom: 0; z-index: 2; 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;}
.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;}
.progress-section {margin: 0px 12px 0px 12px;}
.progress-box {padding: 4px 0;}
.progress-container {display: flex; align-items: center; gap: 8px;}
.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-text {font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; 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>

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>
<link rel="icon" href="/icon/icon.png">
<meta name="theme-color" content="#ffffff">
<style>
body { margin: 0; background-color: #f5f5f5; }
</style>
</head>
<body>
<div id="app"></div>

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 }>
onUpdateLog: (callback: (log: string) => void) => void
removeUpdateLogListener: () => void
windowMinimize: () => Promise<void>
windowMaximize: () => Promise<void>
windowClose: () => Promise<void>
windowIsMaximized: () => Promise<boolean>
}
declare global {

View File

@@ -0,0 +1,6 @@
declare module 'element-plus' {
export const ElMessage: (options: { message: string; type?: 'success' | 'warning' | 'error' | 'info' }) => void
export const ElMessageBox: { confirm: (message: string, title?: string, options?: any) => Promise<void> }
}

View File

@@ -0,0 +1,24 @@
import { CONFIG } from '../api/http'
const DEVICE_ID_KEY = 'device_id'
async function fetchDeviceIdFromClient(): Promise<string> {
const response = await fetch(`${CONFIG.CLIENT_BASE}/api/system/device-id`, {
method: 'GET',
credentials: 'omit',
cache: 'no-store'
})
if (!response.ok) throw new Error('获取设备ID失败')
const result = await response.json()
if (!result?.data) throw new Error('设备ID为空')
return result.data
}
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
}

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

@@ -0,0 +1,78 @@
/**
* 通过后端代理获取图片并转换为Base64
* @param imageUrl 原始图片URL
* @param maxSize 最大尺寸默认80px
* @returns Promise<string | null> Base64字符串或null
*/
export async function convertImageToBase64ViaProxy(imageUrl: string, maxSize: number = 80): Promise<string | null> {
if (!imageUrl) return null
try {
const proxyUrl = `http://127.0.0.1:8081/api/system/proxy/image?url=${encodeURIComponent(imageUrl)}`
const response = await fetch(proxyUrl)
if (!response.ok) return null
const contentType = response.headers.get('Content-Type')
const arrayBuffer = await response.arrayBuffer()
if (!arrayBuffer || arrayBuffer.byteLength === 0) return null
if (arrayBuffer.byteLength < 1000) return null
const mimeType = contentType && contentType.startsWith('image/') ? contentType : 'image/jpeg'
const imageBlob = new Blob([arrayBuffer], { type: mimeType })
const objectUrl = URL.createObjectURL(imageBlob)
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
try {
const canvas = document.createElement('canvas')
const ratio = Math.min(maxSize / img.width, maxSize / img.height)
canvas.width = img.width * ratio
canvas.height = img.height * ratio
const ctx = canvas.getContext('2d')
if (!ctx) {
URL.revokeObjectURL(objectUrl)
resolve(null)
return
}
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
const base64 = canvas.toDataURL('image/jpeg', 0.8)
URL.revokeObjectURL(objectUrl)
resolve(base64)
} catch (error) {
URL.revokeObjectURL(objectUrl)
resolve(null)
}
}
img.onerror = () => {
URL.revokeObjectURL(objectUrl)
resolve(null)
}
img.src = objectUrl
})
} catch (error) {
return null
}
}
/**
* 批量处理图片转换
* @param imageUrls 图片URL数组
* @param maxSize 最大尺寸
* @returns Promise<(string | null)[]> Base64数组
*/
export async function batchConvertImages(imageUrls: string[], maxSize: number = 80): Promise<(string | null)[]> {
const promises = imageUrls.map(async (url) => {
if (!url) return null
return await convertImageToBase64ViaProxy(url, maxSize)
})
return await Promise.all(promises)
}

View File

@@ -0,0 +1,152 @@
// 应用设置管理工具
export type Platform = 'amazon' | 'rakuten' | 'zebra'
export interface PlatformExportSettings {
exportPath: string
}
export interface AppSettings {
// 平台特定设置
platforms: {
amazon: PlatformExportSettings
rakuten: PlatformExportSettings
zebra: PlatformExportSettings
}
// 更新设置
autoUpdate?: boolean
// 关闭行为
closeAction?: 'quit' | 'minimize' | 'tray'
// 启动配置
autoLaunch?: boolean
launchMinimized?: boolean
}
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 = {
exportPath: ''
}
// 默认设置
const defaultSettings: AppSettings = {
platforms: {
amazon: { ...defaultPlatformSettings },
rakuten: { ...defaultPlatformSettings },
zebra: { ...defaultPlatformSettings }
},
autoUpdate: false,
closeAction: 'quit',
autoLaunch: false,
launchMinimized: false
}
// 获取设置(按用户隔离)
export function getSettings(username?: string): AppSettings {
const settingsKey = getSettingsKey(username)
const saved = localStorage.getItem(settingsKey)
if (saved) {
const settings = JSON.parse(saved)
return {
platforms: {
amazon: { ...defaultSettings.platforms.amazon, ...settings.platforms?.amazon },
rakuten: { ...defaultSettings.platforms.rakuten, ...settings.platforms?.rakuten },
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
}
// 保存设置(按用户隔离)
export function saveSettings(settings: Partial<AppSettings>, username?: string): void {
const current = getSettings(username)
const updated = {
platforms: {
amazon: { ...current.platforms.amazon, ...settings.platforms?.amazon },
rakuten: { ...current.platforms.rakuten, ...settings.platforms?.rakuten },
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
}
const settingsKey = getSettingsKey(username)
localStorage.setItem(settingsKey, JSON.stringify(updated))
}
// 保存平台特定设置(按用户隔离)
export function savePlatformSettings(platform: Platform, settings: Partial<PlatformExportSettings>, username?: string): void {
const current = getSettings(username)
const updated = {
...current,
platforms: {
...current.platforms,
[platform]: { ...current.platforms[platform], ...settings }
}
}
const settingsKey = getSettingsKey(username)
localStorage.setItem(settingsKey, JSON.stringify(updated))
}
// 获取平台导出配置(按用户隔离)
export function getPlatformExportConfig(platform: Platform, username?: string): PlatformExportSettings {
const settings = getSettings(username)
return settings.platforms[platform]
}
// 处理平台特定文件导出(按用户隔离)
export async function handlePlatformFileExport(
platform: Platform,
blob: Blob,
defaultFileName: string,
username?: string
): Promise<boolean> {
const config = getPlatformExportConfig(platform, username)
if (!config.exportPath) {
const result = await (window as any).electronAPI.showSaveDialog({
title: '保存文件',
defaultPath: defaultFileName,
filters: [
{ name: 'Excel 文件', extensions: ['xlsx', 'xls'] },
{ name: '所有文件', extensions: ['*'] }
]
})
if (!result.canceled && result.filePath) {
await writeFileToPath(blob, result.filePath)
return true
}
return false
} else {
const filePath = `${config.exportPath}/${defaultFileName}`
await writeFileToPath(blob, filePath)
return true
}
}
// 写入文件到指定路径
async function writeFileToPath(blob: Blob, filePath: string): Promise<void> {
const arrayBuffer = await blob.arrayBuffer()
const buffer = new Uint8Array(arrayBuffer)
const result = await (window as any).electronAPI.writeFile(filePath, buffer)
if (!result.success) throw new Error(result.error)
}

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

@@ -0,0 +1,84 @@
@echo off
setlocal enabledelayedexpansion
set APP_ASAR=%~1
set UPDATE_FILE=%~2
set JAR_UPDATE=%~3
set EXE_PATH=%~4
set UPDATE_DIR=%~5
if not exist "%UPDATE_FILE%" if "%JAR_UPDATE%"=="" exit /b 1
if not exist "%UPDATE_FILE%" if not exist "%JAR_UPDATE%" exit /b 1
REM Wait for application to close
for /f "tokens=*" %%a in ("%EXE_PATH%") do set EXE_NAME=%%~nxa
set COUNT=0
:wait_loop
tasklist /FI "IMAGENAME eq %EXE_NAME%" 2>nul | find /I "%EXE_NAME%" >nul
if errorlevel 1 goto process_closed
set /a COUNT+=1
if %COUNT% GEQ 20 goto process_closed
timeout /t 1 /nobreak >nul
goto wait_loop
:process_closed
timeout /t 1 /nobreak >nul
REM Update ASAR
if exist "%UPDATE_FILE%" (
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
)
REM Update JAR
: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 (
set /a RETRY_COUNT+=1
if !RETRY_COUNT! LEQ 5 (
timeout /t 2 /nobreak >nul
goto :retry_delete
)
)
)
REM Install new JAR file
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 (
set /a INSTALL_RETRY+=1
if %INSTALL_RETRY% LEQ 5 (
timeout /t 2 /nobreak >nul
goto :retry_install
)
goto :start_app
)
if exist "%JAR_UPDATE%" del /f /q "%JAR_UPDATE%" >nul 2>&1
:start_app
REM Clean up update directory
if exist "%UPDATE_DIR%" (
for %%F in ("%UPDATE_DIR%\*") do del /f /q "%%F" >nul 2>&1
)
start "" "%EXE_PATH%"
exit /b 0

View File

@@ -1,14 +1,12 @@
const Path = require('path');
const vuePlugin = require('@vitejs/plugin-vue')
const { defineConfig } = require('vite');
/**
* https://vitejs.dev/config
*/
const config = defineConfig({
root: Path.join(__dirname, 'src', 'renderer'),
publicDir: Path.join(__dirname, 'src', 'renderer', 'public'), // 使用renderer下的public目录
publicDir: Path.join(__dirname, 'public'),
server: {
port: 8083,
},

View File

@@ -10,7 +10,7 @@
</parent>
<groupId>com.tashow.erp</groupId>
<artifactId>erp_client_sb</artifactId>
<version>2.4.8</version>
<version>2.6.3</version>
<name>erp_client_sb</name>
<description>erp客户端</description>
<properties>
@@ -37,7 +37,6 @@
<artifactId>poi-ooxml</artifactId>
<version>4.1.2</version>
</dependency>
<!-- 已移除 JavaFX/FxWeaver 相关依赖,保留为纯 Spring Boot -->
<dependency>
<groupId>com.qiniu</groupId>
<artifactId>qiniu-java-sdk</artifactId>
@@ -55,9 +54,7 @@
<artifactId>webmagic-extension</artifactId>
<version>1.0.3</version>
</dependency>
<!-- JavaFX 相关依赖已移除 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -67,7 +64,12 @@
<dependency>
<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>
</dependency>
<!-- SQLite数据库支持 -->
@@ -95,12 +97,6 @@
<artifactId>webdrivermanager</artifactId>
<version>5.9.2</version>
</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 -->
<dependency>
@@ -120,6 +116,18 @@
<version>0.11.5</version>
<scope>runtime</scope>
</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>
@@ -167,6 +175,8 @@
</exclude>
</excludes>
<mainClass>com.tashow.erp.ErpClientSbApplication</mainClass>
<executable>false</executable>
<layout>ZIP</layout>
</configuration>
</plugin>
<plugin>

View File

@@ -1,5 +1,4 @@
package com.tashow.erp;
import com.tashow.erp.utils.ErrorReporter;
import com.tashow.erp.utils.ResourcePreloader;
import lombok.extern.slf4j.Slf4j;
@@ -8,9 +7,13 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@Slf4j
@SpringBootApplication
@SpringBootApplication(
exclude = {
org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration.class,
org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration.class
}
)
public class ErpClientSbApplication {
public static void main(String[] args) {
ConfigurableApplicationContext applicationContext = SpringApplication.run(ErpClientSbApplication.class, args);
ErrorReporter errorReporter = applicationContext.getBean(ErrorReporter.class);
@@ -18,10 +21,8 @@ public class ErpClientSbApplication {
log.error("捕获到未处理异常: " + ex.getMessage(), ex);
errorReporter.reportSystemError("未捕获异常: " + thread.getName(), (Exception) ex);
});
log.info("Started Success");
ResourcePreloader.init();
ResourcePreloader.preloadErpDashboard();
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;
import java.util.Locale;
/**
* 通用常量信息
*
* @author ruoyi
* 通用常量(保留兼容性)
* 新代码请使用具体业务常量类AmazonConstants、RakutenConstants、HttpConstants等
*/
public class Constants
{
/**
* UTF-8 字符集
*/
public static final String UTF8 = "UTF-8";
@Deprecated
public class Constants {
public static final String HTTP = HttpConstants.HTTP;
public static final String HTTPS = HttpConstants.HTTPS;
public static final Locale DEFAULT_LOCALE = Locale.SIMPLIFIED_CHINESE;
/**
* 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" };
private Constants() {}
}

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

View File

@@ -1,193 +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 IAuthService authService;
@Autowired
private AuthTokenRepository authTokenRepository;
@Autowired
private CacheDataRepository cacheDataRepository;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody Map<String, Object> loginData) {
String username = (String) loginData.get("username");
String password = (String) loginData.get("password");
Map<String, Object> result = authService.login(username, password);
Object success = result.get("success");
Object tokenObj = result.get("token");
if (Boolean.TRUE.equals(success) && tokenObj instanceof String token && token != null && !token.isEmpty()) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.SET_COOKIE, buildHttpOnlyCookie("FX_TOKEN", token, 2 * 24 * 60 * 60));
return ResponseEntity.ok().headers(headers).body(result);
}
return ResponseEntity.ok(result);
}
@PostMapping("/verify")
public ResponseEntity<?> verifyToken(@RequestBody Map<String, Object> data) {
String token = (String) data.get("token");
if (token == null) {
return ResponseEntity.ok(Map.of("code", 400, "message", "token不能为空"));
}
Map<String, Object> result = authService.verifyToken(token);
return ResponseEntity.ok(result);
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody Map<String, Object> registerData) {
String username = (String) registerData.get("username");
String password = (String) registerData.get("password");
if (username == null || password == null) {
return ResponseEntity.ok(Map.of("code", 400, "message", "用户名和密码不能为空"));
}
Map<String, Object> result = authService.register(username, password);
Object success2 = result.get("success");
Object tokenObj2 = result.get("token");
if (Boolean.TRUE.equals(success2) && tokenObj2 instanceof String token && token != null && !token.isEmpty()) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.SET_COOKIE, buildHttpOnlyCookie("FX_TOKEN", token, 2 * 24 * 60 * 60));
return ResponseEntity.ok().headers(headers).body(result);
}
return ResponseEntity.ok(result);
}
@PostMapping("/logout")
public ResponseEntity<?> logout(@RequestBody Map<String, Object> data) {
authService.logout();
return ResponseEntity.ok(Map.of("code", 0, "message", "退出成功"));
}
@GetMapping("/check-username")
public ResponseEntity<?> checkUsername(@RequestParam String username) {
if (username == null || username.trim().isEmpty()) {
return ResponseEntity.ok(Map.of("code", 400, "message", "用户名不能为空"));
}
boolean available = authService.checkUsername(username);
return ResponseEntity.ok(Map.of(
"code", 200,
"message", "检查成功",
"data", available
));
}
/**
* 保存认证密钥
*/
@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("缓存数据删除成功");
}
/**
* 删除缓存数据 - POST方式
*/
@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("会话已恢复");
}
private String buildHttpOnlyCookie(String name, String value, int maxAgeSeconds) {
StringBuilder sb = new StringBuilder();
sb.append(name).append("=").append(value).append(";");
sb.append(" Path=/;");
sb.append(" HttpOnly;");
sb.append(" SameSite=Strict;");
if (maxAgeSeconds > 0) {
sb.append(" Max-Age=").append(maxAgeSeconds).append(";");
}
return sb.toString();
}
}

View File

@@ -1,61 +1,90 @@
package com.tashow.erp.controller;
import com.tashow.erp.fx.controller.JavaBridge;
import com.tashow.erp.repository.BanmaOrderRepository;
import com.tashow.erp.service.IBanmaOrderService;
import com.tashow.erp.utils.ExcelExportUtil;
import com.tashow.erp.service.BanmaOrderService;
import com.tashow.erp.utils.JsonData;
import com.tashow.erp.utils.JwtUtil;
import com.tashow.erp.utils.LoggerUtil;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 斑马订单控制器
* 提供斑马订单查询、店铺列表等功能
*
* @author 占子杰牛逼
*/
@RestController
@RequestMapping("/api/banma")
public class BanmaOrderController {
private static final Logger logger = LoggerUtil.getLogger(BanmaOrderController.class);
@Autowired
IBanmaOrderService banmaOrderService;
BanmaOrderService banmaOrderService;
@Autowired
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")
public ResponseEntity<Map<String, Object>> getOrders(
public JsonData getOrders(
@RequestParam(required = false, name = "accountId") Long accountId,
@RequestParam(required = false, name = "startDate") String startDate,
@RequestParam(required = false, name = "endDate") String endDate,
@RequestParam(defaultValue = "1", name = "page") int page,
@RequestParam(defaultValue = "10", name = "pageSize") int pageSize,
@RequestParam( "batchId") String batchId,
@RequestParam(required = false, name = "shopIds") String shopIds) {
@RequestParam("batchId") String batchId,
@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;
Map<String, Object> result = banmaOrderService.getOrdersByPage(accountId, startDate, endDate, page, pageSize, batchId, shopIdList);
return ResponseEntity.ok(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());
}
Map<String, Object> result = banmaOrderService.getOrdersByPage(accountId, startDate, endDate, page, pageSize, userSessionId, shopIdList);
return result.containsKey("success") && !(Boolean)result.get("success")
? JsonData.buildError((String)result.get("message"))
: JsonData.buildSuccess(result);
}
/**
* 获取最新订单数据
* 获取店铺列表
*
* @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")
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();
List<Map<String, Object>> orders = banmaOrderRepository.findLatestOrders()
List<Map<String, Object>> orders = banmaOrderRepository.findLatestOrders(username)
.parallelStream()
.map(entity -> {
try {
@@ -68,8 +97,6 @@ public class BanmaOrderController {
})
.filter(order -> !order.isEmpty())
.toList();
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"
);
}
}

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