Compare commits

...

31 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
185 changed files with 21900 additions and 4502 deletions

270
CLAUDE.md Normal file
View File

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

View File

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

View File

@@ -24,7 +24,7 @@
},
"win": {
"target": "dir",
"icon": "public/icon/icon.png"
"icon": "public/icon/icon1.png"
},
"files": [
"package.json",
@@ -36,12 +36,15 @@
{
"from": "build/renderer",
"to": "renderer",
"filter": ["**/*"]
},
{
"from": "src/main/static",
"to": "static",
"filter": ["**/*"]
"filter": [
"**/*",
"!icon/**/*",
"!image/**/*",
"!jre/**/*",
"!config/**/*",
"!*.jar",
"!splash.html"
]
},
{
"from": "public",

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,8 @@
{
"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 --dir",
@@ -17,6 +17,7 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.4.1",
"binary-extensions": "^3.1.0",
"chalk": "^4.1.2",
"chokidar": "^3.5.3",
"electron": "^32.1.2",
@@ -32,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

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 固定日志路径到系统公共数据目录 -->
<property name="LOG_HOME" value="C:/ProgramData/erp-logs" />
<!-- 使用 Spring Boot 传递的日志路径 -->
<property name="LOG_HOME" value="${LOG_PATH:-logs}" />
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,6 @@ const electronAPI = {
// 添加日志相关 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),
@@ -34,6 +33,24 @@ const electronAPI = {
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))

View File

@@ -7,11 +7,9 @@ let tray: Tray | null = null
function getIconPath(): string {
const isDev = process.env.NODE_ENV === 'development'
if (isDev) {
return join(__dirname, '../../public/icon/icon.png')
return join(__dirname, '../../public/icon/icon1.png')
}
const bundledPath = join(process.resourcesPath, 'app.asar.unpacked', 'public/icon/icon.png')
if (existsSync(bundledPath)) return bundledPath
return join(__dirname, '../renderer/icon/icon.png')
return join(process.resourcesPath, 'app.asar.unpacked', 'public/icon/icon1.png')
}
export function createTray(mainWindow: BrowserWindow | null) {
@@ -34,10 +32,8 @@ export function createTray(mainWindow: BrowserWindow | null) {
}
}
})
// 右键菜单
updateTrayMenu(mainWindow)
return tray
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,18 +14,6 @@ export interface DeviceQuota {
used: number
}
/**
* 获取本机内网IP地址
*/
async function getLocalIP(): Promise<string> {
try {
const res = await http.get<{ data: string }>('/api/system/local-ip')
return res.data
} catch {
return '127.0.0.1'
}
}
export const deviceApi = {
getQuota(username: string) {
return http.get<{ data: DeviceQuota }>('/monitor/device/quota', { username })
@@ -36,11 +24,18 @@ export const deviceApi = {
},
async register(payload: { username: string; deviceId: string; os?: string }) {
const ip = await getLocalIP()
return http.post('/monitor/device/register', { ...payload, ip })
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 }) {
remove(payload: { deviceId: string; username: string }) {
return http.post('/monitor/device/remove', payload)
},

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,17 +1,10 @@
export type HttpMethod = 'GET' | 'POST' | 'DELETE';
import { AppConfig, isRuoyiPath } from '../config'
export const CONFIG = {
CLIENT_BASE: 'http://localhost:8081',
RUOYI_BASE: 'http://8.138.23.49:8085',
//RUOYI_BASE: 'http://192.168.1.89:8085',
SSE_URL: 'http://8.138.23.49:8085/monitor/account/events'
} as const;
export type HttpMethod = 'GET' | 'POST' | 'DELETE'
export const CONFIG = AppConfig
function resolveBase(path: string): string {
if (path.startsWith('/monitor/') || path.startsWith('/system/') || path.startsWith('/tool/banma')) {
return CONFIG.RUOYI_BASE;
}
return CONFIG.CLIENT_BASE;
return isRuoyiPath(path) ? CONFIG.RUOYI_BASE : CONFIG.CLIENT_BASE
}
function buildQuery(params?: Record<string, unknown>): string {
@@ -32,29 +25,51 @@ async function getToken(): Promise<string> {
}
}
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 res = await fetch(`${resolveBase(path)}${path}`, {
credentials: 'omit',
cache: 'no-store',
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...options.headers
}
});
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 || `HTTP ${res.status}`);
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) {
throw new Error(json.msg || '请求失败');
const error: any = new Error(json.msg || '请求失败');
error.code = json.code;
throw error;
}
return json as T;
}
@@ -80,25 +95,40 @@ export const http = {
async upload<T>(path: string, form: FormData, signal?: AbortSignal) {
const token = await getToken();
const res = await fetch(`${resolveBase(path)}${path}`, {
method: 'POST',
body: form,
credentials: 'omit',
cache: 'no-store',
headers: token ? { 'Authorization': `Bearer ${token}` } : {},
signal
});
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('无法连接服务器,请检查网络后重试');
}
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
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) {
throw new Error(json.msg || '请求失败');
const error: any = new Error(json.msg || '请求失败');
error.code = json.code;
throw error;
}
return json 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

@@ -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

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

View File

@@ -5,6 +5,10 @@ export const zebraApi = {
return http.get('/tool/banma/accounts', name ? { name } : undefined)
},
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)

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

@@ -4,6 +4,7 @@ import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import { authApi } from '../../api/auth'
import { getOrCreateDeviceId } from '../../utils/deviceId'
import { splashApi } from '../../api/splash'
interface Props {
modelValue: boolean
@@ -13,6 +14,7 @@ interface Emits {
(e: 'update:modelValue', value: boolean): 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>()
@@ -33,13 +35,17 @@ async function handleAuth() {
try {
// 获取或生成设备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: loginRes.data.accessToken || loginRes.data.token,
permissions: loginRes.data.permissions,
@@ -49,8 +55,14 @@ async function handleAuth() {
})
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
}
@@ -68,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>
@@ -103,6 +140,7 @@ function showRegister() {
size="large"
style="margin-bottom: 20px;"
:disabled="authLoading"
show-password
@keyup.enter="handleAuth">
</el-input>
@@ -128,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

@@ -35,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
@@ -123,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>
@@ -145,7 +150,8 @@ function backToLogin() {
type="password"
size="large"
style="margin-bottom: 15px;"
:disabled="registerLoading">
:disabled="registerLoading"
show-password>
</el-input>
<el-input
@@ -154,7 +160,8 @@ function backToLogin() {
type="password"
size="large"
style="margin-bottom: 20px;"
:disabled="registerLoading">
:disabled="registerLoading"
show-password>
</el-input>
<div>
@@ -178,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,28 +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 api = curPlatform.value === 'genmai' ? genmaiApi : zebraApi
const username = getUsernameFromToken()
const res = await zebraApi.getAccounts(username)
const list = (res as any)?.data ?? res
accounts.value = Array.isArray(list) ? list : []
const [res, limitRes] = await Promise.all([api.getAccounts(username), api.getAccountLimit(username)])
accounts.value = (res as any)?.data ?? res
accountLimit.value = (limitRes as any)?.data ?? limitRes
}
// 暴露方法供父组件调用
@@ -44,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">
@@ -65,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">
@@ -74,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">在线账号管理</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>
@@ -85,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>
@@ -93,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

@@ -38,7 +38,7 @@ function handleConfirm() {
}
function copyWechat() {
navigator.clipboard.writeText('_linhong').then(() => {
navigator.clipboard.writeText('butaihaoba001').then(() => {
ElMessage.success('微信号已复制')
}).catch(() => {
ElMessage.error('复制失败,请手动复制')
@@ -75,7 +75,7 @@ function copyWechat() {
</div>
<div class="wechat-info">
<div class="wechat-label">客服微信</div>
<div class="wechat-id">_linhong</div>
<div class="wechat-id">butaihaoba001</div>
</div>
<div class="copy-icon">📋</div>
</div>
@@ -93,116 +93,23 @@ function copyWechat() {
</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;
}
.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,16 +1,12 @@
<template>
<div>
<div class="version-info" @click="handleVersionClick">
v{{ version || '-' }}
<span v-if="hasNewVersion" class="update-badge"></span>
</div>
<el-dialog v-model="show" width="522px" :close-on-click-modal="false" align-center
:class="['update-dialog', `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>
@@ -45,7 +41,7 @@
<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">
@@ -68,7 +64,7 @@
<div v-else-if="stage === 'completed'" 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">
@@ -101,16 +97,12 @@ 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 props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
// 暴露方法给父组件调用
defineExpose({
checkForUpdatesNow
})
defineExpose({ checkForUpdatesNow })
const show = computed({
get: () => props.modelValue,
@@ -121,54 +113,40 @@ type Stage = 'check' | 'downloading' | 'completed'
const stage = ref<Stage>('check')
const appName = ref('我了个电商')
const version = ref('')
const hasNewVersion = ref(false) // 控制小红点显示
const prog = ref({percentage: 0, current: '0 MB', total: '0 MB'})
const info = ref({
latestVersion: '2.4.8',
downloadUrl: '',
latestVersion: '',
asarUrl: '',
jarUrl: '',
updateNotes: '',
currentVersion: '',
hasUpdate: false
currentVersion: ''
})
const SKIP_VERSION_KEY = 'skipped_version'
const REMIND_LATER_KEY = 'remind_later_time'
async function autoCheck(silent = false) {
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) {
hasNewVersion.value = false
if (!silent) ElMessage.info('当前已是最新版本')
return
}
if (localStorage.getItem('skipped_version') === result.latestVersion) return
// 发现新版本,更新信息并显示小红点
info.value = {
currentVersion: result.currentVersion,
latestVersion: result.latestVersion,
downloadUrl: result.downloadUrl || '',
asarUrl: result.asarUrl || '',
jarUrl: result.jarUrl || '',
updateNotes: result.updateNotes || '',
hasUpdate: true
}
hasNewVersion.value = true
const remindTime = localStorage.getItem('remind_later_time')
if (remindTime && Date.now() < parseInt(remindTime)) return
const skippedVersion = localStorage.getItem(SKIP_VERSION_KEY)
if (skippedVersion === result.latestVersion) return
const remindLater = localStorage.getItem(REMIND_LATER_KEY)
if (remindLater && Date.now() < parseInt(remindLater)) return
const settings = getSettings()
if (settings.autoUpdate) {
await startAutoDownload()
if (getSettings(getUsernameFromToken()).autoUpdate) {
await downloadUpdate()
return
}
@@ -180,95 +158,40 @@ async function autoCheck(silent = false) {
}
}
function handleVersionClick() {
async function checkForUpdatesNow() {
if (stage.value === 'downloading' || stage.value === 'completed') {
show.value = true
return
}
if (hasNewVersion.value) {
stage.value = 'check'
show.value = true
} else {
checkForUpdatesNow()
}
}
// 立即检查更新(供外部调用)
async function checkForUpdatesNow() {
await autoCheck(false)
await checkUpdate(false)
}
function skipVersion() {
localStorage.setItem(SKIP_VERSION_KEY, info.value.latestVersion)
localStorage.setItem('skipped_version', info.value.latestVersion)
show.value = false
}
function remindLater() {
// 24小时后再提醒
localStorage.setItem(REMIND_LATER_KEY, (Date.now() + 24 * 60 * 60 * 1000).toString())
localStorage.setItem('remind_later_time', (Date.now() + 24 * 60 * 60 * 1000).toString())
show.value = false
}
async function start() {
// 如果已经在下载或已完成,不重复执行
if (stage.value === 'downloading') {
if (stage.value !== 'check') {
show.value = true
return
}
if (stage.value === 'completed') {
show.value = true
return
}
if (!info.value.asarUrl && !info.value.jarUrl) {
ElMessage.error('下载链接不可用')
return
}
stage.value = 'downloading'
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'
}
})
try {
const response = await (window as any).electronAPI.downloadUpdate({
asarUrl: info.value.asarUrl,
jarUrl: info.value.jarUrl
})
if (response.success) {
stage.value = 'completed'
prog.value.percentage = 100
ElMessage.success('下载完成')
show.value = true
} else {
ElMessage.error('下载失败: ' + (response.error || '未知错误'))
stage.value = 'check'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
;(window as any).electronAPI.removeDownloadProgressListener()
}
} catch (error) {
ElMessage.error('下载失败')
stage.value = 'check'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
;(window as any).electronAPI.removeDownloadProgressListener()
}
await downloadUpdate(true)
}
async function startAutoDownload() {
if (!info.value.asarUrl && !info.value.jarUrl) return
async function downloadUpdate(showDialog = false) {
if (!info.value.asarUrl && !info.value.jarUrl) {
if (showDialog) ElMessage.error('下载链接不可用')
return
}
stage.value = 'downloading'
if (showDialog) show.value = true
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
;(window as any).electronAPI.onDownloadProgress((progress: any) => {
@@ -282,54 +205,45 @@ async function startAutoDownload() {
try {
const response = await (window as any).electronAPI.downloadUpdate({
asarUrl: info.value.asarUrl,
jarUrl: info.value.jarUrl
jarUrl: info.value.jarUrl,
latestVersion: info.value.latestVersion
})
if (response.success) {
stage.value = 'completed'
prog.value.percentage = 100
show.value = true
ElMessage.success('更新已下载完成,可以安装了')
ElMessage.success(showDialog ? '下载完成' : '更新已下载完成,可以安装了')
} else {
stage.value = 'check'
if (showDialog) ElMessage.error('下载失败: ' + (response.error || '未知错误'))
;(window as any).electronAPI.removeDownloadProgressListener()
}
} catch (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()
stage.value = 'check'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
hasNewVersion.value = false
show.value = false
ElMessage.info('已取消下载')
} catch (error) {
stage.value = 'check'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
hasNewVersion.value = false
show.value = false
}
;(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('应用即将重启')
@@ -342,25 +256,19 @@ async function installUpdate() {
async function clearDownloadedFiles() {
try {
await ElMessageBox.confirm(
'确定要清除已下载的更新文件吗?清除后需要重新下载。',
'确认清除',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
await ElMessageBox.confirm('确定要清除已下载的更新文件吗?清除后需要重新下载。', '确认清除', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
const response = await (window as any).electronAPI.clearUpdateFiles()
if (response.success) {
ElMessage.success('已清除下载文件')
// 重置状态
stage.value = 'check'
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
hasNewVersion.value = false
show.value = false
ElMessage.success('已清除下载文件')
} else {
ElMessage.error('清除失败: ' + (response.error || '未知错误'))
}
@@ -373,13 +281,13 @@ onMounted(async () => {
version.value = await (window as any).electronAPI.getJarVersion()
const pendingUpdate = await (window as any).electronAPI.checkPendingUpdate()
if (pendingUpdate && pendingUpdate.hasPendingUpdate) {
if (pendingUpdate?.hasPendingUpdate) {
stage.value = 'completed'
prog.value.percentage = 100
return
}
await autoCheck(true)
await checkUpdate(true)
})
onUnmounted(() => {
@@ -390,331 +298,58 @@ onUnmounted(() => {
</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;
transition: all 0.3s ease;
}
.update-badge {
position: absolute;
top: -2px;
right: -2px;
width: 8px;
height: 8px;
background: #f56c6c;
border-radius: 50%;
border: 2px solid #fff;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.8;
}
}
:deep(.update-dialog .el-dialog) {
border-radius: 16px;
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.15);
}
: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 .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-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-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;
}
: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

@@ -4,6 +4,8 @@ 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'))
@@ -26,7 +28,6 @@ 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[]>([])
@@ -58,7 +59,7 @@ const activeStep = computed(() => {
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both' | 'subscribe'>('account')
const checkExpiredType = inject<() => 'device' | 'account' | 'both' | 'subscribe'>('checkExpiredType')
const vipStatus = inject<any>('vipStatus')
// 左侧:上传文件名与地区
const selectedFileName = ref('')
@@ -150,7 +151,7 @@ async function searchProductInternal(product: any) {
if (!product || !product.imgUrl) return false
if (!needsSearch(product)) return true
if (!props.isVip) {
if (checkExpiredType) trialExpiredType.value = checkExpiredType()
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
showTrialExpiredDialog.value = true
return false
}
@@ -223,15 +224,12 @@ 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' })
})
// 点击"获取数据
@@ -241,7 +239,7 @@ async function handleStartSearch() {
// VIP检查
if (!props.isVip) {
if (checkExpiredType) trialExpiredType.value = checkExpiredType()
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
showTrialExpiredDialog.value = true
return
}
@@ -267,7 +265,6 @@ async function handleStartSearch() {
}
allProducts.value = products
pendingFile.value = null
} catch (e: any) {
if (e.name !== 'AbortError') {
statusType.value = 'error'
@@ -358,7 +355,6 @@ function delay(ms: number) {
}
function nextTickSafe() {
// 不额外引入 nextTick使用微任务刷新即可保持体积精简
return Promise.resolve()
}
@@ -367,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')
@@ -424,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
}
}
@@ -440,9 +442,8 @@ async function exportToExcel() {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
const fileName = `乐天商品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
const success = await handlePlatformFileExport('rakuten', blob, fileName)
const username = getUsernameFromToken()
const success = await handlePlatformFileExport('rakuten', blob, fileName, username)
if (success) {
showMessage('Excel文件导出成功', 'success')
}
@@ -458,7 +459,6 @@ onMounted(loadLatest)
</script>
<template>
<div class="rakuten-root">
<div class="main-container">
<div class="body-layout">
<!-- 左侧步骤栏 -->
@@ -480,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>
@@ -489,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>
@@ -499,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 }}
@@ -532,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>
@@ -633,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"
@@ -653,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

@@ -38,7 +38,7 @@ let abortController: AbortController | null = null
// 试用期过期弹框
const showTrialExpiredDialog = ref(false)
const trialExpiredType = ref<'device' | 'account' | 'both' | 'subscribe'>('account')
const checkExpiredType = inject<() => 'device' | 'account' | 'both' | 'subscribe'>('checkExpiredType')
const vipStatus = inject<any>('vipStatus')
function selectAccount(id: number) {
accountId.value = id
loadShops()
@@ -100,7 +100,7 @@ async function fetchData() {
if (refreshVipStatus) await refreshVipStatus()
// VIP检查
if (!props.isVip) {
if (checkExpiredType) trialExpiredType.value = checkExpiredType()
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
showTrialExpiredDialog.value = true
return
}
@@ -114,7 +114,11 @@ 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) {
@@ -258,7 +262,8 @@ async function exportToExcel() {
})
const fileName = `斑马订单数据_${new Date().toISOString().slice(0, 10)}.xlsx`
const success = await handlePlatformFileExport('zebra', blob, fileName)
const username = getUsernameFromToken()
const success = await handlePlatformFileExport('zebra', blob, fileName, username)
if (success) {
showMessage('Excel文件导出成功', 'success')
@@ -289,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 = ''
@@ -367,7 +384,7 @@ 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="accountId === a.id" class="acct-check"></span>
</span>
@@ -391,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>
@@ -407,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>
@@ -419,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>
@@ -510,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"
@@ -548,7 +564,7 @@ async function removeCurrentAccount() {
<!-- 试用期过期弹框 -->
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
<AccountManager ref="accountManagerRef" v-model="managerVisible" platform="zebra" @add="openAddAccount" @refresh="loadAccounts" />
<AccountManager ref="accountManagerRef" v-model="managerVisible" platform="zebra" @refresh="loadAccounts" />
</div>
</template>
@@ -559,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')
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

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

View File

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

View File

@@ -8,19 +8,15 @@ async function fetchDeviceIdFromClient(): Promise<string> {
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

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

View File

@@ -35,3 +35,15 @@ export function getClientIdFromToken(token?: string): string {
}
}
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

@@ -5,6 +5,7 @@ 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
@@ -75,5 +76,9 @@ if errorlevel 1 (
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

@@ -6,7 +6,7 @@ const { defineConfig } = require('vite');
*/
const config = defineConfig({
root: Path.join(__dirname, 'src', 'renderer'),
publicDir: Path.join(__dirname, 'src', '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.7</version>
<version>2.6.3</version>
<name>erp_client_sb</name>
<description>erp客户端</description>
<properties>
@@ -54,9 +54,7 @@
<artifactId>webmagic-extension</artifactId>
<version>1.0.3</version>
</dependency>
<!-- JavaFX 相关依赖已移除 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -64,7 +62,6 @@
<scope>provided</scope>
</dependency>
<!-- 拆分 hutool-all 为按需依赖,减少 JAR 包体积 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-crypto</artifactId>
@@ -119,13 +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>

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

@@ -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

@@ -4,14 +4,22 @@ import com.tashow.erp.repository.AmazonProductRepository;
import com.tashow.erp.service.AmazonScrapingService;
import com.tashow.erp.utils.ExcelParseUtil;
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.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import jakarta.servlet.http.HttpServletRequest;
import java.util.List;
import java.util.Map;
/**
* 亚马逊数据控制器
* 提供亚马逊商品采集、批量获取、Excel导入等功能
*
* @author 占子杰牛逼
*/
@RestController
@RequestMapping("/api/amazon")
public class AmazonController {
@@ -21,22 +29,58 @@ public class AmazonController {
@Autowired
private AmazonProductRepository amazonProductRepository;
/**
* 批量获取亚马逊商品信息
*
* @param request 包含asinList、batchId和region的请求参数
* @param httpRequest HTTP请求对象用于获取用户信息
* @return 商品列表和总数
*/
@PostMapping("/products/batch")
public JsonData batchGetProducts(@RequestBody Map<String, Object> request) {
public JsonData batchGetProducts(@RequestBody Map<String, Object> request, HttpServletRequest httpRequest) {
@SuppressWarnings("unchecked")
List<String> asinList = (List<String>) request.get("asinList");
String batchId = (String) request.get("batchId");
String region = (String) request.getOrDefault("region", "JP");
List<AmazonProductEntity> products = amazonScrapingService.batchGetProductInfo(asinList, batchId, region);
// 从 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();
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列表
*
* @param file Excel文件
* @return ASIN列表和总数
*/
@PostMapping("/import/asin")
public JsonData importAsinFromExcel(@RequestParam("file") MultipartFile file) {
try {

View File

@@ -2,14 +2,22 @@ package com.tashow.erp.controller;
import com.tashow.erp.repository.BanmaOrderRepository;
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.web.bind.annotation.*;
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 {
@@ -19,6 +27,19 @@ public class BanmaOrderController {
@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 JsonData getOrders(
@RequestParam(required = false, name = "accountId") Long accountId,
@@ -27,23 +48,43 @@ public class BanmaOrderController {
@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(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);
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 {

View File

@@ -1,5 +1,6 @@
package com.tashow.erp.controller;
import com.tashow.erp.common.RakutenConstants;
import com.tashow.erp.model.RakutenProduct;
import com.tashow.erp.model.SearchResult;
import com.tashow.erp.service.Alibaba1688Service;
@@ -8,14 +9,22 @@ import com.tashow.erp.service.RakutenScrapingService;
import com.tashow.erp.utils.DataReportUtil;
import com.tashow.erp.utils.ExcelParseUtil;
import com.tashow.erp.utils.JsonData;
import com.tashow.erp.utils.JwtUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.*;
import java.util.stream.Collectors;
import jakarta.servlet.http.HttpServletRequest;
import java.util.*;
/**
* 乐天数据控制器
* 提供乐天商品采集、1688识图搜索等功能
*
* @author 占子杰牛逼
*/
@RestController
@RequestMapping("/api/rakuten")
@Slf4j
@@ -29,9 +38,23 @@ public class RakutenController {
@Autowired
private DataReportUtil dataReportUtil;
/**
* 从Excel获取乐天商品信息
* 支持缓存机制,避免重复采集
*
* @param file 包含店铺名称的Excel文件
* @param batchId 批次ID可选
* @param request HTTP请求对象用于获取用户信息
* @return 商品列表、总数、跳过的店铺等信息
*/
@PostMapping(value = "/products")
public JsonData getProducts(@RequestParam("file") MultipartFile file, @RequestParam(value = "batchId", required = false) String batchId) {
public JsonData getProducts(@RequestParam("file") MultipartFile file, @RequestParam(value = "batchId", required = false) String batchId, HttpServletRequest request) {
try {
// 从 token 中获取 username
String username = JwtUtil.getUsernameFromRequest(request);
// 构建带用户隔离的 sessionId
String userSessionId = JwtUtil.buildUserSessionId(username, batchId);
List<String> shopNames = ExcelParseUtil.parseFirstColumn(file);
if (CollectionUtils.isEmpty(shopNames)) {
return JsonData.buildError("Excel文件中未解析到店铺名");
@@ -39,10 +62,10 @@ public class RakutenController {
List<RakutenProduct> allProducts = new ArrayList<>();
List<String> skippedShops = new ArrayList<>();
for (String currentShopName : shopNames) {
if (rakutenCacheService.hasRecentData(currentShopName)) {
if (rakutenCacheService.hasRecentData(currentShopName, username)) {
// 从缓存获取
List<RakutenProduct> cached = rakutenCacheService.getProductsByShopName(currentShopName).stream().filter(p -> currentShopName.equals(p.getOriginalShopName())).toList();
rakutenCacheService.updateSpecificProductsSessionId(cached, batchId);
List<RakutenProduct> cached = rakutenCacheService.getProductsByShopName(currentShopName, username).stream().filter(p -> currentShopName.equals(p.getOriginalShopName())).toList();
rakutenCacheService.updateSpecificProductsSessionId(cached, userSessionId);
allProducts.addAll(cached);
skippedShops.add(currentShopName);
log.info("使用缓存数据,店铺: {},数量: {}", currentShopName, cached.size());
@@ -57,11 +80,11 @@ public class RakutenController {
}
List<RakutenProduct> newProducts = allProducts.stream().filter(p -> !skippedShops.contains(p.getOriginalShopName())).toList();
if (!newProducts.isEmpty()) {
rakutenCacheService.saveProductsWithSessionId(newProducts, batchId);
rakutenCacheService.saveProductsWithSessionId(newProducts, userSessionId);
}
int cachedCount = allProducts.size() - newProducts.size();
if (cachedCount > 0) {
dataReportUtil.reportDataCollection("RAKUTEN_CACHE", cachedCount, "0");
dataReportUtil.reportDataCollection(RakutenConstants.DATA_TYPE_CACHE, cachedCount, "0");
}
return JsonData.buildSuccess(Map.of(
"products", allProducts,
@@ -76,13 +99,26 @@ public class RakutenController {
}
}
/**
* 1688识图搜索
* 根据图片URL在1688平台进行识图搜索
*
* @param params 包含imageUrl和sessionId的参数
* @param request HTTP请求对象用于获取用户信息
* @return 搜索结果
*/
@PostMapping("/search1688")
public JsonData search1688(@RequestBody Map<String, Object> params) {
public JsonData search1688(@RequestBody Map<String, Object> params, HttpServletRequest request) {
String imageUrl = (String) params.get("imageUrl");
String sessionId = (String) params.get("sessionId");
try {
// 从 token 中获取 username
String username = JwtUtil.getUsernameFromRequest(request);
// 构建带用户隔离的 sessionId
String userSessionId = JwtUtil.buildUserSessionId(username, sessionId);
SearchResult result = alibaba1688Service.get1688Detail(imageUrl);
rakutenScrapingService.update1688DataByImageUrl(result, sessionId, imageUrl);
rakutenScrapingService.update1688DataByImageUrl(result, userSessionId, imageUrl);
return JsonData.buildSuccess(result);
} catch (Exception e) {
log.error("1688识图搜索失败", e);
@@ -90,10 +126,17 @@ public class RakutenController {
}
}
/**
* 获取最新的乐天商品数据
*
* @param request HTTP请求对象用于获取用户信息
* @return 最新商品列表和总数
*/
@GetMapping("/products/latest")
public JsonData getLatestProducts() {
public JsonData getLatestProducts(HttpServletRequest request) {
try {
List<Map<String, Object>> products = rakutenScrapingService.getLatestProductsForDisplay();
String username = JwtUtil.getUsernameFromRequest(request);
List<Map<String, Object>> products = rakutenScrapingService.getLatestProductsForDisplay(username);
return JsonData.buildSuccess(Map.of("products", products, "total", products.size()));
} catch (Exception e) {
log.error("获取最新商品数据失败", e);

View File

@@ -1,11 +1,11 @@
package com.tashow.erp.controller;
import com.tashow.erp.entity.AuthTokenEntity;
import com.tashow.erp.repository.AuthTokenRepository;
import com.tashow.erp.service.CacheService;
import com.tashow.erp.service.impl.GenmaiServiceImpl;
import com.tashow.erp.utils.JsonData;
import com.tashow.erp.utils.LoggerUtil;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -14,6 +14,12 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import java.util.Map;
/**
* 系统管理控制器
* 提供系统配置、认证管理、设备信息等功能
*
* @author 占子杰牛逼
*/
@RestController
@RequestMapping("/api/system")
public class SystemController {
@@ -32,6 +38,13 @@ public class SystemController {
private String buildTime;
@Value("${api.server.base-url}")
private String serverBaseUrl;
/**
* 保存服务认证信息
*
* @param data 包含serviceName和authKey的认证信息
* @return 操作结果
*/
@PostMapping("/auth/save")
public JsonData saveAuth(@RequestBody Map<String, Object> data) {
String serviceName = (String) data.get("serviceName");
@@ -46,6 +59,12 @@ public class SystemController {
return JsonData.buildSuccess("认证信息保存成功");
}
/**
* 获取指定服务的认证信息
*
* @param serviceName 服务名称
* @return 认证token信息
*/
@GetMapping("/auth/get")
public JsonData getAuth(@RequestParam String serviceName) {
return JsonData.buildSuccess(authTokenRepository.findByServiceName(serviceName)
@@ -53,41 +72,95 @@ public class SystemController {
.orElse(null));
}
/**
* 删除指定服务的认证信息
*
* @param serviceName 服务名称
* @return 操作结果
*/
@DeleteMapping("/auth/remove")
public JsonData removeAuth(@RequestParam String serviceName) {
authTokenRepository.findByServiceName(serviceName).ifPresent(authTokenRepository::delete);
return JsonData.buildSuccess("认证信息删除成功");
}
/**
* 获取设备唯一标识
*
* @return 设备ID
*/
@GetMapping("/device-id")
public JsonData getDeviceId() {
return JsonData.buildSuccess(com.tashow.erp.utils.DeviceUtils.generateDeviceId());
}
/**
* 获取本机IP地址
*
* @return 本机IP地址
* @throws Exception 获取IP失败时抛出异常
*/
@GetMapping("/local-ip")
public JsonData getLocalIp() {
try {
return JsonData.buildSuccess(java.net.InetAddress.getLocalHost().getHostAddress());
} catch (Exception e) {
return JsonData.buildSuccess("127.0.0.1");
}
public JsonData getLocalIp() throws Exception {
return JsonData.buildSuccess(java.net.InetAddress.getLocalHost().getHostAddress());
}
/**
* 获取计算机名称
*
* @return 计算机名称
*/
@GetMapping("/computer-name")
public JsonData getComputerName() {
return JsonData.buildSuccess(System.getenv("COMPUTERNAME"));
}
/**
* 获取系统版本信息
*
* @return 包含当前版本号和构建时间的信息
*/
@GetMapping("/version")
public Map<String, Object> getVersion() {
return Map.of("success", true, "currentVersion", currentVersion, "buildTime", buildTime);
}
/**
* 获取服务器配置信息
*
* @return 包含服务器基础URL和SSE URL的配置信息
*/
@GetMapping("/config/server")
public Map<String, Object> getServerConfig() {
return Map.of("baseUrl", serverBaseUrl, "sseUrl", serverBaseUrl + "/monitor/account/events");
}
/**
* 打开跟卖精灵网站
*
* @param accountId 账号ID
* @param request HTTP请求对象用于获取用户信息
* @return 操作结果
*/
@PostMapping("/genmai/open")
public void openGenmaiWebsite() {
genmaiService.openGenmaiWebsite();
public JsonData openGenmaiWebsite(@RequestParam(required = false) Long accountId, HttpServletRequest request) {
try {
String username = com.tashow.erp.utils.JwtUtil.getUsernameFromRequest(request);
genmaiService.openGenmaiWebsite(accountId, username);
return JsonData.buildSuccess("跟卖精灵已打开");
} catch (Exception e) {
logger.error("打开跟卖精灵失败", e);
return JsonData.buildError(e.getMessage() != null ? e.getMessage() : "打开跟卖精灵失败");
}
}
/**
* 图片代理接口
* 用于代理获取外部图片资源,解决跨域问题
*
* @param imageUrl 图片URL
* @return 图片字节数组响应
*/
@GetMapping("/proxy/image")
public ResponseEntity<byte[]> proxyImage(@RequestParam("url") String imageUrl) {
if (imageUrl == null || imageUrl.isEmpty()) {
@@ -117,7 +190,6 @@ public class SystemController {
responseHeaders.setContentType(MediaType.IMAGE_JPEG);
}
responseHeaders.setCacheControl("max-age=3600");
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
} catch (Exception e) {
logger.error("代理图片失败: {}", imageUrl, e);
@@ -125,6 +197,11 @@ public class SystemController {
}
}
/**
* 清理系统缓存
*
* @return 操作结果
*/
@PostMapping("/cache/clear")
public JsonData clearCache() {
cacheService.clearCache();

View File

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

View File

@@ -0,0 +1,35 @@
package com.tashow.erp.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Entity
@Table(name = "brand_trademark_cache",
uniqueConstraints = @UniqueConstraint(columnNames = {"brand"}))
@Data
public class BrandTrademarkCacheEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String brand;
@Column(nullable = false)
private Boolean registered;
@Column
private String username;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
username = "global"; // 全局缓存
}
}

View File

@@ -0,0 +1,48 @@
package com.tashow.erp.entity;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
@Entity
@Table(name = "trademark_sessions")
@Data
public class TrademarkSessionEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "session_id", unique = true, nullable = false)
private String sessionId;
@Column(nullable = false)
private String username;
@Column(name = "file_name")
private String fileName;
@Column(name = "result_data", columnDefinition = "TEXT")
private String resultData;
@Column(name = "full_data", columnDefinition = "TEXT")
private String fullData;
@Column(columnDefinition = "TEXT")
private String headers;
@Column(name = "task_progress", columnDefinition = "TEXT")
private String taskProgress;
@Column(name = "query_status")
private String queryStatus;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
}
}

View File

@@ -1,79 +0,0 @@
package com.tashow.erp.fx.controller;
import lombok.extern.slf4j.Slf4j;
import java.awt.*;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.StringSelection;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
@Slf4j
public class JavaBridge {
/**
* 直接保存字节数组为Excel文件到桌面纯 Spring Boot 环境,无文件对话框)
*/
public String saveExcelFileToDesktop(byte[] data, String fileName) {
try {
if (data == null || data.length == 0) {
log.warn("文件数据为空,无法保存文件");
return null;
}
String userHome = System.getProperty("user.home");
File desktop = new File(userHome, "Desktop");
if (!desktop.exists()) {
// 回退到用户目录
desktop = new File(userHome);
}
File file = new File(desktop, fileName);
int counter = 1;
if (fileName != null && fileName.contains(".")) {
String baseName = fileName.substring(0, fileName.lastIndexOf('.'));
String extension = fileName.substring(fileName.lastIndexOf('.'));
while (file.exists()) {
file = new File(desktop, baseName + "_" + counter + extension);
counter++;
}
} else {
while (file.exists()) {
file = new File(desktop, fileName + "_" + counter);
counter++;
}
}
try (FileOutputStream fos = new FileOutputStream(file)) {
fos.write(data);
fos.flush();
}
String filePath = file.getAbsolutePath();
log.info("Excel文件已保存: {}", filePath);
return filePath;
} catch (IOException e) {
log.error("保存Excel文件失败: {}", e.getMessage(), e);
return null;
}
}
/**
* 复制文本到系统剪贴板
*/
public boolean copyToClipboard(String text) {
try {
if (text == null || text.trim().isEmpty()) {
return false;
}
Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
StringSelection selection = new StringSelection(text);
clipboard.setContents(selection, null);
return true;
} catch (Exception e) {
log.error("复制到剪贴板失败: {}", e.getMessage());
return false;
}
}
}

View File

@@ -62,6 +62,12 @@ public interface AmazonProductRepository extends JpaRepository<AmazonProductEnti
*/
@Query(value = "SELECT * FROM amazon_products WHERE session_id = (SELECT session_id FROM amazon_products GROUP BY session_id ORDER BY session_id DESC LIMIT 1) ORDER BY updated_at ", nativeQuery = true)
List<AmazonProductEntity> findLatestProducts();
/**
* 获取指定用户最新会话的产品数据(按用户和地区隔离)
*/
@Query(value = "SELECT * FROM amazon_products WHERE session_id = (SELECT session_id FROM amazon_products WHERE session_id LIKE :username || '#%' AND region = :region GROUP BY session_id ORDER BY session_id DESC LIMIT 1) ORDER BY updated_at ", nativeQuery = true)
List<AmazonProductEntity> findLatestProducts(@Param("username") String username, @Param("region") String region);
/**
* 删除指定ASIN在指定时间后的数据用于清理12小时内重复

View File

@@ -66,6 +66,12 @@ public interface BanmaOrderRepository extends JpaRepository<BanmaOrderEntity, Lo
*/
@Query(value = "SELECT * FROM banma_orders WHERE session_id = (SELECT session_id FROM banma_orders ORDER BY created_at DESC LIMIT 1) ORDER BY updated_at ASC, id ASC", nativeQuery = true)
List<BanmaOrderEntity> findLatestOrders();
/**
* 获取指定用户最新会话的订单数据(按用户隔离)
*/
@Query(value = "SELECT * FROM banma_orders WHERE session_id = (SELECT session_id FROM banma_orders WHERE session_id LIKE :username || '#%' ORDER BY created_at DESC LIMIT 1) ORDER BY updated_at ASC, id ASC", nativeQuery = true)
List<BanmaOrderEntity> findLatestOrders(@Param("username") String username);
/**
* 删除指定追踪号在指定时间后的数据用于清理12小时内重复

View File

@@ -0,0 +1,29 @@
package com.tashow.erp.repository;
import com.tashow.erp.entity.BrandTrademarkCacheEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
@Repository
public interface BrandTrademarkCacheRepository extends JpaRepository<BrandTrademarkCacheEntity, Long> {
boolean existsByBrand(String brand);
Optional<BrandTrademarkCacheEntity> findByBrandAndCreatedAtAfter(
String brand, LocalDateTime cutoffTime);
List<BrandTrademarkCacheEntity> findByBrandInAndCreatedAtAfter(
List<String> brands, LocalDateTime cutoffTime);
@Modifying
@Transactional
@Query("DELETE FROM BrandTrademarkCacheEntity WHERE createdAt < ?1")
void deleteByCreatedAtBefore(LocalDateTime cutoffTime);
}

View File

@@ -52,6 +52,16 @@ public interface RakutenProductRepository extends JpaRepository<RakutenProductEn
* 检查指定店铺在指定时间后是否有数据
*/
boolean existsByOriginalShopNameAndCreatedAtAfter(String originalShopName, LocalDateTime sinceTime);
/**
* 检查指定店铺在指定时间后是否有数据(按用户隔离)
*/
boolean existsByOriginalShopNameAndSessionIdStartingWithAndCreatedAtAfter(String originalShopName, String sessionIdPrefix, LocalDateTime sinceTime);
/**
* 根据店铺名和 sessionId 前缀获取产品(按用户隔离)
*/
List<RakutenProductEntity> findByOriginalShopNameAndSessionIdStartingWithOrderByCreatedAtAscIdAsc(String originalShopName, String sessionIdPrefix);
/**
* 获取最新的会话ID
@@ -64,6 +74,12 @@ public interface RakutenProductRepository extends JpaRepository<RakutenProductEn
*/
@Query(value = "SELECT * FROM rakuten_products WHERE session_id = (SELECT session_id FROM rakuten_products ORDER BY created_at DESC LIMIT 1) ORDER BY created_at ASC, id ASC", nativeQuery = true)
List<RakutenProductEntity> findLatestProducts();
/**
* 获取指定用户最新会话的产品数据(按用户隔离)
*/
@Query(value = "SELECT * FROM rakuten_products WHERE session_id = (SELECT session_id FROM rakuten_products WHERE session_id LIKE :username || '#%' ORDER BY created_at DESC LIMIT 1) ORDER BY created_at ASC, id ASC", nativeQuery = true)
List<RakutenProductEntity> findLatestProducts(@Param("username") String username);
/**
* 删除指定商品URL在指定时间后的数据用于清理12小时内重复

View File

@@ -0,0 +1,22 @@
package com.tashow.erp.repository;
import com.tashow.erp.entity.TrademarkSessionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
@Repository
public interface TrademarkSessionRepository extends JpaRepository<TrademarkSessionEntity, Long> {
Optional<TrademarkSessionEntity> findBySessionIdAndUsername(String sessionId, String username);
@Modifying
@Transactional
@Query("DELETE FROM TrademarkSessionEntity WHERE createdAt < ?1")
void deleteByCreatedAtBefore(LocalDateTime cutoffTime);
}

View File

@@ -0,0 +1,23 @@
package com.tashow.erp.service;
import java.util.List;
import java.util.Map;
public interface BrandTrademarkCacheService {
/**
* 批量获取缓存1天内有效全局共享
*/
Map<String, Boolean> getCached(List<String> brands);
/**
* 批量保存查询结果(全局共享)
*/
void saveResults(Map<String, Boolean> results);
/**
* 清理1天前的过期数据
*/
void cleanExpired();
}

View File

@@ -30,6 +30,10 @@ public class CacheService {
private CacheDataRepository cacheDataRepository;
@Autowired
private UpdateStatusRepository updateStatusRepository;
@Autowired
private BrandTrademarkCacheRepository brandTrademarkCacheRepository;
@Autowired
private TrademarkSessionRepository trademarkSessionRepository;
public void saveAuthToken(String service, String token, long expireTimeMillis) {
try {
@@ -49,10 +53,11 @@ public class CacheService {
rakutenProductRepository.deleteAll();
amazonProductRepository.deleteAll();
alibaba1688ProductRepository.deleteAll();
zebraOrderRepository.deleteAll();
banmaOrderRepository.deleteAll();
zebraOrderRepository.deleteAll();
cacheDataRepository.deleteAll();
updateStatusRepository.deleteAll();
brandTrademarkCacheRepository.deleteAll();
trademarkSessionRepository.deleteAll();
}
}

View File

@@ -0,0 +1,48 @@
package com.tashow.erp.service;
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.web.multipart.MultipartFile;
/**
* 方舟精选 API 服务接口
*/
public interface IFangzhouApiService {
/**
* 从服务器获取 Token
*/
String getToken();
/**
* 刷新 Token
*/
String refreshToken();
/**
* 调用方舟精选 API表单提交自动处理 Token 过期)
* @param command 命令
* @param data 数据
* @param token Token
* @return 响应结果
*/
JsonNode callApi(String command, String data, String token);
/**
* 上传文件到方舟精选(自动处理 Token 过期)
* @param file 文件
* @param token Token
* @return 响应结果
*/
JsonNode uploadFile(MultipartFile file, String token);
/**
* 轮询获取任务(等待下载链接生成)
* @param token Token
* @param maxRetries 最大重试次数
* @param intervalMs 重试间隔(毫秒)
* @return 任务节点
* @throws InterruptedException 中断异常
*/
JsonNode pollTask(String token, int maxRetries, long intervalMs) throws InterruptedException;
}

View File

@@ -18,12 +18,12 @@ public interface RakutenCacheService {
/**
* 检查店铺是否有最近的数据12小时内
*/
boolean hasRecentData(String shopName);
boolean hasRecentData(String shopName, String username);
/**
* 根据店铺名获取已有数据
*/
List<RakutenProduct> getProductsByShopName(String shopName);
List<RakutenProduct> getProductsByShopName(String shopName, String username);
/**
* 更新指定店铺的产品sessionId

View File

@@ -32,9 +32,9 @@ public interface RakutenScrapingService {
void update1688DataByImageUrl(SearchResult searchResult, String sessionId, String imageUrl);
/**
* 获取最新产品数据并转换为前端格式
* 获取最新产品数据并转换为前端格式(按用户隔离)
*/
List<Map<String, Object>> getLatestProductsForDisplay();
List<Map<String, Object>> getLatestProductsForDisplay(String username);
}

View File

@@ -39,6 +39,10 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
private final RestTemplate noSslRestTemplate = createNoSslRestTemplate();
@Autowired
private ErrorReporter errorReporter;
/**
* 创建忽略SSL证书的RestTemplate
*/
private RestTemplate createNoSslRestTemplate() {
try {
TrustManager[] trustManagers = new TrustManager[] {
@@ -92,12 +96,7 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("data", jsonData);
HttpEntity<MultiValueMap<String, String>> requestEntity = new HttpEntity<>(formData, headers);
Iterator<JsonNode> offerIterator = null;
for (int retry = 0; retry < 3 && (offerIterator == null || !offerIterator.hasNext()); retry++) {
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class);
JsonNode root = objectMapper.readTree(response.getBody());
offerIterator = root.path("data").path("offerList").path("offers").elements();
}
Iterator<JsonNode> offerIterator = getOfferIterator(url, requestEntity);
//运费 - 收集所有运费数据
Set<Double> freight = new HashSet<>();
while (offerIterator.hasNext()) {
@@ -111,12 +110,7 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
.filter(fee -> fee > 0)
.ifPresent(freight::add);
}
offerIterator = null;
for (int retry = 0; retry < 3 && (offerIterator == null || !offerIterator.hasNext()); retry++) {
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class);
JsonNode root = objectMapper.readTree(response.getBody());
offerIterator = root.path("data").path("offerList").path("offers").elements();
}
offerIterator = getOfferIterator(url, requestEntity);
for (int i = 0; i < 10 && offerIterator.hasNext(); i++) {
JsonNode offer = offerIterator.next();
String offerId = offer.path("id").asText();
@@ -162,6 +156,15 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
}
}
private Iterator<JsonNode> getOfferIterator(String url, HttpEntity<MultiValueMap<String, String>> requestEntity) throws IOException {
for (int retry = 0; retry < 3; retry++) {
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, requestEntity, String.class);
JsonNode root = objectMapper.readTree(response.getBody());
Iterator<JsonNode> iterator = root.path("data").path("offerList").path("offers").elements();
if (iterator.hasNext()) return iterator;
}
return Collections.emptyIterator();
}
/**
* 获取sku价格
@@ -244,7 +247,6 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
return 0.0;
}
}
/**
* 上传图片并获取图片ID
* @return
@@ -252,7 +254,16 @@ public class Alibaba1688ServiceImpl implements Alibaba1688Service {
public String uploadImageBase64(String imageUrl) {
String token = Alibaba1688CookieUtil.getToken();
long timestamp = System.currentTimeMillis();
byte[] imageBytes = noSslRestTemplate.getForObject(imageUrl, byte[].class);
byte[] imageBytes = null;
for (int i = 0; i < 3; i++) {
try {
imageBytes = noSslRestTemplate.getForObject(imageUrl, byte[].class);
break;
} catch (Exception e) {
if (i == 2) throw e;
try { Thread.sleep(1000); } catch (InterruptedException ignored) {}
}
}
String base64Image = Base64.getEncoder().encodeToString(imageBytes);
String jsonData = "{\"appId\":32517,\"params\":\"{\\\"searchScene\\\":\\\"imageEx\\\",\\\"interfaceName\\\":\\\"imageBase64ToImageId\\\",\\\"serviceParam.extendParam[imageBase64]\\\":\\\"" + base64Image + "\\\",\\\"subChannel\\\":\\\"pc_image_search_image_id\\\"}\"}";
String sign = Alibaba1688CookieUtil.generateSign(token, String.valueOf(timestamp), jsonData);

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