Initial commit

This commit is contained in:
2025-09-22 11:51:16 +08:00
commit c32381f8ed
1191 changed files with 130140 additions and 0 deletions

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
{
"appId": "com.electron.app",
"directories": {
"output": "dist"
},
"nsis": {
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
"shortcutName": "Electron App"
},
"win": {
"target": "nsis"
},
"linux": {
"target": ["snap"]
},
"files": [
{
"from": "build/main",
"to": "main",
"filter": ["**/*"]
},
{
"from": "build/renderer",
"to": "renderer",
"filter": ["**/*"]
},
{
"from": "src/main/static",
"to": "static",
"filter": ["**/*"]
},
"!build",
"!dist",
"!scripts"
]
}

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
{
"name": "electron-vue-template",
"version": "0.1.0",
"description": "A minimal Electron + Vue application",
"main": "main/main.js",
"scripts": {
"dev": "node scripts/dev-server.js",
"build": "node scripts/build.js && electron-builder",
"build:win": "node scripts/build.js && electron-builder --win",
"build:mac": "node scripts/build.js && electron-builder --mac",
"build:linux": "node scripts/build.js && electron-builder --linux"
},
"repository": "https://github.com/deluze/electron-vue-template",
"author": {
"name": "Deluze",
"url": "https://github.com/Deluze"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.4.1",
"chalk": "^4.1.2",
"chokidar": "^3.5.3",
"electron": "^32.1.2",
"electron-builder": "^25.1.6",
"typescript": "^5.2.2",
"vite": "^4.5.0"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"element-plus": "^2.11.3",
"exceljs": "^4.4.0",
"vue": "^3.3.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

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

View File

@@ -0,0 +1,32 @@
const Path = require('path');
const Chalk = require('chalk');
const FileSystem = require('fs');
const Vite = require('vite');
const compileTs = require('./private/tsc');
function buildRenderer() {
return Vite.build({
configFile: Path.join(__dirname, '..', 'vite.config.js'),
base: './',
mode: 'production'
});
}
function buildMain() {
const mainPath = Path.join(__dirname, '..', 'src', 'main');
return compileTs(mainPath);
}
FileSystem.rmSync(Path.join(__dirname, '..', 'build'), {
recursive: true,
force: true,
})
console.log(Chalk.blueBright('Transpiling renderer & main...'));
Promise.allSettled([
buildRenderer(),
buildMain(),
]).then(() => {
console.log(Chalk.greenBright('Renderer & main successfully transpiled! (ready to be built with electron-builder)'));
});

View File

@@ -0,0 +1,121 @@
process.env.NODE_ENV = 'development';
const Vite = require('vite');
const ChildProcess = require('child_process');
const Path = require('path');
const Chalk = require('chalk');
const Chokidar = require('chokidar');
const Electron = require('electron');
const compileTs = require('./private/tsc');
const FileSystem = require('fs');
const { EOL } = require('os');
let viteServer = null;
let electronProcess = null;
let electronProcessLocker = false;
let rendererPort = 0;
async function startRenderer() {
viteServer = await Vite.createServer({
configFile: Path.join(__dirname, '..', 'vite.config.js'),
mode: 'development',
});
return viteServer.listen();
}
async function startElectron() {
if (electronProcess) { // single instance lock
return;
}
try {
await compileTs(Path.join(__dirname, '..', 'src', 'main'));
} catch {
console.log(Chalk.redBright('Could not start Electron because of the above typescript error(s).'));
electronProcessLocker = false;
return;
}
const args = [
Path.join(__dirname, '..', 'build', 'main', 'main.js'),
rendererPort,
];
electronProcess = ChildProcess.spawn(Electron, args);
electronProcessLocker = false;
electronProcess.stdout.on('data', data => {
if (data == EOL) {
return;
}
process.stdout.write(Chalk.blueBright(`[electron] `) + Chalk.white(data.toString()))
});
electronProcess.stderr.on('data', data =>
process.stderr.write(Chalk.blueBright(`[electron] `) + Chalk.white(data.toString()))
);
electronProcess.on('exit', () => stop());
}
function restartElectron() {
if (electronProcess) {
electronProcess.removeAllListeners('exit');
electronProcess.kill();
electronProcess = null;
}
if (!electronProcessLocker) {
electronProcessLocker = true;
startElectron();
}
}
function copyStaticFiles() {
copy('static');
}
/*
The working dir of Electron is build/main instead of src/main because of TS.
tsc does not copy static files, so copy them over manually for dev server.
*/
function copy(path) {
FileSystem.cpSync(
Path.join(__dirname, '..', 'src', 'main', path),
Path.join(__dirname, '..', 'build', 'main', path),
{ recursive: true }
);
}
function stop() {
viteServer.close();
process.exit();
}
async function start() {
console.log(`${Chalk.greenBright('=======================================')}`);
console.log(`${Chalk.greenBright('Starting Electron + Vite Dev Server...')}`);
console.log(`${Chalk.greenBright('=======================================')}`);
const devServer = await startRenderer();
rendererPort = devServer.config.server.port;
copyStaticFiles();
startElectron();
const path = Path.join(__dirname, '..', 'src', 'main');
Chokidar.watch(path, {
cwd: path,
}).on('change', (path) => {
console.log(Chalk.blueBright(`[electron] `) + `Change in ${path}. reloading... 🚀`);
if (path.startsWith(Path.join('static', '/'))) {
copy(path);
}
restartElectron();
});
}
start();

View File

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

View File

@@ -0,0 +1,199 @@
// 主进程:创建窗口、启动后端 JAR、隐藏菜单栏
import {app, BrowserWindow, ipcMain, session, Menu, screen} from 'electron';
import { Socket } from 'net';
import { existsSync } from 'fs';
import {join, dirname} from 'path';
import {spawn, ChildProcessWithoutNullStreams} from 'child_process';
// 保存后端进程与窗口引用,便于退出时清理
let springProcess: ChildProcessWithoutNullStreams | null = null;
let mainWindow: BrowserWindow | null = null;
let splashWindow: BrowserWindow | null = null;
let appOpened = false;
function openAppIfNotOpened() {
if (appOpened) return;
appOpened = true;
if (mainWindow) {
mainWindow.show();
mainWindow.focus();
}
if (splashWindow) { splashWindow.close(); splashWindow = null; }
}
// 启动后端 Spring Boot使用你提供的绝对路径
function startSpringBoot() {
const jarPath = 'C:/Users/ZiJIe/Desktop/wox/RuoYi-Vue/ruoyi-admin/target/ruoyi-admin.jar';
springProcess = spawn('java', ['-jar', jarPath], {
cwd: dirname(jarPath),
detached: false
});
// 打印后端日志,监听启动成功标志
springProcess.stdout.on('data', (data) => {
console.log(`SpringBoot: ${data}`);
// 检测到启动成功日志立即进入主界面
if (data.toString().includes('Started RuoYiApplication')) {
openAppIfNotOpened();
}
});
// 打印后端错误,检测启动失败
springProcess.stderr.on('data', (data) => {
console.error(`SpringBoot ERROR: ${data}`);
const errorStr = data.toString();
// 检测到关键错误信息,直接退出
if (errorStr.includes('APPLICATION FAILED TO START') ||
errorStr.includes('Port') && errorStr.includes('already in use') ||
errorStr.includes('Unable to start embedded Tomcat')) {
console.error('后端启动失败,程序即将退出');
app.quit();
}
});
// 后端退出时,前端同步退出
springProcess.on('close', (code) => {
console.log(`SpringBoot exited with code ${code}`);
if (mainWindow) {
mainWindow.close();
} else {
app.quit();
}
});
}
// 关闭后端进程Windows 使用 taskkill 结束整个进程树)
function stopSpringBoot() {
if (!springProcess) return;
try {
if (process.platform === 'win32') {
// Force kill the whole process tree on Windows
try {
const pid = springProcess.pid;
if (pid !== undefined && pid !== null) {
spawn('taskkill', ['/pid', String(pid), '/f', '/t']);
} else {
springProcess.kill();
}
} catch (e) {
// Fallback
springProcess.kill();
}
} else {
springProcess.kill('SIGTERM');
}
} catch (e) {
console.error('Failed to stop Spring Boot process:', e);
} finally {
springProcess = null;
}
}
// 创建主窗口(预创建但隐藏)
function createWindow () {
mainWindow = new BrowserWindow({
width: 800,
height: 600,
show: false,
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, 'preload.js'),
nodeIntegration: false,
contextIsolation: true,
}
});
// 彻底隐藏原生菜单栏
try {
Menu.setApplicationMenu(null);
mainWindow.setMenuBarVisibility(false);
if (typeof (mainWindow as any).setMenu === 'function') {
(mainWindow as any).setMenu(null);
}
} catch {}
const rendererPort = process.argv[2];
mainWindow.loadURL(`http://localhost:${rendererPort}`);
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(() => {
// 预创建主窗口(隐藏)
createWindow();
// 显示启动页
const { width: sw, height: sh } = screen.getPrimaryDisplay().workAreaSize;
const splashW = Math.min(Math.floor(sw * 0.8), 1800);
const splashH = Math.min(Math.floor(sh * 0.8), 1200);
splashWindow = new BrowserWindow({
width: splashW,
height: splashH,
frame: false,
transparent: false,
resizable: false,
alwaysOnTop: true,
show: true,
center: true,
});
const candidateSplashPaths = [
join(__dirname, '../../public', 'splash.html'),
];
const foundSplash = candidateSplashPaths.find(p => existsSync(p));
if (foundSplash) {
splashWindow.loadFile(foundSplash);
}
// 注释掉后端启动,便于快速调试前端
// startSpringBoot();
// 快速调试模式 - 直接打开主窗口
setTimeout(() => {
openAppIfNotOpened();
}, 1000);
// 注释掉超时机制
/*
setTimeout(() => {
if (!appOpened) {
console.error('后端启动超时,程序即将退出');
app.quit();
}
}, 30000);
*/
// 保守 CSP仅允许自身脚本避免引入不必要的外部脚本
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
callback({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': ['script-src \'self\'']
}
})
})
app.on('activate', function () {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', function () {
stopSpringBoot();
if (process.platform !== 'darwin') app.quit()
});
app.on('before-quit', () => {
stopSpringBoot();
});
ipcMain.on('message', (event, message) => {
console.log(message);
})

View File

@@ -0,0 +1,5 @@
import {contextBridge, ipcRenderer} from 'electron';
contextBridge.exposeInMainWorld('electronAPI', {
sendMessage: (message: string) => ipcRenderer.send('message', message)
})

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2015",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "../../build/main",
"allowJs": true,
"noImplicitAny": false,
},
"exclude": ["static"]
}

View File

@@ -0,0 +1,535 @@
<script setup lang="ts">
import { onMounted, ref, computed, defineAsyncComponent } from 'vue'
import { ElConfigProvider, ElMessage, ElMessageBox } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
// 图标已移至对应组件
import 'element-plus/dist/index.css'
import { authApi } from './api/auth'
import { deviceApi, type DeviceItem, type DeviceQuota } from './api/device'
import ZebraDashboard from './components/zebra/ZebraDashboard.vue'
const LoginDialog = defineAsyncComponent(() => import('./components/auth/LoginDialog.vue'))
const RegisterDialog = defineAsyncComponent(() => import('./components/auth/RegisterDialog.vue'))
const NavigationBar = defineAsyncComponent(() => import('./components/layout/NavigationBar.vue'))
const RakutenDashboard = defineAsyncComponent(() => import('./components/rakuten/RakutenDashboard.vue'))
const AmazonDashboard = defineAsyncComponent(() => import('./components/amazon/AmazonDashboard.vue'))
// 导航历史栈
const navigationHistory = ref<string[]>(['rakuten'])
const currentHistoryIndex = ref(0)
// 应用状态
const activeMenu = ref('rakuten')
const isAuthenticated = ref(false)
const showAuthDialog = ref(false)
const showRegDialog = ref(false)
const zhCnLocale = zhCn
const currentUsername = ref('')
const showDeviceDialog = ref(false)
const deviceLoading = ref(false)
const devices = ref<DeviceItem[]>([])
const deviceQuota = ref<DeviceQuota>({ limit: 0, used: 0 })
const userPermissions = ref<string>('')
// 菜单配置 - 复刻ERP客户端格式
const menuConfig = [
{ key: 'rakuten', name: 'Rakuten', index: 'rakuten', icon: 'R' },
{ key: 'amazon', name: 'Amazon', index: 'amazon', icon: 'A' },
{ key: 'zebra', name: 'Zebra', index: 'zebra', icon: 'Z' },
{ key: 'shopee', name: 'Shopee', index: 'shopee', icon: 'S' },
]
// 权限检查 - 复刻ERP客户端逻辑
function hasPermission(module: string) {
// 默认显示的基础菜单(未登录时也显示)
const defaultModules = ['rakuten', 'amazon', 'zebra']
if (!isAuthenticated.value) {
return defaultModules.includes(module)
}
const permissions = userPermissions.value
if (!permissions) {
return defaultModules.includes(module) // 没有权限信息时显示默认菜单
}
// 简化权限检查:直接检查模块名是否在权限字符串中
return permissions.includes(module)
}
const visibleMenus = computed(() => menuConfig.filter(item => hasPermission(item.key)))
const canGoBack = computed(() => currentHistoryIndex.value > 0)
const canGoForward = computed(() => currentHistoryIndex.value < navigationHistory.value.length - 1)
function showContent() {
const loading = document.getElementById('loading')
if (loading) {
loading.style.opacity = '0'
setTimeout(() => { loading.style.display = 'none' }, 100)
}
const app = document.getElementById('app-root')
if (app) app.style.opacity = '1'
}
function addToHistory(menu: string) {
if (navigationHistory.value[currentHistoryIndex.value] !== menu) {
navigationHistory.value = navigationHistory.value.slice(0, currentHistoryIndex.value + 1)
navigationHistory.value.push(menu)
currentHistoryIndex.value = navigationHistory.value.length - 1
}
}
function goBack() {
if (canGoBack.value) {
currentHistoryIndex.value--
activeMenu.value = navigationHistory.value[currentHistoryIndex.value]
}
}
function goForward() {
if (canGoForward.value) {
currentHistoryIndex.value++
activeMenu.value = navigationHistory.value[currentHistoryIndex.value]
}
}
function reloadPage() {
window.location.reload()
}
function handleMenuSelect(key: string) {
// 检查是否需要认证
const authRequiredMenus = ['rakuten', 'amazon', 'zebra', 'shopee']
if (!isAuthenticated.value && authRequiredMenus.includes(key)) {
showAuthDialog.value = true
return
}
activeMenu.value = key
addToHistory(key)
}
async function handleLoginSuccess(data: { token: string; user: any }) {
isAuthenticated.value = true
showAuthDialog.value = false
try {
currentUsername.value = data?.user?.username || currentUsername.value
userPermissions.value = data?.permissions || data?.user?.permissions || ''
} catch {}
// 登录成功后自动注册设备 - 简化版
try {
const username = data?.user?.username || currentUsername.value
if (username) {
await deviceApi.register({ username })
}
} catch (e) {
// 设备注册失败不影响登录流程,静默处理
console.warn('设备注册失败:', e)
}
}
async function handleUserClick() {
if (!isAuthenticated.value) {
showAuthDialog.value = true
return
}
try {
await ElMessageBox.confirm('确认退出登录?', '提示', { type: 'warning', confirmButtonText: '退出', cancelButtonText: '取消' })
const token = localStorage.getItem('token') || ''
try { await authApi.logout(token) } catch {}
try { localStorage.removeItem('token') } catch {}
isAuthenticated.value = false
currentUsername.value = ''
userPermissions.value = ''
showAuthDialog.value = true
showDeviceDialog.value = false
ElMessage.success('已退出登录')
} catch {}
}
function handleLoginCancel() {
showAuthDialog.value = false
}
function showRegisterDialog() {
showAuthDialog.value = false
showRegDialog.value = true
}
function handleRegisterSuccess() {
showRegDialog.value = false
showAuthDialog.value = true
}
function backToLogin() {
showRegDialog.value = false
showAuthDialog.value = true
}
// 检查认证状态 - 复刻ERP客户端逻辑
async function checkAuth() {
const token = localStorage.getItem('token')
const authRequiredMenus = ['rakuten', 'amazon', 'zebra', 'shopee']
if (token) {
try {
const response = await authApi.verifyToken(token)
if (response.success) {
isAuthenticated.value = true
if (!currentUsername.value) {
const u = getUsernameFromToken(token)
if (u) currentUsername.value = u
}
userPermissions.value = response.permissions || ''
return
}
} catch {
localStorage.removeItem('token')
}
}
// 检查是否需要显示登录弹框
if (!isAuthenticated.value && authRequiredMenus.includes(activeMenu.value)) {
showAuthDialog.value = true
}
}
function getClientIdFromToken(token?: string) {
try {
const t = token || localStorage.getItem('token') || ''
const payload = JSON.parse(atob(t.split('.')[1] || ''))
return payload.clientId || ''
} catch { return '' }
}
function getUsernameFromToken(token?: string) {
try {
const t = token || localStorage.getItem('token') || ''
const payload = JSON.parse(atob(t.split('.')[1] || ''))
return payload.username || ''
} catch { return '' }
}
async function openDeviceManager() {
if (!isAuthenticated.value) {
showAuthDialog.value = true
return
}
showDeviceDialog.value = true
await fetchDeviceData()
}
async function fetchDeviceData() {
const username = (currentUsername.value || getUsernameFromToken()).trim()
if (!username) {
ElMessage.warning('未获取到用户名,请重新登录')
return
}
try {
deviceLoading.value = true
const [quota, list] = await Promise.all([
deviceApi.getQuota(username),
deviceApi.list(username),
])
deviceQuota.value = quota || { limit: 0, used: 0 }
const clientId = getClientIdFromToken()
devices.value = (list || []).map(d => ({ ...d, isCurrent: d.deviceId === clientId })) as any
} catch (e: any) {
ElMessage.error(e?.message || '获取设备列表失败')
} finally {
deviceLoading.value = false
}
}
async function confirmRemoveDevice(row: DeviceItem & { isCurrent?: boolean }) {
try {
await ElMessageBox.confirm('确定要移除该设备吗?', '你确定要移除设备吗?', { confirmButtonText: '确定移除', cancelButtonText: '取消', type: 'warning' })
await deviceApi.remove({ deviceId: row.deviceId })
devices.value = devices.value.filter(d => d.deviceId !== row.deviceId)
deviceQuota.value.used = Math.max(0, (deviceQuota.value.used || 0) - 1)
if (row.isCurrent) {
// 当前设备被移除,清理登录状态
isAuthenticated.value = false
showAuthDialog.value = true
try { localStorage.removeItem('token') } catch {}
}
ElMessage.success('已移除设备')
} catch (e) {
/* 用户取消或失败 */
}
}
onMounted(async () => {
showContent()
await checkAuth()
})
</script>
<template>
<el-config-provider :locale="zhCnLocale">
<div id="app-root" class="root">
<div class="loading-container" id="loading">
<div class="loading-spinner"></div>
</div>
<div class="erp-container">
<div class="sidebar">
<div class="user-avatar">
<img src="/icon/icon.png" alt="logo" />
</div>
<div class="menu-group-title">电商平台</div>
<ul class="menu">
<li
v-for="item in visibleMenus"
:key="item.key"
class="menu-item"
:class="{ active: activeMenu === item.key }"
@click="handleMenuSelect(item.key)"
>
<span class="menu-text"><span class="menu-icon" :data-k="item.key">{{ item.icon }}</span>{{ item.name }}</span>
</li>
</ul>
</div>
<div class="main-content">
<NavigationBar
:can-go-back="canGoBack"
:can-go-forward="canGoForward"
:active-menu="activeMenu"
@go-back="goBack"
@go-forward="goForward"
@reload="reloadPage"
@user-click="handleUserClick"
@open-device="openDeviceManager" />
<div class="content-body">
<div
class="dashboard-home"
v-if="!isAuthenticated && (activeMenu === 'rakuten' || activeMenu === 'amazon' || activeMenu === 'zebra')">
<div class="icon-container">
<img src="/image/111.png" alt="ERP Logo" class="main-icon" />
</div>
</div>
<ZebraDashboard v-if="activeMenu === 'zebra'" />
<RakutenDashboard v-else-if="activeMenu === 'rakuten'" />
<AmazonDashboard v-else-if="activeMenu === 'amazon'" />
<div v-else class="placeholder">
<div class="placeholder-card">
<div class="placeholder-title">{{ activeMenu.toUpperCase() }} 面板</div>
<div class="placeholder-desc">功能开发中...</div>
</div>
</div>
</div>
<!-- 认证组件 -->
<LoginDialog
v-model="showAuthDialog"
@login-success="handleLoginSuccess"
@show-register="showRegisterDialog" />
<RegisterDialog
v-model="showRegDialog"
@register-success="handleRegisterSuccess"
@back-to-login="backToLogin" />
<!-- 设备管理弹框 -->
<el-dialog
:title="`设备管理 (${deviceQuota.used || 0}/${deviceQuota.limit || 0})`"
v-model="showDeviceDialog"
width="560px"
:close-on-click-modal="false">
<div style="margin-bottom: 10px; color:#909399;">当前账号可以授权绑定 {{ deviceQuota.limit }} 台设备</div>
<el-table :data="devices" size="small" :loading="deviceLoading" style="width:100%" stripe>
<el-table-column label="设备名" min-width="180">
<template #default="scope">
<span>{{ scope.row.name || scope.row.deviceId }}</span>
<el-tag v-if="scope.row.isCurrent" size="small" type="success" style="margin-left:6px;">本机</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="scope">
<el-tag :type="scope.row.status==='online' ? 'success' : 'info'" size="small">{{ scope.row.status==='online' ? '已登录' : '已登出' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="最近" min-width="130">
<template #default="scope">
<span>{{ scope.row.lastActiveAt || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="scope">
<el-button type="text" size="small" style="color:#F56C6C" @click="confirmRemoveDevice(scope.row)">移除设备</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="showDeviceDialog=false">关闭</el-button>
</template>
</el-dialog>
</div>
</div>
</div>
</el-config-provider>
</template>
<style scoped>
.root {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
background-color: #f5f5f5;
opacity: 0;
transition: opacity 0.1s ease;
}
.loading-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100%;
position: fixed;
top: 0;
left: 0;
background-color: #f5f5f5;
z-index: 9999;
transition: opacity 0.1s ease;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 5px solid #e6e6e6;
border-top: 5px solid #409EFF;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.erp-container {
display: flex;
height: 100vh;
}
.sidebar {
width: 220px;
min-width: 220px;
flex-shrink: 0;
background: #ffffff;
border-right: 1px solid #e8eaec;
padding: 16px 12px;
box-sizing: border-box;
}
.platform-icons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.picon { width: 28px; height: 28px; object-fit: contain; }
.user-avatar {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 0;
border-bottom: 1px solid #e8eaec;
margin: 0 0 12px 0;
}
.user-avatar img {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: contain;
background: #ffffff;
}
.menu-group-title {
font-size: 12px;
color: #909399;
margin: 8px 6px 10px;
text-align: left; /* “电商平台”四个字靠左 */
}
.menu {
list-style: none;
padding: 0;
margin: 0;
}
.menu-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: 8px;
cursor: pointer;
color: #333333;
margin-bottom: 4px;
}
.menu-item:hover {
background: #f5f7fa;
}
.menu-item.active {
background: #ecf5ff !important;
color: #409EFF !important;
}
.menu-text {
font-size: 14px;
}
.menu-text { display: inline-flex; align-items: center; gap: 6px; }
.menu-icon { display: inline-flex; width: 18px; height: 18px; border-radius: 4px; align-items: center; justify-content: center; font-size: 12px; color: #fff; }
.menu-icon[data-k="rakuten"] { background: #BF0000; }
.menu-icon[data-k="amazon"] { background: #FF9900; color: #1A1A1A; }
.menu-icon[data-k="zebra"] { background: #34495e; }
.menu-icon[data-k="shopee"] { background: #EE4D2D; }
.main-content {
flex: 1;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
}
/* 导航栏和认证相关样式已移至对应组件 */
.content-body {
position: relative;
flex: 1;
background: #fff;
min-height: 0;
overflow: hidden;
}
.dashboard-home {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
z-index: 100;
}
.icon-container { display: flex; justify-content: center; }
.main-icon {
width: 400px;
height: 400px;
border-radius: 20px;
object-fit: contain;
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
}
.placeholder-card {
background: #ffffff;
border: 1px solid #e8eaec;
border-radius: 12px;
padding: 24px 28px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
color: #2c3e50;
}
.placeholder-title { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
.placeholder-desc { font-size: 13px; color: #606266; }
</style>

View File

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

View File

@@ -0,0 +1,90 @@
import { http } from './http';
// 统一响应处理函数 - 适配ERP客户端格式
function unwrap<T>(res: any): T {
if (res && typeof res.success === 'boolean') {
if (!res.success) {
const message: string = res.message || res.msg || '请求失败';
throw new Error(message);
}
return res as T;
}
// 兼容标准格式
if (res && typeof res.code === 'number') {
if (res.code !== 0) {
const message: string = res.msg || '请求失败';
throw new Error(message);
}
return (res.data as T) ?? ({} as T);
}
return res as T;
}
// 认证相关类型定义
interface LoginRequest {
username: string;
password: string;
}
interface RegisterRequest {
username: string;
password: string;
}
interface LoginResponse {
success: boolean;
token: string;
permissions: string[];
username: string;
message?: string;
}
interface RegisterResponse {
success: boolean;
message?: string;
}
interface CheckUsernameResponse {
available: boolean;
}
export const authApi = {
// 用户登录
login(params: LoginRequest) {
return http
.post('/api/login', params)
.then(res => unwrap<LoginResponse>(res));
},
// 用户注册
register(params: RegisterRequest) {
return http
.post('/api/register', params)
.then(res => unwrap<RegisterResponse>(res));
},
// 检查用户名可用性
checkUsername(username: string) {
return http
.get('/api/check-username', { username })
.then(res => {
// checkUsername 使用标准格式 {code: 200, data: boolean}
if (res && res.code === 200) {
return { available: res.data };
}
throw new Error(res?.msg || '检查用户名失败');
});
},
// 验证token有效性
verifyToken(token: string) {
return http
.post('/api/verify', { token })
.then(res => unwrap<{ success: boolean }>(res));
},
// 用户登出
logout(token: string) {
return http.postVoid('/api/logout', { token });
},
};

View File

@@ -0,0 +1,48 @@
import { http } from './http'
// 与老版保持相同的接口路径与参数
const base = '/api/device'
export interface DeviceQuota {
limit: number
used: number
}
export interface DeviceItem {
deviceId: string
name?: string
status?: 'online' | 'offline'
lastActiveAt?: string
}
export const deviceApi = {
getQuota(username: string) {
return http.get<DeviceQuota | any>(`${base}/quota`, { username }).then((res: any) => {
if (res && typeof res.limit !== 'undefined') return res as DeviceQuota
if (res && typeof res.code === 'number') return (res.data as DeviceQuota) || { limit: 0, used: 0 }
return (res?.data as DeviceQuota) || { limit: 0, used: 0 }
})
},
list(username: string) {
return http.get<DeviceItem[] | any>(`${base}/list`, { username }).then((res: any) => {
if (Array.isArray(res)) return res as DeviceItem[]
if (res && typeof res.code === 'number') return (res.data as DeviceItem[]) || []
return (res?.data as DeviceItem[]) || []
})
},
register(payload: { username: string }) {
return http.post(`${base}/register`, payload)
},
remove(payload: { deviceId: string }) {
return http.postVoid(`${base}/remove`, payload)
},
heartbeat(payload: { username: string; deviceId: string; version?: string }) {
return http.postVoid(`${base}/heartbeat`, payload)
},
}

View File

@@ -0,0 +1,78 @@
// 极简 HTTP 工具:仅封装 GET/POST默认指向本地 8081
export type HttpMethod = 'GET' | 'POST';
const BASE_URL = 'http://localhost:8081';
// 将对象转为查询字符串
function buildQuery(params?: Record<string, unknown>): string {
if (!params) return '';
const usp = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value === undefined || value === null) return;
usp.append(key, String(value));
});
const queryString = usp.toString();
return queryString ? `?${queryString}` : '';
}
// 统一请求入口:自动加上 BASE_URL、JSON 头与错误处理
async function request<T>(path: string, options: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
credentials: 'omit',
cache: 'no-store',
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(text || `HTTP ${res.status}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
return (await res.text()) as unknown as T;
}
export const http = {
get<T>(path: string, params?: Record<string, unknown>) {
return request<T>(`${path}${buildQuery(params)}`, { method: 'GET' });
},
post<T>(path: string, body?: unknown) {
return request<T>(path, { method: 'POST', body: body ? JSON.stringify(body) : undefined });
},
// 用于无需读取响应体的 POST如删除/心跳等),从根源避免读取中断
postVoid(path: string, body?: unknown) {
return fetch(`${BASE_URL}${path}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
credentials: 'omit',
cache: 'no-store',
headers: { 'Content-Type': 'application/json' },
}).then(res => {
if (!res.ok) return res.text().then(t => Promise.reject(new Error(t || `HTTP ${res.status}`)));
return undefined as unknown as void;
});
},
// 文件上传:透传 FormData不设置 Content-Type 让浏览器自动处理
upload<T>(path: string, form: FormData) {
const res = fetch(`${BASE_URL}${path}`, {
method: 'POST',
body: form,
credentials: 'omit',
cache: 'no-store',
});
return res.then(async response => {
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `HTTP ${response.status}`);
}
return response.json() as Promise<T>;
});
},
};

View File

@@ -0,0 +1,38 @@
import { http } from './http';
function unwrap<T>(res: any): T {
if (res && typeof res.code === 'number') {
if (res.code !== 0) {
const message: string = res.msg || '请求失败';
throw new Error(message);
}
return (res.data as T) ?? ({} as T);
}
return res as T;
}
export const rakutenApi = {
// 上传 Excel 或按店铺名查询
getProducts(params: { file?: File; shopName?: string; batchId?: string }) {
const formData = new FormData();
if (params.file) formData.append('file', params.file);
if (params.batchId) formData.append('batchId', params.batchId);
if (params.shopName) formData.append('shopName', params.shopName);
return http
.upload('/api/rakuten/products', formData)
.then(res => unwrap<{ products: any[]; total?: number; sessionId?: string }>(res));
},
search1688(imageUrl: string, sessionId?: string) {
const payload: Record<string, unknown> = { imageUrl };
if (sessionId) payload.sessionId = sessionId;
return http.post('/api/rakuten/search1688', payload).then(res => unwrap<any>(res));
},
getLatestProducts() {
return http.get('/api/rakuten/products/latest').then(res => unwrap<{ products: any[] }>(res));
},
exportAndSave(exportData: unknown) {
return http
.post('/api/rakuten/export-and-save', exportData)
.then(res => unwrap<{ filePath: string; fileName?: string; recordCount?: number; hasImages?: boolean }>(res));
},
};

View File

@@ -0,0 +1,15 @@
import { http } from './http';
export const shopeeApi = {
getAdHosting(params: Record<string, unknown> = {}) {
return http.get('/api/shopee/ad-hosting', params);
},
getReviews(params: Record<string, unknown> = {}) {
return http.get('/api/shopee/reviews', params);
},
exportData(exportParams: Record<string, unknown> = {}) {
return http.post('/api/shopee/export', exportParams);
},
};

View File

@@ -0,0 +1,56 @@
// 斑马订单模型(根据页面所需字段精简定义)
export interface ZebraOrder {
orderedAt?: string;
productImage?: string;
productTitle?: string;
shopOrderNumber?: string;
timeSinceOrder?: string;
priceJpy?: number;
productQuantity?: number;
shippingFeeJpy?: number;
serviceFee?: string;
productNumber?: string;
poNumber?: string;
shippingFeeCny?: number;
internationalShippingFee?: number;
poLogisticsCompany?: string;
poTrackingNumber?: string;
internationalTrackingNumber?: string;
trackInfo?: string;
}
export interface ZebraOrdersResp {
orders: ZebraOrder[];
total?: number;
totalPages?: number;
}
import { http } from './http';
// 斑马 API与原 zebra-api.js 对齐的接口封装
export const zebraApi = {
getOrders(params: Record<string, unknown>) {
return http.get<ZebraOrdersResp>('/api/banma/orders', params);
},
getOrdersByBatch(batchId: string) {
return http.get<ZebraOrdersResp>(`/api/banma/orders/batch/${batchId}`);
},
getLatestOrders() {
return http.get<ZebraOrdersResp>('/api/banma/orders/latest');
},
getShops() {
return http.get<{ data?: { list?: Array<{ id: string; shopName: string }> } }>('/api/banma/shops');
},
refreshToken() {
return http.post('/api/banma/refresh-token');
},
exportAndSaveOrders(exportData: unknown) {
return http.post<{ filePath: string }>('/api/banma/export-and-save', exportData);
},
getOrderStats() {
return http.get('/api/banma/orders/stats');
},
searchOrders(searchParams: Record<string, unknown>) {
return http.get('/api/banma/orders/search', searchParams);
},
};

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1 @@
<template></template>

View File

@@ -0,0 +1,393 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { amazonApi } from '../../api/amazon'
// 响应式状态
const loading = ref(false) // 主加载状态
const tableLoading = ref(false) // 表格加载状态
const progressPercentage = ref(0) // 进度百分比
const localProductData = ref<any[]>([]) // 本地产品数据
const singleAsin = ref('') // 单个ASIN输入
const currentAsin = ref('') // 当前处理的ASIN
const genmaiLoading = ref(false) // Genmai Spirit加载状态
// 分页配置
const currentPage = ref(1)
const pageSize = ref(15)
const totalPages = computed(() => Math.max(1, Math.ceil((localProductData.value.length || 0) / pageSize.value)))
const amazonUpload = ref<HTMLInputElement | null>(null)
const dragActive = ref(false)
// 计算属性 - 当前页数据
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return localProductData.value.slice(start, end)
})
// 通用消息提示
function showMessage(message: string, type: 'success' | 'warning' | 'error' | 'info' = 'info') {
alert(`[${type.toUpperCase()}] ${message}`)
}
// Excel文件上传处理 - 主要业务逻辑入口
async function processExcelFile(file: File) {
try {
loading.value = true
tableLoading.value = true
localProductData.value = []
progressPercentage.value = 0
const response = await amazonApi.importAsinFromExcel(file)
const asinList = response.data.asinList
if (!asinList || asinList.length === 0) {
showMessage('文件中未找到有效的ASIN数据', 'warning')
return
}
showMessage(`成功解析 ${asinList.length} 个ASIN`, 'success')
await batchGetProductInfo(asinList)
} catch (error: any) {
showMessage(error.message || '处理文件失败', 'error')
} finally {
loading.value = false
tableLoading.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 = ''
}
function onDragOver(e: DragEvent) { e.preventDefault(); dragActive.value = true }
function onDragLeave() { dragActive.value = false }
async function onDrop(e: DragEvent) {
e.preventDefault()
dragActive.value = false
const file = e.dataTransfer?.files?.[0]
if (!file) return
const ok = /(\.csv|\.txt|\.xls|\.xlsx)$/i.test(file.name)
if (!ok) return showMessage('仅支持 .csv/.txt/.xls/.xlsx 文件', 'warning')
await processExcelFile(file)
}
// 批量获取产品信息 - 核心数据处理逻辑
async function batchGetProductInfo(asinList: string[]) {
try {
currentAsin.value = '正在处理...'
progressPercentage.value = 0
localProductData.value = []
const batchId = `BATCH_${Date.now()}`
const batchSize = 2 // 每批处理2个ASIN
const totalBatches = Math.ceil(asinList.length / batchSize)
let processedCount = 0
let failedCount = 0
// 分批处理ASIN列表
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)
if (result?.data?.products?.length > 0) {
localProductData.value.push(...result.data.products)
if (tableLoading.value) tableLoading.value = false // 首次数据到达后隐藏表格加载
}
// 统计失败数量
const expectedCount = batchAsins.length
const actualCount = result?.data?.products?.length || 0
failedCount += Math.max(0, expectedCount - actualCount)
} catch (error) {
failedCount += batchAsins.length
console.error(`批次${i + 1}失败:`, error)
}
// 更新进度
processedCount += batchAsins.length
progressPercentage.value = Math.round((processedCount / asinList.length) * 100)
// 批次间延迟避免API频率限制
if (i < totalBatches - 1 && loading.value) {
await new Promise(resolve => setTimeout(resolve, 1000 + Math.random() * 1500))
}
}
// 处理完成状态更新
progressPercentage.value = 100
currentAsin.value = '处理完成'
// 结果提示
if (failedCount > 0) {
showMessage(`采集完成!共 ${asinList.length} 个ASIN成功 ${asinList.length - failedCount} 个,失败 ${failedCount}`, 'warning')
} else {
showMessage(`采集完成!成功获取 ${asinList.length} 个产品信息`, 'success')
}
} catch (error: any) {
showMessage(error.message || '批量获取产品信息失败', 'error')
currentAsin.value = '处理失败'
} finally {
tableLoading.value = false
}
}
// 单个ASIN查询
async function searchSingleAsin() {
const asin = singleAsin.value.trim()
if (!asin) return
localProductData.value = []
loading.value = true
try {
const resp = await amazonApi.getProductsBatch([asin], `SINGLE_${Date.now()}`)
if (resp?.data?.products?.length > 0) {
localProductData.value = resp.data.products
showMessage('查询成功', 'success')
singleAsin.value = ''
} else {
showMessage('未找到商品信息', 'warning')
}
} catch (e: any) {
showMessage(e?.message || '查询失败', 'error')
} finally {
loading.value = false
}
}
// 导出Excel数据
async function exportToExcel() {
if (!localProductData.value.length) {
showMessage('没有数据可供导出', 'warning')
return
}
try {
loading.value = true
showMessage('正在生成Excel文件请稍候...', 'info')
// 数据格式化 - 只保留核心字段
const exportData = localProductData.value.map(product => ({
asin: product.asin || '',
seller_shipper: getSellerShipperText(product),
price: product.price || '无货'
}))
await amazonApi.exportToExcel(exportData, {
filename: `Amazon产品数据_${new Date().toISOString().slice(0, 10)}.xlsx`
})
showMessage('Excel文件导出成功', 'success')
} catch (error: any) {
showMessage(error.message || '导出Excel失败', 'error')
} finally {
loading.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() {
loading.value = false
currentAsin.value = '已停止'
showMessage('已停止获取产品数据', 'info')
}
// 打开Genmai Spirit工具
async function openGenmaiSpirit() {
genmaiLoading.value = true
try {
await amazonApi.openGenmaiSpirit()
} catch (error: any) {
showMessage(error.message || '打开跟卖精灵失败', 'error')
} finally {
genmaiLoading.value = false
}
}
// 分页处理
function handleSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
}
function handleCurrentChange(page: number) {
currentPage.value = page
}
// 使用 Element Plus 的 jumper不再需要手动跳转函数
function openAmazonUpload() {
amazonUpload.value?.click()
}
// 组件挂载时获取最新数据
onMounted(async () => {
try {
const resp = await amazonApi.getLatestProducts()
localProductData.value = resp.data?.products || []
} catch {
// 静默处理初始化失败
}
})
</script>
<template>
<div class="amazon-root">
<div class="main-container">
<!-- 文件导入和操作区域 -->
<div class="import-section" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" :class="{ 'drag-active': dragActive }">
<div class="import-controls">
<!-- 文件上传按钮 -->
<el-button type="primary" :disabled="loading" @click="openAmazonUpload">
📂 {{ loading ? '处理中...' : '导入ASIN列表' }}
</el-button>
<input ref="amazonUpload" style="display:none" type="file" accept=".csv,.txt,.xls,.xlsx" @change="handleExcelUpload" :disabled="loading" />
<!-- 单个ASIN输入 -->
<div class="single-input">
<input class="text" v-model="singleAsin" placeholder="输入单个ASIN" :disabled="loading" @keyup.enter="searchSingleAsin" />
<el-button type="info" :disabled="!singleAsin || loading" @click="searchSingleAsin">查询</el-button>
</div>
<!-- 操作按钮组 -->
<div class="action-buttons">
<el-button type="danger" :disabled="!loading" @click="stopFetch">停止获取</el-button>
<el-button type="success" :disabled="!localProductData.length || loading" @click="exportToExcel">导出Excel</el-button>
<el-button type="warning" :loading="genmaiLoading" @click="openGenmaiSpirit">{{ genmaiLoading ? '启动中...' : '跟卖精灵' }}</el-button>
</div>
</div>
<!-- 进度条显示 -->
<div class="progress-section" v-if="loading">
<div class="progress-box">
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
</div>
<div class="progress-text">{{ progressPercentage }}%</div>
</div>
<div class="current-status" v-if="currentAsin">{{ currentAsin }}</div>
</div>
</div>
</div>
<!-- 数据显示区域 -->
<div class="table-container">
<!-- 数据表格无数据时也显示表头 -->
<div class="table-section">
<div class="table-wrapper">
<table class="table">
<thead>
<tr>
<th width="130">ASIN</th>
<th>卖家/配送方</th>
<th width="120">当前售价</th>
</tr>
</thead>
<tbody>
<tr v-if="paginatedData.length === 0">
<td colspan="3" class="empty-tip">暂无数据请导入ASIN列表</td>
</tr>
<tr v-else v-for="row in paginatedData" :key="row.asin">
<td>{{ row.asin }}</td>
<td>
<div class="seller-info">
<span class="seller">{{ row.seller || '无货' }}</span>
<span v-if="row.shipper && row.shipper !== row.seller" class="shipper">/ {{ row.shipper }}</span>
</div>
</td>
<td>
<span class="price">{{ row.price || '无货' }}</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 表格加载遮罩 -->
<div v-if="tableLoading" class="table-loading">
<div class="spinner"></div>
<div>加载中...</div>
</div>
</div>
<!-- 分页器 -->
<div class="pagination-fixed" >
<el-pagination
background
:current-page="currentPage"
:page-sizes="[15,30,50,100]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="localProductData.length"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.amazon-root { position: absolute; inset: 0; background: #f5f5f5; padding: 12px; box-sizing: border-box; }
.main-container { background: #fff; border-radius: 4px; padding: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); height: 100%; display: flex; flex-direction: column; }
.import-section { margin-bottom: 10px; flex-shrink: 0; }
.import-controls { display: flex; align-items: flex-end; gap: 20px; flex-wrap: wrap; margin-bottom: 8px; }
.single-input { display: flex; align-items: center; gap: 8px; }
.text { width: 180px; height: 32px; padding: 0 10px; border: 1px solid #dcdfe6; border-radius: 4px; font-size: 14px; outline: none; transition: border-color 0.2s ease; }
.text:focus { border-color: #409EFF; }
.text:disabled { background: #f5f7fa; color: #c0c4cc; }
.action-buttons { display: flex; gap: 10px; flex-wrap: wrap; }
.progress-section { margin: 15px 0 10px 0; }
.progress-box { padding: 8px 0; }
.progress-container { display: flex; align-items: center; position: relative; padding-right: 50px; margin-bottom: 8px; }
.progress-bar { flex: 1; height: 6px; background: #ebeef5; border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #409EFF, #66b1ff); border-radius: 3px; transition: width 0.3s ease; }
.progress-text { position: absolute; right: 0; font-size: 13px; color: #409EFF; font-weight: 500; }
.current-status { font-size: 12px; color: #606266; padding-left: 2px; }
.table-container { display: flex; flex-direction: column; flex: 1; min-height: 400px; overflow: hidden; }
.table-section { flex: 1; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; }
.table-wrapper { height: 100%; overflow: auto; }
.table { 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; }
.table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
.table tbody tr:hover { background: #f9f9f9; }
.seller-info { display: flex; align-items: center; gap: 4px; }
.seller { color: #303133; font-weight: 500; }
.shipper { color: #909399; font-size: 12px; }
.price { color: #e6a23c; font-weight: 600; }
.table-loading { position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95); display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 14px; color: #606266; }
.spinner { font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.pagination-fixed { flex-shrink: 0; padding: 8px 12px; background: #f9f9f9; border-radius: 4px; display: flex; justify-content: center; border-top: 1px solid #ebeef5; margin-top: 8px; }
.empty-tip { text-align: center; color: #909399; padding: 16px 0; }
.import-section[draggable], .import-section.drag-active { border: 1px dashed #409EFF; border-radius: 6px; }
</style>
<script lang="ts">
export default {
name: 'AmazonDashboard',
}
</script>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import { authApi } from '../../api/auth'
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'loginSuccess', data: { token: string; user: any }): void
(e: 'showRegister'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const authForm = ref({ username: '', password: '' })
const authLoading = ref(false)
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
async function handleAuth() {
if (!authForm.value.username || !authForm.value.password) return
authLoading.value = true
try {
const data = await authApi.login(authForm.value)
localStorage.setItem('token', data.token)
emit('loginSuccess', {
token: data.token,
user: {
username: data.username,
permissions: data.permissions
}
})
ElMessage.success('登录成功')
resetForm()
} catch (err) {
ElMessage.error((err as Error).message)
} finally {
authLoading.value = false
}
}
function cancelAuth() {
visible.value = false
resetForm()
}
function resetForm() {
authForm.value = { username: '', password: '' }
}
function showRegister() {
emit('showRegister')
}
</script>
<template>
<el-dialog
title="用户登录"
v-model="visible"
:close-on-click-modal="false"
width="400px"
center>
<div style="text-align: center; padding: 20px 0;">
<div style="margin-bottom: 30px; color: #666;">
<el-icon size="48" color="#409EFF"><User /></el-icon>
<p style="margin-top: 15px; font-size: 16px;">请登录以使用系统功能</p>
</div>
<el-input
v-model="authForm.username"
placeholder="请输入用户名"
prefix-icon="User"
size="large"
style="margin-bottom: 15px;"
:disabled="authLoading"
@keyup.enter="handleAuth">
</el-input>
<el-input
v-model="authForm.password"
placeholder="请输入密码"
type="password"
size="large"
style="margin-bottom: 20px;"
:disabled="authLoading"
@keyup.enter="handleAuth">
</el-input>
<div>
<el-button
type="primary"
size="large"
:loading="authLoading"
:disabled="!authForm.username || !authForm.password || authLoading"
@click="handleAuth"
style="width: 120px; margin-right: 10px;">
登录
</el-button>
<el-button
size="large"
:disabled="authLoading"
@click="cancelAuth"
style="width: 120px;">
取消
</el-button>
</div>
<div style="margin-top: 20px; text-align: center;">
<el-button type="text" @click="showRegister" :disabled="authLoading">
还没有账号点击注册
</el-button>
</div>
</div>
</el-dialog>
</template>

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { User } from '@element-plus/icons-vue'
import { authApi } from '../../api/auth'
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'registerSuccess'): void
(e: 'backToLogin'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const registerForm = ref({ username: '', password: '', confirmPassword: '' })
const registerLoading = ref(false)
const usernameCheckResult = ref<boolean | null>(null)
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const canRegister = computed(() => {
const { username, password, confirmPassword } = registerForm.value
return username &&
password.length >= 6 &&
password === confirmPassword &&
usernameCheckResult.value === true
})
async function checkUsernameAvailability() {
if (!registerForm.value.username) {
usernameCheckResult.value = null
return
}
try {
const data = await authApi.checkUsername(registerForm.value.username)
usernameCheckResult.value = data.available
} catch {
usernameCheckResult.value = null
}
}
async function handleRegister() {
if (!canRegister.value) return
registerLoading.value = true
try {
const result = await authApi.register({
username: registerForm.value.username,
password: registerForm.value.password
})
ElMessage.success(result.message || '注册成功,请登录')
emit('registerSuccess')
resetForm()
} catch (err) {
ElMessage.error((err as Error).message)
} finally {
registerLoading.value = false
}
}
function cancelRegister() {
visible.value = false
resetForm()
}
function resetForm() {
registerForm.value = { username: '', password: '', confirmPassword: '' }
usernameCheckResult.value = null
}
function backToLogin() {
emit('backToLogin')
resetForm()
}
</script>
<template>
<el-dialog
title="账号注册"
v-model="visible"
:close-on-click-modal="false"
width="450px"
center>
<div style="text-align: center; padding: 20px 0;">
<div style="margin-bottom: 20px; color: #666;">
<el-icon size="48" color="#67C23A"><User /></el-icon>
<p style="margin-top: 15px; font-size: 16px;">创建新账号</p>
</div>
<el-input
v-model="registerForm.username"
placeholder="请输入用户名"
prefix-icon="User"
size="large"
style="margin-bottom: 15px;"
:disabled="registerLoading"
@blur="checkUsernameAvailability">
</el-input>
<div v-if="usernameCheckResult !== null" style="margin-bottom: 15px; text-align: left;">
<span v-if="usernameCheckResult" style="color: #67C23A; font-size: 12px;">
用户名可用
</span>
<span v-else style="color: #F56C6C; font-size: 12px;">
用户名已存在
</span>
</div>
<el-input
v-model="registerForm.password"
placeholder="请输入密码至少6位"
type="password"
size="large"
style="margin-bottom: 15px;"
:disabled="registerLoading">
</el-input>
<el-input
v-model="registerForm.confirmPassword"
placeholder="请再次输入密码"
type="password"
size="large"
style="margin-bottom: 20px;"
:disabled="registerLoading">
</el-input>
<div>
<el-button
type="success"
size="large"
:loading="registerLoading"
:disabled="!canRegister || registerLoading"
@click="handleRegister"
style="width: 120px; margin-right: 10px;">
注册
</el-button>
<el-button
size="large"
:disabled="registerLoading"
@click="cancelRegister"
style="width: 120px;">
取消
</el-button>
</div>
<div style="margin-top: 20px; text-align: center;">
<el-button type="text" @click="backToLogin" :disabled="registerLoading">
已有账号返回登录
</el-button>
</div>
</div>
</el-dialog>
</template>

View File

@@ -0,0 +1,172 @@
<script setup lang="ts">
import { ArrowLeft, ArrowRight, Refresh, Monitor, Setting, User } from '@element-plus/icons-vue'
interface Props {
canGoBack: boolean
canGoForward: boolean
activeMenu: string
}
interface Emits {
(e: 'go-back'): void
(e: 'go-forward'): void
(e: 'reload'): void
(e: 'user-click'): void
(e: 'open-device'): void
}
defineProps<Props>()
defineEmits<Emits>()
</script>
<template>
<div class="top-navbar">
<div class="navbar-left">
<div class="nav-controls">
<button class="nav-btn" title="后退" @click="$emit('go-back')" :disabled="!canGoBack">
<el-icon><ArrowLeft /></el-icon>
</button>
<button class="nav-btn" title="前进" @click="$emit('go-forward')" :disabled="!canGoForward">
<el-icon><ArrowRight /></el-icon>
</button>
</div>
</div>
<div class="navbar-center">
<div class="breadcrumbs">
<span>首页</span>
<span class="separator">></span>
<span>{{ activeMenu }}</span>
</div>
</div>
<div class="navbar-right">
<button class="nav-btn-round" title="刷新" @click="$emit('reload')">
<el-icon><Refresh /></el-icon>
</button>
<button class="nav-btn-round" title="设备管理" @click="$emit('open-device')">
<el-icon><Monitor /></el-icon>
</button>
<button class="nav-btn-round" title="设置">
<el-icon><Setting /></el-icon>
</button>
<button class="nav-btn-round" title="用户" @click="$emit('user-click')">
<el-icon><User /></el-icon>
</button>
</div>
</div>
</template>
<style scoped>
.top-navbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #ffffff;
border-bottom: 1px solid #e8eaec;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
.navbar-left {
display: flex;
align-items: center;
flex: 0 0 auto;
}
.navbar-center {
display: flex;
justify-content: center;
flex: 1;
}
.navbar-right {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.nav-controls {
display: flex;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
}
.nav-btn {
width: 36px;
height: 32px;
border: none;
background: #fff;
cursor: pointer;
font-size: 16px;
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:active {
outline: none;
border: none;
}
.nav-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
background: #f5f5f5;
color: #c0c4cc;
}
.nav-btn:not(:last-child) {
border-right: 1px solid #dcdfe6;
}
.nav-btn-round {
width: 32px;
height: 32px;
border: 1px solid #dcdfe6;
border-radius: 50%;
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-round:hover {
background: #f5f7fa;
color: #409EFF;
border-color: #c6e2ff;
}
.nav-btn-round:focus,
.nav-btn-round:active {
outline: none;
}
.breadcrumbs {
display: flex;
align-items: center;
color: #606266;
font-size: 14px;
}
.separator {
margin: 0 8px;
color: #c0c4cc;
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,632 @@
<script setup lang="ts">
import {ref, computed, onMounted} from 'vue'
import {rakutenApi} from '../../api/rakuten'
// UI 与加载状态
const loading = ref(false)
const tableLoading = ref(false)
const exportLoading = ref(false)
const statusMessage = ref('')
const statusType = ref<'info' | 'success' | 'warning' | 'error'>('info')
// 查询与上传
const singleShopName = ref('')
const currentBatchId = ref('')
const uploadInputRef = ref<HTMLInputElement | null>(null)
const dragActive = ref(false)
// 数据与分页
const allProducts = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(15)
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return allProducts.value.slice(start, end)
})
// 进度(完成后仍保持显示)
const progressStarted = ref(false)
const progressPercentage = ref(0)
const totalProducts = ref(0)
const processedProducts = ref(0)
function handleSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
}
function handleCurrentChange(page: number) {
currentPage.value = page
}
function openRakutenUpload() {
uploadInputRef.value?.click()
}
function parseSkuPrices(product: any) {
if (!product.skuPrice) return []
try {
let skuStr = product.skuPrice
if (typeof skuStr === 'string') {
skuStr = skuStr.replace(/(\d+(?:\.\d+)?):"/g, '"$1":"')
skuStr = JSON.parse(skuStr)
}
return Object.keys(skuStr).map(p => parseFloat(p)).filter(n => !isNaN(n)).sort((a, b) => a - b)
} catch {
return []
}
}
async function loadLatest() {
const resp = await rakutenApi.getLatestProducts()
allProducts.value = (resp.products || []).map(p => ({...p, skuPrices: parseSkuPrices(p)}))
}
async function searchProductInternal(product: any) {
if (!product || !product.imgUrl) return
if (product.mapRecognitionLink && String(product.mapRecognitionLink).trim() !== '') return
const res = await rakutenApi.search1688(product.imgUrl, currentBatchId.value)
const data = res
Object.assign(product, {
mapRecognitionLink: data.mapRecognitionLink,
freight: data.freight,
median: data.median,
weight: data.weight,
skuPrice: data.skuPrice,
skuPrices: parseSkuPrices(data),
image1688Url: data.mapRecognitionLink,
detailUrl1688: data.mapRecognitionLink,
})
}
function beforeUpload(file: File) {
const ok = /\.xlsx?$/.test(file.name)
if (!ok) alert('仅支持 .xlsx/.xls 文件')
return ok
}
async function processFile(file: File) {
if (!beforeUpload(file)) return
progressStarted.value = true
progressPercentage.value = 0
totalProducts.value = 0
processedProducts.value = 0
loading.value = true
tableLoading.value = true
currentBatchId.value = `RAKUTEN_${Date.now()}`
try {
const resp = await rakutenApi.getProducts({file, batchId: currentBatchId.value})
const products = (resp.products || []).map(p => ({...p, skuPrices: parseSkuPrices(p)}))
allProducts.value = products
statusMessage.value = `已获取 ${allProducts.value.length} 个乐天商品`
const needSearch = allProducts.value.filter(p => p && p.imgUrl && !p.mapRecognitionLink)
if (needSearch.length > 0) {
statusType.value = 'info'
statusMessage.value = `已获取 ${allProducts.value.length} 个乐天商品正在自动获取1688数据...`
await startBatch1688Search(needSearch)
} else {
statusType.value = 'success'
statusMessage.value = `已获取 ${allProducts.value.length} 个乐天商品,所有数据已完整!`
}
} catch (e: any) {
statusMessage.value = e?.message || '上传失败'
statusType.value = 'error'
} finally {
loading.value = false
tableLoading.value = false
}
}
async function handleExcelUpload(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files && input.files[0]
if (!file) return
await processFile(file)
input.value = ''
}
function onDragOver(e: DragEvent) { e.preventDefault(); dragActive.value = true }
function onDragLeave() { dragActive.value = false }
async function onDrop(e: DragEvent) {
e.preventDefault()
dragActive.value = false
const file = e.dataTransfer?.files?.[0]
if (!file) return
await processFile(file)
}
async function searchSingleShop() {
const shop = singleShopName.value.trim()
if (!shop) return
// 重置进度与状态
progressStarted.value = true
progressPercentage.value = 0
totalProducts.value = 0
processedProducts.value = 0
loading.value = true
tableLoading.value = true
currentBatchId.value = `RAKUTEN_${Date.now()}`
try {
const resp = await rakutenApi.getProducts({shopName: shop, batchId: currentBatchId.value})
allProducts.value = (resp.products || []).filter((p: any) => p.originalShopName === shop).map(p => ({ ...p, skuPrices: parseSkuPrices(p) }))
statusMessage.value = `店铺 ${shop}${allProducts.value.length}`
singleShopName.value = ''
const needSearch = allProducts.value.filter(p => p && p.imgUrl && !p.mapRecognitionLink)
if (needSearch.length > 0) {
await startBatch1688Search(needSearch)
} else if (allProducts.value.length > 0) {
statusType.value = 'success'
statusMessage.value = `店铺 ${shop} 的数据已加载完成所有1688链接都已存在`
progressPercentage.value = 100
}
} catch (e: any) {
statusMessage.value = e?.message || '查询失败'
statusType.value = 'error'
} finally {
loading.value = false
tableLoading.value = false
}
}
function stopTask() {
loading.value = false
tableLoading.value = false
statusType.value = 'warning'
statusMessage.value = '任务已停止'
// 保留进度条和当前进度
allProducts.value = allProducts.value.map(p => ({...p, searching1688: false}))
}
async function startBatch1688Search(products: any[]) {
const items = (products || []).filter(p => p && p.imgUrl && !p.mapRecognitionLink)
if (items.length === 0) {
progressPercentage.value = 100
statusType.value = 'success'
statusMessage.value = '所有商品都已获取1688数据'
return
}
loading.value = true
totalProducts.value = items.length
processedProducts.value = 0
progressStarted.value = true
progressPercentage.value = 0
statusType.value = 'info'
statusMessage.value = `正在获取1688数据${totalProducts.value} 个商品...`
await serialSearch1688(items)
if (processedProducts.value >= totalProducts.value) {
progressPercentage.value = 100
statusType.value = 'success'
const successCount = allProducts.value.filter(p => p && p.mapRecognitionLink && String(p.mapRecognitionLink).trim() !== '').length
statusMessage.value = `成功获取 ${successCount}`
}
loading.value = false
}
async function serialSearch1688(products: any[]) {
for (let i = 0; i < products.length && loading.value; i++) {
const product = products[i]
product.searching1688 = true
await nextTickSafe()
await searchProductInternal(product)
product.searching1688 = false
processedProducts.value++
progressPercentage.value = Math.floor((processedProducts.value / Math.max(1, totalProducts.value)) * 100)
if (i < products.length - 1 && loading.value) {
await delay(500 + Math.random() * 1000)
}
}
}
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function nextTickSafe() {
// 不额外引入 nextTick使用微任务刷新即可保持体积精简
return Promise.resolve()
}
async function exportToExcel() {
try {
if (allProducts.value.length === 0) return alert('没有数据可导出')
exportLoading.value = true
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5)
const fileName = `乐天商品数据_${timestamp}.xlsx`
const payload = {
products: allProducts.value,
title: '乐天商品数据导出',
fileName,
timestamp: new Date().toLocaleString('zh-CN'),
// 传给后端的可选提示参数
useMultiThread: true,
chunkSize: 300,
skipImages: allProducts.value.length > 200,
}
const resp = await rakutenApi.exportAndSave(payload)
alert(`Excel文件已保存到: ${resp.filePath}`)
} catch (e: any) {
alert(e?.message || '导出失败')
} finally {
exportLoading.value = false
}
}
onMounted(loadLatest)
</script>
<template>
<div class="rakuten-root">
<div class="main-container">
<!-- 文件导入和操作区域 -->
<div class="import-section" @dragover.prevent="onDragOver" @dragleave="onDragLeave" @drop="onDrop" :class="{ 'drag-active': dragActive }">
<div class="import-controls">
<!-- 文件上传按钮 -->
<el-button type="primary" :disabled="loading" @click="openRakutenUpload">
📂 {{ loading ? '处理中...' : '导入店铺名列表' }}
</el-button>
<input ref="uploadInputRef" style="display:none" type="file" accept=".xlsx,.xls" @change="handleExcelUpload"
:disabled="loading"/>
<!-- 单个店铺名输入 -->
<div class="single-input">
<el-input v-model="singleShopName" placeholder="输入单个店铺名" :disabled="loading"
@keyup.enter="searchSingleShop" style="width: 140px"/>
<el-button type="info" :disabled="!singleShopName || loading" @click="searchSingleShop">查询</el-button>
</div>
<!-- 操作按钮组 -->
<div class="action-buttons">
<el-button type="danger" :disabled="!loading" @click="stopTask">停止获取</el-button>
<el-button type="success" :disabled="!allProducts.length || loading" @click="exportToExcel">导出Excel
</el-button>
</div>
</div>
<!-- 进度条显示 -->
<div class="progress-section" v-if="progressStarted">
<div class="progress-box">
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
</div>
<div class="progress-text">{{ progressPercentage }}%</div>
</div>
<div class="current-status" v-if="statusMessage">{{ statusMessage }}</div>
</div>
</div>
</div>
<!-- 数据显示区域 -->
<div class="table-container">
<!-- 数据表格无数据时也显示表头 -->
<div class="table-section">
<div class="table-wrapper">
<table class="table">
<thead>
<tr>
<th>店铺名</th>
<th>商品链接</th>
<th>商品图片</th>
<th>排名</th>
<th>商品标题</th>
<th>价格</th>
<th>1688识图链接</th>
<th>1688运费</th>
<th>1688中位价</th>
<th>1688最低价</th>
<th>1688中间价</th>
<th>1688最高价</th>
</tr>
</thead>
<tbody>
<tr v-if="paginatedData.length === 0">
<td colspan="12" class="empty-tip">暂无数据请导入店铺名列表</td>
</tr>
<tr v-else v-for="row in paginatedData" :key="row.productUrl + (row.productTitle || '')">
<td class="truncate shop-col" :title="row.originalShopName">{{ row.originalShopName }}</td>
<td class="truncate url-col">
<el-input v-if="row.productUrl" :value="row.productUrl" readonly @click="$event.target.select()" size="small"/>
<span v-else>--</span>
</td>
<td>
<div class="image-container" v-if="row.imgUrl">
<img :src="row.imgUrl" class="thumb" alt="thumb"/>
</div>
<span v-else>无图片</span>
</td>
<td>
<span v-if="row.ranking">{{ row.ranking }}</span>
<span v-else>--</span>
</td>
<td class="truncate" :title="row.productTitle">{{ row.productTitle || '--' }}</td>
<td>{{ row.price ? row.price + '円' : '--' }}</td>
<td class="truncate url-col">
<el-input v-if="row.mapRecognitionLink" :value="row.mapRecognitionLink" readonly @click="$event.target.select()" size="small"/>
<span v-else-if="row.searching1688">搜索中...</span>
<span v-else>--</span>
</td>
<td>{{ row.freight ?? '--' }}</td>
<td>{{ row.median ?? '--' }}</td>
<td>{{ row.skuPrices?.[0] ?? '--' }}</td>
<td>{{ row.skuPrices?.[Math.floor(row.skuPrices.length / 2)] ?? '--' }}</td>
<td>{{ row.skuPrices?.[row.skuPrices.length - 1] ?? '--' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 表格加载遮罩 -->
<div v-if="tableLoading" class="table-loading">
<div class="spinner"></div>
<div>加载中...</div>
</div>
</div>
<!-- 分页器 -->
<div class="pagination-fixed" >
<el-pagination
background
:current-page="currentPage"
:page-sizes="[15,30,50,100]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="allProducts.length"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.rakuten-root {
position: absolute;
inset: 0;
background: #f5f5f5;
padding: 12px;
box-sizing: border-box;
}
.main-container {
background: #fff;
border-radius: 4px;
padding: 15px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
height: 100%;
display: flex;
flex-direction: column;
}
.import-section {
margin-bottom: 10px;
flex-shrink: 0;
}
.import-controls {
display: flex;
align-items: flex-end;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.single-input {
display: flex;
align-items: center;
gap: 8px;
}
.action-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.progress-section {
margin: 15px 0 10px 0;
}
.progress-box {
padding: 8px 0;
}
.progress-container {
display: flex;
align-items: center;
position: relative;
padding-right: 50px;
margin-bottom: 8px;
}
.progress-bar {
flex: 1;
height: 6px;
background: #ebeef5;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #409EFF, #66b1ff);
border-radius: 3px;
transition: width 0.3s ease;
}
.progress-text {
position: absolute;
right: 0;
font-size: 13px;
color: #409EFF;
font-weight: 500;
}
.current-status {
font-size: 12px;
color: #606266;
padding-left: 2px;
}
.table-container {
display: flex;
flex-direction: column;
flex: 1;
min-height: 400px;
overflow: hidden;
}
.empty-section {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 6px;
}
.empty-container {
text-align: center;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.6;
}
.empty-text {
font-size: 14px;
color: #909399;
}
.table-section {
flex: 1;
overflow: hidden;
position: relative;
background: #fff;
border: 1px solid #ebeef5;
border-radius: 4px;
}
.table-wrapper {
height: 100%;
overflow: auto;
}
.table {
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; }
.import-section.drag-active { border: 1px dashed #409EFF; border-radius: 6px; }
.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;
}
.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;
}
</style>
<script lang="ts">
export default {
name: 'RakutenDashboard',
}
</script>

View File

@@ -0,0 +1,322 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { zebraApi, type ZebraOrder } from '../../api/zebra'
type Shop = { id: string; shopName: string }
const shopList = ref<Shop[]>([])
const selectedShops = ref<string[]>([])
const dateRange = ref<string[]>([])
const loading = ref(false)
const exportLoading = ref(false)
const progressPercentage = ref(0)
const showProgress = ref(false)
const allOrderData = ref<ZebraOrder[]>([])
const currentPage = ref(1)
const pageSize = ref(15)
// 批量获取状态
const fetchCurrentPage = ref(1)
const fetchTotalPages = ref(0)
const fetchTotalItems = ref(0)
const isFetching = ref(false)
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return allOrderData.value.slice(start, end)
})
function formatJpy(v?: number) {
const n = Number(v || 0)
return `¥${n.toLocaleString('ja-JP')}`
}
function formatCny(v?: number) {
const n = Number(v || 0)
return `¥${n.toLocaleString('zh-CN')}`
}
async function loadShops() {
try {
const resp = await zebraApi.getShops()
const list = (resp as any)?.data?.data?.list ?? (resp as any)?.list ?? []
shopList.value = list
} catch (e) {
console.error('获取店铺列表失败:', e)
}
}
function handleSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
}
function handleCurrentChange(page: number) {
currentPage.value = page
}
async function fetchData() {
if (isFetching.value) return
loading.value = true
isFetching.value = true
showProgress.value = true
progressPercentage.value = 0
allOrderData.value = []
fetchCurrentPage.value = 1
fetchTotalItems.value = 0
const [startDate = '', endDate = ''] = dateRange.value || []
await fetchPageData(startDate, endDate)
}
async function fetchPageData(startDate: string, endDate: string) {
if (!isFetching.value) return
try {
const data = await zebraApi.getOrders({
startDate,
endDate,
page: fetchCurrentPage.value,
pageSize: 50,
shopIds: selectedShops.value.join(',')
})
const orders = data.orders || []
allOrderData.value = [...allOrderData.value, ...orders]
fetchTotalPages.value = data.totalPages || 0
fetchTotalItems.value = data.total || 0
if (fetchCurrentPage.value < fetchTotalPages.value && isFetching.value) {
progressPercentage.value = Math.round((fetchCurrentPage.value / fetchTotalPages.value) * 100)
fetchCurrentPage.value++
setTimeout(() => fetchPageData(startDate, endDate), 200)
} else {
progressPercentage.value = 100
finishFetching()
}
} catch (e) {
console.error('获取订单数据失败:', e)
finishFetching()
}
}
function finishFetching() {
isFetching.value = false
loading.value = false
// 确保进度条完全填满
progressPercentage.value = 100
currentPage.value = 1
// 进度条保留显示,不自动隐藏
}
function stopFetch() {
isFetching.value = false
loading.value = false
// 进度条保留显示,不自动隐藏
}
async function exportToExcel() {
if (!allOrderData.value.length) return
exportLoading.value = true
try {
const result = await zebraApi.exportAndSaveOrders({ orders: allOrderData.value })
alert(`Excel文件已保存到: ${result.filePath}`)
} catch (e) {
alert('导出Excel失败')
} finally {
exportLoading.value = false
}
}
onMounted(async () => {
await loadShops()
try {
const latest = await zebraApi.getLatestOrders()
allOrderData.value = latest?.orders || []
} catch {}
})
</script>
<template>
<div class="zebra-root">
<div class="main-container">
<!-- 筛选和操作区域 -->
<div class="import-section">
<div class="import-controls">
<!-- 店铺选择 -->
<el-select v-model="selectedShops" multiple placeholder="选择店铺" style="width: 260px;" :disabled="loading">
<el-option v-for="shop in shopList" :key="shop.id" :label="shop.shopName" :value="shop.id"></el-option>
</el-select>
<!-- 日期选择 -->
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
style="width: 200px;"
:disabled="loading"
/>
<!-- 操作按钮组 -->
<div class="action-buttons">
<el-button type="primary" :disabled="loading" @click="fetchData">
📂 {{ loading ? '处理中...' : '获取订单数据' }}
</el-button>
<el-button type="danger" :disabled="!loading" @click="stopFetch">停止获取</el-button>
<el-button type="success" :disabled="exportLoading || !allOrderData.length" @click="exportToExcel">导出Excel</el-button>
</div>
</div>
<!-- 进度条显示 -->
<div class="progress-section" v-if="showProgress">
<div class="progress-box">
<div class="progress-container">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: progressPercentage + '%' }"></div>
</div>
<div class="progress-text">{{ progressPercentage }}%</div>
</div>
<div class="current-status" v-if="fetchTotalItems > 0">
{{ progressPercentage >= 100 ? '完成' : `获取中... (${allOrderData.length}/${fetchTotalItems})` }}
</div>
</div>
</div>
</div>
<!-- 数据显示区域 -->
<div class="table-container">
<!-- 数据表格无数据时也显示表头 -->
<div class="table-section">
<div class="table-wrapper">
<table class="table">
<thead>
<tr>
<th>下单时间</th>
<th>商品图片</th>
<th>商品名称</th>
<th>乐天订单号</th>
<th>下单距今</th>
<th>订单金额/日元</th>
<th>数量</th>
<th>税费/日元</th>
<th>回款抽点rmb</th>
<th>商品番号</th>
<th>1688订单号</th>
<th>采购金额/rmb</th>
<th>国际运费/rmb</th>
<th>国内物流</th>
<th>国内单号</th>
<th>日本单号</th>
<th>地址状态</th>
</tr>
</thead>
<tbody>
<tr v-if="paginatedData.length === 0">
<td colspan="16" class="empty-tip">暂无数据请选择日期范围获取订单</td>
</tr>
<tr v-else v-for="row in paginatedData" :key="row.shopOrderNumber + (row.productNumber || '')">
<td>{{ row.orderedAt || '-' }}</td>
<td>
<div class="image-container" v-if="row.productImage">
<img :src="row.productImage" class="thumb" alt="thumb" />
</div>
<span v-else>无图片</span>
</td>
<td class="truncate" :title="row.productTitle">{{ row.productTitle }}</td>
<td class="truncate" :title="row.shopOrderNumber">{{ row.shopOrderNumber }}</td>
<td>{{ row.timeSinceOrder || '-' }}</td>
<td><span class="price-tag">{{ formatJpy(row.priceJpy) }}</span></td>
<td>{{ row.productQuantity || 0 }}</td>
<td><span class="fee-tag">{{ formatJpy(row.shippingFeeJpy) }}</span></td>
<td>{{ row.serviceFee || '-' }}</td>
<td class="truncate" :title="row.productNumber">{{ row.productNumber }}</td>
<td class="truncate" :title="row.poNumber">{{ row.poNumber }}</td>
<td><span class="fee-tag">{{ formatCny(row.shippingFeeCny) }}</span></td>
<td>{{ row.internationalShippingFee || '-' }}</td>
<td>{{ row.poLogisticsCompany || '-' }}</td>
<td class="truncate" :title="row.poTrackingNumber">{{ row.poTrackingNumber }}</td>
<td class="truncate" :title="row.internationalTrackingNumber">{{ row.internationalTrackingNumber }}</td>
<td>
<span v-if="row.trackInfo" class="tag">{{ row.trackInfo }}</span>
<span v-else>暂无</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 表格加载遮罩 -->
<div v-if="loading && !allOrderData.length" class="table-loading">
<div class="spinner"></div>
<div>加载中...</div>
</div>
</div>
<!-- 分页器 -->
<div class="pagination-fixed">
<el-pagination
background
:current-page="currentPage"
:page-sizes="[15,30,50,100]"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="allOrderData.length"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts">
export default {
name: 'ZebraDashboard',
}
</script>
<style scoped>
.zebra-root { position: absolute; inset: 0; background: #f5f5f5; padding: 12px; box-sizing: border-box; }
.main-container { background: #fff; border-radius: 4px; padding: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); height: 100%; display: flex; flex-direction: column; }
.import-section { margin-bottom: 10px; flex-shrink: 0; }
.import-controls { display: flex; align-items: flex-end; gap: 20px; flex-wrap: wrap; margin-bottom: 8px; }
.action-buttons { display: flex; gap: 10px; flex-wrap: wrap; }
.progress-section { margin: 15px 0 10px 0; }
.progress-box { padding: 8px 0; }
.progress-container { display: flex; align-items: center; position: relative; padding-right: 50px; margin-bottom: 8px; }
.progress-bar { flex: 1; height: 6px; background: #ebeef5; border-radius: 3px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #409EFF, #66b1ff); border-radius: 3px; transition: width 0.3s ease; }
.progress-text { position: absolute; right: 0; font-size: 13px; color: #409EFF; font-weight: 500; }
.current-status { font-size: 12px; color: #606266; padding-left: 2px; }
.table-container { display: flex; flex-direction: column; flex: 1; min-height: 400px; overflow: hidden; }
.empty-section { flex: 1; display: flex; justify-content: center; align-items: center; background: #fff; border: 1px solid #ebeef5; border-radius: 6px; }
.empty-container { text-align: center; }
.empty-icon { font-size: 48px; margin-bottom: 16px; opacity: 0.6; }
.empty-text { font-size: 14px; color: #909399; }
.table-section { flex: 1; overflow: hidden; position: relative; background: #fff; border: 1px solid #ebeef5; border-radius: 4px; }
.table-wrapper { height: 100%; overflow: auto; }
.table { 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; }
.table td { padding: 10px 8px; border-bottom: 1px solid #f0f0f0; vertical-align: middle; }
.table tbody tr:hover { background: #f9f9f9; }
.truncate { max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.image-container { display: flex; justify-content: center; align-items: center; width: 24px; height: 20px; margin: 0 auto; background: #f8f9fa; border-radius: 2px; }
.thumb { width: 16px; height: 16px; object-fit: contain; border-radius: 2px; }
.price-tag { color: #e6a23c; font-weight: bold; }
.fee-tag { color: #909399; font-weight: 500; }
.table-loading { position: absolute; inset: 0; background: rgba(255, 255, 255, 0.95); display: flex; flex-direction: column; justify-content: center; align-items: center; font-size: 14px; color: #606266; }
.spinner { font-size: 24px; animation: spin 1s linear infinite; margin-bottom: 8px; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.pagination-fixed { flex-shrink: 0; padding: 8px 12px; background: #f9f9f9; border-radius: 4px; display: flex; justify-content: center; border-top: 1px solid #ebeef5; margin-top: 8px; }
.tag { display: inline-block; padding: 2px 6px; font-size: 12px; background: #ecf5ff; color: #409EFF; border-radius: 3px; }
</style>

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Vite + Vue template</title>
<link rel="icon" href="/icon/icon.png">
<meta name="theme-color" content="#ffffff">
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,9 @@
import { createApp } from 'vue'
import './style.css';
import 'element-plus/dist/index.css'
import ElementPlus from 'element-plus'
import App from './App.vue'
const app = createApp(App)
app.use(ElementPlus)
app.mount('#app')

View File

@@ -0,0 +1,89 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "esnext",
"useDefineForClassFields": true,
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"lib": ["esnext", "dom"],
"types": ["vite/client"]
},
"include": ["./**/*.ts", "./**/*.d.ts", "./**/*.tsx", "./**/*.vue"],
}

View File

@@ -0,0 +1,12 @@
/**
* Should match main/preload.ts for typescript support in renderer
*/
export default interface ElectronApi {
sendMessage: (message: string) => void
}
declare global {
interface Window {
electronAPI: ElectronApi,
}
}

View File

@@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@@ -0,0 +1,23 @@
const Path = require('path');
const vuePlugin = require('@vitejs/plugin-vue')
const { defineConfig } = require('vite');
/**
* https://vitejs.dev/config
*/
const config = defineConfig({
root: Path.join(__dirname, 'src', 'renderer'),
publicDir: Path.join(__dirname, 'public'),
server: {
port: 8083,
},
open: false,
build: {
outDir: Path.join(__dirname, 'build', 'renderer'),
emptyOutDir: true,
},
plugins: [vuePlugin()],
});
module.exports = config;