Compare commits
36 Commits
07e34c35c8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 358203b11d | |||
| 02858146b3 | |||
| bff057c99b | |||
| d29d4d69da | |||
| 937a84bb81 | |||
| f9d1848280 | |||
| dd23d9fe90 | |||
| 007799fb2a | |||
| cfb9096788 | |||
| cce281497b | |||
| 92ab782943 | |||
| c2e1617a99 | |||
| 7c7009ffed | |||
| 2f00fde3be | |||
| cfb70d5830 | |||
| 4e2ce48934 | |||
| a62d7b6147 | |||
| c9874f1786 | |||
| 87a4a2fed0 | |||
| d0a930d4f2 | |||
| 6443cdc8d0 | |||
| 1aceceb38f | |||
| 84087ddf80 | |||
| 7e065c1a0b | |||
| 0be60bc103 | |||
| 35c9fc205a | |||
| 3a76aaa3c0 | |||
| e2a438c84e | |||
| 5468dc53fc | |||
| 17b6a7b9f9 | |||
| 901d67d2dc | |||
| 1be22664c4 | |||
| 281ae6a846 | |||
| 17f03c3ade | |||
| 0c85aa5677 | |||
| d9f91b77e3 |
270
CLAUDE.md
Normal 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
|
||||||
@@ -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);
|
|
||||||
```
|
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
|
"electronLanguages": ["zh-CN", "en-US"],
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"perMachine": false,
|
"perMachine": false,
|
||||||
@@ -22,8 +23,8 @@
|
|||||||
"shortcutName": "erpClient"
|
"shortcutName": "erpClient"
|
||||||
},
|
},
|
||||||
"win": {
|
"win": {
|
||||||
"target": "nsis",
|
"target": "dir",
|
||||||
"icon": "public/icon/icon.png"
|
"icon": "public/icon/icon1.png"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"package.json",
|
"package.json",
|
||||||
@@ -35,12 +36,15 @@
|
|||||||
{
|
{
|
||||||
"from": "build/renderer",
|
"from": "build/renderer",
|
||||||
"to": "renderer",
|
"to": "renderer",
|
||||||
"filter": ["**/*"]
|
"filter": [
|
||||||
},
|
"**/*",
|
||||||
{
|
"!icon/**/*",
|
||||||
"from": "src/main/static",
|
"!image/**/*",
|
||||||
"to": "static",
|
"!jre/**/*",
|
||||||
"filter": ["**/*"]
|
"!config/**/*",
|
||||||
|
"!*.jar",
|
||||||
|
"!splash.html"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "public",
|
"from": "public",
|
||||||
@@ -53,23 +57,10 @@
|
|||||||
"config/**/*",
|
"config/**/*",
|
||||||
"!erp_client_sb-*.jar",
|
"!erp_client_sb-*.jar",
|
||||||
"!data/**/*",
|
"!data/**/*",
|
||||||
"!jre/bin/jab*.exe",
|
"!jre/bin/*.exe",
|
||||||
"!jre/bin/jac*.exe",
|
"jre/bin/java.exe",
|
||||||
"!jre/bin/jar*.exe",
|
"jre/bin/javaw.exe",
|
||||||
"!jre/bin/jc*.exe",
|
"jre/bin/keytool.exe",
|
||||||
"!jre/bin/jd*.exe",
|
|
||||||
"!jre/bin/jf*.exe",
|
|
||||||
"!jre/bin/jh*.exe",
|
|
||||||
"!jre/bin/ji*.exe",
|
|
||||||
"!jre/bin/jl*.exe",
|
|
||||||
"!jre/bin/jm*.exe",
|
|
||||||
"!jre/bin/jp*.exe",
|
|
||||||
"!jre/bin/jr*.exe",
|
|
||||||
"!jre/bin/jsh*.exe",
|
|
||||||
"!jre/bin/jst*.exe",
|
|
||||||
"!jre/bin/k*.exe",
|
|
||||||
"!jre/bin/rmi*.exe",
|
|
||||||
"!jre/bin/serial*.exe",
|
|
||||||
"!jre/include/**",
|
"!jre/include/**",
|
||||||
"!jre/lib/src.zip",
|
"!jre/lib/src.zip",
|
||||||
"!jre/lib/ct.sym",
|
"!jre/lib/ct.sym",
|
||||||
|
|||||||
7281
electron-vue-template/package-lock.json
generated
Normal file
@@ -1,14 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "electron-vue-template",
|
"name": "erpClient",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "A minimal Electron + Vue application",
|
"description": "A minimal Electron + Vue application",
|
||||||
"main": "main/main.js",
|
"main": "build/main/main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev-server.js",
|
"dev": "node scripts/dev-server.js",
|
||||||
"build": "node scripts/build.js && electron-builder",
|
"build": "node scripts/build.js && electron-builder --dir",
|
||||||
"build:win": "node scripts/build.js && electron-builder --win",
|
"build:win": "node scripts/build.js && electron-builder --win --dir",
|
||||||
"build:mac": "node scripts/build.js && electron-builder --mac",
|
"build:mac": "node scripts/build.js && electron-builder --mac --dir",
|
||||||
"build:linux": "node scripts/build.js && electron-builder --linux"
|
"build:linux": "node scripts/build.js && electron-builder --linux --dir"
|
||||||
},
|
},
|
||||||
"repository": "https://github.com/deluze/electron-vue-template",
|
"repository": "https://github.com/deluze/electron-vue-template",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.4.1",
|
"@vitejs/plugin-vue": "^4.4.1",
|
||||||
|
"binary-extensions": "^3.1.0",
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^4.1.2",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"electron": "^32.1.2",
|
"electron": "^32.1.2",
|
||||||
@@ -32,5 +33,26 @@
|
|||||||
"element-plus": "^2.11.3",
|
"element-plus": "^2.11.3",
|
||||||
"exceljs": "^4.4.0",
|
"exceljs": "^4.4.0",
|
||||||
"vue": "^3.3.8"
|
"vue": "^3.3.8"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.tashow.erp",
|
||||||
|
"productName": "天骄智能电商",
|
||||||
|
"files": [
|
||||||
|
"build/**/*",
|
||||||
|
"node_modules/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
|
"directories": {
|
||||||
|
"buildResources": "assets",
|
||||||
|
"output": "dist"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "dir",
|
||||||
|
"arch": ["x64"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
4417
electron-vue-template/pnpm-lock.yaml
generated
Normal file
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<configuration>
|
<configuration>
|
||||||
<!-- 固定日志路径到系统公共数据目录 -->
|
<!-- 使用 Spring Boot 传递的日志路径 -->
|
||||||
<property name="LOG_HOME" value="C:/ProgramData/erp-logs" />
|
<property name="LOG_HOME" value="${LOG_PATH:-logs}" />
|
||||||
|
|
||||||
<!-- 控制台输出 -->
|
<!-- 控制台输出 -->
|
||||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
|||||||
BIN
electron-vue-template/public/icon/acquisition.png
Normal file
|
After Width: | Height: | Size: 533 B |
BIN
electron-vue-template/public/icon/amazon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
electron-vue-template/public/icon/anjldk.png
Normal file
|
After Width: | Height: | Size: 638 B |
BIN
electron-vue-template/public/icon/asin.png
Normal file
|
After Width: | Height: | Size: 378 B |
BIN
electron-vue-template/public/icon/done.png
Normal file
|
After Width: | Height: | Size: 731 B |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 4.6 KiB |
BIN
electron-vue-template/public/icon/icon1.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
electron-vue-template/public/icon/inProgress.png
Normal file
|
After Width: | Height: | Size: 968 B |
BIN
electron-vue-template/public/icon/networkErrors.png
Normal file
|
After Width: | Height: | Size: 628 B |
BIN
electron-vue-template/public/icon/plsb.png
Normal file
|
After Width: | Height: | Size: 894 B |
BIN
electron-vue-template/public/icon/rakuten.png
Normal file
|
After Width: | Height: | Size: 804 B |
BIN
electron-vue-template/public/icon/vipExclusive.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
electron-vue-template/public/icon/waiting.png
Normal file
|
After Width: | Height: | Size: 533 B |
BIN
electron-vue-template/public/icon/wlymx.png
Normal file
|
After Width: | Height: | Size: 384 B |
BIN
electron-vue-template/public/icon/zebra.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
electron-vue-template/public/image/excel-format-example.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
electron-vue-template/public/image/img.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
electron-vue-template/public/image/img_1.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
electron-vue-template/public/image/user.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
@@ -1,31 +1,78 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>正在启动...</title>
|
<title>正在启动...</title>
|
||||||
<style>
|
<style>
|
||||||
html, body { height: 100%; margin: 0; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; overflow: hidden; }
|
||||||
body {
|
body {
|
||||||
background: #fff; font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;
|
display: flex;
|
||||||
background-image: url('./image/splash_screen.png');
|
flex-direction: column;
|
||||||
background-repeat: no-repeat;
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
background-position: center;
|
}
|
||||||
background-size: cover;
|
.image {
|
||||||
|
flex: 1;
|
||||||
|
background-image: __SPLASH_IMAGE__;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
.box {
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 30px;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
border-top: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
.text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0,0,0,0.85);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(0,0,0,0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
height: 100%;
|
||||||
|
width: 0%;
|
||||||
|
background: linear-gradient(90deg, #1677ff 0%, #4096ff 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
animation: load 3s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 4px 15px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(0,0,0,0.65);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
color: #1677ff;
|
||||||
|
border-color: #1677ff;
|
||||||
|
background: #f0f7ff;
|
||||||
|
}
|
||||||
|
@keyframes load {
|
||||||
|
to { width: 90%; }
|
||||||
}
|
}
|
||||||
.box { position: fixed; left: 0; right: 0; bottom: 28px; padding: 0 0; }
|
|
||||||
.progress { position: relative; width: 100vw; height: 6px; background: rgba(0,0,0,0.08); }
|
|
||||||
.bar { position: absolute; left: 0; top: 0; height: 100%; width: 20vw; min-width: 120px; background: linear-gradient(90deg, #67C23A, #409EFF); animation: slide 1s ease-in-out infinite alternate; }
|
|
||||||
@keyframes slide { 0% { left: 0; } 100% { left: calc(100vw - 20vw); } }
|
|
||||||
</style>
|
</style>
|
||||||
<link rel="icon" href="icon/icon.png">
|
|
||||||
<link rel="apple-touch-icon" href="icon/icon.png">
|
|
||||||
<meta name="theme-color" content="#ffffff">
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline';">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="image"></div>
|
||||||
<div class="box">
|
<div class="box">
|
||||||
|
<span class="text">正在启动</span>
|
||||||
<div class="progress"><div class="bar"></div></div>
|
<div class="progress"><div class="bar"></div></div>
|
||||||
|
<button class="btn" onclick="require('electron').ipcRenderer.send('quit-app')">退出</button>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,7 +2,27 @@ const Path = require('path');
|
|||||||
const FileSystem = require('fs-extra');
|
const FileSystem = require('fs-extra');
|
||||||
|
|
||||||
async function copyAssets() {
|
async function copyAssets() {
|
||||||
console.log('Static assets are now handled by Vite from src/renderer/public');
|
console.log('Copying static assets from public directory...');
|
||||||
|
|
||||||
|
// 注释:icon 和 image 资源已统一由 public 目录管理
|
||||||
|
// electron-builder 会直接从 public 打包这些资源到 app.asar.unpacked
|
||||||
|
// 不需要复制到 build/renderer,避免重复打包导致体积增大
|
||||||
|
|
||||||
|
// const publicDir = Path.join(__dirname, '..', 'public');
|
||||||
|
// const buildRendererDir = Path.join(__dirname, '..', 'build', 'renderer');
|
||||||
|
|
||||||
|
// await FileSystem.copy(
|
||||||
|
// Path.join(publicDir, 'icon'),
|
||||||
|
// Path.join(buildRendererDir, 'icon'),
|
||||||
|
// { overwrite: true }
|
||||||
|
// );
|
||||||
|
// await FileSystem.copy(
|
||||||
|
// Path.join(publicDir, 'image'),
|
||||||
|
// Path.join(buildRendererDir, 'image'),
|
||||||
|
// { overwrite: true }
|
||||||
|
// );
|
||||||
|
|
||||||
|
console.log('Static assets copy skipped (resources managed by public directory).');
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = copyAssets;
|
module.exports = copyAssets;
|
||||||
@@ -22,11 +22,35 @@ const electronAPI = {
|
|||||||
// 添加日志相关 API
|
// 添加日志相关 API
|
||||||
getLogDates: () => ipcRenderer.invoke('get-log-dates'),
|
getLogDates: () => ipcRenderer.invoke('get-log-dates'),
|
||||||
readLogFile: (logDate: string) => ipcRenderer.invoke('read-log-file', logDate),
|
readLogFile: (logDate: string) => ipcRenderer.invoke('read-log-file', logDate),
|
||||||
|
|
||||||
// 关闭行为配置 API
|
// 关闭行为配置 API
|
||||||
getCloseAction: () => ipcRenderer.invoke('get-close-action'),
|
getCloseAction: () => ipcRenderer.invoke('get-close-action'),
|
||||||
setCloseAction: (action: 'quit' | 'minimize' | 'tray') => ipcRenderer.invoke('set-close-action', action),
|
setCloseAction: (action: 'quit' | 'minimize' | 'tray') => ipcRenderer.invoke('set-close-action', action),
|
||||||
|
|
||||||
|
// 缓存管理 API
|
||||||
|
clearCache: () => ipcRenderer.invoke('clear-cache'),
|
||||||
|
|
||||||
|
// 启动配置 API
|
||||||
|
getLaunchConfig: () => ipcRenderer.invoke('get-launch-config'),
|
||||||
|
setLaunchConfig: (config: { autoLaunch: boolean; launchMinimized: boolean }) => ipcRenderer.invoke('set-launch-config', config),
|
||||||
|
|
||||||
|
// 刷新页面 API
|
||||||
|
reload: () => ipcRenderer.invoke('reload'),
|
||||||
|
|
||||||
|
// 窗口控制 API
|
||||||
|
windowMinimize: () => ipcRenderer.invoke('window-minimize'),
|
||||||
|
windowMaximize: () => ipcRenderer.invoke('window-maximize'),
|
||||||
|
windowClose: () => ipcRenderer.invoke('window-close'),
|
||||||
|
windowIsMaximized: () => ipcRenderer.invoke('window-is-maximized'),
|
||||||
|
|
||||||
|
// 开屏图片相关 API
|
||||||
|
saveSplashConfig: (username: string, imageUrl: string) => ipcRenderer.invoke('save-splash-config', username, imageUrl),
|
||||||
|
getSplashConfig: () => ipcRenderer.invoke('get-splash-config'),
|
||||||
|
|
||||||
|
// 品牌logo相关 API
|
||||||
|
saveBrandLogoConfig: (username: string, logoUrl: string) => ipcRenderer.invoke('save-brand-logo-config', username, logoUrl),
|
||||||
|
loadConfig: () => ipcRenderer.invoke('load-config'),
|
||||||
|
clearUserConfig: () => ipcRenderer.invoke('clear-user-config'),
|
||||||
|
|
||||||
onDownloadProgress: (callback: (progress: any) => void) => {
|
onDownloadProgress: (callback: (progress: any) => void) => {
|
||||||
ipcRenderer.removeAllListeners('download-progress')
|
ipcRenderer.removeAllListeners('download-progress')
|
||||||
ipcRenderer.on('download-progress', (event, progress) => callback(progress))
|
ipcRenderer.on('download-progress', (event, progress) => callback(progress))
|
||||||
|
|||||||
@@ -7,11 +7,9 @@ let tray: Tray | null = null
|
|||||||
function getIconPath(): string {
|
function getIconPath(): string {
|
||||||
const isDev = process.env.NODE_ENV === 'development'
|
const isDev = process.env.NODE_ENV === 'development'
|
||||||
if (isDev) {
|
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')
|
return join(process.resourcesPath, 'app.asar.unpacked', 'public/icon/icon1.png')
|
||||||
if (existsSync(bundledPath)) return bundledPath
|
|
||||||
return join(__dirname, '../renderer/icon/icon.png')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTray(mainWindow: BrowserWindow | null) {
|
export function createTray(mainWindow: BrowserWindow | null) {
|
||||||
@@ -34,10 +32,8 @@ export function createTray(mainWindow: BrowserWindow | null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 右键菜单
|
// 右键菜单
|
||||||
updateTrayMenu(mainWindow)
|
updateTrayMenu(mainWindow)
|
||||||
|
|
||||||
return tray
|
return tray
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,17 @@
|
|||||||
import { http } from './http';
|
import { http } from './http';
|
||||||
|
|
||||||
export const amazonApi = {
|
export const amazonApi = {
|
||||||
// 上传Excel文件解析ASIN列表
|
|
||||||
importAsinFromExcel(file: File) {
|
importAsinFromExcel(file: File) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
return http.upload<{ code: number, data: { asinList: string[], total: number }, msg: string | null }>('/api/amazon/import/asin', formData);
|
return http.upload<{ code: number, data: { asinList: string[], total: number }, msg: string | null }>('/api/amazon/import/asin', formData);
|
||||||
},
|
},
|
||||||
|
|
||||||
getProductsBatch(asinList: string[], batchId: string, region: string) {
|
getProductsBatch(asinList: string[], batchId: string, region: string, signal?: AbortSignal) {
|
||||||
return http.post<{ code: number, data: { products: any[] }, msg: string | null }>('/api/amazon/products/batch', { asinList, batchId, region });
|
return http.post<{ code: number, data: { products: any[] }, msg: string | null }>('/api/amazon/products/batch', { asinList, batchId, region }, signal);
|
||||||
},
|
},
|
||||||
|
|
||||||
getLatestProducts() {
|
getLatestProducts() {
|
||||||
return http.get<{ code: number, data: { products: any[] }, msg: string | null }>('/api/amazon/products/latest');
|
return http.get<{ code: number, data: { products: any[] }, msg: string | null }>('/api/amazon/products/latest');
|
||||||
},
|
}
|
||||||
getProductsByBatch(batchId: string) {
|
|
||||||
return http.get<{ products: any[] }>(`/api/amazon/products/batch/${batchId}`);
|
|
||||||
},
|
|
||||||
updateProduct(productData: unknown) {
|
|
||||||
return http.post('/api/amazon/products/update', productData);
|
|
||||||
},
|
|
||||||
deleteProduct(productId: string) {
|
|
||||||
return http.post('/api/amazon/products/delete', { id: productId });
|
|
||||||
},
|
|
||||||
getProductStats() {
|
|
||||||
return http.get('/api/amazon/stats');
|
|
||||||
},
|
|
||||||
searchProducts(searchParams: Record<string, unknown>) {
|
|
||||||
return http.get('/api/amazon/products/search', searchParams);
|
|
||||||
},
|
|
||||||
openGenmaiSpirit() {
|
|
||||||
return http.post('/api/system/genmai/open');
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,11 +23,19 @@ export const deviceApi = {
|
|||||||
return http.get<{ data: DeviceItem[] }>('/monitor/device/list', { username })
|
return http.get<{ data: DeviceItem[] }>('/monitor/device/list', { username })
|
||||||
},
|
},
|
||||||
|
|
||||||
register(payload: { username: string; deviceId: string; os?: string }) {
|
async register(payload: { username: string; deviceId: string; os?: string }) {
|
||||||
return http.post('/monitor/device/register', payload)
|
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)
|
return http.post('/monitor/device/remove', payload)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
39
electron-vue-template/src/renderer/api/genmai.ts
Normal 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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,20 +1,10 @@
|
|||||||
// HTTP 工具:统一管理后端服务配置和请求
|
import { AppConfig, isRuoyiPath } from '../config'
|
||||||
export type HttpMethod = 'GET' | 'POST' | 'DELETE';
|
|
||||||
|
|
||||||
// 集中管理所有后端服务配置
|
export type HttpMethod = 'GET' | 'POST' | 'DELETE'
|
||||||
export const CONFIG = {
|
export const CONFIG = AppConfig
|
||||||
CLIENT_BASE: 'http://localhost:8081',
|
|
||||||
RUOYI_BASE: 'http://192.168.1.89:8085',
|
|
||||||
SSE_URL: 'http://192.168.1.89:8085/monitor/account/events'
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
function resolveBase(path: string): string {
|
function resolveBase(path: string): string {
|
||||||
// RuoYi 后端路径:鉴权、设备、反馈、版本、工具
|
return isRuoyiPath(path) ? CONFIG.RUOYI_BASE : CONFIG.CLIENT_BASE
|
||||||
if (path.startsWith('/monitor/') || path.startsWith('/system/') || path.startsWith('/tool/banma')) {
|
|
||||||
return CONFIG.RUOYI_BASE;
|
|
||||||
}
|
|
||||||
// 其他走客户端服务
|
|
||||||
return CONFIG.CLIENT_BASE;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQuery(params?: Record<string, unknown>): string {
|
function buildQuery(params?: Record<string, unknown>): string {
|
||||||
@@ -26,40 +16,60 @@ function buildQuery(params?: Record<string, unknown>): string {
|
|||||||
return query.toString() ? `?${query}` : '';
|
return query.toString() ? `?${query}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(path: string, options: RequestInit): Promise<T> {
|
async function getToken(): Promise<string> {
|
||||||
// 获取token
|
|
||||||
let token = '';
|
|
||||||
try {
|
try {
|
||||||
const tokenModule = await import('../utils/token');
|
const tokenModule = await import('../utils/token');
|
||||||
token = tokenModule.getToken() || '';
|
return tokenModule.getToken() || '';
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.warn('获取token失败:', e);
|
return '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(`${resolveBase(path)}${path}`, {
|
async function getUsername(): Promise<string> {
|
||||||
credentials: 'omit',
|
try {
|
||||||
cache: 'no-store',
|
const tokenModule = await import('../utils/token');
|
||||||
...options,
|
return tokenModule.getUsernameFromToken() || '';
|
||||||
headers: {
|
} catch {
|
||||||
'Content-Type': 'application/json',
|
return '';
|
||||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
}
|
||||||
...options.headers
|
}
|
||||||
}
|
|
||||||
});
|
async function request<T>(path: string, options: RequestInit & { signal?: AbortSignal }): Promise<T> {
|
||||||
|
const token = await getToken();
|
||||||
|
const username = await getUsername();
|
||||||
|
let res: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
res = await fetch(`${resolveBase(path)}${path}`, {
|
||||||
|
credentials: 'omit',
|
||||||
|
cache: 'no-store',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json;charset=UTF-8',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
...(username ? { 'username': username } : {}),
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error('无法连接服务器,请检查网络后重试');
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
if (res.status >= 500) {
|
||||||
|
throw new Error('无法连接服务器,请检查网络后重试');
|
||||||
|
}
|
||||||
const text = await res.text().catch(() => '');
|
const text = await res.text().catch(() => '');
|
||||||
throw new Error(text || `HTTP ${res.status}`);
|
throw new Error(text || '无法连接服务器,请检查网络后重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentType = res.headers.get('content-type') || '';
|
const contentType = res.headers.get('content-type') || '';
|
||||||
if (contentType.includes('application/json')) {
|
if (contentType.includes('application/json')) {
|
||||||
const json: any = await res.json();
|
const json: any = await res.json();
|
||||||
// 业务状态码判断:支持两种格式
|
|
||||||
// - erp_client_sb (本地服务): code=0 表示成功
|
|
||||||
// - RuoYi 后端: code=200 表示成功
|
|
||||||
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
|
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
|
||||||
throw new Error(json.msg || '请求失败');
|
const error: any = new Error(json.msg || '请求失败');
|
||||||
|
error.code = json.code;
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
return json as T;
|
return json as T;
|
||||||
}
|
}
|
||||||
@@ -68,13 +78,14 @@ async function request<T>(path: string, options: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const http = {
|
export const http = {
|
||||||
get<T>(path: string, params?: Record<string, unknown>) {
|
get<T>(path: string, params?: Record<string, unknown>, signal?: AbortSignal) {
|
||||||
return request<T>(`${path}${buildQuery(params)}`, { method: 'GET' });
|
return request<T>(`${path}${buildQuery(params)}`, { method: 'GET', signal });
|
||||||
},
|
},
|
||||||
post<T>(path: string, body?: unknown) {
|
post<T>(path: string, body?: unknown, signal?: AbortSignal) {
|
||||||
return request<T>(path, {
|
return request<T>(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: body ? JSON.stringify(body) : undefined
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -82,42 +93,46 @@ export const http = {
|
|||||||
return request<T>(path, { method: 'DELETE' });
|
return request<T>(path, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|
||||||
async upload<T>(path: string, form: FormData) {
|
async upload<T>(path: string, form: FormData, signal?: AbortSignal) {
|
||||||
// 获取token
|
const token = await getToken();
|
||||||
let token = '';
|
const username = await getUsername();
|
||||||
|
let res: Response;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tokenModule = await import('../utils/token');
|
res = await fetch(`${resolveBase(path)}${path}`, {
|
||||||
token = tokenModule.getToken() || '';
|
method: 'POST',
|
||||||
|
body: form,
|
||||||
|
credentials: 'omit',
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||||
|
...(username ? { 'username': username } : {})
|
||||||
|
},
|
||||||
|
signal
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('获取token失败:', e);
|
throw new Error('无法连接服务器,请检查网络后重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
if (!res.ok) {
|
||||||
if (token) {
|
if (res.status >= 500) {
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
throw new Error('无法连接服务器,请检查网络后重试');
|
||||||
|
}
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(text || '无法连接服务器,请检查网络后重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
return fetch(`${resolveBase(path)}${path}`, {
|
const contentType = res.headers.get('content-type') || '';
|
||||||
method: 'POST',
|
if (contentType.includes('application/json')) {
|
||||||
body: form,
|
const json: any = await res.json();
|
||||||
credentials: 'omit',
|
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
|
||||||
cache: 'no-store',
|
const error: any = new Error(json.msg || '请求失败');
|
||||||
headers
|
error.code = json.code;
|
||||||
}).then(async res => {
|
throw error;
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => '');
|
|
||||||
throw new Error(text || `HTTP ${res.status}`);
|
|
||||||
}
|
}
|
||||||
const contentType = res.headers.get('content-type') || '';
|
return json as T;
|
||||||
if (contentType.includes('application/json')) {
|
}
|
||||||
const json: any = await res.json();
|
return (await res.text()) as unknown as T;
|
||||||
if (json.code !== undefined && json.code !== 0 && json.code !== 200) {
|
|
||||||
throw new Error(json.msg || '请求失败');
|
|
||||||
}
|
|
||||||
return json as T;
|
|
||||||
}
|
|
||||||
return (await res.text()) as unknown as T;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
115
electron-vue-template/src/renderer/api/mark.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import { http } from './http'
|
import { http } from './http'
|
||||||
|
|
||||||
export const rakutenApi = {
|
export const rakutenApi = {
|
||||||
getProducts(params: { file?: File; shopName?: string; batchId?: string }) {
|
getProducts(params: { file?: File; shopName?: string; batchId?: string }, signal?: AbortSignal) {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
if (params.file) formData.append('file', params.file)
|
if (params.file) formData.append('file', params.file)
|
||||||
if (params.batchId) formData.append('batchId', params.batchId)
|
if (params.batchId) formData.append('batchId', params.batchId)
|
||||||
if (params.shopName) formData.append('shopName', params.shopName)
|
if (params.shopName) formData.append('shopName', params.shopName)
|
||||||
return http.upload('/api/rakuten/products', formData)
|
return http.upload('/api/rakuten/products', formData, signal)
|
||||||
},
|
},
|
||||||
|
|
||||||
search1688(imageUrl: string, sessionId?: string) {
|
search1688(imageUrl: string, sessionId?: string, signal?: AbortSignal) {
|
||||||
const payload: Record<string, unknown> = { imageUrl }
|
const payload: Record<string, unknown> = { imageUrl }
|
||||||
if (sessionId) payload.sessionId = sessionId
|
if (sessionId) payload.sessionId = sessionId
|
||||||
return http.post('/api/rakuten/search1688', payload)
|
return http.post('/api/rakuten/search1688', payload, signal)
|
||||||
},
|
},
|
||||||
|
|
||||||
getLatestProducts() {
|
getLatestProducts() {
|
||||||
|
|||||||
55
electron-vue-template/src/renderer/api/splash.ts
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
13
electron-vue-template/src/renderer/api/system.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { http } from './http';
|
||||||
|
|
||||||
|
export const systemApi = {
|
||||||
|
openGenmaiSpirit(accountId?: number | null) {
|
||||||
|
const url = accountId ? `/api/system/genmai/open?accountId=${accountId}` : '/api/system/genmai/open';
|
||||||
|
return http.post(url);
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCache() {
|
||||||
|
return http.post('/api/system/cache/clear');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -5,6 +5,10 @@ export const zebraApi = {
|
|||||||
return http.get('/tool/banma/accounts', name ? { name } : undefined)
|
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) {
|
saveAccount(body: any, name?: string) {
|
||||||
const url = name ? `/tool/banma/accounts?name=${encodeURIComponent(name)}` : '/tool/banma/accounts'
|
const url = name ? `/tool/banma/accounts?name=${encodeURIComponent(name)}` : '/tool/banma/accounts'
|
||||||
return http.post(url, body)
|
return http.post(url, body)
|
||||||
@@ -18,23 +22,11 @@ export const zebraApi = {
|
|||||||
return http.get('/api/banma/shops', params as Record<string, unknown>)
|
return http.get('/api/banma/shops', params as Record<string, unknown>)
|
||||||
},
|
},
|
||||||
|
|
||||||
getOrders(params: any) {
|
getOrders(params: any, signal?: AbortSignal) {
|
||||||
return http.get('/api/banma/orders', params as Record<string, unknown>)
|
return http.get('/api/banma/orders', params as Record<string, unknown>, signal)
|
||||||
},
|
|
||||||
|
|
||||||
getOrdersByBatch(batchId: string) {
|
|
||||||
return http.get(`/api/banma/orders/batch/${batchId}`)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getLatestOrders() {
|
getLatestOrders() {
|
||||||
return http.get('/api/banma/orders/latest')
|
return http.get('/api/banma/orders/latest')
|
||||||
},
|
|
||||||
|
|
||||||
getOrderStats() {
|
|
||||||
return http.get('/api/banma/orders/stats')
|
|
||||||
},
|
|
||||||
|
|
||||||
searchOrders(searchParams: Record<string, unknown>) {
|
|
||||||
return http.get('/api/banma/orders/search', searchParams)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
9
electron-vue-template/src/renderer/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
// Generated by unplugin-auto-import
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
|
||||||
|
}
|
||||||
31
electron-vue-template/src/renderer/components.d.ts
vendored
Normal 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']
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -3,8 +3,8 @@ import { ref, computed } from 'vue'
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { User } from '@element-plus/icons-vue'
|
import { User } from '@element-plus/icons-vue'
|
||||||
import { authApi } from '../../api/auth'
|
import { authApi } from '../../api/auth'
|
||||||
import { deviceApi } from '../../api/device'
|
|
||||||
import { getOrCreateDeviceId } from '../../utils/deviceId'
|
import { getOrCreateDeviceId } from '../../utils/deviceId'
|
||||||
|
import { splashApi } from '../../api/splash'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -14,6 +14,7 @@ interface Emits {
|
|||||||
(e: 'update:modelValue', value: boolean): void
|
(e: 'update:modelValue', value: boolean): void
|
||||||
(e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }): void
|
(e: 'loginSuccess', data: { token: string; permissions?: string; expireTime?: string; accountType?: string; deviceTrialExpired?: boolean }): void
|
||||||
(e: 'showRegister'): void
|
(e: 'showRegister'): void
|
||||||
|
(e: 'deviceConflict', username: string): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -34,20 +35,17 @@ async function handleAuth() {
|
|||||||
try {
|
try {
|
||||||
// 获取或生成设备ID
|
// 获取或生成设备ID
|
||||||
const deviceId = await getOrCreateDeviceId()
|
const deviceId = await getOrCreateDeviceId()
|
||||||
|
|
||||||
// 注册设备
|
|
||||||
await deviceApi.register({
|
|
||||||
username: authForm.value.username,
|
|
||||||
deviceId: deviceId,
|
|
||||||
os: navigator.platform
|
|
||||||
})
|
|
||||||
|
|
||||||
// 登录
|
// 登录
|
||||||
const loginRes: any = await authApi.login({
|
const loginRes: any = await authApi.login({
|
||||||
...authForm.value,
|
...authForm.value,
|
||||||
clientId: deviceId
|
clientId: deviceId
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 保存开屏图片配置和品牌logo(不阻塞登录)
|
||||||
|
saveSplashConfigInBackground(authForm.value.username)
|
||||||
|
saveBrandLogoInBackground(authForm.value.username)
|
||||||
|
|
||||||
emit('loginSuccess', {
|
emit('loginSuccess', {
|
||||||
token: loginRes.data.accessToken || loginRes.data.token,
|
token: loginRes.data.accessToken || loginRes.data.token,
|
||||||
permissions: loginRes.data.permissions,
|
permissions: loginRes.data.permissions,
|
||||||
@@ -57,8 +55,14 @@ async function handleAuth() {
|
|||||||
})
|
})
|
||||||
ElMessage.success('登录成功')
|
ElMessage.success('登录成功')
|
||||||
resetForm()
|
resetForm()
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
ElMessage.error((err as Error).message)
|
// 设备冲突/数量达上限:触发设备管理
|
||||||
|
if (err.code === 501 ) {
|
||||||
|
emit('deviceConflict', authForm.value.username)
|
||||||
|
resetForm()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(err.message || '登录失败')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
authLoading.value = false
|
authLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,31 @@ function resetForm() {
|
|||||||
function showRegister() {
|
function showRegister() {
|
||||||
emit('showRegister')
|
emit('showRegister')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 保存开屏图片配置
|
||||||
|
async function saveSplashConfigInBackground(username: string) {
|
||||||
|
try {
|
||||||
|
const res = await splashApi.getSplashImage(username)
|
||||||
|
const url = res?.data?.data?.url || res?.data?.url || ''
|
||||||
|
await (window as any).electronAPI.saveSplashConfig(username, url)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[开屏图片] 保存配置失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存品牌logo配置
|
||||||
|
async function saveBrandLogoInBackground(username: string) {
|
||||||
|
try {
|
||||||
|
const res = await splashApi.getBrandLogo(username)
|
||||||
|
const url = res?.data?.url || ''
|
||||||
|
// 保存到本地配置
|
||||||
|
await (window as any).electronAPI.saveBrandLogoConfig(username, url)
|
||||||
|
// 触发App.vue加载品牌logo
|
||||||
|
window.dispatchEvent(new CustomEvent('brandLogoChanged', { detail: url }))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[品牌logo] 加载配置失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -111,6 +140,7 @@ function showRegister() {
|
|||||||
size="large"
|
size="large"
|
||||||
style="margin-bottom: 20px;"
|
style="margin-bottom: 20px;"
|
||||||
:disabled="authLoading"
|
:disabled="authLoading"
|
||||||
|
show-password
|
||||||
@keyup.enter="handleAuth">
|
@keyup.enter="handleAuth">
|
||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
@@ -136,36 +166,10 @@ function showRegister() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-logo {
|
.auth-logo {width: 160px; height: auto;}
|
||||||
width: 160px;
|
.auth-dialog {--el-color-primary: #1677FF;}
|
||||||
height: auto;
|
.auth-dialog :deep(.el-button--primary) {background-color: #1677FF; border-color: #1677FF;}
|
||||||
}
|
.auth-title-wrap {margin-bottom: 12px;}
|
||||||
|
.auth-title {margin: 0; font-size: 18px; font-weight: 700; color: #1f1f1f; text-align: left;}
|
||||||
.auth-dialog {
|
.auth-subtitle {margin: 6px 0 0; font-size: 12px; color: #8c8c8c; text-align: left;}
|
||||||
--el-color-primary: #1677FF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-dialog :deep(.el-button--primary) {
|
|
||||||
background-color: #1677FF;
|
|
||||||
border-color: #1677FF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-title-wrap {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f1f1f;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-subtitle {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8c8c8c;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -35,6 +35,10 @@ const canRegister = computed(() => {
|
|||||||
usernameCheckResult.value === true
|
usernameCheckResult.value === true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function filterUsername(value: string) {
|
||||||
|
registerForm.value.username = value.replace(/[^a-zA-Z0-9_]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
async function checkUsernameAvailability() {
|
async function checkUsernameAvailability() {
|
||||||
if (!registerForm.value.username) {
|
if (!registerForm.value.username) {
|
||||||
usernameCheckResult.value = null
|
usernameCheckResult.value = null
|
||||||
@@ -123,10 +127,11 @@ function backToLogin() {
|
|||||||
|
|
||||||
<el-input
|
<el-input
|
||||||
v-model="registerForm.username"
|
v-model="registerForm.username"
|
||||||
placeholder="请输入用户名"
|
placeholder="请输入用户名(字母、数字、下划线)"
|
||||||
size="large"
|
size="large"
|
||||||
style="margin-bottom: 15px;"
|
style="margin-bottom: 15px;"
|
||||||
:disabled="registerLoading"
|
:disabled="registerLoading"
|
||||||
|
@input="filterUsername"
|
||||||
@blur="checkUsernameAvailability">
|
@blur="checkUsernameAvailability">
|
||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
@@ -145,7 +150,8 @@ function backToLogin() {
|
|||||||
type="password"
|
type="password"
|
||||||
size="large"
|
size="large"
|
||||||
style="margin-bottom: 15px;"
|
style="margin-bottom: 15px;"
|
||||||
:disabled="registerLoading">
|
:disabled="registerLoading"
|
||||||
|
show-password>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
<el-input
|
<el-input
|
||||||
@@ -154,7 +160,8 @@ function backToLogin() {
|
|||||||
type="password"
|
type="password"
|
||||||
size="large"
|
size="large"
|
||||||
style="margin-bottom: 20px;"
|
style="margin-bottom: 20px;"
|
||||||
:disabled="registerLoading">
|
:disabled="registerLoading"
|
||||||
|
show-password>
|
||||||
</el-input>
|
</el-input>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -178,36 +185,10 @@ function backToLogin() {
|
|||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-logo {
|
.auth-logo {width: 160px; height: auto;}
|
||||||
width: 160px;
|
.auth-dialog {--el-color-primary: #1677FF;}
|
||||||
height: auto;
|
.auth-dialog :deep(.el-button--primary) {background-color: #1677FF; border-color: #1677FF;}
|
||||||
}
|
.auth-title-wrap {margin-bottom: 12px;}
|
||||||
|
.auth-title {margin: 0; font-size: 18px; font-weight: 700; color: #1f1f1f; text-align: left;}
|
||||||
.auth-dialog {
|
.auth-subtitle {margin: 6px 0 0; font-size: 12px; color: #8c8c8c; text-align: left;}
|
||||||
--el-color-primary: #1677FF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-dialog :deep(.el-button--primary) {
|
|
||||||
background-color: #1677FF;
|
|
||||||
border-color: #1677FF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-title-wrap {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f1f1f;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-subtitle {
|
|
||||||
margin: 6px 0 0;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8c8c8c;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -1,28 +1,50 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed, defineAsyncComponent, watch } from 'vue'
|
||||||
import { zebraApi, type BanmaAccount } from '../../api/zebra'
|
import { zebraApi, type BanmaAccount } from '../../api/zebra'
|
||||||
|
import { genmaiApi, type GenmaiAccount } from '../../api/genmai'
|
||||||
import { ElMessageBox, ElMessage } from 'element-plus'
|
import { ElMessageBox, ElMessage } from 'element-plus'
|
||||||
import { getUsernameFromToken } from '../../utils/token'
|
import { getUsernameFromToken } from '../../utils/token'
|
||||||
|
|
||||||
type PlatformKey = 'zebra' | 'shopee' | 'rakuten' | 'amazon'
|
const TrialExpiredDialog = defineAsyncComponent(() => import('./TrialExpiredDialog.vue'))
|
||||||
|
|
||||||
|
type PlatformKey = 'zebra' | 'shopee' | 'rakuten' | 'amazon' | 'genmai'
|
||||||
const props = defineProps<{ modelValue: boolean; platform?: PlatformKey }>()
|
const props = defineProps<{ modelValue: boolean; platform?: PlatformKey }>()
|
||||||
const emit = defineEmits(['update:modelValue', 'add', 'refresh'])
|
const emit = defineEmits(['update:modelValue', 'refresh'])
|
||||||
const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
|
const visible = computed({ get: () => props.modelValue, set: v => emit('update:modelValue', v) })
|
||||||
const curPlatform = ref<PlatformKey>(props.platform || 'zebra')
|
const curPlatform = ref<PlatformKey>(props.platform || 'zebra')
|
||||||
|
|
||||||
|
// 监听弹框打开,同步平台并加载数据
|
||||||
|
watch(() => props.modelValue, (newVal) => {
|
||||||
|
if (newVal && props.platform) {
|
||||||
|
curPlatform.value = props.platform
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 升级订阅弹框
|
||||||
|
const showUpgradeDialog = ref(false)
|
||||||
const PLATFORM_LABEL: Record<PlatformKey, string> = {
|
const PLATFORM_LABEL: Record<PlatformKey, string> = {
|
||||||
zebra: '斑马 ERP',
|
zebra: '斑马 ERP',
|
||||||
shopee: 'Shopee 虾皮购物',
|
shopee: 'Shopee 虾皮购物',
|
||||||
rakuten: 'Rakuten 乐天购物',
|
rakuten: 'Rakuten 乐天购物',
|
||||||
amazon: 'Amazon 亚马逊'
|
amazon: 'Amazon 亚马逊',
|
||||||
|
genmai: '跟卖精灵'
|
||||||
}
|
}
|
||||||
|
|
||||||
const accounts = ref<BanmaAccount[]>([])
|
const accounts = ref<(BanmaAccount | GenmaiAccount)[]>([])
|
||||||
|
const accountLimit = ref({ limit: 1, count: 0 })
|
||||||
|
|
||||||
|
// 添加账号对话框
|
||||||
|
const accountDialogVisible = ref(false)
|
||||||
|
const formUsername = ref('')
|
||||||
|
const formPassword = ref('')
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
const api = curPlatform.value === 'genmai' ? genmaiApi : zebraApi
|
||||||
const username = getUsernameFromToken()
|
const username = getUsernameFromToken()
|
||||||
const res = await zebraApi.getAccounts(username)
|
const [res, limitRes] = await Promise.all([api.getAccounts(username), api.getAccountLimit(username)])
|
||||||
const list = (res as any)?.data ?? res
|
accounts.value = (res as any)?.data ?? res
|
||||||
accounts.value = Array.isArray(list) ? list : []
|
accountLimit.value = (limitRes as any)?.data ?? limitRes
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露方法供父组件调用
|
// 暴露方法供父组件调用
|
||||||
@@ -44,11 +66,39 @@ async function onDelete(a: any) {
|
|||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(`确定删除账号 "${a?.name || a?.username || id}" 吗?`, '提示', { type: 'warning' })
|
await ElMessageBox.confirm(`确定删除账号 "${a?.name || a?.username || id}" 吗?`, '提示', { type: 'warning' })
|
||||||
} catch { return }
|
} catch { return }
|
||||||
await zebraApi.removeAccount(id)
|
const api = curPlatform.value === 'genmai' ? genmaiApi : zebraApi
|
||||||
|
await api.removeAccount(id)
|
||||||
ElMessage({ message: '删除成功', type: 'success' })
|
ElMessage({ message: '删除成功', type: 'success' })
|
||||||
await load()
|
await load()
|
||||||
emit('refresh') // 通知外层组件刷新账号列表
|
emit('refresh') // 通知外层组件刷新账号列表
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAddAccount() {
|
||||||
|
if (accountLimit.value.count >= accountLimit.value.limit) {
|
||||||
|
ElMessage({ message: `账号数量已达上限`, type: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
formUsername.value = ''
|
||||||
|
formPassword.value = ''
|
||||||
|
accountDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAccount() {
|
||||||
|
const api = curPlatform.value === 'genmai' ? genmaiApi : zebraApi
|
||||||
|
try {
|
||||||
|
await api.saveAccount({
|
||||||
|
username: formUsername.value,
|
||||||
|
password: formPassword.value,
|
||||||
|
status: 1
|
||||||
|
}, getUsernameFromToken())
|
||||||
|
ElMessage({ message: '添加成功', type: 'success' })
|
||||||
|
accountDialogVisible.value = false
|
||||||
|
await load()
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage({ message: e.message || '添加失败', type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -65,8 +115,9 @@ export default defineComponent({ name: 'AccountManager' })
|
|||||||
<div class="layout">
|
<div class="layout">
|
||||||
<aside class="sider">
|
<aside class="sider">
|
||||||
<div class="sider-title">全账号管理</div>
|
<div class="sider-title">全账号管理</div>
|
||||||
<div class="nav only-zebra">
|
<div class="nav">
|
||||||
<div :class="['nav-item', {active: curPlatform==='zebra'}]" @click="switchPlatform('zebra')">斑马 ERP</div>
|
<div :class="['nav-item', {active: curPlatform==='zebra'}]" @click="switchPlatform('zebra')">斑马 ERP</div>
|
||||||
|
<div :class="['nav-item', {active: curPlatform==='genmai'}]" @click="switchPlatform('genmai')">跟卖精灵</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="content">
|
<section class="content">
|
||||||
@@ -74,10 +125,10 @@ export default defineComponent({ name: 'AccountManager' })
|
|||||||
<div class="top">
|
<div class="top">
|
||||||
<img src="/icon/image.png" class="hero" alt="logo" />
|
<img src="/icon/image.png" class="hero" alt="logo" />
|
||||||
<div class="head-main">
|
<div class="head-main">
|
||||||
<div class="main-title">在线账号管理(3/3)</div>
|
<div class="main-title">在线账号管理({{ accountLimit.count }}/{{ accountLimit.limit }})</div>
|
||||||
<div class="main-sub">
|
<div class="main-sub">
|
||||||
您当前订阅可同时托管3家 Shopee 店铺<br>
|
您当前订阅可同时托管{{ accountLimit.limit }}个{{ curPlatform === 'genmai' ? '跟卖精灵' : '斑马' }}账号<br>
|
||||||
如需扩增同时托管店铺数,请 <span class="upgrade">升级订阅</span>。
|
<span v-if="accountLimit.limit < 3">如需扩增账号数量,请 <span class="upgrade" @click="showUpgradeDialog = true">升级订阅</span>。</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,7 +136,7 @@ export default defineComponent({ name: 'AccountManager' })
|
|||||||
<div v-for="a in accounts" :key="a.id" class="row">
|
<div v-for="a in accounts" :key="a.id" class="row">
|
||||||
<span :class="['dot', a.status === 1 ? 'on' : 'off']"></span>
|
<span :class="['dot', a.status === 1 ? 'on' : 'off']"></span>
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<img class="avatar" src="/image/img_v3_02qd_052605f0-4be3-44db-9691-35ee5ff6201g.jpg" />
|
<img class="avatar" src="/image/user.png" />
|
||||||
<span class="name">{{ a.name || a.username }}</span>
|
<span class="name">{{ a.name || a.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="date">{{ formatDate(a) }}</span>
|
<span class="date">{{ formatDate(a) }}</span>
|
||||||
@@ -93,44 +144,75 @@ export default defineComponent({ name: 'AccountManager' })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<el-button type="primary" class="btn" @click="$emit('add')">添加账号</el-button>
|
<el-button type="primary" class="btn" @click="handleAddAccount">添加账号</el-button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加账号对话框 -->
|
||||||
|
<el-dialog v-model="accountDialogVisible" width="420px" class="add-account-dialog">
|
||||||
|
<template #header>
|
||||||
|
<div class="aad-header">
|
||||||
|
<img class="aad-icon" src="/icon/image.png" alt="logo" />
|
||||||
|
<div class="aad-title">添加{{ curPlatform === 'genmai' ? '跟卖精灵' : '斑马' }}账号</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="aad-row">
|
||||||
|
<el-input v-model="formUsername" :placeholder="curPlatform === 'genmai' ? '请输入账号(nickname)' : '请输入账号'" />
|
||||||
|
</div>
|
||||||
|
<div class="aad-row">
|
||||||
|
<el-input v-model="formPassword" placeholder="请输入密码" type="password" show-password />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button type="primary" class="btn-blue" style="width: 100%" @click="submitAccount">添加</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 升级订阅弹框 -->
|
||||||
|
<TrialExpiredDialog v-model="showUpgradeDialog" expired-type="subscribe" />
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.acc-manager :deep(.el-dialog__header) { text-align:center; }
|
.acc-manager :deep(.el-dialog__header) {text-align:center;}
|
||||||
.layout { display:grid; grid-template-columns: 160px 1fr; gap: 12px; min-height: 340px; }
|
.layout {display:grid; grid-template-columns: 160px 1fr; gap: 12px; min-height: 340px;}
|
||||||
.sider { border-right: 1px solid #ebeef5; padding-right: 10px; }
|
.sider {border-right: 1px solid #ebeef5; padding-right: 10px;}
|
||||||
.sider-title { color:#303133; font-size:13px; font-weight: 600; margin-bottom: 10px; text-align: left; }
|
.sider-title {color:#303133; font-size:13px; font-weight: 600; margin-bottom: 10px; text-align: left;}
|
||||||
.nav { display:flex; flex-direction: column; gap: 4px; }
|
.nav {display:flex; flex-direction: column; gap: 4px;}
|
||||||
.nav-item { padding: 6px 8px; border-radius: 4px; cursor: pointer; color:#606266; font-size: 12px; transition: all 0.2s; text-align: left; }
|
.nav-item {padding: 6px 8px; border-radius: 4px; cursor: pointer; color:#606266; font-size: 12px; transition: all 0.2s; text-align: left;}
|
||||||
.nav-item:hover { background:#f0f2f5; }
|
.nav-item:hover {background:#f0f2f5;}
|
||||||
.nav-item.active { background:#e6f4ff; color:#409EFF; font-weight: 600; }
|
.nav-item.active {background:#e6f4ff; color:#409EFF; font-weight: 600;}
|
||||||
.platform-bar { font-weight: 600; color:#303133; margin: 0 0 12px 0; text-align: left; font-size: 14px; padding-bottom: 8px; border-bottom: 1px solid #ebeef5; }
|
.platform-bar {font-weight: 600; color:#303133; margin: 0 0 12px 0; text-align: left; font-size: 14px; padding-bottom: 8px; border-bottom: 1px solid #ebeef5;}
|
||||||
.content { display:flex; flex-direction: column; min-width: 0; }
|
.content {display:flex; flex-direction: column; min-width: 0;}
|
||||||
.top { display:flex; flex-direction: column; align-items:center; gap: 6px; margin-bottom: 12px; }
|
.top {display:flex; flex-direction: column; align-items:center; gap: 6px; margin-bottom: 12px;}
|
||||||
.hero { width: 160px; height: auto; }
|
.hero {width: 160px; height: auto;}
|
||||||
.head-main { text-align:center; }
|
.head-main {text-align:center;}
|
||||||
.main-title { font-size: 16px; font-weight: 600; color:#303133; margin-bottom: 4px; }
|
.main-title {font-size: 16px; font-weight: 600; color:#303133; margin-bottom: 4px;}
|
||||||
.main-sub { color:#909399; font-size: 11px; line-height: 1.4; }
|
.main-sub {color:#909399; font-size: 11px; line-height: 1.4;}
|
||||||
.upgrade { color:#409EFF; cursor: pointer; }
|
.upgrade {color:#409EFF; cursor: pointer; font-weight: 600; transition: all 0.2s ease;}
|
||||||
.list { border:1px solid #ebeef5; border-radius: 6px; background: #fff; flex: 0 0 auto; width: 100%; max-height: 160px; overflow-y: auto; }
|
.upgrade:hover {color:#0d5ed6; text-decoration: underline;}
|
||||||
.list.compact { max-height: 48px; }
|
.list {border:1px solid #ebeef5; border-radius: 6px; background: #fff; flex: 0 0 auto; width: 100%; max-height: 160px; overflow-y: auto;}
|
||||||
.row { display:grid; grid-template-columns: 8px 1fr 120px 60px; gap: 8px; align-items:center; padding: 4px 8px; border-bottom: 1px solid #f5f5f5; height: 28px; }
|
.list.compact {max-height: 48px;}
|
||||||
.row:last-child { border-bottom:none; }
|
/* 添加账号对话框样式 */
|
||||||
.row:hover { background:#fafafa; }
|
.add-account-dialog .aad-header {display:flex; flex-direction: column; align-items:center; gap:8px; padding-top: 8px; width: 100%;}
|
||||||
.dot { width:6px; height:6px; border-radius:50%; justify-self: center; }
|
.add-account-dialog .aad-icon {width: 120px; height: auto;}
|
||||||
.dot.on { background:#52c41a; }
|
.add-account-dialog .aad-title {font-weight: 600; font-size: 18px; text-align: center;}
|
||||||
.dot.off { background:#ff4d4f; }
|
.add-account-dialog .aad-row {margin-top: 12px;}
|
||||||
.user-info { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
:deep(.add-account-dialog .el-dialog__header) {text-align: center; padding-right: 0; display: block;}
|
||||||
.avatar { width:22px; height:22px; border-radius:50%; object-fit: cover; }
|
.btn-blue {background: #1677FF; border-color: #1677FF; color: #fff;}
|
||||||
.name { font-weight:500; font-size: 13px; color:#303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.btn-blue:hover {background: #0d5ed6; border-color: #0d5ed6;}
|
||||||
.date { color:#999; font-size:11px; text-align: center; }
|
.row {display:grid; grid-template-columns: 8px 1fr 120px 60px; gap: 8px; align-items:center; padding: 4px 8px; border-bottom: 1px solid #f5f5f5; height: 28px;}
|
||||||
.footer { display:flex; justify-content:center; padding-top: 10px; }
|
.row:last-child {border-bottom:none;}
|
||||||
.btn { width: 180px; height: 32px; font-size: 13px; }
|
.row:hover {background:#fafafa;}
|
||||||
|
.dot {width:6px; height:6px; border-radius:50%; justify-self: center;}
|
||||||
|
.dot.on {background:#52c41a;}
|
||||||
|
.dot.off {background:#ff4d4f;}
|
||||||
|
.user-info {display: flex; align-items: center; gap: 8px; min-width: 0;}
|
||||||
|
.avatar {width:22px; height:22px; border-radius:50%; object-fit: cover;}
|
||||||
|
.name {font-weight:500; font-size: 13px; color:#303133; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
|
||||||
|
.date {color:#999; font-size:11px; text-align: center;}
|
||||||
|
.footer {display:flex; justify-content:center; padding-top: 10px;}
|
||||||
|
.btn {width: 180px; height: 32px; font-size: 13px;}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
expiredType: 'device' | 'account' | 'both' // 设备过期、账号过期、都过期
|
expiredType: 'device' | 'account' | 'both' | 'subscribe' // 设备过期、账号过期、都过期、主动订阅
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
@@ -19,12 +20,14 @@ const visible = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const titleText = computed(() => {
|
const titleText = computed(() => {
|
||||||
|
if (props.expiredType === 'subscribe') return '订阅服务'
|
||||||
if (props.expiredType === 'both') return '试用已到期'
|
if (props.expiredType === 'both') return '试用已到期'
|
||||||
if (props.expiredType === 'account') return '账号试用已到期'
|
if (props.expiredType === 'account') return '账号试用已到期'
|
||||||
return '设备试用已到期'
|
return '设备试用已到期'
|
||||||
})
|
})
|
||||||
|
|
||||||
const subtitleText = computed(() => {
|
const subtitleText = computed(() => {
|
||||||
|
if (props.expiredType === 'subscribe') return '联系客服订阅或续费,享受完整服务'
|
||||||
if (props.expiredType === 'both') return '试用已到期,请联系客服订阅以获取完整服务'
|
if (props.expiredType === 'both') return '试用已到期,请联系客服订阅以获取完整服务'
|
||||||
if (props.expiredType === 'account') return '账号试用已到期,请联系客服订阅'
|
if (props.expiredType === 'account') return '账号试用已到期,请联系客服订阅'
|
||||||
return '当前设备试用已到期,请更换新设备体验或联系客服订阅'
|
return '当前设备试用已到期,请更换新设备体验或联系客服订阅'
|
||||||
@@ -35,8 +38,11 @@ function handleConfirm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function copyWechat() {
|
function copyWechat() {
|
||||||
navigator.clipboard.writeText('_linhong')
|
navigator.clipboard.writeText('butaihaoba001').then(() => {
|
||||||
// ElMessage.success('微信号已复制')
|
ElMessage.success('微信号已复制')
|
||||||
|
}).catch(() => {
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -69,8 +75,9 @@ function copyWechat() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="wechat-info">
|
<div class="wechat-info">
|
||||||
<div class="wechat-label">客服微信</div>
|
<div class="wechat-label">客服微信</div>
|
||||||
<div class="wechat-id">_linhong</div>
|
<div class="wechat-id">butaihaoba001</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="copy-icon">📋</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 按钮 -->
|
<!-- 按钮 -->
|
||||||
@@ -86,102 +93,23 @@ function copyWechat() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.trial-expired-dialog :deep(.el-dialog) {
|
.trial-expired-dialog :deep(.el-dialog) {border-radius: 16px;}
|
||||||
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;}
|
||||||
.trial-expired-dialog :deep(.el-dialog__header) {
|
.expired-logo {width: 160px; height: auto;}
|
||||||
padding: 0;
|
.expired-title {font-size: 18px; font-weight: 700; color: #1f1f1f; margin: 0 0 8px 0; text-align: center;}
|
||||||
margin: 0;
|
.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);}
|
||||||
.trial-expired-dialog :deep(.el-dialog__body) {
|
.wechat-icon {flex-shrink: 0;}
|
||||||
padding: 20px;
|
.wechat-icon svg {width: 36px; height: 36px;}
|
||||||
}
|
.wechat-info {flex: 1; text-align: left;}
|
||||||
|
.wechat-label {font-size: 12px; color: #666; margin-bottom: 2px;}
|
||||||
.expired-content {
|
.wechat-id {font-size: 15px; font-weight: 500; color: #1f1f1f;}
|
||||||
display: flex;
|
.copy-icon {margin-left: auto; font-size: 16px; opacity: 0.5; transition: all 0.3s;}
|
||||||
flex-direction: column;
|
.wechat-card:hover .copy-icon {opacity: 1; transform: scale(1.1);}
|
||||||
align-items: center;
|
.confirm-btn {height: 40px; font-size: 14px; font-weight: 500; background: #1677FF; border-color: #1677FF; border-radius: 6px;}
|
||||||
padding: 10px 0;
|
.confirm-btn:hover {background: #4096ff; border-color: #4096ff;}
|
||||||
}
|
|
||||||
|
|
||||||
.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: background 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wechat-card:hover {
|
|
||||||
background: #ebebeb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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
|
<el-dialog v-model="show" width="522px" :close-on-click-modal="false" align-center
|
||||||
:class="['update-dialog', `stage-${stage}`]"
|
:class="['update-dialog', `stage-${stage}`]"
|
||||||
:title="stage === 'downloading' ? `正在更新 ${appName}` : '软件更新'">
|
:title="stage === 'downloading' ? `正在更新 ${appName}` : '软件更新'">
|
||||||
<div v-if="stage === 'check'" class="update-content">
|
<div v-if="stage === 'check'" class="update-content">
|
||||||
<div class="update-layout">
|
<div class="update-layout">
|
||||||
<div class="left-pane">
|
<div class="left-pane">
|
||||||
<img src="/icon/icon.png" class="app-icon app-icon-large" alt="App Icon"/>
|
<img src="/icon/icon1.png" class="app-icon app-icon-large" alt="App Icon"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-pane">
|
<div class="right-pane">
|
||||||
<p class="announce">新版本的"{{ appName }}"已经发布</p>
|
<p class="announce">新版本的"{{ appName }}"已经发布</p>
|
||||||
@@ -45,7 +41,7 @@
|
|||||||
<div v-else-if="stage === 'downloading'" class="update-content">
|
<div v-else-if="stage === 'downloading'" class="update-content">
|
||||||
<div class="download-main">
|
<div class="download-main">
|
||||||
<div class="download-icon">
|
<div class="download-icon">
|
||||||
<img src="/icon/icon.png" class="app-icon" alt="App Icon"/>
|
<img src="/icon/icon1.png" class="app-icon" alt="App Icon"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-content">
|
<div class="download-content">
|
||||||
<div class="download-info">
|
<div class="download-info">
|
||||||
@@ -68,7 +64,7 @@
|
|||||||
<div v-else-if="stage === 'completed'" class="update-content">
|
<div v-else-if="stage === 'completed'" class="update-content">
|
||||||
<div class="download-main">
|
<div class="download-main">
|
||||||
<div class="download-icon">
|
<div class="download-icon">
|
||||||
<img src="/icon/icon.png" class="app-icon" alt="App Icon"/>
|
<img src="/icon/icon1.png" class="app-icon" alt="App Icon"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="download-content">
|
<div class="download-content">
|
||||||
<div class="download-info">
|
<div class="download-info">
|
||||||
@@ -101,16 +97,12 @@ import {ref, computed, onMounted, onUnmounted, watch} from 'vue'
|
|||||||
import {ElMessage, ElMessageBox} from 'element-plus'
|
import {ElMessage, ElMessageBox} from 'element-plus'
|
||||||
import {updateApi} from '../../api/update'
|
import {updateApi} from '../../api/update'
|
||||||
import {getSettings} from '../../utils/settings'
|
import {getSettings} from '../../utils/settings'
|
||||||
|
import {getUsernameFromToken} from '../../utils/token'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
modelValue: boolean
|
|
||||||
}>()
|
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
|
||||||
// 暴露方法给父组件调用
|
defineExpose({ checkForUpdatesNow })
|
||||||
defineExpose({
|
|
||||||
checkForUpdatesNow
|
|
||||||
})
|
|
||||||
|
|
||||||
const show = computed({
|
const show = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
@@ -121,54 +113,40 @@ type Stage = 'check' | 'downloading' | 'completed'
|
|||||||
const stage = ref<Stage>('check')
|
const stage = ref<Stage>('check')
|
||||||
const appName = ref('我了个电商')
|
const appName = ref('我了个电商')
|
||||||
const version = ref('')
|
const version = ref('')
|
||||||
const hasNewVersion = ref(false) // 控制小红点显示
|
|
||||||
const prog = ref({percentage: 0, current: '0 MB', total: '0 MB'})
|
const prog = ref({percentage: 0, current: '0 MB', total: '0 MB'})
|
||||||
const info = ref({
|
const info = ref({
|
||||||
latestVersion: '2.4.8',
|
latestVersion: '',
|
||||||
downloadUrl: '',
|
|
||||||
asarUrl: '',
|
asarUrl: '',
|
||||||
jarUrl: '',
|
jarUrl: '',
|
||||||
updateNotes: '',
|
updateNotes: '',
|
||||||
currentVersion: '',
|
currentVersion: ''
|
||||||
hasUpdate: false
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const SKIP_VERSION_KEY = 'skipped_version'
|
async function checkUpdate(silent = false) {
|
||||||
const REMIND_LATER_KEY = 'remind_later_time'
|
|
||||||
|
|
||||||
async function autoCheck(silent = false) {
|
|
||||||
try {
|
try {
|
||||||
version.value = await (window as any).electronAPI.getJarVersion()
|
version.value = await (window as any).electronAPI.getJarVersion()
|
||||||
const checkRes: any = await updateApi.checkUpdate(version.value)
|
const result = (await updateApi.checkUpdate(version.value))?.data
|
||||||
const result = checkRes?.data || checkRes
|
|
||||||
|
info.value = {
|
||||||
|
currentVersion: result.currentVersion || version.value,
|
||||||
|
latestVersion: result.latestVersion || version.value,
|
||||||
|
asarUrl: result.asarUrl || '',
|
||||||
|
jarUrl: result.jarUrl || '',
|
||||||
|
updateNotes: result.updateNotes || ''
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.needUpdate) {
|
if (!result.needUpdate) {
|
||||||
hasNewVersion.value = false
|
|
||||||
if (!silent) ElMessage.info('当前已是最新版本')
|
if (!silent) ElMessage.info('当前已是最新版本')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (localStorage.getItem('skipped_version') === result.latestVersion) return
|
||||||
|
|
||||||
// 发现新版本,更新信息并显示小红点
|
const remindTime = localStorage.getItem('remind_later_time')
|
||||||
info.value = {
|
if (remindTime && Date.now() < parseInt(remindTime)) return
|
||||||
currentVersion: result.currentVersion,
|
|
||||||
latestVersion: result.latestVersion,
|
|
||||||
downloadUrl: result.downloadUrl || '',
|
|
||||||
asarUrl: result.asarUrl || '',
|
|
||||||
jarUrl: result.jarUrl || '',
|
|
||||||
updateNotes: result.updateNotes || '',
|
|
||||||
hasUpdate: true
|
|
||||||
}
|
|
||||||
hasNewVersion.value = true
|
|
||||||
|
|
||||||
const skippedVersion = localStorage.getItem(SKIP_VERSION_KEY)
|
if (getSettings(getUsernameFromToken()).autoUpdate) {
|
||||||
if (skippedVersion === result.latestVersion) return
|
await downloadUpdate()
|
||||||
|
|
||||||
const remindLater = localStorage.getItem(REMIND_LATER_KEY)
|
|
||||||
if (remindLater && Date.now() < parseInt(remindLater)) return
|
|
||||||
|
|
||||||
const settings = getSettings()
|
|
||||||
if (settings.autoUpdate) {
|
|
||||||
await startAutoDownload()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,95 +158,40 @@ async function autoCheck(silent = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleVersionClick() {
|
async function checkForUpdatesNow() {
|
||||||
if (stage.value === 'downloading' || stage.value === 'completed') {
|
if (stage.value === 'downloading' || stage.value === 'completed') {
|
||||||
show.value = true
|
show.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
await checkUpdate(false)
|
||||||
if (hasNewVersion.value) {
|
|
||||||
stage.value = 'check'
|
|
||||||
show.value = true
|
|
||||||
} else {
|
|
||||||
checkForUpdatesNow()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 立即检查更新(供外部调用)
|
|
||||||
async function checkForUpdatesNow() {
|
|
||||||
await autoCheck(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function skipVersion() {
|
function skipVersion() {
|
||||||
localStorage.setItem(SKIP_VERSION_KEY, info.value.latestVersion)
|
localStorage.setItem('skipped_version', info.value.latestVersion)
|
||||||
show.value = false
|
show.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function remindLater() {
|
function remindLater() {
|
||||||
// 24小时后再提醒
|
localStorage.setItem('remind_later_time', (Date.now() + 24 * 60 * 60 * 1000).toString())
|
||||||
localStorage.setItem(REMIND_LATER_KEY, (Date.now() + 24 * 60 * 60 * 1000).toString())
|
|
||||||
show.value = false
|
show.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
// 如果已经在下载或已完成,不重复执行
|
if (stage.value !== 'check') {
|
||||||
if (stage.value === 'downloading') {
|
|
||||||
show.value = true
|
show.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
await downloadUpdate(true)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startAutoDownload() {
|
async function downloadUpdate(showDialog = false) {
|
||||||
if (!info.value.asarUrl && !info.value.jarUrl) return
|
if (!info.value.asarUrl && !info.value.jarUrl) {
|
||||||
|
if (showDialog) ElMessage.error('下载链接不可用')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
stage.value = 'downloading'
|
stage.value = 'downloading'
|
||||||
|
if (showDialog) show.value = true
|
||||||
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
|
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
|
||||||
|
|
||||||
;(window as any).electronAPI.onDownloadProgress((progress: any) => {
|
;(window as any).electronAPI.onDownloadProgress((progress: any) => {
|
||||||
@@ -282,54 +205,45 @@ async function startAutoDownload() {
|
|||||||
try {
|
try {
|
||||||
const response = await (window as any).electronAPI.downloadUpdate({
|
const response = await (window as any).electronAPI.downloadUpdate({
|
||||||
asarUrl: info.value.asarUrl,
|
asarUrl: info.value.asarUrl,
|
||||||
jarUrl: info.value.jarUrl
|
jarUrl: info.value.jarUrl,
|
||||||
|
latestVersion: info.value.latestVersion
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
stage.value = 'completed'
|
stage.value = 'completed'
|
||||||
prog.value.percentage = 100
|
prog.value.percentage = 100
|
||||||
show.value = true
|
show.value = true
|
||||||
ElMessage.success('更新已下载完成,可以安装了')
|
ElMessage.success(showDialog ? '下载完成' : '更新已下载完成,可以安装了')
|
||||||
} else {
|
} else {
|
||||||
stage.value = 'check'
|
stage.value = 'check'
|
||||||
|
if (showDialog) ElMessage.error('下载失败: ' + (response.error || '未知错误'))
|
||||||
;(window as any).electronAPI.removeDownloadProgressListener()
|
;(window as any).electronAPI.removeDownloadProgressListener()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
stage.value = 'check'
|
stage.value = 'check'
|
||||||
|
if (showDialog) ElMessage.error('下载失败')
|
||||||
;(window as any).electronAPI.removeDownloadProgressListener()
|
;(window as any).electronAPI.removeDownloadProgressListener()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelDownload() {
|
async function cancelDownload() {
|
||||||
try {
|
;(window as any).electronAPI.removeDownloadProgressListener()
|
||||||
;(window as any).electronAPI.removeDownloadProgressListener()
|
await (window as any).electronAPI.cancelDownload().catch(() => {})
|
||||||
await (window as any).electronAPI.cancelDownload()
|
|
||||||
|
stage.value = 'check'
|
||||||
stage.value = 'check'
|
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
|
||||||
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
|
show.value = false
|
||||||
hasNewVersion.value = false
|
ElMessage.info('已取消下载')
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function installUpdate() {
|
async function installUpdate() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm('安装过程中程序将自动重启,请确保已保存所有工作。确定要立即安装更新吗?', '确认安装', {
|
||||||
'安装过程中程序将自动重启,请确保已保存所有工作。确定要立即安装更新吗?',
|
confirmButtonText: '立即安装',
|
||||||
'确认安装',
|
cancelButtonText: '取消',
|
||||||
{
|
type: 'warning'
|
||||||
confirmButtonText: '立即安装',
|
})
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
const response = await (window as any).electronAPI.installUpdate()
|
const response = await (window as any).electronAPI.installUpdate()
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
ElMessage.success('应用即将重启')
|
ElMessage.success('应用即将重启')
|
||||||
@@ -342,25 +256,19 @@ async function installUpdate() {
|
|||||||
|
|
||||||
async function clearDownloadedFiles() {
|
async function clearDownloadedFiles() {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm('确定要清除已下载的更新文件吗?清除后需要重新下载。', '确认清除', {
|
||||||
'确定要清除已下载的更新文件吗?清除后需要重新下载。',
|
confirmButtonText: '确定',
|
||||||
'确认清除',
|
cancelButtonText: '取消',
|
||||||
{
|
type: 'warning'
|
||||||
confirmButtonText: '确定',
|
})
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning'
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const response = await (window as any).electronAPI.clearUpdateFiles()
|
const response = await (window as any).electronAPI.clearUpdateFiles()
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
ElMessage.success('已清除下载文件')
|
|
||||||
// 重置状态
|
|
||||||
stage.value = 'check'
|
stage.value = 'check'
|
||||||
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
|
prog.value = {percentage: 0, current: '0 MB', total: '0 MB'}
|
||||||
hasNewVersion.value = false
|
|
||||||
show.value = false
|
show.value = false
|
||||||
|
ElMessage.success('已清除下载文件')
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error('清除失败: ' + (response.error || '未知错误'))
|
ElMessage.error('清除失败: ' + (response.error || '未知错误'))
|
||||||
}
|
}
|
||||||
@@ -373,13 +281,13 @@ onMounted(async () => {
|
|||||||
version.value = await (window as any).electronAPI.getJarVersion()
|
version.value = await (window as any).electronAPI.getJarVersion()
|
||||||
const pendingUpdate = await (window as any).electronAPI.checkPendingUpdate()
|
const pendingUpdate = await (window as any).electronAPI.checkPendingUpdate()
|
||||||
|
|
||||||
if (pendingUpdate && pendingUpdate.hasPendingUpdate) {
|
if (pendingUpdate?.hasPendingUpdate) {
|
||||||
stage.value = 'completed'
|
stage.value = 'completed'
|
||||||
prog.value.percentage = 100
|
prog.value.percentage = 100
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await autoCheck(true)
|
await checkUpdate(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -390,331 +298,58 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.version-info {
|
:deep(.update-dialog .el-dialog) {border-radius: 16px; box-shadow: 0 24px 48px rgba(0, 0, 0, 0.15);}
|
||||||
position: fixed;
|
|
||||||
right: 10px;
|
|
||||||
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__title) {
|
:deep(.update-dialog .el-dialog__title) {font-size: 14px; font-weight: 500; margin-left: 8px;}
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 默认标题样式(第一阶段 - 检查阶段) */
|
/* 默认标题样式(第一阶段 - 检查阶段) */
|
||||||
:deep(.update-dialog.stage-check .el-dialog__header) {
|
:deep(.update-dialog.stage-check .el-dialog__header) {display: block; text-align: left;}
|
||||||
display: block;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 第二阶段 - 下载中,标题居中 */
|
/* 第二阶段 - 下载中,标题居中 */
|
||||||
:deep(.update-dialog.stage-downloading .el-dialog__header) {
|
:deep(.update-dialog.stage-downloading .el-dialog__header) {display: block; text-align: center;}
|
||||||
display: block;
|
:deep(.update-dialog.stage-downloading .el-dialog__title) {margin-left: 20px;}
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.update-dialog.stage-downloading .el-dialog__title) {
|
|
||||||
margin-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 第三阶段 - 下载完成,标题居中 */
|
/* 第三阶段 - 下载完成,标题居中 */
|
||||||
:deep(.update-dialog.stage-completed .el-dialog__header) {
|
:deep(.update-dialog.stage-completed .el-dialog__header) {display: block; text-align: center;}
|
||||||
display: block;
|
:deep(.update-dialog.stage-completed .el-dialog__title) {margin-left: 20px;}
|
||||||
text-align: center;
|
: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;}
|
||||||
:deep(.update-dialog.stage-completed .el-dialog__title) {
|
.left-pane {display: flex; flex-direction: column; align-items: flex-start;}
|
||||||
margin-left: 20px;
|
.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;}
|
||||||
:deep(.update-dialog .el-dialog__body) {
|
.right-pane .desc {font-size: 13px; color: #6b7280; line-height: 1.6; margin: 0; word-break: break-word;}
|
||||||
padding: 0;
|
.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-content {
|
.update-header.text-center .app-icon {margin-right: 0; margin-bottom: 16px;}
|
||||||
text-align: left;
|
.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-layout {
|
.update-details.form {max-height: none;}
|
||||||
display: grid;
|
.notes-box :deep(textarea.el-textarea__inner) {white-space: pre-wrap;}
|
||||||
grid-template-columns: 88px 1fr;
|
.update-details h4 {font-size: 14px; font-weight: 600; color: #374151; margin: 0 0 8px 0;}
|
||||||
align-items: start;
|
.update-actions.row {display: flex; flex-direction: column; align-items: stretch; gap: 12px;}
|
||||||
margin-bottom: 5px;
|
.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-pane {
|
.left-actions {display: flex; gap: 12px;}
|
||||||
display: flex;
|
.right-actions {display: flex; gap: 8px;}
|
||||||
flex-direction: column;
|
:deep(.update-buttons .el-button) {flex: 1; height: 32px; font-size: 13px; border-radius: 8px;}
|
||||||
align-items: flex-start;
|
.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;}
|
||||||
.app-icon-large {
|
.download-icon .app-icon {width: 64px; height: 64px; border-radius: 12px;}
|
||||||
width: 70px;
|
.download-content {min-width: 0;}
|
||||||
height: 70px;
|
.download-info {margin-bottom: 12px;}
|
||||||
border-radius: 12px;
|
.download-info p {font-size: 14px; font-weight: 600; color: #6b7280; margin: 0;}
|
||||||
margin: 4px 0 0 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;}
|
||||||
.right-pane {
|
.progress-details span {font-size: 12px; color: #909399;}
|
||||||
min-width: 0;
|
.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;}
|
||||||
.right-pane .announce {
|
:deep(.update-buttons .el-button--primary) {background-color: #2563eb; border-color: #2563eb; font-weight: 500;}
|
||||||
font-size: 16px;
|
:deep(.update-buttons .el-button--primary:hover) {background-color: #1d4ed8; border-color: #1d4ed8;}
|
||||||
font-weight: 600;
|
:deep(.update-buttons .el-button:not(.el-button--primary)) {background-color: #f3f4f6; border-color: #d1d5db; color: #374151; font-weight: 500;}
|
||||||
color: #1f2937;
|
:deep(.update-buttons .el-button:not(.el-button--primary):hover) {background-color: #e5e7eb; border-color: #9ca3af;}
|
||||||
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>
|
</style>
|
||||||
@@ -1,23 +1,73 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowLeft, ArrowRight, Refresh, Monitor, Setting, User } from '@element-plus/icons-vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
|
import { Refresh, Setting, Minus, CloseBold } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
canGoBack: boolean
|
canGoBack: boolean
|
||||||
canGoForward: boolean
|
canGoForward: boolean
|
||||||
activeMenu: string
|
activeMenu: string
|
||||||
|
isAuthenticated: boolean
|
||||||
|
currentUsername: string
|
||||||
|
currentVersion: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'go-back'): void
|
(e: 'go-back'): void
|
||||||
(e: 'go-forward'): void
|
(e: 'go-forward'): void
|
||||||
(e: 'reload'): void
|
(e: 'reload'): void
|
||||||
(e: 'user-click'): void
|
(e: 'logout'): void
|
||||||
(e: 'open-device'): void
|
(e: 'open-device'): void
|
||||||
(e: 'open-settings'): void
|
(e: 'open-settings'): void
|
||||||
|
(e: 'open-account-manager'): void
|
||||||
|
(e: 'check-update'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const isMaximized = ref(false)
|
||||||
|
|
||||||
|
const displayUsername = computed(() => {
|
||||||
|
return props.isAuthenticated ? props.currentUsername : '未登录'
|
||||||
|
})
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ command: 'check-update', label: computed(() => `检查更新 v${props.currentVersion}`), class: 'menu-item' },
|
||||||
|
{ command: 'account-manager', label: '我的电商账号', class: 'menu-item' },
|
||||||
|
{ command: 'device', label: '我的设备', class: 'menu-item' },
|
||||||
|
{ command: 'settings', label: '设置', class: 'menu-item' },
|
||||||
|
{ command: 'logout', label: '退出', class: 'menu-item logout-item', showIf: () => props.isAuthenticated }
|
||||||
|
]
|
||||||
|
|
||||||
|
async function handleMinimize() {
|
||||||
|
await (window as any).electronAPI.windowMinimize()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMaximize() {
|
||||||
|
await (window as any).electronAPI.windowMaximize()
|
||||||
|
isMaximized.value = await (window as any).electronAPI.windowIsMaximized()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClose() {
|
||||||
|
await (window as any).electronAPI.windowClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const commandMap: Record<string, keyof Emits> = {
|
||||||
|
logout: 'logout',
|
||||||
|
device: 'open-device',
|
||||||
|
settings: 'open-settings',
|
||||||
|
'account-manager': 'open-account-manager',
|
||||||
|
'check-update': 'check-update'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommand(command: string) {
|
||||||
|
const emitName = commandMap[command]
|
||||||
|
if (emitName) emit(emitName as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
isMaximized.value = await (window as any).electronAPI.windowIsMaximized()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -25,149 +75,109 @@ defineEmits<Emits>()
|
|||||||
<div class="navbar-left">
|
<div class="navbar-left">
|
||||||
<div class="nav-controls">
|
<div class="nav-controls">
|
||||||
<button class="nav-btn" title="后退" @click="$emit('go-back')" :disabled="!canGoBack">
|
<button class="nav-btn" title="后退" @click="$emit('go-back')" :disabled="!canGoBack">
|
||||||
<el-icon><ArrowLeft /></el-icon>
|
<svg viewBox="0 0 24 24" class="arrow-icon">
|
||||||
|
<path d="M15 18l-6-6 6-6" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" title="前进" @click="$emit('go-forward')" :disabled="!canGoForward">
|
<button class="nav-btn" title="前进" @click="$emit('go-forward')" :disabled="!canGoForward">
|
||||||
<el-icon><ArrowRight /></el-icon>
|
<svg viewBox="0 0 24 24" class="arrow-icon">
|
||||||
|
<path d="M9 18l6-6-6-6" fill="none" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button class="nav-btn-round" title="刷新" @click="$emit('reload')">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 设置下拉菜单 -->
|
||||||
|
<el-dropdown trigger="click" @command="handleCommand" placement="bottom-end">
|
||||||
|
<button class="nav-btn-round" title="设置">
|
||||||
|
<el-icon><Setting /></el-icon>
|
||||||
|
</button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu class="settings-dropdown">
|
||||||
|
<el-dropdown-item disabled class="username-item">
|
||||||
|
{{ displayUsername }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item
|
||||||
|
v-for="item in menuItems"
|
||||||
|
:key="item.command"
|
||||||
|
v-show="!item.showIf || item.showIf()"
|
||||||
|
:command="item.command"
|
||||||
|
:class="item.class">
|
||||||
|
{{ typeof item.label === 'string' ? item.label : item.label.value }}
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-center">
|
<div class="navbar-center">
|
||||||
<div class="breadcrumbs">
|
<div class="breadcrumbs">
|
||||||
<span>首页</span>
|
<span>首页</span>
|
||||||
<span class="separator">></span>
|
<span class="separator">/</span>
|
||||||
<span>{{ activeMenu }}</span>
|
<span>{{ activeMenu }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-right">
|
<div class="navbar-right">
|
||||||
<button class="nav-btn-round" title="刷新" @click="$emit('reload')">
|
<!-- 窗口控制按钮 -->
|
||||||
<el-icon><Refresh /></el-icon>
|
<div class="window-controls">
|
||||||
</button>
|
<button class="window-btn window-btn-minimize" title="最小化" @click="handleMinimize">
|
||||||
<button class="nav-btn-round" title="设备管理" @click="$emit('open-device')">
|
<el-icon><Minus /></el-icon>
|
||||||
<el-icon><Monitor /></el-icon>
|
</button>
|
||||||
</button>
|
<button class="window-btn window-btn-maximize" title="最大化" @click="handleMaximize">
|
||||||
<button class="nav-btn-round" title="设置" @click="$emit('open-settings')">
|
<svg viewBox="0 0 12 12" class="maximize-icon">
|
||||||
<el-icon><Setting /></el-icon>
|
<rect x="2" y="2" width="8" height="8" stroke="currentColor" fill="none" stroke-width="1"/>
|
||||||
</button>
|
</svg>
|
||||||
<button class="nav-btn-round" title="用户" @click="$emit('user-click')">
|
</button>
|
||||||
<el-icon><User /></el-icon>
|
<button class="window-btn window-btn-close" title="关闭" @click="handleClose">
|
||||||
</button>
|
<el-icon><CloseBold /></el-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.top-navbar {
|
.top-navbar {height: 40px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; background: #ffffff; border-bottom: 1px solid #e8eaec; box-shadow: 0 1px 3px rgba(0,0,0,0.03); -webkit-app-region: drag; user-select: none;}
|
||||||
height: 40px;
|
.navbar-left {display: flex; align-items: center; gap: 8px; flex: 0 0 auto; -webkit-app-region: no-drag;}
|
||||||
display: flex;
|
.navbar-center {display: flex; justify-content: center; flex: 1;}
|
||||||
align-items: center;
|
.navbar-right {display: flex; align-items: center; gap: 8px; flex: 0 0 auto; -webkit-app-region: no-drag;}
|
||||||
justify-content: space-between;
|
.nav-controls {display: flex; gap: 4px;}
|
||||||
padding: 0 16px;
|
.nav-btn {width: 28px; height: 28px; border: none; background: transparent; cursor: pointer; font-size: 16px; color: #606266; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; outline: none; border-radius: 4px;}
|
||||||
background: #ffffff;
|
.arrow-icon {width: 18px; height: 18px; flex-shrink: 0;}
|
||||||
border-bottom: 1px solid #e8eaec;
|
.arrow-icon path {stroke: currentColor;}
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
|
.nav-btn:hover:not(:disabled) {background: rgba(0, 0, 0, 0.05); color: #409EFF;}
|
||||||
}
|
.nav-btn:hover:not(:disabled) .arrow-icon path {stroke: #409EFF;}
|
||||||
|
|
||||||
.navbar-left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-center {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-right {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-controls {
|
|
||||||
display: flex;
|
|
||||||
border: 1px solid #dcdfe6;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn {
|
|
||||||
width: 32px;
|
|
||||||
height: 28px;
|
|
||||||
border: none;
|
|
||||||
background: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #606266;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:hover:not(:disabled) {
|
|
||||||
background: #f5f7fa;
|
|
||||||
color: #409EFF;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:focus,
|
.nav-btn:focus,
|
||||||
.nav-btn:active {
|
.nav-btn:active {outline: none; border: none;}
|
||||||
outline: none;
|
.nav-btn:disabled {cursor: not-allowed; background: transparent; color: #d0d0d0;}
|
||||||
border: none;
|
.nav-btn:disabled .arrow-icon path {stroke: #d0d0d0;}
|
||||||
}
|
.nav-btn-round {width: 28px; height: 28px; border: none; border-radius: 4px; background: transparent; cursor: pointer; font-size: 16px; color: #606266; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; outline: none;}
|
||||||
|
.nav-btn-round:hover {background: rgba(0, 0, 0, 0.05); color: #409EFF;}
|
||||||
.nav-btn:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.5;
|
|
||||||
background: #f5f5f5;
|
|
||||||
color: #c0c4cc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn:not(:last-child) {
|
|
||||||
border-right: 1px solid #dcdfe6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn-round {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 1px solid #dcdfe6;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #606266;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn-round:hover {
|
|
||||||
background: #f5f7fa;
|
|
||||||
color: #409EFF;
|
|
||||||
border-color: #c6e2ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-btn-round:focus,
|
.nav-btn-round:focus,
|
||||||
.nav-btn-round:active {
|
.nav-btn-round:active {outline: none;}
|
||||||
outline: none;
|
.breadcrumbs {display: flex; align-items: center; color: #606266; font-size: 14px;}
|
||||||
}
|
.separator {margin: 0 6px; color: #c0c4cc; font-size: 14px;}
|
||||||
|
/* 窗口控制按钮 */
|
||||||
|
.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 {
|
<style>
|
||||||
display: flex;
|
/* 设置下拉菜单样式 */
|
||||||
align-items: center;
|
.settings-dropdown {min-width: 180px !important; padding: 4px 0 !important; border-radius: 12px !important; margin-top: 4px !important;}
|
||||||
color: #606266;
|
.settings-dropdown .username-item {font-weight: 600 !important; color: #000000 !important; cursor: default !important; padding: 8px 16px !important; font-size: 14px !important;}
|
||||||
font-size: 14px;
|
.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;}
|
||||||
.separator {
|
.settings-dropdown .logout-item:hover {background: #f5f7fa !important; color: #409EFF !important;}
|
||||||
margin: 0 8px;
|
.settings-dropdown .el-dropdown-menu__item.is-disabled {cursor: default !important; opacity: 1 !important;}
|
||||||
color: #c0c4cc;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -4,6 +4,8 @@ import { ElMessage, ElMessageBox } from 'element-plus'
|
|||||||
import {rakutenApi} from '../../api/rakuten'
|
import {rakutenApi} from '../../api/rakuten'
|
||||||
import { batchConvertImages } from '../../utils/imageProxy'
|
import { batchConvertImages } from '../../utils/imageProxy'
|
||||||
import { handlePlatformFileExport } from '../../utils/settings'
|
import { handlePlatformFileExport } from '../../utils/settings'
|
||||||
|
import { getUsernameFromToken } from '../../utils/token'
|
||||||
|
import { useFileDrop } from '../../composables/useFileDrop'
|
||||||
|
|
||||||
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
|
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
|
||||||
|
|
||||||
@@ -20,12 +22,12 @@ const tableLoading = ref(false)
|
|||||||
const exportLoading = ref(false)
|
const exportLoading = ref(false)
|
||||||
const statusMessage = ref('')
|
const statusMessage = ref('')
|
||||||
const statusType = ref<'info' | 'success' | 'warning' | 'error'>('info')
|
const statusType = ref<'info' | 'success' | 'warning' | 'error'>('info')
|
||||||
|
let abortController: AbortController | null = null
|
||||||
|
|
||||||
// 查询与上传
|
// 查询与上传
|
||||||
const singleShopName = ref('')
|
const singleShopName = ref('')
|
||||||
const currentBatchId = ref('')
|
const currentBatchId = ref('')
|
||||||
const uploadInputRef = ref<HTMLInputElement | null>(null)
|
const uploadInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const dragActive = ref(false)
|
|
||||||
|
|
||||||
// 数据与分页
|
// 数据与分页
|
||||||
const allProducts = ref<any[]>([])
|
const allProducts = ref<any[]>([])
|
||||||
@@ -55,9 +57,9 @@ const activeStep = computed(() => {
|
|||||||
|
|
||||||
// 试用期过期弹框
|
// 试用期过期弹框
|
||||||
const showTrialExpiredDialog = ref(false)
|
const showTrialExpiredDialog = ref(false)
|
||||||
const trialExpiredType = ref<'device' | 'account' | 'both'>('account')
|
const trialExpiredType = ref<'device' | 'account' | 'both' | 'subscribe'>('account')
|
||||||
|
|
||||||
const checkExpiredType = inject<() => 'device' | 'account' | 'both'>('checkExpiredType')
|
const vipStatus = inject<any>('vipStatus')
|
||||||
|
|
||||||
// 左侧:上传文件名与地区
|
// 左侧:上传文件名与地区
|
||||||
const selectedFileName = ref('')
|
const selectedFileName = ref('')
|
||||||
@@ -134,17 +136,28 @@ async function loadLatest() {
|
|||||||
allProducts.value = products.map((p: any) => ({...p, skuPrices: parseSkuPrices(p)}))
|
allProducts.value = products.map((p: any) => ({...p, skuPrices: parseSkuPrices(p)}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasValid1688Data(data: any) {
|
||||||
|
if (!data) return false
|
||||||
|
const skuJson = data.skuPriceJson || data.skuPrice
|
||||||
|
const prices = parseSkuPrices({ skuPriceJson: skuJson })
|
||||||
|
if (!data.mapRecognitionLink) return false
|
||||||
|
if (!Array.isArray(prices) || !prices.length) return false
|
||||||
|
if (!data.freight || data.freight <= 0) return false
|
||||||
|
if (!data.median || data.median <= 0) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async function searchProductInternal(product: any) {
|
async function searchProductInternal(product: any) {
|
||||||
if (!product || !product.imgUrl) return
|
if (!product || !product.imgUrl) return false
|
||||||
if (!needsSearch(product)) return
|
if (!needsSearch(product)) return true
|
||||||
if (!props.isVip) {
|
if (!props.isVip) {
|
||||||
if (checkExpiredType) trialExpiredType.value = checkExpiredType()
|
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
|
||||||
showTrialExpiredDialog.value = true
|
showTrialExpiredDialog.value = true
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
const res: any = await rakutenApi.search1688(product.imgUrl, currentBatchId.value, abortController?.signal)
|
||||||
const res: any = await rakutenApi.search1688(product.imgUrl, currentBatchId.value)
|
|
||||||
const data = res.data
|
const data = res.data
|
||||||
|
if (!hasValid1688Data(data)) return false
|
||||||
const skuJson = data.skuPriceJson || data.skuPrice
|
const skuJson = data.skuPriceJson || data.skuPrice
|
||||||
Object.assign(product, {
|
Object.assign(product, {
|
||||||
mapRecognitionLink: data.mapRecognitionLink,
|
mapRecognitionLink: data.mapRecognitionLink,
|
||||||
@@ -157,6 +170,21 @@ async function searchProductInternal(product: any) {
|
|||||||
image1688Url: data.mapRecognitionLink,
|
image1688Url: data.mapRecognitionLink,
|
||||||
detailUrl1688: data.mapRecognitionLink,
|
detailUrl1688: data.mapRecognitionLink,
|
||||||
})
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchProductWithRetry(product: any, maxRetry = 2) {
|
||||||
|
for (let attempt = 1; attempt <= maxRetry; attempt++) {
|
||||||
|
try {
|
||||||
|
const ok = await searchProductInternal(product)
|
||||||
|
if (ok) return true
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name === 'AbortError') return false
|
||||||
|
console.warn('search1688 failed', e)
|
||||||
|
}
|
||||||
|
if (attempt < maxRetry) await delay(600)
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function beforeUpload(file: File) {
|
function beforeUpload(file: File) {
|
||||||
@@ -196,15 +224,12 @@ async function handleExcelUpload(e: Event) {
|
|||||||
input.value = ''
|
input.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragOver(e: DragEvent) { e.preventDefault(); dragActive.value = true }
|
// 拖拽上传
|
||||||
function onDragLeave() { dragActive.value = false }
|
const { dragActive, onDragEnter, onDragOver, onDragLeave, onDrop } = useFileDrop({
|
||||||
async function onDrop(e: DragEvent) {
|
accept: /\.xlsx?$/i,
|
||||||
e.preventDefault()
|
onFile: processFile,
|
||||||
dragActive.value = false
|
onError: (msg) => ElMessage({ message: msg, type: 'warning' })
|
||||||
const file = e.dataTransfer?.files?.[0]
|
})
|
||||||
if (!file) return
|
|
||||||
await processFile(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 点击"获取数据
|
// 点击"获取数据
|
||||||
@@ -214,11 +239,13 @@ async function handleStartSearch() {
|
|||||||
|
|
||||||
// VIP检查
|
// VIP检查
|
||||||
if (!props.isVip) {
|
if (!props.isVip) {
|
||||||
if (checkExpiredType) trialExpiredType.value = checkExpiredType()
|
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
|
||||||
showTrialExpiredDialog.value = true
|
showTrialExpiredDialog.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abortController = new AbortController()
|
||||||
|
|
||||||
if (pendingFile.value) {
|
if (pendingFile.value) {
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -230,7 +257,7 @@ async function handleStartSearch() {
|
|||||||
progressPercentage.value = 0
|
progressPercentage.value = 0
|
||||||
totalProducts.value = 0
|
totalProducts.value = 0
|
||||||
processedProducts.value = 0
|
processedProducts.value = 0
|
||||||
const resp: any = await rakutenApi.getProducts({file: pendingFile.value, batchId: currentBatchId.value})
|
const resp: any = await rakutenApi.getProducts({file: pendingFile.value, batchId: currentBatchId.value}, abortController?.signal)
|
||||||
const products = (resp.data.products || []).map((p: any) => ({...p, skuPrices: parseSkuPrices(p)}))
|
const products = (resp.data.products || []).map((p: any) => ({...p, skuPrices: parseSkuPrices(p)}))
|
||||||
|
|
||||||
if (products.length === 0) {
|
if (products.length === 0) {
|
||||||
@@ -238,10 +265,11 @@ async function handleStartSearch() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
allProducts.value = products
|
allProducts.value = products
|
||||||
pendingFile.value = null
|
} catch (e: any) {
|
||||||
} catch (e) {
|
if (e.name !== 'AbortError') {
|
||||||
statusType.value = 'error'
|
statusType.value = 'error'
|
||||||
statusMessage.value = '解析失败,请重试'
|
statusMessage.value = '解析失败,请重试'
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
tableLoading.value = false
|
tableLoading.value = false
|
||||||
@@ -255,17 +283,21 @@ async function handleStartSearch() {
|
|||||||
progressPercentage.value = 100
|
progressPercentage.value = 100
|
||||||
statusType.value = 'success'
|
statusType.value = 'success'
|
||||||
statusMessage.value = ''
|
statusMessage.value = ''
|
||||||
|
abortController = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
statusType.value = 'warning'
|
statusType.value = 'warning'
|
||||||
statusMessage.value = '没有可处理的商品,请先导入或查询店铺'
|
statusMessage.value = '没有可处理的商品,请先导入或查询店铺'
|
||||||
|
abortController = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await startBatch1688Search(items)
|
await startBatch1688Search(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopTask() {
|
function stopTask() {
|
||||||
|
abortController?.abort()
|
||||||
|
abortController = null
|
||||||
loading.value = false
|
loading.value = false
|
||||||
tableLoading.value = false
|
tableLoading.value = false
|
||||||
statusType.value = 'warning'
|
statusType.value = 'warning'
|
||||||
@@ -280,6 +312,7 @@ async function startBatch1688Search(products: any[]) {
|
|||||||
progressPercentage.value = 100
|
progressPercentage.value = 100
|
||||||
statusType.value = 'success'
|
statusType.value = 'success'
|
||||||
statusMessage.value = '所有商品都已获取1688数据!'
|
statusMessage.value = '所有商品都已获取1688数据!'
|
||||||
|
abortController = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
@@ -298,6 +331,7 @@ async function startBatch1688Search(products: any[]) {
|
|||||||
statusMessage.value = ''
|
statusMessage.value = ''
|
||||||
}
|
}
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
abortController = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function serialSearch1688(products: any[]) {
|
async function serialSearch1688(products: any[]) {
|
||||||
@@ -305,7 +339,7 @@ async function serialSearch1688(products: any[]) {
|
|||||||
const product = products[i]
|
const product = products[i]
|
||||||
product.searching1688 = true
|
product.searching1688 = true
|
||||||
await nextTickSafe()
|
await nextTickSafe()
|
||||||
await searchProductInternal(product)
|
await searchProductWithRetry(product)
|
||||||
product.searching1688 = false
|
product.searching1688 = false
|
||||||
processedProducts.value++
|
processedProducts.value++
|
||||||
progressPercentage.value = Math.floor((processedProducts.value / Math.max(1, totalProducts.value)) * 100)
|
progressPercentage.value = Math.floor((processedProducts.value / Math.max(1, totalProducts.value)) * 100)
|
||||||
@@ -321,7 +355,6 @@ function delay(ms: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nextTickSafe() {
|
function nextTickSafe() {
|
||||||
// 不额外引入 nextTick,使用微任务刷新即可,保持体积精简
|
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +363,14 @@ function showMessage(message: string, type: 'info' | 'success' | 'warning' | 'er
|
|||||||
ElMessage({ message, type })
|
ElMessage({ message, type })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeSelectedFile() {
|
||||||
|
selectedFileName.value = ''
|
||||||
|
pendingFile.value = null
|
||||||
|
if (uploadInputRef.value) {
|
||||||
|
uploadInputRef.value.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function exportToExcel() {
|
async function exportToExcel() {
|
||||||
if (!allProducts.value.length) {
|
if (!allProducts.value.length) {
|
||||||
showMessage('没有数据可供导出', 'warning')
|
showMessage('没有数据可供导出', 'warning')
|
||||||
@@ -387,12 +428,10 @@ async function exportToExcel() {
|
|||||||
base64: base64Data,
|
base64: base64Data,
|
||||||
extension: 'jpeg',
|
extension: 'jpeg',
|
||||||
})
|
})
|
||||||
|
|
||||||
worksheet.addImage(imageId, {
|
worksheet.addImage(imageId, {
|
||||||
tl: { col: 1, row: row.number - 1 },
|
tl: { col: 1, row: row.number - 1 },
|
||||||
ext: { width: 60, height: 60 }
|
ext: { width: 60, height: 60 }
|
||||||
})
|
})
|
||||||
|
|
||||||
row.height = 50
|
row.height = 50
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,9 +442,8 @@ async function exportToExcel() {
|
|||||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
})
|
})
|
||||||
const fileName = `乐天商品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
|
const fileName = `乐天商品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||||
|
const username = getUsernameFromToken()
|
||||||
const success = await handlePlatformFileExport('rakuten', blob, fileName)
|
const success = await handlePlatformFileExport('rakuten', blob, fileName, username)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
showMessage('Excel文件导出成功!', 'success')
|
showMessage('Excel文件导出成功!', 'success')
|
||||||
}
|
}
|
||||||
@@ -421,7 +459,6 @@ onMounted(loadLatest)
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="rakuten-root">
|
<div class="rakuten-root">
|
||||||
|
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="body-layout">
|
<div class="body-layout">
|
||||||
<!-- 左侧步骤栏 -->
|
<!-- 左侧步骤栏 -->
|
||||||
@@ -443,7 +480,7 @@ onMounted(loadLatest)
|
|||||||
<a class="link" @click.prevent="downloadRakutenTemplate">点击下载模板</a>
|
<a class="link" @click.prevent="downloadRakutenTemplate">点击下载模板</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dropzone" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openRakutenUpload" :class="{ disabled: loading }">
|
<div class="dropzone" :class="{ disabled: loading, active: dragActive }" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop" @click="openRakutenUpload">
|
||||||
<div class="dz-el-icon">📤</div>
|
<div class="dz-el-icon">📤</div>
|
||||||
<div class="dz-text">点击或将文件拖拽到这里上传</div>
|
<div class="dz-text">点击或将文件拖拽到这里上传</div>
|
||||||
<div class="dz-sub">支持 .xls .xlsx</div>
|
<div class="dz-sub">支持 .xls .xlsx</div>
|
||||||
@@ -452,6 +489,7 @@ onMounted(loadLatest)
|
|||||||
<div v-if="selectedFileName" class="file-chip">
|
<div v-if="selectedFileName" class="file-chip">
|
||||||
<span class="dot"></span>
|
<span class="dot"></span>
|
||||||
<span class="name">{{ selectedFileName }}</span>
|
<span class="name">{{ selectedFileName }}</span>
|
||||||
|
<span class="delete-btn" @click="removeSelectedFile" title="删除文件">🗑️</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -462,7 +500,7 @@ onMounted(loadLatest)
|
|||||||
<div class="step-header">
|
<div class="step-header">
|
||||||
<div class="title">网站地区</div>
|
<div class="title">网站地区</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="desc">请选择目标网站地区,如:日本区。</div>
|
<div class="desc">仅支持乐天市场日本区商品查询,后续将开放更多乐天网站地区,敬请期待。</div>
|
||||||
<el-select v-model="region" placeholder="选择地区" size="small" style="width: 100%" disabled>
|
<el-select v-model="region" placeholder="选择地区" size="small" style="width: 100%" disabled>
|
||||||
<el-option v-for="opt in regionOptions" :key="opt.value" :label="opt.label" :value="opt.value">
|
<el-option v-for="opt in regionOptions" :key="opt.value" :label="opt.label" :value="opt.value">
|
||||||
<span style="margin-right:6px">{{ opt.flag }}</span>{{ opt.label }}
|
<span style="margin-right:6px">{{ opt.flag }}</span>{{ opt.label }}
|
||||||
@@ -495,12 +533,8 @@ onMounted(loadLatest)
|
|||||||
</div>
|
</div>
|
||||||
<div class="desc">点击下方按钮导出所有商品数据到 Excel 文件</div>
|
<div class="desc">点击下方按钮导出所有商品数据到 Excel 文件</div>
|
||||||
<el-button size="small" class="w100 btn-blue" :disabled="!allProducts.length || loading || exportLoading" :loading="exportLoading" @click="exportToExcel">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
|
<el-button size="small" class="w100 btn-blue" :disabled="!allProducts.length || loading || exportLoading" :loading="exportLoading" @click="exportToExcel">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
|
||||||
<!-- 导出进度条 -->
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -560,7 +594,7 @@ onMounted(loadLatest)
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="image-container" v-if="row.imgUrl">
|
<div class="image-container" v-if="row.imgUrl">
|
||||||
<img :src="row.imgUrl" class="thumb" alt="thumb"/>
|
<el-image :src="row.imgUrl" class="thumb" fit="contain" :preview-src-list="[row.imgUrl]" />
|
||||||
</div>
|
</div>
|
||||||
<span v-else>无图片</span>
|
<span v-else>无图片</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -596,9 +630,8 @@ onMounted(loadLatest)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pagination-fixed" >
|
<div class="pagination-fixed">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
background
|
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:page-sizes="[15,30,50,100]"
|
:page-sizes="[15,30,50,100]"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
@@ -616,227 +649,101 @@ onMounted(loadLatest)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.rakuten-root {
|
.rakuten-root {position: absolute; inset: 0; background: #fff; box-sizing: border-box;}
|
||||||
position: absolute;
|
.main-container {height: 100%; display: flex; flex-direction: column; padding: 12px; box-sizing: border-box;}
|
||||||
inset: 0;
|
.body-layout {display: flex; gap: 12px; height: 100%;}
|
||||||
background: #f5f5f5;
|
.steps-sidebar {width: 220px; background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 10px; height: 100%; flex-shrink: 0;}
|
||||||
padding: 12px;
|
.steps-title {font-size: 14px; font-weight: 600; color: #303133; text-align: left;}
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-container {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 15px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body-layout { display: flex; gap: 12px; height: 100%; }
|
|
||||||
.steps-sidebar { width: 220px; background: #fff; border: 1px solid #ebeef5; border-radius: 6px; padding: 10px; height: 100%; flex-shrink: 0; }
|
|
||||||
.steps-title { font-size: 14px; font-weight: 600; color: #303133; text-align: left; }
|
|
||||||
|
|
||||||
/* 卡片式步骤,与示例一致 */
|
/* 卡片式步骤,与示例一致 */
|
||||||
.steps-flow { position: relative; }
|
.steps-flow {position: relative;}
|
||||||
.steps-flow:before { content: ''; position: absolute; left: 11px; top: 20px; bottom: 0; width: 1px; background: rgba(229, 231, 235, 0.6); }
|
.steps-flow:before {content: ''; position: absolute; left: 11px; top: 20px; bottom: 0; width: 1px; background: rgba(229, 231, 235, 0.6);}
|
||||||
.flow-item { position: relative; display: grid; grid-template-columns: 22px 1fr; gap: 10px; padding: 8px 0; }
|
.flow-item {position: relative; display: grid; grid-template-columns: 22px 1fr; gap: 10px; padding: 8px 0;}
|
||||||
.flow-item + .flow-item { border-top: 1px dashed #ebeef5; }
|
.flow-item .step-index {position: static; width: 22px; height: 22px; line-height: 22px; text-align: center; border-radius: 50%; background: #1677FF; color: #fff; font-size: 12px; font-weight: 600; margin-top: 2px;}
|
||||||
.flow-item .step-index { position: static; width: 22px; height: 22px; line-height: 22px; text-align: center; border-radius: 50%; background: #1677FF; color: #fff; font-size: 12px; font-weight: 600; margin-top: 2px; }
|
.flow-item:after {display: none;}
|
||||||
.flow-item:after { display: none; }
|
.step-card {border: none; border-radius: 0; padding: 0; background: transparent; min-width: 0;}
|
||||||
.step-card { border: none; border-radius: 0; padding: 0; background: transparent; }
|
.step-header {display: flex; align-items: center; gap: 8px; margin-bottom: 6px;}
|
||||||
.step-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
.title {font-size: 13px; font-weight: 600; color: #303133; text-align: left;}
|
||||||
.title { font-size: 13px; font-weight: 600; color: #303133; text-align: left; }
|
.desc {font-size: 12px; color: #909399; margin-bottom: 8px; text-align: left;}
|
||||||
.desc { font-size: 12px; color: #909399; margin-bottom: 8px; text-align: left; }
|
.mini-hint {font-size: 12px; color: #909399; margin-top: 8px; text-align: left;}
|
||||||
.mini-hint { font-size: 12px; color: #909399; margin-top: 8px; text-align: left; }
|
.links {display: flex; align-items: center; gap: 6px; margin-bottom: 8px;}
|
||||||
.links { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
|
.link {color: #409EFF; cursor: pointer; font-size: 12px;}
|
||||||
.link { color: #409EFF; cursor: pointer; font-size: 12px; }
|
.sep {color: #dcdfe6;}
|
||||||
.sep { color: #dcdfe6; }
|
.content-panel {flex: 1; display: flex; flex-direction: column; min-width: 0;}
|
||||||
|
.left-controls {margin-top: 10px; display: flex; flex-direction: column; gap: 10px;}
|
||||||
.content-panel { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
.dropzone {border: 1px dashed #c0c4cc; border-radius: 6px; padding: 12px; text-align: center; cursor: pointer; background: #fafafa;}
|
||||||
|
.dropzone:hover {background: #f6fbff; border-color: #409EFF;}
|
||||||
.left-controls { margin-top: 10px; display: flex; flex-direction: column; gap: 10px; }
|
.dropzone.disabled {opacity: .6; cursor: not-allowed;}
|
||||||
.dropzone { border: 1px dashed #c0c4cc; border-radius: 6px; padding: 12px; text-align: center; cursor: pointer; background: #fafafa; }
|
.dz-el-icon {font-size: 18px; margin-bottom: 4px; color: #909399;}
|
||||||
.dropzone:hover { background: #f6fbff; border-color: #409EFF; }
|
.dz-text {color: #303133; font-size: 13px;}
|
||||||
.dropzone.disabled { opacity: .6; cursor: not-allowed; }
|
.dz-sub {color: #909399; font-size: 12px;}
|
||||||
.dz-el-icon { font-size: 18px; margin-bottom: 4px; color: #909399; }
|
.single-input.left {display: flex; gap: 8px;}
|
||||||
.dz-text { color: #303133; font-size: 13px; }
|
.action-buttons.column {display: flex; flex-direction: column; gap: 8px;}
|
||||||
.dz-sub { color: #909399; font-size: 12px; }
|
.file-chip {display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; color: #606266; margin-top: 6px; width: 100%; box-sizing: border-box;}
|
||||||
.single-input.left { display: flex; gap: 8px; }
|
.file-chip .dot {width: 6px; height: 6px; background: #409EFF; border-radius: 50%; flex-shrink: 0;}
|
||||||
.action-buttons.column { display: flex; flex-direction: column; gap: 8px; }
|
.file-chip .name {flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
|
||||||
|
.file-chip .delete-btn {cursor: pointer; opacity: 0.6; flex-shrink: 0;}
|
||||||
.file-chip { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: #f5f7fa; border-radius: 4px; font-size: 12px; color: #606266; margin-top: 6px; }
|
.file-chip .delete-btn:hover {opacity: 1;}
|
||||||
.file-chip .dot { width: 6px; height: 6px; background: #409EFF; border-radius: 50%; display: inline-block; }
|
.progress-section.left {margin-top: 10px;}
|
||||||
.file-chip .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.full {width: 100%;}
|
||||||
|
.form-row {margin-bottom: 10px;}
|
||||||
.progress-section.left { margin-top: 10px; }
|
.label {display: block; font-size: 12px; color: #606266; margin-bottom: 6px;}
|
||||||
.full { width: 100%; }
|
|
||||||
.form-row { margin-bottom: 10px; }
|
|
||||||
.label { display: block; font-size: 12px; color: #606266; margin-bottom: 6px; }
|
|
||||||
|
|
||||||
/* 统一左侧控件宽度与主色 */
|
/* 统一左侧控件宽度与主色 */
|
||||||
.steps-sidebar :deep(.el-date-editor),
|
.steps-sidebar :deep(.el-date-editor),
|
||||||
.steps-sidebar :deep(.el-range-editor.el-input__wrapper),
|
.steps-sidebar :deep(.el-range-editor.el-input__wrapper),
|
||||||
.steps-sidebar :deep(.el-input),
|
.steps-sidebar :deep(.el-input),
|
||||||
.steps-sidebar :deep(.el-input__wrapper),
|
.steps-sidebar :deep(.el-input__wrapper),
|
||||||
.steps-sidebar :deep(.el-select) { width: 100%; box-sizing: border-box; }
|
.steps-sidebar :deep(.el-select) {width: 100%; box-sizing: border-box;}
|
||||||
.btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; }
|
.btn-blue {background: #1677FF; border-color: #1677FF; color: #fff;}
|
||||||
.btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; }
|
.btn-blue:disabled {background: #a6c8ff; border-color: #a6c8ff; color: #fff;}
|
||||||
.w100 { width: 100%; }
|
.w100 {width: 100%;}
|
||||||
.steps-sidebar :deep(.el-button + .el-button) { margin-left: 0; }
|
.steps-sidebar :deep(.el-button + .el-button) {margin-left: 0;}
|
||||||
.progress-section { margin: 0px 12px 0px 12px; }
|
.progress-section {margin: 0px 12px 0px 12px;}
|
||||||
.progress-box { padding: 4px 0; }
|
.progress-box {padding: 4px 0;}
|
||||||
.progress-container { display: flex; align-items: center; gap: 8px; }
|
.progress-container {display: flex; align-items: center; gap: 8px;}
|
||||||
.progress-bar { flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden; }
|
.progress-bar {flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden;}
|
||||||
.progress-fill { height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease; }
|
.progress-fill {height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease;}
|
||||||
.progress-text { font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right; }
|
.progress-text {font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right;}
|
||||||
|
.current-status {font-size: 12px; color: #606266; padding-left: 2px;}
|
||||||
.current-status {
|
.export-progress {display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px;}
|
||||||
font-size: 12px;
|
.export-progress-bar {flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden;}
|
||||||
color: #606266;
|
.export-progress-fill {height: 100%; background: #1677FF; border-radius: 2px; transition: width 0.3s ease;}
|
||||||
padding-left: 2px;
|
.export-progress-text {font-size: 11px; color: #1677FF; font-weight: 500; min-width: 32px; text-align: right;}
|
||||||
}
|
.table-container {display: flex; flex-direction: column; flex: 1; min-height: 400px; overflow: hidden;}
|
||||||
.export-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px; }
|
.empty-section {flex: 1; display: flex; justify-content: center; align-items: center; background: #fff; border: 1px solid #ebeef5; border-radius: 6px;}
|
||||||
.export-progress-bar { flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden; }
|
.empty-container {text-align: center;}
|
||||||
.export-progress-fill { height: 100%; background: #1677FF; border-radius: 2px; transition: width 0.3s ease; }
|
.empty-icon {font-size: 48px; margin-bottom: 16px; opacity: 0.6;}
|
||||||
.export-progress-text { font-size: 11px; color: #1677FF; font-weight: 500; min-width: 32px; text-align: right; }
|
.empty-text {font-size: 14px; color: #909399;}
|
||||||
|
.table-section {flex: 1; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column;}
|
||||||
.table-container {
|
.table-wrapper {flex: 1; overflow: auto;}
|
||||||
display: flex;
|
.table-wrapper {scrollbar-width: thin; scrollbar-color: #c0c4cc transparent;}
|
||||||
flex-direction: column;
|
.table-wrapper::-webkit-scrollbar {width: 6px; height: 6px;}
|
||||||
flex: 1;
|
.table-wrapper::-webkit-scrollbar-track {background: transparent;}
|
||||||
min-height: 400px;
|
.table-wrapper::-webkit-scrollbar-thumb {background: #c0c4cc; border-radius: 3px;}
|
||||||
overflow: hidden;
|
.table-wrapper:hover::-webkit-scrollbar-thumb {background: #a8abb2;}
|
||||||
}
|
.table {width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px;}
|
||||||
|
.table th {background: #f5f7fa; color: #909399; font-weight: 600; padding: 8px 6px; border-bottom: 2px solid #ebeef5; text-align: left; font-size: 12px; white-space: nowrap;}
|
||||||
.empty-section {
|
.table td {padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle;}
|
||||||
flex: 1;
|
.table tbody tr:hover {background: #f9f9f9;}
|
||||||
display: flex;
|
.truncate {max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}
|
||||||
justify-content: center;
|
.shop-col {max-width: 160px;}
|
||||||
align-items: center;
|
.url-col {max-width: 220px;}
|
||||||
background: #fff;
|
.empty-tip {text-align: center; color: #909399; padding: 16px 0;}
|
||||||
border: 1px solid #ebeef5;
|
.empty-container {text-align: center;}
|
||||||
border-radius: 6px;
|
.empty-icon {font-size: 48px; margin-bottom: 12px; opacity: 0.6;}
|
||||||
}
|
.empty-text {font-size: 14px; color: #909399;}
|
||||||
|
.import-section.drag-active {border: 1px dashed #409EFF; border-radius: 6px;}
|
||||||
.empty-container {
|
.empty-abs {position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; pointer-events: none;}
|
||||||
text-align: center;
|
.image-container {display: flex; justify-content: center; align-items: center; width: 40px; height: 40px; margin: 0 auto; background: #f8f9fa; border-radius: 2px;}
|
||||||
}
|
.thumb {width: 32px; height: 32px; object-fit: contain; border-radius: 2px;}
|
||||||
|
.table-loading {position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95); display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 14px; color: #606266; pointer-events: none;}
|
||||||
.empty-icon {
|
.spinner {font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px;}
|
||||||
font-size: 48px;
|
@keyframes spin {0% {
|
||||||
margin-bottom: 16px;
|
transform: rotate(0deg);}
|
||||||
opacity: 0.6;
|
100% {transform: rotate(360deg);}
|
||||||
}
|
|
||||||
|
|
||||||
.empty-text {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #909399;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-section { flex: 1; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column; }
|
|
||||||
.table-wrapper { flex: 1; overflow: auto; }
|
|
||||||
.table-wrapper { scrollbar-width: thin; scrollbar-color: #c0c4cc transparent; }
|
|
||||||
.table-wrapper::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
||||||
.table-wrapper::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.table-wrapper::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 3px; }
|
|
||||||
.table-wrapper:hover::-webkit-scrollbar-thumb { background: #a8abb2; }
|
|
||||||
.table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
background: #f5f7fa;
|
|
||||||
color: #909399;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 8px 6px;
|
|
||||||
border-bottom: 2px solid #ebeef5;
|
|
||||||
text-align: left;
|
|
||||||
font-size: 12px;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
padding: 10px 8px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tbody tr:hover {
|
|
||||||
background: #f9f9f9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.truncate {
|
|
||||||
max-width: 260px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.shop-col { max-width: 160px; }
|
|
||||||
.url-col { max-width: 220px; }
|
|
||||||
.empty-tip { text-align: center; color: #909399; padding: 16px 0; }
|
|
||||||
.empty-container { text-align: center; }
|
|
||||||
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.6; }
|
|
||||||
.empty-text { font-size: 14px; color: #909399; }
|
|
||||||
.import-section.drag-active { border: 1px dashed #409EFF; border-radius: 6px; }
|
|
||||||
.empty-abs { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
|
||||||
|
|
||||||
.image-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thumb {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
object-fit: contain;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-loading {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #606266;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
|
||||||
font-size: 24px;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
0% {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-fixed {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: #f9f9f9;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
border-top: 1px solid #ebeef5;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-fixed {flex-shrink: 0; padding: 8px 12px 0 12px; background: #fff; display: flex; justify-content: flex-end;}
|
||||||
|
.pagination-fixed :deep(.el-pager li.is-active) {border: 1px solid #1677FF; border-radius: 4px; color: #1677FF; background: #fff;}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|||||||
@@ -6,26 +6,19 @@ import AccountManager from '../common/AccountManager.vue'
|
|||||||
import { batchConvertImages } from '../../utils/imageProxy'
|
import { batchConvertImages } from '../../utils/imageProxy'
|
||||||
import { handlePlatformFileExport } from '../../utils/settings'
|
import { handlePlatformFileExport } from '../../utils/settings'
|
||||||
import { getUsernameFromToken } from '../../utils/token'
|
import { getUsernameFromToken } from '../../utils/token'
|
||||||
|
|
||||||
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
|
const TrialExpiredDialog = defineAsyncComponent(() => import('../common/TrialExpiredDialog.vue'))
|
||||||
|
|
||||||
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
|
const refreshVipStatus = inject<() => Promise<boolean>>('refreshVipStatus')
|
||||||
|
|
||||||
// 接收VIP状态
|
// 接收VIP状态
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isVip: boolean
|
isVip: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
type Shop = { id: string; shopName: string }
|
type Shop = { id: string; shopName: string }
|
||||||
|
|
||||||
const accounts = ref<BanmaAccount[]>([])
|
const accounts = ref<BanmaAccount[]>([])
|
||||||
const accountId = ref<number>()
|
const accountId = ref<number>()
|
||||||
// 收起功能移除
|
// 收起功能移除
|
||||||
|
|
||||||
const shopList = ref<Shop[]>([])
|
const shopList = ref<Shop[]>([])
|
||||||
const selectedShops = ref<string[]>([])
|
const selectedShops = ref<string[]>([])
|
||||||
const dateRange = ref<string[]>([])
|
const dateRange = ref<string[]>([])
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const exportLoading = ref(false)
|
const exportLoading = ref(false)
|
||||||
const progressPercentage = ref(0)
|
const progressPercentage = ref(0)
|
||||||
@@ -40,12 +33,12 @@ const fetchCurrentPage = ref(1)
|
|||||||
const fetchTotalPages = ref(0)
|
const fetchTotalPages = ref(0)
|
||||||
const fetchTotalItems = ref(0)
|
const fetchTotalItems = ref(0)
|
||||||
const isFetching = ref(false)
|
const isFetching = ref(false)
|
||||||
|
let abortController: AbortController | null = null
|
||||||
|
|
||||||
// 试用期过期弹框
|
// 试用期过期弹框
|
||||||
const showTrialExpiredDialog = ref(false)
|
const showTrialExpiredDialog = ref(false)
|
||||||
const trialExpiredType = ref<'device' | 'account' | 'both'>('account')
|
const trialExpiredType = ref<'device' | 'account' | 'both' | 'subscribe'>('account')
|
||||||
|
const vipStatus = inject<any>('vipStatus')
|
||||||
const checkExpiredType = inject<() => 'device' | 'account' | 'both'>('checkExpiredType')
|
|
||||||
function selectAccount(id: number) {
|
function selectAccount(id: number) {
|
||||||
accountId.value = id
|
accountId.value = id
|
||||||
loadShops()
|
loadShops()
|
||||||
@@ -105,14 +98,14 @@ async function fetchData() {
|
|||||||
|
|
||||||
// 刷新VIP状态
|
// 刷新VIP状态
|
||||||
if (refreshVipStatus) await refreshVipStatus()
|
if (refreshVipStatus) await refreshVipStatus()
|
||||||
|
|
||||||
// VIP检查
|
// VIP检查
|
||||||
if (!props.isVip) {
|
if (!props.isVip) {
|
||||||
if (checkExpiredType) trialExpiredType.value = checkExpiredType()
|
if (vipStatus) trialExpiredType.value = vipStatus.value.expiredType
|
||||||
showTrialExpiredDialog.value = true
|
showTrialExpiredDialog.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abortController = new AbortController()
|
||||||
loading.value = true
|
loading.value = true
|
||||||
isFetching.value = true
|
isFetching.value = true
|
||||||
showProgress.value = true
|
showProgress.value = true
|
||||||
@@ -121,16 +114,17 @@ async function fetchData() {
|
|||||||
fetchCurrentPage.value = 1
|
fetchCurrentPage.value = 1
|
||||||
fetchTotalItems.value = 0
|
fetchTotalItems.value = 0
|
||||||
currentBatchId.value = `ZEBRA_${Date.now()}`
|
currentBatchId.value = `ZEBRA_${Date.now()}`
|
||||||
|
|
||||||
const [startDate = '', endDate = ''] = dateRange.value || []
|
const [start, end] = dateRange.value || []
|
||||||
|
const startDate = start ? `${new Date(start).toLocaleDateString('sv-SE')} 00:00:00` : ''
|
||||||
|
const endDate = end ? `${new Date(end).toLocaleDateString('sv-SE')} 23:59:59` : ''
|
||||||
|
|
||||||
await fetchPageData(startDate, endDate)
|
await fetchPageData(startDate, endDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPageData(startDate: string, endDate: string) {
|
async function fetchPageData(startDate: string, endDate: string) {
|
||||||
if (!isFetching.value) return
|
if (!isFetching.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await zebraApi.getOrders({
|
const response = await zebraApi.getOrders({
|
||||||
accountId: Number(accountId.value) || undefined,
|
accountId: Number(accountId.value) || undefined,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
@@ -138,14 +132,13 @@ async function fetchPageData(startDate: string, endDate: string) {
|
|||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
shopIds: selectedShops.value.join(','),
|
shopIds: selectedShops.value.join(','),
|
||||||
batchId: currentBatchId.value
|
batchId: currentBatchId.value
|
||||||
})
|
}, abortController?.signal)
|
||||||
|
|
||||||
|
const data = (response as any)?.data || response
|
||||||
const orders = data.orders || []
|
const orders = data.orders || []
|
||||||
allOrderData.value = [...allOrderData.value, ...orders]
|
allOrderData.value = [...allOrderData.value, ...orders]
|
||||||
|
|
||||||
fetchTotalPages.value = data.totalPages || 0
|
fetchTotalPages.value = data.totalPages || 0
|
||||||
fetchTotalItems.value = data.total || 0
|
fetchTotalItems.value = data.total || 0
|
||||||
|
|
||||||
if (fetchCurrentPage.value < fetchTotalPages.value && isFetching.value) {
|
if (fetchCurrentPage.value < fetchTotalPages.value && isFetching.value) {
|
||||||
progressPercentage.value = Math.round((fetchCurrentPage.value / fetchTotalPages.value) * 100)
|
progressPercentage.value = Math.round((fetchCurrentPage.value / fetchTotalPages.value) * 100)
|
||||||
fetchCurrentPage.value++
|
fetchCurrentPage.value++
|
||||||
@@ -154,8 +147,10 @@ async function fetchPageData(startDate: string, endDate: string) {
|
|||||||
progressPercentage.value = 100
|
progressPercentage.value = 100
|
||||||
finishFetching()
|
finishFetching()
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
console.error('获取订单数据失败:', e)
|
if (e.name !== 'AbortError') {
|
||||||
|
console.error('获取订单数据失败:', e)
|
||||||
|
}
|
||||||
finishFetching()
|
finishFetching()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,6 +158,7 @@ async function fetchPageData(startDate: string, endDate: string) {
|
|||||||
function finishFetching() {
|
function finishFetching() {
|
||||||
isFetching.value = false
|
isFetching.value = false
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
abortController = null
|
||||||
// 确保进度条完全填满
|
// 确保进度条完全填满
|
||||||
progressPercentage.value = 100
|
progressPercentage.value = 100
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
@@ -170,6 +166,8 @@ function finishFetching() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopFetch() {
|
function stopFetch() {
|
||||||
|
abortController?.abort()
|
||||||
|
abortController = null
|
||||||
isFetching.value = false
|
isFetching.value = false
|
||||||
loading.value = false
|
loading.value = false
|
||||||
// 进度条保留显示,不自动隐藏
|
// 进度条保留显示,不自动隐藏
|
||||||
@@ -264,7 +262,8 @@ async function exportToExcel() {
|
|||||||
})
|
})
|
||||||
const fileName = `斑马订单数据_${new Date().toISOString().slice(0, 10)}.xlsx`
|
const fileName = `斑马订单数据_${new Date().toISOString().slice(0, 10)}.xlsx`
|
||||||
|
|
||||||
const success = await handlePlatformFileExport('zebra', blob, fileName)
|
const username = getUsernameFromToken()
|
||||||
|
const success = await handlePlatformFileExport('zebra', blob, fileName, username)
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
showMessage('Excel文件导出成功!', 'success')
|
showMessage('Excel文件导出成功!', 'success')
|
||||||
@@ -295,7 +294,19 @@ const rememberPwd = ref(true)
|
|||||||
const managerVisible = ref(false)
|
const managerVisible = ref(false)
|
||||||
const accountManagerRef = ref()
|
const accountManagerRef = ref()
|
||||||
|
|
||||||
function openAddAccount() {
|
async function openAddAccount() {
|
||||||
|
try {
|
||||||
|
const username = getUsernameFromToken()
|
||||||
|
const limitRes = await zebraApi.getAccountLimit(username)
|
||||||
|
const limitData = (limitRes as any)?.data ?? limitRes
|
||||||
|
const { limit = 1, count = 0 } = limitData
|
||||||
|
if (count >= limit) {
|
||||||
|
ElMessage({ message: `账号数量已达上限(${limit}个),${limit < 3 ? '请升级订阅或' : ''}请先删除其他账号`, type: 'warning' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('检查账号限制失败:', e)
|
||||||
|
}
|
||||||
isEditMode.value = false
|
isEditMode.value = false
|
||||||
accountForm.value = { name: '', username: '', isDefault: 0, status: 1 }
|
accountForm.value = { name: '', username: '', isDefault: 0, status: 1 }
|
||||||
formUsername.value = ''
|
formUsername.value = ''
|
||||||
@@ -373,7 +384,7 @@ async function removeCurrentAccount() {
|
|||||||
>
|
>
|
||||||
<span class="acct-row">
|
<span class="acct-row">
|
||||||
<span :class="['status-dot', a.status === 1 ? 'on' : 'off']"></span>
|
<span :class="['status-dot', a.status === 1 ? 'on' : 'off']"></span>
|
||||||
<img class="avatar" src="/image/img_v3_02qd_052605f0-4be3-44db-9691-35ee5ff6201g.jpg" alt="avatar" />
|
<img class="avatar" src="/image/user.png" alt="avatar" />
|
||||||
<span class="acct-text">{{ a.name || a.username }}</span>
|
<span class="acct-text">{{ a.name || a.username }}</span>
|
||||||
<span v-if="accountId === a.id" class="acct-check">✔️</span>
|
<span v-if="accountId === a.id" class="acct-check">✔️</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -397,8 +408,8 @@ async function removeCurrentAccount() {
|
|||||||
<section class="step">
|
<section class="step">
|
||||||
<div class="step-index">2</div>
|
<div class="step-index">2</div>
|
||||||
<div class="step-body">
|
<div class="step-body">
|
||||||
<div class="step-title">需要查询的日期</div>
|
<div class="step-title">需查询的店铺与日期</div>
|
||||||
<div class="tip">请选择查询数据的日期范围。</div>
|
<div class="tip">请选择需查询的店铺(可多选)与日期范围,选项为空时默认获取全部数据</div>
|
||||||
<el-select v-model="selectedShops" multiple placeholder="选择店铺" :disabled="loading || !accounts.length" size="small" style="width: 100%">
|
<el-select v-model="selectedShops" multiple placeholder="选择店铺" :disabled="loading || !accounts.length" size="small" style="width: 100%">
|
||||||
<el-option v-for="shop in shopList" :key="shop.id" :label="shop.shopName" :value="shop.id" />
|
<el-option v-for="shop in shopList" :key="shop.id" :label="shop.shopName" :value="shop.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -413,7 +424,7 @@ async function removeCurrentAccount() {
|
|||||||
<div class="step-title">获取数据</div>
|
<div class="step-title">获取数据</div>
|
||||||
<div class="tip">点击下方按钮,开始查询订单数据。</div>
|
<div class="tip">点击下方按钮,开始查询订单数据。</div>
|
||||||
<div class="btn-col">
|
<div class="btn-col">
|
||||||
<el-button size="small" class="w100 btn-blue" :disabled="loading || !accounts.length" @click="fetchData">{{ loading ? '处理中...' : '获取数据' }}</el-button>
|
<el-button size="small" class="w100 btn-blue" :disabled="loading || exportLoading || !accounts.length" @click="fetchData">{{ loading ? '处理中...' : '获取数据' }}</el-button>
|
||||||
<el-button size="small" :disabled="!loading" @click="stopFetch" class="w100">停止获取</el-button>
|
<el-button size="small" :disabled="!loading" @click="stopFetch" class="w100">停止获取</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,7 +436,7 @@ async function removeCurrentAccount() {
|
|||||||
<div class="step-title">导出数据</div>
|
<div class="step-title">导出数据</div>
|
||||||
<div class="tip">点击下方按钮导出所有订单数据到 Excel 文件</div>
|
<div class="tip">点击下方按钮导出所有订单数据到 Excel 文件</div>
|
||||||
<div class="btn-col">
|
<div class="btn-col">
|
||||||
<el-button size="small" type="success" :disabled="exportLoading || !allOrderData.length" :loading="exportLoading" @click="exportToExcel" class="w100">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
|
<el-button size="small" :disabled="exportLoading || loading || !allOrderData.length" :loading="exportLoading" @click="exportToExcel" class="w100 btn-blue">{{ exportLoading ? '导出中...' : '导出数据' }}</el-button>
|
||||||
<!-- 导出进度条 -->
|
<!-- 导出进度条 -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,7 +485,7 @@ async function removeCurrentAccount() {
|
|||||||
<td>{{ row.orderedAt || '-' }}</td>
|
<td>{{ row.orderedAt || '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="image-container" v-if="row.productImage">
|
<div class="image-container" v-if="row.productImage">
|
||||||
<img :src="row.productImage" class="thumb" alt="thumb" />
|
<el-image :src="row.productImage" class="thumb" fit="contain" :preview-src-list="[row.productImage]" />
|
||||||
</div>
|
</div>
|
||||||
<span v-else>无图片</span>
|
<span v-else>无图片</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -516,7 +527,6 @@ async function removeCurrentAccount() {
|
|||||||
<!-- 底部区域:分页器 -->
|
<!-- 底部区域:分页器 -->
|
||||||
<div class="pagination-fixed">
|
<div class="pagination-fixed">
|
||||||
<el-pagination
|
<el-pagination
|
||||||
background
|
|
||||||
:current-page="currentPage"
|
:current-page="currentPage"
|
||||||
:page-sizes="[15,30,50,100]"
|
:page-sizes="[15,30,50,100]"
|
||||||
:page-size="pageSize"
|
:page-size="pageSize"
|
||||||
@@ -554,7 +564,7 @@ async function removeCurrentAccount() {
|
|||||||
<!-- 试用期过期弹框 -->
|
<!-- 试用期过期弹框 -->
|
||||||
<TrialExpiredDialog v-model="showTrialExpiredDialog" :expired-type="trialExpiredType" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -565,93 +575,94 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.zebra-root { position: absolute; inset: 0; background: #f5f5f5; padding: 12px; box-sizing: border-box; }
|
.zebra-root {position: absolute; inset: 0; background: #fff; box-sizing: border-box;}
|
||||||
.layout { background: #fff; border-radius: 4px; padding: 12px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); height: 100%; display: grid; grid-template-columns: 220px 1fr; gap: 12px; }
|
.layout {height: 100%; display: grid; grid-template-columns: 220px 1fr; gap: 12px; padding: 12px; box-sizing: border-box;}
|
||||||
.aside { border: 1px solid #ebeef5; border-radius: 4px; padding: 10px; display: flex; flex-direction: column; transition: width 0.2s ease; }
|
.aside {border: 1px solid #ebeef5; border-radius: 4px; padding: 10px; display: flex; flex-direction: column; transition: width 0.2s ease;}
|
||||||
.aside.collapsed { width: 56px; overflow: hidden; }
|
.aside.collapsed {width: 56px; overflow: hidden;}
|
||||||
.aside-header { display: flex; justify-content: flex-start; align-items: center; font-weight: 600; color: #606266; margin-bottom: 8px; }
|
.aside-header {display: flex; justify-content: flex-start; align-items: center; font-weight: 600; color: #606266; margin-bottom: 8px;}
|
||||||
.aside-steps { position: relative; }
|
.aside-steps {position: relative;}
|
||||||
.step { display: grid; grid-template-columns: 22px 1fr; gap: 10px; position: relative; padding: 8px 0; }
|
.step {display: grid; grid-template-columns: 22px 1fr; gap: 10px; position: relative; padding: 8px 0;}
|
||||||
.step + .step { border-top: 1px dashed #ebeef5; }
|
.step-index {width: 22px; height: 22px; background: #1677FF; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; margin-top: 2px;}
|
||||||
.step-index { width: 22px; height: 22px; background: #1677FF; color: #fff; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 12px; font-weight: 600; margin-top: 2px; }
|
.step-body {min-width: 0; text-align: left;}
|
||||||
.step-body { min-width: 0; text-align: left; }
|
.step-title {font-size: 13px; color: #606266; margin-bottom: 6px; font-weight: 600; text-align: left;}
|
||||||
.step-title { font-size: 13px; color: #606266; margin-bottom: 6px; font-weight: 600; text-align: left; }
|
.aside-steps:before {content: ''; position: absolute; left: 11px; top: 20px; bottom: 0; width: 1px; background: rgba(229, 231, 235, 0.6);}
|
||||||
.aside-steps:before { content: ''; position: absolute; left: 11px; top: 20px; bottom: 0; width: 1px; background: rgba(229, 231, 235, 0.6); }
|
.account-list {height: auto;}
|
||||||
.account-list {height: auto; }
|
.step-actions {margin-top: 8px; display: flex; gap: 8px;}
|
||||||
.step-actions { margin-top: 8px; display: flex; gap: 8px; }
|
.step-accounts {position: relative;}
|
||||||
.step-accounts { position: relative; }
|
.sticky-actions {position: sticky; bottom: 0; background: #fafafa; padding-top: 8px;}
|
||||||
.sticky-actions { position: sticky; bottom: 0; background: #fafafa; padding-top: 8px; }
|
.scroll-limit {max-height: 160px;}
|
||||||
.scroll-limit { max-height: 160px; }
|
.btn-row {display: grid; grid-template-columns: 1fr 1fr; gap: 8px;}
|
||||||
.btn-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
.btn-col {display: flex; flex-direction: column; gap: 6px;}
|
||||||
.btn-col { display: flex; flex-direction: column; gap: 6px; }
|
.w50 {width: 48%;}
|
||||||
.w50 { width: 48%; }
|
.w100 {width: 100%;}
|
||||||
.w100 { width: 100%; }
|
.placeholder-box {display:flex; align-items:center; justify-content:center; flex-direction:column; height: 140px; background: #fff; border: 1px solid #ebeef5; border-radius: 4px;}
|
||||||
.placeholder-box { display:flex; align-items:center; justify-content:center; flex-direction:column; height: 140px; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; }
|
.placeholder-img {width: 120px; opacity: 0.9;}
|
||||||
.placeholder-img { width: 120px; opacity: 0.9; }
|
.placeholder-tip {margin-top: 6px; font-size: 12px; color: #a8abb2;}
|
||||||
.placeholder-tip { margin-top: 6px; font-size: 12px; color: #a8abb2; }
|
.aside :deep(.el-date-editor) {width: 100%;}
|
||||||
.aside :deep(.el-date-editor) { width: 100%; }
|
.aside :deep(.el-range-editor.el-input__wrapper) {width: 100%; box-sizing: border-box;}
|
||||||
.aside :deep(.el-range-editor.el-input__wrapper) { width: 100%; box-sizing: border-box; }
|
|
||||||
.aside :deep(.el-input),
|
.aside :deep(.el-input),
|
||||||
.aside :deep(.el-input__wrapper),
|
.aside :deep(.el-input__wrapper),
|
||||||
.aside :deep(.el-select) { width: 100%; box-sizing: border-box; }
|
.aside :deep(.el-select) {width: 100%; box-sizing: border-box;}
|
||||||
.aside :deep(.el-button + .el-button) { margin-left: 0 !important; }
|
.aside :deep(.el-button + .el-button) {margin-left: 0 !important;}
|
||||||
.btn-row :deep(.el-button) { width: 100%; }
|
.btn-row :deep(.el-button) {width: 100%;}
|
||||||
.btn-col :deep(.el-button) { width: 100%; }
|
.btn-col :deep(.el-button) {width: 100%;}
|
||||||
.btn-blue { background: #1677FF; border-color: #1677FF; color: #fff; }
|
.btn-blue {background: #1677FF; border-color: #1677FF; color: #fff;}
|
||||||
.btn-blue:disabled { background: #a6c8ff; border-color: #a6c8ff; color: #fff; }
|
.btn-blue:disabled {background: #a6c8ff; border-color: #a6c8ff; color: #fff;}
|
||||||
.tip { color: #909399; font-size: 12px; margin-bottom: 8px; text-align: left; }
|
.tip {color: #909399; font-size: 12px; margin-bottom: 8px; text-align: left;}
|
||||||
.avatar { width: 22px; height: 22px; border-radius: 50%; margin-right: 6px; vertical-align: -2px; }
|
.avatar {width: 22px; height: 22px; border-radius: 50%; margin-right: 6px; vertical-align: -2px;}
|
||||||
.acct-text { vertical-align: middle; }
|
.acct-text {vertical-align: middle;}
|
||||||
.acct-row { display: grid; grid-template-columns: 8px 18px 1fr auto; align-items: center; gap: 6px; width: 100%; }
|
.acct-row {display: grid; grid-template-columns: 8px 18px 1fr auto; align-items: center; gap: 6px; width: 100%;}
|
||||||
.acct-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; font-size: 12px; }
|
.acct-text {overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; font-size: 12px;}
|
||||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
.status-dot {width: 6px; height: 6px; border-radius: 50%; display: inline-block;}
|
||||||
.status-dot.on { background: #22c55e; }
|
.status-dot.on {background: #22c55e;}
|
||||||
.status-dot.off { background: #f87171; }
|
.status-dot.off {background: #f87171;}
|
||||||
.acct-item { padding: 6px 8px; border-radius: 8px; cursor: pointer; }
|
.acct-item {padding: 6px 8px; border-radius: 8px; cursor: pointer;}
|
||||||
.acct-item.selected { background: #eef5ff; box-shadow: inset 0 0 0 1px #d6e4ff; }
|
.acct-item.selected {background: #eef5ff; box-shadow: inset 0 0 0 1px #d6e4ff;}
|
||||||
.acct-check { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%; background: transparent; color: #111; font-size: 14px; }
|
.acct-check {display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 50%; background: transparent; color: #111; font-size: 14px;}
|
||||||
.account-list::-webkit-scrollbar { width: 0; height: 0; }
|
.account-list::-webkit-scrollbar {width: 0; height: 0;}
|
||||||
.add-account-dialog .aad-header { display:flex; flex-direction: column; align-items:center; gap:8px; padding-top: 8px; width: 100%; }
|
.add-account-dialog .aad-header {display:flex; flex-direction: column; align-items:center; gap:8px; padding-top: 8px; width: 100%;}
|
||||||
.add-account-dialog .aad-icon { width: 120px; height: auto; }
|
.add-account-dialog .aad-icon {width: 120px; height: auto;}
|
||||||
.add-account-dialog .aad-title { font-weight: 600; font-size: 18px; text-align: center; }
|
.add-account-dialog .aad-title {font-weight: 600; font-size: 18px; text-align: center;}
|
||||||
.add-account-dialog .aad-row { margin-top: 12px; }
|
.add-account-dialog .aad-row {margin-top: 12px;}
|
||||||
.add-account-dialog .aad-opts { display:flex; align-items:center; }
|
.add-account-dialog .aad-opts {display:flex; align-items:center;}
|
||||||
|
|
||||||
/* 居中 header,避免右上角关闭按钮影响视觉中心 */
|
/* 居中 header,避免右上角关闭按钮影响视觉中心 */
|
||||||
:deep(.add-account-dialog .el-dialog__header) { text-align: center; padding-right: 0; display: block; }
|
:deep(.add-account-dialog .el-dialog__header) {text-align: center; padding-right: 0; display: block;}
|
||||||
.content { display: grid; grid-template-rows: 1fr auto; min-height: 0; }
|
.content {display: grid; grid-template-rows: 1fr auto; min-height: 0;}
|
||||||
.table-section { min-height: 0; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column; }
|
.table-section {min-height: 0; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; display: flex; flex-direction: column;}
|
||||||
.table-wrapper { flex: 1; overflow: auto; overflow-x: auto; }
|
.table-wrapper {flex: 1; overflow: auto; overflow-x: auto;}
|
||||||
.table-wrapper { scrollbar-width: thin; scrollbar-color: #c0c4cc transparent; }
|
.table-wrapper {scrollbar-width: thin; scrollbar-color: #c0c4cc transparent;}
|
||||||
.table-wrapper::-webkit-scrollbar { width: 6px; height: 6px; }
|
.table-wrapper::-webkit-scrollbar {width: 6px; height: 6px;}
|
||||||
.table-wrapper::-webkit-scrollbar-track { background: transparent; }
|
.table-wrapper::-webkit-scrollbar-track {background: transparent;}
|
||||||
.table-wrapper::-webkit-scrollbar-thumb { background: #c0c4cc; border-radius: 3px; }
|
.table-wrapper::-webkit-scrollbar-thumb {background: #c0c4cc; border-radius: 3px;}
|
||||||
.table-wrapper:hover::-webkit-scrollbar-thumb { background: #a8abb2; }
|
.table-wrapper:hover::-webkit-scrollbar-thumb {background: #a8abb2;}
|
||||||
.table { width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px; }
|
.table {width: max-content; min-width: 100%; border-collapse: collapse; font-size: 13px;}
|
||||||
.table th { background: #f5f7fa; color: #909399; font-weight: 600; padding: 12px 8px; border-bottom: 2px solid #ebeef5; text-align: left; white-space: nowrap; }
|
.table th {background: #f5f7fa; color: #909399; font-weight: 600; padding: 12px 8px; border-bottom: 2px solid #ebeef5; text-align: left; white-space: nowrap;}
|
||||||
.table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
|
.table td {padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle;}
|
||||||
.table tbody tr:hover { background: #f9f9f9; }
|
.table tbody tr:hover {background: #f9f9f9;}
|
||||||
.truncate { max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.truncate {max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;}
|
||||||
.image-container { display: flex; justify-content: center; align-items: center; width: 28px; height: 24px; margin: 0 auto; background: #f8f9fa; border-radius: 2px; }
|
.image-container {display: flex; justify-content: center; align-items: center; width: 28px; height: 24px; margin: 0 auto; background: #f8f9fa; border-radius: 2px;}
|
||||||
.thumb { width: 22px; height: 22px; object-fit: contain; border-radius: 2px; }
|
.thumb {width: 22px; height: 22px; object-fit: contain; border-radius: 2px;}
|
||||||
.price-tag { color: #e6a23c; font-weight: bold; }
|
.price-tag {color: #e6a23c; font-weight: bold;}
|
||||||
.fee-tag { color: #909399; font-weight: 500; }
|
.fee-tag {color: #909399; font-weight: 500;}
|
||||||
.table-loading { position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95); display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 14px; color: #606266; }
|
.table-loading {position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95); display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 14px; color: #606266;}
|
||||||
.spinner { font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px; }
|
.spinner {font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px;}
|
||||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
@keyframes spin {0% { transform: rotate(0deg);}
|
||||||
.pagination-fixed { position: sticky; bottom: 0; z-index: 2; padding: 8px 12px; background: #f9f9f9; border-radius: 4px; display: flex; justify-content: center; border-top: 1px solid #ebeef5; margin-top: 8px; }
|
100% {transform: rotate(360deg);}
|
||||||
.tag { display: inline-block; padding: 0 6px; margin-left: 6px; font-size: 12px; background: #ecf5ff; color: #409EFF; border-radius: 3px; }
|
}
|
||||||
.empty-abs { position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; pointer-events: none; }
|
.pagination-fixed {position: sticky; bottom: 0; z-index: 2; padding: 8px 12px 0 12px; background: #fff; display: flex; justify-content: flex-end;}
|
||||||
.progress-section { margin: 0px 12px 0px 12px; }
|
.pagination-fixed :deep(.el-pager li.is-active) {border: 1px solid #1677FF; border-radius: 4px; color: #1677FF; background: #fff;}
|
||||||
.progress-box { padding: 4px 0; }
|
.tag {display: inline-block; padding: 0 6px; margin-left: 6px; font-size: 12px; background: #ecf5ff; color: #409EFF; border-radius: 3px;}
|
||||||
.progress-container { display: flex; align-items: center; gap: 8px; }
|
.empty-abs {position: absolute; left: 0; right: 0; top: 48px; bottom: 0; display: flex; align-items: center; justify-content: center; pointer-events: none;}
|
||||||
.progress-bar { flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden; }
|
.progress-section {margin: 0px 12px 0px 12px;}
|
||||||
.progress-fill { height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease; }
|
.progress-box {padding: 4px 0;}
|
||||||
.progress-text { font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right; }
|
.progress-container {display: flex; align-items: center; gap: 8px;}
|
||||||
.export-progress { display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px; }
|
.progress-bar {flex: 1; height: 6px; background: #e3eeff; border-radius: 999px; overflow: hidden;}
|
||||||
.export-progress-bar { flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden; }
|
.progress-fill {height: 100%; background: #1677FF; border-radius: 999px; transition: width 0.3s ease;}
|
||||||
.export-progress-fill { height: 100%; background: #67c23a; border-radius: 2px; transition: width 0.3s ease; }
|
.progress-text {font-size: 13px; color: #1677FF; font-weight: 500; min-width: 44px; text-align: right;}
|
||||||
.export-progress-text { font-size: 11px; color: #67c23a; font-weight: 500; min-width: 32px; text-align: right; }
|
.export-progress {display: flex; align-items: center; gap: 8px; margin-top: 6px; padding: 0 4px;}
|
||||||
|
.export-progress-bar {flex: 1; height: 4px; background: #e3eeff; border-radius: 2px; overflow: hidden;}
|
||||||
|
.export-progress-fill {height: 100%; background: #67c23a; border-radius: 2px; transition: width 0.3s ease;}
|
||||||
|
.export-progress-text {font-size: 11px; color: #67c23a; font-weight: 500; min-width: 32px; text-align: right;}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
20
electron-vue-template/src/renderer/config/index.ts
Normal 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')
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@
|
|||||||
<title>erpClient</title>
|
<title>erpClient</title>
|
||||||
<link rel="icon" href="/icon/icon.png">
|
<link rel="icon" href="/icon/icon.png">
|
||||||
<meta name="theme-color" content="#ffffff">
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background-color: #f5f5f5; }
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 43 KiB |
@@ -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>
|
|
||||||
|
|
||||||
|
|
||||||
@@ -14,6 +14,10 @@ export default interface ElectronApi {
|
|||||||
getUpdateStatus: () => Promise<{ downloadedFilePath: string | null; isDownloading: boolean; downloadProgress: any; isPackaged: boolean }>
|
getUpdateStatus: () => Promise<{ downloadedFilePath: string | null; isDownloading: boolean; downloadProgress: any; isPackaged: boolean }>
|
||||||
onUpdateLog: (callback: (log: string) => void) => void
|
onUpdateLog: (callback: (log: string) => void) => void
|
||||||
removeUpdateLogListener: () => void
|
removeUpdateLogListener: () => void
|
||||||
|
windowMinimize: () => Promise<void>
|
||||||
|
windowMaximize: () => Promise<void>
|
||||||
|
windowClose: () => Promise<void>
|
||||||
|
windowIsMaximized: () => Promise<boolean>
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -8,19 +8,15 @@ async function fetchDeviceIdFromClient(): Promise<string> {
|
|||||||
credentials: 'omit',
|
credentials: 'omit',
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) throw new Error('获取设备ID失败')
|
if (!response.ok) throw new Error('获取设备ID失败')
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
if (!result?.data) throw new Error('设备ID为空')
|
if (!result?.data) throw new Error('设备ID为空')
|
||||||
|
|
||||||
return result.data
|
return result.data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCreateDeviceId(): Promise<string> {
|
export async function getOrCreateDeviceId(): Promise<string> {
|
||||||
const cached = localStorage.getItem(DEVICE_ID_KEY)
|
const cached = localStorage.getItem(DEVICE_ID_KEY)
|
||||||
if (cached) return cached
|
if (cached) return cached
|
||||||
|
|
||||||
const deviceId = await fetchDeviceIdFromClient()
|
const deviceId = await fetchDeviceIdFromClient()
|
||||||
localStorage.setItem(DEVICE_ID_KEY, deviceId)
|
localStorage.setItem(DEVICE_ID_KEY, deviceId)
|
||||||
return deviceId
|
return deviceId
|
||||||
|
|||||||
177
electron-vue-template/src/renderer/utils/imageCompressor.ts
Normal 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
|
||||||
@@ -7,8 +7,6 @@ export interface PlatformExportSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
// 全局设置
|
|
||||||
global: PlatformExportSettings
|
|
||||||
// 平台特定设置
|
// 平台特定设置
|
||||||
platforms: {
|
platforms: {
|
||||||
amazon: PlatformExportSettings
|
amazon: PlatformExportSettings
|
||||||
@@ -17,9 +15,22 @@ export interface AppSettings {
|
|||||||
}
|
}
|
||||||
// 更新设置
|
// 更新设置
|
||||||
autoUpdate?: boolean
|
autoUpdate?: boolean
|
||||||
|
// 关闭行为
|
||||||
|
closeAction?: 'quit' | 'minimize' | 'tray'
|
||||||
|
// 启动配置
|
||||||
|
autoLaunch?: boolean
|
||||||
|
launchMinimized?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const SETTINGS_KEY = 'app-settings'
|
const SETTINGS_KEY_PREFIX = 'app-settings'
|
||||||
|
|
||||||
|
// 获取带用户隔离的设置 key
|
||||||
|
function getSettingsKey(username?: string): string {
|
||||||
|
if (!username || username.trim() === '') {
|
||||||
|
return SETTINGS_KEY_PREFIX
|
||||||
|
}
|
||||||
|
return `${SETTINGS_KEY_PREFIX}-${username}`
|
||||||
|
}
|
||||||
|
|
||||||
// 默认平台设置
|
// 默认平台设置
|
||||||
const defaultPlatformSettings: PlatformExportSettings = {
|
const defaultPlatformSettings: PlatformExportSettings = {
|
||||||
@@ -28,51 +39,59 @@ const defaultPlatformSettings: PlatformExportSettings = {
|
|||||||
|
|
||||||
// 默认设置
|
// 默认设置
|
||||||
const defaultSettings: AppSettings = {
|
const defaultSettings: AppSettings = {
|
||||||
global: { ...defaultPlatformSettings },
|
|
||||||
platforms: {
|
platforms: {
|
||||||
amazon: { ...defaultPlatformSettings },
|
amazon: { ...defaultPlatformSettings },
|
||||||
rakuten: { ...defaultPlatformSettings },
|
rakuten: { ...defaultPlatformSettings },
|
||||||
zebra: { ...defaultPlatformSettings }
|
zebra: { ...defaultPlatformSettings }
|
||||||
},
|
},
|
||||||
autoUpdate: false
|
autoUpdate: false,
|
||||||
|
closeAction: 'quit',
|
||||||
|
autoLaunch: false,
|
||||||
|
launchMinimized: false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取设置
|
// 获取设置(按用户隔离)
|
||||||
export function getSettings(): AppSettings {
|
export function getSettings(username?: string): AppSettings {
|
||||||
const saved = localStorage.getItem(SETTINGS_KEY)
|
const settingsKey = getSettingsKey(username)
|
||||||
|
const saved = localStorage.getItem(settingsKey)
|
||||||
if (saved) {
|
if (saved) {
|
||||||
const settings = JSON.parse(saved)
|
const settings = JSON.parse(saved)
|
||||||
return {
|
return {
|
||||||
global: { ...defaultSettings.global, ...settings.global },
|
|
||||||
platforms: {
|
platforms: {
|
||||||
amazon: { ...defaultSettings.platforms.amazon, ...settings.platforms?.amazon },
|
amazon: { ...defaultSettings.platforms.amazon, ...settings.platforms?.amazon },
|
||||||
rakuten: { ...defaultSettings.platforms.rakuten, ...settings.platforms?.rakuten },
|
rakuten: { ...defaultSettings.platforms.rakuten, ...settings.platforms?.rakuten },
|
||||||
zebra: { ...defaultSettings.platforms.zebra, ...settings.platforms?.zebra }
|
zebra: { ...defaultSettings.platforms.zebra, ...settings.platforms?.zebra }
|
||||||
},
|
},
|
||||||
autoUpdate: settings.autoUpdate ?? defaultSettings.autoUpdate
|
autoUpdate: settings.autoUpdate ?? defaultSettings.autoUpdate,
|
||||||
|
closeAction: settings.closeAction ?? defaultSettings.closeAction,
|
||||||
|
autoLaunch: settings.autoLaunch ?? defaultSettings.autoLaunch,
|
||||||
|
launchMinimized: settings.launchMinimized ?? defaultSettings.launchMinimized
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return defaultSettings
|
return defaultSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存设置
|
// 保存设置(按用户隔离)
|
||||||
export function saveSettings(settings: Partial<AppSettings>): void {
|
export function saveSettings(settings: Partial<AppSettings>, username?: string): void {
|
||||||
const current = getSettings()
|
const current = getSettings(username)
|
||||||
const updated = {
|
const updated = {
|
||||||
global: { ...current.global, ...settings.global },
|
|
||||||
platforms: {
|
platforms: {
|
||||||
amazon: { ...current.platforms.amazon, ...settings.platforms?.amazon },
|
amazon: { ...current.platforms.amazon, ...settings.platforms?.amazon },
|
||||||
rakuten: { ...current.platforms.rakuten, ...settings.platforms?.rakuten },
|
rakuten: { ...current.platforms.rakuten, ...settings.platforms?.rakuten },
|
||||||
zebra: { ...current.platforms.zebra, ...settings.platforms?.zebra }
|
zebra: { ...current.platforms.zebra, ...settings.platforms?.zebra }
|
||||||
},
|
},
|
||||||
autoUpdate: settings.autoUpdate ?? current.autoUpdate
|
autoUpdate: settings.autoUpdate ?? current.autoUpdate,
|
||||||
|
closeAction: settings.closeAction ?? current.closeAction,
|
||||||
|
autoLaunch: settings.autoLaunch ?? current.autoLaunch,
|
||||||
|
launchMinimized: settings.launchMinimized ?? current.launchMinimized
|
||||||
}
|
}
|
||||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated))
|
const settingsKey = getSettingsKey(username)
|
||||||
|
localStorage.setItem(settingsKey, JSON.stringify(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存平台特定设置
|
// 保存平台特定设置(按用户隔离)
|
||||||
export function savePlatformSettings(platform: Platform, settings: Partial<PlatformExportSettings>): void {
|
export function savePlatformSettings(platform: Platform, settings: Partial<PlatformExportSettings>, username?: string): void {
|
||||||
const current = getSettings()
|
const current = getSettings(username)
|
||||||
const updated = {
|
const updated = {
|
||||||
...current,
|
...current,
|
||||||
platforms: {
|
platforms: {
|
||||||
@@ -80,23 +99,25 @@ export function savePlatformSettings(platform: Platform, settings: Partial<Platf
|
|||||||
[platform]: { ...current.platforms[platform], ...settings }
|
[platform]: { ...current.platforms[platform], ...settings }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(updated))
|
const settingsKey = getSettingsKey(username)
|
||||||
|
localStorage.setItem(settingsKey, JSON.stringify(updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取平台导出配置
|
// 获取平台导出配置(按用户隔离)
|
||||||
export function getPlatformExportConfig(platform: Platform): PlatformExportSettings {
|
export function getPlatformExportConfig(platform: Platform, username?: string): PlatformExportSettings {
|
||||||
const settings = getSettings()
|
const settings = getSettings(username)
|
||||||
return settings.platforms[platform]
|
return settings.platforms[platform]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 处理平台特定文件导出
|
// 处理平台特定文件导出(按用户隔离)
|
||||||
export async function handlePlatformFileExport(
|
export async function handlePlatformFileExport(
|
||||||
platform: Platform,
|
platform: Platform,
|
||||||
blob: Blob,
|
blob: Blob,
|
||||||
defaultFileName: string
|
defaultFileName: string,
|
||||||
|
username?: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const config = getPlatformExportConfig(platform)
|
const config = getPlatformExportConfig(platform, username)
|
||||||
|
|
||||||
if (!config.exportPath) {
|
if (!config.exportPath) {
|
||||||
const result = await (window as any).electronAPI.showSaveDialog({
|
const result = await (window as any).electronAPI.showSaveDialog({
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ set APP_ASAR=%~1
|
|||||||
set UPDATE_FILE=%~2
|
set UPDATE_FILE=%~2
|
||||||
set JAR_UPDATE=%~3
|
set JAR_UPDATE=%~3
|
||||||
set EXE_PATH=%~4
|
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 "%JAR_UPDATE%"=="" exit /b 1
|
||||||
if not exist "%UPDATE_FILE%" if not exist "%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
|
if exist "%JAR_UPDATE%" del /f /q "%JAR_UPDATE%" >nul 2>&1
|
||||||
|
|
||||||
:start_app
|
: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%"
|
start "" "%EXE_PATH%"
|
||||||
exit /b 0
|
exit /b 0
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const { defineConfig } = require('vite');
|
|||||||
*/
|
*/
|
||||||
const config = defineConfig({
|
const config = defineConfig({
|
||||||
root: Path.join(__dirname, 'src', 'renderer'),
|
root: Path.join(__dirname, 'src', 'renderer'),
|
||||||
publicDir: Path.join(__dirname, 'src', 'renderer', 'public'),
|
publicDir: Path.join(__dirname, 'public'),
|
||||||
server: {
|
server: {
|
||||||
port: 8083,
|
port: 8083,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
</parent>
|
</parent>
|
||||||
<groupId>com.tashow.erp</groupId>
|
<groupId>com.tashow.erp</groupId>
|
||||||
<artifactId>erp_client_sb</artifactId>
|
<artifactId>erp_client_sb</artifactId>
|
||||||
<version>2.4.7</version>
|
<version>2.6.3</version>
|
||||||
<name>erp_client_sb</name>
|
<name>erp_client_sb</name>
|
||||||
<description>erp客户端</description>
|
<description>erp客户端</description>
|
||||||
<properties>
|
<properties>
|
||||||
@@ -54,9 +54,7 @@
|
|||||||
<artifactId>webmagic-extension</artifactId>
|
<artifactId>webmagic-extension</artifactId>
|
||||||
<version>1.0.3</version>
|
<version>1.0.3</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- JavaFX 相关依赖已移除 -->
|
<!-- JavaFX 相关依赖已移除 -->
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
@@ -66,7 +64,12 @@
|
|||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>cn.hutool</groupId>
|
<groupId>cn.hutool</groupId>
|
||||||
<artifactId>hutool-all</artifactId>
|
<artifactId>hutool-crypto</artifactId>
|
||||||
|
<version>5.8.36</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.hutool</groupId>
|
||||||
|
<artifactId>hutool-poi</artifactId>
|
||||||
<version>5.8.36</version>
|
<version>5.8.36</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- SQLite数据库支持 -->
|
<!-- SQLite数据库支持 -->
|
||||||
@@ -94,12 +97,6 @@
|
|||||||
<artifactId>webdrivermanager</artifactId>
|
<artifactId>webdrivermanager</artifactId>
|
||||||
<version>5.9.2</version>
|
<version>5.9.2</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<!-- https://mvnrepository.com/artifact/org.python/jython-standalone -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.python</groupId>
|
|
||||||
<artifactId>jython-standalone</artifactId>
|
|
||||||
<version>2.7.4</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- JWT parsing for local RS256 verification -->
|
<!-- JWT parsing for local RS256 verification -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -119,13 +116,18 @@
|
|||||||
<version>0.11.5</version>
|
<version>0.11.5</version>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- OSHI for hardware information -->
|
<!-- OSHI for hardware information -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.github.oshi</groupId>
|
<groupId>com.github.oshi</groupId>
|
||||||
<artifactId>oshi-core</artifactId>
|
<artifactId>oshi-core</artifactId>
|
||||||
<version>6.4.6</version>
|
<version>6.4.6</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.hutool</groupId>
|
||||||
|
<artifactId>hutool-all</artifactId>
|
||||||
|
<version>5.8.36</version>
|
||||||
|
<scope>compile</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
@@ -173,6 +175,8 @@
|
|||||||
</exclude>
|
</exclude>
|
||||||
</excludes>
|
</excludes>
|
||||||
<mainClass>com.tashow.erp.ErpClientSbApplication</mainClass>
|
<mainClass>com.tashow.erp.ErpClientSbApplication</mainClass>
|
||||||
|
<executable>false</executable>
|
||||||
|
<layout>ZIP</layout>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|||||||
import org.springframework.context.ConfigurableApplicationContext;
|
import org.springframework.context.ConfigurableApplicationContext;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@SpringBootApplication
|
@SpringBootApplication(
|
||||||
|
exclude = {
|
||||||
|
org.springframework.boot.autoconfigure.task.TaskSchedulingAutoConfiguration.class,
|
||||||
|
org.springframework.boot.autoconfigure.task.TaskExecutionAutoConfiguration.class
|
||||||
|
}
|
||||||
|
)
|
||||||
public class ErpClientSbApplication {
|
public class ErpClientSbApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
ConfigurableApplicationContext applicationContext = SpringApplication.run(ErpClientSbApplication.class, args);
|
ConfigurableApplicationContext applicationContext = SpringApplication.run(ErpClientSbApplication.class, args);
|
||||||
@@ -16,10 +21,8 @@ public class ErpClientSbApplication {
|
|||||||
log.error("捕获到未处理异常: " + ex.getMessage(), ex);
|
log.error("捕获到未处理异常: " + ex.getMessage(), ex);
|
||||||
errorReporter.reportSystemError("未捕获异常: " + thread.getName(), (Exception) ex);
|
errorReporter.reportSystemError("未捕获异常: " + thread.getName(), (Exception) ex);
|
||||||
});
|
});
|
||||||
log.info("Started Success");
|
|
||||||
ResourcePreloader.init();
|
ResourcePreloader.init();
|
||||||
ResourcePreloader.preloadErpDashboard();
|
ResourcePreloader.preloadErpDashboard();
|
||||||
ResourcePreloader.executePreloading();
|
ResourcePreloader.executePreloading();
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -1,169 +1,16 @@
|
|||||||
package com.tashow.erp.common;
|
package com.tashow.erp.common;
|
||||||
|
|
||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通用常量信息
|
* 通用常量(保留兼容性)
|
||||||
*
|
* 新代码请使用具体业务常量类:AmazonConstants、RakutenConstants、HttpConstants等
|
||||||
* @author ruoyi
|
|
||||||
*/
|
*/
|
||||||
public class Constants
|
@Deprecated
|
||||||
{
|
public class Constants {
|
||||||
/**
|
public static final String HTTP = HttpConstants.HTTP;
|
||||||
* UTF-8 字符集
|
public static final String HTTPS = HttpConstants.HTTPS;
|
||||||
*/
|
public static final Locale DEFAULT_LOCALE = Locale.SIMPLIFIED_CHINESE;
|
||||||
public static final String UTF8 = "UTF-8";
|
|
||||||
|
|
||||||
/**
|
private Constants() {}
|
||||||
* GBK 字符集
|
|
||||||
*/
|
|
||||||
public static final String GBK = "GBK";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 系统语言
|
|
||||||
*/
|
|
||||||
public static final Locale DEFAULT_LOCALE = Locale.SIMPLIFIED_CHINESE;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* www主域
|
|
||||||
*/
|
|
||||||
public static final String WWW = "www.";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* http请求
|
|
||||||
*/
|
|
||||||
public static final String HTTP = "http://";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* https请求
|
|
||||||
*/
|
|
||||||
public static final String HTTPS = "https://";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用成功标识
|
|
||||||
*/
|
|
||||||
public static final String SUCCESS = "0";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用失败标识
|
|
||||||
*/
|
|
||||||
public static final String FAIL = "1";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录成功
|
|
||||||
*/
|
|
||||||
public static final String LOGIN_SUCCESS = "Success";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注销
|
|
||||||
*/
|
|
||||||
public static final String LOGOUT = "Logout";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 注册
|
|
||||||
*/
|
|
||||||
public static final String REGISTER = "Register";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录失败
|
|
||||||
*/
|
|
||||||
public static final String LOGIN_FAIL = "Error";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 所有权限标识
|
|
||||||
*/
|
|
||||||
public static final String ALL_PERMISSION = "*:*:*";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理员角色权限标识
|
|
||||||
*/
|
|
||||||
public static final String SUPER_ADMIN = "admin";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 角色权限分隔符
|
|
||||||
*/
|
|
||||||
public static final String ROLE_DELIMETER = ",";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 权限标识分隔符
|
|
||||||
*/
|
|
||||||
public static final String PERMISSION_DELIMETER = ",";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证码有效期(分钟)
|
|
||||||
*/
|
|
||||||
public static final Integer CAPTCHA_EXPIRATION = 2;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 令牌
|
|
||||||
*/
|
|
||||||
public static final String TOKEN = "token";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 令牌前缀
|
|
||||||
*/
|
|
||||||
public static final String TOKEN_PREFIX = "Bearer ";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 令牌前缀
|
|
||||||
*/
|
|
||||||
public static final String LOGIN_USER_KEY = "login_user_key";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户ID
|
|
||||||
*/
|
|
||||||
public static final String JWT_USERID = "userid";
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户头像
|
|
||||||
*/
|
|
||||||
public static final String JWT_AVATAR = "avatar";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建时间
|
|
||||||
*/
|
|
||||||
public static final String JWT_CREATED = "created";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用户权限
|
|
||||||
*/
|
|
||||||
public static final String JWT_AUTHORITIES = "authorities";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 资源映射路径 前缀
|
|
||||||
*/
|
|
||||||
public static final String RESOURCE_PREFIX = "/profile";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* RMI 远程方法调用
|
|
||||||
*/
|
|
||||||
public static final String LOOKUP_RMI = "rmi:";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LDAP 远程方法调用
|
|
||||||
*/
|
|
||||||
public static final String LOOKUP_LDAP = "ldap:";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LDAPS 远程方法调用
|
|
||||||
*/
|
|
||||||
public static final String LOOKUP_LDAPS = "ldaps:";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自动识别json对象白名单配置(仅允许解析的包名,范围越小越安全)
|
|
||||||
*/
|
|
||||||
public static final String[] JSON_WHITELIST_STR = { "org.springframework", "com.ruoyi" };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 定时任务白名单配置(仅允许访问的包名,如其他需要可以自行添加)
|
|
||||||
*/
|
|
||||||
public static final String[] JOB_WHITELIST_STR = { "com.ruoyi.quartz.task" };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 定时任务违规的字符
|
|
||||||
*/
|
|
||||||
public static final String[] JOB_ERROR_STR = { "java.net.URL", "javax.naming.InitialContext", "org.yaml.snakeyaml",
|
|
||||||
"org.springframework", "org.apache", "com.ruoyi.common.utils.file", "com.ruoyi.common.config", "com.ruoyi.generator" };
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -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() {}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +1,85 @@
|
|||||||
package com.tashow.erp.controller;
|
package com.tashow.erp.controller;
|
||||||
import com.tashow.erp.entity.AmazonProductEntity;
|
import com.tashow.erp.entity.AmazonProductEntity;
|
||||||
import com.tashow.erp.repository.AmazonProductRepository;
|
import com.tashow.erp.repository.AmazonProductRepository;
|
||||||
import com.tashow.erp.service.IAmazonScrapingService;
|
import com.tashow.erp.service.AmazonScrapingService;
|
||||||
import com.tashow.erp.utils.ExcelParseUtil;
|
import com.tashow.erp.utils.ExcelParseUtil;
|
||||||
import com.tashow.erp.utils.ExcelExportUtil;
|
|
||||||
import com.tashow.erp.utils.JsonData;
|
import com.tashow.erp.utils.JsonData;
|
||||||
|
import com.tashow.erp.utils.JwtUtil;
|
||||||
import com.tashow.erp.utils.LoggerUtil;
|
import com.tashow.erp.utils.LoggerUtil;
|
||||||
import com.tashow.erp.fx.controller.JavaBridge;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import java.util.HashMap;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Optional;
|
/**
|
||||||
|
* 亚马逊数据控制器
|
||||||
|
* 提供亚马逊商品采集、批量获取、Excel导入等功能
|
||||||
|
*
|
||||||
|
* @author 占子杰牛逼
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/amazon")
|
@RequestMapping("/api/amazon")
|
||||||
public class AmazonController {
|
public class AmazonController {
|
||||||
private static final Logger logger = LoggerUtil.getLogger(AmazonController.class);
|
private static final Logger logger = LoggerUtil.getLogger(AmazonController.class);
|
||||||
@Autowired
|
@Autowired
|
||||||
private IAmazonScrapingService amazonScrapingService;
|
private AmazonScrapingService amazonScrapingService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private AmazonProductRepository amazonProductRepository;
|
private AmazonProductRepository amazonProductRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量获取亚马逊产品信息
|
* 批量获取亚马逊商品信息
|
||||||
|
*
|
||||||
|
* @param request 包含asinList、batchId和region的请求参数
|
||||||
|
* @param httpRequest HTTP请求对象,用于获取用户信息
|
||||||
|
* @return 商品列表和总数
|
||||||
*/
|
*/
|
||||||
@PostMapping("/products/batch")
|
@PostMapping("/products/batch")
|
||||||
public JsonData batchGetProducts(@RequestBody Object request) {
|
public JsonData batchGetProducts(@RequestBody Map<String, Object> request, HttpServletRequest httpRequest) {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> requestMap = (Map<String, Object>) request;
|
List<String> asinList = (List<String>) request.get("asinList");
|
||||||
List<String> asinList = (List<String>) requestMap.get("asinList");
|
String batchId = (String) request.get("batchId");
|
||||||
String batchId = (String) requestMap.get("batchId");
|
String region = (String) request.getOrDefault("region", "JP");
|
||||||
String region = (String) requestMap.getOrDefault("region", "JP");
|
|
||||||
List<AmazonProductEntity> products = amazonScrapingService.batchGetProductInfo(asinList, batchId, region);
|
// 从 token 中获取 username
|
||||||
Map<String, Object> result = new HashMap<>();
|
String username = JwtUtil.getUsernameFromRequest(httpRequest);
|
||||||
result.put("products", products);
|
// 构建带用户隔离的 sessionId
|
||||||
result.put("total", products.size());
|
String userSessionId = JwtUtil.buildUserSessionId(username, batchId);
|
||||||
return JsonData.buildSuccess(result);
|
|
||||||
|
List<AmazonProductEntity> products = amazonScrapingService.batchGetProductInfo(asinList, userSessionId, region);
|
||||||
|
return JsonData.buildSuccess(Map.of("products", products, "total", products.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取最新产品数据
|
* 获取最新的亚马逊商品数据
|
||||||
|
*
|
||||||
|
* @param request HTTP请求对象,用于获取用户信息
|
||||||
|
* @return 最新商品列表和总数
|
||||||
*/
|
*/
|
||||||
@GetMapping("/products/latest")
|
@GetMapping("/products/latest")
|
||||||
public JsonData getLatestProducts() {
|
public JsonData getLatestProducts(HttpServletRequest request) {
|
||||||
List<AmazonProductEntity> products = amazonProductRepository.findLatestProducts();
|
String username = JwtUtil.getUsernameFromRequest(request);
|
||||||
Map<String, Object> result = new HashMap<>();
|
List<String> recentSessions = amazonProductRepository.findRecentSessionIds(org.springframework.data.domain.PageRequest.of(0, 1));
|
||||||
result.put("products", products);
|
String latestSession = recentSessions.stream()
|
||||||
result.put("total", products.size());
|
.filter(sid -> sid != null && sid.startsWith(username + "#"))
|
||||||
return JsonData.buildSuccess(result);
|
.findFirst()
|
||||||
|
.orElse("");
|
||||||
|
|
||||||
|
if (latestSession.isEmpty()) {
|
||||||
|
return JsonData.buildSuccess(Map.of("products", List.of(), "total", 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AmazonProductEntity> products = amazonProductRepository.findBySessionIdOrderByCreatedAtDesc(latestSession);
|
||||||
|
return JsonData.buildSuccess(Map.of("products", products, "total", products.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析Excel文件获取ASIN列表
|
* 从Excel文件导入ASIN列表
|
||||||
|
*
|
||||||
|
* @param file Excel文件
|
||||||
|
* @return ASIN列表和总数
|
||||||
*/
|
*/
|
||||||
@PostMapping("/import/asin")
|
@PostMapping("/import/asin")
|
||||||
public JsonData importAsinFromExcel(@RequestParam("file") MultipartFile file) {
|
public JsonData importAsinFromExcel(@RequestParam("file") MultipartFile file) {
|
||||||
@@ -65,16 +88,10 @@ public class AmazonController {
|
|||||||
if (asinList.isEmpty()) {
|
if (asinList.isEmpty()) {
|
||||||
return JsonData.buildError("未从文件中解析到ASIN数据");
|
return JsonData.buildError("未从文件中解析到ASIN数据");
|
||||||
}
|
}
|
||||||
|
return JsonData.buildSuccess(Map.of("asinList", asinList, "total", asinList.size()));
|
||||||
Map<String, Object> result = new HashMap<>();
|
|
||||||
result.put("asinList", asinList);
|
|
||||||
result.put("total", asinList.size());
|
|
||||||
return JsonData.buildSuccess(result);
|
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("解析文件失败: {}", e.getMessage(), e);
|
logger.error("解析文件失败: {}", e.getMessage(), e);
|
||||||
return JsonData.buildError("解析失败: " + e.getMessage());
|
return JsonData.buildError("解析失败: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,61 +1,90 @@
|
|||||||
package com.tashow.erp.controller;
|
package com.tashow.erp.controller;
|
||||||
import com.tashow.erp.fx.controller.JavaBridge;
|
|
||||||
import com.tashow.erp.repository.BanmaOrderRepository;
|
import com.tashow.erp.repository.BanmaOrderRepository;
|
||||||
import com.tashow.erp.service.IBanmaOrderService;
|
import com.tashow.erp.service.BanmaOrderService;
|
||||||
import com.tashow.erp.utils.ExcelExportUtil;
|
|
||||||
import com.tashow.erp.utils.JsonData;
|
import com.tashow.erp.utils.JsonData;
|
||||||
|
import com.tashow.erp.utils.JwtUtil;
|
||||||
import com.tashow.erp.utils.LoggerUtil;
|
import com.tashow.erp.utils.LoggerUtil;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 斑马订单控制器
|
||||||
|
* 提供斑马订单查询、店铺列表等功能
|
||||||
|
*
|
||||||
|
* @author 占子杰牛逼
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/banma")
|
@RequestMapping("/api/banma")
|
||||||
public class BanmaOrderController {
|
public class BanmaOrderController {
|
||||||
private static final Logger logger = LoggerUtil.getLogger(BanmaOrderController.class);
|
private static final Logger logger = LoggerUtil.getLogger(BanmaOrderController.class);
|
||||||
@Autowired
|
@Autowired
|
||||||
IBanmaOrderService banmaOrderService;
|
BanmaOrderService banmaOrderService;
|
||||||
@Autowired
|
@Autowired
|
||||||
BanmaOrderRepository banmaOrderRepository;
|
BanmaOrderRepository banmaOrderRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询斑马订单
|
||||||
|
*
|
||||||
|
* @param accountId 账号ID(可选)
|
||||||
|
* @param startDate 开始日期(可选)
|
||||||
|
* @param endDate 结束日期(可选)
|
||||||
|
* @param page 页码,默认为1
|
||||||
|
* @param pageSize 每页大小,默认为10
|
||||||
|
* @param batchId 批次ID
|
||||||
|
* @param shopIds 店铺ID列表,多个用逗号分隔(可选)
|
||||||
|
* @param request HTTP请求对象,用于获取用户信息
|
||||||
|
* @return 订单列表和分页信息
|
||||||
|
*/
|
||||||
@GetMapping("/orders")
|
@GetMapping("/orders")
|
||||||
public ResponseEntity<Map<String, Object>> getOrders(
|
public JsonData getOrders(
|
||||||
@RequestParam(required = false, name = "accountId") Long accountId,
|
@RequestParam(required = false, name = "accountId") Long accountId,
|
||||||
@RequestParam(required = false, name = "startDate") String startDate,
|
@RequestParam(required = false, name = "startDate") String startDate,
|
||||||
@RequestParam(required = false, name = "endDate") String endDate,
|
@RequestParam(required = false, name = "endDate") String endDate,
|
||||||
@RequestParam(defaultValue = "1", name = "page") int page,
|
@RequestParam(defaultValue = "1", name = "page") int page,
|
||||||
@RequestParam(defaultValue = "10", name = "pageSize") int pageSize,
|
@RequestParam(defaultValue = "10", name = "pageSize") int pageSize,
|
||||||
@RequestParam( "batchId") String batchId,
|
@RequestParam("batchId") String batchId,
|
||||||
@RequestParam(required = false, name = "shopIds") String shopIds) {
|
@RequestParam(required = false, name = "shopIds") String shopIds,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
// 从 token 中获取 username
|
||||||
|
String username = JwtUtil.getUsernameFromRequest(request);
|
||||||
|
// 构建带用户隔离的 sessionId
|
||||||
|
String userSessionId = JwtUtil.buildUserSessionId(username, batchId);
|
||||||
|
|
||||||
List<String> shopIdList = shopIds != null ? java.util.Arrays.asList(shopIds.split(",")) : null;
|
List<String> shopIdList = shopIds != null ? java.util.Arrays.asList(shopIds.split(",")) : null;
|
||||||
Map<String, Object> result = banmaOrderService.getOrdersByPage(accountId, startDate, endDate, page, pageSize, batchId, shopIdList);
|
Map<String, Object> result = banmaOrderService.getOrdersByPage(accountId, startDate, endDate, page, pageSize, userSessionId, shopIdList);
|
||||||
return ResponseEntity.ok(result);
|
return result.containsKey("success") && !(Boolean)result.get("success")
|
||||||
}
|
? JsonData.buildError((String)result.get("message"))
|
||||||
/**
|
: JsonData.buildSuccess(result);
|
||||||
* 获取店铺列表
|
|
||||||
*/
|
|
||||||
@GetMapping("/shops")
|
|
||||||
public JsonData getShops(@RequestParam(required = false, name = "accountId") Long accountId) {
|
|
||||||
try {
|
|
||||||
Map<String, Object> response = banmaOrderService.getShops(accountId);
|
|
||||||
return JsonData.buildSuccess(response);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.error("获取店铺列表失败: {}", e.getMessage(), e);
|
|
||||||
return JsonData.buildError("获取店铺列表失败: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取最新订单数据
|
* 获取店铺列表
|
||||||
|
*
|
||||||
|
* @param accountId 账号ID(可选)
|
||||||
|
* @return 店铺列表
|
||||||
|
*/
|
||||||
|
@GetMapping("/shops")
|
||||||
|
public JsonData getShops(@RequestParam(required = false, name = "accountId") Long accountId) {
|
||||||
|
Map<String, Object> response = banmaOrderService.getShops(accountId);
|
||||||
|
return JsonData.buildSuccess(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最新的斑马订单数据
|
||||||
|
*
|
||||||
|
* @param request HTTP请求对象,用于获取用户信息
|
||||||
|
* @return 最新订单列表和总数
|
||||||
*/
|
*/
|
||||||
@GetMapping("/orders/latest")
|
@GetMapping("/orders/latest")
|
||||||
public JsonData getLatestOrders() {
|
public JsonData getLatestOrders(HttpServletRequest request) {
|
||||||
|
String username = JwtUtil.getUsernameFromRequest(request);
|
||||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
List<Map<String, Object>> orders = banmaOrderRepository.findLatestOrders()
|
List<Map<String, Object>> orders = banmaOrderRepository.findLatestOrders(username)
|
||||||
.parallelStream()
|
.parallelStream()
|
||||||
.map(entity -> {
|
.map(entity -> {
|
||||||
try {
|
try {
|
||||||
@@ -68,8 +97,6 @@ public class BanmaOrderController {
|
|||||||
})
|
})
|
||||||
.filter(order -> !order.isEmpty())
|
.filter(order -> !order.isEmpty())
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return JsonData.buildSuccess(Map.of("orders", orders, "total", orders.size()));
|
return JsonData.buildSuccess(Map.of("orders", orders, "total", orders.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,33 +1,30 @@
|
|||||||
package com.tashow.erp.controller;
|
package com.tashow.erp.controller;
|
||||||
|
|
||||||
|
import com.tashow.erp.common.RakutenConstants;
|
||||||
import com.tashow.erp.model.RakutenProduct;
|
import com.tashow.erp.model.RakutenProduct;
|
||||||
import com.tashow.erp.model.SearchResult;
|
import com.tashow.erp.model.SearchResult;
|
||||||
import com.tashow.erp.repository.RakutenProductRepository;
|
|
||||||
import com.tashow.erp.service.Alibaba1688Service;
|
import com.tashow.erp.service.Alibaba1688Service;
|
||||||
import com.tashow.erp.service.IRakutenCacheService;
|
import com.tashow.erp.service.RakutenCacheService;
|
||||||
import com.tashow.erp.service.RakutenScrapingService;
|
import com.tashow.erp.service.RakutenScrapingService;
|
||||||
import com.tashow.erp.service.impl.Alibaba1688ServiceImpl;
|
|
||||||
import com.tashow.erp.utils.DataReportUtil;
|
import com.tashow.erp.utils.DataReportUtil;
|
||||||
import com.tashow.erp.utils.ExcelParseUtil;
|
import com.tashow.erp.utils.ExcelParseUtil;
|
||||||
import com.tashow.erp.utils.JsonData;
|
import com.tashow.erp.utils.JsonData;
|
||||||
import com.tashow.erp.utils.QiniuUtil;
|
import com.tashow.erp.utils.JwtUtil;
|
||||||
import com.tashow.erp.fx.controller.JavaBridge;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.util.CollectionUtils;
|
import org.springframework.util.CollectionUtils;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.multipart.MultipartFile;
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
import java.time.Instant;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.ZoneOffset;
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 乐天数据控制器
|
||||||
|
* 提供乐天商品采集、1688识图搜索等功能
|
||||||
|
*
|
||||||
|
* @author 占子杰牛逼
|
||||||
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/rakuten")
|
@RequestMapping("/api/rakuten")
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -37,35 +34,38 @@ public class RakutenController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private Alibaba1688Service alibaba1688Service;
|
private Alibaba1688Service alibaba1688Service;
|
||||||
@Autowired
|
@Autowired
|
||||||
private IRakutenCacheService rakutenCacheService;
|
private RakutenCacheService rakutenCacheService;
|
||||||
@Autowired
|
|
||||||
private JavaBridge javaBridge;
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private DataReportUtil dataReportUtil;
|
private DataReportUtil dataReportUtil;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取乐天商品数据
|
* 从Excel获取乐天商品信息
|
||||||
*
|
* 支持缓存机制,避免重复采集
|
||||||
* @param file Excel文件(首列为店铺名)
|
*
|
||||||
* @param batchId 可选,批次号
|
* @param file 包含店铺名称的Excel文件
|
||||||
* @return JsonData 响应
|
* @param batchId 批次ID(可选)
|
||||||
|
* @param request HTTP请求对象,用于获取用户信息
|
||||||
|
* @return 商品列表、总数、跳过的店铺等信息
|
||||||
*/
|
*/
|
||||||
@PostMapping(value = "/products")
|
@PostMapping(value = "/products")
|
||||||
public JsonData getProducts(@RequestParam("file") MultipartFile file, @RequestParam(value = "batchId", required = false) String batchId) {
|
public JsonData getProducts(@RequestParam("file") MultipartFile file, @RequestParam(value = "batchId", required = false) String batchId, HttpServletRequest request) {
|
||||||
try {
|
try {
|
||||||
|
// 从 token 中获取 username
|
||||||
|
String username = JwtUtil.getUsernameFromRequest(request);
|
||||||
|
// 构建带用户隔离的 sessionId
|
||||||
|
String userSessionId = JwtUtil.buildUserSessionId(username, batchId);
|
||||||
|
|
||||||
List<String> shopNames = ExcelParseUtil.parseFirstColumn(file);
|
List<String> shopNames = ExcelParseUtil.parseFirstColumn(file);
|
||||||
if (CollectionUtils.isEmpty(shopNames)) {
|
if (CollectionUtils.isEmpty(shopNames)) {
|
||||||
return JsonData.buildError("Excel文件中未解析到店铺名");
|
return JsonData.buildError("Excel文件中未解析到店铺名");
|
||||||
}
|
}
|
||||||
List<RakutenProduct> allProducts = new ArrayList<>();
|
List<RakutenProduct> allProducts = new ArrayList<>();
|
||||||
List<String> skippedShops = new ArrayList<>();
|
List<String> skippedShops = new ArrayList<>();
|
||||||
|
|
||||||
// 2. 遍历店铺,优先缓存,缺失则爬取
|
|
||||||
for (String currentShopName : shopNames) {
|
for (String currentShopName : shopNames) {
|
||||||
if (rakutenCacheService.hasRecentData(currentShopName)) {
|
if (rakutenCacheService.hasRecentData(currentShopName, username)) {
|
||||||
// 从缓存获取
|
// 从缓存获取
|
||||||
List<RakutenProduct> cached = rakutenCacheService.getProductsByShopName(currentShopName).stream().filter(p -> currentShopName.equals(p.getOriginalShopName())).toList();
|
List<RakutenProduct> cached = rakutenCacheService.getProductsByShopName(currentShopName, username).stream().filter(p -> currentShopName.equals(p.getOriginalShopName())).toList();
|
||||||
rakutenCacheService.updateSpecificProductsSessionId(cached, batchId);
|
rakutenCacheService.updateSpecificProductsSessionId(cached, userSessionId);
|
||||||
allProducts.addAll(cached);
|
allProducts.addAll(cached);
|
||||||
skippedShops.add(currentShopName);
|
skippedShops.add(currentShopName);
|
||||||
log.info("使用缓存数据,店铺: {},数量: {}", currentShopName, cached.size());
|
log.info("使用缓存数据,店铺: {},数量: {}", currentShopName, cached.size());
|
||||||
@@ -80,15 +80,19 @@ public class RakutenController {
|
|||||||
}
|
}
|
||||||
List<RakutenProduct> newProducts = allProducts.stream().filter(p -> !skippedShops.contains(p.getOriginalShopName())).toList();
|
List<RakutenProduct> newProducts = allProducts.stream().filter(p -> !skippedShops.contains(p.getOriginalShopName())).toList();
|
||||||
if (!newProducts.isEmpty()) {
|
if (!newProducts.isEmpty()) {
|
||||||
rakutenCacheService.saveProductsWithSessionId(newProducts, batchId);
|
rakutenCacheService.saveProductsWithSessionId(newProducts, userSessionId);
|
||||||
}
|
}
|
||||||
// 4. 上报缓存数据使用情况
|
|
||||||
int cachedCount = allProducts.size() - newProducts.size();
|
int cachedCount = allProducts.size() - newProducts.size();
|
||||||
if (cachedCount > 0) {
|
if (cachedCount > 0) {
|
||||||
dataReportUtil.reportDataCollection("RAKUTEN_CACHE", cachedCount, "0");
|
dataReportUtil.reportDataCollection(RakutenConstants.DATA_TYPE_CACHE, cachedCount, "0");
|
||||||
}
|
}
|
||||||
|
return JsonData.buildSuccess(Map.of(
|
||||||
return JsonData.buildSuccess(Map.of("products", allProducts, "total", allProducts.size(), "sessionId", batchId, "skippedShops", skippedShops, "newProductsCount", newProducts.size()));
|
"products", allProducts,
|
||||||
|
"total", allProducts.size(),
|
||||||
|
"sessionId", batchId,
|
||||||
|
"skippedShops", skippedShops,
|
||||||
|
"newProductsCount", newProducts.size()
|
||||||
|
));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("获取乐天商品失败", e);
|
log.error("获取乐天商品失败", e);
|
||||||
return JsonData.buildError("获取乐天商品失败: " + e.getMessage());
|
return JsonData.buildError("获取乐天商品失败: " + e.getMessage());
|
||||||
@@ -96,15 +100,25 @@ public class RakutenController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 1688识图搜索API - 自动保存1688搜索结果
|
* 1688识图搜索
|
||||||
|
* 根据图片URL在1688平台进行识图搜索
|
||||||
|
*
|
||||||
|
* @param params 包含imageUrl和sessionId的参数
|
||||||
|
* @param request HTTP请求对象,用于获取用户信息
|
||||||
|
* @return 搜索结果
|
||||||
*/
|
*/
|
||||||
@PostMapping("/search1688")
|
@PostMapping("/search1688")
|
||||||
public JsonData search1688(@RequestBody Map<String, Object> params) {
|
public JsonData search1688(@RequestBody Map<String, Object> params, HttpServletRequest request) {
|
||||||
String imageUrl = (String) params.get("imageUrl");
|
String imageUrl = (String) params.get("imageUrl");
|
||||||
String sessionId = (String) params.get("sessionId");
|
String sessionId = (String) params.get("sessionId");
|
||||||
try {
|
try {
|
||||||
|
// 从 token 中获取 username
|
||||||
|
String username = JwtUtil.getUsernameFromRequest(request);
|
||||||
|
// 构建带用户隔离的 sessionId
|
||||||
|
String userSessionId = JwtUtil.buildUserSessionId(username, sessionId);
|
||||||
|
|
||||||
SearchResult result = alibaba1688Service.get1688Detail(imageUrl);
|
SearchResult result = alibaba1688Service.get1688Detail(imageUrl);
|
||||||
rakutenScrapingService.update1688DataByImageUrl(result, sessionId, imageUrl);
|
rakutenScrapingService.update1688DataByImageUrl(result, userSessionId, imageUrl);
|
||||||
return JsonData.buildSuccess(result);
|
return JsonData.buildSuccess(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("1688识图搜索失败", e);
|
log.error("1688识图搜索失败", e);
|
||||||
@@ -112,39 +126,21 @@ public class RakutenController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最新的乐天商品数据
|
||||||
|
*
|
||||||
|
* @param request HTTP请求对象,用于获取用户信息
|
||||||
|
* @return 最新商品列表和总数
|
||||||
|
*/
|
||||||
@GetMapping("/products/latest")
|
@GetMapping("/products/latest")
|
||||||
public JsonData getLatestProducts() {
|
public JsonData getLatestProducts(HttpServletRequest request) {
|
||||||
try {
|
try {
|
||||||
List<Map<String, Object>> products = rakutenScrapingService.getLatestProductsForDisplay();
|
String username = JwtUtil.getUsernameFromRequest(request);
|
||||||
|
List<Map<String, Object>> products = rakutenScrapingService.getLatestProductsForDisplay(username);
|
||||||
return JsonData.buildSuccess(Map.of("products", products, "total", products.size()));
|
return JsonData.buildSuccess(Map.of("products", products, "total", products.size()));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
log.error("获取最新商品数据失败", e);
|
||||||
e.printStackTrace();
|
|
||||||
log.info("获取最新商品数据失败", e);
|
|
||||||
return JsonData.buildError("获取最新数据失败: " + e.getMessage());
|
return JsonData.buildError("获取最新数据失败: " + e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 解析 skuPriceJson 或 skuPrice 字段中的价格键,返回从小到大排序的价格列表
|
|
||||||
private static List<Double> parseSkuPriceList(Object skuPriceJson, Object skuPrice) {
|
|
||||||
String src = skuPriceJson != null ? String.valueOf(skuPriceJson) : (skuPrice != null ? String.valueOf(skuPrice) : null);
|
|
||||||
if (src == null || src.isEmpty()) return Collections.emptyList();
|
|
||||||
try {
|
|
||||||
Pattern pattern = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*:");
|
|
||||||
Matcher m = pattern.matcher(src);
|
|
||||||
List<Double> prices = new ArrayList<>();
|
|
||||||
while (m.find()) {
|
|
||||||
String num = m.group(1);
|
|
||||||
try { prices.add(Double.parseDouble(num)); } catch (NumberFormatException ignored) {}
|
|
||||||
}
|
|
||||||
Collections.sort(prices);
|
|
||||||
return prices;
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
return Collections.emptyList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,45 +1,49 @@
|
|||||||
package com.tashow.erp.controller;
|
package com.tashow.erp.controller;
|
||||||
|
|
||||||
import com.tashow.erp.entity.AuthTokenEntity;
|
import com.tashow.erp.entity.AuthTokenEntity;
|
||||||
import com.tashow.erp.repository.AuthTokenRepository;
|
import com.tashow.erp.repository.AuthTokenRepository;
|
||||||
import com.tashow.erp.service.IGenmaiService;
|
import com.tashow.erp.service.CacheService;
|
||||||
|
import com.tashow.erp.service.impl.GenmaiServiceImpl;
|
||||||
import com.tashow.erp.utils.JsonData;
|
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.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.*;
|
import org.springframework.http.*;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 系统级接口控制器
|
* 系统管理控制器
|
||||||
* 整合:认证、配置、版本、工具、代理等功能
|
* 提供系统配置、认证管理、设备信息等功能
|
||||||
|
*
|
||||||
|
* @author 占子杰牛逼
|
||||||
*/
|
*/
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/system")
|
@RequestMapping("/api/system")
|
||||||
public class SystemController {
|
public class SystemController {
|
||||||
|
private static final Logger logger = LoggerUtil.getLogger(SystemController.class);
|
||||||
@Autowired
|
@Autowired
|
||||||
private AuthTokenRepository authTokenRepository;
|
private AuthTokenRepository authTokenRepository;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private IGenmaiService genmaiService;
|
private GenmaiServiceImpl genmaiService;
|
||||||
|
@Autowired
|
||||||
|
private CacheService cacheService;
|
||||||
@Autowired
|
@Autowired
|
||||||
private RestTemplate restTemplate;
|
private RestTemplate restTemplate;
|
||||||
|
|
||||||
@Value("${project.version:2.3.6}")
|
@Value("${project.version:2.3.6}")
|
||||||
private String currentVersion;
|
private String currentVersion;
|
||||||
|
|
||||||
@Value("${project.build.time:}")
|
@Value("${project.build.time:}")
|
||||||
private String buildTime;
|
private String buildTime;
|
||||||
|
|
||||||
@Value("${api.server.base-url}")
|
@Value("${api.server.base-url}")
|
||||||
private String serverBaseUrl;
|
private String serverBaseUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存认证密钥
|
* 保存服务认证信息
|
||||||
|
*
|
||||||
|
* @param data 包含serviceName和authKey的认证信息
|
||||||
|
* @return 操作结果
|
||||||
*/
|
*/
|
||||||
@PostMapping("/auth/save")
|
@PostMapping("/auth/save")
|
||||||
public JsonData saveAuth(@RequestBody Map<String, Object> data) {
|
public JsonData saveAuth(@RequestBody Map<String, Object> data) {
|
||||||
@@ -48,8 +52,7 @@ public class SystemController {
|
|||||||
if (serviceName == null || authKey == null) {
|
if (serviceName == null || authKey == null) {
|
||||||
return JsonData.buildError("serviceName和authKey不能为空");
|
return JsonData.buildError("serviceName和authKey不能为空");
|
||||||
}
|
}
|
||||||
AuthTokenEntity entity = authTokenRepository.findByServiceName(serviceName)
|
AuthTokenEntity entity = authTokenRepository.findByServiceName(serviceName).orElse(new AuthTokenEntity());
|
||||||
.orElse(new AuthTokenEntity());
|
|
||||||
entity.setServiceName(serviceName);
|
entity.setServiceName(serviceName);
|
||||||
entity.setToken(authKey);
|
entity.setToken(authKey);
|
||||||
authTokenRepository.save(entity);
|
authTokenRepository.save(entity);
|
||||||
@@ -57,7 +60,10 @@ public class SystemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取认证密钥
|
* 获取指定服务的认证信息
|
||||||
|
*
|
||||||
|
* @param serviceName 服务名称
|
||||||
|
* @return 认证token信息
|
||||||
*/
|
*/
|
||||||
@GetMapping("/auth/get")
|
@GetMapping("/auth/get")
|
||||||
public JsonData getAuth(@RequestParam String serviceName) {
|
public JsonData getAuth(@RequestParam String serviceName) {
|
||||||
@@ -67,67 +73,93 @@ public class SystemController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除认证密钥
|
* 删除指定服务的认证信息
|
||||||
|
*
|
||||||
|
* @param serviceName 服务名称
|
||||||
|
* @return 操作结果
|
||||||
*/
|
*/
|
||||||
@DeleteMapping("/auth/remove")
|
@DeleteMapping("/auth/remove")
|
||||||
public JsonData removeAuth(@RequestParam String serviceName) {
|
public JsonData removeAuth(@RequestParam String serviceName) {
|
||||||
authTokenRepository.findByServiceName(serviceName)
|
authTokenRepository.findByServiceName(serviceName).ifPresent(authTokenRepository::delete);
|
||||||
.ifPresent(authTokenRepository::delete);
|
|
||||||
return JsonData.buildSuccess("认证信息删除成功");
|
return JsonData.buildSuccess("认证信息删除成功");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 设备管理 ====================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取设备ID
|
* 获取设备唯一标识
|
||||||
|
*
|
||||||
|
* @return 设备ID
|
||||||
*/
|
*/
|
||||||
@GetMapping("/device-id")
|
@GetMapping("/device-id")
|
||||||
public JsonData getDeviceId() {
|
public JsonData getDeviceId() {
|
||||||
String deviceId = com.tashow.erp.utils.DeviceUtils.generateDeviceId();
|
return JsonData.buildSuccess(com.tashow.erp.utils.DeviceUtils.generateDeviceId());
|
||||||
return JsonData.buildSuccess(deviceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 版本信息 ====================
|
/**
|
||||||
|
* 获取本机IP地址
|
||||||
|
*
|
||||||
|
* @return 本机IP地址
|
||||||
|
* @throws Exception 获取IP失败时抛出异常
|
||||||
|
*/
|
||||||
|
@GetMapping("/local-ip")
|
||||||
|
public JsonData getLocalIp() throws Exception {
|
||||||
|
return JsonData.buildSuccess(java.net.InetAddress.getLocalHost().getHostAddress());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前版本号
|
* 获取计算机名称
|
||||||
|
*
|
||||||
|
* @return 计算机名称
|
||||||
|
*/
|
||||||
|
@GetMapping("/computer-name")
|
||||||
|
public JsonData getComputerName() {
|
||||||
|
return JsonData.buildSuccess(System.getenv("COMPUTERNAME"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统版本信息
|
||||||
|
*
|
||||||
|
* @return 包含当前版本号和构建时间的信息
|
||||||
*/
|
*/
|
||||||
@GetMapping("/version")
|
@GetMapping("/version")
|
||||||
public Map<String, Object> getVersion() {
|
public Map<String, Object> getVersion() {
|
||||||
Map<String, Object> result = new HashMap<>();
|
return Map.of("success", true, "currentVersion", currentVersion, "buildTime", buildTime);
|
||||||
result.put("success", true);
|
|
||||||
result.put("currentVersion", currentVersion);
|
|
||||||
result.put("buildTime", buildTime);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 配置信息 ====================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取服务器配置
|
* 获取服务器配置信息
|
||||||
|
*
|
||||||
|
* @return 包含服务器基础URL和SSE URL的配置信息
|
||||||
*/
|
*/
|
||||||
@GetMapping("/config/server")
|
@GetMapping("/config/server")
|
||||||
public Map<String, Object> getServerConfig() {
|
public Map<String, Object> getServerConfig() {
|
||||||
return Map.of(
|
return Map.of("baseUrl", serverBaseUrl, "sseUrl", serverBaseUrl + "/monitor/account/events");
|
||||||
"baseUrl", serverBaseUrl,
|
|
||||||
"sseUrl", serverBaseUrl + "/monitor/account/events"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 工具功能 ====================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开跟卖精灵网页
|
* 打开跟卖精灵网站
|
||||||
|
*
|
||||||
|
* @param accountId 账号ID
|
||||||
|
* @param request HTTP请求对象,用于获取用户信息
|
||||||
|
* @return 操作结果
|
||||||
*/
|
*/
|
||||||
@PostMapping("/genmai/open")
|
@PostMapping("/genmai/open")
|
||||||
public void openGenmaiWebsite() {
|
public JsonData openGenmaiWebsite(@RequestParam(required = false) Long accountId, HttpServletRequest request) {
|
||||||
genmaiService.openGenmaiWebsite();
|
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() : "打开跟卖精灵失败");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 图片代理 ====================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代理获取图片(解决CORS跨域问题)
|
* 图片代理接口
|
||||||
|
* 用于代理获取外部图片资源,解决跨域问题
|
||||||
|
*
|
||||||
|
* @param imageUrl 图片URL
|
||||||
|
* @return 图片字节数组响应
|
||||||
*/
|
*/
|
||||||
@GetMapping("/proxy/image")
|
@GetMapping("/proxy/image")
|
||||||
public ResponseEntity<byte[]> proxyImage(@RequestParam("url") String imageUrl) {
|
public ResponseEntity<byte[]> proxyImage(@RequestParam("url") String imageUrl) {
|
||||||
@@ -158,12 +190,22 @@ public class SystemController {
|
|||||||
responseHeaders.setContentType(MediaType.IMAGE_JPEG);
|
responseHeaders.setContentType(MediaType.IMAGE_JPEG);
|
||||||
}
|
}
|
||||||
responseHeaders.setCacheControl("max-age=3600");
|
responseHeaders.setCacheControl("max-age=3600");
|
||||||
|
|
||||||
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
|
return new ResponseEntity<>(response.getBody(), responseHeaders, HttpStatus.OK);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
logger.error("代理图片失败: {}", imageUrl, e);
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理系统缓存
|
||||||
|
*
|
||||||
|
* @return 操作结果
|
||||||
|
*/
|
||||||
|
@PostMapping("/cache/clear")
|
||||||
|
public JsonData clearCache() {
|
||||||
|
cacheService.clearCache();
|
||||||
|
return JsonData.buildSuccess("缓存清理成功");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"; // 全局缓存
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
@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();
|
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小时内重复)
|
* 删除指定ASIN在指定时间后的数据(用于清理12小时内重复)
|
||||||
|
|||||||
@@ -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)
|
@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();
|
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小时内重复)
|
* 删除指定追踪号在指定时间后的数据(用于清理12小时内重复)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -52,6 +52,16 @@ public interface RakutenProductRepository extends JpaRepository<RakutenProductEn
|
|||||||
* 检查指定店铺在指定时间后是否有数据
|
* 检查指定店铺在指定时间后是否有数据
|
||||||
*/
|
*/
|
||||||
boolean existsByOriginalShopNameAndCreatedAtAfter(String originalShopName, LocalDateTime sinceTime);
|
boolean existsByOriginalShopNameAndCreatedAtAfter(String originalShopName, LocalDateTime sinceTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查指定店铺在指定时间后是否有数据(按用户隔离)
|
||||||
|
*/
|
||||||
|
boolean existsByOriginalShopNameAndSessionIdStartingWithAndCreatedAtAfter(String originalShopName, String sessionIdPrefix, LocalDateTime sinceTime);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据店铺名和 sessionId 前缀获取产品(按用户隔离)
|
||||||
|
*/
|
||||||
|
List<RakutenProductEntity> findByOriginalShopNameAndSessionIdStartingWithOrderByCreatedAtAscIdAsc(String originalShopName, String sessionIdPrefix);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取最新的会话ID
|
* 获取最新的会话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)
|
@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();
|
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小时内重复)
|
* 删除指定商品URL在指定时间后的数据(用于清理12小时内重复)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package com.tashow.erp.security;
|
package com.tashow.erp.security;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.tashow.erp.service.IAuthService;
|
import com.tashow.erp.service.impl.AuthServiceImpl;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -10,16 +10,13 @@ import org.springframework.web.servlet.HandlerInterceptor;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
|
|
||||||
/**
|
|
||||||
* 本地拦截器
|
|
||||||
*/
|
|
||||||
@Component
|
@Component
|
||||||
public class LocalJwtAuthInterceptor implements HandlerInterceptor {
|
public class LocalJwtAuthInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
private final IAuthService authService;
|
private final AuthServiceImpl authService;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
public LocalJwtAuthInterceptor(IAuthService authService) {
|
public LocalJwtAuthInterceptor(AuthServiceImpl authService) {
|
||||||
this.authService = authService;
|
this.authService = authService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,7 @@ package com.tashow.erp.service;
|
|||||||
import com.tashow.erp.entity.AmazonProductEntity;
|
import com.tashow.erp.entity.AmazonProductEntity;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
public interface AmazonScrapingService {
|
||||||
* 亚马逊数据采集服务接口
|
|
||||||
*
|
|
||||||
* @author ruoyi
|
|
||||||
*/
|
|
||||||
public interface IAmazonScrapingService {
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量获取亚马逊产品信息
|
* 批量获取亚马逊产品信息
|
||||||
@@ -19,10 +14,5 @@ public interface IAmazonScrapingService {
|
|||||||
* @return 产品信息列表
|
* @return 产品信息列表
|
||||||
*/
|
*/
|
||||||
List<AmazonProductEntity> batchGetProductInfo(List<String> asinList, String batchId, String region);
|
List<AmazonProductEntity> batchGetProductInfo(List<String> asinList, String batchId, String region);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||